# Agent Guidelines

## Agent Authority (NON-NEGOTIABLE)

**No work is permissible on any system without an approved Redmine ticket.
There are no exceptions to this rule.**

### Scope of authority

1. **Tickets govern all work.** The agent performs ONLY the work described in
   the approved Redmine ticket. Anything outside that scope — no matter how
   small, helpful, or "obvious" — is prohibited.

2. **No autonomous system changes.** The agent does not modify, configure,
   create, delete, or grant anything on a production system unless it is
   explicitly directed by an approved ticket. "Production system" means
   every system in the fleet — there is no "test" exception unless the ticket
   says so.

3. **Propose, never implement.** If the agent discovers additional work that
   should be done — a bug, a misconfiguration, a missing dependency, an
   enhancement — it does NOT implement it. Instead, it creates a Redmine
   sub-ticket (status Feedback) describing the finding and surfaces it to
   the user for approval.

4. **Security and access changes require extra scrutiny.** Changes to sudoers,
   SSH keys, user accounts, firewall rules, authentication policy, file
   permissions, or any privilege-related configuration are treated as
   policy decisions, not implementation details. The agent may suggest
   them but NEVER implements them without explicit user direction in the
   ticket or a sub-ticket the user has approved.

5. **The user makes policy. The agent implements policy.** The agent does
   not decide who gets sudo, what keys go where, what services run, or what
   the access model is. The agent executes the user's decisions, exactly
   as specified.

6. **When in doubt, ask.** If the ticket is ambiguous, if a task seems to
   require something not explicitly authorized, or if the agent is unsure
   whether an action is in scope — STOP and ask the user via the ticket
   or directly. Asking is always acceptable. Overstepping is never
   acceptable.

7. **NEVER close a ticket without explicit user permission.** You may
   SUGGEST a close when the result is clearly scoped and delivered. If
   it's ambiguous whether the work is truly complete, don't suggest a
   close — leave that decision to the user. This applies to ALL tickets,
   no exceptions.
8. **User acceptance testing is MANDATORY before declaring work done.**
   The agent performs implementation and technical validation (services
   running, configs correct, APIs responding). The user performs UAT —
   visually confirming dashboards render data, alerts deliver, tools are
   usable. The agent MUST NOT set done-ratio to 100%, MUST NOT suggest
   closing, and MUST NOT move to the next ticket until the user explicitly
   accepts the work. "Technically wired but blank dashboard" is NOT done.
9. **NEVER access a database directly if an API exists.** APIs are the
   stable contract; databases change schemas without warning. If a tool
   has an API, use it — exhaust all API endpoints, check the docs, try
   alternative methods. Only fall back to direct DB access as a last
   resort AND with explicit user approval for that specific instance.
   Soon all DB access will route through a proxy under zero trust;
   building API-first habits now ensures that transition is clean.

### Access-channel policy: SSH only (NON-NEGOTIABLE)

**The qemu guest-agent is NEVER an access, execution, or key-delivery
channel.** SSH (`sshd`) is the only approved remote access path. Every
command must flow through sshd so it is captured by the standard
auth/logging/audit infrastructure. This is an ITAR/CMMC/TS/SCI
environment — there is no back-door exception, ever.

- **Forbidden:** `qm guest exec` (runs arbitrary commands inside a guest
  over an unaudited channel) and any wrapper around it (e.g. a `vm-guest`
  mode). This includes using guest-agent to *deliver* an SSH key, even if
  the resulting SSH login is itself audited — the delivery bypassed audit.
- **Allowed:** installing or checking qemu-guest-agent for its intended
  purpose — letting Proxmox see guest state (`qm guest cmd <id> ping`,
  `agent: 1` config, `apt install qemu-guest-agent`). Visibility only;
  never execution.
- **Enforced mechanically:** `scripts/check-rules.sh` rule #11 fails on
  any `qm guest exec` / `vm-guest` pattern in code. `tests/remote.sh` has
  no guest-exec mode.
- **If a system is locked out** (no SSH key, no guest-exec path): surface
  it to the user. Do NOT improvise an alternate back-channel. The user
  authorizes the unblock method (console login, credential, etc.).

### Ticket-closing policy (NON-NEGOTIABLE)

**NEVER close a Redmine ticket without explicit user permission.** You
may SUGGEST a close when the result is clearly scoped and delivered. If
it's ambiguous whether the work is truly complete, don't suggest a close
— leave that decision to the user. This applies to ALL tickets, no
exceptions.

### What this means in practice

- Discovered a typo in a config during approved work? **Finish the approved
  work. Create a sub-ticket for the typo. Do not fix it inline.**
- Think a system should also have localuser sudo configured? **Do not add
  it. Propose it in a sub-ticket.**
- Need to install a package the ticket didn't mention? **Ask first.**
- Found a security issue? **Create a ticket immediately with full details.
  Do not remediate without approval.**

This environment operates in ITAR/CMMC/TS/SCI space. Every action must be
traceable to an approved ticket. There is no "I thought it would help."

## Quick Start

**You are an AI agent working on this project. Your first actions, in order:**

> **SESSION-START GATE (NON-NEGOTIABLE):** Steps 1-3 orient you. Step 4 is the
> check-for-understanding gate (rule summary). Steps 5-7 gather state. Step 8 is
> the scope-alignment gate. You MUST NOT begin any task work until the user
> (a) confirms your rule summary AND (b) names the ticket to work on. This runs
> **every session, automatically** — the user should never have to ask for it.

