---
title: The docs convention
owner: Maintainers
summary: Every repository owns its docs/; one site aggregates them. This site is
  the proof.
tags:
  - docs
related:
  - docs/cli
  - docs/mcp
updated: 2026-11-04T13:14:24Z
source: https://github.com/jagreehal/autocatalog/blob/main/docs/docs-convention.md
url: https://jagreehal.github.io/autocatalog/docs/docs-convention/
---
The same argument as the event catalog, applied to prose: documentation belongs
in the repository it describes, and a central site should read it rather than
ask anyone to copy it.

A repository publishes by committing markdown to `docs/`. That is the whole
enrolment step. The site you are reading is built this way — these pages live in
`docs/` at the root of the autocatalog repository, and the site reads them.

## What is a fact, and what is authored

`owner` is required frontmatter, because a document nobody owns is a document
nobody fixes. `updated` is never authored: it comes from git, per file, so a
freshness date cannot be wrong. Backlinks are derived — "linked from" is computed
across every enrolled repository, so nobody maintains the other end of a link.

Cross-repository links are declared as ids, not paths:

```yaml
---
title: Payments service runbook
owner: Payments
related:
  - acme-handbook/incident-communications
---
```

A relative path into a sibling checkout only resolves for someone who has every
repository cloned side by side. An id resolves anywhere, and a link to a document
that does not exist is a failing check rather than a dead link on a page.

A runbook or ADR is usually about something other than a document, so `about:`
attaches it to that resource, named as `kind/id` the way the catalog already
names it — an Event as `event/<domain>/<id>`, the `domain/id` an Agent's
`consumes` uses:

```yaml
---
title: Payment capture runbook
owner: Payments
about:
  - event/payments/payment.captured
  - service/PaymentService
  - agent/refund-agent
---
```

The document is then listed on that event's and service's pages, the resources
it is about are listed under the document, and `/catalog.json` carries each as a
`describes` edge. `ac docs check` fails a target this repository defines nothing
by, with the near miss it probably meant; a kind the repository defines none of
is left to the composed site, which warns and leaves the edge out.

## A repository describes itself

`docs/meta.json` is where a team names its documents, so nobody edits the site
to join it. Every field is optional:

```json
{
  "$schema": "https://unpkg.com/autocatalog-docs-core/contracts/docs-meta.schema.json",
  "label": "Payments",
  "description": "How the payments platform works, from batch submission to payout.",
  "owner": "payments",
  "sidebar": [
    { "directory": "guide", "label": "Start here" },
    { "directory": "runbooks", "label": "Runbooks", "collapsed": true }
  ]
}
```

`label` and `description` are what readers see in the navigation and on the home
page. `owner` owns every document here that names none of its own, so one line
covers a repository and a page can still name another team. `sidebar` orders the
folders of `docs/`; a folder it leaves out still appears, last and collapsed, so
a new section is never hidden. `ac docs check` fails a file that does not parse
or a `directory` that is not a folder directly under `docs/`.

## Decisions, runbooks and incidents

Architecture decision records, runbooks and incident write-ups live in the
repository they are about, in a folder under its `docs/`:

```text
payments-service/docs/
  adr/0003-kafka-for-payment-events.md
  runbooks/replay-payment-events.md
  incidents/2026-09-14-duplicate-payment-completed.md
```

The folder sets the document's kind, so nobody writes `kind:` by hand. A
document elsewhere can still name one in frontmatter (`kind: incident`), and
the deepest matching folder wins when two kinds nest. A folder's `index.md` or
`README.md` introduces the kind and stays a plain document.

Each kind adds fields to the base contract:

| Kind       | Folder       | Fields                                                                                                                                            |
| ---------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `adr`      | `adr/`       | `status` (proposed, accepted, rejected, deprecated, superseded) and `date`, both required; `supersedes` and `supersededBy`, lists of document ids |
| `runbook`  | `runbooks/`  | `severity` (sev1 to sev4) and `lastTested`, a date; both optional                                                                                 |
| `incident` | `incidents/` | `date`, `severity` and `status` (open, mitigated, resolved), required; `rootCause` and `solution`, one sentence each, written once it is resolved |

