diff --git a/.github/workflows/self-ci.yml b/.github/workflows/self-ci.yml index beb6912..90c7554 100644 --- a/.github/workflows/self-ci.yml +++ b/.github/workflows/self-ci.yml @@ -63,6 +63,31 @@ jobs: print("all sources resolve on disk") PY + adr-schema: + name: validate ADR schema + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v6.0.0 + with: + python-version: "3.12" + + - name: Fetch pull request base + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" + + - name: Validate ADR schema + run: python tools/check_adr_schema.py + env: + ADR_SCHEMA_BASE_REF: origin/${{ github.base_ref }} + repo-hygiene-smoke: name: smoke reusable-repo-hygiene permissions: diff --git a/docs/decision-records/0001-use-architecture-decision-records.md b/docs/decision-records/0001-use-architecture-decision-records.md index 89aa89f..0fcbbce 100644 --- a/docs/decision-records/0001-use-architecture-decision-records.md +++ b/docs/decision-records/0001-use-architecture-decision-records.md @@ -37,7 +37,7 @@ original 0001 so this file continues to serve as the template). 3. Replace the title, the metadata table, and every section body with content that is true for the new decision. Keep the section - headings in the order shown. + headings in the order shown, including the Changelog. 4. For sections or metadata fields that have no content, keep them and use one of two explicit values. Write "None." when the field applies but has zero entries. Write "N/A (reason)." when the field @@ -45,8 +45,10 @@ headings. A blank section tells the reader "I forgot"; an explicit "None." or "N/A" tells the reader "I considered this and there is nothing to record." - 5. Add a row to docs/decision-records/README.md's Index table. - 6. Open a pull request in this repository. On merge, mirror the new + 5. Add an initial Changelog row that records the acceptance of the + decision. + 6. Add a row to docs/decision-records/README.md's Index table. + 7. Open a pull request in this repository. On merge, mirror the new ADR into every adopting child repository's docs/decision-records/org/ directory (sync PRs per repo). @@ -68,20 +70,25 @@ # ADR-0001: Use Architecture Decision Records to Document Design Rationale -| Field | Value | -| -------------- | ---------------------------------------- | -| Status | Accepted | -| Date | 2026-04-22 | -| Authors | Nick Warila (@NWarila) | -| Decision-maker | Nick Warila (sole portfolio maintainer) | -| Consulted | None. | -| Informed | None. | -| Reversibility | Medium | -| Review-by | N/A (Accepted) | +| Field | Value | +| ---------------- | --------------------------------------------------------------------------- | +| ID | ADR-0001 | +| Scope | Org baseline | +| Status | Accepted | +| Decision-subject | ADR scope, format, lifecycle, and maintenance rules for decision records. | +| Date accepted | 2026-04-22 | +| Date | 2026-06-02 | +| Last reviewed | 2026-06-02 | +| Authors | Nick Warila (@NWarila) | +| Decision-makers | Nick Warila (sole portfolio maintainer) | +| Consulted | None. | +| Informed | None. | +| Reversibility | Medium | +| Review-by | 2026-11-29 | ## TL;DR -We will use `docs/decision-records/` as the conventional home for architecturally significant decisions across the `nwarila-platform` organization. ADRs are organized into three scopes: **org-baseline** ADRs whose master copies live in this `nwarila-platform/.github` repository at `docs/decision-records/` and are mirrored into every adopting child repository at `docs/decision-records/org/`; **type-template** ADRs whose master copies live in a type-template repository (for example `NWarila/terraform-runner-template` for Terraform consumers) at `docs/decision-records/` and are mirrored into every consumer of that template at `docs/decision-records/template/`; and **repository-specific** ADRs that live only in their owning repository at `docs/decision-records/repo/`. The format is MADR 4.0-aligned but uses a visible Markdown metadata table, adds explicit reversibility, implementing-PR links, and a conservative compliance-notes crosswalk, and uses the more readable `decision-records` directory name in place of MADR's conventional `adr/`. This gives the organization a single source of truth for org-level governance that travels alongside the code in every adopting repository, a per-stack source of truth that travels alongside every consumer of a given type-template, and a place for each repository to record its own architectural choices without conflicting with either shared baseline. +We will use `docs/decision-records/` as the conventional home for architecturally significant decisions across the `nwarila-platform` organization. ADRs are organized into three scopes: **org-baseline** ADRs whose master copies live in this `nwarila-platform/.github` repository at `docs/decision-records/` and are mirrored into every adopting child repository at `docs/decision-records/org/`; **type-template** ADRs whose master copies live in a type-template repository (for example `NWarila/terraform-runner-template` for Terraform consumers) at `docs/decision-records/` and are mirrored into every consumer of that template at `docs/decision-records/template/`; and **repository-specific** ADRs that live only in their owning repository at `docs/decision-records/repo/`. The format is MADR 4.0-aligned but uses a visible Markdown metadata table, adds explicit reversibility, implementing-PR links, `Last reviewed`, an append-only Changelog, and a conservative compliance-notes crosswalk, and uses the more readable `decision-records` directory name in place of MADR's conventional `adr/`. Accepted ADRs are living records when the same decision subject evolves: update the record in place, preserve auditability in the Changelog, and reserve supersession or obsolescence for decisions whose subject has been replaced or is no longer applicable. This gives the organization a single source of truth for org-level governance that travels alongside the code in every adopting repository, a per-stack source of truth that travels alongside every consumer of a given type-template, and a place for each repository to record its own architectural choices without conflicting with either shared baseline. ## Context and Problem Statement @@ -97,7 +104,7 @@ Three audiences matter here: A wiki, a Notion page, a README section, or a folder of ad hoc design notes fails at least one of those audiences. External tools drift from code, READMEs get crowded with user-facing content, and loosely managed documents often disappear or become misleading as ownership changes. -Architecture Decision Records (ADRs) solve this well: they are lightweight, source-controlled, and widely understood. Michael Nygard introduced the pattern in 2011. ThoughtWorks later recommended lightweight ADRs in source control instead of a wiki or website, and MADR 4.0.0, released on 2024-09-17, provides a well-known community template that is easy to adapt. +Architecture Decision Records (ADRs) solve this well: they are lightweight, source-controlled, and widely understood. Michael Nygard introduced the pattern in 2011. ThoughtWorks later recommended lightweight ADRs in source control instead of a wiki or website, and MADR 4.0.0, released on 2024-09-17, provides a well-known community template that is easy to adapt. MADR does not require accepted records to be immutable; this portfolio deliberately uses a living ADR model with explicit changelog evidence instead of silently treating git history as the whole audit trail. The remaining question is not whether to keep a decision log. It is which format to use, how much structure to require, and where those records should live. @@ -135,7 +142,9 @@ ADRs are organized into three scopes with independent four-digit numbering names Within all three scopes, `NNNN` is the next unused four-digit number in that scope's own namespace, allocated monotonically and never reused. The directory name is `decision-records` because it is immediately understandable to readers who do not already know the acronym. The subdirectory split (`org/`, `template/`, `repo/`) keeps the three scopes visually and structurally distinct, so a reader scanning a repository can immediately see which decisions were inherited from the organization, which were inherited from the repository's type-template, and which were made locally. -ADRs follow the structure demonstrated by this file itself, in this order: metadata table, TL;DR, Context and Problem Statement, Decision Drivers, Considered Options, Decision Outcome, Pros and Cons of the Options, Confirmation, Consequences (Positive / Negative / Neutral), Assumptions, Supersedes, Superseded by, Implementing PRs, Related ADRs, and Compliance Notes. Sections that genuinely do not apply are kept and filled with "None." or "N/A (reason)." so readers can distinguish "not applicable" from "forgotten." +ADRs follow the structure demonstrated by this file itself, in this order: metadata table, TL;DR, Context and Problem Statement, Decision Drivers, Considered Options, Decision Outcome, Pros and Cons of the Options, Confirmation, Consequences (Positive / Negative / Neutral), Assumptions, Supersedes, Superseded by, Implementing PRs, Related ADRs, Compliance Notes, and Changelog. Sections that genuinely do not apply are kept and filled with "None." or "N/A (reason)." so readers can distinguish "not applicable" from "forgotten." + +The metadata table records `ID`, `Scope`, `Status`, `Decision-subject`, `Date accepted`, `Date`, `Last reviewed`, `Authors`, `Decision-makers`, `Consulted`, `Informed`, `Reversibility`, and `Review-by`. `Date accepted` is immutable once the ADR is accepted. `Date` follows MADR's "last updated" meaning. `Last reviewed` is the most recent explicit review date and is refreshed on the cadence for the ADR scope: 180 days for org-baseline and type-template ADRs, and 365 days for repository-specific ADRs. A decision is **architecturally significant** and warrants an ADR when any of the following are true: @@ -146,6 +155,8 @@ A decision is **architecturally significant** and warrants an ADR when any of th Decisions that are **not** ADR-worthy include forced choices with no practical alternatives, style-level preferences with negligible downstream impact, runbook procedures, and single-PR implementation details. +Accepted ADRs are living records when the same `Decision-subject` evolves. A pull request may update the decision, scope, rationale, consequences, or review metadata in place, but every substantive change MUST add a new Changelog row that says what changed, why, who or what role made the change, and whether the ADR body changed. When the active decision text changes, the prior text MUST remain recoverable in the Changelog row or in a `Previous decisions` subsection. A `Last reviewed` bump with no body change still requires an explicit re-review row. Supersession is reserved for a different-subject ADR that replaces this one; obsolescence is reserved for a subject that is no longer applicable and has no replacement. + This ADR is the canonical example for this baseline and the starting point for participating repositories in the portfolio. When another repository seeds its own `ADR-0001` from this file, it must rewrite the metadata, context, consequences, and compliance notes so the record is true for that repository. ## Pros and Cons of the Options @@ -203,12 +214,13 @@ Adherence to this ADR is confirmed by the following mechanisms. The wording `MUS 2. **Type-template mirror check.** A child repository derived from a type-template MUST contain `docs/decision-records/template/` populated with byte-identical copies of every accepted type-template ADR from that template's `docs/decision-records/`. The same CI mechanism that enforces the org-baseline mirror SHOULD enforce the template-baseline mirror, run once per type-template the repository derives from. 3. **Layout-skeleton check.** Every adopting child repository MUST contain a complete decision-records directory skeleton — `docs/decision-records/org/`, `docs/decision-records/template/`, and `docs/decision-records/repo/` — even when some scopes contain no ADRs yet. Empty scopes are kept in source control via byte-identical `.gitkeep` placeholders mirrored from the org canonical, so a reader scanning any repo immediately sees the same predictable layout regardless of which scopes are populated. *Content* in each scope remains opt-in per scope (a repo with no repository-specific decisions has an empty `repo/`; a repo that does not derive from a type-template has an empty `template/`); only the *layout* is mandatory. 4. **Repo-scope check.** Repository-specific ADRs MUST live at `docs/decision-records/repo/NNNN-short-kebab-title.md`. They MUST NOT appear in `docs/decision-records/org/` or `docs/decision-records/template/`, and MUST NOT be promoted to either namespace without first being authored as a new ADR in `nwarila-platform/.github` (for the org baseline) or in the appropriate type-template (for a template baseline). A CI script MAY assert this directory split. -5. **Schema check.** A CI script SHOULD verify that every file matching `docs/decision-records/{org,template,repo}/[0-9][0-9][0-9][0-9]-*.md` contains the required section headings from this template: `## TL;DR`, `## Context and Problem Statement`, `## Decision Drivers`, `## Considered Options`, `## Decision Outcome`, `## Pros and Cons of the Options`, `## Confirmation`, `## Consequences`, `## Assumptions`, `## Supersedes`, `## Superseded by`, `## Implementing PRs`, `## Related ADRs`, and `## Compliance Notes`. `## Considered Options` and `## Pros and Cons of the Options` are especially important because they preserve rejected alternatives and trade-offs. +5. **Schema check.** A CI script SHOULD verify that every file matching `docs/decision-records/{org,template,repo}/[0-9][0-9][0-9][0-9]-*.md` contains the required section headings from this template: `## TL;DR`, `## Context and Problem Statement`, `## Decision Drivers`, `## Considered Options`, `## Decision Outcome`, `## Pros and Cons of the Options`, `## Confirmation`, `## Consequences`, `## Assumptions`, `## Supersedes`, `## Superseded by`, `## Implementing PRs`, `## Related ADRs`, `## Compliance Notes`, and `## Changelog`. `## Considered Options` and `## Pros and Cons of the Options` are especially important because they preserve rejected alternatives and trade-offs. 6. **Index check.** A repository that has any ADRs MUST contain `docs/decision-records/README.md` listing every ADR (org-mirrored, template-mirrored, and repo-specific, in clearly separated sections) with its current Status and Summary. A CI script SHOULD diff the directory listing against the index and fail on drift. 7. **Human review.** Every pull request that introduces a new ADR MUST be reviewed. Every pull request that materially contradicts an Accepted ADR SHOULD either update the code to comply, supersede the ADR, or explain why the ADR never actually applied to the change in question. -8. **Editorial rule.** After acceptance, edits MAY correct typos, broken links, formatting, Status, supersession fields, or `Implementing PRs`, but they MUST NOT silently change the decision, its scope, or its rationale. +8. **Living-edit rule.** After acceptance, edits MAY change the decision, scope, rationale, consequences, Status, review metadata, supersession fields, or `Implementing PRs` only when the change is explicit in the pull request and recorded in a new Changelog row. Silent changes to the decision body are not allowed. +9. **Changelog check.** CI SHOULD reject a changed ADR when an ADR body diff has no new Changelog row, a `Last reviewed` value advances without either a body diff or an explicit re-review row, a Changelog row was removed or modified instead of appended, a `Superseded` status lacks a resolvable `Superseded by` link, or a supersession link is not reciprocal. -Enforcement tooling is recommended but not mandatory at acceptance time. A solo-maintainer repository MAY rely on manual discipline; a team repository or a compliance-critical repository SHOULD automate at least the presence, schema, and index checks. +Enforcement tooling is recommended but not mandatory at acceptance time. A solo-maintainer repository MAY rely on manual discipline; a team repository or a compliance-critical repository SHOULD automate at least the presence, schema, index, and living-edit checks. ## Consequences @@ -219,6 +231,8 @@ Enforcement tooling is recommended but not mandatory at acceptance time. A solo- - Reviewers, contributors, and hiring audiences can reconstruct the reasoning behind important architectural choices without a synchronous conversation. - Security-relevant ADRs can contribute reusable evidence for reviews, assessments, and compliance preparation. - The format is self-documenting: this ADR both adopts the format and demonstrates how to use it. +- Living ADRs keep active decisions current without forcing readers to chase a chain of same-subject replacement records. +- The Changelog is the primary audit trail for ADR edits and survives squash merges, mirrors, and automation-authored sync commits better than git history alone. ### Negative @@ -226,6 +240,7 @@ Enforcement tooling is recommended but not mandatory at acceptance time. A solo- - The format is custom enough that future automation and linting will likely need repository-specific support. - If an adopting repository copies this ADR mechanically instead of rewriting repository-specific content, it can create a polished but false record. - Without enforcement tooling, ADRs can still drift from the code they describe. +- Living updates require reviewer discipline; without a meaningful Changelog row, they can hide the same decision drift they are meant to prevent. ### Neutral @@ -233,6 +248,7 @@ Enforcement tooling is recommended but not mandatory at acceptance time. A solo- - Each adopting repository's `docs/decision-records/README.md` becomes the canonical local index for its full ADR set (org-mirrored, template-mirrored, and repo-specific) and is the single page to read to understand the repository's decision posture. - Future repositories may introduce carefully scoped local extensions, but those should be documented in their own repo-specific ADRs (under `docs/decision-records/repo/`) rather than by silently mutating this baseline or any type-template baseline. - Org-baseline ADRs and type-template ADRs are duplicated content (masters in their respective canonical repositories; mirrors in every adopting child repo). The duplication is deliberate — it keeps governance content traveling with the code that implements it — but it means amendments to either upstream require a coordinated update across all adopting repositories. +- Git history corroborates ADR evolution, but the in-document Changelog is the reader-facing record of what changed and why. ## Assumptions @@ -242,6 +258,7 @@ This decision rests on the following assumptions. If any becomes false, this ADR 2. Markdown remains a widely supported, human-readable plain-text format. 3. Participating repositories continue to benefit from keeping governance artifacts near the code instead of in a separate knowledge base. 4. Repositories using this template have a clear decision-making path. In a solo-maintainer repository that may be the maintainer; in a team repository it may be a designated approver or architecture owner. +5. Pull requests that change ADRs have enough base-branch context for CI to detect whether a body diff or Changelog diff occurred. ## Supersedes @@ -274,3 +291,9 @@ This ADR establishes a documentation mechanism, not a deployed security control. | FedRAMP SSP artifacts | System-description and architecture narratives | ADRs can provide reusable source material and traceability for SSP drafting, but they do not replace the SSP or the assessment evidence set. | Subsequent ADRs should keep only the rows that genuinely apply to the decision at hand and should describe the relationship conservatively. + +## Changelog + +| Date | Change | Reason | Author/Role | Body-diff? | +| ---------- | ------------------------------------------- | --------------------------------------------------------- | --------------------------------- | ---------- | +| 2026-06-02 | Adopted the living ADR lifecycle guardrails. | Keep accepted ADRs current while preserving auditability. | Portfolio maintainer / governance | Yes | diff --git a/docs/decision-records/0005-keep-github-control-planes-namespace-local.md b/docs/decision-records/0005-keep-github-control-planes-namespace-local.md index 858d5a5..1b24f71 100644 --- a/docs/decision-records/0005-keep-github-control-planes-namespace-local.md +++ b/docs/decision-records/0005-keep-github-control-planes-namespace-local.md @@ -1,15 +1,20 @@ # ADR-0005: Keep GitHub Control Planes Namespace-Local -| Field | Value | -| -------------- | --------------------------------------- | -| Status | Accepted | -| Date | 2026-06-01 | -| Authors | Nick Warila (@NWarila), Codex | -| Decision-maker | Nick Warila (sole portfolio maintainer) | -| Consulted | CI findings from repo-hygiene, ADR drift, and reusable workflow rollout. | -| Informed | Maintainers of adopting repositories under `nwarila-platform`. | -| Reversibility | Medium | -| Review-by | N/A (Accepted) | +| Field | Value | +| ---------------- | --------------------------------------------------------------------------- | +| ID | ADR-0005 | +| Scope | Org baseline | +| Status | Accepted | +| Decision-subject | Namespace-local ownership for GitHub control-plane governance. | +| Date accepted | 2026-06-01 | +| Date | 2026-06-02 | +| Last reviewed | 2026-06-02 | +| Authors | Nick Warila (@NWarila) | +| Decision-makers | Nick Warila (sole portfolio maintainer) | +| Consulted | CI findings from repo-hygiene, ADR drift, and reusable workflow rollout. | +| Informed | Maintainers of adopting repositories under `nwarila-platform`. | +| Reversibility | Medium | +| Review-by | 2026-11-29 | ## TL;DR @@ -144,3 +149,8 @@ None (current). This decision supports configuration management and separation of duties. It keeps policy authority aligned with repository ownership and makes the source of CI, ADR, and community-health controls explicit in source control. It is not, by itself, a compliance claim. +## Changelog + +| Date | Change | Reason | Author/Role | Body-diff? | +| ---------- | --------------------------- | ------------------------------------------ | --------------------------------- | ---------- | +| 2026-06-02 | Refreshed living metadata. | Apply ADR-0001 living metadata guardrails. | Portfolio maintainer / governance | Yes | diff --git a/docs/decision-records/README.md b/docs/decision-records/README.md index a527de7..f461a1b 100644 --- a/docs/decision-records/README.md +++ b/docs/decision-records/README.md @@ -12,7 +12,7 @@ This `nwarila-platform/.github` directory holds the **master copies** of every o The three scopes use independent four-digit numbering namespaces and the same MADR 4.0-aligned format. See [ADR-0001](0001-use-architecture-decision-records.md) for the authoritative model. -ADRs may begin as `Proposed` while a decision is being discussed. Once accepted, an ADR becomes part of the repository's permanent historical record. Accepted ADRs are not substantively rewritten; later decisions supersede earlier ones through new ADRs. Post-acceptance edits are limited to status updates, supersession links, implementing-PR links, and editorial fixes that do not change the decision itself. +ADRs may begin as `Proposed` while a decision is being discussed. Once accepted, an ADR becomes part of the repository's permanent historical record and remains editable as a living record for the same `Decision-subject`. Same-subject changes are made in place with an append-only Changelog row; different-subject replacements use supersession; no-longer-applicable subjects use obsolescence. ## What is an ADR? @@ -24,7 +24,7 @@ An Architecture Decision Record is a short Markdown document that answers three A reader who knows nothing about the codebase should be able to open any ADR and understand why a particular design exists. A reviewer evaluating the repository should be able to reconstruct the project's architectural reasoning without a synchronous conversation. An auditor in a regulated environment should be able to trace important design choices to source-controlled artifacts. -The format used here is established by [ADR-0001](0001-use-architecture-decision-records.md). It is MADR 4.0-aligned, uses a visible Markdown metadata table instead of YAML front matter, and adds fields for reversibility, traceability, and conservative compliance mapping. +The format used here is established by [ADR-0001](0001-use-architecture-decision-records.md). It is MADR 4.0-aligned, uses a visible Markdown metadata table instead of YAML front matter, and adds fields for decision subject, review cadence, reversibility, traceability, Changelog-backed living updates, and conservative compliance mapping. ## Index @@ -40,13 +40,24 @@ The format used here is established by [ADR-0001](0001-use-architecture-decision An ADR moves through the following statuses. Every ADR in the Index above shows its current status. -- **Proposed.** The ADR has been drafted and is under discussion. The decision has not yet been made. A `Review-by` date should be set; if the ADR is not Accepted or Rejected by that date, it should be revisited or closed. +- **Proposed.** The ADR has been drafted and is under discussion. The decision has not yet been made. A `Review-by` date should be set; if the ADR is not Accepted, Superseded, Obsolete, or withdrawn by that date, it should be revisited or closed. - **Accepted.** The ADR represents an active decision. The code in the repository should reflect it. This is the working state of most ADRs. -- **Rejected.** The ADR was considered and decided against. It remains in the repository as a historical record so future readers can see that the option was evaluated. -- **Superseded by ADR-NNNN.** The decision was valid at the time but has been replaced by a later ADR. Both ADRs remain in the repository. The superseded ADR points forward to the newer one in its `Superseded by` section, and the newer ADR points back in its `Supersedes` section. -- **Deprecated.** The ADR describes a decision that is no longer in force but has not been explicitly replaced. This status should be rare and should usually be followed by a superseding ADR that explains what changed. +- **Superseded by ADR-NNNN.** The decision was valid at the time but has been replaced by a different-subject ADR. Both ADRs remain in the repository. The superseded ADR points forward to the newer one in its `Superseded by` section, and the newer ADR points back in its `Supersedes` section. +- **Obsolete.** The decision subject is no longer applicable and has no replacement. The ADR remains in the repository and its body freezes except for archival link maintenance. +- **Deprecated.** The ADR is still in force but no longer recommended for new work. This status should be rare and should usually lead to an in-place Accepted update, a Superseded status, or an Obsolete status. -An Accepted ADR may still receive non-substantive maintenance updates, but any change that alters the decision, its scope, or its rationale requires a superseding ADR. +An Accepted ADR may receive substantive same-subject updates in place. Every substantive edit to Context, Decision Outcome, Consequences, metadata, or review posture requires a new Changelog row. A `Last reviewed` bump with no body change still requires a Changelog row that says it was re-reviewed and remains valid. + +## Editing Decision Tree + +Use this decision tree before changing an ADR: + +1. If the same `Decision-subject` still exists and the answer, rationale, consequences, review date, or implementation evidence changed, edit the existing ADR in place and append a Changelog row. +2. If a different decision subject replaces this one, create a new ADR, set the old ADR to `Superseded by ADR-NNNN`, and add reciprocal `Supersedes` / `Superseded by` links. +3. If the subject is no longer applicable and has no replacement, set the ADR to `Obsolete`, add a Changelog row, and freeze the body. +4. If only a typo, broken link, or formatting issue changed, make the editorial correction and add a Changelog row when the change could affect interpretation. + +Worked example: if the documentation layout still uses Diátaxis but gains a sanctioned `docs/runbooks/` genre, update ADR-0002 in place because the subject is still the documentation framework. If the organization abandons Diátaxis for a different documentation framework, write a new ADR and supersede ADR-0002. ## How to Contribute a New ADR @@ -58,8 +69,9 @@ An Accepted ADR may still receive non-substantive maintenance updates, but any c 3. Copy [ADR-0001](0001-use-architecture-decision-records.md) to the new file. `NNNN` is the next unused four-digit number in the chosen scope's directory. Numbers are allocated monotonically and never reused. The org, template, and repo namespaces are independent (ADR `org/0001`, `template/0001`, and `repo/0001` can coexist in different directories). 4. Strip the template-instruction HTML comment block at the top of the copied file. 5. Replace the metadata values and every section body with content specific to the new decision. Keep the section headings in the order shown. For sections that genuinely do not apply, write "None." or "N/A (reason)." rather than deleting the heading. A missing heading reads as "I forgot"; an explicit "None." reads as "I considered this and there is nothing to record." -6. Update the appropriate index. For org-baseline ADRs, update the Index in this README. For repository-specific ADRs, update the corresponding section of the owning repository's `docs/decision-records/README.md`. -7. Open a pull request in the repository where the new ADR lives. The new ADR and the index update belong in the same PR. +6. Add an initial Changelog row that records the accepted decision or proposed draft. +7. Update the appropriate index. For org-baseline ADRs, update the Index in this README. For repository-specific ADRs, update the corresponding section of the owning repository's `docs/decision-records/README.md`. +8. Open a pull request in the repository where the new ADR lives. The new ADR and the index update belong in the same PR. ## Conventions @@ -68,8 +80,8 @@ An Accepted ADR may still receive non-substantive maintenance updates, but any c - **Filenames.** `NNNN-short-kebab-title.md`, where `NNNN` is a four-digit zero-padded number and the title is a present-tense verb phrase in kebab case. Example: `0004-pin-github-actions-by-commit-sha.md`. - **Numbering.** Monotonic. Gaps are allowed. Numbers are never reused, even if a proposed ADR is later abandoned. - **Titles.** Start with a present-tense imperative verb. Prefer `Pin GitHub Actions by Commit SHA` over `GitHub Actions Pinning Policy`. The title is also the H1 of the file, prefixed with `ADR-NNNN:`. -- **Metadata fields.** The table at the top of every ADR records `Status`, `Date`, `Authors`, `Decision-maker`, `Consulted`, `Informed`, `Reversibility`, and `Review-by`. `Consulted` and `Informed` follow RACI-style conventions: people whose input was actively sought versus people who were kept in the loop. `Reversibility` is an ease-of-change estimate: `Low` means hard to reverse (deeply committed), `Medium` means reversal is possible but involves meaningful migration or rework, and `High` means easy to reverse. `Review-by` is the date by which a `Proposed` ADR should be accepted or rejected; it is typically `N/A (Accepted)` once the ADR is Accepted. -- **Editing.** Accepted ADRs are append-only for substantive meaning. Allowed post-acceptance updates are Status changes, `Supersedes` and `Superseded by` links, `Implementing PRs`, and editorial corrections that do not alter the decision. +- **Metadata fields.** The table at the top of every ADR records `ID`, `Scope`, `Status`, `Decision-subject`, `Date accepted`, `Date`, `Last reviewed`, `Authors`, `Decision-makers`, `Consulted`, `Informed`, `Reversibility`, and `Review-by`. `Date accepted` is frozen once accepted. `Date` is the last updated date. `Last reviewed` follows the scope cadence: org-baseline and type-template ADRs every 180 days, repository-specific ADRs every 365 days. `Consulted` and `Informed` follow RACI-style conventions: people whose input was actively sought versus people who were kept in the loop. `Reversibility` is an ease-of-change estimate: `Low` means hard to reverse (deeply committed), `Medium` means reversal is possible but involves meaningful migration or rework, and `High` means easy to reverse. +- **Editing.** Accepted ADRs are living records for the same decision subject. Allowed post-acceptance updates include decision, scope, rationale, consequences, Status, `Supersedes` and `Superseded by` links, `Implementing PRs`, review metadata, and editorial corrections, but every substantive update must append a Changelog row. The Changelog is the primary reader-facing audit trail; git history corroborates it. ## Further Reading diff --git a/tools/check_adr_schema.py b/tools/check_adr_schema.py new file mode 100644 index 0000000..b39ff14 --- /dev/null +++ b/tools/check_adr_schema.py @@ -0,0 +1,294 @@ +"""Validate ADR structure and living-update guardrails.""" + +from __future__ import annotations + +import datetime as dt +import os +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +TODAY = dt.date.today() + +ADR_PATTERNS = ( + "docs/decision-records/[0-9][0-9][0-9][0-9]-*.md", + "docs/decision-records/org/[0-9][0-9][0-9][0-9]-*.md", + "docs/decision-records/template/[0-9][0-9][0-9][0-9]-*.md", + "docs/decision-records/repo/[0-9][0-9][0-9][0-9]-*.md", +) + +LEGACY_HEADINGS = ( + "## TL;DR", + "## Context and Problem Statement", + "## Decision Drivers", + "## Considered Options", + "## Decision Outcome", + "## Pros and Cons of the Options", + "## Confirmation", + "## Consequences", + "## Assumptions", + "## Supersedes", + "## Superseded by", + "## Implementing PRs", + "## Related ADRs", + "## Compliance Notes", +) + +LIVING_FIELDS = ( + "id", + "scope", + "status", + "decision-subject", + "date accepted", + "date", + "last reviewed", + "authors", + "decision-makers", + "consulted", + "informed", + "reversibility", + "review-by", +) + +VALID_STATUSES = {"proposed", "accepted", "superseded", "obsolete", "deprecated"} +DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +ADR_ID_RE = re.compile(r"ADR-(\d{4})") + + +def run_git(args: list[str]) -> str | None: + try: + return subprocess.check_output( + ["git", *args], + cwd=ROOT, + encoding="utf-8", + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + return None + + +def fail(errors: list[str], path: Path, message: str) -> None: + errors.append(f"{path.relative_to(ROOT).as_posix()}: {message}") + + +def adr_files() -> list[Path]: + files: list[Path] = [] + for pattern in ADR_PATTERNS: + files.extend(ROOT.glob(pattern)) + return sorted(files) + + +def parse_metadata(text: str) -> dict[str, str]: + lines = text.splitlines() + table: dict[str, str] = {} + in_table = False + for line in lines: + if not line.startswith("|"): + if in_table: + break + continue + in_table = True + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if len(cells) != 2 or set(cells[0]) <= {"-", " "}: + continue + if cells[0].lower() == "field": + continue + table[cells[0].lower()] = cells[1] + return table + + +def section_body(text: str, heading: str) -> str: + marker = f"\n{heading}\n" + if marker not in f"\n{text}": + return "" + after = f"\n{text}".split(marker, 1)[1] + next_heading = re.search(r"\n## ", after) + if next_heading: + return after[: next_heading.start()].strip() + return after.strip() + + +def body_without_changelog(text: str) -> str: + marker = "\n## Changelog\n" + if marker not in f"\n{text}": + return text.strip() + return f"\n{text}".split(marker, 1)[0].strip() + + +def changelog_rows(text: str) -> list[str]: + body = section_body(text, "## Changelog") + rows: list[str] = [] + for line in body.splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + if not cells: + continue + first = cells[0].lower() + if first == "date" or set(cells[0]) <= {"-", " "}: + continue + rows.append(stripped) + return rows + + +def changed_adr_paths() -> set[str]: + paths: set[str] = set() + base_ref = os.environ.get("ADR_SCHEMA_BASE_REF", "").strip() + diff_args = [] + if base_ref and run_git(["rev-parse", "--verify", base_ref]) is not None: + diff_args.append( + [ + "diff", + "--name-only", + "--diff-filter=AM", + f"{base_ref}...HEAD", + "--", + "docs/decision-records", + ] + ) + diff_args.extend( + [ + ["diff", "--name-only", "--diff-filter=AM", "--", "docs/decision-records"], + ["diff", "--cached", "--name-only", "--diff-filter=AM", "--", "docs/decision-records"], + ] + ) + for args in diff_args: + diff = run_git(args) + if not diff: + continue + paths.update( + line.strip().replace("\\", "/") + for line in diff.splitlines() + if line.strip().endswith(".md") and not line.strip().endswith("/README.md") + ) + return paths + + +def old_text(base_ref: str, rel_path: str) -> str | None: + return run_git(["show", f"{base_ref}:{rel_path}"]) + + +def validate_required_shape(path: Path, text: str, errors: list[str]) -> None: + for heading in LEGACY_HEADINGS: + if f"\n{heading}\n" not in f"\n{text}\n": + fail(errors, path, f"missing required heading {heading!r}") + + +def validate_living_shape(path: Path, text: str, errors: list[str]) -> None: + metadata = parse_metadata(text) + for field in LIVING_FIELDS: + if field not in metadata or not metadata[field].strip(): + fail(errors, path, f"missing living metadata field {field!r}") + + status = metadata.get("status", "").lower().split()[0] + if status and status not in VALID_STATUSES: + fail(errors, path, f"unsupported status {metadata['status']!r}") + + for field in ("date accepted", "date", "last reviewed"): + value = metadata.get(field, "") + if value and not DATE_RE.match(value): + fail(errors, path, f"{field!r} must use YYYY-MM-DD") + + if "decision-maker" in metadata: + fail(errors, path, "use 'Decision-makers', not 'Decision-maker'") + + if "\n## Changelog\n" not in f"\n{text}\n": + fail(errors, path, "missing required heading '## Changelog'") + rows = changelog_rows(text) + if not rows: + fail(errors, path, "Changelog must contain at least one data row") + for row in rows: + cells = [cell.strip() for cell in row.strip("|").split("|")] + if len(cells) != 5 or any(not cell for cell in cells): + fail(errors, path, f"Changelog row must have five non-empty cells: {row!r}") + + last_reviewed = metadata.get("last reviewed", "") + if DATE_RE.match(last_reviewed): + reviewed = dt.date.fromisoformat(last_reviewed) + cadence_days = 365 if "/repo/" in path.as_posix() else 180 + if TODAY - reviewed > dt.timedelta(days=cadence_days): + fail(errors, path, f"Last reviewed is older than {cadence_days} days") + + +def validate_terminal_links(path: Path, text: str, files_by_number: dict[str, Path], errors: list[str]) -> None: + metadata = parse_metadata(text) + status = metadata.get("status", "").lower() + if not status.startswith("superseded"): + return + + successor_body = section_body(text, "## Superseded by") + match = ADR_ID_RE.search(successor_body) + if not match: + fail(errors, path, "Superseded ADR must name a successor in '## Superseded by'") + return + + successor = files_by_number.get(match.group(1)) + if successor is None: + fail(errors, path, f"successor ADR-{match.group(1)} does not exist") + return + + successor_text = successor.read_text(encoding="utf-8") + current_id = f"ADR-{path.name[:4]}" + if current_id not in section_body(successor_text, "## Supersedes"): + fail(errors, path, f"successor {successor.name} does not reciprocate Supersedes link") + + +def validate_changed_adr(path: Path, rel_path: str, text: str, errors: list[str]) -> None: + base_ref = os.environ.get("ADR_SCHEMA_BASE_REF", "").strip() + previous = old_text(base_ref, rel_path) if base_ref else None + + validate_living_shape(path, text, errors) + + new_rows = changelog_rows(text) + old_rows = changelog_rows(previous) if previous is not None else [] + if old_rows and new_rows[: len(old_rows)] != old_rows: + fail(errors, path, "Changelog rows must be append-only") + appended_rows = new_rows[len(old_rows) :] + + if previous is None: + if not appended_rows: + fail(errors, path, "new ADR must include an initial Changelog row") + return + + old_body = body_without_changelog(previous) + new_body = body_without_changelog(text) + old_metadata = parse_metadata(previous) + new_metadata = parse_metadata(text) + body_changed = old_body != new_body + review_changed = old_metadata.get("last reviewed") != new_metadata.get("last reviewed") + + if (body_changed or review_changed) and not appended_rows: + fail(errors, path, "ADR body or Last reviewed changed without a new Changelog row") + + old_status = old_metadata.get("status", "").lower().split()[0] + if old_status in {"superseded", "obsolete"} and body_changed: + fail(errors, path, f"terminal {old_status!r} ADR body must stay frozen") + + +def main() -> None: + errors: list[str] = [] + files = adr_files() + files_by_number = {path.name[:4]: path for path in files} + changed = changed_adr_paths() + + for path in files: + text = path.read_text(encoding="utf-8") + rel_path = path.relative_to(ROOT).as_posix() + validate_required_shape(path, text, errors) + validate_terminal_links(path, text, files_by_number, errors) + if rel_path in changed: + validate_changed_adr(path, rel_path, text, errors) + + if errors: + joined = "\n - ".join(errors) + raise SystemExit(f"ADR schema check failed:\n - {joined}") + + scope = f"{len(changed)} changed ADR(s)" if changed else "static ADR shape" + print(f"ADR schema check passed: {len(files)} ADR(s), {scope}") + + +if __name__ == "__main__": + main()