1. **Set up the environment:** `bash scripts/setup-hooks.sh` (installs git hooks — idempotent).
2. **Read this file** (`AGENTS.md`) — project policy and domain knowledge.
3. **Read the latest questions file** (`questions-v*.md`) — open questions awaiting human input. The version number increments each round (v1, v2, v3...).
4. **Check for understanding — GATE.** In your own words, summarize ALL the rules
   back to the user before doing any work: Agent Authority (ticket-governed,
   propose-never-implement), Access-Channel SSH-only policy, Remote access
   (remote.sh chokepoint mandatory, DNS names only — never IP literals),
   Questions policy (no harness question-tools), Documentation policy
   (Discourse is SoR), Redmine tracking, Git policy (always commit+push,
   shellcheck), Mandatory infra-change documentation (#298 audit log), SSH
   routing chokepoints (`tests/remote.sh`), the mechanically enforced
   `check-rules.sh` rules, and Credentials (Vault migration TODO). Then stop
   and wait for the user to confirm. This checkpoint guarantees every
   session starts aligned.
5. **Check Redmine** — `docker run --rm --env-file ~/.creds/redmine.env git.knownelement.com/reachableceo/redmine-cli:latest list --assigned-to-me -p 55` for active work.
6. **Check current state:** `git log --oneline -10`.
7. **Run rule audit:** `bash scripts/check-rules.sh --fast`.
8. **Scope-alignment — GATE.** Present the session handoff's "What's Left"
   priority list (or the Redmine queue if no handoff exists). Do NOT scan the
   full ticket queue and pick work on your own — **the user directs what gets
   worked on, always.** Note any new or urgent items from the Redmine check,
   then ask which ticket to work on tonight. **STOP and wait.** Do not set
   `.crush/active-ticket`, do not read systems, do not run diagnostics, until
   the user names the target. This gate prevents the agent from burning
   context on work the user didn't ask for.

## Enforcement Model

Git hooks (`scripts/pre-commit`, `scripts/pre-push`) enforce the rules defined in
`scripts/check-rules.sh`. The rules engine checks: shellcheck (zero warnings
including info-level), Docker image pinning (no `:latest`), container naming,
required files, Discourse pointer headers, and more. Run `bash scripts/check-rules.sh`
for a full audit or `--fast` for pre-commit speed. Bypass with `--no-verify`
(emergencies only).

## Task Tracking

- **Redmine is the system of record for all work.**
- **NEVER close a ticket without explicit user permission.** Suggest a
  close when clearly scoped/delivered; if ambiguous, don't suggest.
- **Ticket-first enforcement (mechanically enforced).** Before starting
  any work, set the active ticket: `echo '#NNN' > .crush/active-ticket`.
  The Crush hook (`hooks/ticket-gate.sh`) blocks modifying operations
  until this file exists. If no ticket exists, CREATE ONE FIRST via
  redmine-cli, then set it. Clear when done: `> .crush/active-ticket`.
  **Parallel sessions:** each session uses its OWN suffixed file (e.g.
  `.crush/active-ticket-plant`, `.crush/active-ticket-core`) so concurrent
  sessions never clobber each other's ticket; any non-empty
  `.crush/active-ticket*` satisfies the gate.
- **WORKING.md** is the only in-repo task tracker — a scratchpad for the current
  session. The pre-commit hook blocks commits while any task remains unchecked.
- **Parallel sessions:** when two sessions share this repo (e.g. physical plant
  vs core infra), the second session uses a gitignored scratch tracker such as
  `.crush/WORKING-PLANT.md` instead of WORKING.md, so the two trackers (and
  their pre-commit gates) never collide. Wipe at session close.
- Clear WORKING.md before responding to the user.

## Rolling HUD (session-scoped, NOT persisted)

The rolling HUD is a **live status display** the agent maintains throughout
the session to help the human follow along. It is NOT a system of record —
Redmine, Discourse, and git are the durable systems. The HUD exists purely
for the human's situational awareness during the session.

- **Format:** a compact block shown at the end of each significant response
  (after completing a step, hitting a blocker, or pivoting). Example:

  ```
  ┌─ SESSION HUD ────────────────────────────────────────
  │ Active: #343 (Monitoring coverage matrix)
  │ Done:   ✓ scope-alignment gate added to both AGENTS.md
  │         ✓ committed + pushed (6a2550b)
  │ Now:    drafting coverage matrix on Discourse
  │ Next:   → #341 TEMPer USB (deploy on pfv-tsys1)
  │         → #338 LibreNMS alerts (BLOCKED: pushover.env)
  ├─ USER ACTION ITEMS ──────────────────────────────────
  │ • Populate ~/.creds/pushover.env (unblocks #338, #428)
  │ • Populate ~/.creds/prometheus.env + grafana.env (#430)
  └──────────────────────────────────────────────────────
  ```

- **Placement:** may be written to `.crush/hud.md` on disk to keep context
  window smaller (re-read and update rather than hold in memory). Never
  committed to git. Wiped at session end.
- **Sections:**
  - **Active:** current ticket number + one-line description
  - **Done:** ✓ items completed this session (append as work progresses)
  - **Now:** what the agent is actively doing
  - **Next:** the queued items (per handoff priority or user direction)
  - **User action items:** things ONLY the user can do (populate creds,
    physical work, manual deploys) with the tickets they unblock
- **When to show it:** after each logical unit of work, at blockers, and
    when pivoting between tickets. Not every trivial response — use judgment.
- **NOT a substitute for Redmine/Discourse/git.** The HUD is ephemeral. When
    work completes, update the durable systems (ticket notes, Discourse wiki,
    commits). The HUD just tracks the live narrative for the human.

## Working Style

- **Stop over-thinking.** Get to code and output faster. Explore with code;
  gather ground truth. Do not burn tokens reasoning about things a quick command
  answers.
- **Prefer Unix utilities** (awk, sed, grep, cut, tr) for file editing and
  text processing over harness edit tools when feasible — deterministic and
  exact where harness editors can be whitespace-fragile.
- **Farm work out to deterministic tooling:** linters, LSPs, formatters, test
  runners. If an LSP is wired up, use it; otherwise pull a Docker image and lint
  inside it.
- **Use sub-agents as subcontractors:** scoped spec in, distilled deliverable out.
  Never read 10+ files sequentially; batch into agent calls.
- **Command timeouts (NON-NEGOTIABLE):** Every command that touches a remote
  system MUST be wrapped with `timeout`. Hard limits: 30s for quick reads
  (status, ps, ls), 120s for standard operations, 300s for deployments/pulls.
  If a command hits the timeout, STOP and investigate root cause — never
  blindly retry. A hung command is a failed command. Detect failure fast,
  diagnose, fix, move on. Example: `timeout 120 bash tests/remote.sh vm 'cmd'`.
  This applies to ALL tools — bash, docker, CLIs, sub-agents.

## Field Lessons (append at session close)

Hard-won operational gotchas. One line each; keep them short and load-bearing.

- **CLI body scanner workaround:** inline `-b "body"` arguments containing
  words like `systemctl` are blocked by the command scanner. Write long bodies
  to `/tmp/note.md` and pass `-b "$(cat /tmp/note.md)"` (redmine-cli +
  discourse-cli both).
- **python3-venv trap:** `python3 -c 'import venv'` succeeds even without the
  package; the resulting venv has no pip. Guard with `dpkg -s python3-venv`,
  verify `test -x venv/bin/pip`, delete pip-less venvs and recreate.
- **temperusb API:** there is no `Temper` class. Use
  `TemperHandler().get_devices()[i].get_temperatures(sensors=[0,1])`
  (sensor 0 = internal, 1 = external probe).
- **LibreNMS API:** device add requires `"version": "v2c"` (with the v);
  `discovery.php`/`poller.php` must run as the `librenms` user; SNMP
  community fields are redacted in API responses by design.
- **remote.sh modes:** `vm-copy`/`prox-copy` destination must be a FULL file
  path, never a directory; `vm-file`/`prox-file` push and execute a script
  via `bash -s` (use for idempotent deploy scripts).
- **Remote restart verification:** after restarting services (especially in
  parallel across hosts), re-verify state several seconds later. Immediate
  checks race the restart and produce false failures.
- **NUT:** `upsd.conf` LISTEN edits only take effect after
  `systemctl restart nut-server`. udev rule changes need
  `udevadm control --reload-rules && udevadm trigger`.
- **Tailscale app-connector sources (HA):** HA polls originate from its LAN
  IP (192.168.3.12), not its Tailscale IP. Include the HA LAN IP in every
  allowlist (snmpd `rocommunity` source lists, upsd LISTEN binds).
- **`git mv` fails on untracked files:** plain `mv` first, `git add` after.
- **SNMP extend with UTF-8:** net-snmp CLI prints `°C` extend output as
  Hex-STRING, but pysnmp/HA receive the decoded string. Parse the decoded
  lm-sensors text lines with a regex value_template.
- **pfv-bms (Home Assistant):** no SSH by design; everything goes through the
  REST API (`/api/`, `/api/config`, `/api/services`) with the long-lived
  token in `~/.creds/homeassistant.env`. Instance unit system is °F — set
  explicit `unit_of_measurement: "°C"` on custom sensors.
- **HAOS core_ssh on 22222 is the sanctioned host shell:** `VM_IP=pfv-bms-lan.knel.net
  VM_USER=root VM_PORT=22222 bash tests/remote.sh vm '...'` — use the LAN name
  (knel.net zone is Tailscale-IP based; TS inbound to pfv-bms is unreliable).
  Host OS has NO git/ssh/curl — run git against /mnt/data/supervisor/homeassistant
  via `docker run homeassistant/amd64-addon-git_pull:<ver>`.
- **Git pull add-on (core_git_pull) wipes /config on first run** if `.git` is
  absent (rm -rf + fresh clone). ALWAYS pre-seed /config as a git checkout
  (deploy key ~/.creds/pfv-bms-gitpull, repo KNEL/pfv-bms) before starting it.
- **HA core boot death-spiral:** supervisor watchdog SIGTERMs slow boots
  (docker logs: "finish process received signal 15" while supervisor logs
  "Still waiting for Core to start"). Fix from the 22222 shell:
  `ha ha options --watchdog=false`, start core, diagnose, re-enable once stable.
- **Technitium on netinfra-02 (tsys-dns container) does not reload rsync-synced
  zone files** — `docker restart tsys-dns` after record adds until #469 (native
  clustering) lands. netinfra-01 (the dns-cli target) is fine.
