Skip to content

Documentation Style Guide

Canonical reference for all Cloud-Native DCS documentation. Applies to human and AI authors.

What the Docs Gates Read

Every gate described here reads the files git can see: tracked files, plus untracked files no ignore rule matches. A new doc is therefore linted before it is staged, and a gitignored path under docs/ is exempt.

That exemption is the point. docs/ holds several gitignored trees: the derivative notes on the purchased standards (docs/isa-88/, docs/mtp/, and their siblings), and any *.local.md override. Those are scratch, local to one machine, and CI cannot reproduce a finding in one. When the gates walked the working tree instead, a scratch note without front matter failed make lint-docs-style on every commit in the repo, in every session, and no git stash could clear it (#1391).

Enumeration lives in one helper per language: scripts/lib/docs-files.sh, scripts/docs-shots/lib/git-visible.mjs, hack/gitfiles.py. A new docs gate uses one of them. find, grep -r, readdirSync, and rglob all walk the working tree. That is the #1391 bug restated.

Front Matter

Every doc starts with YAML front matter:

---
title: Human-Readable Title
category: tutorial | how-to | reference | explanation
related:
  - docs/other-doc.md
  - docs/another.md
---

Categories follow the Divio documentation framework:

Category Purpose Example
tutorial Learning-oriented walkthrough E2E LED Test
how-to Task-oriented guide Alarm Management
reference Information-oriented lookup REST API Reference
explanation Understanding-oriented discussion Architecture

There is no other valid category: value (runbooks and incident reports are how-to, and compliance traceability docs are reference). make lint-docs-style enforces the enum, the presence of title:, and that the H1 matches title.

Hardcoded Counts

Never hardcode counts that change as the project grows. "18 CRDs" and "5 binaries" go stale on the next scaffold, so write "all CRDs" and "all binaries". If an exact count is genuinely useful, generate it from source (e.g., ls config/crd/bases/ | wc -l).

Terminology

Follow docs/terminology.md for user-facing terms. Key rules:

  • "control system" not "cluster" in user-facing docs
  • "edge device" not "Raspberry Pi" when speaking generically
  • "unit runtime" not "sidecar"
  • "process cell" not "cell"

Prose

Everything else in this guide is structural and mostly lint-backed. The writing itself is governed by the org-wide prose core (cndcs-claude-harness/portable/docs/writing-style.md, injected at SessionStart by the harness-writing-style plugin): complete sentences, the em-dash zero, colon and semicolon discipline, contrast and negation rules, and the reference-scaffolding carve-out.

make lint-docs-prose enforces the mechanical share (#1518, #1519), and it is strict: every non-exempt page must be clean outright. The #1518 sweep (the founder-directed exception to the core's no-bulk-rewrites default) brought the whole site into conformance, and #1528 deleted the migration baseline that carried the transition.

Two registered deltas narrow the core for this surface, both founder rulings recorded in #1518 and in the core's exception table:

  • Dated records are exempt. An accepted ADR, a dated compliance drill evidence record, or a banner-marked historical design doc that predates the gate is a signed-in-time record and stays as written (the ADR ruling in #1518, widened to the other two classes by the #1528 ruling). The frozen list is scripts/.docs-prose-adr-exempt, nothing is ever added to it, and a new ADR or drill record is linted from its first commit. Amending an old record for another reason brings its prose with it.
  • The em dash survives as a scaffolding separator. A heading, a table cell, or a list item may carry the **Term** — description glyph. Flowing prose keeps the flat ban.

One mechanical exemption follows from the core's quoted-and-generated rule: a whole-line HTML comment is exempt from every prose rule, since a comment never reaches a reader (the canonical doc-video embed comment carries a semicolon by design). An inline comment beside prose exempts nothing.

The audience is engineers evaluating or operating the product, and prose quality is a credibility surface for them. Documentation that reads like unrevised machine output undercuts trust in the product it describes. The buyer-facing treatment of these ideas lives in cndcs-marketing/STYLE.md.

Docs-specific voice and scaffolding

  • "We" is acceptable only in explanation docs when stating a design decision and its rationale. Everywhere else, name the product or the component as the actor.
  • A single scope-setting sentence at the top of a doc is fine. Running narration ("in this section", "as mentioned above") is not.
  • Reference scaffolding here additionally includes code-adjacent instruction lines ("Apply with dcs apply -f <file>.yaml"), one-line admonitions, UI-gap placeholder notes, front matter, tab labels, and image alt text. Fragments are the correct register in all of them.

Captions

A caption identifies the figure. It does not restate the argument the body already makes.

  • One phrase or short sentence. Two short sentences only when the figure genuinely has two beats.
  • Name the real view and the objects on screen ("The batch detail view with the granulation phase held"). The drawing notation and the previous caption's text are both the wrong source. Write from the rendered image and the section it supports.
  • Punctuation is tighter than body prose: no em dashes, no semicolons, and a colon only for a named-count enumeration ("three views: ...").
  • Alt text is not a caption copy. The alt describes what the image shows for assistive tech (the view, the key objects, the state on screen), and the caption orients the reader in the argument. Write both. Never paste one into the other.

Code Examples

Use pharma-realistic values in examples (e.g., granulator-1, site-newark-plant, reactor-temp-high). Avoid generic names like foo, bar, test-1.

For docs covering functionality inside the application (the Batch Operations and Explanation sections), follow the Tabbed Alternatives rules below. UI is the primary interface for this audience.

For docs covering platform / SRE / integrator concerns (Platform Administration, Integration, Reference, Compliance), YAML / CLI / API are the correct primary form. No UI tab is required. Keep examples in the most appropriate single transport for the task.

Tabbed Alternatives

In the Batch Operations and Explanation sections of the site, every user-facing action step should offer the reader a choice of interface. Most users of these docs are UI-first operators and engineers who will not be comfortable with YAML until they are much deeper into the system. The tabs must meet them where they are.

Scope

In scope (the Batch Operations and Explanation nav sections of mkdocs.yml):

  • docs/batch-operations/index.md, docs/equipment-operation.md, docs/batch-execution.md, docs/alarm-management.md, docs/io.md, docs/control-modules.md, docs/phases.md, docs/recipes.md, docs/data-interface.md, docs/historian.md
  • docs/library/mental-models.md, docs/library/adaptation-guide.md, docs/library/parameter-binding.md, docs/library/alarms-and-interlocks.md, docs/library/debugging-running-batches.md

Out of scope: Platform Administration, Integration, Reference, Compliance, Getting Started, Project, and the Libraries section of shipped templates. Those audiences are platform-savvy and a CLI-first or YAML-first presentation is correct.

Which tabs to show

Pick tabs by the shape of the action. The CLI / API / YAML tabs collapse into "three transports for the same manifest" for most CRUD steps, and showing all three is redundant padding that rots.

Action type Tabs Example
Create / edit a resource UI | YAML Apply a PhaseTemplate, ControlModule, MasterRecipe
Imperative command UI | CLI Approve a recipe, abort a batch, issue an ISA-88 command
Observe / read UI | CLI Watch live batch state, list units
Workflow (CLI does more than apply) UI | CLI | YAML dcs simulate (which creates a Batch, waits, and cleans up) vs. a raw Batch manifest
API has semantics CLI / YAML can't express UI | CLI | API Streaming / SSE / WebSocket endpoints

The API tab is rare. Reserve it for endpoints the CLI cannot express. For ordinary CRUD, a single once-per-page footnote like "Integrators: see API Reference for the REST equivalents" is enough, and per-step API tabs are almost always duplication.

Authoring the tabs

Tabs use the pymdownx.tabbed syntax already enabled in mkdocs.yml:

=== "UI"

    `/system` &rarr; Equipment Library &rarr; Phases &rarr; pick site
    `riverbend` &rarr; **+ New Phase Template**. Paste the manifest
    and **Save**.

=== "YAML"

    <!-- lint-examples:allow: reference snippet showing the structure with placeholders, not an applyable manifest -->
    ```yaml
    apiVersion: procedural.dcs.io/v1alpha1
    kind: PhaseTemplate
    ...
    ```

    Apply with `dcs apply -f <file>.yaml`.

Tabs in the same group must sit at column 0 with their content indented by four spaces. A non-indented, non-blank line ends the group.

Placeholder for UI paths that aren't implemented yet

When the tab-set rules call for a UI tab but the UI path genuinely does not exist yet, still show the UI tab with a placeholder admonition. A missing tab silently tells UI-first readers that they are not the target audience. A placeholder tells them honestly that the gap is known and tracked.

=== "UI"

    !!! info "Not yet in the UI"
        No UI path for this step yet — tracked in
        [#NNN](https://github.com/cloud-native-dcs/cloud-native-dcs/issues/NNN).
        Use the CLI / YAML tab below.

=== "CLI"

    ```bash
    dcs --site riverbend masterrecipe approve my-recipe
    ```

Conventions:

  • Use !!! info: the placeholder marks a roadmap gap, and !!! warning would dress it as an error state.
  • Always link a tracking issue. Un-linked placeholders become permanent technical debt that nobody reviews.
  • Point the reader at the working tab(s).

For steps where the UI path exists but hasn't been documented yet, use the same admonition form with wording like "UI path exists but not yet documented — see [#NNN]". This distinguishes a missing feature from a missing doc so future contributors fix the right thing.

Lint enforcement

Three checks run in make verify and the pre-commit hook for staged docs/*.md files:

  • make lint-docs-ui-tabs — every tab group in an in-scope file containing === "CLI", === "API", or === "YAML" must also contain === "UI".
  • make lint-docs-untabbed-actions — covers every doc except the Platform Administration section (platform / Helm plumbing is exempt). Rules:
    • ```yaml blocks must live inside a tab group (e.g. === "UI" / === "YAML") everywhere they appear. An application CR the reader might copy should show both the UI and YAML path.
    • ```bash / ```shell / ```sh / ```console blocks are flagged only inside H2 sections that already carry at least one tab group, so pure reference prose without any tabs is unaffected.
  • make lint-docs-ui-tab-shots — every === "UI" tab must carry a screenshot reference (or a placeholder admonition).

For pre-existing tab groups that predate the UI-tabs check, place an HTML comment marker within four lines above the group's first === line to opt out:

<!-- lint-docs-ui-tabs:allow (UI gap acknowledged in prose above; tracked in #NNN) -->

=== "CLI"
    ...

The lint-docs-untabbed-actions:allow marker has a deliberately high bar: use it only when the YAML has no UI counterpart and we don't plan to add one. Anti-patterns, CRD-shape snippets, and spec fragments that describe a field the UI exposes are all actions. Put them in UI | YAML tabs.

Qualifying cases for the marker:

  • Platform / Helm values (mqtt:, changeControl:, cluster-scoped values.yaml snippets): a platform operator authors these during install, outside the DCS UI. Run-section docs are already out of lint scope. Helm values that appear elsewhere still need the marker.
  • API / schema reference (docs/api-reference.md request and response bodies, MkDocs front-matter schema): by definition the reference is the raw shape.
  • Third-party language syntax (docs/structured-text.md ST embedded inside a YAML transition) where the YAML is the vehicle for a language example.

Side-by-side comparisons are NOT a qualifying case. When two variants of a CR need contrasting (sim vs real, before vs after, small vs large), use two consecutive tab groups that share a single screenshot in their UI tabs and put the YAML differences in their YAML tabs. A summary differences table between the two blocks gives the reader the at-a-glance comparison without forcing them into raw YAML. See docs/io.md "Authoring an IOModule" + "Sim vs real — what changes" for the canonical pattern.

Placement: within the four lines immediately above the fenced block:

<!-- lint-docs-untabbed-actions:allow: Helm values, not an application CR -->
```yaml
# ...
Each marker must state the reason. A marker is not a blanket waiver.
If you add one, be prepared to defend it under this checklist.

### Content-view tabs (FBD / ST)

The UI / CLI / API / YAML tab axis covers *which interface* the reader
uses. A second, orthogonal tab axis covers *which content view* a
control-module template's logic is rendered in: **FBD** (Function
Block Diagram) or **ST** (Structured Text). These are equivalent
authoring forms, and both produce the same `ControlModuleTemplate` /
`ControlProgram` resource. Wherever a template's logic appears in
prose or as a screenshot, the reader should be able to flip between
the two views the same way they do in the gateway UI.

Use a separate top-level `=== "FBD"` / `=== "ST"` group at the same
level as the interface tabs. Path placeholders below use `<stem>` to
mean the template's filename root (e.g. `discrete-valve`, `pid-loop`).
The actual references in the library use the real stems with a `.png`
extension.

```markdown
=== "FBD"

    ![…FBD diagram…](images/control-modules/<stem>-fbd){ .fbd }

=== "ST"

    ![…ST source…](images/control-modules/<stem>-st){ .st }

Conventions:

  • Order: FBD first, ST second. Matches the gateway's default view on the template detail page and the section header toggle.
  • Don't nest FBD/ST inside a UI/YAML group. Two flat groups read more cleanly and the screenshots stay full-width.
  • YAML examples that materially differ between the two forms (blocks + connections vs. structuredText) belong in their own FBD/ST group, separate from the UI/YAML tab group used for the CRD-shape walkthrough above. Don't try to show all four combinations. Pick one form for the walkthrough and link to the other.
  • The FBD/ST labels are not in the UI-tabs lint's enforcement set, so using them does not require a === "UI" companion tab.

Skip the FBD/ST group when only one form is meaningful, for example the FB Network Editor section (the visual editor is FBD-only) or the ST language reference (no FBD counterpart for the syntax tables).

Headings

  • H1: Title only (one per doc, matching front matter title exactly, enforced by make lint-docs-style)
  • H2: Major sections
  • H3: Subsections
  • H4: Rare, and only for deeply nested content
  • Never skip heading levels (e.g., H2 directly to H4)

Cross-References

  • Use relative links between docs: [Architecture](architecture.md). Absolute URLs are for external sites only
  • A "Related Documentation" footer is optional, but when present it must use that exact heading (see Related Documentation Footer)
  • Link to compliance traceability docs when documenting standard-related features: [ISA-88 traceability](compliance/isa88.md)

Anchors

A #fragment is slugged the way the published site slugs it. The site is the artefact a reader follows the link on, so an anchor that works only in a GitHub blob view is broken where the documentation is actually read. make lint-docs-links resolves every fragment against that slug and names the closest heading when one misses.

The two slugifiers agree on almost everything. They part company on a heading whose punctuation sits between two spaces, because the site collapses the run into one separator and GitHub keeps one per removed character. Copying an anchor out of a GitHub heading link is how four broken ones reached the tree. Derive it from the heading text instead.

#### `activeRetentionDays` — etcd / kine sizing

[sizing](capacity-planning.md#activeretentiondays-etcd-kine-sizing)

Two other forms resolve. A heading may carry an explicit id with attr_list (## A heading {#chosen-anchor}), which is worth using when the wording is likely to change under an anchor other pages already point at. Two headings that slug alike get _1, _2 and so on in document order, and a link to the second one names it that way.

Disabled Features

Some features are intentionally disabled in the product (removed from UI, CLI, and API) even though the underlying CRDs or code may remain. Docs must not mention them, because a mention misleads users into trying features that don't work.

The docs/.disabled-terms.tsv file is the authoritative list, and each row is <pattern>\t<reason>. scripts/lint-disabled-features.sh greps the docs git can see (see § "What the Docs Gates Read") against it on every pre-commit and in CI. A snapshot at scripts/.disabled-terms-snapshot grandfathers pre-existing mentions so the lint only fails on new violations. The baseline only ratchets down.

Workflow:

  • You want to remove a mention: edit the doc, then scripts/lint-disabled-features.sh --update to shrink the snapshot.
  • A feature was newly disabled: add a rule to docs/.disabled-terms.tsv, then --update to record the current state. Clean up mentions over time.
  • A feature was re-enabled: delete the rule, then --update to prune stale snapshot entries.

Icons

Unicode symbols only (per CLAUDE.md). Never use emoji.

Good: ⚙ ◆ ☑ ✎ ◉ Bad: (no emoji characters)

Compliance References

When documenting a feature related to a standard (ISA-88, IEC 61131-3, 21 CFR Part 11, IEC 62443), include a note linking to the traceability doc:

> **Compliance**: This feature implements ISA-18.2 alarm state model.
> See [ISA-88 traceability](compliance/isa88.md) for requirements mapping.

Tables

  • Use Markdown tables for structured data
  • Align columns for readability in source
  • Include a header row

Diagrams

  • Use Mermaid fenced code blocks (```mermaid). The site renders them via the pymdownx.superfences custom fence configured in mkdocs.yml.
  • Default to flowchart TD for topologies and pipelines, stateDiagram-v2 for state machines, and sequenceDiagram for interactions.
  • Quote node labels (["..."]) and use <br/> for line breaks. Put edge explanations on the edges themselves with -->|text|.
  • Keep nodes to a short title plus one or two descriptive lines. Push per-layer bullets into a bulleted list under the diagram.
  • Do not use ASCII box-drawing characters (, , , , …) to build diagrams. They render as fixed-width blobs, break screen readers, and diverge from the Mermaid-rendered look of the rest of the site. Reserve ASCII for short inline fragments where a real diagram would be overkill.
  • scripts/lint-docs-diagrams.sh greps the docs git can see (see § "What the Docs Gates Read") for box-drawing characters and fails on any match. The pre-commit hook and CI run it automatically when markdown under docs/ is staged.

Diagram accuracy

Diagrams make factual claims and are held to the same accuracy bar as code examples. Every name in a diagram must be real:

  • CRD kinds, API groups, and field names exactly as generated in config/crd/bases/. Never an invented illustrative schema or a placeholder kind.
  • Component and binary names as they exist under cmd/.
  • ISA-88 state and command names exactly as defined in pkg/statemachine/.
  • Depicted relationships (who creates, watches, owns, or approves what) must match the controller code.

A diagram may simplify by omitting. It may not invent. When a diagram depicts a contract the code implements, wrap the region in a contract marker so make check-docs-reverse flags drift.

Diagram theme

The default Mermaid theme is the standard, and meaning is carried by node labels and edge labels alone:

  • No classDef, style, or inline color directives to encode semantics. A reader must get the full meaning from the text alone.
  • A diagram color grammar (reserved colors and strokes with registered meanings, like the marketing site's symbol registry) is a deliberate design effort with its own review tooling. Do not introduce one ad hoc. If a need arises, file an issue and design it as a system.

Video Clips

Short UI walkthroughs are embedded as raw HTML <video>, with no MkDocs plugin involved. MkDocs has no video extension we depend on, and a <video> element is small, well-understood, and styles cleanly against the theme. A plugin (mkdocs-video or similar) would buy nothing over the snippet below and grows the build's dependency surface. Keep it raw HTML.

The clip binaries are not committed. They are recorded from docs/videos/*.clip.yaml, published to Cloudflare R2, and served from media.cndcs.io (see scripts/docs-shots/README.md and the recorder pipeline, #658). Animated conceptual diagrams ship through the same pipeline and embed identically. A diagram: clip spec renders a committed HTML/SVG source deterministically, with no live surface to film (ADR 0030, authoring contract in scripts/docs-shots/README.md § "Animated diagrams"). Embeds reference those stable URLs by convention: https://media.cndcs.io/<tier>/videos/<stem>.<ext>, where <tier> is docs (COI-gated, the default) or marketing (public, for marketing-tagged clips). The recorder emits four artifacts per clip: <stem>.mp4 (the source), <stem>.gif (animated fallback), <stem>.jpg (a still poster frame), and an intermediate <stem>.webm.

Take the tier from docs/videos/manifest.json. The publish step uploads each artifact to exactly one key and records the resulting URL in that committed manifest. A doc may embed a marketing-tier clip, and several do, but it has to name the tier the clip was published to, because no copy exists at the other one. Copying a neighbouring snippet without checking the manifest is how #1304 happened. The failure is total: the .jpg poster and the .gif fallback 404 alongside the .mp4, so the reader gets an empty region with no still to fall back on. make lint-docs-clips fails any tier that disagrees with the manifest.

Canonical embed

<figure class="doc-video">
  <video controls muted playsinline preload="none"
         poster="https://media.cndcs.io/<tier>/videos/<stem>.jpg">
    <source src="https://media.cndcs.io/<tier>/videos/<stem>.mp4" type="video/mp4">
    <img src="https://media.cndcs.io/<tier>/videos/<stem>.gif"
         alt="<describe what the clip shows, same detail as a screenshot alt>">
  </video>
  <figcaption>One line on what the reader is watching and where.</figcaption>
</figure>

Conventions:

  • All three URLs carry the same <tier>, the one the manifest records for that stem.

  • poster (the .jpg) is required. It keeps the gated/remote clip from showing a blank box before play. With preload="none" the heavy MP4 isn't fetched until the reader hits play, so the poster is all they see at rest.

  • <source> points at the .mp4, and the <img> GIF is the in-<video> fallback. To prove the clip is referenced, lint-docs-clips matches the .mp4 or .gif URL. Always keep one of them present.
  • controls muted playsinlinemuted + playsinline are required for mobile inline playback. Never add loop. The site script (docs/javascripts/doc-video-autoplay.js) starts each clip when its figure scrolls into view, plays it ONCE, and lets a click replay it. A looping video never fires ended, which breaks that play-once contract. The docs clips are narratives with endings, so a restarting loop reads as a glitch. Readers with prefers-reduced-motion keep click-to-play.
  • .doc-video is the only class — it's styled in docs/stylesheets/extra.css. Do not invent per-page video classes.
  • Write a real alt and figcaption, at the same level of detail you'd give a screenshot. The alt describes the clip for assistive tech, and the caption orients the reader.
  • Inside a === "UI" tab, indent the whole block four spaces like any other tab content. A clip does not replace the static screenshots a UI tab needs (make lint-docs-ui-tab-shots). It complements them.

make lint-docs-clips warns when a docs/videos/*.clip.yaml spec records a clip that no doc embeds. Referencing the stem's .mp4/.gif URL as above satisfies it.

Eliding waits

A clip whose honest wall time is dominated by waiting (image pulls, rollout waits, a scheduler tick) may elide that waiting with a cut: step in its spec (#1275). The cut wraps exactly the wait it removes:

- term.run: kubectl get pods -w
- cut:
    term.waitFor: { pattern: 'Running', timeoutMs: 600000 }

The camera keeps rolling while the wrapped wait runs. At encode time the recorder drops the captured frames between the cut's start and the moment the wait resolved, then concatenates the surrounding footage in capture order. Every frame in the published clip is real, unedited capture of the real system. A cut removes time and nothing else: no reordering, no synthesis.

Eliding is appropriate only when the removed time is pure waiting whose completion is verifiable on screen: the wrapped wait's condition (the pod turning Running, the status chip appearing) is visible in the frames on either side of the splice, and the spec records exactly what was skipped. Never use a cut to tighten the pacing of interactions, and never to imply a speed the product does not have. The mechanism enforces the first half of that rule by construction. A cut may wrap only wait-class steps (settle, wait, pause, the waitFor* family, a terminal waitFor), and make lint-docs-clips rejects anything else.

Every elision must be signalled, and the recorder does this automatically: after the wait resolves it holds a small "… 4m 32s of waiting elided" notice on camera for about a second, so the cut never reads as "this happened instantly" and the removed wall time is on record in the footage itself. Do not crop or obscure the notice. When the waiting is itself the claim (a failover time, a rollout duration), state the real duration in the caption or the surrounding prose. The shortened clip cannot carry that claim.

Contract Markers

Some documentation describes a contract the code implements: a state transition table, an API surface, an audit-trail field list. When that section changes, the corresponding code must be re-checked. Mark such regions with paired HTML comments referencing a contract ID:

<!-- contract:isa88-state-machine -->
| From State | Start | Stop | ... |
| ... |
<!-- /contract:isa88-state-machine -->

Each contract ID is opted into reverse-detection by listing it in a rule in docs/doc-map.json:

{
  "category": "statemachine",
  "patterns": ["pkg/statemachine/**/*.go"],
  "docs": ["docs/compliance/isa88.md"],
  "contracts": ["isa88-state-machine"]
}

make check-docs-reverse (hack/check-docs-reverse.py) runs git-diff against the doc. When a changed line falls inside a contract region whose ID is referenced by a rule, the rule's patterns are reported as code to review. If any changed file matches one of the patterns, the rule is considered satisfied. The assumption is that you already inspected the implementation while editing both sides.

Guidelines for contract markers:

  • Wrap only the section that maps to code. Editorial tweaks to the surrounding introductory text should not trip the check.
  • One contract ID per rule is the common case. A doc may host multiple distinct regions sharing the same ID (the check unions their ranges).
  • Pick a short, kebab-case ID that describes what is contracted: isa88-state-machine names the contract, while compliance-isa88-section-7 names a doc location that will move.

File Naming

  • Lowercase with hyphens: alarm-management.md
  • Match the primary topic: mqtt-telemetry.md not mqtt-client-architecture.md

A related-links footer is optional. Don't pad pages that have nothing useful to link. When a doc does carry one, the section must be titled exactly:

## Related Documentation

- [Doc Title](relative-link.md) -- one-line description

Variant headings ("See also", "Related", "Where to go next", "Next steps", "What next?", "Further reading", ...) are not allowed. scripts/lint-docs-style.sh (make lint-docs-style) rejects them so the footer reads identically across the site. A short prose lead-in under the heading is fine. The heading text is what's standardized.