The ADR states match EventCatalog's. What a document applies to is `about:`,
the same field every document has, so a decision about a service is listed on
that service's page:

```yaml
---
title: Publish payment events on Kafka
owner: payments-platform
status: accepted
date: 2026-03-14
supersedes:
  - payments-service/adr/0001-rabbitmq-for-payment-events
about:
  - service/payments-service
  - event/payment/payment.completed
---
```

An incident keeps its short answers in frontmatter, where the index shows them,
and the full write-up in the body:

```yaml
---
title: Duplicate payment.completed after a consumer rebalance
owner: payments-platform
date: 2026-09-14
severity: sev2
status: resolved
rootCause: The settle step published before committing its offset.
solution: Publishing now happens after the commit.
about:
  - service/payments-service
---
```

`ac docs check` validates each document against the base contract and its
kind's schema together, names the kind in each failure, and checks that every
`supersedes` and `supersededBy` id resolves, as it does for `related`.

### The conventions file

An organisation changes the kinds with one file,
`contracts/autocatalog.conventions.json`, which also says
[where a team keeps its events](/docs/plugins#you-may-not-need-one), published and found the way the
frontmatter contract is: at the aggregation root, or the one an enrolled
repository (usually the handbook) publishes. Two repositories publishing
different copies is an error that names both files.

```bash
ac docs init-conventions   # write the built-in kinds, then edit them in place
```

```json
{
  "kinds": {
    "runbook": false,
    "adr": { "folder": "decisions" },
    "rfc": {
      "label": "Proposals",
      "folder": "rfcs",
      "path": "proposals",
      "description": "Changes proposed across teams, and what came of them.",
      "columns": ["status", "date", "reviewers", "owner"],
      "statusField": "status",
      "dateField": "date",
      "schema": {
        "type": "object",
        "required": ["status", "date"],
        "properties": {
          "status": { "enum": ["draft", "review", "accepted", "withdrawn"] },
          "date": { "type": "string", "format": "date" },
          "reviewers": { "type": "array", "items": { "type": "string" } }
        }
      }
    }
  }
}
```

Each kind takes:

- `label`: the plural the index page, sidebar and home page use.
- `folder`: where its documents sit under `docs/`.
- `path`: its index page, `/<path>/`. Defaults to the label in lower case.
- `description`: the sentence under the index page's title.
- `schema`: JSON Schema for the kind's own frontmatter, checked together with
  the base contract. A base contract closed with `additionalProperties: false`
  still accepts the kind's fields.
- `columns`: the frontmatter fields the index table shows after the title.
- `statusField` and `dateField`: the field shown as a badge and filtered on, and
  the field the index sorts newest first by.

`false` turns a built-in kind off. An object for a built-in kind replaces only
the fields it names, and a field it names replaces the built-in one whole,
`schema` included. A kind's path must not be an enrolled repository's name or
a page the site already publishes, such as `catalog`; the build says which kind
to change.

## Check every pull request

A document that breaks the contract leaves its repository's documents off the
site, so catch it in the pull request. This is the whole workflow:

```yaml
name: catalog
on: pull_request
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: jagreehal/autocatalog/actions/check@v1
```

It checks the documents, source contracts and events the repository publishes,
and skips what it does not have. On a pull request it also compares against the
base branch, so a change that deletes a contract fails even when nothing is left
to check. Document and contract failures mark the files they name. A repository with
rules passes `allow-code-execution: true`, which runs them, so leave it off for
pull requests from forks. On another CI, run
`npx autocatalog check --base-ref origin/main`.

## The contract is yours

Frontmatter is validated against a JSON Schema the organisation owns, found at
`contracts/docs-frontmatter.json` without a flag. Where the root is a directory
of cloned repositories, as `ac site --topic` builds from, it is the one an
enrolled repository publishes there, usually the handbook:

```bash
ac docs init-schema   # write the default contract, then edit it in place
ac docs check         # frontmatter, cross-repo links, deleted-but-linked docs
ac docs sync-schema   # has this repository's copy drifted from the canonical one?
ac docs manifest      # publish contracts/docs-manifest.json
```

Add a field to that schema and it validates in CI, reaches the page, and can be
enforced from a Python repository that has never installed any of this — because
the contract is JSON Schema, not a plugin API.

A repository committing its own copy of the contract is how CI works without a
sibling checkout, and `ac docs sync-schema` is what keeps the copy honest: the
copy records where it came from in `$id`, the standard keyword for exactly that,
and drift becomes a failing command rather than a surprise.

## What the site adds, and what it refuses to

The site is [Starlight](https://starlight.astro.build) with a plugin. Search,
keyboard navigation, dark mode, translations and accessibility stay Starlight's —
they are not ours to get wrong. The plugin changes where the documents come
from and what the site can derive from the enrolled repositories. Conventional
`events/` and Agent-system directories join the same Repository identity
automatically.

`/catalog` is the human inventory: every Document, Event, Service, Agent, Tool,
Model, Workflow, Policy, Eval, Domain, and Repository with its provenance and
connections. `/catalog.json` publishes the same information as
`autocatalog/graph-v1`. Rich event and agent analysis stays in the deeper domain
shells; this page is the shared navigation layer.

That contract is committed as JSON Schema and shipped with `autocatalog-core`
at `contracts/graph-v1.schema.json`, for the same reason the frontmatter
contract is: a rule that only exists as a TypeScript type cannot be enforced by
a repository that does not run TypeScript.

A repository whose own catalog will not compile is **quarantined**, not fatal.
Its documents, events or agents are left out, the build warns, and `/catalog` names the
repository and the reason. A document that breaks the frontmatter contract
leaves out every document in its repository, so its pages and the index never
disagree. Refusing to publish something partial still holds —
it holds in the repository that owns the mistake, where `ac docs check` and
`ac validate` fail the merge. One team must not be able to take down the index
every other team reads.

It also publishes two things for agents:

- a raw markdown twin at every page's URL plus `.md`
- `llms.txt`, an index of the whole site, linking those twins

Both are generated from the same documents the pages render, so neither can
drift from what a reader sees.

## Using it for your own docs

```bash
pnpm add -D autocatalog-docs-astro
```

```js
import starlight from '@astrojs/starlight';
import autocatalogDocs from 'autocatalog-docs-astro';

export default defineConfig({
  integrations: [
    starlight({
      title: 'Acme docs',
      plugins: [autocatalogDocs({ root: '..' })],
    }),
  ],
});
```

`root: '..'` is the aggregating shape: a docs site in its own repository, reading
the sibling repositories checked out beside it. Point `root` at a directory that
contains `docs/` instead — with `repo` naming it — to publish one repository's
own documentation, which is what this site does.

Repositories do not have to sit one level down. Every folder below `root` with
its own `docs/`, `events/`, `contracts/` or `autocatalog.consumes.json` publishes
under its folder name, so the packages of a monorepo publish the way separate
checkouts do: `acme-platform/services/orders/docs/runbook.md` is `/orders/runbook`.
A package's owners come from the nearest CODEOWNERS above it. Two folders with
the same name cannot both publish: the one nearer the root keeps the name, and
the site names the other and leaves it out. `test/`, `tests/`, `fixtures/`,
`examples/`, `experiments/`, `starters/` and `templates/` folders are never
searched, and a `docs/` or `events/` folder that holds packages, such as a docs
site's source, is read as packages.

Leave the sidebar out and it is generated from what is on disk, one group per
repository. Configure a sidebar and yours is kept, which is the right call once a
site has enough pages to need sections.