- **Supervisor add-on options via API:** docker cp a script into
  hassio_supervisor, read token from /data/cli.json `access_token`, POST
  /addons/<slug>/options (the /apps/ spelling 404s).
- **NEVER restart/kill BOTH members of a redundant pair simultaneously**
  (founder ruling 2026-09-01 after the DNS outage): restart/redeploy ONE
  node, verify service health from an independent vantage, only then the
  second. Blue/green with a health gate between hops — always serial.
- **Pi-hole v6: `dnsmasq_lines` in pihole.toml are passed to FTL's embedded
  dnsmasq, which rejects some valid-dnsmasq options (e.g. `no-negcache`)
  with "bad option" and dnsmasq then never starts — the container looks
  "Up" but serves nothing on :53. Use first-class toml settings instead;
  never sed-edit pihole.toml without a single-node health-gated rollout.
- **DNS outage recovery path:** workstation resolv.conf dies with the LAN
  Pi-holes; use `tailscale status` peer IPs + the chokepoint env overrides
  (NETINFRA01_HOST/NETINFRA02_HOST, VM_IP) to reach hosts by Tailscale
  while names are unresolvable. Fix one node, verify, then the other.
- **dhcpd + AppArmor:** on the netinfra pair, dhcpd can only read configs
  under `/etc/dhcp/**` — staging a candidate config in /tmp fails dhcpd -t
  with "Permission denied" even as root. Stage as /etc/dhcp/*.candidate,
  test, then move into place.

## Questions (NON-NEGOTIABLE)

**NEVER use a harness "question"/"ask user" tool** (structured prompts,
modal forms, tabbed questions). Banned across every project, every harness.
They are not portable, not version-controlled, and bypass the git record.
**All questions go in the current `questions-v(N).md` file** — write the
question; the human edits the answer inline in the same file. **Version up
the filename each time answers land** (v1 → v2 → v3...): create
`questions-v2.md` with resolved Q&A marked, new questions appended. This
preserves the history of each Q&A round. Synthesize resolved Q&A into
Discourse (decisions) and Redmine (work items). See `BASELINE-PROMPT.md`
§10.

## Documentation policy (IMPORTANT)

**Discourse is the canonical source of truth for all knowledge documentation.**
Knowledge docs (architecture, runbooks, references, audits, policies) have been
migrated to [community.turnsys.com](https://community.turnsys.com/c/vp-techops)
as wiki topics in the **VP TechOps** category.

All `.md` files in this repo (except `AGENTS.md` and `LICENSE`) are now
**pointers** that link to their corresponding Discourse topic. **Do not update
documentation content in git** — edit the Discourse wiki topic instead. Git
edit history no longer serves as the documentation changelog; Discourse
preserves wiki edit history automatically.

Code (scripts, configs, playbooks) still lives in git as the source of truth
for executables. Only *documentation* moved to Discourse.

**Redmine wikis stay disabled — Discourse is the sole documentation space**
(founder ruling, 2026-09-02). All Redmine projects have the wiki module off
(verified 0/62, 2026-09-02); never enable it on new or existing projects.

## Top-level files

All `.md` files now point to Discourse. The key pointers:

| File | Points to | Discourse topic |
|------|-----------|-----------------|
| `README.md` | Project overview | [#296](https://community.turnsys.com/t/296) |
| `STATUS.md` | Ticket index + infra summary | [#297](https://community.turnsys.com/t/297) |
| `docs/docmap.md` | Documentation index | [#296](https://community.turnsys.com/t/296) |

Work tracking stays in [Redmine](https://projects.knownelement.com).

## Repository Layout

```
k8s/                k3s cluster setup scripts (HA control plane over Tailscale) + docs/
proxmox/            Proxmox fleet docs (hardware audit, capacity, storage)
awx/                Ansible AWX deployment (k3s + AWX Operator)
HomeAssistant/      Redirect stub — HA tooling + reference packs moved to KNEL/pfv-bms (tools/, docs/reference-packs/) [#762]
tests/              Test suite + VM validation harness + remote.sh SSH chokepoint
scripts/            Framework: git hooks, rule engine (check-rules.sh), shared lib
docs/               Server-build docs, docmap index, and archive
archive/            Historical/superseded code (provisioning -> replaced by KNELIAC project;
                    KNELServerBuild -> live patterns now in KNEL/monitoring, remainder
                    preserved under archive/KNELServerBuild/ [#474])
vendor/             Vendored KNELShellFramework
```

- **2026-09-03 repo split (#769)** — domain tooling moved out of this catch-all
  into dedicated KNEL repos (working dirs `~/projects/KNEL/<repo>`; full git
  history retained here):

  | body of work | repo | ticket |
  |---|---|---|
  | SIEM/syslog (Wazuh) | KNEL/siem | #335 |
  | DNS/NTP/DHCP + switches | KNEL/netinfra | #694 |
  | Fleet CA | KNEL/ca | #697 |
  | Monitoring/OAM | KNEL/monitoring | #343, #773 |
  | Inventory/CMDB | KNEL/inventory | #705, #774 |
  | Facilities (PDU/console/UPS/sensors) | KNEL/facilities | #458 |
  | Performance/tuning | KNEL/perf | #709, #732, #737 |
  | Backups (PBS) | KNEL/backups (new stub) | #315, #684 |
  | Outbound mail (PMG) | KNEL/mailgateway (new stub) | #696 |
  | Secrets mgmt | KNEL/secrets (new stub) | #770 |
  | apt-satellite (deb version control) | KNEL/apt-satellite (new stub) | #771 |
  | Security Onion (ops side) | KNEL/security-onion (new stub) | #772 |

  Unit tests moved with their code (netinfra, facilities). IaC consumers:
  KNEL/KNELIAC. Env map + snapshot lifecycle: Discourse t/331.

- **Server provisioning moved to KNELIAC**: The
  [`archive/provisioning/`](archive/provisioning/) tree is historical. Active
  server provisioning lives in the **KNELIAC** project at
  `/home/reachableceo/projects/KNEL/KNELIAC`.
- **Non-bash files**: Some files under `archive/provisioning/Agents/` have `.sh`
  extension but are PHP (shebang `#!/usr/bin/php`). Skip in syntax checks.
- **Remote access (NON-NEGOTIABLE):** ALL SSH/SCP to ANY host MUST go
  through the chokepoint scripts — [`tests/remote.sh`](tests/remote.sh)
  (Proxmox hosts + all VMs) or
  `~/projects/KNEL/netinfra/dns-cluster-setup/remote-dns.sh`
  (DNS infra hosts: netinfra-01/02, netboot; lives in KNEL/netinfra [#769]). NEVER call
  `ssh`/`scp` directly — the harness blocks raw ssh and the command scanner
  rejects it. There are no exceptions.
- **DNS names ONLY (NON-NEGOTIABLE):** NEVER use IP address literals
  (neither LAN nor Tailscale IPs) in any command, script, or config.
  ALWAYS use DNS names. For Proxmox hosts: `PROX_HOST=<dns-name>`. For VMs:
  `VM_IP=<dns-name>`. For conman/SNMP/any tool: pass the DNS name. If a
  DNS name does not resolve, fix it in DNS (Technitium) or consult the
  [system inventory — Discourse #307](https://community.turnsys.com/t/307).
  Do NOT fall back to IP literals. This rule eliminates the per-session
  discovery tax of finding the right IP for each host.
- **How to access a production VM:**
  1. Look up the DNS name in the [system inventory — Discourse #307](https://community.turnsys.com/t/307).
  2. `VM_IP=<dns-name> VM_USER=root bash tests/remote.sh vm '<command>'`
  3. If the name does not resolve from the workstation, use `PROX_HOST=<proxmox-node>`
     and run `qm guest cmd <vmid> network-get-interfaces` (visibility only —
     NOT execution) to find the Tailscale DNS name, then access via that.

## Git Policy

1. **ALWAYS commit + push. Never wait. Ever.** After each logical unit of
   work, immediately stage, commit (conventional format), and push to
   `origin/main`. Do NOT pause for review. Do NOT ask permission. Do NOT
   stop to "let the user read it first." The user reviews rendered markdown
   on Gitea AFTER it is pushed. **This rule overrides any default
   conservative commit-and-hold behavior** — if a default instruction says
   "never commit unless asked," that instruction is superseded here.
2. **Atomic commits.** Each commit coherent on its own.
3. **Conventional format**: `feat(scope): desc`, `fix(scope): desc`,
   `docs: desc`, `refactor(scope): desc`, `test(scope): desc`.
4. **All shell scripts MUST pass `shellcheck` before commit.** No exceptions.
   Run it via the wrapper:
   ```bash
   bash tests/shellcheck.sh                 # whole repo
   bash tests/shellcheck.sh ups/*.sh        # specific files
   ```
   This invokes `koalaman/shellcheck:stable` through Docker (no native binary
   needed). Fix every reported finding — including `info`-level — or add a
   targeted `# shellcheck disable=SCxxxx  # <reason>` directive with a
   justification. A script that emits any diagnostic is a protocol violation.
   Non-bash scripts (PHP with `.sh` shebang `#!/usr/bin/php`, etc.) are exempt.

## Cross-linking (NON-NEGOTIABLE)

**Every artifact cross-references its related artifacts with CLICKABLE
links.** House rule (Charles, 2026-09-02, [#743]) — becomes hard
requirement once GLPI/CMDB change control is online (ITIL/ITSM ramp):

- Redmine ticket comments link the commits they describe (full Gitea URL,
  e.g. `https://git.knownelement.com/KNEL/PFVCluster/commit/<sha>`).
- Commit bodies carry the full URL of the corresponding Redmine comment
  (`https://projects.knownelement.com/issues/NNN#note-M`) — the "meat"
  lives in the comment; the commit body points at it.
- Discourse posts link both the Redmine ticket and the Gitea repo/commit;
  Redmine tickets and repo docs link their Discourse topics.
- One click from ANY system reaches the other two. An artifact without its
  links is not done.
- When GLPI is online: change requests become the entry point for infra
  changes and get cross-referenced into Redmine/Discourse/Gitea the same
  way; CMDB items link their tickets and vice versa.

Mechanical enforcement (rule-engine check for the ticket-comment URL in
commit bodies when `[#NNN]` is present) is tracked under [#441].

## CI/CD (fleet standard, 2026-09-04)

**Every gitea repo runs CI via Gitea Actions.** Runner:
`ukrrs-pfv-gitea-runner` (act_runner 0.2.13, compose at
`~/projects/ukrrs/ci/gitea-runner/` on the pfv workstation) — registered
**org-scoped to KNEL** with label **`ultix` ONLY**; workflows must use
`runs-on: ultix` or they queue forever.
Repo duty: carry `.gitea/workflows/ci.yml` — lint for the repo's language
(pinned container images, never `:latest`) + a secret-scan step, on `dev`
and `release` pushes. Branch model: `dev` = WIP (CI must pass), `release` =
production/deploy branch (CI is the final gate).
Full standard: [Discourse topic #333](https://community.turnsys.com/t/333).
Tracking: [#784](https://projects.knownelement.com/issues/784).

## TDD & Linting

- **Red/green TDD for all code.** Mandatory (founder 2026-08-27). Interim
  relaxation applies ONLY until the Ansible/IaC transition (week of 9/1);
  from then on every playbook/script ships with its failing test first.
  `scripts/test.sh` is the local gate; `tests/validation` + `tests/security`
  run on sectestbed targets.
- **Linters on all code, as early as possible.** shellcheck zero-warning
  (including info-level) is already enforced pre-commit.

## IaC Codification (shadow tracking) — NON-NEGOTIABLE

**Every manual/direct change to a fleet system must be codified same-day as
an AWX playbook item.** The fleet converges to 100% IaC (founder mandate,
2026-08-27, #454).

- Made a manual change? Add a checklist item to #454 (or a child ticket) in
  the same session — what changed, where, exact commands, and any quirks
  (e.g. "needed udevadm trigger after NUT install").
- Work is NOT "done" until the manual change is codified or explicitly
  ticketed for codification.
- New manual fixes during incidents: fix first, codify immediately after.
- Playbooks live in this repo, tested through the `sectestbed-*` fleet.

## DNS change discipline (NON-NEGOTIABLE)

**Any change to Technitium records/zones (dns-cli or API), dhcpd, or the
live DNS/DHCP configs MUST end with a same-session git SoR sync.**
Founder rule, 2026-09-02 — drift-check exists because git went stale; do
not reopen that gap:

1. Make the change (serial + health-gated across the redundant pair).
2. `cd ~/projects/KNEL/netinfra && bash dns/technitium/zone-snapshot.sh` — refresh the DZ
   snapshots from the primary.
3. `cd ~/projects/KNEL/netinfra && bash dns/drift-check.sh` — must print `ALL IN SYNC`.
4. Commit + push the refreshed snapshots in the same session.

## Redmine Tracking Policy

**Redmine is the system of record for all work.** Do not track status,
checklists, or TODOs in repo files. Use Redmine tickets instead.

**Tracker discipline:** OAM/ops/feature/audit tickets use tracker **Support
(3)** — NOT Bug. The redmine-cli `create` defaults to Bug; always correct the
tracker after create (python escape hatch: `tracker_id=3`). [2026-08-27]

- **URL:** https://projects.knownelement.com
- **Version:** Potential to Kinetic Ready (due 2026-09-30)
- **Project:** Known Element Enterprises - Technology & Facility Services (id 55)

### Rules
1. **Every piece of work** (feature, fix, deployment, config change) gets a
   Redmine ticket. If one doesn't exist, create it.
2. **Reference tickets in docs and commits** using `[#NNN]` notation.
   Example: `[#367] Rebuilt k3s control plane after cnode wipe`.
3. **When work completes**, update the ticket: set done ratio to 100%,
   add a note describing what was done and where the code lives.
   **NEVER close a ticket without explicit user permission.** You may
   SUGGEST a close when the result is clearly scoped and delivered. If
   it's ambiguous whether the work is truly complete, don't suggest a
   close — leave that decision to the user.
4. **Operations Status** lives on Discourse ([topic #297](https://community.turnsys.com/t/297)) — update that wiki topic if the infrastructure summary needs refreshing. The `STATUS.md` file in git is now a pointer only.
5. **Link code to tickets** — ticket descriptions and notes should reference
   the relevant file paths in this repo (e.g., the ups tooling in KNEL/facilities).

### CLI access (read + write)

Tickets are managed via the `redmine-cli` container, invoked directly
with `docker run` (no wrapper script). Full command reference, patterns, and
the subtask escape hatch live in the CLI's own `AGENTS.md`
(`~/projects/ukrrs/connectors/KNEL-AIMiddleware/tooling-cli/redmine/AGENTS.md`) — read it
for anything beyond the basics.

```bash
# Connection sanity check (run first in any session):
docker run --rm --env-file ~/.creds/redmine.env \
    git.knownelement.com/reachableceo/redmine-cli:latest whoami

# Your queue (project 55):
docker run --rm --env-file ~/.creds/redmine.env \
    git.knownelement.com/reachableceo/redmine-cli:latest list --assigned-to-me -p 55

# Show / create / update / close:
docker run --rm --env-file ~/.creds/redmine.env \
    git.knownelement.com/reachableceo/redmine-cli:latest show 367
docker run --rm --env-file ~/.creds/redmine.env \
    git.knownelement.com/reachableceo/redmine-cli:latest create -p 55 -s "Subject" -d "desc"
docker run --rm --env-file ~/.creds/redmine.env \
    git.knownelement.com/reachableceo/redmine-cli:latest update 367 -n "Done: committed in abc123" --done-ratio 100
docker run --rm --env-file ~/.creds/redmine.env \
    git.knownelement.com/reachableceo/redmine-cli:latest close 367
```

Key IDs: project **55** (`technicaloperations`), user **5** (`reachableceo`).
Statuses: New(1), In Progress(2), Resolved(3,closed), Feedback(4), Closed(5),
Rejected(6). New subtasks go to **Feedback (4)**. Tracker **3** = Support.
**Gotcha:** `create` has no `--parent` flag — to make a subtask, use the
`python-redmine` escape hatch inside the container (see
`tooling-cli/redmine/AGENTS.md`). Always `show` a ticket before updating it.

Credentials (`REDMINE_URL`/`REDMINE_API_KEY`) live in the centralized store
at `~/.creds/redmine.env`.

## Documentation Workflow

**Discourse is the source of truth for all knowledge docs.** After any work
session, an agent MUST:

1. **Update Discourse wiki topics** — if infrastructure facts changed (new
   VM, IP change, host retired), edit the relevant wiki topic at
   [community.turnsys.com/c/vp-techops](https://community.turnsys.com/c/vp-techops).
2. **Update the Operations Status topic** ([#297](https://community.turnsys.com/t/297))
   if tickets were opened or closed.
3. **Grep for stale paths in code** — `grep -rn 'old/path' --include='*.sh'`
   after any rename or restructure. Fix all references in the same commit.
4. **If a new top-level directory was created**, update:
   - `AGENTS.md` → "Repository Layout" code block
   - `AGENTS.md` → "Key Scripts" table (if it has an entrypoint script)
   - Create a new Discourse wiki topic for any documentation
5. **Self-audit before commit.** Code changes must be internally consistent.
   Documentation changes go to Discourse, not git.

### CLI access (read + write)

Wiki topics are managed via the `discourse-cli` container, invoked directly
with `docker run` (no wrapper script). Full command reference, patterns, and
the raw-API escape hatch live in the CLI's own `AGENTS.md`
(`~/projects/ukrrs/connectors/KNEL-AIMiddleware/tooling-cli/discourse/AGENTS.md`) — read it
for anything beyond the basics.

```bash
# Connection sanity check (run first in any session):
docker run --rm --env-file ~/.creds/discourse.env \
    git.knownelement.com/reachableceo/discourse-cli:latest whoami

# List VP TechOps topics:
docker run --rm --env-file ~/.creds/discourse.env \
    git.knownelement.com/reachableceo/discourse-cli:latest ls -c vp-techops

# Show a topic / edit a wiki post (find post id via `show`):
docker run --rm --env-file ~/.creds/discourse.env \
    git.knownelement.com/reachableceo/discourse-cli:latest show 297
docker run --rm --env-file ~/.creds/discourse.env \
    git.knownelement.com/reachableceo/discourse-cli:latest update <post_id> -b "new markdown body"
```

VP TechOps = category **74**. Key topics: #296 (project overview), #297
(ops status), #298 (audit log). The API user is trust-level 4 but **not
admin** — admin-only ops (category creation, setting the wiki flag) will
403; surface those to the user rather than retrying. **Gotcha:**
`update`/`delete` take a post **id**, not a post number. Never create a new
topic for an update to existing knowledge — edit the wiki post in place.

Credentials (`DISCOURSE_URL`/`DISCOURSE_API_KEY`/`DISCOURSE_API_USERNAME`)
live in the centralized store at `~/.creds/discourse.env`.

## Key Scripts

| Script | Purpose |
|--------|---------|
| [`scripts/check-rules.sh`](scripts/check-rules.sh) | Rule audit engine (shellcheck, image pinning, Discourse pointers, required files) |
| [`scripts/setup-hooks.sh`](scripts/setup-hooks.sh) | Install git hooks (pre-commit, pre-push) |
| [`scripts/pve-snapshot.sh`](scripts/pve-snapshot.sh) | PVE host snapshot/backup pass — hard-protects prod before risky windows (verified 2026-09) |
| [`scripts/audit-fleet.sh`](scripts/audit-fleet.sh) | Fleet-wide audit sweep (hosts, services, drift) |
| [`scripts/garden.sh`](scripts/garden.sh) | Repo/workspace gardening loop (strays, cruft, staleness) |
| `scripts/kuma-*.py` suite | Uptime Kuma management (add-ping, fleet-sync, inventory, maintenance, notifications, regroup) |
| [`tests/remote.sh`](tests/remote.sh) | **SSH chokepoint** — all Proxmox host + sandbox VM access routes here |
| KNEL/netinfra: `dns-cluster-setup/remote-dns.sh` | SSH chokepoint for DNS infra hosts (netinfra-01/02, netboot) |
| KNEL/netinfra: `dns/technitium/zone-snapshot.sh` | Refresh git SoR Technitium zone snapshots from the primary — **required after every DNS change** |
| KNEL/netinfra: `dns/drift-check.sh` | Live-vs-git drift audit for DHCP/DNS/NTP/zones — must be green before any DNS-adjacent push |
| KNEL/inventory: `seed/from-inventory.sh` | Regenerate the CMDB seed CSV from the Discourse #307 inventory raw (re-run when #307 changes) |
| `redmine-cli` container | Redmine CLI (ticket read/write via `docker run`; see `tooling-cli/redmine/`) |
| `discourse-cli` container | Discourse CLI (wiki topic read/write via `docker run`; see `tooling-cli/discourse/`) |
| `dns-cli` container | Technitium DNS CLI (zones, list, add, delete, search, flush; see `tooling-cli/dns/`) |
| [`tests/vm-validation.sh`](tests/vm-validation.sh) | Deploy + validate on sandbox VM |
| [`tests/run-tests.sh`](tests/run-tests.sh) | Test suite |
| KNEL/netinfra: `dns-cluster-setup/setup.sh` | DNS cluster replication |
| [`k8s/install-cp.sh`](k8s/install-cp.sh) | Bootstrap k3s HA control plane |
| KNEL/facilities: `powerman/setup.sh` | Configure Cyclades PDU via powerman |
| KNEL/facilities: `console/setup.sh` | Configure serial console access via ser2net + conman |
| KNEL/facilities: `ups/setup.sh` | Configure NUT (Network UPS Tools) for UPS monitoring |
| KNEL/perf: `deploy-tuning.sh` | Deploy perf tunings |
| KNEL/perf: `scripts/` | Read-only audit: probe-storage, probe-network, conman-console, snmp-switch-audit, probe-drift, audit-vm-disks, audit-guest-io, deploy-tuned-guests |

## Switch Console Access (conman)

Switch configs are pulled via serial console through a conman + ser2net
stack on pfv-tsys4.

- **conmand server:** pfv-tsys4 (port 7890)
- **ser2net:** pfv-tsys4, TCP ports 2001-2006
  (do NOT connect to ser2net directly — it conflicts with conman's
  persistent sessions; always use the conman client)
- **Script:** KNEL/perf: `scripts/conman-console.py`
  — drives console sessions read-only via PTY. No expect/tcl required.
- **Command files:** KNEL/netinfra: `switches/` — `.cmds` files
  with switch-specific show commands
- **Query available consoles:** `conman -d pfv-tsys4 -q`

```bash
# Pull a switch config (example):
CONMAN_SERVER=pfv-tsys4:7890 python3 ~/projects/KNEL/perf/scripts/conman-console.py \
  --console pfv-r5-core-01 --cmds ~/projects/KNEL/netinfra/switches/pfv-r5-core-01.cmds
```

| Console name | TCP port | Device |
|--------------|----------|--------|
| pfv-r5-core-01 | 2001 | Dell PowerConnect 5448 (rack 5 core, mgmt+storage) |
| pfv-r3-tor-mgmt-01 | 2002 | Dell PowerConnect 5324 (rack 3 mgmt TOR) |
| pfv-r3-tor-stor-01 | 2003 | Dell PowerConnect 5324 (rack 3 storage TOR) |
| pfv-rrinfra-rtr | 2004 | Cisco router (rrinfra) |
| pfv-r2-tor-01 | 2005 | Rack 2 TOR switch |
| pfv-r6-mgmt-01 | 2006 | Rack 6 management switch |

## Mandatory: Document ALL Infrastructure Changes

**This is non-negotiable. Every infrastructure change (VM config, disk
cache, network setting, service config, storage migration) MUST be
documented BEFORE moving to the next task step — not "later" or "at the
end."**

Required for EVERY infrastructure change:

1. **Redmine ticket** — create one if none exists. Reference as `[#NNN]`.
2. **Discourse audit log** — reply to topic
   [#298](https://community.turnsys.com/t/298) with a dated entry (what
   changed, why, where).
3. **Discourse relevant wiki topic** — update the architecture/reference
   topic if the change affects documented infrastructure facts (storage
   #300, network #299, k8s #305, etc.).
4. **Git commit** — if code/config changed in the repo, commit + push
   immediately per the Git Policy above.

## Tooling

- `gh`, `docker`, `jq` available on the workstation.
- No native shellcheck — use `bash tests/shellcheck.sh` (Docker wrapper
  `koalaman/shellcheck:stable`). ALL scripts must pass including info-level.
- For raw API calls not covered by the Redmine/Discourse CLIs, use
  `python3` inside the CLI Docker containers (escape hatch pattern in
  `tooling-cli/{discourse,redmine}/AGENTS.md`).
- `curl`/`wget`/`httpie` may be blocked by some harnesses. Use the CLIs or
  the python-in-Docker escape hatch for HTTP writes.

## Key Commands Quick Reference

```bash
# Tests + validation:
bash tests/run-tests.sh                        # test suite
bash tests/vm-validation.sh                    # VM validation
bash tests/shellcheck.sh                       # shellcheck whole repo
bash tests/shellcheck.sh path/to/*.sh          # shellcheck specific files

# Redmine + Discourse sanity checks (run first in any session):
docker run --rm --env-file ~/.creds/redmine.env \
    git.knownelement.com/reachableceo/redmine-cli:latest whoami
docker run --rm --env-file ~/.creds/discourse.env \
    git.knownelement.com/reachableceo/discourse-cli:latest whoami

# Proxmox host access (DNS names only — never IPs):
PROX_HOST=pfv-tsys5 bash tests/remote.sh prox 'qm list'
PROX_HOST=pfv-tsys5 bash tests/remote.sh prox 'pvesm status'

# Production VM access (DNS names only — never IPs):
VM_IP=tsys-librenms VM_USER=root bash tests/remote.sh vm 'systemctl status cron'

# DNS infra access:
bash ~/projects/KNEL/netinfra/dns-cluster-setup/remote-dns.sh netinfra01-root 'systemctl status docker'

# Switch console (read-only config pull — DNS names only):
CONMAN_SERVER=pfv-tsys4:7890 python3 ~/projects/KNEL/perf/scripts/conman-console.py \
  --console pfv-r5-core-01 --cmds ~/projects/KNEL/netinfra/switches/pfv-r5-core-01.cmds
```

## Credential Management

- API keys currently in `.env` files under KNEL-AIMiddleware (gitignored).
- User goal: migrate all keys to Hashicorp Vault
  (`vault.knownelement.com`). No vault token present yet
  (`~/.vault-token` missing). Track as high-priority TODO.

## Key Docs

→ **All documentation lives on Discourse:**
  [community.turnsys.com/c/vp-techops](https://community.turnsys.com/c/vp-techops)

→ All `.md` files in this repo are pointers to Discourse topics.

→ **Complete Linux System Inventory:**
  [Topic #307](https://community.turnsys.com/t/307) — every Linux system
  (hosts + VMs + physical), with Tailscale IPs, DNS names, SSH access
  status, and tuned profiles. Reference this for monitoring coverage,
  access management, and hostname consistency.

## Project Context

Solo-founder R&D Proxmox cluster in a private residence. Shoestring budget.
Production lives on a Cloudron VPS in Reston VA. See the
[Operations Status topic](https://community.turnsys.com/t/297) for the ticket
index and infrastructure summary.
All work is tracked in [Redmine](https://projects.knownelement.com)
(version: Potential to Kinetic Ready, due 2026-09-30).
- **HA 2026.8 snmp platform traps:** temperature/current-classed sensors with
  `state_class: measurement` + non-numeric render ('unknown') are dropped AT
  ADD-TIME (ValueError) when a host is asleep — entity vanishes entirely. Fix:
  no state_class (stats live in VM) + templates render EMPTY on no-match.
- **Legacy snmp extend OID encoding:** suffix = strlen + ASCII decimal per
  char ("garagepdu" = 9.103.97.114.97.103.101.112.100.117 — count carefully).
- **Old APC PDUs (AOS ~2.7):** SNMPv1 only; subtree WALKS die silently, small
  single-table walks + GETs work. Garage AP7830 current OID:
  .1.3.6.1.4.1.318.1.1.12.2.3.1.1.2.1 (tenths of amps). Relay pattern via
  tsys-librenms (the allowed poller vantage) for v1-only/ACL-locked gear.
- **Debian snmpd on monitoring VMs:** snmpd may fail to bind 127.0.0.1:161
  (exit 1, "Error opening specified endpoint") when something squats
  localhost — bind only the LAN/TS addresses in agentaddress.
- **iDRAC6/7 SNMP:** agents exist but hw MIB tables unpopulated on old fw;
  IPMI SDR via iDRAC SSH (`racadm`, one command per exec, legacy syntax on
  iDRAC6: `racadm config -g cfgOobSnmp -o ...`) is the data path.
- **jq in one-liner state checks:** use `.state` — not `.s` — a wrong key
  silently returns null and looks like an entity outage (cost an hour tonight).
- **HA config flows are REST** (`POST /api/config/config_entries/flow`),
  not websocket; websocket only monitors. ha-ws-call.py + REST flow are the
  two levers for agent-driven HA setup.
- **mvdan/sh `source a b` silently ignores file b** — source env files
  one per line.
- **HA todo lists (founder HUD):** items are hidden from `/api/states` —
  fetch via `POST /api/services/todo/get_items?return_response=true`.
  Renaming works ONLY with a minimal `todo/update_item` payload
  (`entity_id`, `item`=<uid>, `rename`); passing `status`/`due` back earns
  a 400. HUD summaries carry `[#NNN]` Redmine prefixes — sync completed
  items back to Redmine when the founder asks.
- **HA package YAML: NEVER append a second top-level `sensor:` key** — YAML
  duplicate keys are last-one-wins and silently shadow the whole first block
  at config load (#344 outage 2026-09-02: all 22 fleet temps down after the
  restart that pulled the file). Merge new sensors into the existing block.
- **HA 2026.8 snmp add-time crash, general form:** ANY unit-bearing sensor
  with a non-numeric native_value at ADD (host down renders '' / literal
  string "unknown"; value_template can never yield None — even `{{ none }}`
  is stringified to "None") = ValueError, entity vanishes till manual
  reload. `check_config` does NOT catch it. Durable pattern: unit-less raw
  snmp transport + `template:` layer carrying unit/device_class under the
  ORIGINAL unique_ids (KNEL/pfv-bms packages/plant_snmp.yaml header).
- **HA entity_id comes from the NAME slug, not unique_id** — if a sensor's
  name and unique_id diverge, cross-references must use the name slug
  (garage PDU amps lesson: semantic read a nonexistent `..._amps_raw`).
- **HA entity_id collisions spawn `_2` entities; registry remove no-ops for
  LIVE entities** (they re-register on their next write). Clean `_2` by
  jq-editing `.storage/core.entity_registry` with core STOPPED, backup
  first. Orphaned (dead) entries remove fine via websocket
  (ha-ws-call.py — pass commands as JSON files; shell quoting eats argv).
- **git_pull add-on: `ha addons start` is a no-op while it's running** —
  use `restart` to force an immediate pull. It auto-restarts core for
  files in its restart list; otherwise pull ≠ reload (config lands on
  disk, running core keeps the old one until a platform reload/restart).
- **Restore drills / heavy I/O NEVER on hosts running prod VMs** (2026-09-03
  incident, #684): qmrestore of a 32G disk to scratch VMID 5599 on tsys1
  starved VM 100 (pfv-bms) I/O ~40 min, degraded prod HA. Drills run dev-lane
  only (tsys5); prod restores are server-throttled via PBS traffic-control.
- **`pkill -f <pat>` over the ssh chokepoint self-matches** your own remote
  command line and kills the session (exit 255, no output). Use the bracket
  trick: `pkill -f "[q]mrestore"`.
- **pvesh backup jobs take `-vmid`** (comma list), not `-vmids`; aborted
  `qmrestore` leaves a create-locked stub VM — `qm unlock <id>` then destroy.
- **PBS 4.x gotchas:** prune retention lives in `prune-job` (datastore keep-*
  opts are gone); API tokens need their OWN acl entry — user-only ACL yields
  "Cannot find datastore" from PVE.
- **PVE bridge port + hotplug NIC (#780):** a re-registered NIC (USB move
  changes its enumeration path) comes back UP but is NOT re-enslaved to its
  bridge — `ifup <nic>` is not enough; run `ifup <bridge>` and confirm
  `bridge link` shows `state forwarding`. NFS `hard` mounts mostly self-heal,
  but `nconnect=4` mounts can stay wedged on stale channels after a flap:
  force-remount (`umount -f -l`, recreate the mountpoint dir if the lazy
  umount removed it, fresh mount). `pvesm status` "active" can be stale —
  verify with a real read, not the status line.
- **PMG pmgproxy restart hang (#696):** `systemctl restart pmgproxy` can hang
  in "deactivating (stop)" on old workers ignoring TERM. If stuck >90s:
  `systemctl kill -s KILL pmgproxy && systemctl start pmgproxy` — API/UI
  only, mail flow unaffected. Postfix restarts don't hang.
