diff --git a/notes/dev/plans/2026-06-24-rack-power.md b/notes/dev/plans/2026-06-24-rack-power.md
new file mode 100644
index 0000000..bc00e35
--- /dev/null
+++ b/notes/dev/plans/2026-06-24-rack-power.md
@@ -0,0 +1,553 @@
+# Rack Power (Phase 2) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add power-distribution data (`power:` feeds + PDU files) to the rack pipeline, validate it, and render a mermaid power graph on the generated rack page — reusing every Phase 1 mechanism.
+
+**Architecture:** Extend the existing `scripts/gen_rack.py` with `validate_power` (rule 3 from the spec) and `render_power` (a `flowchart LR` with the outlet number as the edge label); insert a `## Power` section into `render_page`. PDU files are 0U `left`/`right` items that Phase 1 already renders as side-rails and `gen_overview.py` already lists. Pull the mermaid superfences fence forward into `mkdocs.yml` so the graph renders.
+
+**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
+
+**Spec:** `notes/dev/specs/2026-06-24-rack-power-design.md`.
+
+## Global Constraints
+
+- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy `gen_rack.py`/`gen_overview.py` style). No randomness/time in generated output.
+- `re` and `yaml` are already imported in `scripts/gen_rack.py`; do not add new imports.
+- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure (existing Phase 1 behaviour).
+- Generated files keep the existing `_Auto-generated … do not edit by hand_` banner.
+- PDU files are **0U**: `rack_face: left|right`, **no** `rack_u`/`u_height`, and a positive-int `outlets`.
+- Power data added here is **provisional placeholder data** (like the existing `mfNN` U positions), not real values.
+- The Makefile `docs-check` and CI drift step already diff the whole `docs/infrastructure/racks/` dir — **do not edit** `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml`.
+- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
+
+---
+
+### Task 1: `validate_power` — rule 3 validation (TDD)
+
+Add PDU lookup + power-feed validation and wire it into `generate`. This task is testable on validation alone (no rendering needed).
+
+**Files:**
+- Modify: `scripts/gen_rack.py` (add `_pdu_index`, `validate_power`; call in `generate`)
+- Modify: `tests/test_gen_rack.py` (append tests)
+
+**Interfaces:**
+- Consumes: `SchemaError`, the `item()`/`_write_item` test helpers, `generate`.
+- Produces:
+ - `_pdu_index(items: list[dict]) -> dict[str, dict]` — `{hostname: fm}` for `kind == "pdu"` items.
+ - `validate_power(items: list[dict]) -> None` — raises `SchemaError` on a bad PDU `outlets` declaration or a bad `power` feed.
+
+- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
+
+```python
+def test_validate_power_accepts_valid_feed():
+ items = [
+ item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
+ item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=[{"pdu": "pdu01", "outlet": 1}]),
+ ]
+ gen_rack.validate_power(items)
+
+
+def test_validate_power_rejects_unknown_pdu():
+ items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=[{"pdu": "ghost", "outlet": 1}])]
+ with pytest.raises(gen_rack.SchemaError):
+ gen_rack.validate_power(items)
+
+
+def test_validate_power_rejects_non_pdu_target():
+ items = [
+ item(hostname="sw01", kind="switch", rack_u=1, u_height=1,
+ rack_face="front"),
+ item(hostname="mf00", rack_u=2, u_height=1, rack_face="front",
+ power=[{"pdu": "sw01", "outlet": 1}]),
+ ]
+ with pytest.raises(gen_rack.SchemaError):
+ gen_rack.validate_power(items)
+
+
+def test_validate_power_rejects_outlet_over_count():
+ items = [
+ item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
+ item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=[{"pdu": "pdu01", "outlet": 9}]),
+ ]
+ with pytest.raises(gen_rack.SchemaError):
+ gen_rack.validate_power(items)
+
+
+def test_validate_power_rejects_outlet_zero():
+ items = [
+ item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
+ item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=[{"pdu": "pdu01", "outlet": 0}]),
+ ]
+ with pytest.raises(gen_rack.SchemaError):
+ gen_rack.validate_power(items)
+
+
+def test_validate_power_rejects_malformed_entry():
+ items = [
+ item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
+ item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=["pdu01"]),
+ ]
+ with pytest.raises(gen_rack.SchemaError):
+ gen_rack.validate_power(items)
+
+
+def test_validate_power_rejects_pdu_without_outlets():
+ items = [
+ item(hostname="pdu01", kind="pdu", rack_face="left"),
+ item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=[{"pdu": "pdu01", "outlet": 1}]),
+ ]
+ with pytest.raises(gen_rack.SchemaError):
+ gen_rack.validate_power(items)
+
+
+def test_generate_returns_1_on_bad_power_ref(tmp_path):
+ hw = tmp_path / "hardware"
+ out = tmp_path / "out"
+ hw.mkdir()
+ _write_item(
+ hw,
+ "mf00",
+ "---\nhostname: mf00\nkind: server\nstatus: in-use\n"
+ "rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
+ "power:\n - { pdu: ghost, outlet: 1 }\n---\n",
+ )
+ rc = gen_rack.generate(hw, out)
+ assert rc == 1
+ assert not (out / "rack01.md").exists()
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pytest tests/test_gen_rack.py -q`
+Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'validate_power'`.
+
+- [ ] **Step 3: Add `_pdu_index` and `validate_power` after `check_overlaps` in `scripts/gen_rack.py`**
+
+```python
+def _pdu_index(items: list[dict]) -> dict[str, dict]:
+ """Map hostname -> frontmatter for every kind:pdu item."""
+ return {
+ fm.get("hostname"): fm
+ for fm in items
+ if fm.get("kind") == "pdu"
+ }
+
+
+def validate_power(items: list[dict]) -> None:
+ """Validate PDU outlet declarations and `power` feeds within one rack.
+
+ Rule 3: every power[].pdu resolves to a kind:pdu file, and outlet is
+ within that PDU's `outlets` count.
+ """
+ pdus = _pdu_index(items)
+ for name, fm in pdus.items():
+ outlets = fm.get("outlets")
+ if not isinstance(outlets, int) or outlets < 1:
+ raise SchemaError(
+ f"{name}: kind:pdu must declare a positive integer 'outlets'"
+ )
+ for fm in items:
+ feeds = fm.get("power")
+ if feeds is None:
+ continue
+ name = fm.get("hostname", "?")
+ if not isinstance(feeds, list):
+ raise SchemaError(f"{name}: power must be a list")
+ for feed in feeds:
+ if not isinstance(feed, dict):
+ raise SchemaError(f"{name}: power entry must be a mapping")
+ pdu = feed.get("pdu")
+ outlet = feed.get("outlet")
+ if not isinstance(pdu, str) or not pdu:
+ raise SchemaError(f"{name}: power entry needs a non-empty 'pdu'")
+ if not isinstance(outlet, int):
+ raise SchemaError(
+ f"{name}: power entry for {pdu} needs an integer 'outlet'"
+ )
+ target = pdus.get(pdu)
+ if target is None:
+ raise SchemaError(
+ f"{name}: power pdu={pdu!r} is not a known kind:pdu file"
+ )
+ count = target["outlets"]
+ if outlet < 1 or outlet > count:
+ raise SchemaError(
+ f"{name}: outlet {outlet} out of range 1..{count} on {pdu}"
+ )
+```
+
+- [ ] **Step 4: Wire `validate_power` into `generate` in `scripts/gen_rack.py`**
+
+Change the overlap loop so it also validates power. Replace:
+
+```python
+ if not errors: # only check overlaps once placements are individually valid
+ for rack, ritems in racks.items():
+ try:
+ check_overlaps(ritems)
+ except SchemaError as e:
+ errors.append(f"{rack}: {e}")
+```
+
+with:
+
+```python
+ if not errors: # only check overlaps once placements are individually valid
+ for rack, ritems in racks.items():
+ try:
+ check_overlaps(ritems)
+ validate_power(ritems)
+ except SchemaError as e:
+ errors.append(f"{rack}: {e}")
+```
+
+- [ ] **Step 5: Run to verify pass**
+
+Run: `pytest tests/test_gen_rack.py -q`
+Expected: PASS (all prior tests + 8 new).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add scripts/gen_rack.py tests/test_gen_rack.py
+git commit -m "feat(rack): validate power feeds against PDU outlets"
+```
+
+---
+
+### Task 2: `render_power` + page section (TDD)
+
+**Files:**
+- Modify: `scripts/gen_rack.py` (add `_node_id`, `render_power`; edit `render_page`)
+- Modify: `tests/test_gen_rack.py` (append tests)
+
+**Interfaces:**
+- Consumes: `_pdu_index` (Task 1), `render_page`, `generate`.
+- Produces:
+ - `_node_id(name: str) -> str` — hostname with non-alphanumeric chars replaced by `_`.
+ - `render_power(rack: str, items: list[dict]) -> str` — a fenced `mermaid` `flowchart LR` ending in a newline, or `""` when no item has a `power` feed.
+
+- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
+
+```python
+def test_render_power_has_nodes_and_edge_labels():
+ items = [
+ item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
+ item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=[{"pdu": "pdu01", "outlet": 3}]),
+ ]
+ out = gen_rack.render_power("rack01", items)
+ assert "```mermaid" in out
+ assert "flowchart LR" in out
+ assert "pdu01" in out
+ assert "8 outlets" in out
+ assert "outlet 3" in out
+ assert "mf00" in out
+
+
+def test_render_power_redundant_device_has_two_edges():
+ items = [
+ item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
+ item(hostname="pdu02", kind="pdu", rack_face="right", outlets=8),
+ item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=[{"pdu": "pdu01", "outlet": 1},
+ {"pdu": "pdu02", "outlet": 1}]),
+ ]
+ out = gen_rack.render_power("rack01", items)
+ assert out.count("-->|outlet") == 2
+
+
+def test_render_power_empty_when_no_feeds():
+ items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
+ assert gen_rack.render_power("rack01", items) == ""
+
+
+def test_render_power_is_deterministic():
+ a = item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8)
+ b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
+ power=[{"pdu": "pdu01", "outlet": 2}])
+ c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
+ power=[{"pdu": "pdu01", "outlet": 1}])
+ assert gen_rack.render_power("rack01", [a, b, c]) == \
+ gen_rack.render_power("rack01", [c, b, a])
+
+
+def test_generate_includes_power_section(tmp_path):
+ hw = tmp_path / "hardware"
+ out = tmp_path / "out"
+ hw.mkdir()
+ _write_item(
+ hw,
+ "pdu01",
+ "---\nhostname: pdu01\nkind: pdu\nstatus: in-use\n"
+ "rack: rack01\nrack_face: left\noutlets: 8\n---\n",
+ )
+ _write_item(
+ hw,
+ "mf00",
+ "---\nhostname: mf00\nkind: server\nstatus: in-use\n"
+ "rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
+ "power:\n - { pdu: pdu01, outlet: 1 }\n---\n",
+ )
+ rc = gen_rack.generate(hw, out)
+ assert rc == 0
+ page = (out / "rack01.md").read_text()
+ assert "## Power" in page
+ assert "```mermaid" in page
+ assert "outlet 1" in page
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pytest tests/test_gen_rack.py -q`
+Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_power'`.
+
+- [ ] **Step 3: Add `_node_id` and `render_power` after `render_svg` in `scripts/gen_rack.py`**
+
+```python
+def _node_id(name: str) -> str:
+ """A mermaid-safe node id derived from a hostname."""
+ return re.sub(r"[^0-9A-Za-z]", "_", str(name))
+
+
+def render_power(rack: str, items: list[dict]) -> str:
+ """Return a mermaid power-distribution flowchart, or '' if no feeds."""
+ powered = [fm for fm in items if fm.get("power")]
+ if not powered:
+ return ""
+ pdus = _pdu_index(items)
+
+ edges: list[tuple[str, int, str]] = []
+ for fm in powered:
+ device = fm.get("hostname", "?")
+ for feed in fm["power"]:
+ edges.append((feed["pdu"], feed["outlet"], device))
+ edges.sort()
+
+ lines: list[str] = ["```mermaid", "flowchart LR"]
+ for pdu in sorted(pdus):
+ outlets = pdus[pdu].get("outlets")
+ lines.append(f' {_node_id(pdu)}["{pdu}
{outlets} outlets"]')
+ devices = sorted(
+ powered,
+ key=lambda i: (
+ i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
+ i.get("hostname", ""),
+ ),
+ )
+ for fm in devices:
+ device = fm.get("hostname", "?")
+ lines.append(f' {_node_id(device)}["{device}"]')
+ for pdu, outlet, device in edges:
+ lines.append(
+ f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
+ )
+ lines.append("```")
+ return "\n".join(lines) + "\n"
+```
+
+- [ ] **Step 4: Insert the `## Power` section in `render_page` in `scripts/gen_rack.py`**
+
+In `render_page`, replace this block:
+
+```python
+ lines.append(f"")
+ lines.append("")
+ lines.append("## Occupancy")
+```
+
+with:
+
+```python
+ lines.append(f"")
+ lines.append("")
+ power = render_power(rack, items)
+ if power:
+ lines.append("## Power")
+ lines.append("")
+ lines.append(power.rstrip())
+ lines.append("")
+ lines.append("## Occupancy")
+```
+
+- [ ] **Step 5: Run to verify pass**
+
+Run: `pytest tests/test_gen_rack.py -q`
+Expected: PASS (all prior tests + 5 new).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add scripts/gen_rack.py tests/test_gen_rack.py
+git commit -m "feat(rack): render mermaid power graph into the rack page"
+```
+
+---
+
+### Task 3: Enable mermaid, populate provisional power data, regenerate
+
+**Files:**
+- Modify: `mkdocs.yml` (mermaid superfences fence)
+- Create: `docs/hardware/pdu01.md`, `docs/hardware/pdu02.md`
+- Modify: `docs/hardware/mf00.md`..`mf04.md` (add `power:`)
+- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
+
+**Interfaces:**
+- Consumes: `python3 scripts/gen_rack.py` / `make docs-index`, `mkdocs build --strict`, `make docs-check`.
+
+> **Operator note — provisional data.** The PDU placements and outlet assignments below are placeholders proving the feature, matching the existing fictional `mfNN` U positions. Replace with real values when known; `validate_power` will reject dangling/over-count feeds loudly.
+
+- [ ] **Step 1: Enable the mermaid custom fence in `mkdocs.yml`**
+
+In `mkdocs.yml`, replace the bare superfences line:
+
+```yaml
+ - pymdownx.superfences
+```
+
+with:
+
+```yaml
+ - pymdownx.superfences:
+ custom_fences:
+ - name: mermaid
+ class: mermaid
+ format: !!python/name:pymdownx.superfences.fence_code_format
+```
+
+- [ ] **Step 2: Create the two PDU files**
+
+Create `docs/hardware/pdu01.md`:
+
+```markdown
+---
+hostname: pdu01
+kind: pdu
+status: in-use
+rack: rack01
+rack_face: left
+outlets: 8
+---
+
+## Notes
+
+- Provisional placeholder PDU (left rail). Outlet assignments are not yet real.
+```
+
+Create `docs/hardware/pdu02.md`:
+
+```markdown
+---
+hostname: pdu02
+kind: pdu
+status: in-use
+rack: rack01
+rack_face: right
+outlets: 8
+---
+
+## Notes
+
+- Provisional placeholder PDU (right rail). Provides redundant feeds.
+```
+
+- [ ] **Step 3: Add `power:` to the five host files**
+
+In `docs/hardware/mf00.md`, add to the frontmatter (before the closing `---`) — note mf00 has **two** feeds (the redundant demonstration):
+
+```yaml
+power:
+ - { pdu: pdu01, outlet: 1 }
+ - { pdu: pdu02, outlet: 1 }
+```
+
+In `docs/hardware/mf01.md` add:
+
+```yaml
+power:
+ - { pdu: pdu01, outlet: 2 }
+```
+
+In `docs/hardware/mf02.md` add:
+
+```yaml
+power:
+ - { pdu: pdu01, outlet: 3 }
+```
+
+In `docs/hardware/mf03.md` add:
+
+```yaml
+power:
+ - { pdu: pdu01, outlet: 4 }
+```
+
+In `docs/hardware/mf04.md` add:
+
+```yaml
+power:
+ - { pdu: pdu01, outlet: 5 }
+```
+
+- [ ] **Step 4: Regenerate all indices and rack artifacts**
+
+Run: `make docs-index`
+Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing pdu01/pdu02 under "PDUs"); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (7 item(s))`.
+
+- [ ] **Step 5: Confirm the generated page has a rendered power graph and PDU rails**
+
+Run: `grep -c "outlet" docs/infrastructure/racks/rack01.md`
+Expected: ≥ 6 (one edge per feed: mf00 ×2, mf01..mf04 ×1).
+
+Run: `grep -c "pdu0" docs/infrastructure/racks/rack01-elevation.svg`
+Expected: ≥ 2 (pdu01 + pdu02 drawn as side-rails).
+
+- [ ] **Step 6: Run the full test suite**
+
+Run: `make test`
+Expected: PASS (all tests).
+
+- [ ] **Step 7: Build the site strictly**
+
+Run: `mkdocs build --strict`
+Expected: build succeeds with no warnings-as-errors; `site/infrastructure/racks/rack01/index.html` contains a `
` (or ``) block rather than a plain `` fence. + +Verify: `grep -c "mermaid" site/infrastructure/racks/rack01/index.html` +Expected: ≥ 1. + +- [ ] **Step 8: Confirm the drift guard is satisfied** + +Run: `make docs-check` +Expected: exit 0 — committed artifacts match a fresh regeneration. + +- [ ] **Step 9: Commit** + +```bash +git add mkdocs.yml docs/hardware/ docs/infrastructure/racks/ +git commit -m "feat(rack): enable mermaid, populate provisional power data" +``` + +--- + +## Self-Review + +**Spec coverage (`2026-06-24-rack-power-design.md`):** +- `power:` frontmatter on devices — Task 3 (populate); validated Task 1. ✔ +- PDU files (`kind: pdu`, `outlets`, 0U `left`/`right`) — Task 3; outlets validated Task 1. ✔ +- Validation rule 3 (pdu resolves to kind:pdu; outlet in range; pdu declares outlets) — Task 1 (`validate_power`), wired into `generate`. ✔ +- Mermaid power graph, outlet as edge label, redundancy as two edges, omit-when-empty, deterministic — Task 2 (`render_power`), inserted in `render_page`. ✔ +- Node-id sanitization — Task 2 (`_node_id`). ✔ +- Mermaid pulled forward in `mkdocs.yml` — Task 3 Step 1. ✔ +- No Makefile/CI/overview_config changes — honored (Global Constraints); drift covered by existing `racks/` diff — Task 3 Steps 4/8. ✔ +- Provisional data (pdu01 left → mf00..mf04 o1..o5; pdu02 right → mf00 o1) — Task 3 Steps 2–3. ✔ + +**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The only operator-judgement item is provisional power values, explicitly bounded and guarded by `validate_power`. + +**Type consistency:** `_pdu_index` → `dict[str, dict]`; `validate_power`/`check_overlaps` → `None` (raise `SchemaError`); `render_power`/`render_page`/`render_svg`/`_node_id` → `str`; `generate` → `int` (0/1). `validate_power(ritems)` is called per-rack alongside `check_overlaps(ritems)`. `render_power` consumes `_pdu_index` and feeds `render_page`. Names match across tasks and tests.