feat: add docs checker for ADRs and implementation plans (#1)
release / release (push) Successful in 17s
ci / check (push) Successful in 18s

Add cmd/docs, the first corestack component: `docs new adr|plan "Title"`
writes records from embedded templates, and `docs check` validates them
against the public format spec in docs/docs-format.md (timestamp IDs,
frontmatter keys and enums, separate decision and implementation status,
supersession symmetry, MADR 4 layout with required Consequences, plan
sections, and repo-root/durable references), reporting path:line errors.

Also adds the module skeleton (go.auvem.com/corestack, BSD-3-Clause,
README, AGENTS.md, CLAUDE.md), Gitea CI running tests and docs check,
a tag-triggered goreleaser release for darwin/linux binaries, and the
accepted ADR adopting the format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit was merged in pull request #1.
This commit is contained in:
endandClaude Opus 5.5 committed 2026-10-23 15:56:03 -07:00
1 parent 37b0516fd2
commit cc482d9a9b
27 files changed
+2464

No files matched your search

@@ -0,0 +1,82 @@
---
title: Adopt a checked format for ADRs and implementation plans
date: 2026-10-23
status: accepted
implementation: implemented
summary: Use one documented ADR and plan format, enforced by a shared Go CLI (docs check).
tags:
- documentation
- adr
- tooling
supersedes: []
superseded_by: []
related: []
tracking: []
implemented_by:
- https://gitea.auvem.com/corestack/go/pulls/1
---
# Adopt a checked format for ADRs and implementation plans
## Context and Problem Statement
We record architecture decisions as ADRs and track delivery work as implementation plans. Across our repositories, four independent implementations of this practice grew up: each had its own filename scheme, frontmatter keys, status vocabulary and checking script (or none). The same concept was spelled differently from repository to repository, cross-references were relative paths that broke when files moved, and "accepted" did not say whether anything had been built. Tooling and agents had to learn each variant, and fixes to one checker did not reach the others.
We need one format that people and tools can write without surprises, that separates *the decision* from *its delivery*, and that is verified mechanically so records do not rot. The format must be public and documented well enough that someone can write valid files from the specification alone.
## Decision Drivers
* One vocabulary and one layout everywhere, so a record means the same thing in every repository.
* Stable identity: a record's ID and path never change, so references stay valid.
* The decision status and the implementation status are tracked separately.
* Checks run locally and in CI with no service dependency.
* Low upkeep: one tool to maintain, not one per repository.
## Considered Options
* MADR 4 template plus generic Markdown and link linting.
* Keep a checking script per repository, converging informally.
* Extract and publish one existing checker.
* Define one format and implement one shared Go CLI that creates and checks records.
## Decision Outcome
Chosen option: "Define one format and implement one shared Go CLI that creates and checks records", because it is the only option that gives a single enforced vocabulary and identity scheme while keeping the checks free of service dependencies.
The format is specified in [docs-format](/docs/docs-format.md). It uses the MADR 4 layout, with `### Consequences` under Decision Outcome made mandatory, and adds the parts MADR leaves open: timestamp-based permanent IDs in filenames, a fixed frontmatter schema, a separate `implementation` field, reciprocal supersession, durable repo-root references, and a plan record with its own lifecycle. The CLI, `docs`, offers `docs new adr|plan` and `docs check`.
### Consequences
* Good, because the format has one specification, one checker and one set of error messages.
* Good, because records are validated, so dead links, missing sections and contradictory statuses fail CI instead of accumulating.
* Good, because the permanent ID survives title edits.
* Bad, because the format is ours: we own the specification and the tool, and breaking changes before 1.0 will require updating adopters.
* Neutral, because the check validates structure only; it cannot tell whether a decision is sound.
## Pros and Cons of the Options
### MADR 4 template plus generic Markdown and link linting
* Good, because it is a public, widely understood template and nothing custom is built.
* Bad, because MADR is a template with no checker of its own, and generic linters enforce no ID, status, supersession or reference rules.
### Keep a checking script per repository
* Good, because there is no new shared artifact and each team keeps full control.
* Bad, because the four existing variants already diverged; nothing stops further divergence.
* Bad, because each script is maintained, tested and fixed separately, and most repositories would simply not have one.
### Extract and publish one existing checker
* Good, because it is cheaper: an existing Go checker already handles timestamp IDs, exclusive creation and reciprocal supersession.
* Bad, because it has no plan record, no `implementation` field and no public specification, and it is tied to one repository's conventions.
### Define one format and implement one shared Go CLI that creates and checks records
* Good, because one specification and one implementation can be tested thoroughly and installed with `go install`.
* Good, because the same binary creates records, so new files start with the required structure.
* Bad, because we commit to maintaining a small public tool and a specification.
## More Information
MADR 4: https://adr.github.io/madr/. The specification is [docs-format](/docs/docs-format.md); the implementation lands in the same pull request that records this decision.
+245
View File
@@ -0,0 +1,245 @@
# Docs format
This is the complete specification for architecture decision records (ADRs) and implementation plans checked by `docs check`. It is standalone: a file that follows this document is valid without running the tool. The words MUST, MUST NOT and MAY are used in their RFC 2119 sense.
The ADR body uses the [MADR 4](https://adr.github.io/madr/) layout; `Consequences` is mandatory. The frontmatter, naming and cross-reference rules are specific to this format.
## 1. Files and directories
- ADRs live in `docs/adr/`, plans in `docs/plans/`, relative to the repository root. Both are flat: subdirectories are not scanned and are errors.
- A missing directory is treated as empty. `docs/adr` and `docs/plans` MUST be real directories: a symlink in their place is an error, and neither may resolve outside the repository through a symlinked parent such as `docs`.
- Entries whose name starts with `.` (such as `.DS_Store` or editor swap files) are ignored. `README.md` in either directory is skipped. Every other entry MUST be a regular file whose name matches section 2 and ends in `.md`. Any other file, directory or name is an error. There are no other exceptions: no templates and no companion files; every `.md` file is a record.
- Files are UTF-8 without a byte order mark. LF and CRLF line endings are both accepted.
## 2. Names and IDs
A record file is named `YYYYMMDDTHHMMSSmmmZ-slug.md`, for example `20261023T204512345Z-use-example-queue.md`.
- The timestamp is UTC to the millisecond and MUST be a real date and time.
- The slug is lowercase `a-z0-9` words joined by single hyphens: no leading, trailing or doubled hyphen, 1 to 80 characters.
- The filename without `.md` (the stem) is the record's permanent ID. Never rename a record, even to fix its slug; change the title instead.
- IDs MUST be unique across `docs/adr` and `docs/plans` (compared by the timestamp-and-slug stem).
- Tools create `docs/<type>/<stem>.md` exclusively (`O_EXCL`) and advance the timestamp by 1 ms on `EEXIST`, or while any record in either directory has the same stem. This is tool behavior and does not affect validity.
## 3. Frontmatter
Every record starts with a YAML frontmatter block: the first line is exactly `---`, and the block ends at the next line that is exactly `---`. The block MUST be a mapping containing exactly the keys listed for the record type, no more, no fewer, each once. Key order is free. Empty lists are written `[]`. A key with no value (null) is an error.
A plain `~`, `null`, `Null` or `NULL` is null, the same as an empty value, and is an error. Every other scalar is a string with no type resolution: its parsed text, whether quoted or not. For example, `date` may be quoted or unquoted, `title: 2026` is the string `2026`, and `title: "null"` is the string `null`. A block scalar (`|`, `>`) is its parsed text. Anchors, aliases, explicit tags and multiple YAML documents are errors. "Single line" means the parsed value contains no line break.
### 3.1 ADR keys
| Key | Type | Rule |
| --- | --- | --- |
| `title` | string | Non-empty, single line. |
| `date` | `YYYY-MM-DD` | Valid date. Equals the date in the filename (UTC). |
| `status` | enum | `proposed`, `accepted`, `rejected`, `deprecated`, `superseded`. |
| `implementation` | enum | `not-started`, `in-progress`, `implemented`, `deferred`, `not-applicable`. |
| `summary` | string | Non-empty, single line. |
| `tags` | list of strings | Non-empty list. Each tag is lowercase `a-z0-9` words joined by single hyphens; no duplicates. |
| `supersedes` | list of references | May be empty. |
| `superseded_by` | list of references | May be empty. |
| `related` | list of references | May be empty. |
| `tracking` | list of references | May be empty. |
| `implemented_by` | list of references | May be empty. |
### 3.2 Plan keys
| Key | Type | Rule |
| --- | --- | --- |
| `status` | enum | `draft`, `active`, `completed`, `cancelled`. |
| `tracking` | list of references | May be empty unless `active`. |
| `adrs` | list of references | May be empty. |
| `completion` | list of references | May be empty unless `completed` or `cancelled`. |
A plan has no `title` key; its title is its H1.
### 3.3 References in frontmatter
Every list item is a string. No list may contain duplicates. Which kinds of reference each key accepts:
| Key | Accepts |
| --- | --- |
| `supersedes`, `superseded_by`, plan `adrs` | Repo-root paths to ADRs (`/docs/adr/<id>.md`). |
| `related` | Repo-root paths to any record (`/docs/adr/...` or `/docs/plans/...`). |
| ADR `tracking` | `http` or `https` URLs, or repo-root paths to plans (`/docs/plans/<id>.md`). |
| Plan `tracking` | `http` or `https` URLs. |
| `implemented_by`, `completion` | `http` or `https` URLs (pull request, commit, release), or repo-root paths to existing files. |
See section 7 for what a valid path or URL is. A record MUST NOT reference itself. An ADR whose delivery is tracked by a plan lists the plan in `tracking`; other record-to-record links go in `related`.
Reference lists MUST be durable: an item that is a bare issue number such as `#123` or `owner/repo#123`, or one of the placeholders "this PR", "this pull request" or "this commit" (any case), is rejected. Use a full URL. This applies to whole list items in frontmatter reference lists only; `title`, `summary` and body text are not inspected for it.
## 4. Status
`status` is the decision; `implementation` is delivery of that decision. They are independent except where stated.
ADR `status`:
- `proposed`: under discussion; not binding.
- `accepted`: the decision stands. Accepting is the owner's act.
- `rejected`: considered and declined. Requires `implementation: not-applicable`.
- `deprecated`: once accepted, no longer recommended, with no replacement.
- `superseded`: replaced by another ADR. Requires non-empty `superseded_by`.
ADR `implementation`:
- `not-started`, `in-progress`, `deferred`: delivery state.
- `implemented`: requires non-empty `implemented_by`.
- `not-applicable`: nothing to deliver (required for `rejected`; allowed for any other status).
Per-status requirements, summarized:
| Condition | Requirement |
| --- | --- |
| `status: superseded` | `superseded_by` non-empty. |
| `superseded_by` non-empty | `status: superseded`. |
| `status: rejected` | `implementation: not-applicable`. |
| `implementation: implemented` | `implemented_by` non-empty. |
Plan `status`:
| Status | Meaning | `tracking` | `completion` |
| --- | --- | --- | --- |
| `draft` | Being written. | any | MUST be empty |
| `active` | Work under way. | MUST be non-empty | MUST be empty |
| `completed` | All steps done. | any | MUST be non-empty |
| `cancelled` | Abandoned. | any | MUST be non-empty |
## 5. Supersession
- If ADR A lists B in `supersedes`, then B MUST list A in `superseded_by`, and the reverse. Both records exist and are ADRs.
- Supersession MUST NOT form a cycle.
- The superseding record (A, the one whose `supersedes` lists the other) MUST have status `accepted`, or `superseded` or `deprecated` (it was accepted once and has since moved on). It MUST NOT be `proposed` or `rejected`.
- While a replacement is `proposed`, list the ADR it would replace in `related`. On acceptance, in the same change, move it to `supersedes` and set the target's `status: superseded` and `superseded_by`.
- A superseded record keeps its original `date` and filename; only its `status` and `superseded_by` change.
## 6. Body
The body is Markdown (CommonMark) after the frontmatter. Headings inside fenced code blocks or HTML comments do not count. Only headings at the top level of the document count. Required section headings are matched case-sensitively and exactly, at level 2 (`##`). A section runs until the next heading of level 1 or 2, so its subsections belong to it. A required section MUST appear exactly once and contain non-empty prose: at least one paragraph (including paragraphs inside blockquotes), list item or table cell of text. HTML comments and code (fenced, indented or inline) do not count as prose. Extra sections are allowed anywhere.
Heading text is the raw source after the `#` markers, trimmed, with any closing `#` run removed; it is compared byte for byte, with no inline-markup rendering. Setext headings are read as the equivalent ATX heading.
### 6.1 ADR
- The first heading is an H1 whose heading text equals `title`. There is one H1.
- Required H2 sections: `Context and Problem Statement`, `Considered Options`, `Decision Outcome`.
- `Decision Outcome` MUST contain exactly one `### Consequences` (H3) subsection with non-empty prose, and at least one prose block before its first H3 subsection. A level-2 `## Consequences` is an error.
- Optional MADR sections (for example `Decision Drivers`, `Pros and Cons of the Options`, `More Information`) MAY be added.
### 6.2 Plan
- The first heading is a non-empty H1: the plan title. There is one H1.
- Required H2 sections: `Scope`, `Steps`, `Validation`.
- `## Outcome` is optional for every status. Closed plans still require `completion` (section 4).
### 6.3 Placeholders
A record is invalid if it still contains the exact marker `{{docs:todo}}` anywhere outside code, including inside a frontmatter string value or list item (write it quoted there: `summary: "{{docs:todo}}"`). A list item equal to the marker reports only the marker error, not the format error for its key. `docs new` writes this marker into each field and section it leaves for you to fill; replace it with content. No other text is treated as a placeholder, so words like TODO or TBD are fine. To mention the marker itself, put it in inline or fenced code.
## 7. References and links
A reference is either a repo-root path or a URL. This applies to frontmatter reference lists and to every Markdown link and image destination in the body (inline, reference-style and autolinks; bare URLs in text are not checked). In the body, link rules apply only to Markdown link destinations: forge autolink shortcuts in plain text (`#123`, `owner/repo#123`, `@user`, commit SHAs) and raw HTML (`<a href>`, `<img src>`) are not inspected. Every link reference definition is checked, whether or not it is used.
Repo-root path:
- Starts with a single `/` and is relative to the repository root, for example `/docs/adr/20261023T204512345Z-use-example-queue.md`. A leading `//` (protocol-relative `//host/...`) is rejected.
- Repo-root links render correctly on Git forges (GitHub, Gitea) but not in every Markdown viewer.
- The destination is percent-decoded before the existence check (`%20` is a space). A `?query` is an error.
- The target MUST exist as a regular file inside the repository, and the path MUST match the target's case exactly, component by component, even on case-insensitive filesystems. Symlinks that resolve outside the repository are rejected.
- In body links only, a `#fragment` suffix is allowed; the fragment is not verified. In frontmatter, a fragment is an error.
- Rejected: relative paths (`foo.md`, `./foo.md`), any `..` segment and backslashes. Other non-existent targets, including machine paths such as `/Users/...` or `/home/...`, fail the existence rule; `file:` URLs fail the scheme rule.
URL:
- Scheme `http` or `https` with a host, for example `https://example.com/org/repo/pull/42`. Credentials in the URL (`user:pass@`) are rejected. URLs are not fetched.
Always allowed in body prose only: in-page anchors (`#section`) and `mailto:` links. Any other scheme is rejected.
## 8. What `docs check` reports
`docs check [--root DIR]` checks every record under `DIR` (default: the working directory) and prints one line per problem:
```
path:line: message
```
`path` is relative to the root using `/` separators. `line` is the best location of the problem (the key, heading or link), or `1` for file-level problems. Output is sorted by path, then line, then message. The exit status is `1` if any problem is reported, `2` for usage errors, and `0` otherwise, in which case a one-line summary of the records checked is printed.
It reports violations of everything above:
- unexpected files, directories or names; bad timestamps or slugs; duplicate IDs;
- missing, malformed or unterminated frontmatter; unknown, missing, duplicate or null keys; wrong types or values; `date` not matching the filename;
- status/implementation/field combinations from section 4;
- supersession problems from section 5, and self-references;
- missing, repeated or empty required sections; H1 problems; `{{docs:todo}}` markers;
- invalid, unresolvable or non-durable references and links.
It does not judge whether a decision is good, whether a record should have been written, or whether a link's fragment exists.
## 9. Minimal valid examples
`docs/adr/20261023T204512345Z-use-example-queue.md`:
```markdown
---
title: Use Example Queue for background jobs
date: 2026-10-23
status: proposed
implementation: not-started
summary: Run background jobs on Example Queue.
tags:
- jobs
supersedes: []
superseded_by: []
related: []
tracking: []
implemented_by: []
---
# Use Example Queue for background jobs
## Context and Problem Statement
Jobs currently run inline and block requests.
## Considered Options
* Example Queue
* Keep running jobs inline
## Decision Outcome
Chosen option: Example Queue, because it decouples jobs from requests.
### Consequences
* We operate one more service.
```
`docs/plans/20261023T210000000Z-adopt-example-queue.md`:
```markdown
---
status: draft
tracking: []
adrs:
- /docs/adr/20261023T204512345Z-use-example-queue.md
completion: []
---
# Adopt Example Queue
## Scope
Move the email job to Example Queue.
## Steps
1. Add the queue client.
2. Move the email job.
## Validation
The email job runs from the queue in staging.
```