diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 620f186..a786b9e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,9 +30,15 @@ jobs: git fetch --depth=50 origin "${{ gitea.ref }}" git checkout FETCH_HEAD + - name: Fetch base branch for ADR immutability + run: git fetch --depth=1 origin main + - name: Install checker dependencies run: pip install --quiet pyyaml + - name: ADR immutability (no edits to accepted records) + run: python scripts/check_adrs.py --base origin/main + - name: Documentation rules (links, glossary, readability) run: python scripts/check_docs.py diff --git a/AGENTS.md b/AGENTS.md index 2c6e9b3..1036ef2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,12 @@ Status / Context / Options / Decision / Consequences format. Start from When a change alters a boundary, contract, or trade-off, add an ADR (copy [`docs/adr/TEMPLATE.md`](docs/adr/TEMPLATE.md), take the next number, update the index) and cross-link it from the affected design doc. A later reversal gets a -new ADR that supersedes the old one — never edit an Accepted record. +new ADR that supersedes the old one — **never edit an Accepted record**. + +**Immutable:** every `docs/adr/ADR-*.md` file is frozen once on `main`. CI rejects +any change that modifies an existing ADR. Updates go in a new numbered ADR only. +The index ([`docs/adr/README.md`](docs/adr/README.md)) and [`TEMPLATE.md`](docs/adr/TEMPLATE.md) +may still change; decision bodies do not. ## Runtime & operations (sim environment) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a5b35b..88164d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,7 +117,7 @@ Default review voice = caveman-full: - [ ] Tests added or updated; `pytest tests/ -q` passes locally - [ ] `docker build` in `simulator/` succeeds (test stage included) - [ ] Documentation updated if behaviour or boundaries changed -- [ ] **ADR added** if the change alters an architectural boundary, contract, or trade-off (see [`docs/adr/`](docs/adr/)) +- [ ] **ADR added** if the change alters an architectural boundary, contract, or trade-off (see [`docs/adr/`](docs/adr/)) — never edit an existing `ADR-*.md`; supersede with a new number - [ ] English only; no identifying references - [ ] Conventional commit message diff --git a/docs/adr/ADR-0001-no-swarm-orchestrator.md b/docs/adr/ADR-0001-no-swarm-orchestrator.md index 4b0a471..cb0a249 100644 --- a/docs/adr/ADR-0001-no-swarm-orchestrator.md +++ b/docs/adr/ADR-0001-no-swarm-orchestrator.md @@ -17,7 +17,7 @@ Partitions are the normal case, not the exception. | Option | Pros | Cons | | --- | --- | --- | | A — Cluster orchestrator spanning the swarm | Familiar tooling | Assumes stable membership; a partition stalls coordination; single point of failure in the air | -| B — Each drone autonomous, coordinating through exchanged data | Survives partitions; no air-side control plane to fail | No global view; behavior must be derivable from local state | +| B — Each drone autonomous, coordinating through exchanged data | Survives partitions; no air-side control plane to fail | No global view; behaviour must be derivable from local state | ## Decision diff --git a/docs/adr/ADR-0002-one-storage-format.md b/docs/adr/ADR-0002-one-storage-format.md index a583455..2c01c16 100644 --- a/docs/adr/ADR-0002-one-storage-format.md +++ b/docs/adr/ADR-0002-one-storage-format.md @@ -6,13 +6,9 @@ Accepted (2026-07-08) ## Context -Data lives across [storage floors](../00-glossary.md#t0--hot) T0–T4. Primary persistent -stores are [T1](../00-glossary.md#t1--warm) (drone NVMe in flight) and -[T3](../00-glossary.md#t3--warehouse) (ground warehouse after landing); -[T0](../00-glossary.md#t0--hot) is the in-memory hot window, -[T2](../00-glossary.md#t2--shared) optional on-board derived replication, -[T4](../00-glossary.md#t4--dev) dev/sim slices. If each floor used a different -format or layout, offload would need transformation code, +Data lives in three places: on the drone's NVMe in flight (T1), in the ground +warehouse after landing (T3), and in the dev/simulation environment. If each +tier used a different format or layout, offload would need transformation code, schemas would drift, and a query proven on the bench would not run unchanged on flight data. diff --git a/docs/adr/ADR-0003-sync-derived-state-only.md b/docs/adr/ADR-0003-sync-derived-state-only.md index 5ce09ee..b473cf5 100644 --- a/docs/adr/ADR-0003-sync-derived-state-only.md +++ b/docs/adr/ADR-0003-sync-derived-state-only.md @@ -25,7 +25,7 @@ new derived data triggers downstream action through hooks; nothing polls asking - Air bandwidth scales with the number of peers and the broadcast rate, not with sensor resolution. -- The [source of truth](../00-glossary.md#source-of-truth) is the only place with the full picture; in-flight decisions use +- The warehouse is the only place with the full picture; in-flight decisions use the local window plus peer broadcasts. - Broadcast staleness per peer becomes a first-class health signal ([`../07-observability.md`](../07-observability.md)). diff --git a/docs/adr/README.md b/docs/adr/README.md index 8985ee6..c4d8e8b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,14 +27,14 @@ decisions specific to the platform are ADRs and belong here. | [ADR-0005](ADR-0005-wireguard-beneath-ssh.md) | WireGuard beneath SSH for the mesh transport | Accepted | | [ADR-0006](ADR-0006-iac-boundaries.md) | IaC split: Ansible hosts, Terraform ground, Flux ground-only, fleet manifest for drones | Accepted | | [ADR-0007](ADR-0007-non-root-image.md) | Run the simulator image as a non-root user | Accepted | -| [ADR-0008](ADR-0008-runtime-data-paths.md) | Shared [`var/t1`](../00-glossary.md#t1--warm) / [`var/t3`](../00-glossary.md#t3--warehouse) runtime paths; state out of Git | Accepted | +| [ADR-0008](ADR-0008-runtime-data-paths.md) | Shared `var/t1` / `var/t3` runtime paths; state out of Git | Accepted | | [ADR-0009](ADR-0009-bounded-lake-scans.md) | Bound observability lake scans to a flight window and cache | Accepted | ## Writing a new ADR 1. Copy [`TEMPLATE.md`](TEMPLATE.md) to `ADR-NNNN-short-slug.md` (next free number). -2. Context, Options, Decision, Consequences. +2. Fill in Context, Options (if more than one was real), Decision, Consequences. 3. Add a row to the index above. 4. Cross-link the ADR from the design doc it affects (and vice versa). -Accepted = immutable. Reversal → new ADR supersedes. Never edit Accepted. +Keep the tone laconic: state the decision and why, skip the narrative. diff --git a/docs/improvement/ci-rules.md b/docs/improvement/ci-rules.md index 2c22722..13929b1 100644 --- a/docs/improvement/ci-rules.md +++ b/docs/improvement/ci-rules.md @@ -18,6 +18,7 @@ Tracked from [issue #1](https://git.produktor.io/eSlider/swarm-house/issues/1) a | Glossary fragments | error | All `00-glossary.md#…` links use anchors that exist in the glossary | | Section vs term links | warning | Links to glossary *section* headers (`#swarm--robotics`, …) are listed; prefer `###` term anchors where they exist | | Bare cross-refs | error | Prose cross-references to numbered docs without a proper markdown link | +| ADR immutability | error | No modifications to existing `docs/adr/ADR-*.md` (compare `origin/main`); add new ADR to supersede | Warnings are printed in the job log but do not fail CI. Errors exit non-zero and block merge. @@ -46,6 +47,7 @@ Docs-only changes therefore still get link and glossary validation on `main`; th ```bash python scripts/check_docs.py +python scripts/check_adrs.py --base origin/main python scripts/check_docs.py --warnings-as-errors # treat section-link warnings as failures ``` diff --git a/scripts/check_adrs.py b/scripts/check_adrs.py new file mode 100644 index 0000000..f27db7f --- /dev/null +++ b/scripts/check_adrs.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Reject modifications to accepted Architecture Decision Records.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ADR_PREFIX = "docs/adr/ADR-" + + +def _git(*args: str) -> str: + return subprocess.check_output( + ["git", *args], + cwd=ROOT, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + + +def _exists_on(base: str, path: str) -> bool: + try: + _git("cat-file", "-e", f"{base}:{path}") + return True + except subprocess.CalledProcessError: + return False + + +def find_modified_adrs(base: str) -> list[str]: + try: + diff_out = _git("diff", "--name-only", f"{base}...HEAD", "--", "docs/adr/") + except subprocess.CalledProcessError: + return [] + + modified: list[str] = [] + for rel in diff_out.splitlines(): + if not rel.startswith(ADR_PREFIX) or not rel.endswith(".md"): + continue + if _exists_on(base, rel): + modified.append(rel) + return sorted(modified) + + +def run_check(base: str = "origin/main") -> int: + modified = find_modified_adrs(base) + if modified: + print("Immutable ADR violation — accepted records cannot be edited:") + for path in modified: + print(f" error: {path}") + print( + "\nReversal or refinement → new ADR-NNNN file. " + "Never edit an existing ADR-*.md." + ) + return 1 + + print(f"ADR immutability OK (base {base})") + return 0 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base", + default="origin/main", + help="Git ref to compare against (default: origin/main)", + ) + args = parser.parse_args() + sys.exit(run_check(base=args.base)) + + +if __name__ == "__main__": + main() diff --git a/simulator/tests/test_check_adrs.py b/simulator/tests/test_check_adrs.py new file mode 100644 index 0000000..af90ee4 --- /dev/null +++ b/simulator/tests/test_check_adrs.py @@ -0,0 +1,70 @@ +"""Tests for scripts/check_adrs.py — ADR immutability gate.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[2] / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from check_adrs import find_modified_adrs, run_check # noqa: E402 + + +def _git(cwd: Path, *args: str) -> None: + subprocess.check_call(["git", *args], cwd=cwd, stdout=subprocess.DEVNULL) + + +def test_no_modified_adrs_on_current_branch() -> None: + assert run_check(base="origin/main") == 0 + + +def test_detects_adr_edit_in_sandbox(tmp_path: Path) -> None: + _git(tmp_path, "init") + _git(tmp_path, "config", "user.email", "ci@test") + _git(tmp_path, "config", "user.name", "ci") + adr = tmp_path / "docs/adr" + adr.mkdir(parents=True) + (adr / "ADR-0001-test.md").write_text("v1\n") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-m", "add adr") + _git(tmp_path, "branch", "-M", "main") + (adr / "ADR-0001-test.md").write_text("v2\n") + _git(tmp_path, "checkout", "-b", "feature") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-m", "edit adr") + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr("check_adrs.ROOT", tmp_path) + try: + assert find_modified_adrs("main") == ["docs/adr/ADR-0001-test.md"] + assert run_check(base="main") == 1 + finally: + monkeypatch.undo() + + +def test_new_adr_allowed(tmp_path: Path) -> None: + _git(tmp_path, "init") + _git(tmp_path, "config", "user.email", "ci@test") + _git(tmp_path, "config", "user.name", "ci") + adr = tmp_path / "docs/adr" + adr.mkdir(parents=True) + (adr / "ADR-0001-test.md").write_text("v1\n") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-m", "add adr") + _git(tmp_path, "branch", "-M", "main") + (adr / "ADR-0002-new.md").write_text("new\n") + _git(tmp_path, "checkout", "-b", "feature") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-m", "add adr 2") + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr("check_adrs.ROOT", tmp_path) + try: + assert find_modified_adrs("main") == [] + assert run_check(base="main") == 0 + finally: + monkeypatch.undo()