# Changelog

All notable changes to Netcanon are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

> **Note on pre-launch commit SHAs.**  Older entries below cite
> commits by short SHA (`(commit X)`).  Pre-launch the project went
> through a `git filter-repo` history rewrite to scrub PII (see the
> Phase 1 entry).  That rewrite changed every commit's SHA, so most
> short-SHA citations in entries written before Phase 1 won't
> resolve via `git show <sha>` against current history.  The
> *narrative* around each SHA is the load-bearing context; the SHA
> is decorative.  Entries written after Phase 1 (the entries closest
> to the top of `[Unreleased]`) cite current SHAs that resolve.

See also: [`README.md`](README.md) for project orientation;
[`ARCHITECTURE.md`](ARCHITECTURE.md) for the four-layer design that
much of the work below evolves.

**Date convention:** entries below use the maintainer's local
authoring date (UTC-7 / Pacific).  Git tag commits may land on the
following UTC day; cross-check `git log <tag>` for the exact UTC
timestamp if your timezone matters for an audit.

---

## [Unreleased]

## [0.6.1] - 2026-07-22

### Changed

- **Unified design language adopted** (netcanon-dev/ui-design-spec tag
  `v0.2.1`, commit `1c4af329`).  Why: netcanon's hand-rolled two-theme
  var set drifts from the org look with every tweak; vendoring the
  spec's drop-in deliverable makes the palette/mode a runtime user
  setting shared across netcanon-dev projects while leaving every
  template's `var(--…)` reference untouched.  Three files are
  byte-pinned (sha256) under `netcanon/templates/_vendor/` and
  Jinja-inlined by `base.html` exactly like the `_partials`:
  `netcanon-ui.css` (the `--nc-*` tokens — ten palettes ×
  light/dark/auto via `data-nc-theme`/`data-nc-mode`),
  `theme-picker.js` (the `NcTheme` runtime; owns persistence in
  `localStorage["nc-mode"]`/`["nc-theme"]`), and
  `compat-netcanon.css` (remaps the legacy `--page-bg`/`--surface`/…
  vars onto `--nc-*`).  Default palette: `indigo`; light + dark +
  follow-the-OS all remain available.  The nav sun/moon toggle now
  drives `NcTheme.set(null, mode)` (`<html data-nc-mode>`) instead of
  the old plain `data-theme` attribute; a one-time boot snippet
  migrates a previously persisted `localStorage["netcanon.theme.v1"]`
  into `nc-mode`, and the toggle still mirrors its choice one-way
  into the legacy key so the self-contained `/docs` theme copy stays
  in step.  Visible changes: the nav is now the unified surface-toned
  app bar (no longer fixed navy), accents/badges/focus rings take
  unified values, and the `<pre>` code wells deliberately keep the
  fixed VS Code Dark+ palette in both modes.  Follow-ups (tracked in
  ARCHITECTURE.md/the migration plan): delete the now-inert legacy
  var blocks, var-ize the `tok-*` syntax palette, migrate `/docs` and
  `migrate.html`'s hardcoded chrome, surface `<nc-theme-picker>`.

### Fixed

- **Switchport-only VLANs now translate** (#389).  A Cisco IOS-XE
  `show running-config` keeps its VLAN database in `vlan.dat`, so a VLAN
  used only via `switchport access vlan N` (or trunk `native`) has no
  `vlan <N>` stanza and no SVI in the text.  The five port-centric
  parsers (`cisco_iosxe_cli`, `arista_eos`, `cisco_nxos`,
  `juniper_junos`, `aruba_aoscx`) pruned those real access/native VLANs
  as phantoms, so e.g. a Catalyst 9300 with VLANs 10/20/100/150 on
  access ports translated to Arista/Junos with the VLAN database missing
  all four — leaving ports that reference undeclared VLANs.  A new shared
  `access_and_native_vlan_ids` transform keeps every access + native VID
  (single, operator-declared) while still pruning a VID that appears
  solely in a wide `trunk allowed vlan` range as possible phantom
  inflation.  Round-trip stays stable, so the cross-vendor CODEC_BUG
  count is unchanged (5).

## [0.6.0] - 2026-07-16

Two threads land together here: the **codec promotion wave** (#344–#356) that
graduated a batch of proven-clean matrix cells into real round-tripping
capability, and the **complete remediation of the 2026-07-12 HEAD-review** — a
12-agent adversarial review of all `main` since v0.5.3 (verdict
*GO-WITH-FIXES*).  All six remediation waves plus the deferred tail shipped;
the cross-vendor CODEC_BUG count held at 5 / 1224 cells throughout.  Two new
operator env knobs (`NETCANON_MAX_PENDING_BACKUP_JOBS`; the egress guard now
also sees IPv6 transition formats); **no breaking API changes**.

### Added

- **Management-plane wiring across four codecs** — `cisco_nxos` and
  `cisco_iosxr` now render + parse their whole Tier-1 management plane
  (domain / DNS / NTP / syslog), and `cisco_iosxe_cli` + `arista_eos` grew
  syslog harvest, so these surfaces round-trip instead of dropping on render.
  (#352, #353, #354)
- **VXLAN-EVPN `nve1` on `cisco_iosxe_cli`** — the NVE interface (VLAN↔VNI
  bindings, multicast group, source-interface, L3VNI) now parses + renders.
  (#351)
- **Static-route fidelity** — admin distance (metric) wires through on
  arista / mikrotik / fortigate / iosxr; OPNsense harvests its two-block static
  routes on parse; the two-token `<dest> <iface> <gateway>` form is preserved.
  (#348, #349, #350, #362)
- **FortiGate secondary interface IPs + nested IPv6** — `config secondaryip`
  and the FortiOS 7.x nested `config ipv6` interface address now round-trip.
  (#344, #345)
- **VyOS system domain-name + name-server; NX-OS GRE / IPIP tunnel encap.**
  (#347, #346)
- **`NETCANON_MAX_PENDING_BACKUP_JOBS`** (default 1000) — `POST /api/v1/backups`
  now bounds the number of in-flight jobs; a runaway intake flood is shed with
  `429 Too Many Requests` + `Retry-After` instead of piling up an unbounded
  executor queue (each entry pinning decrypted credentials), registry rows and
  pending files.  Queueing under the cap is unchanged (#333/#334).  (#384)

### Fixed

- **Security — ReDoS (polynomial parse-time DoS)** — five un-hardened siblings
  of the #337 fix (the `\s+…(<lazy>)\s*$` shape) across fortigate / arista /
  iosxe_cli / aoss, all reachable via an unauthenticated 10 MB
  `POST /plan raw_text`, anchored to linear time.  (#358)
- **Security — egress allow-list transition formats (SEC-3)** — the
  `block_private_egress` guard only unwrapped IPv4-mapped IPv6, so NAT64
  (`64:ff9b::/96`), 6to4 and the deprecated IPv4-compatible literals embedding
  a loopback / metadata IPv4 slipped through; every embedded IPv4 is now
  unwrapped and re-checked (shared with the sanitizer's leak guard).  (#383)
- **Codec data-loss** — management-plane parsers no longer swallow trailing
  keyword tokens (NX-OS `use-vrf`, IOS-XR `vrf`, static-route tag / bfd); the
  NVE VLAN-VNI binding tolerates an `ingress-replication` tail; FortiGate
  VARP-only SVIs stop emitting an invalid `set ip`.  (#363, #364)
- **Aruba AOS-S static-route admin distance** — a static route carrying a
  trailing ``distance N`` / bare-metric token was silently dropped WHOLE by
  the end-anchored route regex; the distance is now parsed into the canonical
  ``metric`` and emitted on render, so it round-trips (and Aruba preserves
  route metric as a render target too).  (#386)
- **Backup subsystem** — desktop TOFU host-keys persist again (the job
  `settings` snapshot was dead, silently degrading changed-key MITM detection);
  a live job can no longer vanish through a stale-pending eviction window; a
  crashed runner is finalized (was stranded non-terminal with no log line); LRU
  overshoot is reclaimed to cap; a submit-vs-shutdown race retries instead of
  500; a concurrent poll can't observe `failed` with a null error.  (#360,
  #361, #375)
- **API robustness** — hardened the un-hardened error / persistence siblings:
  `/sanitize` and the CRUD routes return a `4xx` instead of `500` on
  corrupt-input / missing-id / disk-full, with in-memory (and APScheduler)
  rollback on a failed persist so a listed resource can't resurrect on restart;
  `DeviceProfileUpdate` rejects unknown fields.  (#374)
- **Performance** — the junos trunk-member dedup (O(range×list), ~265× faster
  on a 32-line stanza) and six O(N²) mgmt-plane server-list scans are
  set-guarded.  (#359)

### Changed

- **Fidelity ratchet** — the cross-vendor mesh now pins an absolute CODEC_BUG
  ceiling and the METHODOLOGY over- / under-declaration buckets against the
  committed baseline (a wire-up that lands without flipping its disposition YAML
  turns CI red), baseline writes are gated behind `--write-baseline`, and
  `CROSS_MESH_RESULTS.md` byte-reproducibility is guarded.  Over-declared-lossy
  cells with no drifting fixture were audited and the genuinely-stale ones
  promoted to `good`.  (#365, #366, #367, #368, #369, #373, #381)
- **OpenAPI response surface** — the CRUD routers now declare their full
  non-2xx response space (400 / 403 / 404 / 409 / 500 / 501), verified by an
  AST guard that pins every inline-raised status.  (#382)
- **Honesty corrections** — SNMP v3 passphrase hashes are treated as cosmetic in
  the mesh comparator; an `opnsense → cisco_iosxe` dns disposition was corrected
  from `not_applicable` to `unsupported`; stale VRF / DNS / VXLAN reason text
  across the expectation YAMLs and operator docs was swept to match the HEAD
  codecs.  (#355, #380, #371, #372, #376)
- **Packaging / release** — closed a batch of release-path footguns (the
  `:latest` gate, the MSI prerelease predicate + tag normalization, floating
  action SHAs, the changelog-guard skip, the sdist-leak gate), pinned the cosign
  verify recipe to the exact per-version identity, and added a
  Dockerfile ↔ lockfile digest lockstep guard.  (#370, #377)
- **Test guards** — un-skipped vacuous canaries, fixed a query-string POST
  bypass in the auto-wait test client, and made the certainty-header guard fail
  (not skip) for real codecs.  (#378)

## [0.5.5] - 2026-07-07

Post-review hardening release: the complete remediation of the 2026-07-06
Fable multi-lens review of v0.5.3 (verdict *SHIP-WITH-FIXES*; zero parse
crashes across ~18k fuzzed inputs, every prior v0.5.x remediation verified
healthy).  62 findings across three waves — the rename-transform seam that
could silently render inconsistent / duplicate-IP configs on the default
`/plan` flow, silent VLAN / DNS / next-hop corruption on documented Junos and
MikroTik input shapes, a Tier-1 docs over-promise, honest lossy declarations,
concurrency / jobs hardening, and a dedicated backup thread pool.  One new
operator env knob (`NETCANON_MAX_CONCURRENT_BACKUP_JOBS`); **no breaking API
changes**.  As in the prior review, the dominant pattern was a hardened path
with an un-hardened sibling.  (0.5.4 skipped intentionally.)

### Added

- **`NETCANON_MAX_CONCURRENT_BACKUP_JOBS`** — backup jobs now run on a
  dedicated, capped `ThreadPoolExecutor` (default 8 concurrent jobs) instead of
  the shared request thread pools.  A minutes-long backup used to hold one of
  anyio's ~40 default worker tokens (manual path) or share the default executor
  with `/sanitize` + the egress filter (scheduled path), so a pile-up starved
  every synchronous route; both entry points now dispatch to the dedicated
  pool.  The manual `POST /api/v1/backups` runs the job truly in the background
  — it returns `pending` and callers poll `GET /{id}` for the terminal state
  (the documented contract, now enforced end-to-end).  (#333, #334)

### Fixed

- **Rename-transform seam — silent inconsistent / duplicate-IP output (DATA-1/2)**
  — the default `/plan` rename+translate seam walked an incomplete set of
  canonical cross-references: a rename could leave dangling VXLAN
  `source_interface` / VRRP `track_interfaces`, a `{A: B, B: None}` map dropped
  *both* endpoints, and only one of the two VLAN-id surfaces was translated — so
  the flow could render internally inconsistent or duplicate-IP configs with
  `warnings=[]`.  The sweep now covers those cross-refs and both `dot1q_vlan` +
  `vxlan_vnis[].vlan_id`.  (#315)  Four further rename-bookkeeping gaps
  (collision warn-only, SVI `Vlan<N>` tracking, empty-key guard, two-pass
  switchport projection) and the applied/dropped accounting + not-found
  warnings are closed too.  (#323, #306)
- **Junos block-form input silently corrupted VLANs (11-F1)** — a Junos
  `vlan members [ v1 v2 ]` bracket list and numeric / `range` members were
  dropped, and a rename left stale `apply-groups` references; both orchestrators
  now expand the members and flatten stale group content (mirroring
  `sanitize`).  (#316)
- **MikroTik `%`-gateway + Aruba SVI IPv6 (11-F3 / F2)** — a MikroTik
  `gateway=<ip>%<iface>` next-hop lost its interface (link-local v6 broke
  entirely); an Aruba AOS-S VLAN-context `ipv6 address` was dropped despite the
  matrix declaring it supported.  Both now round-trip.  (#317)
- **Per-pane endpoints skipped port translation** — the four per-pane
  `/plan/{vlans,local_users,snmp,snmpv3}` endpoints did not engage
  interface-name translation the main `/plan` flow does.  (#324)
- **Arista `vrf definition` body dropped** — the legacy (≤4.22) `vrf definition`
  stanza body (route-distinguisher / route-target / description) was lost on
  harvest.  (#327)
- **MikroTik VRRP virtual-IP prefix** — a VRRP virtual address took the parent
  interface's mask instead of its own configured prefix.  (#326)
- **Live backup jobs could vanish** — a running (non-terminal) job could be
  LRU-evicted from the in-memory registry mid-run (→ `GET /{id}` 404 for a job
  that is genuinely running), and scheduled jobs weren't persisted at creation
  (invisible for their whole run — the primary unattended mode).  Non-terminal
  jobs are now never evicted and scheduled jobs persist immediately.  (#328)
- **Non-UTF-8 stored config → opaque 500** — a stored config containing
  non-UTF-8 bytes raised an uncaught decode error (HTTP 500) on read / diff /
  migrate / detect; content is now decoded with `errors="replace"`.  (#329)
- **Lock siblings** — the device-profile registry cap is enforced under the
  registry lock, and the scheduler snapshots the schedule set before iterating
  (an unlocked iteration could silently skip a run).  (#304)
- **CLI / open-config / profile save** — CLI `sanitize` guards its `-o` write,
  the open-config endpoint returns `501` when disabled, and a failed
  device-profile save rolls back cleanly.  (#305)
- **Sanitize errors + OpenAPI + device type** — `/sanitize` returns a unified
  `422` on bad input with an accurate OpenAPI `responses` block, and a device
  `type_key` absent from the library is a clean `422`.  (#308)
- **Quadratic parse/diff scans** — four O(n²) hot-path scans (FortiGate / Arista
  / Junos parse, and the config diff) replaced with dict/set lookups.  (#322)

### Changed

- **Tier-1 docs over-promise (HIGH #1)** — the front-page "Tier 1 … every
  shipped codec parses + renders these fully" claim listed **timezone** (wired
  on 0/12 codecs) and **syslog** (1/12); both are removed and DNS/NTP scoped to
  "most codecs — see the per-codec tables".  A new guard pins the claim to the
  live matrix.  (#319)
- **CAPABILITIES §A honesty** — dropped the "enumerates every path" completeness
  claim (the live capability matrix is the source of truth), fixed a
  wrong-direction row (`iosxe_cli virtual-gateway-address` → **Lossy**), and
  removed five stale hard-coded "N supported surfaces" counts.  (#320)
- **Honest lossy declarations** — MikroTik / Aruba SNMPv3 auth+priv-protocol
  substitution and MikroTik DHCP lease-time loss now declare `lossy` instead of
  silently normalising while reporting `severity: ok`.  (#325)
- **PUT device-profile null semantics** — an explicit `null` in a
  `PUT /api/v1/devices/{id}` body now clears a nullable field (and a required
  field returns `422`) instead of being silently ignored.  (#318)
- **Docs microcopy** — corrected v0.5.x version-echo / `strip_unsupported`
  drift (#303) and three stale microcopy claims (a CI-gate description, the
  VyOS "in progress" status now that it's graduated, and a stale SNMPv3 / VRRP
  migrate-page example).  (#330)
- **Same-vendor idempotency + internal dedup** — a MikroTik render now echoes
  its RouterOS version header so the v6 NTP dialect gate is idempotent across a
  double-sanitize (#311); the same-vendor version-echo logic and the
  `iter_xpaths` codec walk were de-duplicated into shared helpers with no
  behaviour change (#312, #313).
- **CI / test guards** — the PII recurrence guard was widened to every
  operator-path spelling and given a publish-time backstop in all three publish
  workflows (#332, #309); the complexity ratchet catches every `noqa` spelling
  (#310); the cross-mesh CI guard gained a YAML-less pair-drift ratchet, a
  coverage pin, a fixture-scoped render allowlist, and a field-targeted diff
  (#321, #331); the three review artifacts are committed in-tree under
  `docs/reviews/` (#314).

### Security

- **Docker `:latest` gating + TOFU host-keys** — the Docker `:latest` tag is
  gated off pre-release tags (a pre-release could otherwise move `:latest`), and
  the paramiko trust-on-first-use host-key store is now read-merge-write instead
  of overwrite (the overwrite silently re-opened the MITM window the v0.4.5
  TOFU default closed).  (#307)

### Performance

- **Linear `trunk allowed` merge** — the `switchport trunk allowed vlan add`
  merge is now linear (was O(|ids|·|base|); a ~250 KB config, well under the
  10 MB cap, pinned a threadpool worker for minutes).  (#318)

## [0.5.3] - 2026-07-05

Follow-on to the 2026-07-05 version-vector feasibility assessment: the one
semantic version-gating slice worth shipping, plus its truth-maintenance
docs.  No breaking changes and no new public API.

### Added

- **MikroTik RouterOS-6 NTP dialect** — on a *same-vendor* render (sanitize,
  or a mikrotik→mikrotik plan) of a device whose captured version is
  RouterOS 6, the `/system ntp client` block now emits the v6
  `set ... primary-ntp=A [secondary-ntp=B]` form (1–2 IP-literal servers)
  instead of the v7 `servers=` form a 6.x device rejects.  Gated on
  affirmative same-vendor v6 evidence; cross-vendor, unknown-version,
  hostname, and 3+-server renders keep the v7 form byte-identically, so the
  cross-mesh baseline is unchanged.  This is the sole version-gating slice
  the assessment recommended building — the rest of the "version vector"
  stays out of scope.  (#299)

### Changed

- **Docs (truth maintenance)** — reconciled three stale version claims now
  that all 12 parsers populate `source_version` and version-conditional
  rendering actually exists: the `codecs/base.py` `version_hint` "version
  gating lives in the CapabilityMatrix" phantom, and the `ARCHITECTURE.md`
  / `migrate.html` "parsers don't yet populate source_version" notes.  (#300)

## [0.5.2] - 2026-07-05

Codec fidelity fixes surfaced by the 2026-07-05 version-vector feasibility
assessment: five real silent-loss / mislabel bugs, each shipped as an ordinary
version-AGNOSTIC correction (union grammar or same-vendor echo), NOT as
version-gated behaviour.  **No breaking changes and no new features.**

### Fixed

- **MikroTik NTP** — the parser read only the RouterOS 7 inline `servers=` NTP
  form, silently dropping every server written in the RouterOS 6
  `primary-ntp=` / `secondary-ntp=` / `server-dns-names=` form or the RouterOS 7
  `/system ntp client servers` subsection.  All three forms are now union-parsed
  (order-preserving, de-duplicated, skipping the empty and `0.0.0.0` unset
  sentinels).  (#293)
- **Junos pre-ELS switchport mode** — pre-ELS Junos (and platforms like the
  EX4550 that stayed non-ELS at 15.1) spell the L2 mode `port-mode`, where ELS
  spells it `interface-mode`.  The parser recognised only `interface-mode`, so a
  `port-mode access` port fell through to the `vlan members` trunk-default and an
  **access port silently became a trunk** on reparse.  `port-mode` is now
  accepted alongside `interface-mode`.  (#294)
- **Arista legacy VRF** — top-level `vrf definition <name>` stanzas (EOS 4.22 and
  earlier) were dropped from `routing_instances`; only the modern `vrf instance`
  form was harvested.  Both forms are now accepted.  (#295)
- **Cisco IOS-12 VRF** — classic top-level `ip vrf <name>` stanzas were dropped
  from `routing_instances` (only the modern `vrf definition` form was harvested),
  leaving a dangling interface `ip vrf forwarding` reference.  Both forms are now
  accepted.  (#296)
- **Version-stamp fidelity** — on a same-vendor re-render (sanitize, or an X→X
  migration) the cisco_nxos / cisco_iosxr / aruba_aoscx / vyos renderers stamped
  a hardcoded release constant into their version banner, silently relabeling the
  device's real OS version (e.g. sanitizing a `10.3(2)` NX-OS config returned
  `9.3(11)`).  Each now echoes the source device's own `source_version` when the
  tree came from that codec; cross-vendor and unknown-version renders stay
  byte-identical.  (#297)

### Changed

- **API-7 docs** — removed a documented `strip_unsupported` migration affordance
  that was never implemented; the `force` / `block` unsupported-handling
  behaviour was already correct, so only the docs needed reconciling.  (#292)

## [0.5.1] - 2026-07-04

Post-v0.5.0 hardening tail: three small batches remediating the deferred
low-value items from the 2026-07-03 Fable-5 review.  All defense-in-depth,
concurrency and CI-hygiene fixes — **no breaking changes and no new
features**.

### Security

- **SEC-9** — added a `Content-Security-Policy` header.  Every response gets a
  strict same-origin policy (`default-src`/`connect-src 'self'`, `object-src
  'none'`, `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'`);
  script/style keep `'unsafe-inline'` because the UI is inline-heavy.  The
  `/docs` page gets a scoped variant that additionally allows the Swagger UI
  CDN (jsDelivr) it loads from — a same-origin-only policy would blank it.
  Audited live in the browser (zero violations across the UI; /docs renders
  fully).  (#290)
- **SEC-5** — the paramiko `_drain` pre-command flush now has absolute
  wall-clock + byte caps that its idle-reset can't extend past, so a device
  that streams without pausing can no longer hang the worker or grow the read
  buffer unbounded (OOM).  (#288)

### Fixed

- **CONC-3** — `FileConfigStore.save` serialises its collision-resolution →
  write → rename under a store lock, so two concurrent saves for the same
  device+second can't both claim the base path (a TOCTOU that silently
  collapsed two backups into one clobbered file).  (#289)
- **CONC-5** — a backup job is persisted to disk at creation, so a job
  LRU-evicted from the in-memory registry *while still running* is still
  reachable via the disk fallback instead of returning a `404` for an active
  job.  (#289)
- **CONC-7** — `_get_fernet` uses double-checked locking around lazy key
  initialisation, so on a fresh install two threads racing the first
  encrypt/decrypt can no longer each generate and persist a *different* key
  (last-writer-wins on the key file), which would fail the loser's ciphertext
  closed later.  (#289)

### Changed

- **PKG-4** — the release workflows' write scopes (`contents: write` on the
  MSI build; `packages`/`id-token`/`security-events: write` on the Docker
  build) are declared at the job level so the publish-time pytest gate runs
  read-only, matching pypi-publish.yml's already-scoped model.  (#288)
- **PKG-5** — the PyPI and Docker publish jobs refuse to run from anything but
  a version tag, so a `workflow_dispatch` on a branch can no longer build a
  PyPI-legal `.devN` release (or move the Docker `:latest` tag onto one).
  (#288)
- **API-7 (docs)** — corrected the `LossyPath` / `UnsupportedPath` / severity
  docstrings that claimed `force=True` clears a validation `block`.  It does
  not: `force` only skips the cross-device-class guard; a `block` renders
  anyway and flags the job `partial` — `strip_unsupported` is the mechanism
  that actually drops the offending paths.  (#288)
  _Erratum (v0.5.2, #292): the `strip_unsupported` mechanism named above
  never existed — it was vaporware.  `force` / `block` behaviour was already
  correct, so the affordance was dropped rather than implemented; see the
  [0.5.2] entry._

## [0.5.0] - 2026-07-04

Post-review hardening release: the complete remediation of the 2026-07-03
Fable-5 full-project review (verdict *GO-WITH-FIXES, healthy codebase*).  22
fixes spanning security, codec fidelity/honesty, concurrency, robustness,
packaging and docs — **no breaking changes and no new features**.  The review's
headline pattern was "a hardened path with an un-hardened sibling" (a guard
written once but never mirrored to its twin); most fixes below port an existing
in-tree defence to the surface that lacked it.

### Security

- **SEC-3** — `GET /api/v1/backups/{job_id}` now constrains `job_id` to a strict
  UUID pattern, closing a path-traversal / existence-oracle vector that reached
  the file-store path join unguarded (the sibling config store was already
  guarded).  A malformed id is a clean `422`.  (#282)
- **SEC-1** — `GET /api/v1/schedules/` no longer echoes legacy inline device
  `password` / `enable_password` over the read API (`BackupSchedulePublic`).
  (#271)
- **SEC-2 / SEC-7** — the device-profile and schedule stores scrub a pydantic
  `ValidationError`'s `input_value` before logging (`scrub_exc_for_log`), and
  SECURITY.md's credential-log claim now cites the test that actually asserts
  it.  (#270)
- **SEC-6** — the SNMP community (a shared secret) is no longer logged verbatim
  at DEBUG nor echoed into operator-facing rename warnings — presence-only.
  (#282)
- **SEC-10** — `POST /api/v1/migration/detect` caps `raw_text` at 10 MB, matching
  `/plan` (DoS-surface parity).  (#282)
- **SEC-11** — the opt-in egress allow-list now blocks the unspecified address
  (`0.0.0.0` / `::`, incl. IPv4-mapped), which routes to loopback on many stacks.
  (#282)

### Fixed

- **PKG-1 (HIGH)** — the Docker image (and the MSI) shipped an **empty vendor
  registry**: `migration/vendors/*.yaml` reached the wheel only via
  setuptools_scm's VCS file-finder, so the git-less container build loaded zero
  vendors and the translator core was non-functional.  Added the package-data
  glob; the docker-smoke gate now asserts a vendor actually loads.  (#265)
- **API-1** — `POST /api/v1/migration/plan` with a non-port override map passed
  `port_rename_map=None`, silently disengaging the translator so interface names
  rendered verbatim.  Restores the v0.3.2 auto-translate-by-default contract for
  API callers.  (#266)
- **UX-1** — the migrate-page parse-failure banner/button rendered the literal
  text "undefined" instead of the codec label.  (#267)
- **ROB-1** — the opnsense and cisco_iosxe (XML) parsers raised a raw `ValueError`
  on a malformed numeric field (`<vlan><tag>`, `<subinterface><index>`), escaping
  the codec boundary as an HTTP 500; both now raise `ParseError` (clean 4xx).
  (#277)
- **CODEC-1 / CODEC-3** — `fortigate_cli`, `aruba_aoss` and `mikrotik_routeros`
  now escape `"` and `\` inside quoted free-text (description / SNMP
  location+contact / community), so a value containing a quote no longer emits
  device-invalid config; matched parse-side unescaping keeps the round-trip
  stable.  (#275)
- **VyOS free-text quotes** — VyOS rejects an embedded double-quote in a value
  string even when escaped (vyos.dev/T1246), so its renderer *sanitises* the
  quote (to an apostrophe) rather than escaping it, and logs the alteration.
  (#281)
- **CODEC-2** — a VyOS `vif <vid>` sub-interface now populates `dot1q_vlan`, so it
  cross-renders to a valid routed sub-interface (e.g. Cisco IOS `encapsulation
  dot1Q <vid>`) instead of a tag-less, device-rejected one.  (#276)
- **MTX-1 / MTX-2** — `cisco_nxos` and `aruba_aoscx` declare `/vxlan-vnis/udp-port`
  **lossy**: they silently normalised a non-default VXLAN UDP port to 4789 while
  reporting `severity: ok`.  (#278)
- **MTX-4** — `cisco_iosxr` and `vyos` now declare `/anycast-gateway-mac`
  **unsupported** (was undeclared → fail-open to supported).  (#280)
- **MTX-5** — the VLAN/SVI-mount secondary-IP walk now uses address cardinality
  (not the `is_secondary` flag), so a flagless multi-address SVI's dropped
  secondary surfaces in the validation report instead of `severity: ok` —
  mirroring the interface-mount fix.  (#284)
- **CONC-1** — `BackupJobRegistry` is lock-guarded; a concurrent poll during a
  list no longer raises `RuntimeError: OrderedDict mutated during iteration`
  (user-visible 500).  (#272)
- **CONC-2** — the schedule store is lock-guarded and re-checks existence before
  its post-run save, so a schedule deleted mid-run is no longer resurrected on
  disk.  (#274)
- **CONC-4 / CONC-6 / CONC-8 / CONC-9** — `list_configs` skips crash-orphaned
  `.tmp` files; the scheduler snapshots the device-profile registry under its
  lock (was an unlocked iteration that could silently skip a run); the paramiko
  collector closes its client on a connect failure; and the two-sided config diff
  returns `404` (not `500`) when a config is deleted mid-request.  (#283)
- **PERF-1 / PERF-4** — the Aruba AOS-S port-range expander and the target-profile
  `range:` shorthand both clamp their span, so a hostile / typo'd range can't
  allocate ~1e9 / ~1e6 entries.  (#273, #286)
- **API-3 / API-4 / API-5** — `sanitize` catches `RenderError` (was an uncaught
  traceback / 500), rejects input the declared codec can't parse instead of
  emitting an empty scaffold with zero substitutions, and the demo reports a
  `partial` job as non-success.  (#285)
- **TEST-1** — the `/detect` stored-config end-to-end test no longer
  permanently self-skips (it compared against `200` where the backup route
  returns `202`), and feeds real OPNsense content.  (#279)

### Changed

- **GUARD-1** — the two PII review directories are gitignored and the PII guard
  gained a tracked-path assertion (its prior 'is gitignored' comment was false).
  (#268)
- **Doc / matrix drift** — corrected the Junos dot1q lossy-reason, four
  CAPABILITIES.md drifts, SECURITY.md's browser-fetch claim, the CONTRIBUTING
  regen-tool name, THIRD-PARTY-NOTICES (+7 missing deps), the lock-gen base
  digest, and two API docstrings (`/plan/snmpv3`, `port_rename_map`).  (#269, #285)
- **TEST-2 / TEST-5** — the cross-mesh CI guard now catches a per-pair codec-bug
  regression (not just the aggregate total + pair set), and the best-effort
  tracemalloc memory test skips rather than fatally aborting the `-x` suite on a
  flake.  (#286)

## [0.4.15] - 2026-07-03

### Fixed

- **IPv6 static routes now round-trip correctly across every codec.**  A
  cross-vendor dogfood mesh over a large real-world corpus surfaced that an
  IPv6 static route (a `CanonicalStaticRoute` with a v6 destination) was
  mishandled by most codecs: FortiGate and Cisco IOS-XE CLI **crashed**
  (`RenderError: prefix length ... out of range`) by forcing the route
  through the IPv4 dotted-mask path; Arista EOS, Cisco NX-OS and both Aruba
  codecs emitted an invalid `ip route <v6-prefix>` (wrong keyword); and
  Cisco IOS-XR filed the route under `address-family ipv4 unicast`.  Each
  now emits its native form -- `ipv6 route <prefix> <next-hop>` (IOS-XE CLI
  #251, NX-OS #254, Arista #253, Aruba AOS-CX #255, AOS-S #256),
  `config router static6` (FortiGate #252), and `address-family ipv6
  unicast` (IOS-XR #257) -- and the Cisco codecs parse the v6 form back so
  the route survives a round-trip (IOS-XE CLI #258, NX-OS #259, IOS-XR
  #260).  Junos, MikroTik and VyOS were already correct; OPNsense and the
  IOS-XE NETCONF codec continue to declare the static-route surface lossy.
- **Arista EOS: an indented `!` comment no longer closes the interface
  stanza.**  A `!`-prefixed comment between interface sub-commands (e.g. a
  `! review:` note the renderer emits for an un-translatable attribute) was
  treated as an end-of-stanza delimiter, silently dropping every
  sub-command after it (mtu / ip address / ...) on reparse.  Only a
  column-0 `!` closes the block now.  (#249)
- **Arista EOS: the legacy `vrf forwarding <name>` interface syntax now
  parses to `<name>`.**  The parser captured the `forwarding` keyword into
  the VRF name (`forwarding mgmt` instead of `mgmt`), which then rendered a
  bogus space-bearing VRF on every target.  (#250)
- **MikroTik RouterOS: a VLAN's `vlan-id` is taken from the VLAN record,
  not the interface-name digits.**  An SVI whose name digits differed from
  its 802.1Q tag (`name=vlan202` carrying tag `20`) rendered the wrong
  `vlan-id` and, via a poisoned dedup set, a phantom duplicate VLAN.  (#261)
- **MikroTik RouterOS: free-form `name=` / `interface=` values containing a
  space are quoted.**  Three render sites emitted the value bare (unlike
  their peers), so a name with a space truncated at the first space on
  reparse; the quoting helper is a no-op for the common space-free case.
  (#262)
- **Cisco IOS-XR: `source_version` is extracted from the newer
  `!! IOS XR Configuration version = <rel>` banner** (as well as the older
  `... Configuration <rel>` form), instead of capturing the literal word
  `version`.  (#263)

### Dependencies

- Bump the Python base image `3.14.5` -> `3.14.6-slim-bookworm` (tag +
  digest, both build stages).  (#232)
- Bump the github-actions group: `actions/checkout` v6 -> v7,
  `actions/setup-python` -> v6.2.0, and digest bumps for
  `softprops/action-gh-release` and `zizmorcore/zizmor-action`.  (#210)

## [0.4.14] - 2026-07-02

### Fixed

- **Codec `parse()` no longer leaks a raw pydantic validation error.**
  A parsed value that violates a canonical field constraint — a VLAN id
  above 4094, an IPv4 prefix length above 32, a VRRP priority above its
  ceiling, an out-of-range FHRP group id — now surfaces as the
  documented `ParseError` at the `CodecBase` boundary instead of
  escaping as a raw `ValidationError`, so the HTTP routes return 400
  rather than a 500 and the CLI reports cleanly.  This generalises the
  single sanitize-boundary conversion from 0.4.11 into one uniform guard
  applied to every codec and every caller (the migrate pipeline, the
  cross-vendor mesh, sanitize, and the CLI).  (#246)

### Changed

- **FHRP priority / group-id widened to the full HSRP union so
  legitimate values are preserved rather than rejected.**
  `CanonicalVRRPGroup.priority` now spans `0-255` (was `1-254`) and
  `group_id` spans `0-4095` (the lower bound was `1`), representing HSRP
  group 0, priority 0, and priority 255 (the HSRP maximum / VRRP
  address-owner value) which the VRRP-centric bounds previously refused
  (under the 0.4.14 boundary above they became a clean `ParseError`, but
  the data was still dropped).  FortiGate now preserves a legitimate
  `set priority 255` verbatim instead of clamping it to 254, and Aruba
  AOS-S maps `owner` to priority 255 symmetrically (parse `owner` → 255,
  render 255 → `owner`) so the address-owner role round-trips.  (#247)

## [0.4.13] - 2026-07-02

### Added

- **Routed sub-interface 802.1Q tag now translates across the L3
  codecs.**  A new `CanonicalInterface.dot1q_vlan` surface captures the
  802.1Q tag on a *routed* (layer-3) sub-interface — Cisco
  `encapsulation dot1Q N`, Juniper `unit N vlan-id`, Arista
  `encapsulation dot1q vlan N` — distinct from the L2 access-VLAN
  field.  It round-trips and translates across Juniper Junos, Cisco
  IOS-XE CLI, Cisco NX-OS, Arista EOS, and Cisco IOS-XR.  Previously a
  Junos routed sub-interface tag was mis-mapped onto the L2 access-VLAN
  field and rendered as `switchport access vlan N` on a
  switchport-centric target (a semantic inversion) or silently dropped.
  Codecs without a routed sub-interface construct (Aruba AOS-CX/AOS-S,
  FortiGate, MikroTik, OPNsense) honestly declare the surface
  unsupported.  (GAP 7.)

### Fixed

- **NX-OS auto-detection no longer mis-classifies as Cisco IOS-XE
  CLI.**  NX-OS configs whose vendor tell is the `feature <name>`
  keyword, the `nxapi` management plane, or a `!RANCID-CONTENT-TYPE:
  cisco-nx` collection header now detect as NX-OS instead of losing the
  alphabetical tie-break to `cisco_iosxe_cli`.  The RANCID/oxidized
  collection header is now a high-precision detection signal across the
  Cisco (IOS/NX-OS/IOS-XR), Arista, and Juniper probes.  Corpus
  detection agreement rose to 100% on the RANCID-labelled configs.

- **NX-OS HSRPv2 groups are now represented instead of rejected.**  The
  `CanonicalVRRPGroup.group_id` bound widened from 0-255 (VRRP VRID) to
  0-4095 (the VRRP/HSRP union), so an `hsrp 301` group parses and
  round-trips rather than raising a validation error.

## [0.4.12] - 2026-07-02

### Added

- **`source_version` is now captured on parse by all 12 codecs** (was
  3). Each codec's parser records the source device's firmware/OS
  release into `CanonicalIntent.source_version`: the running-config
  `version` line (Cisco IOS-XE), the `! device: (…, EOS-4.27.0F)`
  banner (Arista EOS), `set version` / block-form `version X;` (Junos),
  the `Created on release #` banner (Aruba AOS-Switch), the
  `#config-version` header (FortiGate), the `by RouterOS` export header
  (MikroTik), the trailing `// Release version:` comment (VyOS — not the
  `vyos-config-version` schema marker), and the OpenConfig
  `<software-version>` element (Cisco IOS-XE NETCONF). OPNsense records
  an honest empty string — its `config.xml` carries only a config-schema
  revision, not the OS release, so conflating them would poison the
  data. The field is metadata only: render does not echo it and it is
  excluded from the round-trip and cross-vendor comparators, so
  behaviour is unchanged — this lays the data groundwork for future
  version-aware translation.

### Fixed

- **`arista_eos` auto-detection no longer mis-classifies marker-light EOS
  configs as `cisco_iosxe_cli`** (surfaced by the dogfood detection
  label-noise sweep). Real EOS configs whose only vendor tell was a `.swi`
  boot image or a `!RANCID-CONTENT-TYPE: arista` collection header — with no
  `! device: … EOS-` banner — scored 0 on the Arista probe and fell through
  to the generic IOS-CLI codec (batfish `eos_mlag` / `arista-originator` /
  `arista_dhcp_relay` were detected as `cisco_iosxe_cli` at margin 70-90).
  The probe now recognises the Arista-unique `.swi` boot image (Cisco boots
  `.bin`) and the `RANCID-CONTENT-TYPE` header. Corpus detection agreement
  vs batfish's own filename labels rose 81% → 94%.

- **`mikrotik_routeros` no longer silently re-enables a shut interface**
  (surfaced by the dogfood negation-semantics sweep). A source interface
  administratively down (`enabled=False`) rendered to RouterOS as a VLAN
  SVI, bridge, or loopback dropped the `disabled=yes` attribute — only the
  physical-ethernet path emitted it — so RouterOS defaulted the interface
  up and the shutdown silently INVERTED on reparse (a shut interface came
  back up). The vlan/bridge/loopback `add` paths now emit `disabled=yes`
  when the interface is disabled, and the bridge parser reads `disabled=`
  so a shut loopback round-trips too. Was 10 real shut→up inversions on
  the aruba_aoscx / cisco_iosxr → mikrotik pairs; the lone remaining case
  (iosxr→aruba_aoss loopback) is a genuine AOS-S platform limitation
  (loopbacks are always-up), not a defect.

## [0.4.11] - 2026-06-30

### Fixed

- **`/api/v1/sanitize` (and the `netcanon sanitize` CLI) no longer
  returns 500 on input that yields an out-of-range canonical value**
  (surfaced by the dogfood mesh harness driving the LIVE endpoint). A
  config that parses structurally but maps to a field outside its
  canonical constraint -- e.g. an NX-OS `hsrp 301` group-id exceeding
  the VRRP 0-255 range that `CanonicalVRRPGroup.group_id` enforces --
  made the codec's `parse()` raise a pydantic `ValidationError` (not the
  contract's `ParseError`), which escaped the route's `except ParseError`
  and surfaced as a 500. `sanitize_text` now converts a parse-time
  `ValidationError` into a `ParseError`, so the endpoint returns a clean
  400 and the CLI reports it instead of dumping a traceback. (The
  `/migration/plan` path already degraded to a failed job, not a 500.)

- **`mikrotik_routeros` no longer aborts a whole config over a bare
  host address** (surfaced by the dogfood mesh harness on real
  RouterOS `.rsc` exports). `/ip address` rows for loopbacks and VRRP
  VIPs are commonly written `address=X ... network=X` with no CIDR
  mask; the parser raised `ParseError: address 'X' missing CIDR
  prefix` and dropped the entire device. A host whose network base
  equals the host itself is only consistent with a `/32`, so the
  parser now infers `/32` for a prefix-less address instead of failing
  (20 real corpus configs went from hard-fail to clean parse). An
  address carrying an explicit but non-numeric prefix (`X/bogus`)
  still raises.

- **`opnsense` splits comma-joined system DNS servers** (surfaced by
  the dogfood mesh harness on real `config.xml`). A `<system>` config
  that crams several resolvers into one element
  (`<dnsserver>10.0.1.10, 10.0.1.11</dnsserver>`) was parsed as a
  single bogus DNS entry, which then rendered as one malformed
  name-server downstream (`ip name-server 10.0.1.10, 10.0.1.11`,
  reparsing to `10.0.1.10,`). System `<dnsserver>` values are now
  split on commas so each resolver becomes its own canonical entry,
  matching the existing per-DHCP-zone handling.

- **`cisco_nxos` SNMPv3 `priv` key no longer leaks through the
  sanitizer** (surfaced by the dogfood mesh harness on real napalm
  NX-OS captures). The USM parser assumed `priv <cipher> <key>` (two
  tokens), but NX-OS just as often emits the bare `priv <key>` form
  (default DES, no explicit cipher). The greedy regex then captured
  the real priv key as the "cipher" -- landing it in the un-sanitized
  `priv_protocol` field -- and the trailing `localizedkey` keyword as
  the key. `netcanon sanitize` therefore re-emitted the privacy key
  verbatim. The cipher is now matched against an enumerated token set
  (`aes-128`/`aes-192`/`aes-256`/`des`/`3des`) so the bare form parses
  the key into the redacted `priv_passphrase`; render emits `priv`
  whenever a passphrase is set, so the default-DES form also
  round-trips faithfully instead of dropping the key.

- **`cisco_iosxe` (NETCONF) auto-detection no longer over-claims NETCONF
  envelopes it can't parse** (surfaced by the dogfood mesh harness on
  real configs). The `probe()` matched any `<rpc-reply>`/`<data>` envelope,
  so an IOS-XR config delivered as `<rpc-reply><data><cli>...` was
  confidently mis-detected as `cisco_iosxe` and then failed parse with
  "no <interfaces> element found". The envelope branch now requires an
  `<interfaces>` payload (the only subtree the stub consumes); the
  OpenConfig-namespace and bare-`<interfaces>` paths are unchanged.

## [0.4.10] - 2026-06-30

### Fixed

- **Arista LACP mode no longer silently corrupted on round-trip**
  (audit bb47f21 T0-1). The `arista_eos` parser read `channel-group N
  mode <X>` but kept only the channel id and hard-coded the LAG
  `mode="active"`, so a `passive` or static (`on`) Arista bundle
  re-parsed as `active` -- `parse(render(x)) != x`, reported `ok` by the
  live validator. The parser now captures the mode token via a map
  (mirroring `cisco_nxos`), so active/passive/static round-trip. Codecs
  that genuinely cannot preserve the mode -- verified by a round-trip
  probe: `aruba_aoscx` (passive->static), `aruba_aoss`, `juniper_junos`
  (static->active), `mikrotik_routeros`, `opnsense` (passive->active) --
  now declare `/lags/lag/mode` lossy so the loss surfaces instead of
  reporting `ok`. A registry-wide LAG-mode value-fidelity guard pins it.

## [0.4.9] - 2026-06-29

### Added

- **`POST /api/v1/configs/diff` accepts an opt-in `context` fold control**
  (audit 276eaeb #18). The JSON diff serialised every line, including all
  unchanged ones — tens of thousands of rows for two large near-identical
  configs. Passing `context: <int>` now collapses runs of equal lines more
  than that many lines from any change: those cold lines are omitted from
  `lines` and counted in `stats.collapsed` (the `equal` stat still reflects
  the full diff). Omitting `context` returns the full report exactly as
  before — no contract change for existing callers.

### Fixed

- **OpenAPI schema now advertises the `/api/v1` bearer-token requirement**
  (audit 276eaeb #9). `require_api_key` enforced `Authorization: Bearer`
  as a plain dependency, invisible to OpenAPI, so `/docs` had no Authorize
  button and the gate wasn't discoverable from the schema. When
  `NETCANON_API_KEY` is set, the schema now declares a `BearerAuth`
  security scheme and applies it to every `/api/v1` operation; with no key
  the schema stays open (honest about the zero-config posture).
- **`POST /api/v1/configs/diff` walks the config store once, not twice**
  (audit 276eaeb T0-5). The route resolved the left and right records via
  two `_resolve_record` calls that each re-ran `storage.list_configs()`
  (a full storage directory walk), so every diff stat'd every stored file
  twice. It now builds a `{filename: record}` index once and resolves both
  sides from it, mirroring the HTML diff page. No behaviour change.
- **`POST /api/v1/sanitize` now caps the upload size** (audit 276eaeb
  #19). The endpoint read the entire request body into memory and fed it
  to the synchronous parse->redact->render pipeline with no limit, so an
  oversized upload was an availability footgun. It now reads at most
  `NETCANON_MAX_SANITIZE_UPLOAD_BYTES` (default 16 MiB) and returns `413`
  beyond that. Real configs are far below the cap; raise the env var or
  run the CLI `netcanon sanitize` for anything larger.
- **Doc-drift corrections** (audit 276eaeb): `SECURITY.md` listed the
  hash-locked dependency manifest as "Pending" though `requirements.lock`
  shipped in v0.4.8; the `aruba_to_arista.md` walkthrough cited a dead
  `POST /api/v1/migration/run` endpoint and a non-existent `rename_overrides`
  field (the real endpoint is `/api/v1/migration/plan`, the field is
  `port_rename_map` plus four sibling `*_rename_map` fields); and the
  `paramiko_collector` docstrings still described `auto_add` as the
  host-key-checking default (the default has been `tofu` since v0.4.5).
- **Multi-address interfaces no longer report a silent `ok` when extra
  addresses are dropped** (audit 276eaeb T0-1). The capability walker
  emitted `/interfaces/interface/ipv{4,6}/address/secondary-ip` only when
  an address carried `is_secondary=True`, but IPv6 has no `secondary`
  keyword (the flag is never set) and flagless IPv4 sources (Junos /
  OPNsense parse) leave it False on every address. So a genuinely
  multi-address interface migrated to a single-address codec (FortiGate /
  OPNsense, which render only the first address) reported `severity: ok`
  while a whole subnet's reachability vanished. The walker now keys on
  address *cardinality* (every address beyond the first), so the dropping
  codecs' existing `secondary-ip` unsupported declaration fires regardless
  of the flag. Capability matrices were already honest about which codecs
  drop extra addresses, so no declaration changes were needed.
- **IPv6 transition addresses no longer leak their embedded public IPv4**
  (audit 276eaeb T0-4): 6to4 (`2002::/16`), NAT64 (`64:ff9b::/96` +
  the RFC 8215 `64:ff9b:1::/48`), IPv4-mapped, IPv4-compatible, and
  Teredo addresses carry a routable IPv4 in their low bits but often
  classify as private/reserved at the v6 layer, so the sanitizer's
  preserve branch emitted them verbatim — leaking e.g.
  `2002:0808:0808::1` (→ 8.8.8.8) and `64:ff9b::8.8.8.8`. They are now
  redacted to a docs `2001:db8::N` address whenever the embedded IPv4
  is public; transition addresses wrapping a *private* IPv4 (e.g.
  `2002:c0a8:0101::`) are still preserved.
- **Paramiko shell collector fails closed on a never-settling stream**
  (audit 276eaeb T0-3): when a device kept streaming up to the
  `_MAX_SECONDS` cap without the read ever going idle, `_collect_output`
  used to *return* the truncated buffer, which the backup runner then
  saved with `status="success"` — a silently-corrupted backup. It now
  raises `TimeoutError` on that path (restoring parity with the
  read-timeout fail-closed `NetmikoCollector`), so a partial capture
  surfaces as a failed backup instead of a good-looking truncated one.

## [0.4.8] - 2026-06-29

### Fixed

* **Pinned the shipped Docker image's dependencies with a hash-locked
  manifest** (audit e5b77d7 finding #5: "no pinned/hash-locked dependency
  manifest for shipped artifacts"). A new `requirements.lock` -- 51 packages
  resolved with `--generate-hashes` inside the exact digest-pinned
  `python:3.14` base image (`tools/gen_requirements_lock.sh`) -- is installed
  by the Dockerfile builder with `pip --require-hashes`, so the container's
  dependency input set is constrained to exact versions and integrity-verified
  rather than re-resolved from pyproject's ranges at build time. The PyPI
  wheel deliberately keeps pyproject's ranges (it is a library). A consistency
  guard (`tests/unit/test_requirements_lock.py`) fails CI if the lock drifts
  out of sync with the declared dependencies.

* **Added a cyclomatic-complexity gate to CI** (audit e5b77d7 finding #4:
  "19 F-rank functions and no complexity gate"). Ruff's mccabe check (`C90`,
  `max-complexity = 25`) now fails the `Lint (ruff)` job on any new or
  modified function above the audit's recommended threshold. The ~25
  pre-existing hot spots (codec parse/render, the canonical walker, the
  sanitiser) are grandfathered with an inline `C901` suppression -- a visible,
  greppable backlog -- and a new ratchet guard
  (`tests/unit/test_complexity_ratchet.py`) pins that count so the debt can
  only shrink, never grow.

* **Hardened five config-parser regexes against polynomial ReDoS** (CodeQL
  `py/polynomial-redos`, alerts #124-#128). The Arista DHCP `dns-server`, the
  AOS-CX `vlan trunk allowed`, the NX-OS and IOS-XE VRF `description`, and the
  IOS-XE `ip route` patterns each paired a `\s+` separator (or a greedy next-hop
  `(\S+)`) with a value group whose character class overlapped it, so a long
  attacker-controlled line pasted through the migrate / sanitize API could drive
  super-linear backtracking. Anchored each value boundary on a disjoint class
  (`\S` for the value patterns, `\s` for the static-route trailing group);
  behaviour is identical for every real config because each consumer already
  `.split()`s or `.strip()`s the captured group (confirmed by a render -> parse
  equivalence check, pinned by `tests/unit/migration/test_redos_hardening.py`).

* **Static-route next-hop, routing-instance type, and IPv6 address scope no
  longer report a green `severity: ok` when dropped on translation.** Live
  validation walked the `/routing/static-route`, `/routing-instances/instance`,
  and `/interfaces/interface/ipv6/address` anchors but not these three
  discriminator sub-fields, so a migration that dropped a default-route next-hop,
  flattened a `mac-vrf` to a plain `vrf`, or lost an address's `link-local`
  scope showed a green banner on a real loss. The walker now yields
  `/routing/static-route/gateway`, `/routing-instances/instance/instance-type`,
  and `/interfaces/interface/ipv6/address/scope`, and every codec declares them
  per its actual render -- verified by a render -> parse round-trip probe
  (supported only where the value survives a round-trip; lossy where the anchor
  renders but the field downgrades; unsupported where the codec renders no such
  anchor). This closes the last three `KNOWN_GAP` walker exemptions, completing
  the walk-expansion begun in the SNMPv3 (PR-2a) and VRRP/FHRP (PR-2b) entries
  below (audit e5b77d7, PR-2c).

* **VRRP / FHRP redundancy sub-field losses no longer report a green
  `severity: ok`.** Live validation walked the
  `/interfaces/interface/vrrp-groups/group` anchor but not its election /
  timer / family sub-fields, so a migration that reinterpreted the FHRP
  family (e.g. HSRP -> VRRP), dropped the master-election priority / preempt /
  advertisement-interval, or lost the IPv6 virtual addresses / FHRP
  authentication / group description showed a green banner on a real
  redundancy loss. The walker now yields seven sub-paths (mode / priority /
  preempt / advertisement-interval / authentication / virtual-ipv6s /
  description) and all 12 codecs declare them per their actual render
  behaviour. Notably `mode` (the VRRP / HSRP / CARP discriminator) is lossy
  or unsupported on EVERY codec -- none renders all three families, so a
  cross-family translation always warrants review. Second surface of the
  tracked `KNOWN_GAP` walk-expansion ("PR-2"). Found by an independent blind
  audit (`e5b77d7`, T0-2 / PR-2b).
* **SNMPv3 USM sub-field losses no longer report a green `severity: ok`.**
  Live validation walked the `/snmp/v3-user` anchor but not its
  auth-protocol / priv-protocol / priv-passphrase / VACM-group sub-fields, so
  a migration that downgraded the SNMPv3 auth algorithm (e.g. SHA-256 ->
  SHA-1 on AOS-CX / VyOS), substituted the privacy cipher (3DES -> AES on
  FortiGate), re-keyed the opaque passphrase, or dropped the group showed a
  green banner on a real cryptographic downgrade. The canonical walker now
  yields these four sub-paths and the 12 codecs declare them per their actual
  render behaviour: faithful round-trips stay supported; auth/priv downgrades
  and key re-keys are `lossy` (reasons naming the downgrade); codecs that
  render no SNMPv3 (the cisco_iosxe NETCONF stub, cisco_iosxr, opnsense)
  declare them unsupported. First surface of the tracked `KNOWN_GAP`
  walk-expansion ("PR-2"). Found by an independent blind audit (`e5b77d7`,
  T0-2 / PR-2a).
* **The sanitiser no longer leaks the org domain for trailing-dot or
  underscore FQDNs.** `redact_host` maps a multi-label FQDN host field (NTP /
  syslog / SNMP-trap / RADIUS target) to an opaque `host-N.example.test`
  placeholder, but its hostname regex required the name to end in an
  alphanumeric label and allowed only hyphens -- so a rooted FQDN with a
  trailing root dot (`nms.corp.example.`) and a host whose label contains an
  underscore (`srv_01.corp.example`, common in AD / SRV / operator names)
  failed to match and passed through verbatim, re-leaking the domain. The
  regex now accepts an optional trailing dot and underscores in labels; bare
  single labels (`localhost`, `srv_01`) with no dot still pass through (no
  domain to leak, no over-match). Found by an independent blind audit
  (`e5b77d7`, #12).
* **Server-rendered UI pages no longer block the asyncio event loop.** The
  nine GET page handlers (`/`, `/jobs`, `/schedules`, `/configs`, the diff
  view, `/devices`, `/definitions`, `/migrate`, `/sanitize`) were declared
  `async def` but ran fully synchronous bodies -- five of them doing a
  blocking full-tree `list_configs()` filesystem walk (the diff view also two
  `get_content()` reads) directly on the event loop, stalling every
  concurrent request for the duration of the walk. They are now plain `def`
  handlers, so Starlette runs them in its threadpool -- matching the sibling
  API / migration routes and the storage layer's own non-blocking contract.
  Page output is unchanged. Found by an independent blind audit
  (`e5b77d7`, #3).
* **The `X-Netcanon-Job-Status` automation header is now declared in the
  OpenAPI schema.** All seven job-running migration POST endpoints (`/plan`,
  `/plan/ports`, `/plan/vlans`, `/plan/local_users`, `/plan/snmp`,
  `/plan/snmpv3`, `/render`) set this header at runtime so an HTTP-only CI
  gate can tell `completed` from `partial`/`failed`, but it was absent from
  `responses=` (which advertised only 404 + 422), so generated clients and
  contract tools could not discover it. Each 200 response now declares the
  header with its `completed` / `partial` / `failed` enum (the MigrationJob
  body schema is unchanged). Found by an independent blind audit
  (`e5b77d7`, #8).
* **The green "Validation OK" migration banner no longer overstates its
  coverage.** It claimed "every field that translates maps to a supported
  path on the target", but live validation walks only a subset of the
  canonical tree -- ~13 enumerated, CI-tracked leaves (SNMPv3 auth/privacy
  algorithms, VRRP priority/preempt/mode, static-route gateway, and others;
  see `tests/unit/migration/test_walker_completeness.py`) are not yet
  inspected, so a green banner could accompany a silently-downgraded SNMPv3
  privacy cipher or a dropped VRRP failover parameter. The banner now reads
  "Validation OK for the checked fields" and names representative
  not-yet-checked sub-fields, so the operator-facing severity stops implying
  full coverage while the walk-expansion (PR-2) is pending. Found by an
  independent blind audit (`e5b77d7`, T0-2 interim).
* **`ARCHITECTURE.md` no longer mislabels the bidirectional
  `cisco_iosxe_cli` codec as "parse-only" and no longer omits four shipped
  codecs.** The Phase-1 roadmap entry called the IOS-XE CLI codec parse-only
  (it is certified `direction = bidirectional`) and listed only the original
  eight codecs, omitting the four bidirectional, round-trip-certified codecs
  that shipped in v0.1.5--v0.1.8 (Cisco NX-OS, Cisco IOS-XR, Aruba AOS-CX,
  VyOS). The earlier parse-only doc sweep (`81d9740`, #8) fixed three
  developer docs but not this cited location. Also corrected a stale
  cross-mesh note that pointed at a never-shipped
  `cross_vendor_expectations.yaml` "(planned)" file -- the expectations
  shipped as the `tests/fixtures/cross_vendor_expectations/` directory,
  reconciled by `PHASE4_RECONCILIATION.md`. Found by an independent blind
  audit (`e5b77d7`, #6).
* **VRF / routing-instance names are now redacted by the sanitiser.** A VRF
  name can encode customer / tenant identity (`Tenant_A`, `ACME-MGMT`) -- the
  same risk class as a VLAN name, which was already redacted, yet VRF names
  passed through verbatim (an inconsistency an earlier release deliberately
  deferred). `CanonicalRoutingInstance.name`, `CanonicalInterface.vrf`,
  `CanonicalStaticRoute.vrf`, and `CanonicalEvpnType5Route.vrf` now map to a
  stable `vrf-N` placeholder, cross-reference-stable so the routing-instance
  definition and every `vrf <name>` reference rename together (the round-trip
  stays valid). Found by an independent blind audit (`81d9740`, #5).
* **The SNMPv3 lossy warning now names the cryptographic downgrade, not just
  a "re-key".** AOS-CX renders SNMPv3 with SHA-1 auth and AES-128 priv only,
  so a stronger source algorithm (e.g. SHA-256, AES-256) is silently
  downgraded on render -- but the operator-facing lossy reason on the
  `/snmp/v3-user/auth-passphrase` path only mentioned re-keying the
  ciphertext, mislabelling a security downgrade as routine. The reason now
  states the SHA-1 / AES-128 ceiling and that this is a cryptographic
  downgrade. (Full per-codec surfacing of the unwalked `auth-protocol` /
  `priv-protocol` / `priv-passphrase` leaves -- incl. the mikrotik 3DES->DES
  and fortigate 3DES->AES priv substitutions -- remains the tracked
  walk-expansion follow-up.) Found by an independent blind audit
  (`81d9740`, T0-3).
* **An interface-bound ACL (`ip access-group ... in/out`) is no longer
  dropped silently with no banner.** The IOS-XE Tier-3 detector matched only
  column-0 ACL *definitions* (`^ip access-list ...`); the per-interface
  *binding* is indented under `interface`, so a config that lost an
  inbound/outbound packet filter surfaced nothing in the "detected but not
  translated" banner -- and when the ACL definition lived off-box, nothing
  fired at all. The detector now also matches the indented `ip`/`ipv6
  access-group <name> in|out` binding (excluding `ip igmp access-group`, a
  multicast join-filter) so the dropped security control is surfaced. Shared
  by the codecs that reuse the IOS-XE detector (Arista EOS, Aruba AOS-S).
  Found by an independent blind audit (`81d9740`, T0-4).
* **Developer docs no longer mislabel the bidirectional `cisco_iosxe_cli`
  codec as "parse-only".** Three stale references -- the canonical
  add-a-field worked example (`docs/adding-a-canonical-field.md`), the
  feature-parity walkthrough, and a `CanonicalSNMPv3User` docstring -- called
  the codec parse-only even though it is certified `direction = bidirectional`
  and is a render target in seven cross-vendor fixtures, so a contributor
  following the worked example would have skipped wiring the Cisco render
  path. The 2026-05-21 docs-audit had corrected only the user-facing vendor
  page. Found by an independent blind audit (`81d9740`, #8).
* **Backup jobs no longer report a false `completed` (or hang at
  `running`) when a device has an unknown `type_key`.** A typo'd or renamed
  device type (ordinary operator data -- device profiles and schedules do
  not validate it against the loaded definition library) hit a bare
  `definitions[type_key]` dict subscript *outside* the per-device error
  handler. The serial path let the `KeyError` strand the whole job at
  `running`; the parallel path left the device `queued` so the terminal
  logic counted zero failures and marked the job `completed` though the
  device never ran. The lookup now resolves an unknown type to an honest
  per-device `failed` result, and a terminal-time safety net forces any
  still-non-terminal device to `failed` so "the device never ran" can never
  read as success. Found by an independent blind audit (`81d9740`, T0-2).

## [0.4.7] - 2026-06-25

### Fixed

* **The published source distribution (sdist) no longer ships the test
  suite.** `tests/fixtures/real/` holds third-party, device-generated
  config captures committed for regression testing (provenance in
  `tests/fixtures/real/NOTICE.md`); several carry no upstream LICENSE.
  `setuptools_scm` bundles every git-tracked file into the sdist by
  default, so the published PyPI source tarball was redistributing them. A
  new `MANIFEST.in` (`prune tests`) excludes the whole `tests/` tree -- it
  is not needed to install or run netcanon and the wheel never contained
  it -- and a CI gate in the "Build sdist + wheel" job fails if any
  `tests/` file reappears in the sdist. Found by an independent blind
  audit (`f92e97a`, T0-5).
* **The sanitiser now redacts virtual / anycast MAC addresses.** The
  anycast virtual-gateway MAC, the per-address virtual-gateway MAC (v4 +
  v6), the per-group VRRP virtual MAC, and the system-wide
  anycast-gateway MAC were rendered verbatim by the codecs but bypassed
  every field-typed redaction, so a burned-in or operator-assigned MAC
  (the OUI reveals the hardware vendor; the full address is
  device-unique) leaked into a shared bug report. A new `redact_mac`
  primitive maps each to a stable RFC 7042 documentation MAC
  (`00:00:5e:00:53:NN`, separator style preserved); protocol-standard
  VRRP / HSRP / GLBP / CARP virtual MACs and multicast / broadcast
  addresses are preserved (they identify nothing). The sanitizer-
  completeness partition guard moves the four MAC leaves from
  `REDACTION_NOT_WIRED` to redacted. Found by an independent blind audit
  (`f92e97a`, T0-3).
* **Per-pane migration endpoints now set the `X-Netcanon-Job-Status`
  response header.** `/plan` and `/render` surfaced the job disposition
  (`completed` / `partial` / `failed`) in this header so an HTTP-only
  automation gate -- which always sees a 200 with the job in the body --
  can still tell a real translation from a partial/failed one. The five
  per-pane override endpoints (`/plan/ports`, `/plan/vlans`,
  `/plan/local_users`, `/plan/snmp`, `/plan/snmpv3`) run the same pipeline
  but omitted the header, so a gate watching only the header saw no signal
  on those routes. They now set it too, and the body-only parity test was
  extended to compare the header. Found by an independent blind audit
  (`f92e97a`, #7).
* **`.env.example` no longer advertises a `0.0.0.0` bind default.** The
  sample config still showed `# NETCANON_HOST=0.0.0.0`, contradicting the
  loopback (`127.0.0.1`) bind default the server has shipped since v0.4.5.
  The comment now documents the loopback default and warns that `0.0.0.0`
  should only be used behind an authenticating reverse proxy. Found by an
  independent blind audit (`f92e97a`, T0-4 residual).
* **OPNsense no longer silently drops static routes.** The OPNsense
  codec's `config.xml` renderer emits no `<staticroutes>` block, so a
  translated static route dropped entirely on render -- yet
  `/routing/static-route` was the one routing-codec base path left
  undeclared in the capability matrix, so `classify()` defaulted it to
  `supported` and `validate_against` reported `severity: ok` while the
  whole route vanished. It is now declared `lossy` (warn) -- matching this
  codec's cross-vendor expectation dispositions and the #175 SVI-total-
  drop precedent -- so the loss surfaces as a warning instead of a silent
  all-clear; operators re-add routes on the OPNsense target. Three
  misleading sub-field reasons (which claimed "render emits destination +
  next-hop only") were corrected, and a new registry-wide guard pins that
  any codec rendering a plain gateway route to nothing must declare the
  base path. Found by an independent blind audit (`f92e97a`, T0-1).
* **VLAN-SVI L3 sub-fields no longer drop silently.** The xpath walker
  yielded only `/vlans/vlan/ipv4/address/ip` for a VLAN SVI while the
  interface mount walked five sub-paths, so a secondary IP, anycast
  virtual-gateway-address, or virtual-gateway-mac carried on a VLAN
  record rode the `classify()` default to `supported` and
  `validate_against` reported `severity: ok` while the value vanished on
  render (e.g. a Junos `irb` anycast gateway migrated to a target that
  drops it). The walker now walks all three VLAN-SVI sub-paths, and a
  12-codec census (verified by an independent multi-agent pass) declared
  each per target: lossy where the codec renders the SVI L3 but drops the
  sub-field, unsupported where it has no anycast/secondary grammar at
  all, supported where it round-trips. The completeness guard that should
  have caught this had inherited the same blind spot -- it passed a
  dual-mounted leaf if ANY mount was walked, so the interface-mount walk
  masked the VLAN-SVI gap; it now requires the three independent-loss
  sub-fields be walked on EVERY mount. Found by an independent blind
  audit (`f92e97a`, T0-2). The SVI-fold fidelity enhancement (preserving
  the sub-fields through `project_svi_to_vlan`) and the IPv6-on-VLAN
  schema addition are deferred as separate work.

## [0.4.6] - 2026-06-25

### Security

* **Static-route destination prefixes are now redacted by the
  sanitiser.** `netcanon sanitize` redacted a static route's next-hop
  (`gateway`) but passed its `destination` CIDR through verbatim, so a
  route to a public provider / peer / branch block leaked the real prefix
  into a shared sanitised config or bug report. The destination is now
  redacted to an RFC 5737 / RFC 3849 documentation range with the prefix
  length preserved (via the existing `redact_cidr`); the default route
  (`0.0.0.0/0`) and private aggregates are preserved. Found by an
  independent blind audit (`65f9c01`, #20).
* **DNS-name host fields no longer re-leak the org domain.** NTP /
  syslog / SNMP-trap / RADIUS targets written as FQDNs
  (`syslog.corp.example.com`, or a bare registered domain `acme.com`)
  passed through `netcanon sanitize` verbatim -- the IP-typed redaction
  only caught literal addresses -- so the operator's domain re-leaked
  into a shared config. Such fields now redact to a stable
  `host-N.example.test` placeholder (the WHOLE name, since the
  registered-domain boundary can't be found reliably without a
  public-suffix list); IP entries are unchanged and a bare single label
  (`localhost`) is preserved. This deliberately also redacts public
  service names (`pool.ntp.org`) -- harmless for a config meant for
  sharing. Completes the second half of audit `65f9c01` #20.
* **Anycast / VARP virtual-gateway address no longer leaks through the
  sanitizer.** `CanonicalIPv4Address` / `CanonicalIPv6Address.virtual_gateway_address`
  -- the anycast gateway IP rendered verbatim by Arista (`ip address
  virtual`), Aruba (`active-gateway ip`), Junos, NX-OS DAG and IOS-XE
  SD-Access -- was passed through `netcanon sanitize` while its primary-IP
  sibling was redacted, so a public virtual-gateway IP could leak into a
  shared sanitised config or bug report. It is now redacted at every
  address site (interface + VLAN SVI, v4 + v6), mirroring the VRRP/CARP
  VIP redaction; private gateways (the LAN-gateway common case) are
  preserved. Found by an independent blind audit (`3ec11f3`, finding #6)
  and runtime-reproduced.
* **Corrected a misleading API-key claim in the README + SECURITY.md.**
  The README stated the HTML UI's "data calls hit `/api/v1`", implying
  `NETCANON_API_KEY` protected the config data shown in the UI. It does
  not: the server-rendered diff view (`/configs/{a}/vs/{b}`) emits full
  config text and `/configs` / `/devices` list inventory server-side,
  outside the key-gated `/api/v1`. The docs now state plainly that the key
  gates `/api/v1` only and a reverse proxy is required to authenticate the
  UI for any non-loopback exposure; a regression test names the behavior.
  No behavior change -- the UI was, and remains, intentionally
  proxy-fronted (a native in-app UI login was deliberately not added).
  Found by an independent blind audit (`3ec11f3`, T0-1).
* **Credential fail-closed behavior documented accurately.** The
  `credentials.py` docstrings said the loader "skips the profile" on a
  wrong / rotated / lost Fernet key, and `SECURITY.md` still described only
  the pre-fix legacy-plaintext migration path. Corrected to the actual
  (already-shipped, safe) behavior: a token-shaped value that won't decrypt
  raises `CredentialDecryptError`; the field is left untouched and the
  re-save skipped (no double-encryption); the profile still loads but that
  credential fails SSH auth at backup time (logged) and is stripped from
  API output. No behavior change -- the security hole (ciphertext returned
  as plaintext + double-encryption) was fixed in #136; this is the
  contract/doc-accuracy residual (`3ec11f3`, T0-3).
* **Global ceiling on concurrent backup collections.** The per-job worker
  pool capped a single job at `MAX_BACKUP_CONCURRENCY` (10), but nothing
  bounded the *number of jobs* in flight -- several schedules firing
  together, or a schedule firing during a manual run, could open N pools'
  worth of SSH/NETCONF sessions at once (N x 10) and exhaust threads / file
  descriptors on the backup host. A process-wide `BoundedSemaphore`
  (`netcanon.services.backup_runner`) now caps the *sum* of in-flight
  collections across every concurrent job; an over-limit worker blocks
  (back-pressure) until a slot frees, never failing -- the device just
  stays `queued` until its turn. Defaults to `MAX_BACKUP_CONCURRENCY` so a
  single job's behavior is unchanged; raise it via
  `NETCANON_MAX_GLOBAL_BACKUP_CONCURRENCY`. Found by an independent blind
  audit (`3ec11f3`, r7).
* **Release artefacts now gated on tests inside the publish run, not only
  at merge.** The publish workflows (PyPI, Docker, desktop MSI) trigger on
  a `v*.*.*` tag push (and `workflow_dispatch`) and previously ran no tests
  in the publish run -- the test gate lived entirely in branch protection on
  `main` (off-tree, admin-bypassable) plus an on-main ancestry check. Each
  publish workflow now begins with a `Test gate (unit + integration)` job
  the build/publish jobs `needs:`, re-running the suite on the exact ref
  being shipped, so a manual dispatch or a bypassed merge still can't ship
  red code. Layer 1 (the full 4-Python + e2e + desktop matrix at merge)
  still covers the surfaces this fast subset omits. SECURITY.md documents
  both layers + the residual `workflow_dispatch` / admin-bypass risks. Found
  by an independent blind audit (`3ec11f3`, T0-4).
* **Release publish now also requires the full CI run to have passed for
  the tagged commit.** On top of the in-run unit+integration test gate, each
  publish workflow (PyPI / Docker / MSI) adds a `Require CI success for this
  commit` step that queries the Actions API and refuses to publish unless the
  CI workflow concluded `success` for that exact commit -- so the *full*
  matrix (4-Python + e2e + desktop + build + docker-smoke + PII-guard), not
  just the in-run subset, is proven green. Closes the residual that the
  on-main ancestry check proves ancestry, not CI success (admin-bypass /
  force-merge / a never-PR'd `workflow_dispatch`); fails closed on a missing
  or non-success CI run. Found by an independent blind audit (`3ec11f3`, T0-4).

### Fixed

* **Unrecognized input no longer reports `completed` via the API /
  automation contract.** A non-empty config the source codec couldn't
  parse (wrong source vendor, garbage) produced an empty canonical tree,
  rendered a banner-only stub, and the job -- plus the
  `X-Netcanon-Job-Status` header -- reported `completed` with zero
  warnings, so a CI gate checking only status saw green for a translation
  that produced nothing. The web UI already flagged this (the empty-result
  banner); the backend now does too -- `run_plan` downgrades a whole-input
  rejection to `partial` with an explanatory `error`. Tightly gated
  (non-trivial input + zero recognized paths + no Tier-3 detected + a real
  `CanonicalIntent` tree) so a minimal-but-valid config, an all-Tier-3
  config, and the internal mock codec are never mislabelled. This is the
  OUTPUT-side half of the silent-loss meta-finding (#180 closed the
  input-walk half). Found by an independent blind audit (`65f9c01`, T0-2).
* **Per-port voice VLAN (`switchport voice vlan N`) is no longer
  silently dropped at parse.** `cisco_iosxe_cli` *rendered* the line but
  never *parsed* it, so a real Cisco config's voice binding parsed to
  `None` and round-tripped away while self-validation reported
  `severity: ok` -- voice-vlan was undeclared in the capability matrix and
  rode the `classify()` 'supported' default (the silent-loss class). The
  parser now captures it (`_SWITCHPORT_VOICE_RE`) so voice-vlan fully
  round-trips same-vendor, and the surface is declared explicitly across
  the mesh: `supported` on `cisco_iosxe_cli`, `unsupported` on every codec
  that drops it on render (`cisco_nxos`, `arista_eos`, `juniper_junos`,
  `aruba_aoss`, `aruba_aoscx` -- the other six already declared it) so a
  now-parseable binding can't relocate the silent loss to a target. A new
  registry-wide guard fails CI if any codec leaves voice-vlan at the
  silent default. Cross-mesh + phase4 snapshots regenerated (CODEC_BUG
  flat at 5). Found by an independent blind audit (`65f9c01`, #11).
* **FortiGate hardware-switch members no longer remap silently across
  vendors.** On small appliances (40F/60F/80F) `internal1`-`internalN`
  and `lanN` are switched ports of the `internal` / `lan` L2 fabric, but
  they classified as plain physical ports and were remapped to a
  positional target name (`internal3` -> MikroTik `ether3`) with no
  advisory at all -- the bare `internal` / `lan` aggregate warned, the
  numbered member did not, silently dropping the L2-fabric-membership
  semantic the project's own `fortigate_60f.yaml` profile records. The
  source classifier now flags the member
  (`PortIdentity.hw_switch_member`) and the vendor-agnostic port-name
  orchestrator surfaces a soft "verify cabling / L2 role" advisory on
  cross-vendor runs (same-vendor round-trips losslessly, so none fires;
  the generic `portN` form is left clean to avoid over-firing). The
  rename still happens -- the advisory rides alongside it. Found by an
  independent blind audit (`65f9c01`, #7).
* **Trunk `switchport trunk allowed vlan add` / `remove` no longer
  collapses VLAN membership.** `show running-config` renders a long
  trunk allowed-list across multiple lines using the relative keyword
  forms (`... add 30,40`, `... remove 20`), but `cisco_iosxe_cli`,
  `cisco_nxos` and `arista_eos` overwrote the allowed-list per line
  (keeping only the last) and glued the leading keyword onto the first
  id token (`"add 30"` -> non-numeric -> silently skipped), so `allowed
  vlan 10,20` followed by `allowed vlan add 30,40` collapsed to `[40]`
  with no warning and `severity: ok` -- silently changing L2
  segmentation. All three now route through a shared
  `merge_trunk_allowed` helper that strips the keyword and applies it
  (set / add / remove / none / all / except) relative to the running
  list; the bare single-line form is byte-identical, so existing
  round-trips are unchanged. Found by an independent blind audit
  (`65f9c01`, T0-1).
* **Stale top-level `definitions/` paths corrected across the onboarding
  docs.** The v0.4.5 packaging move relocated the device-definition +
  target-profile YAML tree to `netcanon/definitions/library/` (shipped in
  the wheel), but ARCHITECTURE / AGENTS / CAPABILITIES, the glossary, the
  target-profile / collector / definitions READMEs, three vendor pages and
  the desktop dev-path table still pointed at the old repo-root
  `definitions/` location -- several as broken markdown links. All updated
  to the real paths (README was already correct); docs-only, no behaviour
  change. Found by an independent blind audit (`65f9c01`, #17).
* **Silent loss of VLAN SVI / management L3 addresses to router / firewall
  codecs.** A VLAN's SVI L3 carried on the VLAN record itself (the Junos
  `irb` / Aruba SVI-on-VLAN shape, folded onto
  `CanonicalVlan.ipv4_addresses`) was never walked by `_walk_canonical`, so
  `validate_against` classified it `supported` and reported `severity: ok`
  while `cisco_nxos`, `cisco_iosxr`, `aruba_aoscx`, `opnsense` and
  `mikrotik_routeros` silently dropped it on render. The walker now yields
  `/vlans/vlan/ipv4/address/ip` and those five codecs declare it lossy
  (warn), so the loss is surfaced; the SVI-model codecs (Arista, IOS-XE
  CLI, AOS-S) preserve it and FortiGate renders it on a routed
  sub-interface. Found by an independent blind audit (`3ec11f3`, T0-2); the
  audit named nxos+iosxr, the fix's own per-codec census also caught
  aoscx/opnsense/mikrotik.
* **Silent capability-loss on VLAN port membership for the router /
  firewall codecs.** `opnsense`, `mikrotik_routeros`, and `fortigate_cli`
  model a VLAN as a single-parent sub-interface and structurally cannot
  represent multi-port `tagged` / `untagged` membership, yet left
  `/vlans/vlan/tagged-ports` + `/vlans/vlan/untagged-ports` undeclared --
  so `validate_against` reported them *supported* while a
  switch -> {firewall,router} migration silently dropped the port -> VLAN
  bindings. Now declared unsupported, consistent with the per-interface
  switchport surface those codecs already declared unsupported. A new
  naming-sensitive silent-loss guard
  (`tests/unit/migration/test_silent_loss_naming_sensitive.py`) pins this,
  plus LAG-member + switchport round-trip honesty across the fleet, using
  per-codec native-named intents (the value-detail + registry-honesty
  guards deliberately excluded these naming-sensitive surfaces).

## [0.4.5] - 2026-06-24

A security-and-honesty release.  It began as the ship-blocker subset of
a fourth blind-audit pass (8.0/10 against the published v0.4.2 tree) and
grew to carry that audit's full Bucket A/B/C remediation, a three-stage
"silent-loss" capability-matrix honesty sweep, the Playwright e2e suite
gated in CI, and a hardened SSH host-key posture.  **Two security
defaults changed (both reversible):** the server now binds loopback
(`127.0.0.1`) instead of `0.0.0.0`, and SSH host-key checking now
defaults to `tofu` (trust-on-first-use with key pinning) instead of
`auto_add` (trust-anything).  Each audit finding was reproduced against
`main` before fixing.  No cross-mesh fidelity change; the `CODEC_BUG`
baseline stays at 5.  (Versions 0.4.3-0.4.4 were staged but never
released -- folded into this cut.)

### Security

* **BREAKING: SSH host-key checking now defaults to `tofu`, not
  `auto_add`.** New backups pin each device's host key on first connect
  (under `{data_dir}/known_hosts`) and reject a later changed key
  (`BadHostKeyException`) -- catching MITM / re-key on the management
  path, on both the Paramiko and Netmiko collectors (the latter via the
  pre-flight added below).  **Operational impact:** after a *legitimate*
  device re-key (re-image, hardware swap, IP reuse) the next backup fails
  with a clear `BadHostKeyException` until you remove that host's line
  from `{data_dir}/known_hosts`.  Set `NETCANON_SSH_HOST_KEY_CHECKING=
  auto_add` to restore the pre-v0.4.5 trust-anything behaviour (a startup
  warning then flags that verification is disabled).  (`#170`)

* **The default bind address is now `127.0.0.1` (loopback), not
  `0.0.0.0`** (audit T0-2).  The zero-config `host` default previously
  advertised an all-interfaces bind; binding a network interface is now
  an explicit opt-in (`NETCANON_HOST=0.0.0.0` or `uvicorn ... --host
  0.0.0.0`).  Docker is unchanged -- the image sets `NETCANON_HOST=
  0.0.0.0` explicitly and the `netcanon serve` entrypoint still fails
  closed without an API key or `NETCANON_ALLOW_INSECURE_BIND`; the
  desktop already pinned loopback.  `netcanon serve` with no
  `NETCANON_HOST` now serves on loopback instead of refusing.  (`#160`,
  commit cad9c81)

* **A startup WARNING now fires when SSH host-key checking is
  `auto_add`** (audit T0-4).  The backup collectors' default trusts any
  device host key on first connect with no persistence (MITM-able on the
  management path).  A blanket flip to `tofu` is unsafe as a default — on
  the Netmiko path `tofu` maps to strict OS `~/.ssh/known_hosts` checking
  with no persist API, so a first-run backup of an unknown device would
  fail — so instead the insecure posture is surfaced once at startup
  (mirrors the bind guard), naming
  `NETCANON_SSH_HOST_KEY_CHECKING='tofu'` and the Netmiko caveat.
  (`#163`, commit 56cd02f)
* **The config sanitiser now redacts EVPN/VXLAN/VRF overlay
  identifiers** (audit #12).  VRF route-distinguisher + route-targets,
  the VXLAN BUM multicast group + VTEP flood-list, EVPN Type-5
  route-targets + advertised prefix, and the SNMPv3 engineID previously
  bypassed every field-typed redaction (a verifier leaked `65501:100` /
  `239.7.7.7` / `9.9.9.9` through them).  RD/RTs map cross-reference-
  stable to the RFC 5398 documentation ASN; the BUM group to the RFC
  5771 MCAST-TEST-NET range.  (`#162`, commit 8ff9c56)
* **SSH host-key `tofu` / `reject` now work on the Netmiko collector.**
  The strict modes previously pointed Netmiko at the operator's OS
  `~/.ssh/known_hosts`, so a netcanon server with an empty OS store
  rejected every device on first connect -- the documented `tofu` mode
  was unusable on that path. The Netmiko collector now runs an auth-less
  paramiko pre-flight (`verify_host_key`) that learns + persists (or
  rejects) the host key in the netcanon-managed `{data_dir}/known_hosts`
  store -- the same store the Paramiko collector uses, so one device
  shares one pinned key across both -- then connects `ssh_strict` against
  it. Default stays `auto_add` (non-breaking); this removes the blocker
  that kept the default from becoming `tofu`. (`#169`)

### Fixed

* **`pip install netcanon` + `uvicorn netcanon.main:app` no longer
  crashes on startup** (audit T0-3, NEW HIGH).  The device-definition
  YAML library lived at the repo root and was never shipped as wheel
  package-data, so a wheel-installed server raised `FileNotFoundError`
  loading definitions from a working-directory-relative `./definitions`
  (`netcanon serve` from any other directory hit the same crash; Docker
  and the source tree masked it).  The library now lives inside the
  package (`netcanon/definitions/library/`, exposed as
  `netcanon.definitions.LIBRARY_DIR`) and ships in the wheel; the default
  `definitions_dir` resolves to that packaged copy.
  `NETCANON_DEFINITIONS_DIR` still overrides for a custom tree.  (`#158`,
  commit 6c80ca4)
* **Desktop: the Preferences dialog is now built on the Qt GUI thread.**
  The tray's `on_preferences` callback fires on pystray's background
  thread and constructed/`exec`'d a `QWidget` directly there (cross-thread
  Qt undefined behaviour / a crash on some builds).  It now marshals the
  dialog onto the GUI thread via
  `QMetaObject.invokeMethod(QueuedConnection)`, matching the pattern
  `window.py` already uses for show/hide/quit.  (`#159`, commit 2331b9c)
* **VXLAN BUM-replication detail no longer ships a green banner when
  dropped** (silent-loss class, Bucket-C stage 1).  `arista_eos`,
  `aruba_aoscx`, and `juniper_junos` render the VLAN<->VNI binding
  (`/vxlan-vnis/vni`, declared supported) but not the BUM-replication
  underlay, so a VNI's multicast group (`/vxlan-vnis/mcast-group`) and
  ingress-replication flood list (`/vxlan-vnis/flood-list`) dropped on
  render while `validate_against` -- exact-string `classify` defaulting
  the undeclared sub-leaves to "supported" -- reported `severity: ok`
  (verified live: cisco_nxos -> arista_eos).  Both sub-leaves are now
  declared `lossy` on those three codecs, so the migrate banner warns;
  a new registry guard (`test_silent_loss_list_subfields`) enforces the
  base-identity-coverage rule with single-mode targeted intents.  Matrix
  declarations only -- no render change, `CODEC_BUG` stays 5.  (`#165`)
* **More silent-loss sub-detail drops now warn** (silent-loss class,
  Bucket-C stage 2).  Extends #165's guard with two more clean
  naming-independent value sub-details: a VLAN's `description` (distinct
  from its name) on `arista_eos` / `aruba_aoss` / `cisco_iosxe_cli` /
  `cisco_nxos` / `fortigate_cli` / `juniper_junos` / `opnsense`, and an
  SNMPv3 USM user's `engine-id` on `arista_eos` / `aruba_aoscx` /
  `aruba_aoss` / `cisco_iosxe_cli` / `fortigate_cli` / `juniper_junos` /
  `mikrotik_routeros` -- each rendered the identity leaf (VLAN id / v3
  user) but silently dropped the sub-detail.  Both are now declared
  `lossy`.  Matrix declarations only -- no render change, `CODEC_BUG`
  stays 5.  (`#166`)
* **Anycast VARP + tunnel-type sub-detail drops now surface** (silent-
  loss class, Bucket-C stage 3).  The nuanced final stage, verified per-
  codec by an adversarial agent workflow.  Adds 5 guard cases + 12
  declarations: the anycast `virtual-gateway-address` (separate VARP VIP)
  is `unsupported` on `cisco_iosxr` / `vyos` (no anycast grammar) and
  **demoted supported -> lossy** on `cisco_iosxe_cli` / `cisco_nxos`
  (their DAG / SD-Access render round-trips only the primary-IP-as-
  gateway shape; a separate cross-vendor VIP drops); `virtual-gateway-
  mac` is `lossy` on `aruba_aoscx`; and `tunnel-type` is `lossy` on
  `cisco_iosxe` / `cisco_iosxr` / `cisco_nxos` / `vyos` / `aruba_aoscx`
  (interface survives, encapsulation discriminator dropped).  Matrix
  declarations only -- no render change, `CODEC_BUG` stays 5.  (`#167`)

### Added

* **Clean-room wheel-install CI guard.**  The `Build sdist + wheel` job
  now installs the built wheel into a fresh venv and boots `uvicorn
  netcanon.main:app` from an *empty* directory against `/health` --
  catching the packaging-regression class (definitions or templates
  missing from the wheel) that the source-tree `test` job structurally
  cannot see.  (`#158`)

## [0.4.2] - 2026-06-21

A low-severity polish release.  A third blind-audit pass (run3)
surfaced ~50 documentation-honesty / CI / dependency-hygiene /
test-coverage items; each was verified against current `main` before
fixing (several proved non-findings and were documented rather than
"fixed").  No runtime behaviour change beyond the backup-settings
refactor below; the cross-mesh `CODEC_BUG` baseline stays at 5.

### Added

* **Reverse-parity capability-honesty guard.**  A registry-wide test
  asserts every `lossy` / `unsupported` xpath a codec declares is
  either walkable by the shared canonical walker or a documented
  synthetic / Tier-3 marker -- catching a typo'd declaration that
  `validate_against` could never reach (the sibling of the existing
  supported-path guard; the empirical sweep found zero dead
  declarations, so it is a documented-allowlist gate, not a blanket
  assertion).
* **Attribution headers on every real-capture fixture.**  The 48
  previously-headerless third-party captures (7 vendors) now carry a
  3-line `Source` / `License` / `Snapshot` header in their own comment
  syntax; `NOTICE.md` remains the authoritative provenance ledger.
* **THIRD-PARTY-NOTICES completeness.**  Added the copyleft `scp`
  (LGPL-2.1-or-later, bundled transitively via netmiko) plus 16
  additional direct/transitive permissive components the MSI
  redistributes (licenses read from each component's package metadata).

### Changed

* **Backup jobs resolve `Settings` once and share it across the probe +
  collect collectors** instead of re-reading env / `.env` on every SSH
  session.
* **Runtime dependency upper-caps** -- `pydantic<3`, `pydantic-settings<3`,
  `apscheduler<4` -- so a breaking major can no longer be pulled by `>=`.
* **Docker Hub mirror is now optional** -- the publish workflow skips the
  Docker Hub login + image when `DOCKERHUB_TOKEN` is absent (forks publish
  to GHCR alone); the canonical release path is byte-identical.
* **Publish-workflow concurrency guards** (`docker-publish` + `pypi-publish`,
  `cancel-in-progress: false`) so a re-pushed tag can't double-publish.
* **Documentation honesty pass** -- README trust-signals + badges, the
  testing-methodology docs, the certified-codec counts (synced to 12),
  four vendor capability pages, and a `COMPARISON.md` landscape; image
  verification docs now show the immutable-digest `cosign verify` form.

### Fixed

* **e2e migrate tests** that drove the now-hidden internal `mock` codec
  through the UI dropdown -- a v0.4.0 fallout invisible to CI (which runs
  only unit + integration) -- re-pointed at real codec pairs.
* **FortiGate** `_prefix_to_mask` duplication removed and a swallowed
  `RenderError` on a malformed SVI mask now surfaces.
* `THIRD-PARTY-NOTICES.txt` referenced a non-existent `bundled
  LICENSE.txt` (the bundled file is `LICENSE`).
* Assorted code + test hygiene: sanitize-CLI read guard, a failed-job
  status header, dead-module removal, vacuous-skip + XXE-rejection test
  guards, literal-typed codec metadata, and the internal `mock` codec
  hidden from the user-facing codec list / sanitiser / detection.

## [0.4.1] - 2026-06-20

### Security

* **Sanitiser strips Junos apply-group bodies + redacts more free-text
  PII (v0.4.0 self-audit).**  A `$6$` hash, SNMP community, or username
  placed INSIDE a Junos `apply-group` was carried verbatim in
  `group_content` and re-emitted by the renderer, bypassing every
  field-typed redaction in the bug-report sanitiser (the flattened
  top-level copies were redacted, but the group-sourced `set groups
  <G> ...` lines were not).  `group_content` + `apply_groups` are now
  stripped fail-closed alongside `raw_sections`/`dropped_tier3_sections`.
  Also redacted the modelled free-text fields the walk previously
  skipped -- VLAN name/description, static-route / routing-instance /
  VRRP description, and DHCP domain-name (operator/org PII); VLAN-name
  redaction is cross-reference-stable so `vlan members <name>` stays
  consistent.  Found by the post-release adversarial self-audit; the
  earlier DATA-02 fix had covered only `raw_sections`.

* **Sanitiser now redacts IPv6, VRRP virtual IPs, and DHCP pool ranges (run3 audit).**  The IPv4-only IP redaction missed three "same class, new surface" leaks: interface IPv6 + any IPv6 in the DNS / NTP / syslog / trap-target / RADIUS / DHCP lists (now mapped to the RFC 3849 docs range, with ULA / link-local / loopback / multicast preserved); the VRRP / CARP virtual IP -- often the public HA gateway, previously bypassed while its sibling auth secret was redacted; and the DHCP pool's range bounds + served subnet.  IPv6 redaction is cross-reference-stable like the IPv4 path.

* **Credential decryption now fails closed on a wrong / rotated / lost key (run3 audit).**  `decrypt_field` treated *any* decryption failure as legacy plaintext, so a real Fernet token under the wrong key was returned verbatim as the SSH password (`was_encrypted=False`) and the storage layer then re-saved it, double-encrypting / corrupting the field.  Token shape is now positively detected (base64url -> `0x80` version byte, >= 57 bytes); a token-shaped value that won't decrypt raises `CredentialDecryptError`, the migration helper logs loudly, leaves the field untouched, and skips re-save.  Genuine legacy plaintext still migrates.

* **Supply-chain hardening (run3 audit).**  The `cosign verify` identity regexp is now anchored + dot-escaped + fully qualified to the workflow path `@refs/tags/v` (Go regexp is a substring match -- the old `https://github.com/<repo>` pattern also matched `<repo>-attacker/...`).  A **gating** Trivy scan (CRITICAL, fixable-only) was added to the PR CI so a vulnerable image fails the PR before it can reach a release tag; it immediately caught two fixed CRITICAL GnuTLS CVEs in the digest-pinned base image, now patched via an `apt-get upgrade` hardening layer in the Dockerfile runtime stage.

### Changed

* **Silently-dropped static-route sub-fields + secondary IPs now surface as warn/block (run3 audit; behaviour change).**  A migration that dropped a static route's admin distance (metric), name (description), or interface-nexthop (connected) route -- or a second IP on an interface -- reported `severity: ok` while discarding the data.  The shared validation walker now yields those sub-paths, and every codec that drops one declares it `lossy` (warn; the migration proceeds) or `unsupported` (block; overridable with `force=True`), grounded in an empirical render->re-parse drop-map.  A connected route migrating to Arista / Junos / OPNsense now blocks (the route vanishes); the FortiGate / OPNsense secondary-IP drop blocks (a whole-subnet reachability loss).  Cross-mesh `CODEC_BUG` unchanged at 5.

### Fixed

* **`cisco_iosxe` (NETCONF stub) now declares the switchport surface
  unsupported (v0.4.0 self-audit, ENG-01 completion).**  The
  OpenConfig/NETCONF stub renders only name/enabled/type per interface
  and drops every per-port VLAN-membership attribute, but left the 5
  switchport xpaths undeclared -- so `classify` defaulted them to
  `supported` and a switch->iosxe migration reported `severity: ok`
  while the membership was silently discarded.  It now declares them
  unsupported like its 5 sibling L3-style targets.  Cross-mesh
  `CODEC_BUG` unchanged at 5.

* **Sub-minute DHCP lease no longer renders the invalid, unstable
  `lease 0 0 0` (v0.4.0 self-audit).**  A lease of 0 < t < 60 s floored
  all of days/hours/minutes to 0 in `cisco_iosxe_cli` (and `arista_eos`)
  render, emitting a wire-invalid `lease 0 0 0` that reparsed to 0 and
  then drifted to the 86400 default on the next render -- contradicting
  the ENG-03 lossless claim.  Sub-minute leases now floor to 1 minute
  (IOS-XE/EOS finest granularity), so the output is valid and stable
  across re-renders.

* **An invalid `NETCANON_LOG_LEVEL` no longer crashes startup
  (v0.4.0 self-audit).**  `configure_logging` runs at import time,
  outside the graceful-startup handler, so a mistyped level
  (`verbose`, a stray-whitespace `"DEBUG "`, an empty value) raised an
  unhandled `ValueError` and took down every entry path (bare uvicorn,
  `netcanon serve`, desktop) with a raw traceback.  `Settings.log_level`
  is now a validated `Literal` with a normalising validator that lowers
  case/strips whitespace and degrades an unknown value to `info` with a
  warning -- so a clean value always reaches both `logging.setLevel` and
  `uvicorn.run`.

* **Doc accuracy: the API-key gate covers `/api/v1` data/operation
  routes, not the OpenAPI schema (v0.4.0 self-audit).**  Softened the
  "every /api/v1 route" wording in `auth.py` + `SECURITY.md`:
  `/api/v1/openapi.json` stays open so the intentionally-unauthenticated
  `/docs` page can render it (it carries only route metadata -- no
  config contents or credentials).  No behavioural change.

* **`cisco_iosxe_cli` now parses the static-route admin distance + name (run3 audit).**  `ip route ... <distance> name <X>` previously parse-and-ignored the trailing tokens, so a cisco round-trip silently reset the admin distance and dropped the route name.  The distance now maps to `metric` (round-trips) and the name to `description` (single-token names round-trip via `name <X>`; multi-word names stay declared lossy).

* **Scheduled-backup egress check no longer blocks the event loop (run3 audit).**  The per-device egress allow-list ran a blocking `socket.getaddrinfo` directly on the scheduler's asyncio event loop; it is now offloaded to a worker thread, so a slow DNS answer can't stall the loop.

* **Doc honesty (run3 audit).**  The Phase-4 reconciliation report no longer claims "56/56 pairs validated" -- it states the 56 expectation YAMLs cover only 8 of 12 codecs (aoscx / iosxr / nxos / vyos have none, so 723/1224 cells are expected-uncovered, not a missing-files error).  `NOTICE.md` "Adding new captures" now forbids new no-LICENSE upstreams.

## [0.4.0] - 2026-06-19

### Added

* **Contribution hygiene (audit finding LIC-01).**  Added a Developer
  Certificate of Origin (DCO 1.1) sign-off requirement (CONTRIBUTING.md
  + PR template), a `TRADEMARKS.md` nominative-use notice, and an
  explicit "device-generated output isn't creative authorship"
  rationale atop the real-capture fixture `NOTICE.md`.

* **Opt-in API authentication + fail-closed bind (audit finding
  SEC-01).**  Set `NETCANON_API_KEY=<token>` to require an
  `Authorization: Bearer <token>` header on every `/api/v1` route;
  unset (the default) keeps the zero-config loopback / desktop
  experience unauthenticated.  A new `netcanon serve` command (now the
  Docker `ENTRYPOINT`) refuses to start on a non-loopback bind (e.g.
  `0.0.0.0`) unless a key is set or `NETCANON_ALLOW_INSECURE_BIND=1` is
  passed, so accidental unauthenticated public exposure is a conscious
  choice rather than the default.  **Breaking:** an existing
  `docker run` (binds `0.0.0.0`) now needs one of those env vars — see
  the README quickstart and SECURITY.md.

* **README now leads with the browser UI (audit finding MKT-01).**
  Added a migrate-page screenshot (`docs/assets/migrate.png`) above
  the fold: a Cisco IOS-XE -> Junos translation showing
  auto-detection, the “Validation OK” / “Tier-3 sections
  detected” honesty banners (with the dropped ACL/NAT lines listed),
  and the rendered Junos `set` output -- so the README shows what the
  tool looks like, not only what it claims.

### Fixed

* **Documentation honesty fixes (audit findings DOC-01, CI-02, CI-04).**
  Corrected the README claims that CI "runs the full matrix" (it runs
  unit + integration; e2e + desktop are local) and is "the source of
  truth for pass counts" (the `-x` invocation stops at first failure),
  and that bare `pytest` runs the desktop tier (needs the `[desktop]`
  extra); repointed the LAG-helper, NETCONF-frozenset, backup-NOS list,
  variance-class table, and routes-table doc drifts to match the code;
  softened the "every interactive element has a data-testid"
  overclaim; and guarded the top-level `import pystray` so the desktop
  module imports without the optional extra.

* **The sanitiser now strips Tier-3 `raw_sections` (audit finding
  DATA-02).**  `sanitize_intent` cleared `dropped_tier3_sections` but
  left `raw_sections` (verbatim carry-through vendor text) untouched.
  No production parser populates it today, but the IOS-XE renderer
  emits any entries verbatim, so a future parser filling it could
  round-trip unredacted secrets.  It is now stripped fail-closed
  alongside `dropped_tier3_sections`, with a coverage-guard test.

* **`NETCANON_LOG_LEVEL` now takes effect on the server/Docker path
  (audit finding OBS-01).**  `main.py` called `configure_logging(
  level="INFO")` with a hardcoded level at import time, so the stdlib
  root logger ignored `Settings.log_level` — `NETCANON_LOG_LEVEL=debug`
  silently dropped all `logger.debug(...)` output under the bare
  `uvicorn netcanon.main:app` entry point (uvicorn configures only its
  own `uvicorn.*` loggers, never the root).  The level now resolves from
  `Settings().log_level`.  Also corrected the `logging_config` /
  `config.py` docstrings that wrongly described the call as a uvicorn-CLI
  no-op.

* **Cisco IOS-XE DHCP pool lease-time now round-trips losslessly
  (audit finding ENG-03).**  `cisco_iosxe_cli` render emitted
  `lease 0 <total_hours>`, truncating days into hours and dropping
  minutes (e.g. `lease 2 6 30` rendered as `lease 0 54`, silently
  losing 30 minutes), and never emitted `lease infinite`.  Render now
  emits the full `lease <days> <hours> <minutes>` triple (and the
  `infinite` marker), matching the parser grammar and the arista_eos
  render.  Cross-mesh `CODEC_BUG` unchanged at 5.

* **Switch -> firewall/router migrations now surface dropped L2
  switchport membership instead of a green banner (audit finding
  ENG-01).**  The L3-only target codecs (`fortigate_cli`, `opnsense`,
  `cisco_iosxr`, `mikrotik_routeros`, `vyos`) have no Cisco-style
  access/trunk port model, so a source switch's per-port VLAN
  membership (`switchport-mode`, `access-vlan`, `trunk-allowed-vlans`,
  `trunk-native-vlan`, `voice-vlan`) is dropped on render.  Those
  paths were previously undeclared, so `CapabilityMatrix.classify()`
  defaulted them to `supported` and the validation report said
  `severity: ok` while the membership was silently lost (reproduced
  live in the 2026-06 blind audit).  Each L3-only target now declares
  the switchport surface `unsupported`, so the loss is reported as a
  block-severity migration finding.  Cross-mesh `CODEC_BUG` unchanged
  at 5 (the drift demotes to `EXPECTED_UNSUPPORTED`).

### Security

* **Docker base image is digest-pinned (audit finding SUP-01).**
  Both build stages now pin `python:3.14.5-slim-bookworm` by its
  multi-arch manifest digest
  (`@sha256:a9bee15510a3...cf679ccb`) rather than the floating tag,
  so a silently re-tagged or compromised upstream
  `:3.14.5-slim-bookworm` cannot change what we build.  Dependabot's
  existing `docker` ecosystem bumps the tag and digest together.

## [0.3.2] - 2026-06-18

### Changed

* **Cross-vendor interface-name translation now runs by default.**  A
  plain `POST /api/v1/migration/plan` translation (and the browser UI's
  standard translate flow) engages the auto port-name heuristic even
  when no target profile or rename map is supplied, so physical
  switchports, LAGs, SVIs, and loopbacks render with native
  target-vendor names (Cisco `GigabitEthernet1/0/1` -> Junos `ge-1/0/1`,
  Aruba `1/1`, ...) instead of being left verbatim.  Explicit rename
  maps and target profiles still win; the low-level `run_plan` primitive
  is unchanged (still verbatim for callers that compose their own
  transforms).  The bundled `netcanon demo` now exercises this path and
  prints the interface-name translations it applied.

* **Lint gate now covers `tools/` and `netcanon_desktop/`**
  (`.github/workflows/ci.yml`).  The v0.2.0 ruff baseline linted only
  `netcanon/` + `tests/`; the dev tools and the desktop shell are now
  ruff-clean too (57 fixups -- unused imports/noqa, import ordering,
  pyupgrade syntax, a `zip(..., strict=False)`, an undefined-name
  annotation fix in `icons.py`, etc.) and the CI `ruff check` scope is
  widened to all four trees so they stay clean. No runtime behaviour change.

### Fixed

* **Cisco access-VLAN membership no longer dropped when `switchport mode
  access` is implicit.**  IOS treats an interface carrying `switchport
  access vlan N` as an access port even without the explicit `switchport
  mode access` line; the `cisco_iosxe_cli` parser now infers
  `switchport_mode="access"` in that case, so VLAN membership survives
  translation to VLAN-centric targets (e.g. Junos `ethernet-switching vlan
  members`).  Conservative: an explicit mode is never overridden, ports
  that also carry trunk config are left untouched, and sub-interfaces
  (names with a `.`) are excluded since real IOS rejects `switchport`
  there.  CODEC_BUG count unchanged (5).

## [0.3.1] - 2026-06-17

### Added

* **CHANGELOG/version guard test** (`tests/unit/test_changelog.py`).  Asserts
  every stable `vX.Y.Z` release tag has a matching `## [X.Y.Z]` section, plus
  unique + strictly-descending + dated headers and an `[Unreleased]` anchor.
  Closes the gap that let the `[0.2.0]` header get silently dropped in the
  v0.3.0 cut (restored in #111) with no test to catch it.

### Changed

* **VyOS backup definition graduated from provisional to live-validated**
  (`definitions/vyos/vyos/1.x.yaml`).  The backup-collection wiring (netmiko
  `vyos` driver, `show configuration commands`, prompt patterns, `show
  version` probe) shipped in #110 flagged "NOT YET VALIDATED" — verified in
  code only.  It has now been run end-to-end against a real VyOS rolling
  instance: a backup job completed and pulled the set-form running config, so
  the honesty marker is dropped (def notes + `/definitions` Notes column +
  CAPABILITIES.md), and the def's test now asserts the marker is absent.  The
  other three backup defs from #110 (`CiscoNXOS`, `CiscoIOSXR`, `ArubaCX`)
  remain provisional — no free emulator to validate against.

* **AOS-CX backup `show version` probe now also matches the CX Switch Simulator**
  (`definitions/aruba/aos-cx/10.x.yaml`).  The probe captured the OS version only
  from the two-letter hardware platform prefix (`FL.`/`GL.`); the free AOS-CX
  *Switch Simulator* reports `Virtual.10.x`, which the old regex silently missed
  (the job goes green while `detected_os_version` stays empty).  Widened the
  prefix to `(?:[A-Z]{2}|Virtual)\.` so the version probe fires on both hardware
  and the simulator (hardware contract unchanged).  Surfaced by the virtual-NOS
  viability research (`docs/reviews/2026-06-17-virtual-nos-viability/`); the
  `ArubaCX` def itself stays provisional until run against a real device.

## [0.3.0] - 2026-06-16

The user-facing counterpart to v0.2.0's internal refactor: a web-UI
accessibility + usability pass (informed by a wide UI/UX review),
browser-friendly error pages, a backup-coverage expansion to every
migration-codec vendor, and a startup-log cleanup.  **No change to
migration output** — no codec parse/render code changed, so the
cross-mesh fidelity audit is identical to 0.2.0 (CODEC_BUG flat at 5).

### Added

* **Themed 404 / 500 error pages** (#106).  A mistyped URL or an
  unhandled server error previously returned raw `{"detail":"Not Found"}`
  JSON with no navigation.  Browser navigations (`Accept: text/html`,
  non-`/api/` path) now get a styled `error.html` carrying the status
  code, a generic message, and a link back to the dashboard; the page
  never echoes the exception detail.  API / `/api/` requests keep their
  JSON contract unchanged.  Registered via
  `ui.register_exception_handlers(app)` so the desktop shell (shared
  `create_app`) inherits it.

* **Backup device definitions for NX-OS, IOS-XR, AOS-CX, and VyOS**
  (#110).  These four vendors had a migration codec but no `definitions/`
  entry, so configs could be *migrated* but not *backed up* from a live
  device.  New family bases: `definitions/cisco/nx-os/10.x.yaml`
  (`type_key: CiscoNXOS`, netmiko `cisco_nxos`),
  `definitions/cisco/ios-xr/7.x.yaml` (`CiscoIOSXR`, `cisco_xr`),
  `definitions/aruba/aos-cx/10.x.yaml` (`ArubaCX`, `aruba_aoscx`), and
  `definitions/vyos/vyos/1.x.yaml` (`VyOS`, `vyos`, fetching
  `show configuration commands` set-form which the vyos codec accepts).
  Distinct `type_key`s avoid colliding with the existing `Cisco`
  (IOS-XE) and `Aruba` (AOS-S) family bases — every migration-codec
  family now has a backup definition.  Each ships a tested
  `show version` probe; per-definition unit tests lock in the collector
  wiring + probe regexes.  **Provisional**: the collection wiring is
  verified in code and against sample output but has **not yet been run
  against a live device**, so each carries a "NOT YET VALIDATED" marker
  in its `notes`, surfaced in the `/definitions` Notes column.

* **Skip-to-content link + screen-reader utility** (#108).  A
  visually-hidden skip link (revealed on focus) jumps keyboard /
  screen-reader users straight to `<main>`, and a reusable `.sr-only`
  class backs accessible-name text elsewhere.

* **Jobs page manual Refresh** (#105).  The jobs list does not
  auto-update; it now has an explicit Refresh button and a hint saying
  so, rather than leaving operators wondering whether a running job is
  stuck.

### Changed

* **Web-UI accessibility pass** (#103, #104, #107, #108).  Driven by a
  wide UI/UX review (evidence at `docs/reviews/2026-06-16-ux-review/`,
  20 must-fixes).  Highlights: AA-contrast text token + a dedicated
  `--focus-ring` token in both themes; the three disclosure headers
  (jobs / devices / job-progress) and the rename modal are now fully
  keyboard-operable (`role="button"` + `tabindex` + Enter/Space +
  `aria-expanded`; modal Esc-to-close, focus-on-open, focus-restore);
  `scope="col"` on every data table; `aria-label`s on icon-only buttons
  and the dashboard controls; and the error-toast announces via
  `role="alert"` / `aria-live="assertive"`.  No behavioural change to
  the flows themselves.

* **Microcopy honesty + terminology** (#103, #104, #108).  The Migrate
  page's source / target selectors are relabelled "adapter" → "vendor"
  (matching how the rest of the UI speaks); the validation-OK banner
  now states plainly that Netcanon has no deploy path; the unsupported-
  category banner drops the internal `raw_sections` jargon; and the
  Configs page gained a one-line intro.

* **Developer tooling** (#101, #67).  Added a reusable "ultracode
  blackboard" multi-agent workflow runner under `.claude/workflows/`
  (read-only reviewer agents each write one report; the main thread
  actuates) plus its `docs/agent-workflow.md` doctrine; the wide UI/UX
  review above was its first run.  Bumped pinned CI actions
  (`actions/checkout`, `github/codeql-action`) via Dependabot.

### Fixed

* **`/sanitize` rendered an empty scaffold on first load** (#103).  The
  result container relied on a `.hidden` class that had no CSS rule, so
  the (empty) result region painted on initial page load instead of
  staying hidden until a sanitize ran.  Switched to an inline
  `display:none` toggled by the existing reveal logic.

* **Async edges in list / detail flows** (#105).  Delete / config-open
  controls across Configs, Schedules, and Jobs now disable themselves
  in-flight (no double-submit), treat a `404` after a delete as success
  (the row is already gone), and deep-link with the **full** job id
  (`/jobs#<id>`) with a not-found toast when the anchor target is
  absent.

* **Startup log spammed 54 spurious validation warnings** (#109).
  `DefinitionLoader.load_all` scanned the definitions tree with
  `rglob("*.yaml")` and so descended into
  `definitions/target_profiles/`, validating each Tier-3 rename profile
  against the `DeviceDefinition` schema and logging a `WARNING` for
  every one.  A documented `_RESERVED_SUBDIRS` exclusion now skips that
  sibling subdir (it is owned by
  `migration.target_profiles.load_profiles_dir`); recursion for genuine
  nested per-vendor definitions is unchanged.  Cosmetic only — no
  definition was ever dropped — but the noise had masked any real bad
  definition at boot.

## [0.2.0] - 2026-06-16

Internal maintenance + refactor release.  **No change to migration
output** — the codec work below is behaviour-preserving: the cross-mesh
fidelity audit is unchanged (CODEC_BUG flat at 5; every
`CROSS_MESH_RESULTS.md` / `PHASE4_RECONCILIATION.md` cell byte-identical
across all 1224 source→target pairs), and each refactor PR was gated on
that invariant plus a green unit + integration suite.  The one
user-facing change is the `tzlocal` dependency fix.

### Fixed

* **`tzlocal` dependency — a fresh install could fail at startup** (#97).
  `apscheduler` (the backup scheduler) imports `tzlocal` at startup, but
  `tzlocal` was only an *undeclared transitive* dependency.  When the
  broken `tzlocal 5.4.2` was published upstream — a ~4.8 KB stub wheel
  that installs but imports as `ModuleNotFoundError: No module named
  'tzlocal'` — a fresh `pip install` selected it as the highest version
  (PEP 440: `5.4.2 > 5.4`) and the app failed to boot.  `tzlocal` is now a
  **direct dependency pinned `>=5.0,!=5.4.2`**, so the build no longer
  depends on the upstream yank that has since neutralised 5.4.2.  Existing
  environments with a working `tzlocal` were unaffected.  An inline
  `pyproject.toml` TODO flags re-checking for a newer valid `tzlocal`
  during a future app-wide dependency review.

### Changed

* **Codec parsing internals — shared stanza-scan utility** (#89, #90, #91,
  #92, #93, #94, #95, #96, #98, #99).  Five CLI codecs (`cisco_iosxr`,
  `cisco_nxos`, `cisco_iosxe_cli`, `arista_eos`, `aruba_aoscx`) previously
  hand-rolled the same control-flow skeleton in every stanza parser —
  *open on a header line, accumulate indented sub-lines, close on a `!` or
  a dedent, flush at end-of-input*.  That skeleton is now a single
  composable function, `codecs/_scanner.scan_stanzas`, and the per-codec
  parsers delegate to it while keeping their vendor-specific regex
  cascades verbatim (the scanner carries zero canonical-model knowledge,
  so its output is identical to the loop it replaces).  Eight parse loops
  adopted it — interface, VRF / routing-instance, and DHCP-pool stanzas.
  Shared VLAN id-list helpers (`_parse_vlan_list` / `_coalesce_vlan_ids`)
  were also lifted into `codecs/_helpers.py` behind a golden-equivalence
  test (#89) before any inline copy was deleted (#90).  Codecs whose
  grammar does not fit the flat open/accumulate/close shape — `arista_eos`'s
  fused interface/VLAN/Vxlan scanner, the LAG accumulators, and the
  brace / set-form / XML codecs — intentionally keep their bespoke loops.

* **Developer tooling — `ruff` linting baseline** (#85, #86, #87, #88).
  Adopted a `ruff` configuration (`[tool.ruff]`: rule set
  `F,E,W,I,UP,B,C4,PIE,SIM,RUF`; line-length 120; **linter only — no
  autoformatter / mass reflow**) pinned to `ruff>=0.15,<0.16`; applied its
  safe auto-fixes; resolved the remaining findings by hand; added `from
  __future__ import annotations` across the remaining modules; and wired a
  **`Lint (ruff)`** CI gate over `netcanon` + `tests`.  No runtime change.
  (The `tools/` and `netcanon_desktop/` trees are not yet in the gate's
  scope — a deferred follow-up.)

## [0.1.9] - 2026-06-16

### Added

* **Real-capture corpus: JNPRAutomate MNHA vSRX fixture** —
  `jnprautomate_mnha_vsrx_a_junos.set` from
  [JNPRAutomate/mnha-ipsec-and-multiple-routing-instances](https://github.com/JNPRAutomate/mnha-ipsec-and-multiple-routing-instances)
  (MIT).  The densest Junos fixture (637 lines) and the first with
  **multiple routing-instances carrying descriptions** (virtual-router +
  vrf mix), a **3-address loopback** (`lo0.10`), **`st0` secure-tunnel**
  units, and IPsec/IKE + MNHA `apply-groups`.  As a cross-mesh source it
  exposed five over-optimistic expectation cells, all corrected honestly
  in the same change (see *Fixed* below) so it lands at CODEC_BUG flat 5.
  Sanitized: `secondary-dns 8.8.8.8` → RFC5737; IKE/IPsec/RADIUS secrets
  are upstream `$9$Fake…` placeholders (non-secret, kept verbatim).

* **Real-capture corpus: akarneliuk NX-OS EVPN-VXLAN leaf fixture** —
  `akarneliuk_evpn_vxlan_mcast_leaf_c1l1_nxos939` from
  [akarneliuk/multivendor-network-labs](https://github.com/akarneliuk/multivendor-network-labs)
  (NX-OS 9.3(9); unlicensed device-output, factual-output precedent).  A
  **double regression witness**: the **inline `member vni <vni>
  mcast-group 239.11.11.x` form** of the mcast-group parse (complements
  the `leaf2` own-sub-line form) *and* the dual-AF route-target
  de-duplication fix above — its tenant `vrf context` blocks carry
  `route-target both auto [evpn]` across both address-families with an
  asymmetric inter-tenant `route-target import 65000:<vni>` route-leak,
  the exact shape that previously drifted `rt_imports`.  Also exercises
  `associate-vrf` L3VNI, anycast-gateway SVIs, vn-segment L2VNI, `nve1`
  VTEP, vPC/port-channel LAGs, ULA IPv6 SVIs, OSPF/PIM/BGP-EVPN (Tier-3).
  Parses, round-trips stably, auto-detects uniquely; cross-mesh-neutral
  (CODEC_BUG flat at 5).  Secrets (OSPF key-chain / PIM ah-md5 / BGP
  template password) sanitized to synthetic same-format values; all IPs
  RFC1918 / ULA / admin-scoped multicast.

* **Real-capture corpus: aruba_aoscx VRF / IPv6 spine fixture** —
  `canu_csm17_spine001_ipv6_vrf` from HPE Cray's open-source
  [`canu`](https://github.com/Cray-HPE/canu) fabric-config generator (MIT; CSM 1.7
  golden config).  The **first AOS-CX real capture** to close the **routing-instance**
  (`vrf CSM` / `vrf keepalive`), **per-interface VRF** (`vrf attach`), **static default
  route** (`ip route 0.0.0.0/0`), and **interface IPv6 address**
  (`ipv6 address 2001:db8:100::2/64`) surfaces — the prior aoscx corpus was
  L2-EVPN-only.  Bannerless (no `!Version` line), so it only detects unambiguously
  after the recent IOS-XE-probe NX-OS/AOS-CX deferral; parses, round-trips stably,
  auto-detects to a unique codec, cross-mesh-neutral (CODEC_BUG flat at 5).  All IPs
  RFC1918 / RFC3849-doc, MACs synthetic, no secrets — zero sanitization needed.

* **Real-capture corpus: 4 more gap-fill fixtures** from the 2026-06-15
  `fixture-gap-hunt` acquisition wave — each fetched from a license-vetted
  public source, sanitized (real secrets → synthetic same-format, real
  public IPs → RFC5737/RFC3849), smoke-tested through its codec, and
  cross-mesh-neutral (CODEC_BUG flat at 5).
    * **cisco_nxos** — `busterswt_spine_leaf_xk32_1` (NX-OS 9.3(12) — trunk
      native-VLAN incl. the previously-unwitnessed **port-channel** case +
      per-VRF static route) and `networklessons_clab_dag_symmetric_irb_leaf1`
      (10.2(7) — the corpus's **first real IPv4 distributed-anycast-gateway**
      capture: `anycast-gateway-mac` + per-SVI `virtual-gateway-address`,
      previously synthetic-only).
    * **cisco_iosxr** — `iosxr_design_cst_pa3` (IOS-XR 7.5.2, Cisco's
      `ios-xr/design` repo) closing the **4-segment interface-name** gap
      (`TenGigE0/0/0/0`, `HundredGigE0/0/1/0`, `MgmtEth0/RP0/CPU0/0`, dot1q
      `.200` sub-interface) plus the rare `bundle … mode on` static LAG.
    * **vyos** — `vyos_forum_snmpv3_user_eq13` (forum-share; VyOS 1.3) closing
      the four **SNMPv3 USM** surfaces (`/snmp/v3-user` + auth-passphrase +
      engine-id + location) the v2c-community-only corpus left unverified.

* **Real-capture corpus: 7 gap-fill fixtures** closing **14 previously-unverified
  capability-matrix surfaces** (identified by the `fixture-gap-hunt` analysis —
  diffing `iter_xpaths()` over the existing corpus against each
  `CapabilityMatrix.supported` list; see `docs/fixture-gap-analysis/2026-06-15/`).
  Each parses, round-trips stably, and auto-detects to a unique codec; full
  provenance + license + sanitization notes in `tests/fixtures/real/NOTICE.md`.
    * **cisco_nxos** — `batfish_nxos_n9kv_ebgp_r1` (Apache-2.0; NX-OS **10.3(9)**,
      first >9.2(3) data point — static IPv6 address + per-VRF static route) and
      `nautobot_gc_nxos_snmp_spine01` (9.3(3) — `snmp-server community/contact/location/host`).
    * **aruba_aoscx** — `netutils_aoscx_snmpv3` (Apache-2.0; SNMPv3 USM + per-interface
      VRF + routing-instance).
    * **juniper_junos** — `saidvandeklundert_snmpv3` (MIT; 17.2 — SNMPv3 USM + SNMP
      location) and `tsg8139_evpn_leaf_dhcpv6` (MIT; **23.2**, fills the 19–23.x bridge
      gap — DHCPv6 client).
    * **vyos** — `houdev_vyos_dhcpv6_pd_client` (DHCPv6 client + Prefix-Delegation).
    * **cisco_iosxe** — `ciscolive_brkops1104_evpn_leaf` (IOS-XE **17.15** version
      anchor; no new canonical surface).

### Fixed

* **cisco_nxos VXLAN static head-end replication (flood-list) now parses +
  renders.** `/vxlan-vnis/flood-list` was declared **supported** in the
  capability matrix, but `_parse_vxlan` never read the `interface nve1 /
  member vni N / ingress-replication protocol static / peer-ip X` lines (and
  the renderer's docstring even claimed ingress-replication was "not modelled,
  declared lossy" — contradicting the matrix).  A real static-IR VTEP lost
  100% of its flood-list on parse while the matrix advertised support — the
  silent-drop honesty defect class, and the deferred sibling of the
  `member vni` mcast-group fix.  Parse now harvests the `peer-ip` list onto
  `CanonicalVxlan.flood_list`; render emits the `ingress-replication protocol
  static` / `peer-ip` block (mutually exclusive with `mcast-group` per VNI).
  Round-trips stably; cross-mesh-neutral (CODEC_BUG flat at 5 — no committed
  fixture exercised it).  Surfaced by a post-fix-phase gap-recheck
  (declared-supported xpaths minus what any real fixture exercises).

* **Cross-vendor fidelity honesty: five over-optimistic junos→target
  expectations corrected, two via the *right* mechanism rather than
  papering the docs.** Surfaced by the JNPRAutomate MNHA vSRX capture
  (above), which as a cross-mesh source exposed five fields the
  expectation YAMLs declared `good` but that genuinely don't round-trip:
    * **Comparator** — `is_secondary` is now treated as a cosmetic,
      target-determined subfield in the cross-vendor address comparison
      (`tools/run_full_mesh.py`).  IOS-XE *must* mark all-but-one
      interface address `secondary`; a Junos 3-address loopback renders
      1 primary + 2 secondary with **every address preserved**, so the
      flag difference was a false CODEC_BUG, not data loss.  Genuine
      address *drops* (e.g. OPNsense keeping one IPv4) still flag.
    * **fortigate_cli** — the dotted-form VLAN heuristic
      (`vlan_heuristics.py`) no longer classifies **loopback / tunnel
      logical units** (`lo0.2`, `st0.100`, `gr-…`) as dot1q VLANs, so the
      render stops synthesising phantom `config system interface` VLAN
      entries (and phantom re-parsed `vlans`) from a Junos source's
      loopback/tunnel units.  Genuine dot1q forms (`port1.10`,
      `LAG_INTERNAL.100`) are unaffected.
    * **Expectation YAMLs** — three declarations corrected to `lossy`
      with mechanism notes: arista `routing_instances[].description`
      (EOS `vrf instance` has no description command), opnsense
      `interfaces[].ipv4_addresses` (one IPv4 per interface — the note
      already documented the collapse), and mikrotik
      `interfaces[].description` (tunnel interfaces get a `review:`
      placeholder the source didn't carry).
  CODEC_BUG stays flat at 5; the change adds focused unit tests for the
  comparator and the FortiGate heuristic.

* **cisco_nxos VRF route-targets are de-duplicated so EVPN-VXLAN tenant
  VRFs round-trip stably.** A tenant `vrf context` typically repeats the
  same route-target value across the `ipv4 unicast` / `ipv6 unicast`
  address-families and the `... evpn` scope; the flat canonical
  `rt_imports` / `rt_exports` lists carry no address-family / evpn
  dimension, so those repeats were retained as artefacts (e.g.
  `rt_imports: ["auto", "auto"]`).  When an asymmetric import-only
  route-leak was mixed in (inter-tenant leaking — `route-target import
  <asn>:<vni>`), the renderer emitted that import-only RT *after* the
  duplicated `both` lines, so a parse→render→parse round-trip drifted
  `rt_imports` order and the round-trip was unstable.  Parse now
  de-duplicates each direction (order-preserving), matching NX-OS's own
  idempotent route-target semantics, emitting idiomatic non-duplicated
  output, and stabilising the round-trip.  Cross-mesh-neutral (CODEC_BUG
  flat at 5; a few cross-vendor cells improve as the cleaner canonical
  aligns with targets that already emit a single RT).  Surfaced by the
  akarneliuk EVPN-VXLAN leaf capture.

* **fortigate_cli VRRP `set priority 255` no longer crashes the parser.**
  FortiOS permits VRRP priority 1-255 (255 is the address-owner
  sentinel), but `CanonicalVRRPGroup.priority` caps at 254 (`le=254` —
  255 is RFC-reserved / unrepresentable).  The parser passed the raw int
  straight into the model, so a legitimate master config carrying `set
  priority 255` raised an unhandled pydantic `ValidationError` (a
  500-class parse crash) rather than parsing cleanly.  Parse now clamps
  the priority into `[1, 254]`, mirroring the aruba_aoss `owner` → 254
  mapping.  Surfaced during the 2026-06-15 `fixture-gap-hunt` while
  smoke-testing a real FortiGate VRRP pair; +1 regression test,
  cross-mesh-neutral (CODEC_BUG flat at 5).

* **cisco_iosxe_cli no longer mis-detects bannerless NX-OS / AOS-CX
  configs as IOS-XE.**  A containerlab / golden-config capture lacking
  its vendor banner (`!Command: show running-config` for NX-OS,
  `!Version ArubaOS-CX` for AOS-CX) caps its own structural probe at
  confidence 90 and **ties** the IOS-XE probe's generic `no shutdown` /
  `interface loopback` / `switchport` markers at 90 — then loses the
  alphabetical tie-break and silently mis-detects as `cisco_iosxe_cli`.
  Same hazard + fix pattern as the IOS-XR deferral: the IOS-XE probe now
  DEFERS (returns `None`) when an NX-OS-exclusive marker (`feature nv
  overlay` / `feature vn-segment-vlan-based` / `nv overlay evpn` /
  `vn-segment <vni>`) or an AOS-CX-exclusive marker (`interface lag <N>`
  / per-interface `vrf attach`) is present.  `interface nve1` is
  deliberately excluded (IOS-XE Catalyst VXLAN-EVPN uses it too).
  Zero IOS-XE regression (a real IOS-XE 17.15 EVPN-leaf fixture still
  detects); unblocks the bannerless NX-OS containerlab fixture added
  below.  Surfaced during the 2026-06-15 `fixture-gap-hunt` acquisition
  wave.
    * **cisco_nxos** — adds `networklessons_clab_vxlan_mcast_leaf2_nxos`
      (the regression witness for the #73 `mcast-group` parse fix via the
      **own-sub-line** form), now landable thanks to the deferral above;
      cross-mesh-neutral, CODEC_BUG flat at 5.

* **cisco_nxos VXLAN `mcast-group` now harvested on parse.**  The
  capability matrix declared `/vxlan-vnis/mcast-group` **supported** and
  the renderer emitted `member vni <vni>` + `mcast-group <ip>`, but the
  parser's `_parse_vxlan` never read the `member vni` lines — so the L2
  overlay multicast group was dropped 100% on the first parse, and a
  supported surface silently round-tripped to empty.  Parse now harvests
  `mcast-group` onto `CanonicalVxlan.mcast_group` in **both** real NX-OS
  grammar forms (inline `member vni N mcast-group X` and the own-sub-line
  `mcast-group X`), skipping `member vni N associate-vrf` (the L3VNI
  binding, harvested from `vrf context`).  Surfaced + verified during the
  2026-06-15 `fixture-gap-hunt` codec-bug verification pass; parse-side
  fix, CODEC_BUG flat at 5 and every cross-mesh artifact byte-identical
  (no existing NX-OS fixture carries `mcast-group`).  Unblocks importing
  the akarneliuk / networklessons real VXLAN multicast captures the gap
  report identified.

* **fortigate_cli SNMPv3 `auth-proto sha224` no longer silently
  upgraded to `sha256`.**  The FortiGate render map coerced canonical
  `auth_protocol="sha224"` to the FortiOS token `sha256`, even though
  FortiOS 7.x natively accepts `sha224` (the parse map already mapped
  `sha224 → sha224` faithfully).  On a same-vendor round-trip the value
  was silently mangled — and the coercion contradicted the
  junos→fortigate expectation YAML, which already declared `sha224`
  preserved.  Render now maps `sha224 → sha224`, so the full SHA-2 auth
  family round-trips losslessly.  Surfaced + verified during the
  2026-06-15 `fixture-gap-hunt` codec-bug verification pass; render-side
  one-line fix, CODEC_BUG flat at 5 (no mesh source emits `sha224` into
  a FortiGate target).

* **aruba_aoss VRRP grammar corrected to the real AOS-S form.**  The
  parser required `ip vrrp vrid <N>` and the renderer emitted it, but
  real ArubaOS-Switch uses a global `router vrrp` enable plus
  `vrrp vrid <N>` (NO `ip` prefix) nested in the `vlan N` stanza —
  verified against the AOS-S 16.10 "Basic configuration process" CLI
  reference and four independent operator captures (the
  `fixture-gap-hunt` flagged this as the worst-covered codec's primary
  blocker).  Parse now accepts the real `vrrp vrid` form (and still the
  legacy `ip vrrp vrid` for back-compat), tolerates the older
  `virtual-ip-address <ip> <mask>` two-token VIP, and maps the `owner`
  role keyword to the canonical priority ceiling 254 (255 is reserved /
  unrepresentable); render emits `router vrrp` + `vrrp vrid`.  No
  cross-mesh change (no aoss fixture exercised VRRP); CODEC_BUG flat at
  5.  Unblocks importing a real AOS-S VRRP capture.

* **Cross-vendor VLAN SVI L3 now survives translation to SVI-model
  targets.**  A VLAN carrying its Layer-3 on
  ``CanonicalVlan.ipv4_addresses`` with NO sibling ``Vlan<N>`` interface
  — the shape a Junos ``irb`` + ``set vlans <name> l3-interface irb.N``
  source produces — silently dropped its SVI address when rendered to
  Arista EOS / Cisco IOS-XE (both materialise an SVI only from a
  ``Vlan<N>`` interface in the canonical, never from the VLAN record).
  A new shared transform ``synthesize_svis_from_vlan_l3`` (the inverse of
  ``project_svi_to_vlan``) materialises the missing ``interface Vlan<N>``
  at render time, so the L3 round-trips — matching what the cross-vendor
  expectation YAMLs already declared ``good``.  Side benefit: existing
  Aruba AOS-S → Arista / IOS-XE VLAN-L3 now round-trips too.  Non-SVI
  targets (FortiGate, MikroTik) carry a VLAN's L3 on a VLAN-child
  interface that requires a physical parent the canonical VLAN record
  doesn't hold, so those two pairs are now honestly declared ``lossy``
  for ``vlans[].ipv4_addresses`` (was an aspirational ``good``).
  Surfaced by the ``saidvandeklundert`` Junos SNMPv3 real capture during
  the 2026-06-15 ``fixture-gap-hunt``; CODEC_BUG flat at 5.

* **arista_eos round-trip fidelity (3 defects).**  Surfaced while
  diffing `parse(render(parse(x)))` against `parse(x)` on an Arista AVD
  `eos_cli_config_gen` golden config during the 2026-06-15
  `fixture-gap-hunt`; each broke parse→render→parse stability and is now
  covered by a focused regression test.
    1. **SVI-description / VLAN-name fold.**  The shared
       `project_svi_to_vlan` transform folds an `interface Vlan<N>`
       `description` into the synthesised VLAN name.  arista parse now
       (a) normalises VLAN names to the EOS-safe form render emits
       (`\s+`→`_`), so a multi-word `SVI Description` no longer drifts to
       `SVI_Description` on the re-parse, and (b) runs the SVI fold
       *before* the EVPN `router bgp / vlan N` pass so MAC-VRF names key
       off the same VLAN names on both parses (previously
       `routing_instances` drifted and collapsed 20→18).  The
       synthesised-VLAN branch of `project_svi_to_vlan` now also dedupes
       copied addresses, matching its merge branch.
    2. **RADIUS line-bleed.**  `_RADIUS_SERVER_RE` no longer lets a bare
       `radius-server host <ip>` line (what render emits for a key-less
       server) consume the trailing newline and bleed into the next
       line, which had swallowed entries / cross-attributed keys on
       round-trip (12→9).
    3. **Trunk stale access-VLAN.**  A port made a trunk now clears any
       earlier `switchport access vlan` (render's trunk branch never
       emits it, so a lingering value silently dropped on round-trip).
  The real-capture round-trip harness now also sorts `routing_instances`
  by name (cosmetic order, consistent with the 7 collections it already
  sorts).

### Internal

* **Shared codec helpers — de-duplicated four vendor-neutral functions
  into `netcanon/migration/codecs/_helpers.py`.**  Four small pure helpers
  had been copy-pasted across the CLI / text codecs (the older
  "duplicate rather than lift" convention); they are now defined once and
  imported: `_normalise_mac_to_colon_hex` (was in cisco_iosxe_cli /
  cisco_nxos / aruba_aoscx), `_is_link_local_v6` (cisco_iosxe_cli /
  cisco_iosxr / cisco_nxos / aruba_aoscx), `_mask_to_prefix`
  (cisco_iosxe_cli / cisco_iosxr / arista_eos / aruba_aoss /
  fortigate_cli), and `_prefix_to_mask` (cisco_iosxe_cli / cisco_iosxr).
  Net −254 lines.  The two fallible helpers take a keyword-only `vendor=`
  so the raised `ParseError` / `RenderError` messages stay byte-identical
  (the cross-mesh report's render-error text is unchanged).
  Behaviour-preserving: the full unit + integration gate is green and the
  cross-mesh audit holds **CODEC_BUG flat at 5**.  The consolidated
  `_mask_to_prefix` adopts cisco_iosxr's already-correct 32-bit
  zero-padded contiguity check for all five copies, incidentally fixing a
  latent edge case where a mask whose leading octet is zero (e.g. the
  non-contiguous `0.255.0.0`) was mis-counted rather than rejected — no
  fixture or test exercises it, so the audit is unaffected.  fortigate
  keeps its own `_prefix_to_mask` (distinct message, no range pre-check).

* **Dead-code & dead-test removal.**  Removed an empty
  `if TYPE_CHECKING: pass` block (and its now-unused import) from
  `netcanon/api/deps.py` — every annotation there uses a runtime import.
  Deleted two permanently-`@pytest.mark.skip`-marked e2e tests in
  `tests/e2e/test_migrate_rename_modal.py`
  (`test_loopback_appears_as_warning_row`,
  `test_keep_verbatim_re_includes_auto_dropped_row`) whose premise died
  when Aruba gained loopback support (commit 5f4855a) — both skip reasons
  already noted the surfaces are covered by sibling tests.

## [0.1.8] - 2026-06-15

### Security

* **PII-recurrence guard (Phase 0 of the 2026-06 PII remediation).**  A new
  `.github/workflows/pii-guard.yml` job `git grep`s the tracked tree on every
  PR / push and fails the build if any file leaks the maintainer's personal
  email or an operator-machine Windows user-profile path.  A committed
  local hook (`scripts/git-hooks/pre-push`, opt in with
  `git config core.hooksPath scripts/git-hooks`) covers the
  commit-message / `Co-authored-by:`-trailer / author-identity vector before
  a push leaves the machine.  Both guards assemble or self-exclude their own
  patterns so they don't match themselves, and the gitignored review dossier
  (which legitimately quotes the values) is never scanned.  This phase also
  redacted the one residual operator path that the original pre-launch sweep
  missed (a committed review doc, `docs/project-review/.../01-investigation-CE-god-file.md`)
  and corrected that sweep's stale "zero hits" assertion.

* **PII history rewrite completed (Phase 1 of the 2026-06 PII remediation).**
  Scrubbed the personal-email co-author trailer (rewritten to the
  `Netcanon contributor` pseudonym) and the residual operator-machine path from
  *all* historical commits, and re-pointed every tag, via `git filter-repo` +
  force-push.  Verified: zero occurrences of either value across all reachable
  commits and blobs, and the working-tree content is byte-identical (same tree
  hash) so no code or functionality changed.  Every commit SHA changed, so
  older `(commit X)` citations in this file no longer resolve via
  `git show` — the narrative around each is the load-bearing context.  A
  GitHub Support cache-purge of the now-orphaned commit objects is the
  remaining manual follow-up (Phase 2).

* **Opt-in SSH host-key verification for the backup collectors (review finding
  #11).**  Both collectors previously installed paramiko `AutoAddPolicy` —
  trusting any host key unconditionally, so a man-in-the-middle on the
  management path could harvest the SSH password + enable secret.  A new
  `Settings.ssh_host_key_checking` (`NETCANON_SSH_HOST_KEY_CHECKING`, default
  `auto_add` = legacy behaviour) adds `tofu` (trust-on-first-use *with*
  persistence under `{data_dir}/known_hosts`; a later changed key is rejected)
  and `reject` (only already-known hosts).  The Paramiko shell collector uses
  the netcanon data-dir store (concurrent writes serialised by a lock); the
  Netmiko collector maps the strict modes onto the OS `~/.ssh/known_hosts`
  (`system_host_keys` + `ssh_strict`).  Default-off → no change for existing
  deployments.  Verified end-to-end against a real in-process paramiko SSH
  server (`tests/integration/test_ssh_hostkey.py`: TOFU learn/persist,
  reconnect-stable, changed-key-rejected, reject-unknown, auto_add-legacy).
  See SECURITY.md.

* **Optional egress allow-list for backup targets (review finding #3).**  A new
  `Settings.block_private_egress` (`NETCANON_BLOCK_PRIVATE_EGRESS`, default
  `false`) makes the backup entry points (`POST /api/v1/backups` + the schedule
  trigger) refuse targets that resolve to loopback or link-local addresses —
  including the `169.254.169.254` cloud-metadata endpoint — so a reachable
  non-operator can't turn the unauthenticated backup engine into an internal
  port-scanner / metadata probe.  RFC-1918 ranges stay allowed (real managed
  devices live there).  Default-off → zero change for desktop / trusted-VLAN
  deployments; enforced at the entry point, not yet at SSH-connect time (a
  noted DNS-rebinding follow-up).  See SECURITY.md.

* **Publish jobs refuse to release a tag that isn't on `main` (review finding
  #19).**  The PyPI, Docker, and desktop-MSI publish workflows each gain an
  early `git merge-base --is-ancestor` guard, so the "tag = publish" trigger
  can no longer push an immutable PyPI release / move the Docker `:latest` /
  attach an MSI from a tag cut on an arbitrary off-`main` commit.  The
  PyPI-publish action is also re-pinned with a `# v1.14.0` comment (the SHA was
  already that release's commit but was labelled as a branch ref, so Dependabot
  wasn't tracking it for security bumps).

* **Device-profile credentials are write-only over the API; backups resolve
  them server-side.**  The device-profile REST routes (`GET`/`LIST`/`POST`/`PUT
  /api/v1/devices`) now serialise through a new `DeviceProfilePublic` response
  model that omits `password` / `enable_password`, so the Fernet-decrypted
  credential is never returned to a client — closing the top finding of the
  2026-06-14 multi-lens review.  Credentials are accepted in create / update
  request bodies but never echoed.  To keep the web "Run backup now" flows
  working without that round-trip, `POST /api/v1/backups` now resolves a
  device's credentials *server-side* from its `device_profile_id` when none are
  supplied inline (mirroring the existing scheduled-trigger path), so the
  plaintext password no longer has to traverse the browser at all.
  `DeviceTarget.credentials` is now optional; a device with neither inline
  credentials nor a resolvable profile is rejected with HTTP 422.  A two-sided
  guard test (`tests/unit/test_device_profile_public.py`) keeps the public
  model in lockstep with `DeviceProfile` minus the two credential fields.

* **VLAN-range expansion is bounded before materialization (DoS hardening).**
  The four comma/range VLAN-list expanders (`cisco_iosxe_cli`, `cisco_nxos`,
  `aruba_aoscx`, `arista_eos`) clamp a range to the valid VLAN space (1..4094)
  *before* building it, so an out-of-bounds span (e.g. `switchport trunk
  allowed vlan 1-9999999999`) reaching the parser through a trusted operator's
  own config paste can no longer OOM the worker process.  Lossless for valid
  configs — the kept set equals the prior downstream 1..4094 filter; the fix
  only moves the bound ahead of materialization.  Surfaced by the 2026-06-14
  multi-lens codebase review.

### Fixed

* **IOS-XR configs no longer mis-detect as IOS-XE (2026-06 review #21).**  A new
  parametrized detect-uniqueness test over the whole real-capture corpus
  surfaced that every committed `cisco_iosxr` fixture (7 Batfish + 1 xrd-tools)
  resolved to `cisco_iosxe_cli`: the Batfish XR captures carry generic Cisco
  banners that tied IOS-XE at confidence 98, and `detect_codec`'s alphabetical
  tie-break then silently picked `cisco_iosxe_cli`; the banner-less xrd config
  lost outright (90 vs 75).  The IOS-XE probe now defers (returns no match) when
  an XR-EXCLUSIVE marker is present — the `!! IOS XR Configuration` banner,
  4-segment physical ports (`GigabitEthernet0/0/0/0`), `MgmtEth`, or the
  `route-policy` / `end-policy` / `prefix-set` routing DSL — none of which can
  appear in a genuine IOS-XE config, so real IOS-XE detection is unaffected.
  Every real capture now detects to its own codec as the sole top scorer.

* **Pathologically deep Junos block-form input fails cleanly (review #20).**  The
  recursive block-form → set-form pre-parser (`juniper_junos`) used one Python
  stack frame per `{` with no cap, so a hostile `{{{{…` payload raised an opaque
  `RecursionError` (HTTP 500).  A depth guard (`_MAX_BLOCK_DEPTH = 100`, far
  beyond any real config's ~4-deep nesting) now raises a clean `ParseError`
  instead.  The VyOS walker's explicit stack was already immune.

* **Deterministic LAG ordering in the cross-mesh certification artifact.**  The
  Cisco-family `_lag_sort_key` (`cisco_nxos`, `cisco_iosxe_cli`) now carries the
  verbatim LAG name as a final tiebreaker, so two case-variants of the same
  channel (a `Port-Channel3` stub vs a synthesized `port-channel3`) sort
  deterministically instead of by hash-randomized set-iteration order.  (The
  underlying case-mismatch duplicate-LAG round-trip defect is tracked
  separately.)

* **Shared device-profile registry is now lock-guarded across threads (review
  finding #10).**  The backup worker thread persists a profile's
  `detected_facts` while route handlers (which FastAPI runs in a threadpool for
  sync endpoints) create / update / delete the same dict + on-disk files.  A
  new `DEVICE_PROFILE_REGISTRY_LOCK` (in `storage/device_profile_store.py`)
  serialises every read-modify-write-persist + delete critical section, and the
  worker re-checks membership inside the lock before saving — so a
  delete-then-save interleaving can no longer resurrect a just-deleted profile
  to disk, and concurrent `detected_facts` updates can't be lost.

### Changed

* **Hygiene cluster (2026-06 review lows #20/#21).**  Several small
  defence-in-depth and reproducibility wins shipped together:
  * `MigrationPlanRequest.raw_text` now carries a `max_length` of 10M chars —
    a model-boundary body cap (any real single-device config is far smaller)
    that complements the existing VLAN-range DoS clamp.
  * The `CROSS_MESH_RESULTS.md` and `PHASE4_RECONCILIATION.md` headers no
    longer embed a wall-clock timestamp (or, for Phase 4, the gitignored
    mesh-run JSON filename).  Provenance is now the git commit that updates
    the file, so `--matrix` / reconciliation regeneration is byte-reproducible
    for a given tree — a non-empty `git diff` on those artifacts means real
    drift, not a new run time.
  * The fixture-directory → codec-name identity table (`_DIR_TO_CODEC_NAME`),
    previously hand-replicated in `tools/run_full_mesh.py` and
    `tests/.../test_real_captures.py` (and known to have drifted), is now a
    single source of truth in `netcanon/migration/fixture_dirs.py` that both
    import.
  * The credential key-file write (`security/credentials.py`) documents the
    real Windows confidentiality model honestly — the `os.chmod(0o600)` is a
    no-op there; protection comes from the inherited user-profile ACL, and the
    `NETCANON_FERNET_KEY` env var keeps the key off disk entirely.

* **Live validation now declares the surfaces each codec silently drops
  (validation-honesty completion; 2026-06 adversarial review of the keystone).**
  An adversarial verification pass over the merged keystone (#60/#61/#62) found
  that the walker now *sees* every Tier-2 surface but the codecs that DROP a
  naming-independent surface on render still hadn't declared it — so
  `validate_against` returned `severity: ok` while the render discarded the
  data (a `{hostname, domain, dns, ntp, syslog, radius, dhcp}` config validated
  "clean" against aruba_aoscx / cisco_nxos while 100% of those surfaces vanished;
  a RADIUS shared secret was among the silently-dropped).  Every codec that
  drops `domain` / `timezone` / `dns_servers` / `ntp_servers` / `syslog_servers`
  / `dhcp_servers` / `radius_servers` / `routing_instances` on render now
  declares the exact granular walker xpath (`/system/syslog-server`,
  `/radius-servers/server/{host,key}`, `/dhcp-servers/pool`,
  `/routing-instances/instance`, …) `unsupported`, so the loss is visible in the
  report.  Corrected one supported-but-dropped lie: opnsense declared
  `/system/ntp-server` supported but renders no timeservers — moved to
  unsupported (so e.g. FortiGate→OPNsense now honestly reports `partial`/`block`
  on a config with NTP).  Two new registry guards enforce this going forward —
  a *total-drop ⇒ declared* check restricted to the false-positive-free
  naming-independent surfaces, and a walker-grounded *rendered ⇒
  not-unsupported* check at sub-field granularity — plus a model-field
  completeness guard for the marker dict (the review found
  `anycast_gateway_mac` / `interfaces` / `timezone` were missing).  The walker
  also descends into VRRP group sub-fields (`virtual-ips` when >1 VIP,
  `virtual-mac`, `track-interfaces`) so the FortiGate / AOS-S multi-VIP losses
  they declare lossy finally surface, and the redundant aruba_aoss
  VLAN-port double-yield + the codec-author guide's dead
  `/interfaces/interface/config/name` example are cleaned up.  No parse/render
  change; cross-mesh CODEC_BUG flat at 5 (the only matrix reshape is the
  `/routing-instances/instance` declarations flipping four non-VRF targets'
  cells to expected-unsupported).
* **Registry-wide capability-matrix honesty guard + walker completion (review
  finding #9).**  The two-sided render/walker honesty invariant was enforced
  for exactly one codec (the cisco_iosxe NETCONF stub); the 11 production
  codecs were ungated, so a render expansion or matrix loosening could drift a
  codec's declared capabilities from its real behaviour with no failing test.
  New `tests/unit/migration/test_registry_capability_honesty.py` parametrises
  over every bidirectional codec and enforces the two *false-positive-free*
  directions of the invariant: **reverse-parity** (every declared `supported`
  xpath must be one the shared canonical walker can emit — a path it can't is
  unreachable by `validate_against` and so a dead declaration; review #8c) and
  **rendered ⇒ not-unsupported** (a top-level field that survives a
  render→re-parse round-trip must not be declared `unsupported` — a matrix that
  calls a surface unsupported while round-tripping it would wrongly block/warn a
  migration), plus a no-supported/unsupported-overlap check.  To make
  reverse-parity pass, `_walk_canonical` is completed to yield the remaining
  declared surfaces — VLAN `tagged-ports` / `untagged-ports` / `description`,
  the per-interface switchport view (`switchport-mode` / `access-vlan` /
  `trunk-allowed-vlans` / `trunk-native-vlan`, no-`config/` spelling matching
  nxos/aoscx), `dhcp-client` (v4), and the IPv6 `virtual-gateway-address` /
  `virtual-gateway-mac` (plus IPv4 `virtual-gateway-mac`) the IPv4 path already
  carried — and the redundant `/interfaces/interface/config/name` declaration
  (five codecs) is dropped in favour of the walkable `/interfaces/interface/name`.
  aruba_aoss now declares its switchport view `supported` (it round-trips L2
  membership) and mikrotik declares `/vlans/vlan/description` lossy (RouterOS
  overloads the single per-VLAN comment).  No parse/render change, no
  `unsupported` declaration changed → cross-mesh fidelity matrix untouched
  (CODEC_BUG flat at 5).  (The third honesty direction — *dropped* ⇒
  unsupported — stays covered by the per-codec round-trip tests + cross-mesh
  audit; a single universal kitchen-sink can't detect drops without
  vendor-naming false positives.)
* **Capability-matrix xpath vocabularies normalised to one granular shape
  (review finding #8).**  Codecs declared the same canonical surface under two
  incompatible xpath spellings, so half of them were unreachable by the
  exact-match `classify` (and by the walker, which speaks the granular shape):
  local users were declared as `/aaa/authentication/users/user/config/*` on
  five codecs (arista_eos, fortigate_cli, opnsense, juniper_junos, cisco_iosxr)
  but as `/local-users/user/*` on three (aruba_aoscx, cisco_nxos, vyos); the
  arista DHCP pool used an underscore (`/dhcp_servers/pool`) where the walker
  and junos use a hyphen (`/dhcp-servers/pool`); the cisco_iosxe stub declared
  EVPN Type-5 as `/evpn-type5/route` where every other codec uses
  `/evpn-type5-routes/route`; and the per-interface VRF binding was
  `/interfaces/interface/vrf` on nxos/aoscx vs `/interfaces/interface/config/vrf`
  on iosxr/vyos.  All are now the single granular vocabulary the walker emits,
  so the declarations are reachable by live validation and comparable across
  codecs.  `tools/run_full_mesh.py`'s `_FIELD_TO_XPATH_PREFIX` EVPN entry is
  fixed in lockstep (`/evpn-type5/` → `/evpn-type5-routes/`; the old prefix
  matched nothing).  Pure declaration renames — no parse/render change; the
  cross-mesh fidelity matrix is unchanged (CODEC_BUG flat at 5; field
  dispositions byte-identical).
* **Interactive validation report now sees every populated Tier-2 surface
  (review finding #7).**  The shared canonical walker (`_walk_canonical`, reused
  by 10 codecs' `iter_xpaths`) previously yielded xpaths only for system
  scalars, interfaces, VLANs, static routes, and SNMP — so
  `validate_against` was structurally blind to DHCP pools, LAGs, local users,
  RADIUS servers, VXLAN/EVPN, routing-instances, and the per-interface
  `mtu` / `vrf` / `lag-member-of` sub-fields.  A migration whose target codec
  drops or lossy-converts one of those could read `severity: ok`.  The walker
  now yields a granular hyphenated xpath for **every** populated surface
  (guarded so an empty surface still yields nothing), so the report can flag
  the loss before an operator commits.  This only ever makes the report *more*
  conservative (more warn/block, never less); it does not touch parse/render
  output, so the cross-mesh fidelity matrix is unchanged.  The stale "Phase 1
  adds glob/prefix matching" promise in `CapabilityMatrix.classify` (never
  built, not needed under the shared-vocabulary design) is removed from the
  docstring.  New coverage guard:
  `tests/unit/migration/codecs/cisco_iosxe_cli/test_walk_canonical_coverage.py`.
  (Per-interface switchport sub-fields and the matrix-vocabulary normalisation
  — review #8 — follow in a separate pass.)
* **Documentation honesty pass (multi-lens review remediation).**  The README
  trust-signal now states the live `CODEC_BUG` residual (5 triaged-benign cells)
  and names `tests/fixtures/real/PHASE4_RECONCILIATION.md` as the authoritative
  count, instead of claiming "zero".  The codec-author guide
  (`netcanon/migration/codecs/README.md`) is corrected from "Eight codecs" to
  twelve (count-resilient, pointing at `RESULTS.md`) and its Tier-3 detector
  enumeration refreshed to the full per-vendor set.
* **`docs/CAPABILITIES.md` §A completeness.**  Added the four missing
  per-codec "Unsupported / Lossy" panels (`cisco_nxos`, `cisco_iosxr`,
  `aruba_aoscx`, `vyos`), generated from each codec's `CapabilityMatrix`, so
  §A now enumerates every codec's declared exceptions rather than only the
  original eight (also flagged by the 2026-06-14 review).
* **Front-door honesty pass — vendor breadth + "certified" semantics (review
  findings #14, #15).**  README / `docs/COMPARISON.md` / `docs/IDENTITY.md`
  now describe the shipped breadth (twelve codecs across Cisco / Juniper /
  Arista / Aruba / Fortinet / MikroTik / OPNsense / VyOS) instead of the stale
  seven-vendor list, and `vyos` is added to the `pyproject.toml` keywords +
  IDENTITY GitHub-topics so the new VyOS family is discoverable on PyPI /
  GitHub.  The Tier-1 "every shipped codec renders these fully" line now
  excepts the interfaces-only `cisco_iosxe` NETCONF stub.  `ARCHITECTURE.md`,
  `docs/CAPABILITIES.md`, and `translator-plans.txt` decouple `certified`
  (round-trip fidelity) from "deploy-ready": there is no deploy path today —
  rendered output is for manual review/apply.

### Added

* **Desktop MSI ships a third-party license notice (review finding #18).**  The
  Windows installer redistributes PySide6/Qt (LGPLv3), paramiko (LGPL-2.1), and
  pystray (LGPL-3.0) as binaries; `setup_desktop.py` now bundles
  `THIRD-PARTY-NOTICES.txt` (component inventory + SPDX licenses + the LGPL
  source-offer) and the project `LICENSE` next to the EXE, closing the
  attribution gap on the distributed artifact.  (The PyPI wheel only declares
  the deps, so it was never affected.)
* **`.github/CODEOWNERS` (review finding #19).**  A version-controlled,
  documented template for review ownership — the file makes the gate intent
  explicit; the maintainer still wires the real owner handle + the
  "require Code Owner review" branch-protection setting (noted in the header).

* **VyOS codec — set-form input (`show configuration commands`).**  The
  `vyos` codec now accepts VyOS *set-form* configuration (the flat `set
  <path> [value]` output of `show configuration commands`) in addition to
  the native curly-brace `config.boot`.  A new front-end
  (`_setform_to_brace` in `vyos/parse.py`) converts set-form to the
  equivalent curly-brace text up front, so the certified brace-stack walker
  — and every phase of its dispatch — runs unchanged (mirroring how the
  `juniper_junos` codec converts block-form to set-form ahead of its
  set-parser).  The conversion is idempotent on curly-brace input and
  generic enough that Tier-3 blocks (`set firewall` / `set nat` / `set
  protocols bgp`) still feed the dropped-sections banner.  The probe
  disambiguates VyOS set-form from Junos set-form: VyOS-disjoint markers
  (Linux `ethernet ethN` / `bonding bondN` / `vxlan` netdev names, `set
  service`, `set protocols static route`, `set vrf name`) claim it, while
  Junos-defining markers (`set version`, `ge-/xe-/…` interface names,
  `routing-options`, `set snmp`, `set vlans`) veto so the `juniper_junos`
  codec keeps its set-form (verified non-colliding both ways).  Render is
  unchanged — always curly-brace `config.boot`; set-form is input-only.  No
  canonical surfaces change, so the capability matrix and cross-mesh are
  unaffected (CODEC_BUG flat at 5).  Synthetic-validated; a permissive real
  set-form capture is tracked in `WANTED.md`.
* **VyOS codec — real-capture validation for `service snmp`.**  Lands two
  permissive real `config.boot` SNMP captures under
  `tests/fixtures/real/vyos/`, flipping the `service snmp` surface from
  synthetic-only to real-validated: `scottlaird/vyos-parser` (Apache-2.0,
  VyOS 1.5, `community public`) and `rapid7/metasploit-framework`'s
  `vyos_config` module docs (BSD-3, VyOS 1.3, `community ro`/`write` —
  the codec keeps the canonical single community).  Both round-trip
  cleanly through the real-capture harness; secrets are author-redacted
  or published example hashes.  The corpus now spans **three VyOS majors
  (1.3 / 1.4 / 1.5)** from four sources.  A companion hunt found **no**
  permissive + curly-brace real config exercising `vrf name` (only GPL
  `vyos-1x` smoketests or unlicensed), so VRF stays synthetic-validated
  (tracked in `WANTED.md`).  Cross-mesh regenerated (1020 → 1044 cells);
  high-severity CODEC_BUG held flat at 5.
* **VyOS codec — Phase 5 (VXLAN + real-capture validation).**  Adds
  `interfaces vxlan vxlanN` netdevs → `CanonicalVxlan` (one VNI per
  netdev: `vni` / `source-address`|`source-interface` / `group` /
  `remote` / `port`).  VyOS models VXLAN as a Linux netdev — one VNI
  each, the L2 VLAN binding lives on a separate `bridge` — unlike the
  NX-OS/AOS-CX vlan→vni table, so the required canonical `vlan_id` is
  synthesised from the VNI and the netdev name is regenerated
  `vxlan<index>` on render (both deterministic; declared lossy).
  Graduates `/vxlan-vnis/{vni,mcast-group,flood-list,udp-port}` →
  `supported`; `/vxlan-vnis/{source-interface,vlan-id}` → `lossy`; the
  per-VNI EVPN RD/RT (`/vxlan-vnis/l2vni-route-target`) + symmetric-IRB
  L3VNI stay `unsupported`.  Also captures **block-form NTP servers**
  (`server <host> { }`, VyOS 1.4-rolling mid-2023+) alongside the older
  bare-leaf form — a real-world grammar gap surfaced by the new corpus.
  **VXLAN is real-validated**: two Apache-2.0 `config.boot` captures from
  [`zhouleyan/wcni-kind`](https://github.com/zhouleyan/wcni-kind) (the
  two ends of one `vni 10` tunnel) land under `tests/fixtures/real/vyos/`
  and round-trip cleanly through the real-capture harness.  `vyos` stays
  `certified`.  Cross-mesh regenerated (996 → 1020 cells); high-severity
  CODEC_BUG held flat at 5.

## [0.1.7] - 2026-06-14

### Added

* **VyOS codec — Phase 4 (certified).**  Flips the `vyos` codec from
  `experimental` to **`certified`** on the strength of a real-capture
  corpus: 6 VyOS 1.4-rolling `config.boot` files from the MIT-licensed
  (SEI) `cisagov/prescup-challenges` US President's Cup material, committed
  under `tests/fixtures/real/vyos/` (2 round-1 IPv4/OSPF routers + 4
  round-3b IPv6/BGP routers).  All parse, are deterministic, and
  round-trip cleanly through the real-capture harness (OSPF/BGP surfaced
  via `dropped_tier3_sections`), clearing the `base.py` "≥3 real captures
  round-trip cleanly" bar 2×.  Corpus chosen over `vyos/vyos-build` to
  avoid the VyOS project's GPL/LGPL.  Wires `tests/fixtures/real/vyos/`
  into the real-capture harness + cross-mesh (`_DIR_TO_CODEC_NAME` in both
  `test_real_captures.py` and `tools/run_full_mesh.py`).  **Honest scope:**
  the real corpus exercises interfaces / host-name / local-users / NTP;
  `service snmp` + VRF remain synthetic-only (no real capture carries
  them), and VXLAN + `set`-form input are still future phases.  Cross-mesh
  regenerated (924 → 996 cells — 6 new VyOS source configs);
  high-severity CODEC_BUG held flat at 5.
* **VyOS codec — Phase 3 (`service snmp` + VRF routing-instances).**
  Extends the `vyos` codec with two management-/control-plane surfaces.
  **SNMP** (`service snmp`) → `CanonicalSNMP`: the v1/v2c `community`
  (a name-keyed block; rendered read-only), `location` / `contact`, and
  `v3 { user <name> { auth { … } privacy { … } } }` USM users (auth +
  privacy protocol, the opaque `encrypted-password` key, and `group`;
  the single config-wide `engineid` is mapped onto every user).
  **VRF**: `vrf { name <X> { table <N> } }` → `CanonicalRoutingInstance`
  + the per-interface `vrf <X>` leaf → `CanonicalInterface.vrf`.  The
  per-interface binding never conjures a routing-instance — instances
  materialise only from their authoritative `vrf name` declaration (the
  phantom-instance guard).  Graduates
  `/snmp/{community,location,contact,v3-user}` +
  `/routing-instances/instance/name` +
  `/interfaces/interface/config/vrf` → `supported`;
  `/snmp/v3-user/{auth-passphrase,engine-id}` +
  `/routing-instances/instance/table` → `lossy` (VyOS requires a numeric
  VRF `table` id the canonical model has no field for — the codec
  synthesises a deterministic `100 + sort-index` on render).  Per-VRF
  static routes (`/routing/static-route/vrf`) stay `unsupported`
  (deferred).  Still `experimental` (no real corpus yet).  Cross-mesh
  regenerated (924 cells, roster unchanged); high-severity CODEC_BUG
  held flat at 5.
* **VyOS codec — Phase 2 (local users + NTP + bonding LAGs).**  Extends
  the `vyos` codec: `system login user { authentication
  encrypted-password }` → `CanonicalLocalUser` (VyOS login users map to
  privilege 15 / role `admin` — declared lossy, no per-user numeric
  privilege); `system` / `service` `ntp { server <host> }` →
  `CanonicalIntent.ntp_servers`; and `interfaces bonding bondN` →
  `CanonicalLAG` + a kind-`lag` interface (`mode 802.3ad` → LACP
  `active`, non-LACP modes collapse to `static` — lossy).  Bond members
  are parsed from BOTH the 1.4-style `bonding bondN { member { interface
  ethN { } } }` and the legacy 1.2-style `ethernet ethN { bond-group
  bondN }` forms, and rendered in the 1.4 member-interface form.
  Graduates `/local-users/user/{name,role,hashed-password}` +
  `/system/ntp-server` + `/lags/lag/{name,members}` +
  `/interfaces/interface/lag-member-of` → `supported`;
  `/local-users/user/privilege-level` + `/lags/lag/mode` → `lossy`.
  Still `experimental` (no real corpus yet).  Cross-mesh regenerated
  (924 cells, roster unchanged); high-severity CODEC_BUG held flat at 5.
* **New codec: VyOS (`vyos`) — Phase 1.**  Bidirectional codec for the
  VyOS router/firewall NOS (the OSS Vyatta successor) — netcanon's
  **12th migration codec**.  VyOS stores its configuration as a
  JunOS-style **curly-brace tree** (`config.boot` / `show
  configuration`), parsed by a new brace-stack walker.  It is a distinct
  codec from `juniper_junos`: a VyOS config has no `set ` lines, uses
  Linux-style `ethN` / `lo` / `bondN` device names, and terminates
  leaves with bare newlines (no `;`) — the probe keys off the
  `// vyos-config-version` trailer (or, trailer-less, curly-brace
  structural markers) and vetoes both Junos set-form (`set ` lines) and
  Junos curly-form (`;`-terminated leaves).  Phase-1 Tier-1 surface:
  `system host-name`; `interfaces ethernet` / `loopback` / `dummy`
  blocks (`address` IPv4+IPv6 CIDR / `dhcp` / `description` / `disable`
  → admin-down / `mtu`); `vif` VLAN sub-interfaces (modelled as
  `ethN.<vid>` interfaces); and `protocols static` routes (`route` /
  `route6` / `next-hop` / `distance`).  Leaf values are parsed
  quote-agnostically (VyOS 1.4+ quotes them, older releases don't).
  `certainty` is `experimental` — synthetically round-trip-validated; a
  real-capture corpus (the MIT-licensed `cisagov/prescup-challenges`
  VyOS configs) + the certified tier follow in later phases, alongside
  `bonding` LAGs, `system login` users, `service` (SSH/NTP/SNMP), and
  VRF.  Cross-mesh regenerated (836 → 924 cells); high-severity
  CODEC_BUG held flat at 5.

## [0.1.6] - 2026-06-13

### Added

* **Aruba AOS-CX codec — Phase 4 (VXLAN L2VNI + certified).**  Parses +
  renders the VTEP overlay: `interface vxlan 1` / `source ip <X>` /
  `vni <VNI>` / nested `vlan <VLAN>` → `CanonicalVxlan` L2 VLAN↔VNI
  bindings (the cross-vendor primitive shared with NX-OS / Arista /
  Junos).  AOS-CX states the VTEP source as an IPv4 *address* (not an
  interface name), stashed verbatim in the opaque
  `CanonicalVxlan.source_interface` and re-emitted only when it parses as
  an IP (a cross-vendor interface-name source omits the `source ip` line
  rather than emit a malformed address).  Graduates `/vxlan-vnis/vni` →
  `supported` and `/vxlan-vnis/source-interface` → `lossy`; the per-VLAN
  L2VNI RD/route-target (`evpn / vlan N / rd auto / route-target ...` —
  `auto`-derived, no cross-vendor home) + symmetric-IRB L3VNI stay
  `unsupported`.  **Flips `certainty` `best_effort` → `certified`**: a
  4-config real-capture corpus from the Apache-2.0
  [`aruba/aoscx-ansible-dcn-workflows`](https://github.com/aruba/aoscx-ansible-dcn-workflows)
  reference fabric (2 VXLAN-EVPN leaves + 2 active-gateway cores, AOS-CX
  10.04 / 10.13) round-trips cleanly — the `active-gateway` surface,
  synthetic-only through Phase 3, is now exercised by the real arch4
  core configs.  Cross-mesh + phase-4 regenerated; high-severity
  CODEC_BUG held flat at 5.
* **Aruba AOS-CX codec — Phase 3 (`active-gateway` anycast).**  Parses +
  renders the VSX/EVPN distributed-gateway surface: the per-SVI
  `active-gateway ip <vip>` (mirrored into
  `CanonicalIPv4Address.virtual_gateway_address`) + `active-gateway ip
  mac <mac>` (the first occurrence → the chassis-wide
  `CanonicalIntent.anycast_gateway_mac`, colon-hex).  Unlike NX-OS DAG
  (where the gateway VIP == the SVI's own IP), AOS-CX is Arista-VARP
  style — the VIP is distinct from the SVI's per-switch address — but it
  reuses the same certified anycast canonical surface.  Graduates
  `/interfaces/interface/ipv4/address/virtual-gateway-address` +
  `/anycast-gateway-mac` → `supported`; the IPv6 anycast companion stays
  unsupported (parity with the NX-OS / IOS-XE deferral).  Cross-mesh +
  phase-4 regenerated; high-severity CODEC_BUG held flat at 5.
* **Aruba AOS-CX codec — SNMP + `best_effort`.**  Adds the SNMP surface:
  `snmp-server community`, `snmp-server system-location` /
  `system-contact` (note AOS-CX's `system-` prefix vs NX-OS's bare
  keyword), and `snmpv3 user <name> auth <md5|sha> auth-pass ciphertext
  <blob> [priv <des|aes> priv-pass ciphertext <blob>]` USM users.
  Graduates `/snmp/community` + `/snmp/v3-user` → `supported` and adds
  `/snmp/location` + `/snmp/contact`; `/snmp/v3-user/auth-passphrase` is
  declared lossy (the device-key `ciphertext` blob is same-device-portable
  only, and the `plaintext` key form normalises to `ciphertext` on
  render); `/snmp/trap-host` stays unsupported (the `snmp-server host`
  trap-receiver grammar is deferred).  With the core L2/L3 + LAG + user +
  SNMP surface synthetically round-trip-validated, `certainty` bumps
  `experimental` → `best_effort`.  Cross-mesh + phase-4 regenerated;
  high-severity CODEC_BUG held flat at 5.
* **Aruba AOS-CX codec — Phase 2 (L2 switchport + LAGs + local users).**
  Graduates the L2 surface to `supported`: `no routing` + `vlan access
  <N>` / `vlan trunk native <N> [tag]` / `vlan trunk allowed <list|all>`
  (with VLAN-centric port projection), LAGs (`interface lag N` is now
  materialised as a kind-`lag` interface carrying its switchport config,
  plus per-port `lag N` membership and `lacp mode active|passive` —
  static aggregation when absent), and local users (`user <name> group
  <group> password ciphertext <blob>`; `group` → role, administrators →
  privilege 15 else 1, declared lossy).  An empty trunk-allowed list
  round-trips as `vlan trunk allowed all`; the `interface lag N`
  `multi-chassis` (VSX MLAG) modifier is dropped in v1.  `certainty`
  stays `experimental` — no real-capture corpus yet, and SNMP +
  `active-gateway` anycast + VXLAN-EVPN are still pending.  Cross-mesh +
  phase-4 regenerated; high-severity CODEC_BUG held flat at 5.
* **New codec: Aruba AOS-CX (`aruba_aoscx`) — Phase 1.**  Bidirectional
  `show running-config` translator for Aruba's modern switch portfolio
  (6000 / 6100 / 6200 / 6300 / 6400 / 8100 / 8320 / 8325 / 8360 / 8400 /
  9300 / CX-10000), the **11th migration codec**.  Distinct vendor
  identity from the legacy `aruba_aoss` (AOS-S) codec — AOS-CX is a
  ground-up redesign modelled on `arista_eos` (see
  `docs/fixture-research-2015/11-aruba_aoscx.md`), so it gets its own
  grammar, `CapabilityMatrix`, probe, and render path.  Phase 1 covers
  the Tier-1 surface: hostname, basic-L3 interfaces (description /
  admin-state / `mtu` / IPv4 + IPv6 CIDR / `vrf attach`), VLANs (id +
  name + description), top-level `vrf <name>` declarations, and
  default-VRF static routes.  Handles the AOS-CX grammar divergences —
  multi-token interface names (`interface vlan 11` / `interface lag 1` /
  `interface 1/1/1`), the admin-down interface default (loopbacks
  excepted), and the `!Version ArubaOS-CX` probe banner (no collision
  with the `;`-banner AOS-S codec or Arista's `interface Ethernet`
  markers).  `certainty` is `experimental` — the L2 switchport / LAG /
  SNMP / local-users / `active-gateway` anycast / VXLAN-EVPN surfaces and
  a real-capture corpus (`certified` tier) follow in later phases.  Wired
  through the standard new-codec sites (`aruba_aoscx` vendor YAML,
  `cli-aoscx` input format, `detect_tier3_sections_aoscx`, cross-mesh
  source + target).  Cross-mesh + phase-4 regenerated; high-severity
  CODEC_BUG held flat at 5.
* **Cisco NX-OS codec — `certified` (real-capture corpus).**  Flips
  `certainty` `best_effort` → `certified` and lands a 6-config
  `batfish/lab-validation` real-capture corpus (Apache-2.0) under
  `tests/fixtures/real/cisco_nxos/`, spanning four distinct NX-OS 9.2(3)
  scenarios: HSRP (`nxos_hsrp` ×2 — FHRP + LAG), EVPN L3VNI
  (`nxos_evpn_l3vni` ×2 — VRF + `nve1` VTEP + symmetric-IRB L3VNI), EVPN
  L2VNI (`nxos_evpn_l2vni` — VLAN↔VNI), and BGP-redistribute
  (`nxos_bgp_redist_connected`).  All six parse, are deterministic, and
  round-trip cleanly through `test_real_captures.py` (wired via
  `_DIR_TO_CODEC_NAME` in the harness + `tools/run_full_mesh.py`, which
  now exercises NX-OS as a cross-vendor source).  > 3× the `base.py`
  "≥3 real captures" bar; the single-version (9.2(3)) corpus is noted in
  `WANTED.md` with a newer-major fixture as a welcomed nice-to-have.
  Cross-mesh + phase-4 regenerated; high-severity CODEC_BUG held flat
  at 5.
* **Cisco NX-OS codec — IPv4 Distributed Anycast Gateway (DAG).**  Parses
  + renders the per-SVI `fabric forwarding mode anycast-gateway` marker
  (which mirrors the SVI's primary IP into
  `CanonicalIPv4Address.virtual_gateway_address`) plus the chassis-wide
  `fabric forwarding anycast-gateway-mac` (round-trips NX-OS
  dotted-triplet ↔ canonical colon-hex).  Mirrors the `cisco_iosxe_cli`
  SD-Access implementation — the grammar is identical across the two
  Cisco DC platforms.  Matrix graduates
  `/interfaces/interface/ipv4/address/virtual-gateway-address` +
  `/anycast-gateway-mac` to `supported`; the IPv6 anycast companion stays
  `unsupported` (parity with the IOS-XE codec's deferral).  Closes the
  last Tier-2 gap in the NX-OS codec.  Cross-mesh + phase-4 regenerated;
  high-severity CODEC_BUG held flat at 5.

## [0.1.5] - 2026-06-13

### Added

* **Cisco IOS-XR codec — Phase 4 (`certified`), completing the codec.**
  Flips `certainty` `best_effort` → `certified` and adds a second,
  independent real-capture source so the certification doesn't rest on a
  single upstream's grammar conventions: 3 `ios-xr/xrd-tools`
  `xr_compose_topos` configs (Apache-2.0, official Cisco GitHub org) —
  `xrdtools_srv6_pe1.cfg` (SRv6 L3VPN — VRF + IS-IS core + segment-routing
  srv6), `xrdtools_sr_xrd1.cfg` (SR-MPLS — IS-IS + BGP + `extcommunity-set`),
  and `xrdtools_isis_r1.cfg` (IS-IS IP-FRR).  These exercise the IS-IS /
  SR-MPLS / SRv6 grammar the batfish corpus lacks; all three parse, are
  deterministic, and round-trip cleanly.  The real-capture corpus is now
  **10 configs from two sources**, > 3× the `base.py` "≥3 real captures"
  `certified` bar.  Tenth migration codec, fourth `certified` Cisco
  surface.  Cross-mesh + phase-4 regenerated; high-severity CODEC_BUG held
  flat at 5.  (Design dossier: `docs/v0.2.0-planning/04-iosxr-codec/`.)
* **Cisco IOS-XR codec — Phase 3 (SP-routing parse-and-display + real
  batfish corpus; certainty → `best_effort`).**  Lands the 7-config
  `batfish/lab-validation` real-capture corpus (Apache-2.0) under
  `tests/fixtures/real/cisco_iosxr/`: the `cisco_xr_ios_vpnv4` XR-XE
  VPNv4 PEs (3 VRFs + RD-from-`router bgp` + MPLS-LDP + OSPF), the
  `iosxr_ebgp_basic` borders (`encapsulation dot1q` subinterface +
  prefix-set + route-policy), and the `iosxr_ibgp_rr_over_ospf` RR +
  client (2× Bundle-Ether with member `bundle id N mode active`).  All
  7 parse, are deterministic, and round-trip cleanly through
  `test_real_captures.py` (wired via `_DIR_TO_CODEC_NAME`).  The
  SP-routing / policy stanzas the codec deliberately drops (`router
  bgp`, `router ospf`, `mpls ldp`, `route-policy`, `prefix-set`) are
  surfaced on `dropped_tier3_sections` via the
  `detect_tier3_sections_iosxr` notification banner — parse-and-display,
  not translation (no `CanonicalBgpNeighbor` surface exists).  The
  Phase-2 RD-from-`router bgp` harvest is validated against the real
  vpnv4 PEs (IP-based RDs like `10.254.1.1:65102` round-trip).  No
  capability-matrix change — the unsupported declarations stand; the
  operator-facing banner improves.  Certainty bumped
  `experimental` → `best_effort`.  Cross-mesh + phase-4 regenerated;
  high-severity CODEC_BUG held flat at 5.  Phase 4 (≥1-2 grammar-diverse
  non-batfish captures + the `certified` flip) remains.
* **Cisco IOS-XR codec — Phase 2 (VRF + RD-from-`router bgp` + LAGs +
  local users + dot1q VLAN synth).**  Top-level `vrf <name>` stanzas with
  nested `import|export route-target` blocks → `CanonicalRoutingInstance`
  (RT import/export; XR's word order is `import route-target` with the
  RT values on their own `!`-terminated lines, not NX-OS/IOS-XE's
  single-line `route-target import <rt>`); per-interface bare `vrf
  <name>` membership → `CanonicalInterface.vrf`; per-VRF `router static /
  vrf <name>` routes → `CanonicalStaticRoute.vrf`; `Bundle-Ether<N>`
  LAGs from member `bundle id <N> mode <m>` lines → `CanonicalLAG` +
  `lag_member_of`; local users (the `username` block form `group
  <task-group>` + `secret <type> <hash>`, with `root-lr` / `root-system`
  → admin privilege 15); and sub-interface `encapsulation dot1q <vid>` →
  synthesised `CanonicalVlan` id-list (no port membership; XR routers
  have no classic `vlan` stanza).  **The route-distinguisher is
  harvested from / rendered to the `router bgp <asn> / vrf <name> / rd`
  block** — IOS-XR keeps the RD in the BGP process, NOT the `vrf` stanza
  (the big XR/IOS-XE structural divergence).  The re-emitted minimal
  `router bgp` RD-carrier derives its ASN from the RD administrator field
  (the `<asn>:<nn>` convention) so a same-vendor round-trip re-detects
  the identical `router bgp <asn>` Tier-3 header; an XR config with no
  `router bgp` stanza keeps `route_distinguisher=''` (declared lossy).
  Matrix graduates to **22 supported / 3 lossy / 17 unsupported**
  (`/routing-instances/instance` is declared both supported and lossy →
  classifies lossy for the RD-from-BGP caveat; SNMP + the SP-routing /
  `route-policy` / MPLS / `l2vpn` Tier-3 stanzas remain unsupported).
  Cross-mesh + phase-4 regenerated; high-severity CODEC_BUG held flat at
  5.  Phases 3 (SP-routing harvest) + 4 (certified corpus) remain.
* **Cisco IOS-XR codec — Phase 1, a new bidirectional codec
  (`cisco_iosxr`).**  Parses + renders `show running-config` for the
  ASR 9000 / NCS 5500 / 540 / 8000 / CRS service-provider routers:
  hostname, `domain name`, interfaces (4-segment physical
  `GigabitEthernet0/0/0/0` + the TenGigE/HundredGigE/… speed variants,
  `Loopback`, `MgmtEth`, `Bundle-Ether`, sub-interfaces — with
  `ipv4 address X Y` dotted-mask, `ipv6 address X/N` CIDR, description,
  `shutdown`, `mtu`), and default-VRF `router static` routes
  (`<CIDR> <interface> <next-hop>`, incl. `Null0` blackhole + gateway-
  only forms).  4-segment XR port names map onto the cross-vendor
  `PortIdentity` as rack/slot/instance with the 4th (per-PIC port)
  segment preserved via `meta["iosxr_port_index"]` for same-vendor
  round-trip — it drops to `0` on a 3-segment cross-vendor rename
  (declared lossy); `Bundle-Ether<N>` ↔ `Port-channel<N>` and `MgmtEth`
  cascade through the existing port-name bridge.  The probe keys off the
  unambiguous `!! IOS XR Configuration` banner (98) with an XR-marker
  fallback (4-segment ports + `ipv4 address` + `MgmtEth` + `route-policy`)
  that does NOT steal IOS-XE / NX-OS captures.
  `detect_tier3_sections_iosxr` surfaces the dropped SP-routing /
  `route-policy` / `l2vpn` stanzas on the migrate banner.  **Shipped
  `bidirectional` / `experimental`** — not the dossier's transient
  `parse_only`, because the repo forbids a `parse_only` + `cli-*` codec
  (`TestNoOrphanedParseOnlyCliCodec`); same call the NX-OS codec made.
  Capability matrix: **11 supported / 2 lossy / 22 unsupported** — VRF
  stanzas + RD-from-`router bgp` (Phase 2), Bundle-Ether membership /
  LAGs (Phase 2), local users (Phase 2), and the SP-routing /
  `route-policy` Tier-3 harvest (Phase 3) remain unsupported; the
  certified real-capture corpus lands in Phase 4.  Tenth migration
  codec.  Cross-mesh + phase-4 regenerated; high-severity CODEC_BUG held
  flat at 5.  (Design dossier: `docs/v0.2.0-planning/04-iosxr-codec/`.)
* **Cisco NX-OS codec — Phase 4 (VXLAN-EVPN + L3VNI + `vtep` PortKind),
  completing the codec.**  Parses the EVPN overlay: `vlan N / vn-segment
  <vni>` → `CanonicalVxlan` (L2 VLAN↔VNI binding), `interface nve1 /
  source-interface <X>` (the switch-level VTEP source, broadcast onto
  every record), and `vrf context X / vni <N>` →
  `CanonicalRoutingInstance.l3_vni` (symmetric-IRB L3VNI).  The `nve1`
  stanza is intercepted as a config container — not materialised as an
  interface (mirrors arista_eos's `Vxlan1`); its `member vni` /
  `suppress-arp` / `ingress-replication` sub-flags are parse-discard (v1
  assumes modern BGP-EVPN head-end replication).  Render emits the
  `vn-segment` lines, the `interface nve1` VTEP (`member vni <vni>` for
  L2 + `member vni <l3vni> associate-vrf` for L3), `vrf context X / vni`,
  and derives `feature nv overlay` + `feature vn-segment-vlan-based`.
  Adds `"vtep"` to the canonical `PortKind` literal (nve1 → vtep; other
  codecs return `None` → the verbatim+warning fall-through; nxos formats
  vtep → `nve1`).  Capability matrix now **44 supported / 11 lossy / 11
  unsupported** — newly supported `/vxlan-vnis/{vni,source-interface,
  udp-port,mcast-group,flood-list}` + `/routing-instances/instance/l3-vni`;
  new lossy `/vxlan-vnis/vni` (nve per-VNI sub-flags) +
  `/evpn-type5-routes/route` (modelled via the l3_vni VRF binding, not
  per-prefix records).  Only the T2 anycast-gateway surface remains
  unsupported.  `certainty` stays `best_effort` (reaches `certified`
  once a real-capture corpus across two OS versions lands).  Cross-mesh +
  phase-4 regenerated; high-severity CODEC_BUG held flat at 5.
* **Cisco NX-OS codec — Phase 3 (VRF RD / route-target + per-VRF static
  routes).**  Graduates the L3-isolation surface inside `vrf context X`:
  `rd <rd>` (the NX-OS `rd auto` derivation preserved verbatim as a
  sentinel), `address-family ipv4 unicast / route-target
  import|export|both <rt> [evpn]` (the `both` shorthand expands to
  import + export; the trailing `evpn` address-family discriminator is
  dropped on render), and per-VRF static routes nested inside the block
  (`  ip route X/N GW [pref]` → `CanonicalStaticRoute.vrf`).  The
  per-VRF route harvest never materialises a phantom routing-instance —
  the `vrf context` header already created it (see the per-VRF harvest
  pattern from PRs #23/#24).  Render nests RD / route-target / per-VRF
  routes back inside the `vrf context` block (collapsing matched
  import+export RTs to the compact `route-target both <rt>` form) and
  defensively synthesises a `vrf context` wrapper for any per-VRF route
  whose VRF lacks a routing-instance record (e.g. a cisco_iosxe_cli
  source's `ip route vrf X`).  Capability matrix now 38 supported /
  9 lossy / 16 unsupported — newly supported `…/rt-exports` +
  `/routing/static-route/vrf`; new lossy
  `/routing-instances/instance/route-distinguisher` (`auto` sentinel) +
  `/routing-instances/instance/rt-imports` (`evpn` suffix).  `certainty`
  stays `best_effort`.  Cross-mesh + phase-4 regenerated: the Cisco→Cisco
  `batfish_cisco_ip_route` capture now translates at full fidelity
  (WARN 20/21 → OK 21/21) and junos→nxos mechanical drift dropped;
  high-severity CODEC_BUG held flat at 5.

## [0.1.4] - 2026-06-13

### Added

* **Cisco NX-OS codec — Phase 2c (HSRP), completing Phase 2.**  Parses the
  nested NX-OS HSRP block (`interface VlanN / hsrp N / ip <vip> /
  priority / preempt / authentication md5|text`) into
  `CanonicalVRRPGroup(mode="hsrp")` (indent-aware nested-block parse;
  NX-OS preempt-disabled-by-default honoured).  Render emits every FHRP
  group as an `hsrp` block, so a cross-vendor VRRP / CARP group
  normalises to HSRP on NX-OS — the virtual-IP redundancy intent
  survives but the wire protocol changes, so
  `/interfaces/interface/vrrp-groups/group` is declared **lossy**.
  `feature hsrp` is render-derived.  **With Phase 2 complete, the codec
  graduates `certainty` `experimental` → `best_effort`** (matrix now
  34 supported / 7 lossy / 20 unsupported).  Cross-mesh + phase-4
  regenerated; high-severity CODEC_BUG held flat at 5.
* **Cisco NX-OS codec — Phase 2b (SNMP + local users).**  Graduates the
  management surfaces: SNMPv2c `community` + `location` / `contact` /
  trap-`host`, and SNMPv3 USM users (`snmp-server user <name> <group>
  auth <proto> <key> [priv <proto> <key>] localizedkey [engineID …]` —
  privacy cipher normalised `aes-128` ↔ `aes128`, opaque `0x` keys +
  colon-decimal engineID preserved verbatim), plus local users
  (`username <name> password <type> <hash> role <role>`; the NX-OS
  named `role` → numeric privilege is lossy: network-admin / vdc-admin →
  15, everything else → 1, role preserved verbatim same-vendor).
  Capability matrix now 34 supported / 6 lossy / 20 unsupported (new
  lossy: `/local-users/user/privilege-level`,
  `/snmp/v3-user/auth-passphrase` [10.x `localizedV2key` normalised to
  `localizedkey`], `/snmp/v3-user/engine-id` [colon-decimal vs hex]).
  `certainty` stays `experimental` until HSRP (Phase 2c) completes
  Phase 2.  Cross-mesh + phase-4 regenerated; CODEC_BUG held flat at 5.
* **Cisco NX-OS codec — Phase 2a (L2 switchport + LAG).**  Graduates the
  Layer-2 surface from `unsupported` to `supported`: switchport mode
  (access / trunk), access-vlan, trunk allowed + native VLANs, the NX-OS
  L2-default semantics (a routed physical/LAG port renders an explicit
  `no switchport`; SVIs / loopbacks / mgmt0 stay inherently L3), the
  VLAN-centric port projection (tagged / untagged), and LAG membership
  (`interface port-channelN` + per-member `channel-group N mode
  active|passive|on`, with `on` → canonical `static`).  `feature lacp`
  is render-derived on LAG presence.  Mirrors `arista_eos`'s
  render-decides L2-default model rather than materialising a phantom
  access mode on bare ports.  Capability matrix now 26 supported /
  3 lossy / 23 unsupported; `certainty` stays `experimental` (SNMP +
  local-users land in Phase 2b, HSRP in Phase 2c).  Cross-mesh + phase-4
  regenerated; high-severity CODEC_BUG held flat at 5.
* **New codec: Cisco NX-OS (`cisco_nxos`) — Phase 1 of 4.**  Bidirectional
  `show running-config` translator for the Nexus 3000 / 5000 / 7000 /
  9000 data-center series.  NX-OS is a distinct CLI grammar from IOS-XE
  (same vendor, different parser): CIDR-only addressing (`ip address
  X/N`, never dotted mask), `vrf context` / `vrf member` keywords,
  render-derived `feature` declarations, and uniform `Ethernet<slot>/
  <port>` naming with no speed prefix.  Phase 1 ships the scaffold +
  L1/L3 basics: hostname; interfaces (description / admin-state / MTU /
  IPv4 + IPv6 CIDR / `vrf member` / mgmt-port classification); VLANs
  (comma+range id-list, names, SVI synthesis); `vrf context` (name +
  description); and default-VRF static routes (`ip route X/N GW
  [pref]`).  Switchport / LAG / SNMP / local-users / per-VRF static /
  VRF RD-RT / VXLAN-EVPN are declared `unsupported` in the capability
  matrix (15 supported / 3 lossy / 31 unsupported) and land in
  Phases 2-4; `certainty="experimental"` until the L2/L3 surface and a
  real-capture corpus arrive.  The codec auto-enrolls into the
  cross-mesh + phase-4 reconciliation (486 cells; high-severity
  CODEC_BUG held flat at 5 — every cross-vendor cell into/out of NX-OS
  classifies `EXPECTED_LOSSY` / `EXPECTED_UNSUPPORTED` / `ALIGNED`,
  none `CODEC_BUG`).  Companion wiring: `cli-nxos` added to
  `INPUT_FORMATS`, `detect_tier3_sections_nxos` (BGP / OSPF / ACL /
  route-map / QoS notification surface), a `cisco_nxos` vendor YAML,
  and a synthetic kitchen-sink fixture under the generic round-trip
  drift-guards.  Design dossier:
  [`docs/v0.2.0-planning/03-nxos-codec/`](docs/v0.2.0-planning/03-nxos-codec/).
* **Per-VRF static routes on `cisco_iosxe_cli`** (v0.2.0 — graduates a
  ship-before-wire surface).  `ip route vrf <NAME> <dest> <mask> <gw>`
  now round-trips: the parser populates `CanonicalStaticRoute.vrf`
  (previously the `vrf` qualifier failed the static-route regex and
  was silently dropped) and the renderer re-emits the qualifier.  This
  flips `/routing/static-route/vrf` from `unsupported` to `supported`
  on the codec — the two-sided ship-before-wire guard
  (`test_canonical_vrrp_anycast_schema.py::_WIRED_UP_BY_CODEC`) tracks
  the graduation.  Trailing route-leak / administrative-distance /
  `name` tokens (e.g. the `global` next-hop-leak keyword) are not
  modelled and parse-and-ignore, same as the global-table form.
  Cross-vendor migration toward a target with no per-VRF static-route
  grammar (Arista / FortiGate / MikroTik / OPNsense / Aruba — each
  declares the path `unsupported`) still drops the VRF binding; the
  phase-4 reconciler classifies that drop as `EXPECTED_UNSUPPORTED`,
  not a false `CODEC_BUG`.  Cross-mesh + phase-4 artifacts regenerated;
  high-severity CODEC_BUG count unchanged at 5 (documented residuals).

* **Per-VRF static routes on `juniper_junos`** (v0.2.0 — completes the
  per-VRF static-route surface for the two vendors with native
  grammar).  `set routing-instances <NAME> routing-options static
  route <dest> next-hop <gw>` now harvests onto
  `CanonicalStaticRoute.vrf` and renders back out, graduating
  `/routing/static-route/vrf` from `lossy` to `supported` (the
  routing-instances dispatcher now descends into `routing-options
  static`).  next-hop form only — `discard` / `reject` blackhole
  routes and the explicit `rib <name>` form (e.g. IPv6 `inet6.0`) are
  not modelled, same as the global table.  A VRF implied solely by a
  static route does not materialise an empty routing-instance, so a
  Cisco `ip route vrf X` source (which declares no VRF) round-trips
  through Junos without conjuring one.  With both codecs wired,
  `cisco_iosxe_cli` ↔ `juniper_junos` per-VRF static routes now
  round-trip in both directions.  Cross-mesh + phase-4 artifacts
  regenerated; high-severity CODEC_BUG count unchanged at 5.

The remainder of [Unreleased] below is P2 + documentation remediation
from the 2026-06-06 review (Batches 2–12), sequenced in that dossier's
`recommended-remediation-plan.md`.  Those batches made no
canonical-model or codec-grammar changes.

### Security

* **Sanitiser now redacts the PII / network tail it previously left
  intact** (finding R-16 / CF-04).  `netcanon sanitize` redacted
  secrets and interface L3 addresses but skipped five identifying
  fields that parsers populate and renderers emit: SNMP `contact` /
  `location` (operator email / name / site), the SNMP trap-target +
  RADIUS-server + DHCP-pool-gateway hosts (public IPv4), and — the
  material leak — VLAN-SVI addresses on `CanonicalVlan.ipv4_addresses`,
  a field separate from `interfaces[].ipv4_addresses` that the
  interface walk never reached.  On Aruba AOS-S the SVI is a property
  of the VLAN and renders straight off that un-walked list, so a public
  SVI IP survived sanitisation verbatim.  Contact / location now map to
  opaque placeholders; the host / SVI fields reuse the existing
  public-IPv4 → RFC 5737 docs-range policy (private / loopback /
  hostname forms preserved, cross-references stable).  These are
  PII/network rather than secrets, so they are deliberately kept out of
  the secret-coverage guard's registry; a new forward test pins that
  they no longer survive.  Host fields written as DNS names remain a
  documented passthrough (`SECURITY.md` / `BUG_REPORTING.md`).

### Fixed

* **Junos render now raises `RenderError` (not `TypeError`) on
  non-`CanonicalIntent` input** (finding R-03 / CC-01 / CF-03 / DD-01).
  `juniper_junos.render_intent` was the only codec raising `TypeError`
  from its input-type guard; the pipeline catches `RenderError`, so the
  mismatch mis-bucketed the failure as an "unexpected error in stage
  rendering" rather than a clean render error.  Now matches the
  `CodecBase.render` contract and the other seven codecs.

* **`POST /api/v1/sanitize` no longer blocks the event loop** (finding
  R-04 / CA-01).  The async handler ran the synchronous, CPU-bound
  parse → redact → render pipeline directly on the event loop; it now
  offloads via `asyncio.to_thread` (matching the scheduler in
  `routes/schedules.py`), so a large sanitise no longer stalls
  concurrent requests.

* **`classify_port_name` now populates `PortIdentity.original` on all
  paths** for `arista_eos` and `juniper_junos` (finding R-17 / CC-03).
  The field is documented "always populated by the source classifier"
  (the cross-vendor port-name bridge echoes it verbatim when a target
  can't represent an identity), but the two codecs returned it empty on
  every path — a latent contract violation.  Added a guard test pinning
  the contract.  Separately, converted an unused `(?P<secondary>…)`
  capture in `cisco_iosxe_cli`'s VRRP regex to a non-capturing group
  (the code-smell half of R-13 / CC-02; the cross-vendor `is_secondary`
  fidelity fix is tracked separately as it needs multi-site parser
  changes + round-trip tests).

* **Classic `ip address X secondary` now survives cross-vendor
  translation** (finding R-13 / CC-02 — the fidelity half, review
  Batch 7).  `cisco_iosxe_cli` parse now captures `is_secondary` (its
  render stays positional, so self-round-trips are byte-stable);
  `arista_eos` parse reads the plain-`ip address` `secondary` trailer
  instead of dropping it; and `arista_eos`'s plain render branch emits
  ` secondary` for flagged addresses.  A secondary address is no longer
  silently demoted to primary on `cisco_iosxe_cli → arista_eos` (or
  `arista_eos → arista_eos`).  Additive on the common IP-address path;
  +6 round-trip tests.

* **FortiGate per-interface MTU declared `supported` in the capability
  matrix** (finding R-06).  The codec has parsed and rendered `set mtu N`
  (gated by `set mtu-override enable`) since the MTU wire-through, but
  `_CAPS` never declared `/interfaces/interface/config/mtu` — a
  matrix-silent gap where the declaration had drifted from behaviour.
  Added it to `supported` (matrix-inert: the validation walker doesn't
  emit the xpath, so zero cell changes) with a guard test pinning the
  declaration.  Corrected two docs that contradicted the code:
  `docs/vendors/fortigate.md` claimed MTU was "not emitted when FortiGate
  is the target" (false — render emits it), and `CAPABILITIES.md`'s
  Tier-1 "every codec parses and renders" blanket now points to the
  honest per-codec tables and names the known exceptions (FortiGate
  `tunnel_type` lossy, Aruba `mtu` unsupported).

* **NETCONF (`cisco_iosxe`) port-rename now shows one UI banner instead
  of N per-port warning rows** (finding R-21).  The Phase-0.5 NETCONF
  stub inherits no-op `classify_port_name` / `format_port_identity`, so
  migrating *to* it produced a "no native representation" warning per
  interface that flooded the migrate UI's ports pane.  Declared `"ports"`
  in the codec's `unsupported_rename_categories` (joining `"snmpv3"`),
  which drives the existing single amber pane-compat banner — the same
  declarative mechanism the codec already uses for SNMPv3.  Explicit
  operator rename maps still apply unchanged.  (Collapsing the warnings
  at the pipeline/API level too was evaluated and deferred — it
  entangles with explicit-rename precedence + `strip_unmappable`
  semantics, beyond this P3's scope.)  +new guard test.

### Internal

* **Frozen-signature guard test for the migration pipeline** (finding
  R-11 / CD-03).  `run_plan` / `run_plan_with_rename` /
  `run_plan_with_overrides` are called positionally by the UI, tests,
  and README examples; a new test pins their parameter names, order,
  kind, and defaults so an accidental signature change fails CI instead
  of silently breaking positional callers.

* **Backup orchestration extracted to `services/backup_runner.py`**
  (finding R-18 / CA-02).  `_process_one_device` + the renamed public
  `run_backup_job` (was `_run_backup_job`) moved out of the
  `api/routes/backups.py` route module into a service sibling of
  `services/migration_pipeline.py`, so the scheduler no longer reaches
  *into* a route module to borrow the backup engine.  Behaviour-
  preserving move — the documented SSH mock-point
  `netcanon.api.routes.backups.get_collector` is unchanged: the service
  resolves the factory back through the route module at call time, so
  the ~60 tests that patch that string keep working with zero edits.

* **`/docs` Swagger-UI reskin split into `api/routes/docs.py`** (finding
  R-12 / CE-01).  The ~440-line dark-mode Swagger wrapper (six `_DOCS_*`
  CSS/JS constants + the `swagger_ui()` handler) moved out of
  `api/routes/ui.py` (which was ~48% this one concern) into its own
  `include_in_schema=False` router, registered at root in `main.py` so
  the public `/docs` URL is byte-for-byte unchanged.  `ui.py` keeps the
  eight thin HTML page handlers; the location-agnostic 24-method
  `test_swagger_docs_page.py` contract test stays green, plus a new
  guard pins that the constants actually moved.

* **Refreshed the cross-mesh + Phase-4 reconciliation artifacts to the
  current 45-fixture corpus** (finding R-08).  `CROSS_MESH_RESULTS.md`
  and `PHASE4_RECONCILIATION.md` were pinned to a stale 39-fixture
  snapshot (2026-05-05); regenerated both (424 cells) via
  `tools/run_full_mesh.py --matrix` + `tools/run_phase4_reconciliation.py`.
  Also corrected a factually-wrong `lossy` reason in
  `fortigate_cli__cisco_iosxe.yaml` that blamed a nonexistent FortiGate
  MTU parse gap — the real cause is the `cisco_iosxe` NETCONF target stub
  not emitting `<config><mtu>` (the old reason contradicted the code, the
  green MTU test, and sibling YAMLs).  The larger corpus surfaces 13
  `CODEC_BUG` cells — triaged in the reconciler-honesty entry below
  (zero real defects; 8 were the un-wired anycast surface).

* **Phase-4 reconciler is now capability-aware for the anycast surface**
  (follow-up to R-08's corpus refresh).  Triage of those 13 `CODEC_BUG`
  cells found **zero real codec defects**: 8 were the v0.2.0 Wave-C anycast
  surface — arista VARP / junos anycast-gateway SVIs translated to targets
  (`cisco_iosxe` NETCONF, `opnsense`) that declare `virtual-gateway-address`
  unsupported — false-flagged only because a per-pair expectation YAML's
  field-level `good` can't express "supported EXCEPT the anycast
  sub-attribute".  `tools/run_phase4_reconciliation.py` now consults the
  **target codec's capability matrix** (the source of truth) and
  reclassifies anycast-companion-only drift to an anycast-unsupported target
  as `EXPECTED_UNSUPPORTED`, per address family (so `cisco_iosxe_cli`'s
  *supported* IPv4 SD-Access anycast still flags genuine drift).  Drops the
  high-severity count 13 → 5; the 5 residuals are documented benign
  structural artifacts in
  `tests/fixtures/real/phase4_findings_residuals.md`.  +5 guard tests;
  reconciliation artifacts regenerated.  Dev-tooling only — no
  shipped-package behaviour change.

* **Committed `_phase4_runs/latest.json` is now slim** (follow-up to the
  reconciler-honesty entry above).  The committed reconciliation mirror
  was a tracked ~166k-line dump of all 424 cells (incl. the ok-severity
  `ALIGNED` / `EXPECTED_*` / `TRIVIAL_EMPTY` bulk) that churned wholesale
  on every fixture-corpus regen.  `write_json` (`_slim_for_commit`) now
  commits only the high-severity `CODEC_BUG` ("investigate first") cells
  plus the full top-level aggregates / matrix / pair-counts — ~87 %
  smaller (5 cells / ~21k lines today) and stable across regens — while
  the full per-cell archive remains the gitignored timestamped JSON
  beside it.  Self-documents its filter + elided counts under
  `latest_json_slimmed`; the `phase4_findings_*.md` doc links + Phase 4b
  investigation flow are unchanged (`CODEC_BUG` cells are exactly what
  they read).  +1 guard test.  Dev tooling only — no shipped-package
  behaviour change.

### Documentation

* **Aruba AOS-S codec header corrected to `certified`** (finding R-05 /
  DE-01).  `aruba_aoss/__init__.py` still claimed
  `Certainty: best_effort` while `codec.py` declared `certified` — the
  2026-05-21 audit fixed the identical MikroTik drift but missed Aruba.
  Added a guard test asserting every codec's header `Certainty:` line
  matches its `certainty` ClassVar, so the contradiction can't recur.

* **Fixed stale / contradictory doc references** surfaced by the review:
  `RESULTS.md` "10 bugs across five codecs" → "17 across seven" to match
  its own summary table (R-07); `ARCHITECTURE.md`'s per-pane override
  section now lists all five rename categories incl. SNMPv3 /
  `/plan/snmpv3` (was four — R-22); `AGENTS.md` swapped a drifted
  `SECURITY.md (line 385)` reference for a durable section anchor (R-10);
  and removed a dangling `@pytest.mark.slow` snippet from the NX-OS codec
  test-plan (the `slow` marker was removed in v0.1.2 — R-28).

* **Docstrings + `ARCHITECTURE.md` brought in line with the shipped
  architecture** (findings R-09, R-14, R-15).  `ARCHITECTURE.md` now
  documents the `netcanon/security/` credential-encryption package
  (Fernet + 3-tier key resolution) and the `_tier3_detection.py`
  silent-drop-honesty policy — closing the gap `AGENTS.md` already
  claimed was closed.  The VRRP / anycast docstrings in
  `canonical/intent.py` now read "wired in v0.2.0 (Wave B/C)" instead of
  the stale "ship-before-wire / every codec lists this unsupported".
  And the "Phase 0 / libyang-in-Phase-0.5 / opaque `dict[str,str]`"
  framing in `migration/__init__.py`, `canonical/__init__.py`, and
  `models/migration.py` is replaced with present-tense reality (the
  Pydantic canonical IR shipped; `canonical/loader.py` remains a
  deliberately-unused libyang stub).

* **Documentation / interlinking sweep** (review Batch 6 — findings
  R-19, R-20, R-23, R-24, R-25, R-26, R-27, actuated from a read-only
  agent sweep under `docs/project-review/2026-06-06/remediation-sweep/`).
  Normalised all 8 codec `__init__.py` headers to one template
  (consistent digit ordinal + standalone `Direction:` / `Certainty:`
  lines — the certainty guard test now covers all 8, zero skips);
  recorded why the codec-contract types (`CapabilityMatrix` et al.) live
  in `models/` rather than `codecs/` (R-19, an HTTP `response_model`);
  documented the best-effort/lossy `file_store` hostname-display decode
  (R-20, display-only); repointed the `AGENTS.md` contents-map exemplar
  to `definitions.html` (R-23); added reciprocal + inbound links so no
  hand-authored doc is orphaned (R-25 / R-27) and converted the NX-OS
  planning index to real Markdown links (R-26).

## [0.1.3] - 2026-06-06

Remediation of the two P1 findings from the read-only full-project
review at [`docs/project-review/2026-06-06/`](docs/project-review/2026-06-06/)
(two-fleet review + adversarial verification).  No canonical-model or
codec-grammar changes; translation behaviour and the capability matrix
are unchanged.  The remaining P2/P3 findings are sequenced in that
dossier's `recommended-remediation-plan.md`.

### Security

* **Sanitiser now redacts VRRP / CARP / HSRP authentication secrets**
  (finding R-01 / CF-01).  `netcanon sanitize` walked only interface
  `description` + `ipv4_addresses` and never visited
  `vrrp_groups[].authentication` — a cleartext-bearing field (`plain:` /
  `carp-key:` schemes hold the literal secret; the Aruba / OPNsense /
  cisco_iosxe_cli renderers emit it verbatim).  So a config containing a
  VRRP/CARP auth key, run through the tool operators are told to use
  before posting a public bug report, leaked that secret — and
  `--dry-run` showed no substitution, giving false assurance.  The
  value is now redacted while the `<scheme>:` prefix is preserved (the
  renderers slice a scheme-width prefix, so the prefix must survive for
  the rendered output to stay valid).  Added a two-sided structural
  guard test asserting every secret-bearing canonical field has a
  redaction rule.  `SECURITY.md` + `BUG_REPORTING.md` redaction tables
  updated.

### Fixed

* **`netcanon demo` now ships in the wheel + Docker image** (finding
  R-02 / CF-02 / DA-01).  The README's "See it in 10 seconds" command
  invoked `tools/demo.py`, but the repo-root `tools/` is excluded from
  both the wheel (`packages.find` matches `netcanon*` only) and the
  image (the Dockerfile copies only `netcanon/`), and the image
  entrypoint is the server — so `docker run … python tools/demo.py`
  could never have worked against a published artifact.  The demo
  scenarios moved into the package at `netcanon/tools/demo.py` behind a
  new `netcanon demo` subcommand that ships automatically; the repo-root
  `tools/demo.py` is retained as a thin re-export shim so
  `python tools/demo.py` keeps working from a source checkout.  README
  hero corrected to `docker run --rm --entrypoint netcanon …
  demo --pair cisco__junos` (and `netcanon demo …` for pip installs).

## [0.1.2] - 2026-05-21

Security-hardening release.  No canonical-model or codec-grammar
changes; all changes are defense-in-depth for the supply chain
(CI / workflows / image build) and for operator-uploaded XML parsing.
Translation behaviour, capability matrix, and fixture corpus are
identical to `v0.1.1` — operators in the middle of a migration can
upgrade safely without re-validating any prior translations.

### Security fixes

* **XML entity-bomb mitigation** on operator-uploaded XML parse paths
  (`4e37a70`).  `xml.etree.ElementTree.fromstring` expands internal
  entities by default on Python 3.x — verified empirically on
  3.14.4 — so a billion-laughs payload could hang the FastAPI
  worker.  Swapped to `defusedxml.ElementTree.fromstring` at the two
  operator-input parse sites (`opnsense/parse.py:169` +
  `cisco_iosxe/codec.py:543`) with an explicit `DefusedXmlException`
  clause wrapping the rejection into the codec's `ParseError`.
  XML *generation* paths (`ET.Element`, `ET.SubElement`, `tostring`,
  `register_namespace`) stay on stdlib — they don't consume
  untrusted input.  Adds `defusedxml>=0.7.1` to dependencies.
  Closes CodeQL `py/xml-bomb` alerts.
* **Template-injection hardening** in the MSI-publish workflow
  (`dbee8f8`).  `desktop-msi-publish.yml:84` interpolated
  `${{ inputs.tag || github.ref_name }}` directly into a shell
  `run:` block — a workflow_dispatch caller supplying a tag-name
  with shell metacharacters could execute arbitrary code with
  access to `DOCKERHUB_TOKEN`, signing keys, and the build env.
  Refactored to env-var indirection (`env: TAG_RAW: ${{ ... }}`)
  so the value enters shell context as a literal.  Closes zizmor
  `template-injection` alerts.

### Dependency-side security bumps

* **Python base image `3.14-slim-bookworm` → `3.14.5-slim-bookworm`**
  (`538f9e1`, Dependabot PR #6).  Clears 6 HIGH/CRITICAL CVEs in
  `libgnutls30` flagged by local Trivy scan against the `v0.1.1`
  image; the patched `libgnutls30 3.7.9-2+deb12u7` lands in the
  3.14.5 base layer.  No code change required.
* **`aquasecurity/trivy-action 0.24.0` → `v0.36.0`** (`1f68713`).
  Clears Dependabot critical alert CVE-2026-33634 ("Trivy ecosystem
  supply chain was briefly compromised"); vulnerable range was
  `< 0.35.0`.  Also fixed the original `@0.24.0` reference which
  failed to resolve at runtime (no such tag — the trivy-action repo
  publishes only `v`-prefixed tags).
* **`github/codeql-action/upload-sarif v3` → `v4.35.4`** + **`sigstore/cosign-installer v3` → `v4.1.2`** (`d193573`, Dependabot PR #7, actions-all group bump).  Major-version bumps; no breaking-change impact on our usage.

### CI / workflow hardening

* **`ci.yml` workflow-level `permissions: contents: read`**
  (`69f7259`).  All 3 ci.yml jobs (test, build-distribution,
  docker-build-smoke) are read-only; default-deny `GITHUB_TOKEN`
  scope at workflow scope.  Closes CodeQL
  `actions/missing-workflow-permissions` (×3) + zizmor
  `excessive-permissions` (×4) — single edit clears 7 alerts.
* **`persist-credentials: false` on all 6 `actions/checkout@v6`
  calls** (`619a353`).  No netcanon workflow performs a git push,
  fetch, tag, or config write that requires the persisted credential
  helper; registry logins use explicit secrets, OIDC, or
  action-internal auth.  Closes zizmor `artipacked` ×6.
* **SHA-pinned 11 third-party action references** (`5882fbe`):
  `softprops/action-gh-release`, `docker/setup-buildx-action`,
  `docker/login-action` (×2), `docker/metadata-action`,
  `docker/build-push-action`, `aquasecurity/trivy-action`,
  `sigstore/cosign-installer`, `anchore/sbom-action`,
  `pypa/gh-action-pypi-publish`, `zizmorcore/zizmor-action`.
  Convention: SHA pin with trailing tag comment
  (`@<sha>  # <tag>`) so Dependabot can still propose bumps.
  `actions/*` and `github/*` references retain tag-pins per the
  hybrid policy (GitHub-controlled repos with force-push
  protection).
* **New `.github/zizmor.yml` site-config** (`5882fbe`).  Implements
  the hybrid action-pinning policy via the `unpinned-uses` rule:
  `ref-pin` allowed for `actions/*` + `github/*`; `hash-pin`
  required for everything else.  Prevents the rule from re-firing
  noisily on every `actions/checkout` call.
* **Dropped `cache: "pip"` from publish workflows** (`7c231c4`).
  Single-shot publish jobs run once per release with a cold dep
  tree; cache buys nothing and expands the cache-poisoning attack
  surface.  CI's pip caches remain enabled (multi-shot benefit).
  Closes zizmor `cache-poisoning` ×2.
* **Dependabot cooldown blocks** (`eb3a046` + `6f36ff8`).  Added
  per-ecosystem `cooldown: { default-days: 7 }` to all 3 ecosystems
  (pip / github-actions / docker).  Github-actions was initially
  set to 3 per Stage 1C's brief but zizmor's rule has an internal
  minimum of 7; bumped to 7 for uniformity.  Closes zizmor
  `dependabot-cooldown` ×3.
* **`zizmor.yml on.push.paths` covers `dependabot.yml` + `zizmor.yml`**
  (`ef1b7d3`).  Original `paths:` filter only matched
  `.github/workflows/**`, so a cooldown-only commit on main did
  not re-trigger zizmor.  Future config-file edits now re-scan.

### New scanner enablement (no operator-visible behaviour change)

The following scanners were enabled during this cycle.  None gate
releases; all are informational; all surface in **Security → Code
scanning** for unified triage:

* **Private vulnerability reporting** — researchers can report
  privately via GitHub's UI.
* **Secret scanning + push protection** — scans repo history +
  blocks credential pushes at commit time.
* **CodeQL default setup** — Python + GitHub Actions +
  JavaScript/TypeScript surfaces.
* **zizmor workflow security scanning** — uploads SARIF to Code
  Scanning on every workflow-file or dependabot.yml change.
* **Trivy Docker image scanning** — wired into `docker-publish.yml`;
  fires on next `v*.*.*` tag push (i.e. this release).  Will scan
  the `0.1.2` image and surface OS-package + Python-package CVEs.

### New process scaffolding

* **`docs/security-triage/`** (`d5d1099` + `051db83`) — durable
  evidence-trail folder for triaging Code Scanning / Dependabot /
  secret-scanning alert waves.  Process: snapshot → cluster →
  parallel read-only Stage 1 agents → orchestrator synthesis →
  dismissals via `gh api` with written reasons → Stage 2 fixes.
  The 2026-05-21 worked example covers the initial scanner-
  enablement wave (79 alerts → 33 REAL fixed + 46 dismissed with
  reasons).  Bidirectional cross-references from `AGENTS.md`,
  `SECURITY.md`, `BUG_REPORTING.md`.
* **`AGENTS.md` Documentation Sync Checklist row** for "alert wave
  arrives" — defines trigger conditions + process pointer.

### What didn't change

* Canonical model — no changes to `intent.py`.
* Codec grammar — no parse/render logic changes.
* Capability matrix — no `supported` / `lossy` / `unsupported`
  transitions on any codec.
* Real-capture fixture corpus — no additions / removals as part
  of the v0.1.2 security-cycle commits proper.  Note: 2 CARP HA
  fixtures (master + backup) landed in commit `4686198` between
  the v0.1.1 tag and the v0.1.2 cycle; they predate this release
  block but are reflected in v0.1.2's RESULTS.md totals.
* Public HTTP API surface — unchanged.
* CLI surface — unchanged.

### v0.1.1 honest-gaps update

Of the gaps documented in v0.1.1's CHANGELOG:

* **No UI verification** — STILL OPEN.  Claude in Chrome / Preview
  tools now available in-session; deferred to a separate cycle.
* **No cross-vendor VRRP integration tests** — STILL OPEN.
* **IPv6 anycast on IOS-XE SD-Access unsupported** — STILL OPEN
  (no fixture available).
* **Cisco IOS-XE NETCONF stub anycast unsupported** — STILL OPEN
  (Phase-0.5 stub policy unchanged).
* **Junos per-VRF static routes lossy** — STILL OPEN.
* **Modern Arista/IOS-XE multi-line VRRP form lossy** — STILL OPEN.

## [0.1.1] - 2026-05-19

First post-rc release.  Skips a `v0.1.0` final tag because the
previous rc cycle (`v0.1.0-rc1` through `v0.1.0-rc9`) already
shipped to PyPI / GHCR / Docker Hub / GitHub Releases under the
`0.1.0rc*` version namespace; tagging `v0.1.0` final on top would
have asymmetric semantics across the channels.  `v0.1.1` is
strictly semver-cleaner.

What's in this release on top of `v0.1.0-rc9`:

* **Junos channelized sub-interface render fix** + `QFX10K2-174`
  real-capture fixture (`f52489c`).
* **Cisco IOS-XE `username password 0 X` round-trip bugfix**
  (`b85c39c`).
* **v0.2.0 Wave A schema** — `CanonicalVRRPGroup` + new fields on
  address / static-route / intent records (`c5da044`).  Pure
  schema, ship-before-wire: every codec declares the new paths
  `unsupported` until per-codec wire-up.
* **v0.2.0 Wave B + C wire-up across 7 codecs** — VRRP / HSRP /
  CARP groups on all bidirectional codecs; anycast-gateway on
  3 codecs (Junos / Arista EOS / Cisco IOS-XE SD-Access)
  (`e542b49`).
* **Documentation pass** — `docs/CAPABILITIES.md` rewrite, 7
  per-vendor pages (`docs/vendors/*.md`), planning-folder
  closures (`docs/v0.2.0-planning/{01,02}/IMPLEMENTED.md`),
  WANTED.md + RELEASE_PLAN.md updates (`4ce0cb9`).

Cumulative test growth vs `v0.1.0-rc9`: the schema + codec wave
landed ~180 new unit tests (+31 schema, +149 codec).  Absolute
counts vary by Python-version matrix slice — run
`py -m pytest tests/unit --collect-only -q | tail -1` against
the tag for the exact figure on your interpreter.

Honest known gaps in this release (documented for operators):

* Real-capture VRRP fixtures only exist for IOS-XE and Junos
  (`batfish_iosxe_basic_vrrp.txt`,
  `ksator_labmgmt_qfx10k2_junos173.set`).  Aruba / FortiGate /
  MikroTik / OPNsense VRRP coverage is synthetic-only.
* Cross-vendor migration matrix doesn't yet exercise the full
  new VRRP / anycast surface end-to-end.  Per-codec parse +
  render are tested; integration coverage follows in `v0.1.2`
  or `v0.2.0`.
* No UI verification this release — the `/migrate` page may
  need template updates to render
  `CanonicalInterface.vrrp_groups` data visually.
* IPv6 anycast on IOS-XE SD-Access is `unsupported` (no fixture
  coverage).
* Cisco IOS-XE NETCONF stub (`cisco_iosxe` codec) still declares
  every new path `unsupported`; matches its Phase-0.5 stub
  policy.
* Junos per-VRF static routes are `lossy` (parses, doesn't
  harvest from routing-instances dispatcher — separate scope).
* Modern Arista + IOS-XE multi-line VRRP address-family form is
  `lossy` (classic single-line form is supported).

Tier-D codecs (Cisco NX-OS, Cisco IOS-XR) remain queued for
`v0.3.0+` per `docs/v0.2.0-planning/{03,04}/README.md`.

### v0.2.0 Wave A + B + C: VRRP / HSRP / CARP + anycast-gateway across 7 codecs

The headline v0.2.0 enrichment.  Closes the universal-but-unmodelled
L3-redundancy gap surfaced during the fixture-research arc
(`f52489c..8adaefd`) by landing a canonical surface for classic FHRP
(VRRP / HSRP / CARP) plus anycast-gateway, then wiring 7 bidirectional
codecs against it.  Ships as a single chained release — schema first
(Wave A), then per-codec wire-up (Wave B + C) — across 3 commits
(`c5da044`, `b85c39c`, `e542b49`).  Design rationale at
[`docs/v0.2.0-planning/`](docs/v0.2.0-planning/); per-vendor doc
pages updated under [`docs/vendors/`](docs/vendors/); capability
matrix at [`docs/CAPABILITIES.md`](docs/CAPABILITIES.md).

#### Wave A — schema landing (commit `c5da044`)

Pure schema addition.  Every new canonical path is declared
`unsupported` in every codec's `CapabilityMatrix` at the moment Wave A
lands — the *ship-before-wire* invariant the project relies on, so
the migrate-page banner fires loudly for any operator who tries to
push a fixture that exercises one of the new fields before the
codec's Wave B / C wire-up has landed.

Schema additions in `netcanon/migration/canonical/intent.py`:

* **`CanonicalVRRPGroup`** — new class modelling the classic FHRP
  redundancy primitive.  Field set: `group_id` (1-255), `mode`
  (`"vrrp"` / `"hsrp"` / `"carp"` — free string, not enum, so future
  codecs can extend without schema churn), `virtual_ips`,
  `virtual_ipv6s`, `virtual_mac`, `priority` (1-254), `preempt`,
  `advertisement_interval`, `authentication` (opaque
  `"<scheme>:<value>"` token), `track_interfaces`, `description`.
* **`CanonicalInterface.vrrp_groups: list[CanonicalVRRPGroup]`** —
  attachment point on the interface record.
* **`CanonicalIPv4Address.virtual_gateway_address: str`** +
  **`.virtual_gateway_mac: str`** + **`.is_secondary: bool`** —
  anycast-gateway companion fields on the address record.  The
  `is_secondary` addition extends beyond strict anycast scope —
  also unblocks Cisco / Arista `secondary` trailers on classic
  `ip address X/Y secondary` and Junos multi-address-per-unit
  semantics.
* **`CanonicalIPv6Address`** — same three additions for IPv6.
* **`CanonicalIntent.anycast_gateway_mac: str`** — chassis-wide
  anycast MAC for vendors that declare it once at the system level
  (Arista `ip virtual-router mac-address`, NX-OS / IOS-XE-SD-Access
  `fabric forwarding anycast-gateway-mac`).  Stored in colon-hex
  canonical form; per-vendor renderers re-emit in their native
  format.
* **`CanonicalStaticRoute.vrf: str`** — per-VRF static-route
  discriminator.  T3 (NX-OS) research surfaced the field; adding it
  in Wave A also closes the latent IOS-XE per-VRF-static-route
  surface that had been declared lossy.

Five architectural calls landed alongside the schema (full design
record in [`docs/v0.2.0-planning/README.md`](docs/v0.2.0-planning/README.md)
§ "Cross-task synthesis"):

1. **HYBRID resolution** between T1 (VRRP) and T2 (anycast)
   planning agents.  T1 and T2 reached opposite recommendations on
   the same architectural question — should anycast share T1's
   `CanonicalVRRPGroup` (mode discriminator), or be an
   independent per-address field?  Adopted T2's domain-modelling
   integrity argument: classic FHRP **is** a router-group election
   (`group_id` / `priority` / `preempt` are domain primitives);
   anycast **is** an IP property (every leaf has it; no election).
   Forcing one merged discriminator would have synthesised fake
   `group_id` / `priority` / `preempt` fields with no source token
   and no target render — exactly the "kitchen-sink discriminator"
   anti-pattern.  Precedent: the canonical model already separates
   `CanonicalVxlan` from `CanonicalEvpnType5Route` even though both
   are EVPN-overlay primitives.
2. **`mode` values: `vrrp` / `hsrp` / `carp` only** — no
   `"anycast"` mode.  Falls directly out of the HYBRID resolution.
3. **`virtual_mac` per-group**, with codec-side cascade for vendors
   that declare the MAC once chassis-wide (Arista's `ip
   virtual-router mac-address` cascades into every group on parse;
   render hoists back).
4. **`is_secondary` on address records** beyond strict anycast
   scope — also unblocks EOS VARP secondary virtual IPs and EOS
   classic `ip address X/Y secondary` round-trip handling on the
   existing fixture corpus.
5. **`virtual-gateway-mac` per-IP is a vendor-specific surface**
   (Junos per-unit MAC override; Arista lossy declaration) and
   does NOT participate in the cross-codec ship-before-wire
   declaration — only codecs with the grammar declare the path.

Tests in `tests/unit/migration/test_canonical_vrrp_anycast_schema.py`
(**31 new tests**, 7 classes):

* `TestCanonicalVRRPGroupDefaults` — minimal + full construction,
  mode discriminator, free-string forward-compat.
* `TestCanonicalVRRPGroupValidation` — VRID range (1-255),
  priority range (1-254), boundary conditions.
* `TestVRRPGroupsOnInterface` — default empty list, multiple
  groups, `model_dump` round-trip.
* `TestIPv4AnycastFields` / `TestIPv6AnycastFields` — defaults,
  set-companion, secondary flag, link-local-with-anycast.
* `TestStaticRouteVrfField` — default global, per-VRF route.
* `TestAnycastGatewayMacOnIntent` — default empty, system MAC set.
* `TestShipBeforeWireUnsupportedDeclarations` — parametrised
  across all 8 codecs; asserts every codec declares the 5 new
  paths `unsupported`.  Guards against accidental partial wire-up
  landing the schema without the matrix declarations.

Audit-runner update: `tools/run_full_mesh.py` gains
`anycast_gateway_mac` in `_AUDITED_FIELDS` so the cross-mesh audit
covers the new field — the audit-coverage drift guard at
`tests/unit/audit/test_run_full_mesh.py::
test_audited_fields_cover_every_canonical_top_level` fired
immediately on the schema landing and surfaced the omission, exactly
the self-healing-test invariant the project relies on.

Wave A test status: 3192 passed, 56 skipped, 0 failed (was 3161
before; **+31 schema tests**).

#### IOS-XE password round-trip bugfix (commit `b85c39c`)

Sub-task between Wave A and Wave B+C.  The IOS-VRRP fixture landed
in `8adaefd` was sanitised away from upstream verbatim specifically
to dodge a latent IOS-XE codec round-trip bug surfaced during that
fixture's ingestion.  This commit fixes the bug, then restores the
fixture to upstream verbatim form.

**The bug.**  Cisco IOS-XE annotates `username … {secret|password}
<N> X` with an encoding-type digit `N`:

* `0` — plaintext (device hashes on commit)
* `5` — md5crypt
* `7` — Cisco's reversible XOR (legacy)
* `8` — PBKDF2-SHA256
* `9` — scrypt

The canonical model carries types 5/7/8/9 as `"<digit> <hash>"` so
the render side can reconstruct the original wire form.  Type `0`
is the wire-explicit spelling of *plaintext* and had been carrying
its `"0 "` prefix through the canonical model verbatim — which
the render path then re-prefixed under a fresh `secret 0 `,
producing `secret 0 0 X`.  Re-parsing that line captured the second
`0` as a hash-type marker, breaking parse↔render symmetry.

**The fix.**  In
`netcanon/migration/codecs/cisco_iosxe_cli/parse.py` —
`_parse_local_users` strips the `"0 "` prefix on parse when
`hash_type == "0"` and stores the bare value as
`hashed_password`.  The render side's existing path already
prepends `secret 0 ` when plaintext needs to cross the wire, so
the round-trip becomes idempotent.  The keyword normalises
`password` → `secret` (the IOS-XE-preferred form) on re-emit but
the encoding-type digit and payload round-trip exactly.  Types
5/7/8/9 are unchanged.

**Tests.**  `TestLocalUserPasswordRoundTrip` (**8 new tests**) in
`tests/unit/migration/test_cisco_iosxe_cli.py` covers plaintext
prefix strip on parse, idempotent type-0 round-trip, render emits
`secret 0 X` (never `secret 0 0 X`), types 5/7/8/9 round-trip
unchanged, `secret 0 X` source form parses to the same canonical
representation as `password 0 X`, mixed-type users round-trip
independently.

**Fixture restored.**
`tests/fixtures/real/cisco_iosxe/batfish_iosxe_basic_vrrp.txt` now
holds the upstream-verbatim `username cisco privilege 15 password
0 cisco` line (deliberately-cleartext lab credential material from
a Batfish parser test snapshot — RFC 5737-class test data, not a
real device secret).  `NOTICE.md` updated to reflect "retained
verbatim" provenance.

#### Wave B + C — 7-codec wire-up (commit `e542b49`)

The big merge.  Seven parallel `general-purpose` agents working in
isolated worktrees each wired one codec against the Wave A schema,
flipping the relevant `CapabilityMatrix` paths from `unsupported`
to `supported` (or `lossy` where the cross-vendor mapping is
partial) and adding parse + render + tests for the per-vendor
grammar.  Per-codec branch attribution preserved on origin under
`worktree-agent-*` branches.

Per-codec contributions:

| Codec               | Worktree commit | New tests | Wave  |
|---------------------|-----------------|----------:|-------|
| `aruba_aoss`        | `2d60a80`       |      +17  | B     |
| `fortigate_cli`     | `4062430`       |      +17  | B     |
| `mikrotik_routeros` | `374d825`       |      +20  | B     |
| `opnsense` (CARP)   | `ded4e31`       |      +21  | B     |
| `juniper_junos`     | `7f760e2`       |      +23  | B + C |
| `arista_eos`        | `31302bd`       |      +24  | B + C |
| `cisco_iosxe_cli`   | `2162435`       |      +27  | B + C |

**+149 codec tests total**, plus the unified schema test
(`test_canonical_vrrp_anycast_schema.py`) reconciled from 7 agent
variants into a single `_WIRED_UP_BY_CODEC` map tracking per-codec
wire-up state with a **two-sided invariant**:

* Graduated paths (listed under a codec's set in
  `_WIRED_UP_BY_CODEC`) MUST appear in `supported` or `lossy` and
  MUST NOT remain in `unsupported`.
* Un-graduated paths (NOT listed for a codec) MUST remain in
  `unsupported`.

Either side firing means a partial wire-up.  Loud test failure.

##### Capability matrix wire-up state

| Codec               | VRRP | v4 anycast | v6 anycast | system MAC | per-VRF SR |
|---------------------|------|------------|------------|------------|------------|
| `cisco_iosxe_cli`   |  *   |     *      |     -      |     *      |     -      |
| `cisco_iosxe`       |  -   |     -      |     -      |     -      |     -      |
| `juniper_junos`     |  *   |     *      |     *      |     -      |   lossy    |
| `arista_eos`        |  *   |     *      |     *      |     *      |     -      |
| `aruba_aoss`        |  *   |     -      |     -      |     -      |     -      |
| `fortigate_cli`     |  *   |     -      |     -      |     -      |     -      |
| `mikrotik_routeros` |  *   |     -      |     -      |     -      |     -      |
| `opnsense` (CARP)   |  *   |     -      |     -      |     -      |     -      |

(`*` = supported, `lossy` = parses but cross-vendor incomplete,
`-` = still `unsupported`)

##### Per-codec scope highlights

**`juniper_junos`** (Wave B + C):

* VRRP: `set interfaces ... unit N family inet address X
  vrrp-group M virtual-address Y / priority / preempt / no-preempt
  / advertise-interval / track interface / authentication-type+key
  / description / accept-data`.
* Anycast: `family inet address X/M virtual-gateway-address Y`,
  `family inet6 address X/M virtual-gateway-address Y`, per-unit
  `virtual-gateway-v4-mac` / `virtual-gateway-v6-mac` overrides.
* IRB-to-VLAN fold preserves new fields onto
  `CanonicalVlan.ipv4_addresses`.
* **QFX10K2 fixture now round-trips anycast data PRESERVED** — was
  silently dropped before this round.
* `/anycast-gateway-mac` kept `unsupported` (Junos has no
  chassis-wide MAC; per-unit override only).
* `/routing/static-route/vrf` flipped to `lossy` (routing-instances
  dispatcher needs per-VRF static harvest — separate scope).

**`arista_eos`** (Wave B + C):

* VRRP: classic + modern multi-line, priority / preempt / track /
  timers / description / mac-address / auth-md5 / auth-text.
* VARP: `ip address virtual X/Y [secondary]`, `ipv6 address
  virtual`, system-wide `ip virtual-router mac-address`.
* VARP-only records carry `ip=""` — EOS has no per-leaf primary;
  virtual IS the only IP.
* `source-nat` discriminator on `ip address virtual` is Tier-3
  parse-and-ignore (VRF-leaked traffic).
* `secondary` trailer preservation closes a pre-existing EOS
  parser gap.
* Per-IP `virtual-gateway-mac` is **`lossy`** — EOS only has the
  chassis-wide MAC; per-IP override does not exist in the grammar.
  Cross-vendor sources from Junos that populate per-IP MAC fields
  surface a review banner on Arista render — operator-visible
  signal of the cross-vendor mapping gap.

**`cisco_iosxe_cli`** (Wave B + C):

* VRRP: classic single-line per-attribute grammar.
* Modern address-family form parses to group-ID-only shell with
  `lossy` declaration documenting the gap.
* SD-Access anycast: per-SVI `fabric forwarding mode
  anycast-gateway` (mirror semantics — primary IP IS the virtual),
  system `fabric forwarding anycast-gateway-mac`.
* MAC parser accepts all 3 vendor forms (dotted-triplet, colon-hex,
  dash-hex); renderer always emits dotted-triplet.
* IPv6 anycast kept `unsupported` (no fixture coverage today —
  see "What's deferred" below).

**`aruba_aoss`** (Wave B only):

* VRRP nested inside `vlan N` stanza.
* Groups attach to the synthesised `VlanN` `CanonicalInterface`
  via the existing SVI-absorption rules.
* AOS-S `preempt` vendor-default is `False` (unlike Cisco `True`).

**`fortigate_cli`** (Wave B only):

* VRRP nested `config vrrp / edit N / set vrip X` inside `config
  system interface / edit X`.
* `set vrip6` triggers implicit `set version 3` on render.

**`mikrotik_routeros`** (Wave B only):

* Two-stage parse correlation: `/interface vrrp` declarations
  collected in scratch; `/ip address` lines binding to `vrrp<N>`
  pseudo-interfaces route into the corresponding group's
  `virtual_ips`.
* `v3-protocol=ipv6` discriminator routes to `virtual_ipv6s`.

**`opnsense`** (Wave B; CARP variant):

* Walks `<virtualip><vip>` children, filters by
  `<mode>carp</mode>` (silently skipping `ipalias` / `proxyarp` /
  `vrrp` modes).
* Inverted **`advskew`→`priority` normalisation**: `priority =
  254 - advskew` (CARP is lower-wins; canonical is higher-wins).
  Declared `lossy` because the inversion is symmetric in canonical
  form but not in every cross-vendor source.
* Authentication `<password>X</password>` stores as `carp-key:X`
  canonical form.

##### Architectural notes on the agent consolidation

The unified `_WIRED_UP_BY_CODEC` map design (final form: per-codec
set of graduated paths, two-sided invariant) was selected from
**4 independent agent variants**.  The IOS-XE agent's design was
closest to the final form; the `_NEW_PATHS` tuple was pruned from
7 paths back to the 5 paths Wave A actually declared.  Per-IP MAC
paths are vendor-specific surfaces (Junos per-unit MAC override,
Arista lossy declaration) and are not required to be declared
across all 8 codecs.

The Arista agent's design call to declare per-IP
`virtual-gateway-mac` as `lossy` (rather than the planning doc's
proposed cascade pattern) is preserved on this merge — surfacing
a review banner on Arista render when cross-vendor sources from
Junos populate per-IP MAC fields is more useful operator-facing
signal than silently losing the data.

#### Cumulative impact

* **+180 new tests** (31 Wave A schema + 149 codec wire-up + 8
  IOS-XE password round-trip regression = **188**, of which 180
  ride the v0.2.0 enrichment scope and 8 are the latent-bugfix
  side-task).
* **Migration unit suite**: 2530 → 2679 passing (Wave A landed at
  2530 baseline post-3192 unit suite; Wave B+C ended at 2679).
* **Full unit suite**: 3192 → 3341 passing, 56 skipped, 0 failed.
* **~6,800 LOC** of new production codec implementation across
  the three commits (Wave A schema + IOS-XE bugfix + 7-codec
  wire-up), against **~13,700 LOC of design documentation**
  authored ahead of any production code (the agent-driven
  planning pass at `docs/v0.2.0-planning/`).

#### What's deferred

* **Cisco IOS-XE NETCONF stub (`cisco_iosxe`)** — every new
  canonical path remains `unsupported`.  The codec is a
  Phase-0.5 NETCONF skeleton that declares every canonical
  surface `unsupported` per its stub policy; full wire-up is
  out of scope until a v0.x.x NETCONF push lands.
* **IPv6 anycast on IOS-XE** — no fixture coverage in the
  current corpus.  The codec has the grammar for IPv4
  SD-Access anycast but the IPv6 path stays `unsupported`
  pending a Catalyst SD-Access IPv6 capture.  P1 gap tracked
  in `docs/v0.2.0-planning/02-anycast-gateway/06-fixture-
  targets.md`.
* **Per-VRF static routes on `juniper_junos`** — declared
  `lossy` rather than `supported`.  The routing-instances
  dispatcher needs a per-VRF static-route harvest pass that is
  scoped separately from this round (planning at
  `docs/v0.2.0-planning/03-nxos-codec/03-canonical-mapping.md`
  notes the touchpoints).
* **NX-OS codec (T3)** + **IOS-XR codec (T4)** — design
  artifacts shipped in the v0.2.0 planning pass but the codec
  implementations themselves slip to **v0.3.0+**.  Aggregate
  budget for the two codecs is ~4,300-5,900 LOC + ~3,000-4,100
  test LOC combined — too large for a single maintainer's
  v0.2.0 review budget.  T3 ships first per the planning
  synthesis (enterprise reach > SP audience at equal cost);
  T4 explicitly recommends deferral until T3 is in flight.

#### Verification

Full unit suite green (exit 0, **+180 tests** across the
v0.2.0 enrichment + **+8 tests** for the IOS-XE password
round-trip bugfix, total **3341 passing**, 56 skipped,
0 failed).  Per-vendor doc pages updated under
[`docs/vendors/`](docs/vendors/) for each codec that
graduated paths.  Capability matrix at
[`docs/CAPABILITIES.md`](docs/CAPABILITIES.md) reflects the
new per-codec state.

### v0.2.0 design + planning: 4-track enrichment artifacts

Preparatory work for the v0.2.0 enrichment cycle.  Two commits
(`8adaefd` + `5adee9b`) seed the fixture corpus and ship a full
design-artifact set for four parallel enrichment tracks.  No
production source code is modified by either commit — every file
is either a fixture under `tests/fixtures/real/` or markdown
under `docs/v0.2.0-planning/`.

#### Fixtures (commit `8adaefd`)

Two carefully-chosen real captures from `batfish/lab-validation`
that surface cross-vendor grammar gaps not yet modelled in
netcanon's canonical intent tree:

* **`tests/fixtures/real/cisco_iosxe/batfish_iosxe_basic_vrrp.txt`**
  (153 lines, Apache-2.0).  First dedicated **VRRP fixture in the
  corpus** — `vrrp 12 ip 192.168.1.254` + `vrrp 12 priority 110`
  on GigabitEthernet0/2.  Landed sanitised in `8adaefd` because
  the upstream `username … password 0 …` line tripped a latent
  IOS-XE codec round-trip bug; restored to upstream verbatim in
  `b85c39c` once the bug shipped.
* **`tests/fixtures/real/arista_eos/batfish_eos_evpn_vlan_based_leaf.txt`**
  (330 lines, Apache-2.0).  Fifth Arista fixture and sibling to
  `batfish_labval_dc1_leaf2a_eos4230.txt` — the two variants
  share topology + node naming but encode EVPN differently (this
  one uses L2VNIs + centralised L3 gateway; the sibling is the
  symmetric-IRB variant on the same EOS train).  Different
  `router bgp / vlan-aware-bundle` shape, different `interface
  Vxlan1` block layout.

`tests/fixtures/real/WANTED.md` expanded with the cross-vendor
canonical-model enrichment plan — VRRP / HSRP / anycast-gateway
grammar matrix per vendor + proposed `CanonicalVRRPGroup` surface
+ anycast-gateway Tier-2 follow-up grammar inventory.  Tier-D
NX-OS + IOS-XR entries upgraded with concrete
`batfish/lab-validation` snapshot pointers (ready-to-pull seed
corpus once those codecs ship).

#### Planning artifacts (commit `5adee9b`)

`docs/v0.2.0-planning/` — **~13,700 lines / 29 markdown files**
authored by **4 parallel `general-purpose` agents**, each given
the same hard constraint (write only into the assigned subfolder,
never modify production code).  Total agent runtime ~21 minutes
wall-clock (parallel) / ~84 minutes CPU-time.

Four enrichment tracks:

| # | Subfolder | Topic | Target |
|---|---|---|---|
| T1 | [`01-vrrp-canonical/`](docs/v0.2.0-planning/01-vrrp-canonical/) | `CanonicalVRRPGroup` model + wiring plan for 7 bidi codecs | v0.2.0 (shipped this cycle) |
| T2 | [`02-anycast-gateway/`](docs/v0.2.0-planning/02-anycast-gateway/) | Per-address `virtual_gateway_address` + system MAC | v0.2.0 (shipped this cycle) |
| T3 | [`03-nxos-codec/`](docs/v0.2.0-planning/03-nxos-codec/) | Full new Cisco NX-OS bidirectional codec | v0.3.0+ |
| T4 | [`04-iosxr-codec/`](docs/v0.2.0-planning/04-iosxr-codec/) | Full new Cisco IOS-XR bidirectional codec | v0.3.0+ (defer until after T3) |

Per-task artifact set (~7 files per subfolder, mirroring across
all 4 tasks): `README.md` (executive summary + scope) +
`01-canonical-model.md` / `01-grammar-survey.md` +
`02-per-vendor-grammar.md` / `02-codec-architecture.md` +
`03-parse-render-touchpoints.md` / `03-canonical-mapping.md` +
`04-test-plan.md` + `05-capabilities-matrix-updates.md` /
`06-capabilities-matrix.md` + `05-/06-fixture-targets.md`.

#### Cross-task synthesis (the load-bearing decision)

The **top-level synthesis README** at
[`docs/v0.2.0-planning/README.md`](docs/v0.2.0-planning/README.md)
cross-references all four tasks and resolves the headline
architectural conflict surfaced during the planning pass: T1 and
T2 agents reached **opposite recommendations** on whether anycast
should share T1's `CanonicalVRRPGroup` (mode discriminator) or be
an independent per-address field.

**Resolution: HYBRID** — classic FHRP (VRRP / HSRP / CARP) as a
record type (`CanonicalVRRPGroup` with `mode` discriminator,
group_id + priority + preempt as domain primitives); anycast as
an IP-address property (`virtual_gateway_address` /
`virtual_gateway_mac` fields directly on `CanonicalIPv4Address`
and `CanonicalIPv6Address`, plus a system-wide
`CanonicalIntent.anycast_gateway_mac`).  Full rationale + the
four reasons T2's argument was stronger documented at
[`docs/v0.2.0-planning/README.md`](docs/v0.2.0-planning/README.md)
§ "The headline conflict".

The HYBRID resolution lights up Wave A's schema shape (commit
`c5da044`) verbatim.

#### Schema-extension cascade documented

The 4 planning passes surfaced **5 canonical-model extensions**
needed for v0.2.0 across the cascade (every extension reused by
≥2 tasks), plus a 6th that's a side-benefit (`CanonicalStaticRoute.vrf`
also closes a pre-existing IOS-XE per-VRF-static-route lossy
declaration).  All 5 ship together in Wave A so per-codec wiring
can land incrementally.

Aggregate budget per the planning synthesis: v0.2.0 ships Waves
A + B + C (**~1,185 production LOC + ~450 test LOC** —
re-graded to ~6,800 LOC actual on landing once full codec
parse + render paths were written out).  v0.3.0+ ships Waves D
(NX-OS) + E (IOS-XR) — combined ~4,300-5,900 production LOC +
~3,000-4,100 test LOC.

#### Open questions flagged for maintainer arbitration

10 open questions flagged for arbitration ahead of Wave A landing
— 4 architectural (which surface shape), 5 per-task scope, 1
audience/sequencing.  All 4 architectural questions were
arbitrated into Wave A's schema landing (HYBRID; mode discriminator
keeps `"carp"`; `virtual_mac` per-group with codec-side cascade;
`is_secondary` bundled into T2 scope).

#### Implementation status at commit time

Nothing in either commit changes runtime behaviour.  The fixtures
auto-discover into `tests/unit/migration/test_real_captures.py`
(parse / determinism / round-trip invariants); the planning
artifacts are review-ready design briefs that the Wave A / B / C
implementations cite by relative path in their commit messages.
Per-task `IMPLEMENTED.md` stubs pointing at the v0.2.0 merge
commits will land alongside the v0.2.0 tag.

### Junos codec: channelized sub-interface render fix + QFX10K2 fixture

Drive-by codec fix that closes a real round-trip stability bug surfaced
during fixture research and pulls the held-out **QFX10002-72Q** capture
back into the real-capture corpus.

#### The bug

The Junos render path uses ``_SUBIFACE_RE`` to decompose canonical
sub-interface names like ``ge-0/0/0.100`` back into
``parent / unit`` so it can emit native ``set interfaces <parent>
unit <N> ...`` grammar.  The regex required ``parent`` to match
``[A-Za-z]+-\d+/\d+/\d+`` exactly — but **channelized (break-out)
port** names carry an extra ``:<channel>`` segment (e.g.
``xe-0/0/6:2`` is channel 2 of physical port 6, common on QFX
10K / 100G break-out platforms).  Such names didn't match, so
``_split_subiface_name("xe-0/0/6:2.10")`` returned ``(None,
None)``, and the render fell through to the top-level branch
which emitted the malformed double-suffix form:

```
set interfaces xe-0/0/6:2.10 unit 0 family inet address ...
```

instead of the native:

```
set interfaces xe-0/0/6:2 unit 10 vlan-id 10
set interfaces xe-0/0/6:2 unit 10 family inet address ...
```

The first-pass parse captured ``access_vlan`` on the materialised
``xe-0/0/6:2.10`` CanonicalInterface; the rendered output didn't
preserve it (no ``unit N vlan-id N`` line); the re-parse yielded
``access_vlan=None`` → round-trip stability test failed.

#### The fix

One-line regex change in
``netcanon/migration/codecs/juniper_junos/render.py`` (the
``_SUBIFACE_RE`` constant):

```python
# before
_SUBIFACE_RE = re.compile(r"^(?P<parent>[A-Za-z]+-\d+/\d+/\d+)\.(?P<unit>\d+)$")
# after
_SUBIFACE_RE = re.compile(
    r"^(?P<parent>[A-Za-z]+-\d+/\d+/\d+(?::\d+)?)\.(?P<unit>\d+)$"
)
```

Existing non-channelized cases (``ge-0/0/0.100`` / ``xe-0/0/6.10`` /
``et-0/0/48.0``) continue to match the same way; channelized cases
(``xe-0/0/6:2.10`` / ``xe-0/0/6:2.100``) now decompose correctly;
logical-SVI cases (``irb.10`` / ``ae1.0``) still don't match (correct
— those have a separate render branch).  No other code paths
duplicate the regex pattern.

#### Tests

* ``tests/unit/migration/test_juniper_junos.py``
  ``TestPerUnitVlanTagging`` — 2 new tests:
  - ``test_render_channelized_subiface_splits_correctly`` —
    asserts the render emits the native ``unit N`` form for a
    channelized sub-interface + does NOT emit the pre-fix
    malformed double-suffix form
  - ``test_channelized_subiface_round_trip`` — end-to-end
    parse → render → parse for a channelized port with two
    sub-interfaces, asserting ``access_vlan`` is preserved on
    both
* ``tests/unit/migration/test_real_captures.py`` — auto-picks
  up the new ``ksator_labmgmt_qfx10k2_junos173.set`` fixture
  via ``_discover_fixtures``; all 3 invariants
  (parse / determinism / round-trip) pass.

Full migration suite (``tests/unit/migration/``): 2485 passed,
56 skipped (pre-existing), 0 failed.

#### Fixture

``tests/fixtures/real/junos/ksator_labmgmt_qfx10k2_junos173.set``
(new — 391 lines, MIT / Copyright Juniper Networks 2018) — pulled
from
https://github.com/ksator/lab_management/blob/master/backup/QFX10K2-174_config.2017-12-19@15%3A28%3A23.
This is the densest Junos fixture in the corpus: channelized
break-out sub-interfaces, EVPN-multihoming LAG grammar (ESI
identifiers + LACP system-id override — parse-and-ignore per the
existing ``/groups`` Lossy declaration), 7 ``irb.<vid>`` SVIs with
VRRP + family inet6, 6 VLAN-to-VNI VXLAN mappings, 3 VRF
routing-instances with sub-interface bindings.  Surfaced the
channelized-render bug; held out of the corpus pre-fix per the
hold-out note in NOTICE.md.

NOTICE.md hold-out note removed; replaced with a full provenance
row crediting the upstream capture + documenting which grammar the
fixture exercises.

### Phase 3 Round 9: runtime checks — load + memory smoke + browser-compat

The pre-launch checklist round.  Two parts: lightweight load + memory
smoke tests covering the "10 devices × 100KB × concurrent backups"
scenario, and a manual browser-compat sweep across the in-app UI
post-R7/R7.1/R7.2/R8 work.

Single PR (`r9-runtime-checks`).  Deliberately lightweight: no
Docker dependency (the original scope mentioned containerised
browser-compat checks but Docker Desktop has been flaky on the host
machine; covered via native browser testing instead).  Real load
testing belongs in a separate ``tools/`` script not run as part of
the test suite — these are smoke tests that pin properties + catch
gross regressions.

#### Load + memory tests

``tests/integration/test_load_and_memory.py`` (new, **6 tests**)
covers the runtime properties that pre-launch operators care about:

* **Sustained load**: 50 sequential backups (each with a 100KB
  synthetic Cisco-shaped config) against a cap-10 registry.
  Verifies the registry's LRU eviction holds end-to-end through
  the real API surface (not just direct registry inserts, which
  the R8 unit tests covered).  Confirms: memory cap holds, disk
  has all 50, every job is queryable by ID (transparent disk
  fallback for evicted ones).
* **Eviction + persistence**: submit one job, flood-evict it,
  GET it back from disk.  Pins the R8 contract that completion
  state survives memory eviction (the ``FileJobStore.save`` call
  in ``_run_backup_job`` is what makes this true).
* **Concurrent backups (single-device)**: 20 parallel POSTs via
  ``ThreadPoolExecutor(max_workers=10)``.  Verifies every
  request returns 202, every job ID is unique, every job
  persists.  Catches races in the registry's ``__setitem__`` or
  the BackgroundTask pipeline.
* **The operator-flagged scenario**: 3 concurrent jobs each with
  10 devices (so 30 devices × 100KB ≈ 3 MB of synthetic config
  text flowing through the pipeline simultaneously).  Verifies
  all 3 jobs complete with 10 successful results each.
* **BackupJob instance count bounded**: ``gc.get_objects()``
  walks live ``BackupJob`` instances during a 30-job burst
  against a cap-5 registry.  Delta-from-baseline check (other
  tests in the suite pollute the count via Pydantic's cycle
  structures; delta is what matters).  Pre-R8 this delta would
  have been ~30; post-R8 the cap bounds it.
* **Tracemalloc peak under load**: stdlib ``tracemalloc``
  snapshot before vs after a 20-job burst.  Asserts peak Python-
  managed memory delta < 5 MB (a 10× regression ceiling).
  Best-effort — flaky in CI envs with varying allocator state;
  the primary guard is the ``gc.get_objects`` test above.

All 6 tests complete in <3 seconds — fast enough to stay in the
regular test sweep rather than a separate marker.

#### Browser-compat sweep

Manual visual + functional check in **Chrome** and **Firefox**
(natively installed on the host; Docker-containerised Safari /
Edge deferred to v0.2.0 pending host-env improvements).  The
following surfaces were verified working in both browsers:

* Every page loads (`/`, `/devices`, `/jobs`, `/schedules`,
  `/configs`, `/definitions`, `/migrate`, `/sanitize`, `/docs`)
  with no console errors.
* Theme toggle flips correctly + persists across navigation +
  paints with no FOUC.
* `?` cheatsheet opens via keypress and nav-button + closes via
  Esc, second `?`, and click-outside.
* Forms surface validation errors as readable strings (not
  `[object Object]`).
* Modal flows end-to-end (compare-picker → diff page; sanitize
  page submit with dry-run + result rendering; migrate
  translate flow).
* `/docs` Swagger UI re-themes in both modes including the
  schema explorer chrome.

**Acknowledged-as-intentional**: the config viewer modal
(``#_config-viewer``) renders with its always-dark VS Code "Dark+"
palette regardless of the document theme.  Same pattern as
``pre.diff-body``, ``pre.mig-output``, the syntax-highlight
``.tok-*`` token classes, and the sanitize page's
``.san-output``.  Code surfaces are conventionally always-dark in
the IDE world — matching that convention is more familiar to
operators than re-theming.  Flagged here so future visual
reviewers know it's deliberate.

#### Deferred to v0.2.0

* Safari + Edge browser-compat (requires Docker or VMs).
* WebKit Playwright tests in CI (currently chromium-only).
* Memory-under-load benchmarks targeting 1,000+ concurrent jobs
  (the 6 smoke tests cover up to ~30; larger benchmarks
  appropriate for a ``tools/`` script not a unit test).

#### Verification

Full test suite green (exit 0, **+6 new tests**, total ~3556).
Operator-confirmed manual browser-compat in Chrome + Firefox.

### Phase 3 Round 8: backup-job registry — bounded memory + disk lazy-load

Pre-R8 ``app.state.jobs`` was an unbounded ``dict[str, BackupJob]``
that grew every time a backup job ran.  A server that handled 100k
jobs over its lifetime held ~500 MB of ``BackupJob`` objects in
memory (each job is metadata-only at ~5 KB — the actual config text
is on disk in ``configs/`` referenced by filename, NOT inlined into
the job).  This round caps the in-memory cache + falls through to
disk lazy-load on get-by-id miss.

Single PR (`r8-jobs-eviction`).

#### Design

**Disk is the source of truth.**  Every job is persisted to
``jobs/{id}.json`` via the existing ``FileJobStore.save()`` — the
in-memory registry is purely a cache for fast API access.  Pre-R8
the cache was the same shape as disk; post-R8 the cache is bounded
+ disk grows independently (jobs accumulate forever on disk;
retention policy is a v0.2.0 concern).

**``BackupJobRegistry`` wraps an ``OrderedDict``** with LRU
eviction on insert over cap.  Dict-like surface mirrors what the
pre-R8 routes already used (``__setitem__`` / ``__getitem__`` /
``__contains__`` / ``__len__`` / ``values()`` / ``get()``) so the
swap from ``dict`` to registry required only type-hint updates in
the route handlers — zero behavioural changes.

**Lazy-load on memory miss**: ``__getitem__`` first checks the
cache, then on miss calls ``FileJobStore.load_one(job_id)`` (new in
this PR).  Loaded jobs are promoted into the cache (potentially
evicting an even-older entry to make room).  ``__contains__`` does
the same memory→disk fallback for the ``in`` check route handlers
use before get-by-id.

**``values()`` returns memory-resident only** (used by the list
endpoint).  This is a deliberate semantic: operators want "recent
jobs", not every job ever recorded.  At default cap (1000), the
list always covers the most-recent 1000 jobs; for the typical
deployment (tens-to-hundreds of devices × hourly-to-daily schedules
= ~10k jobs/year) this is months of history.

#### What ships

* ``netcanon/storage/job_registry.py`` (new, ~225 LOC).
  ``BackupJobRegistry`` class with full dict-like surface + warm-
  cache constructor option + registry-specific
  ``total_disk_count()`` for diagnostic endpoints.  Module docstring
  documents every semantic choice (why ``__len__`` returns memory-
  only, why ``values()`` doesn't include disk-only, etc.).
* ``netcanon/storage/job_store.py`` — two new methods:
  ``load_one(job_id) -> BackupJob | None`` (single-record disk read
  for the registry's lazy-load), and ``list_job_ids() -> list[str]``
  (cheap directory scan for ``total_disk_count``).  Same corrupt-
  file-logging semantics as ``load_all``.
* ``netcanon/config.py`` — new ``max_memory_jobs`` setting
  (``Field(default=1000, ge=0)``).  Override via the
  ``NETCANON_MAX_MEMORY_JOBS`` env var.  ``0`` disables caching
  entirely (every read hits disk).
* ``netcanon/main.py`` — startup swaps ``app.state.jobs`` from
  ``job_store.load_all()`` (raw dict) to ``BackupJobRegistry(
  job_store, max_memory_jobs=settings.max_memory_jobs)``.  The
  constructor warms the cache from disk so the API is fast on the
  common case post-restart.
* ``netcanon/api/deps.py`` — ``get_jobs`` return type updated from
  ``dict[str, BackupJob]`` to ``BackupJobRegistry``.
* ``netcanon/api/routes/backups.py`` — three route-handler type hints
  updated to match (no behavioural changes; the registry's dict-like
  surface means the route bodies didn't need to change).
* ``netcanon/api/routes/ui.py`` — removed the dead ``/health``
  endpoint that was shadowed by ``health_router`` (registered
  first in ``main.py``).  The endpoint referenced
  ``len(state.jobs)`` and would have returned misleading
  memory-only counts post-R8 anyway; cleaner to delete the dead
  code than revive it.  If operators want per-resource diagnostic
  counts in the future, add a dedicated ``/diagnostics`` endpoint
  rather than reviving the shadow.
* ``tests/unit/test_job_registry.py`` (new, **26 tests**) — covers
  construction (default cap, custom cap, zero cap, negative
  rejection), bounded memory (LRU eviction, zero-cap-never-caches,
  update-doesn't-grow), LRU ordering (get-moves-to-MRU,
  setitem-moves-to-MRU), lazy disk-load (getitem-loads-from-disk,
  promotes-to-cache, missing-raises-KeyError, get-with-default,
  contains-checks-disk, non-string-key-rejected), warm cache (loads-
  recent-N, disabled-starts-empty, zero-cap-with-warm), dict
  surface (iter, values, len), registry-specific (total_disk_count,
  max_memory_jobs property), edge cases (cap-of-one,
  corrupt-disk-file).
* ``tests/unit/test_job_store.py`` — **7 new tests** in two classes:
  ``TestFileJobStoreLoadOne`` (4 tests for the new method:
  returns-job, returns-None-missing, returns-None-corrupt, round-
  trip) and ``TestFileJobStoreListJobIds`` (3 tests: empty-dir,
  one-per-json, ignores-non-json).
* ``tests/integration/test_jobs_disk_fallback.py`` (new, **5
  tests**) — end-to-end verification: list endpoint returns memory-
  resident only after exceeding cap; get-by-id lazy-loads evicted
  job from disk; get-by-id returns 404 for truly-missing job;
  ``app.state.jobs`` IS a ``BackupJobRegistry``; registry uses the
  ``max_memory_jobs`` setting value.

#### Deferred to v0.2.0 (added to post-launch roadmap)

* **Pagination for list endpoint**: today returns memory-resident
  jobs (up to ``max_memory_jobs``).  Real pagination (`?limit=N&
  offset=M`) + UI updates would let operators browse historical
  jobs without raising the in-memory cap.  Defer because the typical
  v0.1.0 install has <1000 jobs.
* **Retention policy / auto-delete on disk**: today jobs grow on
  disk forever.  Operators can manually prune ``jobs/*.json`` if
  the directory gets too large.  Add a ``max_disk_jobs`` setting +
  a daily cleanup task in v0.2.0.
* **Startup-time optimisation**: warm-cache uses ``load_all()`` which
  parses every JSON file — for installs with 100k+ jobs that's
  10–20s of startup time.  v0.2.0 path: stat-sort by mtime + parse
  only the newest N.
* **``/diagnostics`` endpoint** exposing the registry's
  memory/disk split (``jobs_memory`` vs ``jobs_disk``) for operators
  who want to monitor cache hit rate.  Defer until there's a real
  operator ask.

#### Verification

Full test suite green (exit 0, **+38 new tests**, total ~3550).
The registry's dict-like surface meant the route handlers needed
zero behavioural changes — the swap is invisible from the API
contract perspective.  Operators on installs with <1000 jobs see
zero difference (same hit-from-memory behaviour); operators with
>1000 jobs see bounded memory + transparent disk fallback for
historical job lookups.

### Phase 3 Round 7.2: Swagger UI dark mode + nav sync

Closes the dark-mode story.  Round 7.1 swept the in-app templates but
left ``/docs`` (Swagger UI) as a light-only outlier — that page is
built from ``fastapi.openapi.docs.get_swagger_ui_html()`` and
post-processed in ``netcanon.api.routes.ui:swagger_ui``, NOT a Jinja
template extending base.html.  R7.1's tokenization didn't reach it.

This round wraps the Swagger UI page with the same theme infrastructure
the rest of the app has, plus CSS overrides for Swagger UI's internal
class selectors.

Single PR (`r7.2-swagger-darkmode`).

#### What ships

1. **Theme-detect boot script** injected at ``<body>`` open.  Reads
   ``localStorage["netcanon.theme.v1"]`` + falls back to
   ``prefers-color-scheme: dark``.  Mutates ``<html data-theme>``
   before any CSS applies → no flash of light content.  Duplicates
   the base.html boot script verbatim (the comment in ui.py flags
   the sync requirement).
2. **Token definitions** (``:root`` + ``[data-theme="dark"]`` blocks)
   for ``--page-bg``, ``--surface``, ``--surface-alt/-elev/-hover``,
   ``--text-primary/-muted/-faint``, ``--border/-strong``,
   ``--nav-*``, ``--accent``, ``--btn-secondary-*``, ``--pre-*``,
   ``--shadow-card``.  Only the tokens referenced by the nav + Swagger
   overrides are included — the full base.html set wasn't needed.
3. **Nav-bar parity** with base.html: page-nav cluster (10 links) +
   spacer + right-rail ``?`` cheatsheet trigger + sun/moon theme
   toggle.  Each link carries the same ``data-testid`` as base.html's
   nav so downstream automation finds the same surface on /docs as
   anywhere else.
4. **Local ``toggleTheme()`` JS** inlined (the ``_partials/theme-
   toggle.js`` JS isn't loaded on the docs page).  Stripped-down
   version: flips ``data-theme``, persists to localStorage.  No
   ``aria-label`` live-update — the developer-page UX cost is
   minimal compared to duplicating the full partial.
5. **Swagger UI dark-mode CSS overrides**: ~30 selectors wrapped in
   ``[data-theme="dark"] .swagger-ui ...`` with ``!important`` (to
   beat the CDN stylesheet's specificity).  Covers info / opblock
   cards / tag headings / parameter & response tables / model
   explorer / try-it-out inputs / code samples / secondary text.
   Doesn't try to theme every Swagger surface — the long tail is
   v0.2.0 polish.

#### The cheatsheet button: URL-param redirect, not inline modal

The ``?`` button on /docs **navigates to ``/?show-shortcuts=1``**
instead of opening an inline cheatsheet modal.  Rationale:

* The docs page has no per-page keyboard shortcuts of its own (no
  config viewer, no diff page, no compare picker).  The cheatsheet
  content all applies to OTHER pages.
* Inlining the modal HTML + CSS + JS partial would duplicate ~190
  LOC into the post-processed page for a surface that doesn't host
  its own shortcuts.
* Auto-opening on a real app page (home) lands the operator
  somewhere the shortcuts actually apply, with the modal visible
  immediately.

A small handler in ``_partials/kbd-cheatsheet.js`` watches for
``?show-shortcuts=1`` in the URL on DOMContentLoaded.  When present,
it opens the modal AND strips the param via ``history.replaceState``
so refreshing doesn't re-open.

#### File breakdown

* ``netcanon/api/routes/ui.py`` — module-level constants for boot
  script / tokens / nav HTML / nav CSS / toggle JS / Swagger dark
  CSS, all docstring-annotated with the sync requirement against
  base.html.  Route handler reads cleaner now (5 named injection
  points vs the prior inline string concat).
* ``netcanon/templates/_partials/kbd-cheatsheet.js`` — new
  ``DOMContentLoaded`` listener for the ``?show-shortcuts=1`` URL
  param (~20 LOC).  Wrapped in try/catch for browsers without
  ``URLSearchParams`` (very old IE / very old Safari) — the modal
  stays openable via the ``?`` keypress.
* ``tests/integration/test_swagger_docs_page.py`` (new, **26
  tests**) across 5 classes:
  - ``TestDocsPageBoot`` (3 tests) — boot script + tokens + dark
    overrides defined
  - ``TestDocsNavBar`` (15 tests, 9 parametrized) — brand,
    page-nav links, active-class, cheatsheet button URL, theme
    toggle, spacer positioning
  - ``TestSwaggerDarkOverrides`` (4 tests) — opblock + scheme-
    container overrides, !important density, input theming
  - ``TestShowShortcutsUrlParam`` (2 tests) — URL-param handler
    registered + replaceState used
  - ``TestDocsResponseIntegrity`` (3 tests) — swagger-ui div,
    bundle script, openapi URL all survive post-processing

#### Sync requirement against base.html

The boot script + token definitions in ui.py duplicate fragments
of base.html.  When tokens change in base.html (e.g. adding a new
``--accent-dim``), the same change must propagate to ui.py.
Flagged in the ui.py docstrings.  A future refactor could lift the
token block into a single Python constant imported by both surfaces
— deferred to v0.2.0 polish.

#### Verification

Full test suite green (exit 0, **+26 tests**, total 3515).  Manual
visual verification: /docs page re-themes correctly when toggle
flips (info / opblock cards / tables / inputs all darken); the
``?`` button navigates home with the cheatsheet pre-opened; theme
toggle persists across navigation between /docs and the rest of
the app.

### Phase 3 Round 7.1: dark-mode coverage sweep (in-app surfaces)

Visual verification on Round 7 surfaced **six pre-existing dark-mode
coverage gaps** across the in-app templates — vendor chips, the
configs compare-picker modal, the migrate `<textarea>` + result
card, schedules target panels + Disabled badge, sanitize page's
undefined-token fallbacks, the migrate/diff/sanitize banner palette,
and the job-progress icon colours.  These existed on `main` pre-R7
but became visible enough during R7 verification that the user
flagged them.

Single PR (`r7.1-dark-mode-coverage`).  Swagger UI internals
theming (`/docs` page) deferred to Round 7.2 — partial-Swagger
nav-sync work would create worse UX than the current always-light
docs page.

#### Recurring patterns DRYed out

This round was overwhelmingly mechanical: replace hardcoded hex
values with the existing CSS-variable tokens already declared in
`base.html`.  Five patterns repeated across files:

1. **`background:#fff` card surfaces** → `var(--surface)` +
   `box-shadow:var(--shadow-card)`.  Applied to `.device-card`,
   `.job-card`, `.mig-form`, `.mig-result-section`,
   `#mig-rename-modal`, `#compare-picker-box`, `.san-result-section`.
2. **Banner palette literals** (`#d4edda/#155724` etc.) →
   `var(--badge-completed-bg/-fg)` / `var(--badge-partial-bg/-fg)`
   / `var(--badge-failed-bg/-fg)` / `var(--alert-info-bg/-fg)`.
   Applied to `.diff-banner-*`, `.mig-banner-*`, `#mig-rename-fitcheck.fit-*`,
   `table.mig-rename-table tr.has-warning/collision/drop`, `.san-banner-info`.
3. **Chip pattern `background:#e8e8f0;color:#333`** →
   `var(--surface-elev)` + `var(--text-primary)`.  Applied to
   `.diff-chip`, devices `device-type` chip, schedules profile
   chip, configs `.compare-option .type-chip`.
4. **Muted text colours** `#555/#666/#888/#aaa/#bbb` →
   `var(--text-muted)` / `var(--text-faint)` based on contrast
   intent.  ~20 occurrences across migrate.html, schedules.html,
   sanitize.html, jobs.html, devices.html, index.html.
5. **`<textarea>` not in base.html's `input, select` rule** → added
   `textarea` to the selector list.  This single edit fixes both
   the migrate textarea AND the sanitize textarea (and any future
   `<textarea>` element added to the app).

#### Specific high-impact fixes

* **sanitize.html undefined-token fallbacks**: the page CSS
  referenced `var(--bg-input)`, `var(--bg-card)`, and `var(--text)`
  — none of which are declared in base.html.  The hardcoded
  fallback values (`#fff` / `#1a1a2e` / `#ccc`) kicked in, making
  the textarea + result card stay light in dark mode regardless of
  the document's `data-theme` attribute.  Switched to
  `var(--surface)` / `var(--text-primary)` / `var(--border-strong)`
  (real tokens).
* **configs.html compare-picker modal**: the operator-flagged "full-
  white modal in dark mode" surface.  The `#compare-picker-box`
  rule now uses `var(--surface)` + `var(--shadow-modal)` and the
  inner option rows + type chips re-theme correctly.  The modal
  header keeps its always-dark `#1a1a2e` (intentional — matches
  the config-viewer header + nav-bar convention).
* **migrate.html rename modal chrome**: ~20 separate hex
  declarations across the modal toolbar, rail, table, fit-check
  banner, row-state highlights, summary chips, and footer.  All
  tokenized.  Active rail buttons retain their always-dark navy
  styling (intentional — mirrors the nav).
* **schedules.html Disabled badge**: was `#e2e3e5/#383d41` (light
  grey on near-black) — already-defined `var(--badge-pending-bg/-fg)`
  tokens cover the same semantic, so reused those.  Same swap
  applied to `.enabled-badge` → `var(--badge-completed-bg/-fg)`.
* **base.html job-progress icon colours**: the `.jp-icon-running`
  / `-success` / `-failed` colours were sourced from the light-mode
  badge text-foreground palette (`#856404` / `#155724` / `#721c24`)
  which became near-invisible on dark `--surface`.  Switched to
  the `--badge-*-fg` tokens so dark mode picks up the bright
  contrast variants automatically.

#### What stays hardcoded — by design

Surfaces that are intentionally always-dark (operator-facing as
always-dark, regardless of theme):

* Nav bar (`var(--nav-bg)` = `#1a1a2e` light / `#0d0d18` dark) — same
  navy in both modes, with a slight dark-mode adjustment.
* Config-viewer modal header — always-dark `#1a1a2e/#eee`.
* Config-viewer `<pre>` body + VS Code "Dark+" syntax-highlight
  palette (tok-*) — pre blocks are always dark.
* Job-progress panel header — always-dark navy.
* Diff page body + collapsed-context markers + add/remove markers
  — diff view is always dark (mirrors GitHub diff convention).
* Migrate rename modal header + active rail buttons + preview pane.
* `mig-chip` semantic-classification colour variants (router-green,
  firewall-red, etc.) — always-on category colours regardless of
  theme; semantic meaning is preserved.

#### File breakdown

* `base.html` — added `textarea` to the `input, select` rule;
  tokenized the 4 `.jp-icon-*` colours.
* `index.html` — 2 small fixes (gear-icon disclosure + "(optional)"
  hint).
* `devices.html` — card chrome + edit panel + chip + detected-facts
  panel + 4 `<summary>` accent colours.
* `jobs.html` — full card chrome + 6 muted-text colours + status
  icon colours + btn-link tokens.
* `configs.html` — compare-picker modal (box, options, type chip,
  hover); hash-scroll highlight; 2 JS-injected muted-text styles.
* `schedules.html` — badges + 2 target panels (inline) + profile
  chip + various muted text.
* `migrate.html` — banner palette + form + result section + path
  list + draggable rename modal (toolbar, fitcheck, rail, table,
  row-state highlights, summary, footer) + 4 inline text colours.
* `diff.html` — chip + banner palette + stats + direction arrow.
* `sanitize.html` — undefined-var fallbacks removed; banner +
  audit table + output area tokenized.
* `_partials/rename-table.js` — JS-emitted collision-count chip.
* `tests/integration/test_dark_mode_coverage.py` (new, **16 tests**)
  — regression guards for each operator-flagged surface plus the
  recurring patterns.

#### Verification

Full test suite passed (exit 0, +16 new tests, total 3489).  Manual
visual verification: every operator-flagged surface (devices, jobs,
schedules, configs compare-picker, migrate form/textarea/rename
modal/banners, sanitize page) re-themes correctly when the theme
toggle flips.

#### Deferred to Round 7.2

The Swagger UI `/docs` page remains light-only.  Its dark mode
needs (a) syncing the nav extension (`?` button + theme toggle, both
currently absent from the docs page's hand-rolled nav), AND (b)
shipping CSS overrides for ~30-40 Swagger UI class selectors
(`.swagger-ui .opblock-*`, `.swagger-ui table`, etc.).  Doing one
without the other creates worse UX than the status quo, so deferred
as a self-contained follow-up round.

### Phase 3 Round 7: keyboard-shortcut cheatsheet modal (UX polish)

The app shipped four undocumented keyboard shortcuts (config-viewer
search `Enter` / `Shift+Enter` / `Esc`, diff-page collapsed-marker
`Enter` / `Space`, configs-page compare-picker `Esc`) plus the brand-
new theme-toggle button.  These were invisible to anyone who didn't
read the source — and operators who run multiple tabs + Vim-style
workflows expect a shortcuts panel.  This round adds one.

Single PR (`r7-kbd-cheatsheet`).

#### What ships

* A new global modal rendered by `base.html` on every page,
  documenting the existing-but-undocumented shortcuts in four
  sections: Global / Config viewer / Diff page / Configs page.
  Mirror's the `_config-viewer` modal's shell (same backdrop, same
  dark-navy header, same close-button styling), narrower than the
  config viewer (560px vs 800px) because the content is a shortcut
  list, not config text.
* A `?` keypress handler at the document level that toggles the
  modal open / closed.  Bails out when a text-editable field is
  focused (`<input type="text">`, `<textarea>`, `<select>`,
  contenteditable) so typing `?` into the host field or a search
  box doesn't hijack the modal.  Non-text inputs (checkboxes,
  radios, file pickers) still pass through — the operator likely
  meant the shortcut.
* A nav-bar `?` trigger button between the page-nav cluster and
  the theme toggle, mirroring the theme toggle's styling
  (transparent background, hover lightens, focus ring).  Provides
  discoverability for operators who don't know about the keypress.
* `Esc` listener: closes the cheatsheet if open.  Coexists with the
  existing config-viewer Esc handler and the configs-page
  compare-picker Esc handler — the listeners fan out independently
  and only the relevant modal responds.

#### File breakdown

* `netcanon/templates/_partials/kbd-cheatsheet.js` (new, ~95 LOC).
  Exports `openKbdCheatsheet()` / `closeKbdCheatsheet()` globals
  (matching the `_config-viewer` partial's convention) plus a
  private `_kbdIsTextEditableTarget(target)` helper that
  excludes the non-text input types so the shortcut still fires
  on checkbox focus.
* `netcanon/templates/base.html` — three additions: the modal
  HTML (placed alongside `_config-viewer`), modal CSS in the
  shared `<style>` block (uses `var(--surface)` / `var(--shadow-
  modal)` tokens so the modal re-themes correctly under dark
  mode), and a `{% include "_partials/kbd-cheatsheet.js" %}` line
  alongside the other partials.  Theme-toggle CSS was extended to
  share the rule set with the new trigger button (one selector,
  two IDs).
* `tests/integration/test_kbd_cheatsheet.py` (new) — **20 tests**
  in four classes: `TestCheatsheetTestids` (parametrized over 8
  base-inheriting routes × 9 expected testids), `TestCheatsheetTriggerButton`
  (4 tests on the nav-bar `?` button — onclick wiring, aria-label,
  tooltip-mentions-shortcut, position relative to theme toggle),
  `TestCheatsheetContent` (4 tests asserting the documented keys
  appear in each section's `<dl>` — the cheatsheet IS the docs, so
  drift between actual shortcuts and copy is a real risk),
  `TestCheatsheetWiring` (4 tests on the JS plumbing: open/close
  functions present, `?` listener registered, modal hidden by
  default, input-focus guard present).
* `tests/testid_reference.md` — added the trigger-button row to
  the navigation table + a new "Keyboard shortcut cheatsheet
  modal" subsection listing all eight modal testids.

#### Design decisions

* **Trigger button placement** — between the page-nav cluster and
  the theme toggle, not at the end of the right rail.  Reasoning:
  the `?` is a discoverability lever for first-time operators; the
  theme toggle is something existing operators already know about.
  Putting `?` first in the right rail (reading left-to-right)
  catches the eye sooner.
* **Modal styling reuse** — share the `cv-nav-btn` class for the
  close button and the `_config-viewer` backdrop pattern.  One
  more modal in the app should look like every other modal; the
  alternative — a custom shell — would have added visual variance
  for no operator benefit.
* **`?` as toggle, not just open** — pressing `?` while open closes.
  Matches the asymmetric-key convention from Gmail / GitHub
  shortcut palettes; alternative was open-only with explicit Esc
  to close, but the toggle is more discoverable.
* **No `aria-keyshortcuts`** — the W3C-recommended attribute is
  partial-support across screen readers; the redundant `title`
  attribute on the trigger button (`Keyboard shortcuts (?)`) is
  more reliable.

#### Verification

Full test sweep passed (3473 tests, exit code 0) skipping one
pre-existing flaky e2e test (`TestThemeToggle::test_body_
background_reflects_theme[chromium]` fails on stashed pre-R7 main
too — flagged for post-launch investigation, not blocking this
round).  Manual verification: `?` keypress on every page opens
the modal; `?` again closes; Esc closes; clicking outside the
inner box closes; nav-bar `?` button opens; typing `?` inside the
config-viewer search input is treated as literal text (no modal
hijack).



Mid-Phase-3 doc-staleness sweep.  All Phase 3 PRs shipped
with CHANGELOG entries — that part of the discipline held.  The
**supporting docs that describe phase state and the sanitize
workflow** drifted because the rounds shipped fast enough that the
narrative docs hadn't been updated since the audit kicked off.

Three docs updated; zero code touched.

#### `docs/RELEASE_PLAN.md` — Phase 3 status block

The status block listed Phase 3 as *"Remaining MUST-tier work"* with
a single bullet pointing at the "Pre-launch quality hardening"
section below — that wording was accurate when the section was
written, but obsolete after rounds 1, 2, 1.5, 3, 3.1, 4, 4.1, 4.2,
5, 6, and 6.1 shipped.

Updated to:

* Add **Phase 3 (in-flight, 9/13 rounds shipped)** as a shipped
  phase entry, with a per-round bullet listing every PR with a
  one-line summary of what it closed.
* Add four new entries to "Remaining MUST-tier work before public
  flip": Round 7 (kbd cheatsheet), Round 8 (jobs-dict eviction),
  Round 9 (runtime checks — browser-compat + memory smoke), and
  Round 10 (cut v0.1.0-rc7 + finalize CHANGELOG / RELEASE_PLAN).
* Include cumulative test-count delta (3266 → 3453 tests) so the
  scope is visible without grep-archaeology.

The "Pre-launch quality hardening" section narrative below stays
unchanged — it's accurate as a description of *what was audited*;
the rounds listing above shows *what got addressed*.

#### `README.md` — sanitize-UI mentions in three places

Round 6 added the `/sanitize` browser page but the README's three
sanitize mentions (trust-signal paragraph, pip-install section,
bug-report workflow) still pointed only at the CLI / API.  Each
now mentions all three invocation paths (Browser UI / CLI / HTTP
API) with the UI flagged as the easiest when the server is running.

#### `BUG_REPORTING.md` — restructure to three sanitize options

Pre-update the doc had two options ("CLI no-server-required" / "HTTP
API for running-server").  Post-Round-6 it has three.  Restructured:

* Intro paragraph names all three paths up-front (Browser UI / CLI /
  HTTP API) with the "all share one library" reassurance.
* **Option A — Browser UI** now leads, with concrete walkthrough
  steps (source vendor → input mode → optional dry-run → submit)
  matching what the sanitize.html page actually renders.  Calls
  out the safety banner explicitly so operators know placeholders
  aren't redeployable.
* **Option B — CLI** (was Option A) — unchanged content, renumbered.
* **Option C — HTTP API** (was Option B) — unchanged content,
  renumbered.
* "Both paths call the same library" → "All three paths call the
  same library".

Sanitize-categories table extended with the two new R6.1 entries
(`local-user-name` → `localuserN`, `snmpv3-user-name` → `snmpv3userN`)
+ Round 6.1's wording-improvement on the existing `local-user-hash`
row.

#### Audited but not changed

Cross-checked against the AGENTS.md doc-sync table to see what else
might need updating:

* **AGENTS.md doc-sync table itself** — my new modules
  (`netcanon/api/_errors.py` R3, `netcanon/migration/codecs/_input_shape.py`
  R4.2) carry full module docstrings; new tests landed in the
  existing tiers; testid_reference.md got sections for the new
  sanitize page testids in the Round 6 PR + R6.1 updates in the
  username-redaction PR.  No
  new doc-sync rule needed.
* **`ARCHITECTURE.md`** — the operator-error translator (R3) and
  input-shape helper (R4.2) are tactical helpers, not architectural
  changes; their module docstrings carry the design rationale.
  Would be the right place if the translator pattern gets reused
  by a third route someday.
* **`docs/TROUBLESHOOTING.md`** — the new operator-facing error
  messages (Rounds 1, 3, 3.1) are self-explanatory by design — they
  *are* the troubleshooting steps, embedded in the error text.  No
  new "common error patterns" rows needed.
* **`docs/HOW_WE_TEST.md`** — no methodology change; just more
  tests added within the existing unit / integration tiers (3266
  → 3453).  Test count is by deliberate convention *not* in the
  doc body (per the "no hard-coded counts in prose" hard rule);
  the doc points at CI output as source of truth.

Other docs unchanged: `CAPABILITIES.md`, `COMPARISON.md`,
`IDENTITY.md`, `METHODOLOGY.md`, the per-vendor pages, the
walkthroughs — none referenced by Phase 3 polish work.

Full unit + integration suite green (3453 passed, 57 skipped — no
delta vs. post-R6.1 baseline since this is docs-only).

### Phase 3 — Polish pass, Round 6.1: username redaction + safety note

Defect surfaced by user during Round 6 visual verification.  The
sanitize library redacted password hashes (`local-user-hash`,
`snmpv3-auth`, `snmpv3-priv`) but left the **usernames themselves**
verbatim — including in the user's own test where the local-user
record name happened to mirror their workstation login.  In a
public bug-report context that's an operator-correlation vector:
"the operator at this org uses the same login on their workstation
and their network gear → cross-reference with public social
profiles".

User's diagnostic question — *"can we not make usernames within the
same class (iosuser1/2/3, snmpv3user1/2/3, etc) mapping to fields
iteratively with proper prefixing?"* — was exactly right.  The
existing `_SubstitutionTable` already does cross-reference-stable
per-class numbering for hashes (`fake-hash-0001`, `fake-hash-0002`,
…) and IPs (RFC-5737 docs ranges cycled across three /24s); usernames
are a clean extension of the same pattern.

#### Library — `netcanon.tools.sanitize`

Two new categories in the dispatch table + two new methods on
`_SubstitutionTable`:

* **`local-user-name`** — `CanonicalLocalUser.name` redacted to
  `localuser1`, `localuser2`, …  Applies across all CLI vendors
  with local-user accounts (Cisco IOS-XE, IOS, Junos, FortiOS,
  Aruba AOS-S, MikroTik, Arista EOS, OPNsense).  Per-class counter
  starts at 1 each session.
* **`snmpv3-user-name`** — `CanonicalSNMPv3User.name` (USM
  securityName) redacted to `snmpv3user1`, `snmpv3user2`, …
  Independent counter from local-user-name (a config with one of
  each produces `localuser1` + `snmpv3user1`, NOT `localuser1` +
  `snmpv3user2`).

**Cross-reference stability via dict-keyed cache** — same input
name always maps to the same placeholder across the whole config.
If a local user is referenced from another stanza (AAA, sudo, role
assignment, SNMPv3 trap-target group membership), the rename
applies consistently.  Test `test_duplicate_input_names_collapse_to_same_placeholder`
pins this.

#### UI — `netcanon/templates/sanitize.html`

* **Operator-facing safety note** added below the page intro
  paragraph: *"For sharing only. Placeholders (`device-1`,
  `localuser1`, `fake-hash-0001`, etc.) are intentionally
  non-functional — pasting the sanitized output back onto a live
  device would set real passwords / usernames to literal placeholder
  strings."*  Carries `data-testid="sanitize-safety-note"` so
  Round-7+ tests can pin its presence.  Addresses the
  secret-type-0-placeholder hazard surfaced during Round 6 (a
  sanitized `secret 0 fake-hash-0001` line would be interpreted by
  Cisco as a cleartext password of literally `fake-hash-0001`).

* **`CATEGORY_LABELS` mapping extended** with operator-readable
  labels for the two new categories ("Local user names",
  "SNMPv3 user names").  Lookup is per-class so the counters
  strip + audit table render the new categories naturally.

#### Tests

`tests/unit/tools/test_sanitize.py` — 8 new tests:

* `TestLocalUserNameRedaction` (5 tests): single-user → `localuser1`,
  multiple-users iteratively numbered, duplicate names collapse to
  same placeholder (cross-reference stability), empty name not
  redacted (no spurious substitution row), name + hash substitutions
  independent.
* `TestSNMPv3UserNameRedaction` (3 tests): single-v3-user →
  `snmpv3user1`, multiple v3 users iteratively numbered, per-class
  counters independent of local-user-name counter.

Zero existing sanitize tests broken — all 29 prior assertions
continue to pass (none pinned "name stays unchanged" since
pre-R6.1 the library never touched the name field at all).

Full unit + integration suite green (3453 passed, 57 skipped — up
8 from 3445 post-R6 baseline thanks to the 8 new tests).

#### What's still NOT redacted (deliberate scope decision)

* **Interface names** (e.g. `GigabitEthernet1/0/1`, `Vlan10`) —
  operator-meaningful for verifying the round-trip; redacting them
  would break config structure intelligibility.  Not PII.
* **VLAN numbers, port-channel numbers, AS numbers** — non-PII
  structural constants.
* **VRF names** — usually generic (`Mgmt-vrf`).  Could carry
  operator identity in rare cases (`CompanyName-Mgmt`); deferred
  pending real-world feedback.
* **DHCP pool names** — usually generic (`LAN`, `GUEST`).  Same.

These are noted in the module docstring's "Limitations" section as
known carry-throughs.

### Phase 3 — Polish pass, Round 6: Sanitize UI page

The Phase-3 audit flagged **sanitize-has-no-UI** as a HIGH-severity
feature-discovery gap before v0.1.0 launch.  Today the sanitization
library is reachable three ways:

* `POST /api/v1/sanitize` (multipart, JSON or text response depending
  on `dry_run` flag)
* `netcanon sanitize` CLI subcommand
* `python -c "from netcanon.tools.sanitize import sanitize_text; …"`

Operators following the README's "before you file a bug report,
sanitize your config" guidance had to choose between curl
gymnastics and CLI installation steps.  Round 6 ships a proper
`/sanitize` page mirroring the migrate page's input-mode idioms
(paste raw / pick stored) so operators familiar with one surface
immediately understand the other.

#### `netcanon/templates/sanitize.html` — new template

Structure mirrors `migrate.html` deliberately — same `.form-group`
+ input-mode radio + result-region pattern — but **simpler** (no
Tier-3 rename modal, no fit-check, no compatibility-guard).
Operator-facing copy at the top, source-vendor select + input-mode
radio + textarea/filename picker + dry-run checkbox + submit
button.  Result panel shows status summary, per-category counter
strip, sanitized output `<pre>` (with copy + download buttons),
and a substitution audit `<table>` (category, field, original,
redacted).

Scoped CSS uses `.san-*` prefix to avoid coupling sanitize's look
to migrate's; 30 LOC of duplicated styles, intentional separation.

Form-field tooltips follow the Round-5 discipline (every input has
a `title=`).

#### Dual-fetch on submit — Option B from the design

The sanitize API returns one of two shapes depending on `dry_run`:
`text/plain` (sanitized config + `X-Netcanon-Substitution-Count`
header) or `application/json` (substitution audit).  Operators
consistently want **both** views — *what changed* AND *the final
text*.  Three implementation options were on the table:

* (A) Match API exactly: dry-run checkbox controls mode; operator
  submits twice to see both.  Simple, but ergonomically awkward.
* (B) Dual parallel fetch — when dry-run is unchecked, fire both
  calls in parallel and join the results client-side.  Operator
  sees both views in one round-trip.  ~30 extra LOC of JS, no
  API change.
* (C) Extend the API to return both shapes in a single response.
  Cleanest long-term, but breaking API change for a UX detail.

Chose **(B)**.  On submit:

* If `dry_run` is checked → one fetch (audit-only); output section
  hides.
* If `dry_run` is unchecked → two fetches in parallel via
  `Promise.all`.  Audit result populates the counter strip + audit
  table; non-dry-run result populates the sanitized-output `<pre>`.

Either call failing surfaces via `formatApiError` + `showToast` —
same pattern as every other form (Round-1.5 helper).

#### Operator-readable category labels

The library emits machine-style category strings (`ipv4-public`,
`snmpv3-auth`, `local-user-hash`); the template ships a
`CATEGORY_LABELS` dict that maps each to human prose ("Public
IPv4 addresses", "SNMPv3 auth passphrases", "Local user password
hashes").  Matches Round-2 vocabulary discipline.  Unknown
categories (future library additions) fall through to the raw key
so the counter strip never renders blank text.

#### Route + nav plumbing

* New `GET /sanitize` route in `netcanon/api/routes/ui.py` (10
  LOC including docstring).
* Nav link added to `base.html` between Migrate and API Docs
  (the "tools" cluster — Migrate is the other tool).
* Same anchor added to the Swagger UI's wrapped nav-bar
  (`ui.py:432-443`) so operators on `/docs` keep the link too.

`netcanon/main.py` registration unchanged — `ui_router` is already
mounted.

#### Test coverage

`tests/integration/test_sanitize_page.py` — 10 new tests:

* **Page route** (4): returns 200, returns HTML, all 21
  required testids present, source-vendor select carries
  `required`.
* **Nav link** (4): present on dashboard, active on `/sanitize`,
  not-active on other pages, present in Swagger-wrapped nav.
* **Stored-config dropdown** (2): seeded records appear as
  `<option>`s with their device_type label; empty-storage path
  still renders the placeholder.

Submit-flow / form interactivity coverage stays with the
existing `tests/integration/test_sanitize_api.py` (the API is
tested; the page just renders + posts FormData).

#### `testid_reference.md` — new Sanitize section

Documents all 23 sanitize-page testids with element type + notes.
Explains the dual-fetch design choice inline so a future
contributor reading the section understands why submit triggers
two HTTP calls when dry-run is unchecked.

#### Scope (delivered vs. estimated)

Recon agent estimated 440-570 LOC total.  Actual: ~570 LOC
across template (370) + route handler (29) + tests (130) +
testid_reference section (52) + nav-link (3 lines).  At the
high end of the estimate; the dual-fetch JS was slightly
heavier than the recon's single-fetch baseline.

Full unit + integration suite green (3445 passed, 57 skipped —
up 10 from 3435 post-R5 baseline thanks to the 10 new
sanitize-page tests).

### Phase 3 — Polish pass, Round 5: tooltip pass on form-heavy pages

The Phase-3 audit flagged the backup form (`index.html`) and the
schedules form (`schedules.html`) as **HIGH-severity** tooltip gaps —
zero `title=` attributes across 7 + 4 form fields, leaving operators
to guess what each field meant.  The migrate page (best-tooltipped
before this round) had Source / Target adapter selects without
format-specific guidance.

Round 5 ships **21 tooltips** across four templates, all on
input / select elements so they trigger on hover.  Each tooltip is
written to answer one of three operator questions: *"what is this
for?"*, *"what's a valid value?"*, *"what happens if I leave it blank
or use the default?"*  Capped at ~200 chars per tooltip so browsers
don't wrap them weirdly.

#### `index.html` — backup form (7 tooltips)

| Field | Tooltip |
|---|---|
| Device Type | "Vendor + OS family that determines how Netcanon connects (Paramiko shell vs Netmiko), what config command to run, and how to parse the output." |
| Host / IP | "IPv4 (192.168.1.1), IPv6 (2001:db8::1), or RFC-1123 hostname (core-sw-01.example.com)." |
| Username | "SSH login. Common defaults: admin (most vendors), netadmin (Aruba), root (OPNsense). Use a service account in production." |
| Password | "SSH password. Encrypted at rest with Fernet — see SECURITY.md → Credential Storage for the resolution chain." |
| Enable Password | "Privileged-mode (enable / superuser) password. Only required when the selected Device Type's definition sets `needs_enable=true` (Cisco IOS, FortiOS, …). Auto-shows when needed." |
| Port (collapsed under ⚙) | "TCP port for SSH. Defaults to 22. Change only if the device runs SSH on a non-standard port." |
| Save as device profile | "Optional friendly name. When set, the entry is persisted to /devices and pre-selectable in the Device Profile dropdown above for future backups." |

#### `devices.html` — create form (7 tooltips)

Same 7 fields as `index.html`'s backup form plus a Notes-field
tooltip ("Free-text notes attached to the profile (rack location,
owner, change ticket, etc.). Not used by the backup pipeline.").
The 5 overlay-pin tooltips already shipping at
`devices.html:84, 87, 212, 216` are unchanged — they were the audit's
"good ratio" reference.  The edit form (10 fields, separate
section) is deliberately left untouched: operators editing an
existing profile already understand the schema.

#### `schedules.html` — 5 tooltips (3 fields + 2 section headings)

| Element | Tooltip |
|---|---|
| Schedule Name | "Shown in the schedules table and in job logs. Pick something a second operator would recognise without context." |
| Run every (preset) | "How often the schedule fires. Cron expressions aren't supported — pick a preset or enter minutes via 'Custom…'." |
| Custom minutes input | "Minutes between runs (1 to 43,200 = 30 days). Schedule fires as soon as the interval elapses after the previous run." |
| "Target by Device Type" H3 | "Match-by-vendor: every saved Device Profile whose Device Type is checked here runs when the schedule fires. Unioned with 'Target Specific Devices' below." |
| "Target Specific Devices" H3 | "Match-by-profile: explicitly listed profiles always run. Use for one-offs or to single out a device from a type-wide schedule." |

The two H3 tooltips fold the previously-inline `<span>` hints into
hover detail — the inline hints stay too (they're free-floating
column-format docs that don't need a hover) but the H3 tooltip
explains the *union* semantics the inline span can't fit.

#### `migrate.html` — Source / Target adapter selects (2 tooltips)

Recon-agent recommendation from Round 4: format-specific guidance on
the adapter labels is more useful than a paragraph of intro text
above the form (which is why Round 4 Sub-task C kept the intro
short and deferred richer guidance to here).

| Element | Tooltip |
|---|---|
| Source adapter | "What format your input is in. Cisco IOS-XE expects CLI text (show running-config); OPNsense expects XML (config.xml); Junos expects 'set'-form or block-form; FortiOS expects CLI (show full-configuration)." |
| Target adapter | "What format Netcanon renders. Same-vendor (round-trip) and cross-vendor both supported. Capability gaps + Tier-3 sections that don't translate are surfaced inline after the run." |

#### Zero behavioural changes

Pure additive `title=` attributes; no JS, no testids changed, no
labels reworded.  Full unit + integration suite green (3435 passed,
57 skipped — no delta vs. post-R4.2 baseline because this is HTML-
attribute-only work).

### Phase 3 — Polish pass, Round 4.2: input-shape helper + UI defense-in-depth

Defect surfaced during the visual sanity check of Round 4 / 4.1.  When
the operator picked a stored OPNsense XML config and submitted it
through the `cisco_iosxe_cli` source codec, the result came back as
`status: completed` with **zero supported / lossy / unsupported paths**
and a near-empty rendered output (`Building configuration… / service
timestamps … / end`).  No `ParseError` was raised, so Round-4's
Sub-task A "Did you mean: <vendor>?" suggestion (which gates on
`job.error.startsWith('parse failed')`) **never had a chance to fire**.

#### Root cause: `stripped.startswith("<")` doesn't tolerate framing junk

Every CLI-flavoured codec (cisco_iosxe_cli, fortigate_cli,
juniper_junos, mikrotik_routeros, arista_eos, aruba_aoss) had an
inline guard in `parse()` (and a matching one in `probe()`):

```python
stripped = raw.lstrip()
if stripped.startswith("<") or stripped.startswith("{"):
    raise ParseError("looks like XML or JSON, not ...")
```

That `stripped[0]` check is **too strict for real captures**.  The
operator's OPNsense XML file led with `cat /conf/config.xml\r\r\r\n`
— shell-command echo from the backup collector's session capture
— and the actual `<?xml` opener wasn't until line 2.  Same gap
exists for any input prefixed with:

* OPNsense / FreeBSD shell capture (`cat ... | head`)
* Cisco-IOS session transcript (`router# show running-config / Building configuration... / !`)
* Operator MOTD / banner before the actual config body

The parser silently fell through to its line-scanning loop, found
nothing it recognised, and returned an empty `CanonicalIntent` →
zero supported paths → near-empty render.

#### New shared helper `netcanon.migration.codecs._input_shape.detect_input_shape()`

Module-private utility — single export:

```python
detect_input_shape(raw: str, *, max_lines: int = 5) -> str | None
# returns "xml" | "json" | None
```

Scans the first `max_lines` **non-empty** lines (stripping leading
whitespace per line) for XML / JSON shape markers.  XML detection
matches either `<?xml` (the declaration) or `<name[separator]` (a
root element opener with a separator like space, `>`, or `/`).
JSON detection matches a leading `{` at start of stripped line.
Bounded scan keeps the helper O(1) for arbitrarily large inputs.

Specifically does **not** false-positive on:

* CLI descriptions / comments containing `<` mid-token
  (e.g. `description <upstream link>`, `! <-- BGP block below -->`)
* Junos block-form (`system { ... }`) — the bare `{` only appears
  inside a stanza, never at column 0 of a stripped first line

21 unit tests in `tests/unit/migration/test_input_shape.py` pin
the contract: clean XML / JSON / CLI inputs, framing-tolerant
captures, no-false-positive on CLI-with-angle-brackets, bounded
scan behaviour, edge cases (empty / whitespace-only / namespaced
XML / self-closing root).

#### Codec sweep — 12 file touches

All 6 CLI codecs migrated to the shared helper:

* `parse.py` (6 files) — replaces the strict
  `stripped.startswith(...)` guard with `detect_input_shape(raw)`.
  Per-shape error messages preserved (XML vs JSON specificity).
  Junos's `parse.py` keeps its special `{` handling for the
  block-form / set-form / curly-brace branch — only the XML
  check moved to the helper.
* `codec.py` `probe()` (6 files) — replaces the matching strict
  guard with the helper.  Same outcome (return `None` on
  XML / JSON), but tolerates framing junk consistently with
  `parse()`.

Two existing tests for cisco-input rejection had to be updated
because the error message wording is now per-shape ("looks like
JSON" rather than the older conjoined "looks like XML or JSON");
no functional regressions.  All five other codecs' existing
XML / JSON rejection tests already used per-shape wording
(`"looks like XML"` / `"looks like JSON"`) and passed unchanged.

New regression test
`test_xml_with_leading_shell_echo_rejected` pins the user-reported
scenario: an OPNsense XML prefixed with `cat /conf/config.xml\n`
must raise `ParseError` instead of silently producing an empty
intent.

#### UI defense-in-depth: detect-suggest also fires on empty-validation

Even with the parser sweep above, **other** permissive parsers in
the codebase could in principle produce the same
"completed-with-zero-supported" symptom (the OPNsense XML codec
itself, the cisco_iosxe NETCONF codec, etc.).  Extended Round-4
Sub-task A's trigger gate to cover this case:

```js
var isParseFailed = job.status === 'failed'
  && typeof job.error === 'string'
  && job.error.indexOf('parse failed') === 0;
var isEmptyCompleted = job.status === 'completed'
  && job.validation
  && (job.validation.supported_paths.length
    + job.validation.lossy_paths.length
    + job.validation.unsupported_paths.length) === 0;
if (isParseFailed || isEmptyCompleted) {
  enrichParseFailureBanner(body, job);
}
```

False-positive prevention is delegated to the existing downstream
gate in `enrichParseFailureBanner` — the suggestion only renders if
`/detect`'s top candidate is a **different** codec from the one
the operator submitted.  Legitimate empty-config translations
(operator deliberately translating a minimal stub) don't produce
spurious suggestions because the top candidate would match the
already-picked source.

`testid_reference.md` description for
`migrate-parse-failure-detect-suggest` updated to document the
two trigger conditions + the false-positive-prevention gate.

Full unit + integration suite green (3435 passed, 57 skipped — up
22 from the 3413 post-R4.1 baseline thanks to the 21 helper tests
and the new cisco-prefix regression test).

### Phase 3 — Polish pass, Round 4.1: detect-suggest also updates target on round-trip

Defect surfaced during live testing of Round 4.  Operator
landed on `/migrate` with the default source=Cisco + target=Cisco
selection, picked an OPNsense XML config via the "Pick a stored
config" mode, and was offered the "Use this source" suggestion by
the proactive auto-detect banner.  Click swapped source to OPNsense
— but left target on Cisco.  Result was OPNsense → Cisco translation,
which is a valid translation but not what the operator expected
(they were trying to inspect the OPNsense config, not translate it
across vendors).

The same defect applied to my Round-4 Sub-task A "Switch source to
\<vendor\> and retry" button (`migrate-parse-failure-detect-suggest`)
— inherited the source-only logic from the proactive banner.

Root cause: both buttons did `sourceSel.value = codec` and nothing
to target.  The signal that the operator intended a round-trip
translation (and would want both halves of the pair swapped on
detect-suggest acceptance) is `target === source` *before* the click
— that means they picked the same codec for both halves and either
deliberately wanted same-vendor processing or hadn't yet set a
deliberate target.  Cross-vendor intent
(`target !== source` before the click) means the operator
deliberately picked a different translation destination and must
not be overwritten.

#### New shared helper `applyDetectedCodec(codec)`

Single function that both detect-suggest buttons now call:

1. Sets `sourceSel.value` to the detected codec
2. Dispatches a `change` event (adapter-info / compat-banner side
   effects mirror operator-driven dropdown changes)
3. **If `targetSel.value === prevSource && targetSel.value !== codec`** —
   the round-trip-intent gate — also updates `targetSel.value` and
   dispatches its `change` event
4. Returns `{changedTarget: boolean}` so callers can adapt their
   toast / button text

Behaviour table:

| Pre-click source | Pre-click target | Detected codec | Post-click source | Post-click target |
|---|---|---|---|---|
| Cisco | Cisco | OPNsense | OPNsense | OPNsense *(flipped)* |
| Cisco | Aruba | OPNsense | OPNsense | Aruba *(preserved)* |
| OPNsense | Cisco | OPNsense | OPNsense | Cisco *(no change — already correct)* |

Two callers wired to the helper:

* `useDetectedSource()` (proactive auto-detect banner button, exists
  since pre-Phase-3) — toast now reads
  *"Source and target set to \<codec\> (round-trip)"* when both
  changed, *"Source set to \<codec\>"* when only source changed.
* The `migrate-parse-failure-detect-suggest` button (Round 4
  Sub-task A) — uses the helper before calling
  `form.requestSubmit()` so the re-submit goes against the
  smart-adjusted source+target pair.

`testid_reference.md` description updated to document the smart
target adjustment + the cross-vendor preservation rule.

Full unit + integration suite green (3413 passed, 57 skipped — no
change vs. post-R4 baseline; Round 4.1 is template-only).

### Phase 3 — Polish pass, Round 4: migrate-page UX cluster

Three sub-tasks targeted by the Phase-3 audit's migrate-page bucket,
shipped as a two-commit PR.  Commit 1 adds new behaviour (detect on
parse failure); Commit 2 is pure copy work (4 empty-state banner
rewrites + header text).  Total ~140 LOC across one template + one doc.

#### Detect-on-parse-failure: one-click recovery from wrong-codec paste

Common operator mistake: paste an OPNsense XML config into the
`cisco_iosxe_cli` CLI codec, hit Translate, get back a red banner
saying *"Error: parse failed: cisco_iosxe_cli: input looks like XML
or JSON, not IOS CLI."*  Today the operator has to (1) read the
message, (2) guess which codec is right, (3) manually swap the source
dropdown, (4) click Translate again.

The `/detect` endpoint (which probes a config's first 500 bytes
through every registered codec and returns ranked candidates) already
existed and is wired to the textarea's input handler to populate the
green *"Auto-detected: Cisco IOS-XE 87% confidence"* banner above the
form.  But it was never called *reactively* after a `/plan` parse
failure — the proactive auto-detect doesn't fire if the operator
pastes-and-immediately-submits before the 350ms debounce elapses, and
the wrong-codec error path had no second chance.

New JS helper `enrichParseFailureBanner(planBody, job)` called from
the migrate-form `onSubmit` when `job.error` starts with
`"parse failed"`.  It POSTs the same input shape (XOR `raw_text` /
`source_filename`) to `/detect` with `min_confidence=40` (matching the
proactive threshold), and if the top candidate's codec differs from
the source the operator submitted, appends a *"Did you mean:
<vendor>?"* suggestion inline below the parse-error banner.  The
suggestion includes:

* The human-readable vendor display name (looked up via
  `adapterEntry()`)
* A confidence-percentage chip
* The `/detect` `reason` line (e.g. "first 500 bytes contain
  `<opnsense>` XML root element")
* A *"Switch source to <vendor> and retry"* button (new testid
  `migrate-parse-failure-detect-suggest`, carries
  `data-suggested-codec`) — click swaps the source dropdown,
  dispatches a `change` event (so adapter-info side effects fire),
  and calls `form.requestSubmit()` to re-run `/plan`.  Two clicks
  collapse to one.

Best-effort + silent-fail: any `/detect` network blip, 5xx, or empty
candidate list leaves the original parse-error banner untouched.
Operator never sees a worse experience than pre-Round-4.

Why client-side instead of server-side wire-up (`/plan` auto-runs
`/detect` on failure, returns candidates inside `MigrationJob`):

* Zero `MigrationJob` schema churn — adding a UX-only
  `detect_candidates` field would pollute a model that's also
  consumed by the deploy-side pipeline downstream
* Failure isolation — if `/detect` is slow or flakes, the
  parse-failure banner still renders synchronously
* Code reuse — shares `adapterEntry()` lookup and banner-append
  patterns with the existing `renderDetectBanner()` machinery

`/plan` and `/detect` endpoint contracts unchanged.

#### Four empty-state banners rewritten in Tier-3 voice

Per-pane *"No X found in the source config."* banners on the rename
modal (VLANs / local-users / SNMP community / SNMPv3 users) stated
the fact but never explained whether that's a problem or what to
do about it.  Operators chasing phantom bugs — or worse, retrying
the same wrong-vendor paste — were the failure mode.

The Tier-3 detection banner on the same page (gold standard for
operator-facing tone) always couples fact to action: *"<fact>.
Operator must apply them manually on the target device."*  Round 4
brings the four empty-state banners into that voice:

* **VLANs pane** — names the most common cause (wrong codec) and
  the recovery path (auto-detect banner above), plus the legitimate-
  empty case (VLAN database in a separate file)
* **Local-users pane** — distinguishes the legitimate-empty case
  (TACACS+ / RADIUS-only devices) from the missing-config case
  (separate AAA file)
* **SNMP community pane** — points at the sibling SNMPv3 pane when
  the operator picked the wrong protocol version, plus the
  separate-management-stanza-file case
* **SNMPv3 users pane** — mirror of the SNMP-community hint, plus
  the cross-vendor v3 re-key reality already documented in
  `migration.py:520-526`

No testid changes; no JS partial changes; no behaviour changes —
pure HTML-string replacements at lines 646 / 678 / 704 / 728 of
`migrate.html`.  Grep verified zero existing test assertions on the
old literal strings outside `tests/testid_reference.md` descriptive
prose (and those descriptions document the testid + element type,
not the visible text).

#### Migrate-page header: drop the developer leak

The migrate-page subtitle ended with *"Backed by `POST
/api/v1/migration/plan`"* — useful to a developer skimming for the
endpoint, useless / mildly confusing to an operator who just wants
to translate a config.  Rewrote the paragraph to be entirely
operator-facing:

> Pick a source vendor (or paste your config and let auto-detect
> suggest one), pick a target, and Netcanon translates the canonical
> config across vendors.  Capability gaps and Tier-3 sections (ACLs,
> NAT, QoS, route-maps, IPsec) are surfaced inline after translation
> so you can apply them manually on the target device.

Three lines replacing three lines; no HTML structure change.  Rich
inline help (tooltips on the source / target labels with format-
specific guidance — e.g. "Cisco IOS-XE expects CLI text; OPNsense
expects XML") is deliberately deferred to the Round 5 tooltip pass,
where it belongs.

### Phase 3 — Polish pass, Round 3.1: Netmiko collapses-all-errors hotfix

Defect surfaced during live testing of Round 3 against three bad-host
scenarios.  All three produced the same Netmiko-timeout message
instead of three distinct DNS / refused / unreachable translations:

* `not.a.real.host.invalid` →
  `[…] Connection or command-read timed out — the device is slow to respond…`
  *(should have been "DNS lookup failed — verify the hostname spelling")*
* `192.0.2.1` (RFC-5737 unreachable) →
  *same Netmiko-timeout message*
  *(should have been "Connection timed out — host unreachable")*
* `127.0.0.1:1` (refused) →
  *same Netmiko-timeout message*
  *(should have been "Connection refused — SSH is not running on the
  target port")*

Root cause: Netmiko's `establish_connection()` (`base_connection.py:1168`)
catches `socket.gaierror`, `paramiko.NoValidConnectionsError`, and
`TimeoutError` then re-raises a single `NetmikoTimeoutException` with
a generic *"TCP connection to device failed. Common causes are: 1.
Incorrect hostname or IP address. 2. Wrong TCP port. ..."* message —
useful for shell users, useless for operators reading a single-line
toast.  Round 3's translator hit the wrapped `NetmikoTimeoutException`
and never inspected the chained underlying exception, so all three
scenarios collapsed to the same fallback message.

Python preserves the original exception via the implicit
`BaseException.__context__` attribute set automatically when you
`raise NewError(...)` inside an `except` block.  The fix is a
two-piece change to `netcanon.api._errors`:

* **New helper `_netmiko_underlying_cause(exc)`** that walks the
  exception chain (prefers `__cause__` if explicitly set via `raise
  ... from ...`, falls back to `__context__`) and returns the
  immediate underlying exception.  Walks one level only — deeper
  recursion would risk picking up unrelated context from outer
  try/except blocks.

* **`_humanize_netmiko_timeout()` now peeks `__context__`** and
  dispatches via `isinstance` checks to the per-cause formatter:
  `gaierror` → DNS-failure message, `NoValidConnectionsError` /
  `ConnectionRefusedError` → refused message, `TimeoutError` →
  host-unreachable message.  When the chain is empty or has a type
  we don't specifically handle (e.g. a real read-timeout AFTER a
  successful connect), the original generic "Connection or
  command-read timed out" message is preserved — that's still the
  right message for read-side timeouts.

* **New top-level mapping for `paramiko.NoValidConnectionsError`**
  in the dispatch table.  Listed before `ConnectionRefusedError`
  because `NoValidConnectionsError` is a `SSHException` subclass,
  not a `ConnectionRefusedError` subclass.  Covers the
  `paramiko_collector` code path (OPNsense) where the exception
  reaches the translator directly without Netmiko wrapping.

Five new regression tests in `tests/unit/test_api_errors.py` pin the
fix: each of the three wrapped scenarios produces the right per-cause
message + a no-chain case asserts the generic fallback is preserved
for legitimate read-timeouts + direct-path `NoValidConnectionsError`
hits the standalone mapping.  Interactive proof against the actual
exception types matches the test assertions exactly.

Total: ~70 LOC in `_errors.py`, ~100 LOC of new tests, zero existing
tests broken (full suite: 3413 passed, 57 skipped — up 5 from the
post-Round-3 baseline of 3408).

### Phase 3 — Polish pass, Round 3: backup-error translator layer

Phase-3 audit's headline finding: every operator-visible failure on
the backup-execute surface funneled through a single
`except Exception` at `netcanon/api/routes/backups.py:355` that did
`result.error = str(exc)[:500]`.  Quality varied wildly:

* **GOOD** — in-house `ValueError` (50MB cap, missing
  `netmiko_device_type` field) raised with carefully-chosen messages.
* **OPAQUE** — `socket.timeout`'s empty `str`, `socket.gaierror`'s
  bare `[Errno -2] Name or service not known`.
* **LEAKY** — `NetmikoAuthenticationException`'s 10+ line "see also"
  troubleshooting block crammed into a single toast;
  `paramiko.SSHException`'s wildly inconsistent messages; `OSError` on
  save exposing the server's internal filesystem paths to the
  operator (privacy / security).

Round 3 introduces a tight translator layer that collapses this
surface into a host-prefixed, single-line operator-readable message
per exception type — without changing the API contract, the server
log, or any other route.

#### `netcanon.api._errors.translate_backup_error()` — new module

The translator lives in `netcanon/api/_errors.py` (module-private name
on purpose — there is no use case for this outside `routes/backups.py`,
and a public name would invite unrelated callers to plug in and dilute
the mapping).  Signature:

```python
translate_backup_error(exc: BaseException, *, host: str,
                       step: str = "collect") -> str
```

Returns a string in the form `"[<host>] <translation>"`.  Caller is
responsible for the existing `[:500]` truncation that preserves the
`BackupResult.error` wire contract.

Mapping table is an ordered list of `(exception_type, formatter)`
pairs checked via `isinstance` — ordering matters because subclass
relationships matter (`ConnectionRefusedError` before generic
`OSError`; `NetmikoAuthenticationException` before any future
re-parenting of the Netmiko hierarchy).  Thirteen specific mappings
plus a typed fallback covering: paramiko auth, netmiko auth, netmiko
timeout, paramiko SSH (banner / no-kex / etc), `socket.gaierror`,
`ConnectionRefusedError`, `ConnectionError` (reset / broken pipe /
aborted), `TimeoutError` (also `socket.timeout` on Py ≥3.10),
`UnicodeError`, in-house `ValueError` passthrough, `OSError`
storage-write, internal-bug catch (`KeyError` / `AttributeError`),
fully-unknown fallback.

The `step="collect"` argument is reserved for the planned follow-up
that splits the single try/except into per-phase blocks (connect /
collect / save).  Carried in the signature today so call sites can be
written against the final shape without churn.

#### Why a function, not a FastAPI exception handler

The failing code runs inside a `BackgroundTasks` worker thread (see
`_run_backup_job` → `ThreadPoolExecutor`), **not** in an HTTP request
lifecycle.  FastAPI's `@app.exception_handler` only fires on the
request stack; it cannot intercept exceptions inside a background
worker that writes to `BackupResult.error`.  A function call from the
broad-except is the only shape that reaches the right code path.
This is documented in the module docstring so future contributors
don't re-litigate the choice.

#### Caller change: one line in `backups.py`

The broad-except now reads:

```python
result.error = translate_backup_error(
    exc, host=device.host, step="collect",
)[:500]
```

The full exception still goes to the server log via the existing
`logger.error(..., exc_info=True)` immediately below.  The diagnostic
trail is **preserved**; only the operator-visible text shape changed.

#### Test coverage — 70 new unit tests

`tests/unit/test_api_errors.py` is a green-field test module with no
existing assertions to displace.  Coverage:

* **16 parametrized exception types**, each asserted against four
  contracts (host-prefix, expected concept substring, single-line,
  under 500 chars).
* **6 targeted regression tests** for high-value invariants:
  storage-path-leak guard (OSError with `.filename` set must not
  reveal the server path), paramiko-multi-line trimming, ValueError
  passthrough preserving in-house messages verbatim, unknown-type
  fallback surfacing the class name for grep-ability, `socket.timeout`
  / `TimeoutError` alias parity on Py ≥3.10, `step` argument no-op
  forward-compat contract.

No existing tests broke: grep confirmed zero test assertions on the
operator-visible backup-error text pre-Round-3 (existing
`RuntimeError("simulated")` patterns asserted only on
`status == "failed"`, which is unchanged).

#### Out of scope (deliberately)

* `netcanon/api/routes/migration.py:172` — `LookupError → 404 detail=str(exc)`.
  `str(LookupError)` from `get_codec` is already operator-readable;
  the same `[host] ...` shape doesn't apply here (no host in the
  migration domain).  Could get a cosmetic touch in Round 4.
* `netcanon/services/migration_pipeline.py` — already prefixes its
  errors with `parse failed:` / `render failed:` / etc.  Different
  convention; not a copy quality bug.
* `netcanon/api/routes/configs.py:196` — already does the right thing
  after Phase 3 Round 1.  Referenced as a precedent in the module
  docstring.

### Phase 3 — Polish pass, Round 1.5: defects from visual sanity check

Two defects surfaced during a manual eyeballing of Rounds 1 + 2.
Bundled here as a small follow-up before continuing into Round 3
because one of them silently swallowed all helpful Pydantic validation
text across every form in the app — actionable error copy in
subsequent rounds would still render as `[object Object]` until this
JS surfacing layer is fixed.

#### Form errors no longer render as `[object Object]`

FastAPI returns validation errors in two shapes:

* `HTTPException` → `{"detail": "string"}`
* Pydantic 422  → `{"detail": [{"loc": [...], "msg": "...", "type": ...}, ...]}`

Every form's error handler did
`showToast('Error: ' + (err.detail || res.statusText), 'error')` —
which silently collapsed the Pydantic-422 array to JavaScript's
default `[object Object]` rendering.  Operators submitting a form
that triggered Pydantic validation (host validation, schedule
constraints, device-profile uniqueness, etc.) saw a literal
`Error: [object Object]` toast and had to read server logs to find
out what was actually wrong.

Discovered by submitting the New Device Profile form with
`not!a!valid!host` in the Host / IP field during the post-Round-2
visual sanity check — the just-improved host-validation error
("Invalid host '...': must be a valid IPv4 address, IPv6 address, or
RFC-1123 hostname.  Example: ...") never reached the toast because of
this stringification gap.

New helper `formatApiError(payload, statusText)` in `base.html`
detects both response shapes and normalises to a short
operator-readable line:

* String detail → use verbatim
* Pydantic 422 array → `<field>: <msg>; <field>: <msg>; ...` with
  the noisy `Value error, ` prefix stripped
* Empty / unknown → fall back to `statusText`

Updated all 12 affected call sites across `devices.html` (×4),
`schedules.html` (×3), `index.html` (×2), `configs.html`, `jobs.html`,
`migrate.html`, and `_partials/rename-apply.js` to call the helper
instead of doing the broken inline concat.  Two of those sites
(`migrate.html`, `rename-apply.js`) had a less-broken `JSON.stringify`
fallback that produced ugly-but-readable JSON; they now produce the
same clean output as the rest.

#### Definitions reload now refreshes the overlay registry

`POST /api/v1/definitions/reload` rebuilt `state.definitions` (the
family-base map consumed by backups) but did **not** rotate
`state.definition_loader` — the loader reference that the
`/definitions` page reads `_variants` from when rendering the
Version / Model Overlays section.

Operator-visible effect (verified manually during the visual sanity
check by moving an overlay YAML out of `definitions/` and clicking
"Reload from disk"): the toast said "Loaded N definitions" + the
backup flow correctly used the new bases, but the overlays section
on `/definitions` silently kept the *pre-reload* overlay set until
a full process restart.

Fix is a single line in `netcanon/api/routes/definitions.py` —
assign the freshly constructed loader to `state.definition_loader`
alongside the existing `state.definitions` assignment.

Two regression tests pin the fix:

* `test_reload_rotates_definition_loader_reference` — asserts the
  loader reference identity changes after reload (`after is not
  before`).  Cheap contract test.
* `test_reload_picks_up_new_overlay_on_disk` — end-to-end: drops a
  fresh overlay YAML, hits reload, asserts the new loader's
  `_variants` sees the overlay.

### Phase 3 — Polish pass, Round 2: vocabulary discipline

Driven by a Round-1 audit-followup mapping pass: catalogue every term
that names the same operator-level concept, pick one canonical per
concept, sweep labels.  The audit had flagged four concept-vs-term
collisions; mapping confirmed three were real and one was overflagged
(see below).  Zero API breaks — every JSON field, route, model class,
and YAML key on disk is preserved.

#### "Device Profile" — pick one term for the saved-credential concept

The same operator-level entity was named four different things across
the UI: "Saved Device" (`index.html:15`), "Saved Devices" (devices.html
page heading), "Save as Profile" (`index.html:69`), and "Profile Name"
(`devices.html:24`).  The model class is `DeviceProfile`, the JSON
key is `device_profile_id`, the URL is `/devices`.  Aligned to
**"Device Profile"** everywhere operator-facing:

* `index.html:15` "Saved Device" → "Device Profile"
* `index.html:69` "Save as Profile" → "Save as device profile"
* `devices.html:100` "Saved Devices" page heading → "Device Profiles"

"Profile Name" (the field-of-the-entity, like "User Name") stays —
it's grammatically the name *for* a profile, not the entity itself,
so renaming it would be wrong.

#### "Device Type" — backup-flow canonical (without conflating Migrate's "Adapter")

The backup flow labelled the same dropdown three different ways:
"Type" on `index.html:30`, `devices.html:29, 160`, `jobs.html:93`; the
config-list table header at `configs.html:11` already said
"Device Type".  Aligned everything to **"Device Type"** matching
`configs.html` (the most-trafficked column) and the existing
`device-type-select` testid:

* `index.html:30` → "Device Type"
* `devices.html:29` → "Device Type" (create form)
* `devices.html:160` → "Device Type" (edit form)
* `jobs.html:93` → "Device Type" (results table header)

Specifically rejected unifying to "Codec": the migrate subsystem
already uses "Adapter" coherently for its own (distinct) concept —
parse-only / render-only / bidirectional codec classes from
`netcanon/migration/codecs/`.  These are two registries, not one;
forcing one canonical across both would damage migrate's vocabulary.
The two flows stay lexically separate: backup uses "Device Type",
migrate uses "Adapter".

#### Host-validation error: actionable instead of "Invalid hostname or IP address"

`netcanon/models/validators.py:29` raised
`f"Invalid hostname or IP address: {v!r}"` — accurate but not
actionable.  Operator gets no hint of what *would* be valid.  Updated
to mirror the docstring (which already said "IPv4, IPv6, or RFC-1123
hostname") and add concrete examples:

    Invalid host '<bad>': must be a valid IPv4 address, IPv6 address,
    or RFC-1123 hostname.  Example: '192.168.1.1', '2001:db8::1', or
    'core-sw-01.example.com'.

#### Schedule frequency — audit overflagged, no change

The audit flagged `schedules.html:30` "Run every" vs
`schedules.html:110` "Interval" as a vocabulary collision.  They're
not — one is a form label (which works as a verb phrase) and the
other is a table-column header (which needs to fit in narrow column
width).  "Run every (mins)" is uglier than "Interval".  Left alone.

#### testid_reference.md docstring sync

Two `testid_reference.md` entries quoted the old strings as part of
their notes — updated to match the new canonicals.  Both `data-testid`
values themselves are unchanged.

### Phase 3 — Polish pass, Round 1: actionable editor errors + overlays empty state

First round of the [pre-launch quality hardening
phase](docs/RELEASE_PLAN.md#pre-launch-quality-hardening).  Driven by a
read-only audit across failure-mode quality, browser/UI polish, and
operator-facing copy quality.  The audit produced a punch list; this
round addresses the lowest-effort high-severity items that are pure
copy + template work and don't depend on the larger structural changes
(exception-translator layer, vocabulary discipline pass) coming in
subsequent rounds.

#### Actionable error copy on the editor endpoint

`POST /api/v1/configs/{filename}/open` returned four operator-hostile
errors that named implementation details instead of operator actions:

* `"open_in_editor is disabled on this server."` — gave the operator no
  hint of how to enable the feature.  Now names the
  `NETCANON_OPEN_IN_EDITOR` environment variable and the `.env` field
  explicitly, and reminds operators that the feature is intentionally
  off for remote deployments.

* `"File type not permitted for editor access."` — didn't name the file,
  the disallowed extension, or the allowed list.  Now reports the
  filename, the rejected extension, and the full allowed-extensions
  enumeration so the operator can rename or download instead.

* `"os.startfile is not supported on this platform."` — leaked the
  Python implementation detail to the operator.  Now reads "Cannot
  open files in the OS default editor on this platform (...).  Use
  the download link to fetch the file instead." with `sys.platform`
  for diagnostic context, no library names.

* `"Could not open file: <raw exception>"` — echoed the raw OSError
  text to the HTTP response (often a Windows path leaking server
  filesystem layout).  Now suppresses the underlying exception from
  the response detail (still logged server-side with `exc_info=True`)
  and points the operator at the server log.

A regression test (`test_open_returns_500_when_startfile_raises`) used
to *assert* that the raw exception text leaked through — that assertion
was pinning a privacy bug.  Updated to assert the new contract: the
filename appears in the detail, the "check server log" hint appears,
and the raw OSError string is explicitly absent.

#### Overlays section no longer disappears silently when empty

The Definitions page wrapped its Version / Model Overlays section in
`{% if overlays %}` — with zero overlays loaded (the default for most
install profiles), the section simply didn't render.  Operators
reading the page had no way to distinguish "no overlays exist" from
"section forgot to render / feature broken" — exactly the confusion
that produced the original "5 loaded but only 4 visible" user report
that motivated the overlay-rendering work in the first place.

The section now always renders.  When zero overlays are loaded, an
empty-state paragraph explains the consequence in concrete operator
terms ("all backups will use the family-base definition for their
device type") and tells the operator how to add an overlay ("drop a
YAML file under `definitions/` with an `os_version` or `model` field
to pin behaviour for a specific firmware revision").  When at least
one overlay is loaded, the original table renders unchanged.

A new `data-testid="no-overlays-msg"` is registered in
`tests/testid_reference.md`.  The previous test
`test_overlays_section_absent_when_no_overlays_loaded` (which pinned
the disappearing-section behaviour) is renamed to
`test_overlays_section_renders_empty_state_when_no_overlays` and
asserts the new always-renders contract.

### Fix: credential encryption now works in container / headless deployments

Surfaced by running the just-published Docker image locally and trying
to save a device profile via the UI: every save returned a
500 Internal Server Error.  Backtrace pointed at
`netcanon/security/credentials.py:42`:

```
keyring.errors.NoKeyringError: No recommended backend was available.
```

The Fernet key used to encrypt device passwords at rest was being
resolved exclusively via the OS keyring library.  The keyring library
needs a backend — Windows Credential Manager / macOS Keychain / Linux
SecretService daemon via dbus + libsecret.  **None of those exist
inside the slim-bookworm container.**  The `keyring` library installed
fine but its `fail.Keyring` shim raised the moment the encrypt path
hit it.

This was a deployment-pattern flaw, not a regression.  It meant the
Docker quickstart in the README (the recommended install) crashed at
the first device-save — the very first action an operator takes after
running the container.  `SECURITY.md:322` quietly acknowledged it as
"headless Linux is not a supported target", which contradicted every
other surface that pushes Docker as the primary install.

#### Three-tier Fernet key resolution

`credentials.py` now resolves the key in this order, first hit wins:

1. **`NETCANON_FERNET_KEY` env var** — operator-explicit.  Recommended
   for container / headless / production deployments.  Generate once
   with `python -c "from cryptography.fernet import Fernet;
   print(Fernet.generate_key().decode())"` and inject via your
   orchestrator's secret-injection mechanism.  Key never touches the
   application's data directory.
2. **OS keyring** — Windows Credential Manager (DPAPI) / macOS
   Keychain / Linux SecretService.  Default for desktop installs.
3. **File fallback at `$NETCANON_DATA_DIR/.fernet_key`** — auto-
   generated on first use when neither env var nor keyring is
   available (typical zero-config container).  Persists in the
   operator's bind-mounted data volume so subsequent restarts
   decrypt existing profiles.  Plaintext on disk but in the same
   volume the operator already trusts for jobs / schedules / device
   profile JSON.  Tier 1 is preferred for production.

The lookup is short-circuit: tier 2 is only attempted if tier 1 misses,
and tier 3 only if tier 2's backend raises.  Native desktop installs
keep the existing keyring behaviour with zero changes — the new tiers
only activate where the existing behaviour fails.

#### Implementation

* **`netcanon/security/credentials.py`**: rewrote `_get_fernet()` into
  a `_resolve_key()` helper that walks the three tiers, plus per-tier
  helpers (`_read_keyring`, `_write_keyring`, `_read_key_file`,
  `_write_key_file`).  Each helper isolates one failure mode so the
  control flow stays readable.  File writes attempt `os.chmod(0o600)`
  (POSIX) and silently no-op on Windows where chmod doesn't enforce.
  A `WARNING`-level log line announces tier-3 auto-bootstrap so the
  operator can spot the upgrade path to tier 1.

* **`tests/unit/test_credentials.py`**: new `TestEnvVarKey` class (env
  takes priority over keyring, env value is stripped of trailing
  whitespace, empty env var falls through) + new `TestFileFallback`
  class (writes key file when keyring raises `NoKeyringError`, loads
  pre-existing key file, full encrypt/decrypt round-trip with file
  fallback as the key source).

* **`tests/conftest.py`**: the autouse `_mock_keyring` fixture now
  also unsets `NETCANON_FERNET_KEY` for the duration of each test so
  a maintainer with the env var exported in their shell doesn't see
  mysterious test failures.  Tests that explicitly exercise the env-
  var tier set the var themselves via `monkeypatch.setenv`.

* **`.env.example`**: documented `NETCANON_FERNET_KEY` with the
  generation snippet and the trade-offs vs. file fallback.

* **`.gitignore`**: `.fernet_key` + `**/.fernet_key` — auto-generated,
  must never be committed.

* **`SECURITY.md`**: rewrote the "Credential Storage" section with
  the three-tier table, expanded the per-tier rationale, and updated
  the "Known Limitations" row that previously labelled headless Linux
  as unsupported (it's now a fully-supported deployment path).

* **`README.md`**: Docker quickstart now shows the env-var pattern
  with a one-liner key generator.  Operators following the README
  literally get the recommended production deployment shape.

#### Operator migration

Existing desktop installs are unaffected — tier 2 is hit first as
before, and the keyring path is unchanged.

Existing container deployments that were running pre-fix had no
way to save a device with a password, so there's no existing
encrypted state to migrate.  On first run with the new image the
container will either pick up `NETCANON_FERNET_KEY` (if set) or
auto-bootstrap `data/.fernet_key` and proceed normally.

Operators currently running unencrypted file fallback (this PR's
tier 3) who want to promote to tier 1 (env var, recommended): read
the existing key, set it as the env var on the next container start,
delete `data/.fernet_key`.

### Remove vestigial development artifacts

Cleanup of legacy / duplicate files left over from earlier project
phases that no longer add value (and in two cases were actively
misleading about which manifest is authoritative).

* **`Get-NetworkConfigs.ps1`** (553 lines) and
  **`Test-NetworkConfigs.ps1`** (347 lines) — the project's original
  PowerShell prototype, dating back to the first commit
  (`Initial commit: network config collector script and captured
  configs`).  Functionally superseded by `netcanon/collectors/` and
  the FastAPI app.  Deleted from tracked tree; historical CHANGELOG
  references kept (they document the rebrand-era rename sweep that
  also touched these files).
* **`HUMAN_TESTING.md`** (508 lines) — Claude-curated personal
  testing checklist whose own preamble said *"Assistant keeps this
  up to date as features ship"*.  Internal scratch material, not
  appropriate for the public repo.  The matrix-honesty discipline
  the project ships is documented in `docs/HOW_WE_TEST.md` /
  `tests/fixtures/real/RESULTS.md` / `PHASE4_RECONCILIATION.md`,
  not in a one-maintainer task queue.
* **`requirements.txt`** + **`requirements-dev.txt`** —
  dependency declarations that duplicated `pyproject.toml`'s
  `[project].dependencies` and `[project.optional-dependencies].dev`
  respectively.  Neither is referenced by the `Dockerfile` or any
  CI workflow; both pre-date the move to PEP 621-style pyproject
  metadata.  With Dependabot configured for `package-ecosystem:
  "pip"`, having both manifests present would generate duplicate
  update PRs against the same dependency set.  pyproject.toml is
  now the single source of truth — `pip install .[dev]` covers
  what `requirements-dev.txt` previously did.

### Bonus: corrected stale path in `docs/archive/README.md`

`netconfig/migration/codecs/<vendor>/parse.py` → `netcanon/`.
A rebrand-sweep miss that survived through Phase 1.5.

### Pre-launch sanitization pass (developer-facing docs)

Trims developer-facing docs of pre-launch strategy / planning content
that doesn't add operator value (and could read as either presumptuous
or amateurish to a public audience).  Substance preserved everywhere
it's load-bearing; only marketing-strategy / launch-venue / forward-
looking-strategy material trimmed.

#### `docs/RELEASE_PLAN.md` (821 → ~200 lines)

The doc itself anticipated this conversion: *"when the release
actually happens, prune the 'plan' sections and convert this file
into a `docs/RELEASE_NOTES.md` that records what was actually done."*
This is that conversion in place (filename kept to avoid breaking
~7 cross-references; content rewritten).

* **Kept:** as-shipped phase log (Phase 1 / 1.5 / 2 / 4 / 4.5 / 5 / 6
  / 7 narratives), post-launch roadmap notes (backup retention, lock
  manifest, Docker Hub README sync), the
  pre-launch quality hardening notes (Phase 3 — still pending), the
  universal "what we deliberately don't do" principles (no v1.0
  until external validation, no public demo instance, no binary
  config submissions, no firewall-translation promises, no
  Discussions until triage capacity, no silent-drop translations).
* **Trimmed:** the launch-strategy material ("Maximum bug-report
  surface area" with audience analysis + posting-venue list,
  "Specific framing that works for this tool" marketing copy,
  "Concrete release sequence" with phase 8-11 launch playbook,
  "Priority ranking" MUST/SHOULD/NICE breakdown, "When to start"
  triggers).  Project identity material (now in `IDENTITY.md`),
  sanitiser design rationale (now in `SECURITY.md` / `BUG_REPORTING.md`),
  and packaging-tier discussion (now in `SECURITY.md` Supply-Chain
  Integrity) all dropped from this file since they're authoritatively
  sourced elsewhere now.

#### `translator-plans.txt`

Conservative trim — preserved historical record, fixed obvious
staleness:

* **Removed:** stale `PHASE 1` / `PHASE 2` / `PHASE 3` design-target
  blocks at the bottom that duplicated the SHIPPED phases earlier in
  the file (with old terminology — "adapter" instead of "codec" —
  and design intents that all landed under different names).
* **Updated:** the "Quick-reference checklist when adding a new
  codec" — replaced obsolete file names (`parser.py` / `renderer.py`
  / `capabilities.yaml`) with current convention (`parse.py` /
  `render.py` / in-code `CapabilityMatrix`), removed the "entry
  point in pyproject.toml" step (codecs aren't registered that way),
  added the actual current required surfaces (cross-vendor
  expectation YAMLs, real-capture fixtures + NOTICE/RESULTS rows,
  `docs/CAPABILITIES.md` row, `docs/vendors/<vendor>.md` page).
* **Renamed:** "Honest limitations — DO NOT PROMISE IN MARKETING" →
  "Honest limitations" (the suffix read as internal marketing-team
  framing).

The `[SHIPPED]` historical record blocks (~600 lines documenting
each shipped feature with design-space rationale) are kept verbatim.
A future contributor genuinely benefits from seeing "we considered X
and rejected it for reason Y" — that's the project's design-history
record and removing it would be data loss.

#### `AGENTS.md` — corrected stale SHA references

Four pre-rewrite SHAs (`a93bee8`, `de8e0f3`, `01f394c`, `8c9e9d4`)
that survived the rename PR's sweep updated to their post-rewrite
equivalents (`ba72502` for the type_key validator commit; `7b3d7ed`
/ `271f196` / `a5441b9` for BD-Aruba / BD-Junos / BD-Arista
respectively).  These are load-bearing references in the hard-rule
rationale and the doc-sync table — operators who follow the citations
need them to resolve.

#### `CHANGELOG.md` header — disclaimer about pre-launch SHAs

Older `[Unreleased]` entries cite ~30 short SHAs that don't resolve
against current history (all filter-repo casualties from Phase 1's
PII scrub).  Stripping them all would be ~30 individual edits with
unclear value — the *narrative* around each SHA is the substance,
the SHA is decorative.  Added a one-paragraph disclaimer at the top
of the file explaining the situation honestly.

#### Personal-identifier sweep

Verified clean:

* No maintainer name / email variants present in the tracked tree
* No operator-machine path leaks (Windows user-profile or Linux
  home-directory paths).  *Correction (see the "PII-recurrence guard"
  entry under [Unreleased]): this sweep was incomplete — one residual
  operator path survived in a committed review doc and was missed
  here; it has since been redacted and a CI guard now enforces the
  invariant on every push.*
* All sample IPs in tracked content are RFC1918 (`192.168.x` /
  `10.x.x.x` / `172.16.x.x`) or RFC5737 (`192.0.2.x` /
  `198.51.100.x` / `203.0.113.x`) docs ranges — no real WAN IPs
* All real-world FortiGate fixtures verified to contain no operator-
  identifying content beyond the public provenance noted in
  `tests/fixtures/real/NOTICE.md`

#### What this PR does NOT touch

Resisted the urge to over-sanitize.  The following are intentionally
kept visible:

* `AGENTS.md` engineering rules + doc-sync table (the project's
  craft signal — keep transparent)
* `docs/METHODOLOGY.md` (the matrix-honesty discipline distilled —
  this IS the differentiator)
* `docs/IDENTITY.md` (project identity reference — fine to expose)
* The `[SHIPPED]` design-space rationale in `translator-plans.txt`
  (historical record of decisions; future contributors benefit from
  seeing why one path was chosen over another)
* CHANGELOG narrative about the Phase 1 PII scrub, the rc1
  mislabel, the netcanonio detour — those read as iterative honesty
  in moderation; condensing them happens at v0.1.0 final tag time,
  not now

### Wire `setuptools_scm`: tag-driven package versioning

Eliminates the manual `pyproject.toml` version-bump-per-RC tax and
the entire class of PyPI filename-collision bugs.  Tag → version
becomes automatic.

#### Before (the manual cycle)

`pyproject.toml` had `version = "0.1.0"` (or `"0.1.0rc4"` after the
v0.1.0-rc4 fix) hardcoded.  Each release cycle required:

1. Open a PR bumping the pyproject version manually
2. Wait for CI green
3. Merge
4. Tag `v0.1.0-rcN`
5. Hope the rc number didn't collide with anything previously
   uploaded to PyPI (since PyPI permanently claims wheel filenames
   even after yanking)

The v0.1.0-rc1 / rc2 / rc3 / rc4 sequence each hit a variant of
this — version `0.1.0` permanently claimed by the broken rc1
publish; rc2 and rc3 inherited the same hardcoded `version = "0.1.0"`
and got rejected; rc4 manually bumped to `"0.1.0rc4"`.  Future
RCs (or v0.1.0 final, since `0.1.0` is permanently claimed) would
have continued requiring manual bump-PRs forever.

#### After (this PR)

Tag, push, done.  `setuptools_scm` derives the package version
from the git tag at build time:

* Tag `v0.1.0-rc5` → wheel `netcanon-0.1.0rc5-py3-none-any.whl`
* Tag `v0.1.1` → wheel `netcanon-0.1.1-py3-none-any.whl`
* Untagged commit (e.g. CI run on main between releases) → wheel
  `netcanon-0.1.0rc5.dev3-py3-none-any.whl` (PEP 440 dev version,
  guaranteed-distinct from any tagged release)

Every release tag produces a unique PyPI filename automatically.
No collisions possible.

#### Implementation

* **`pyproject.toml`**:
  - Added `setuptools_scm>=8` to `[build-system].requires`.
  - Replaced `version = "0.1.0rc4"` with `dynamic = ["version"]`.
  - Added `[tool.setuptools_scm]` block:
    - `version_file = "netcanon/_version.py"` — bakes the resolved
      version into the package so runtime can read it without
      needing git available (matters in containers).
    - `local_scheme = "no-local-version"` — strips PEP 440 local
      components (`+g<sha>`).  PyPI rejects local versions in
      releases; without this, untagged-commit builds would produce
      strings PyPI rejects.
    - `fallback_version = "0.0.0"` — backstop for source trees
      with no git history (extracted tarballs, etc).  Should never
      hit in CI or worktree work.

* **`.gitignore`**: exclude `netcanon/_version.py` (auto-generated;
  never committed).

* **`Dockerfile`**: builder stage gains an
  `ARG SETUPTOOLS_SCM_PRETEND_VERSION_FOR_NETCANON` (defaults to
  empty).  `setuptools_scm` checks this env var first before
  trying git; lets the Docker builder skip needing `.git/` in its
  context (which is excluded per `.dockerignore`).

* **`.github/workflows/docker-publish.yml`**: passes
  `${{ steps.meta.outputs.version }}` (the semver-parsed tag) as
  the `SETUPTOOLS_SCM_PRETEND_VERSION_FOR_NETCANON` build-arg to
  the build-push-action.  Keeps the Docker build context clean
  while ensuring the resolved version matches the published tag.

* **`.github/workflows/ci.yml` + `.github/workflows/pypi-publish.yml`**:
  every `actions/checkout@v6` step now uses `fetch-depth: 0`.
  Default shallow checkouts have no tags; setuptools_scm would
  fall back to `0.0.0` and produce misleading "version 0.0.0"
  builds.  With full history, the tags are present and version
  resolves correctly.

* **`AGENTS.md`**: new doc-sync row covering the new release
  workflow ("Tag, push, done"), the build-arg flow for Docker, the
  fetch-depth requirement for any future CI step that runs
  `pip install -e .` or `python -m build`, and the gitignored
  `_version.py`.

#### Verification

Built locally on this branch (off main, post-rc4 tag):

```
$ git describe --tags --abbrev=7
v0.1.0-rc4

$ python -m setuptools_scm
0.1.0rc5.dev0

$ python -m build --wheel
Successfully built netcanon-0.1.0rc5.dev0-py3-none-any.whl
```

Untagged commit on this branch produced
`0.1.0rc5.dev0` — distinct from rc4 (the latest tag), PEP 440
clean, no local-version components, would be accepted by PyPI.
When this PR merges and you tag `v0.1.0-rc5`, the wheel will
build as `0.1.0rc5` (no `.dev0`) and publish cleanly to PyPI.

#### Carry-over: when v0.1.0 final ships

PyPI version `0.1.0` is permanently claimed by the broken rc1
publish (yanked, but yanking doesn't free filenames).  The
"final" v0.1.0 release will need to bump to `v0.1.1` (or any
unused PyPI version).  This is structural to PyPI, unrelated to
setuptools_scm, and can't be undone.  Future major / minor
releases (`v0.1.2`, `v0.2.0`, etc.) work fine.

### PyPI version bump + Docker tag-emission cleanup

Three publish-side fixes bundled.  The `v0.1.0-rc3` release exposed
two distinct issues at the registry / package-manager surface; this
PR fixes both plus removes a long-standing redundancy.

#### 1. PyPI: bump `pyproject.toml` `version` to `0.1.0rc4`

**Symptom:** the `v0.1.0-rc3` PyPI publish failed with:

```
400 File already exists ('netcanon-0.1.0-py3-none-any.whl', with
blake2_256 hash 'c61e26397f...').
See https://pypi.org/help/#file-name-reuse for more information.
```

**Cause:** `pyproject.toml` had `version = "0.1.0"` hardcoded.  Every
build produces files named `netcanon-0.1.0-*` regardless of the
git tag that triggered the publish.  v0.1.0-rc1 published with the
broken templates and permanently claimed that filename on PyPI;
yanking doesn't free the filename (PyPI's policy: filenames are
immutable once uploaded).  rc2 and rc3 inherited the same
`version="0.1.0"` and PyPI rejected each as a duplicate.

**Fix:** bump pyproject `version` to `"0.1.0rc4"` (PEP 440 form,
no dash).  Each RC bumps explicitly so each gets a unique PyPI
filename.  Inline comment in `pyproject.toml` documents the trap so
future maintainers don't re-introduce it.

The Docker side wasn't affected — Docker tags are unique per push
regardless of the package's internal version.  v0.1.0-rc3 published
to GHCR + Docker Hub successfully and the published image is
working (HTML pages render correctly; verified end-to-end).  Only
PyPI was blocked.

#### 2. Docker tag emission: drop redundant `v`-prefixed tag

**Symptom:** every Docker Hub release published two tags pointing
at the same image bytes — e.g. `v0.1.0-rc3` AND `0.1.0-rc3`.
Visible on the Docker Hub Tags page; same digest under both tags.

**Cause:** `docker-publish.yml`'s `metadata-action` config had:

```yaml
tags: |
  type=ref,event=tag                  # emits v0.1.0-rc3 (with `v`)
  type=semver,pattern={{version}}     # emits 0.1.0-rc3  (without `v`)
  ...
```

**Fix:** remove the `type=ref,event=tag` line.  Keep only the
semver-derived form, which matches the convention of official
Docker Hub images (`python:3.13-slim`, `node:20`, etc.), the PyPI
version string, and most operator muscle memory for `docker pull`
commands.

Note: existing dual-tagged releases (`v0.1.0-rc1`, `v0.1.0-rc2`,
`v0.1.0-rc3`) on Docker Hub stay as-is; this only affects future
publishes.  Operators with hardcoded `:v0.1.0-rc3` references in
their tooling will need to switch to `:0.1.0-rc3` for future
releases.  Cleanup of the lingering `v`-prefixed tags is manual
(via the Docker Hub UI Tag → Delete dropdown) — see the action-
items list in the previous CHANGELOG entry.

#### 3. Docker `:latest` emission on tag pushes

**Symptom:** the `:latest` tag on Docker Hub / GHCR was never
populated.  Every released RC pushed `vX.Y.Z-rcN` and `X.Y.Z-rcN`
tags but no `:latest`.  README + CHANGELOG both documented
`docker pull netcanon/netcanon:latest` as the recommended
quickstart, which would fail with "manifest unknown".

**Cause:** the `metadata-action` config had:

```yaml
type=raw,value=latest,enable={{is_default_branch}}
```

The `is_default_branch` template variable is only true when the
workflow's git ref IS the default branch (e.g. push to `main`).
For tag-triggered workflows (`push: tags: ['v*.*.*']`), the ref is
the tag, not the branch — so `is_default_branch` evaluates false,
and `:latest` never emits.

**Fix:** drop the `enable={{is_default_branch}}` condition.
`:latest` now updates on every release tag push.  This means RC
tags will set `:latest` to point at the current RC — appropriate
for a pre-launch project where there's no stable to anchor `:latest`
to.  Once v0.1.0 final ships, RC tags become rare; if/when stable +
RC release cadence overlaps and we want `:latest` to track stable
only, the condition can be re-added with a different gate (e.g.
`enable=${{ !contains(github.ref, '-') }}` to skip pre-release tags).

#### Action items for the operator (manual)

After this PR merges, tagging `v0.1.0-rc4` will publish:

* **PyPI:** `netcanon==0.1.0rc4` (working — the templates fix from
  the previous PR + the unique version string from this one combined
  unblock the publish)
* **Docker Hub + GHCR:** `:0.1.0-rc4` and `:latest` (no more
  `v`-prefix duplicate; `:latest` finally exists and points at the
  rc4 image)

The dangling `v0.1.0-rc1` / `v0.1.0-rc2` / `v0.1.0-rc3` tags from
earlier publishes can be deleted manually via the Docker Hub UI
once rc4 is up — they all point at broken or pre-fix images and
operators have no reason to pull them.  Same for GHCR via
https://github.com/netcanon/netcanon/pkgs/container/netcanon →
Manage versions.

#### Long-term fix (post-v0.1.0)

Tracked in `docs/RELEASE_PLAN.md` post-launch roadmap notes:
**wire `setuptools-scm`** so the package version is auto-derived
from the git tag.  No more manual pyproject bumps per RC; no more
opportunity for `v`-prefix-vs-not confusion.  Tag `v0.1.0-rc5` →
package builds as `0.1.0rc5` (PEP 440 normalized) automatically.
Tag `v0.1.0` → builds as `0.1.0`.  Removes a class of bugs
permanently.  ~10-line change to `pyproject.toml` + workflow; not
urgent but worth doing before v0.1.0 final.

### Fix: `templates/_partials/*.js` missing from built wheel (every HTML page 500'd)

**Severity: blocker.**  v0.1.0-rc1 and v0.1.0-rc2 published artefacts
(both PyPI wheels and Docker images on GHCR + Docker Hub) shipped
**without** the 12 Jinja partials under `netcanon/templates/_partials/`.
Every HTML page that extends `base.html` (which includes the dashboard,
migrate page, devices, configs, schedules — i.e. *every operator-
facing page*) raised `jinja2.exceptions.TemplateNotFound` and FastAPI
returned 500.  `/health` worked because it returns JSON, not a
template.

Discovered by pulling `netcanon/netcanon:v0.1.0-rc2` from Docker Hub
into a clean container, hitting `/`, getting `Internal Server Error`,
and checking `docker logs`:

```
jinja2.exceptions.TemplateNotFound: '_partials/config-viewer.js'
not found in search path:
'/usr/local/lib/python3.14/site-packages/netcanon/templates'
```

#### Root cause

`pyproject.toml` had:

```toml
[tool.setuptools.package-data]
netcanon = ["templates/*.html"]
```

That glob matches only `.html` files at the **top** of `templates/`.
It misses files with non-`.html` extensions (the `.js` partials) AND
files in subdirectories (the `_partials/` subdirectory).  When
setuptools builds the wheel, those 12 files are silently excluded.
When operators install the wheel — directly via `pip install netcanon`
or transitively via the Docker image's multi-stage build — the
package is missing the partials.

Editable installs (`pip install -e`) hide this because they import
files directly from the source tree on each load, bypassing the
wheel-packaging step.  This is why local Python development works
fine but every published artefact is broken.

#### Fix (3 changes in this PR)

1. **`pyproject.toml`** — expanded the package-data glob:
   ```toml
   netcanon = [
       "templates/*.html",
       "templates/_partials/*.js",
   ]
   ```
   Inline comment added explaining the trap so future contributors
   don't reintroduce it.  Verified locally: `python -m build --wheel`
   now produces a wheel containing all 9 HTML files + all 12 `_partials/*.js`
   files.

2. **`.github/workflows/ci.yml`** — extended the `docker-build-smoke`
   job to curl `GET /` (the dashboard) in addition to `/health`.
   The original smoke only verified `/health`, which doesn't render
   a template, which is why this regression slipped through CI.
   The expanded check is small (one extra `curl`) and catches the
   exact bug class that bit us — every operator-facing page renders
   `base.html`, which renders the partials, so a single dashboard
   request is sufficient signal.

3. **`AGENTS.md`** — new doc-sync row mapping "new template file
   with non-`.html` extension OR in a new subdirectory" → "update
   `pyproject.toml` `[tool.setuptools.package-data]`".  Cites the
   v0.1.0-rc1 / rc2 bug as the failure mode this rule prevents.

#### Aftermath / coordination required

Three operator-action items beyond merging this PR:

* **Yank `netcanon==0.1.0`, `netcanon==0.1.0rc1`, `netcanon==0.1.0rc2`
  from PyPI.**  All three are broken (every HTML page 500s).  Yank
  via https://pypi.org/manage/project/netcanon/ → click each version
  → Yank.  Yanking marks the version as "do not install for new
  users" (`pip install netcanon` skips it) but doesn't free the
  filename — that's PyPI's permanent policy.  Already-pinned
  installs continue to work; new installs skip the broken versions.
* **Mark broken Docker tags clearly.**  The `v0.1.0-rc1` and
  `v0.1.0-rc2` tags on `ghcr.io/netcanon/netcanon` and
  `docker.io/netcanon/netcanon` are also broken.  Either delete
  them via the registry UI or push a tag-level note in the Docker
  Hub repo description warning operators away.  Don't silently
  let `:latest` keep pointing at a broken image — repoint it to the
  next working release after the fix lands.
* **Tag `v0.1.0-rc3` once this PR merges.**  Verifies the fix
  end-to-end (the new docker-build-smoke step will fail-fast if
  the wheel is still broken; if green, both registries publish a
  working image and PyPI gets a working wheel).

After v0.1.0-rc3 verifies, the path forward is unchanged: tag
`v0.1.0` final when ready (note: PyPI version `0.1.0` is permanently
claimed by the broken rc1 publish; the actual final release will
need to be `v0.1.1` or higher per PyPI's filename-immutability
policy.  See the broader PyPI versioning issue tracked in
`docs/RELEASE_PLAN.md` post-launch roadmap notes — `setuptools-scm`
wiring would auto-derive package version from git tags and prevent
this class of issue recurring).

#### Why CI didn't catch this

The original `docker-build-smoke` job built the image and curled
`/health` — which doesn't render a template, so the missing
partials didn't surface.  Closed in this PR.

The release workflow (`docker-publish.yml`) doesn't smoke-test
either; it builds, signs, publishes, and runs `cosign verify` (which
verifies the *signature*, not the application's runtime behaviour).
That's appropriate for a publish workflow, but it means the
`docker-build-smoke` is the load-bearing pre-publish gate — and
it had a coverage gap.

### Correct Docker Hub namespace: `netcanonio` → `netcanon`

The `v0.1.0-rc2` Docker publish failed against `docker.io/netcanonio/netcanon`
with `unauthorized: incorrect username or password`.  Root cause was
not auth — it was that `netcanonio` was a wrong-turn namespace.  When
the Docker Hub mirror PR was authored, the operator believed the
`netcanon` namespace was already taken and picked `netcanonio` as a
fallback.  Verifying afterwards showed `netcanon` was actually
available and now belongs to the project's Docker Hub account.

This PR corrects every `netcanonio` reference to `netcanon`:

* `.github/workflows/docker-publish.yml` — `DOCKERHUB_IMAGE` env var
* `README.md` — `docker run` example
* `docs/IDENTITY.md` — Distribution surfaces table + drops the
  "namespace divergence" note (no longer divergent)
* `SECURITY.md` — Distribution channels table
* `AGENTS.md` — distribution-variants clarifying paragraph
* `CHANGELOG.md` — earlier `[Unreleased]` Docker Hub mirror entry
  rewritten to reflect the corrected namespace

After this PR merges, the operator should also:

* Update the `DOCKERHUB_USERNAME` repo secret to `netcanon`
* Re-run the failed `v0.1.0-rc2` Docker publish workflow from the
  Actions tab (no need to re-tag — same trigger ref)
* Optionally clean up the dormant `netcanonio` Docker Hub namespace
  (if it was registered as a placeholder) so it doesn't sit empty

The Docker Hub mirror has never successfully pushed any image, so
the correction is cleanly retroactive — no operators have been
told to pull from the wrong namespace.

### `AGENTS.md`: clarify Docker / pip / MSI as distribution variants

Closes a small ambiguity in the "Parallel Platform Development"
section.  Pre-edit, the section listed Web + Desktop as the two
platforms requiring feature parity, but didn't say where Docker /
pip / MSI fit.  A future contributor could reasonably wonder
whether Docker is a third parity-requiring target (it isn't —
`docker run ghcr.io/netcanon/netcanon` runs the same
`uvicorn netcanon.main:app` entrypoint as host-installed web; the
container is a packaging variant of the web platform, not a
separate code path).

Two small additions:

* **Clarifying paragraph after the platforms table.**  Names
  Docker (GHCR + Docker Hub mirror), `pip install netcanon`, and
  the Windows MSI as distribution variants of the two platforms —
  Docker + pip both produce a web-platform install; the MSI
  produces a desktop-platform install.  Explicit that none of
  them require their own parity test row.
* **New doc-sync table row for packaging / distribution workflow
  changes.**  Covers `Dockerfile`, `.dockerignore`, the publish
  workflows (`docker-publish.yml` / `pypi-publish.yml`), base-
  image bumps, action-version bumps, registry namespace changes,
  and signing / SBOM / attestation surface changes.  Required
  follow-ups when any of those change: `SECURITY.md` Supply-Chain
  Integrity table, `docs/IDENTITY.md` Distribution surfaces
  table, `README.md` Install section, and (for a new Python
  matrix entry) the matching classifier in `pyproject.toml`.

The Phase 6 (packaging foundation), Python 3.14 (CI matrix
expansion), and Docker Hub mirror waves all hit at least one of
these surfaces, so the row is anchored in real recent waves
rather than speculative.

### Rename `CLAUDE.md` → `AGENTS.md` (cross-tool convention name)

Renames the contributor-directives file from a vendor-named
(`CLAUDE.md`) to the emerging cross-tool convention
(`AGENTS.md`).  Substance unchanged — same hard rules, same
doc-sync table, same engineering discipline; just the filename and
its 197 cross-references across 56 files.

The rename also touches the starter template at
`docs/templates/CLAUDE.md.template` → `docs/templates/AGENTS.md.template`
and the four genuine `Claude`-named references that lived outside
the directive file itself:

* `docs/glossary.md` — "humans and Claude agents" → "human or
  AI-assisted"
* `translator-plans.txt` — "Written for Claude, not humans" →
  "Written for AI-assisted contributors and grep — not narrative
  reading"
* `.gitignore` — "Claude Code internal" → "AI-assistant tooling —
  local session state, never committed", and expanded to ignore
  `.cursor/` and `.aider*/` alongside `.claude/` so the gitignore
  treats AI tooling generically
* The directives file itself — content was already vendor-neutral
  (no "Claude" string anywhere in body text); only the filename +
  cross-refs changed

The rename is a pure structural change; no rules were dropped,
softened, or rewritten.

#### Why now

Pre-public-flip polish.  `AGENTS.md` is becoming the cross-tool
standard for "the rulebook the contributor reads first" — adopted
by OpenAI codex, several Cursor projects, and others.  The
generic name decouples the file from any particular AI tool's
conventions and reduces the perception that the project is tied
to one vendor's tooling.

#### What did NOT change

* **Git history.**  Past commits with `Co-Authored-By` trailers
  stay as they are — those are truthful attribution and rewriting
  them would require a destructive `git filter-repo` pass we're
  not doing again after the Phase 1 PII scrub.  Going forward,
  commits attribute to the project contributor identity only.
* **Real-world fixtures.**  Two FortiGate operator-contributed
  fixtures contain "Claude" in their unmodified config text
  (banner / comment / description); leaving them untouched
  preserves fixture provenance and authenticity.
* **The contributor directives content.**  Every hard rule, every
  doc-sync table row, every cross-reference target stays the same
  — only the filename changed.

#### Doc-sync follow-up

* `CONTRIBUTING.md` gained a small "A note on the development
  workflow" subsection inside "What this project values":
  acknowledges AI-assisted development as part of the workflow
  context, declares that matrix-honesty discipline applies to the
  AI workflow the same as the code, and points at `AGENTS.md` as
  the canonical (tooling-agnostic) directives file.

### Distribution: mirror published images to Docker Hub

`docker-publish.yml` now pushes each release to **two registries** in
the same `docker/build-push-action` step:

* **GHCR — `ghcr.io/netcanon/netcanon`** — primary, signed via
  cosign keyless (Sigstore + GitHub OIDC), SBOM attached as a cosign
  attestation (SPDX JSON via syft).  The "trust chain" image.
* **Docker Hub — `docker.io/netcanon/netcanon`** — convenience
  mirror.  Same image bytes (single build, dual push from one
  buildx step), same tag patterns.  No cosign signature, no SBOM
  attestation — Docker Hub is treated as a discoverability /
  corporate-egress-friendliness path; operators in regulated
  environments should continue pulling from GHCR for the attested
  provenance.

Why mirror to Docker Hub: network engineers in corporate environments
often have egress whitelists that allow `docker.io` but block GHCR;
`docker pull netcanon/netcanon` is also closer to muscle memory
than the GHCR equivalent, so tutorials and quick-start docs read
cleaner.

All distribution surfaces share the `netcanon` name (GHCR org,
Docker Hub user, GitHub org, PyPI project).

#### Auth surface

Two new repository secrets (configured manually in repo settings,
not in code):

* `DOCKERHUB_USERNAME` — the Docker Hub account username with push
  permission to the `netcanon` namespace
* `DOCKERHUB_TOKEN` — Docker Hub Personal Access Token with **Read
  & Write** scope (least privilege; never password)

The token is per-purpose ("Github Actions docker-publish" description)
and revocable via Docker Hub Account Settings → Security → Personal
Access Tokens if it ever leaks.

#### Cosign filtering

The cosign signing step now filters by registry prefix to sign only
the GHCR tags.  Without this filter, cosign would attempt to sign
the Docker Hub mirror too, which would either fail (if the workflow
lacks Docker Hub keyless signing setup) or attach a Docker Hub-
specific signature with weaker provenance.  The filter keeps the
trust chain unambiguous: cosign-verifiable signatures live on GHCR;
Docker Hub is the unsigned mirror.

#### Docs sync

* `README.md` — added the `docker run netcanon/netcanon:latest`
  example as an alternative install path under the existing
  Docker section, with a note that the mirror is unsigned.
* `docs/IDENTITY.md` — added a "Distribution surfaces" table
  enumerating GHCR / Docker Hub / PyPI with their provenance
  guarantees per channel.
* `SECURITY.md` — added a "Distribution channels and what each
  provides" sub-table to the Supply-Chain Integrity section,
  making the cosign / SBOM / attestation differences between the
  channels explicit.

### CI: Python 3.14 coverage + Docker build smoke test

Closes the verification gap that the Dependabot Dockerfile bump
(`python:3.13-slim-bookworm` → `python:3.14-slim-bookworm`)
opened: previously the test matrix tested 3.11/3.12/3.13
only, so the bumped base image was effectively unverified by CI.

Two changes:

* **CI matrix expanded to include Python 3.14.**
  `.github/workflows/ci.yml` adds `"3.14"` to the test matrix and
  `pyproject.toml` adds the corresponding
  `Programming Language :: Python :: 3.14` classifier so the package
  metadata is consistent with what's tested.
* **New `docker-build-smoke` CI job.**  Builds the Dockerfile on
  every PR and `push` to main, runs the resulting image, and polls
  `GET /health` for up to 60 seconds.  Catches Dockerfile-side
  regressions (base bumps, COPY mistakes, entrypoint breakage) at
  PR-time rather than at tag-time when `docker-publish.yml` fires
  the GHCR push.

The smoke test exercises the full container lifecycle (build →
boot → ready → health-check responds) but doesn't run pytest
inside the container — that's still the matrix job's role on the
host runner.  The two layers complement: matrix verifies the
package code; smoke verifies the image-shipping path.

### Documentation audit pass (post-Phase-7)

Multi-agent review of every operator-facing doc against current state,
with prioritised fixes applied across BLOCKER + HIGH + MEDIUM tiers.
Five parallel review agents covered: top-level docs (README /
CHANGELOG / BUG_REPORTING / HUMAN_TESTING / CONTRIBUTING / SECURITY /
LICENSE); `docs/` root operator pages (CAPABILITIES / COMPARISON /
IDENTITY / HOW_WE_TEST / TROUBLESHOOTING / METHODOLOGY / RELEASE_PLAN
/ glossary); per-vendor pages (`docs/vendors/*.md`); walkthroughs
(`docs/walkthroughs/*.md`); architecture + contributor docs
(ARCHITECTURE / adding-a-canonical-field / adding-a-target-profile /
feature-parity-walkthrough / translator-plans).  Headline fixes:

#### BLOCKER (factually wrong / would break trust)

* **README Docker volume-mount bug.**  Removed `-v $(pwd)/definitions:
  /app/definitions` example — the in-image `definitions/` YAMLs are
  tracked content; mounting an empty host dir over them crashes
  startup with the same `FileNotFoundError` the Phase 6 wave caught.
  Replaced with `-v $(pwd)/data:/app/data` (the actual operator-
  state mount slot).
* **README sanitiser link target.**  Pointed at `BUG_REPORTING.md`
  (the workflow doc) instead of `CAPABILITIES.md` (the matrix).
* **HUMAN_TESTING.md hardcoded codec count.**  "all 7 codecs" →
  "the registered codecs" (AGENTS.md hard-rule violation).
* **`docs/IDENTITY.md` Topics ↔ pyproject.toml mirror claim.**
  Added `python` + `fastapi` to `pyproject.toml` `keywords` so the
  two surfaces actually match (doc claimed mirror; reality didn't).
* **Phantom CLI flag.**  `cisco_iosxe_to_junos.md` referenced a
  `--rename-interfaces` flag that doesn't exist; replaced with the
  actual mechanism (migrate-page rename modal / `rename_overrides`
  in the API payload).
* **Invalid git SHAs in contributor docs.**  `adding-a-canonical-
  field.md` referenced `e3b48b4` (MTU) and `e495a0b` (local_users);
  `feature-parity-walkthrough.md` referenced `145642e` (SNMPv3 USM).
  The first two were filter-repo casualties from the netconfig→
  netcanon rename and aren't recoverable; rewrote to remove SHA
  references and point at `git log --grep="wire-through"` for live
  tree examples.  The SNMPv3 SHA was wrong; corrected to `8c6e493`
  (the actual P2C6 commit).
* **`translator-plans.txt` stale `[ ]` marker.**  Module-variant
  target profiles marked unshipped despite Option B being fully
  shipped (allowlist + schema + reference YAML all live); replaced
  with `[SHIPPED]` block citing the current-tree artefacts and
  preserving the original design-space notes for historical record.

#### HIGH (cross-cutting purges)

* **Hardcoded `~12,000 cells` purge.**  Appeared in
  `docs/HOW_WE_TEST.md`, `docs/TROUBLESHOOTING.md`, and two places
  in `docs/RELEASE_PLAN.md`.  Replaced with pointers to the live
  source (`tests/fixtures/real/PHASE4_RECONCILIATION.md`, machine-
  generated, can't drift behind code).  Hard-rule violation.
* **Docker-tag policy.**  `:0.1.0` doesn't exist on GHCR yet
  (only `:0.1.0-rc1` from Phase 6).  Switched README + RELEASE_PLAN
  examples to `:latest` (always resolves; auto-tracks current
  build) so operators can copy-paste without 404'ing.
* **Stale forward-looking phase prose.**  `CONTRIBUTING.md` (Phase
  4.5 sanitiser as future work), `SECURITY.md` (Phase 6 Supply-
  Chain Integrity as future work), and `RELEASE_PLAN.md`'s body
  ("Status as of 2026-05-05: helper does NOT yet exist") all
  contradicted their own status blocks.  Rewrote to current-state.

#### Vendor-page capability drift (per-vendor codec investigation)

Every contested claim in the agent report was checked against the
codec implementation directly (parse.py + render.py grep of MTU /
VRF / RADIUS / DHCP / local_users):

* **`cisco_iosxe.md`** — TL;DR clarified that `cisco_iosxe_cli` is
  parse-only (Cisco-as-source); MTU claim retained (it IS parsed —
  carried into canonical for target codecs to render).
* **`fortigate.md`** — added explicit caveat that MTU + VRF binding
  are parsed-on-source-only (FortiGate-as-source carries them
  through; FortiGate-as-target doesn't emit them — codec gap, not
  doc gap).  Removed "35K-line" hardcoded count from the gotchas.
* **`mikrotik_routeros.md`** — moved DHCP server pools from Tier 1
  to Tier 2 (matches `CAPABILITIES.md` and the codec's own
  `# Tier 2 DHCP` source comment).  Renamed-port preservation
  reframed as intra-vendor round-trip preservation (not a cross-
  vendor translation surface).
* **`aruba_aoss.md`** — moved `dhcp-snooping` and `web-management
  ssl` / `ip authorized-managers` to a "parse-tolerant carry-
  through" subsection (parsed on source; cross-vendor render path
  not yet wired).
* **`arista_eos.md`** — corrected "EOS 4.21 through 4.30+" to "4.21
  through 4.26" (corpus-validated range).
* **`aruba_aoss.md`** — dropped `YA` software branch + 2530 chassis
  from the version-coverage list (no fixture); reframed as "CLI
  grammar parses + renders for these; not pinned by a fixture yet".
* **`juniper_junos.md`** — same treatment for SRX (no fixture;
  reframed).  "Five distinct Junos majors" corrected to "five
  captures across four majors" (two 25.4 captures = one major).

#### Walkthrough fidelity (paired-with-demo accuracy)

* **`aruba_to_arista.md`** — corrected the paradigm-flip block to
  match what the demo actually emits (bare `interface 1` not
  `interface Ethernet1`; both forms are valid Arista CLI).  Added
  a "Note on port naming" callout describing how to invoke the
  rename mesh for canonical `Ethernet<N>` form.  Manual-review
  checklist updated to match.
* **`cisco_iosxe_to_junos.md`** — added the missing third interface
  line (`GigabitEthernet0/0/2`) to the sample output block.
* **`fortigate_to_mikrotik.md`** + **`opnsense_to_junos.md`** —
  softened hardcoded "35K-line" / "2,000+ lines" / "~5-10% Tier-1/2"
  / "~90-95% Tier-3" prose to qualitative phrasing (hard-rule).

#### `BUG_REPORTING.md` SLA realism

Triage SLA changed from "48 hours" to "2 weeks", with explicit
context that this is a one-maintainer project worked on alongside
a full-time dayjob.  Critical reports (security, silent data loss
in `supported`-declared translations) escalate.

#### `tests/fixtures/real/RESULTS.md`: cisco_iosxe (NETCONF) section

Added a `## cisco_iosxe (NETCONF / OpenConfig)` section documenting
the codec's `best_effort` cert state, Phase 0.5 stub-render scope,
and the cert-decision rationale (stays `best_effort` until either
NETCONF render demand materialises or a multi-version operator-
contributed corpus lands).  This is the doc the per-vendor index
links to as cert source-of-truth.

#### `SECURITY.md` accuracy refresh

Beyond stale-Phase-6 Supply-Chain Integrity content (rewritten to
current state — multi-stage Docker, cosign keyless signing, syft
SBOM attestation, PyPI Trusted Publishing, non-root runtime), the
threat model was reframed to acknowledge the dual-deployment shape
(desktop on loopback vs. web/Docker with operator-supplied reverse
proxy + auth).  Added a new "Sanitiser (Bug-Reporting Workflow)"
section documenting Phase 4.5's redaction categories.  Known-risk
table grew rows for "banner / comment text not sanitised" and
"IPv6-public redaction not implemented" — both honest disclosure
of the v0.1.0 sanitiser's documented limitations.

#### `docs/RELEASE_PLAN.md` post-launch roadmap entry

Added a "Backup retention / rolling delete on scheduled jobs"
note: today every scheduled-job run lands a fresh
`configs/<host>_<ts>.<ext>` file with no automatic cleanup; long-
running schedules grow the backup directory unbounded.  Want a
per-schedule retention policy (keep N most recent, OR keep configs
newer than D days, OR both) configurable at schedule-creation.
Doesn't gate v0.1.0; tracked here so it survives the eventual
RELEASE_PLAN→RELEASE_NOTES conversion.

#### Cross-cutting smaller fixes

* **`docs/glossary.md` Tier-3 description** — corrected from
  "`raw_sections` passthrough" to "detected-but-deliberately-not-
  translated; surfaced via `CanonicalIntent.dropped_tier3_sections`"
  (matches actual model field name).
* **`docs/COMPARISON.md`** — "8 vendor families" → enumerated list
  of 7 (Cisco / Juniper / Fortinet / Aruba / Arista / MikroTik /
  OPNsense — the codec count is 8, vendor families are 7).
* **`docs/TROUBLESHOOTING.md`** — `alpha`/`beta` cert tiers (don't
  exist) replaced with `best_effort` (the actual non-`certified`
  tier in use); broken `#hash-portability-policy` anchor link
  replaced with prose pointer.
* **`ARCHITECTURE.md`** — Phase 1 evolution-roadmap bullet no
  longer mislabels `cisco_iosxe_cli` as bidirectional (it's
  parse-only).
* **`docs/IDENTITY.md`** — "we use 13" Topics count replaced with
  "the list below" (the count rots without a CI guard).

### Public release plan — Phase 7: README rewrite

Phase 7 from [`docs/RELEASE_PLAN.md`](docs/RELEASE_PLAN.md) — the
operator-facing front door.  The pre-rewrite README read like
internal documentation (architecture-first, contributor-table
above the fold).  The new README leads with operator value and
defers contributor scaffolding to a later section.

#### Reframed structure

* **Tagline above the fold** — the locked tagline from
  [`docs/IDENTITY.md`](docs/IDENTITY.md) ("Multi-vendor network
  config translator with a verifiable cross-vendor audit") is the
  first thing readers see.
* **Concrete before/after demo** — paste a Cisco IOS-XE snippet,
  see the rendered Junos output, both inline.  Static text
  equivalent of the asciinema the Phase 4 changelog flagged as
  Phase 7's deliverable.  Live demo is one `docker run` away.
* **Trust signal as invitation** — surfaces the zero-`CODEC_BUG`
  cross-mesh-audit claim (the headline number lives in
  [`docs/HOW_WE_TEST.md`](docs/HOW_WE_TEST.md), per the prose-
  count hard rule), then immediately points at
  [`BUG_REPORTING.md`](BUG_REPORTING.md): the audit only covers
  cells we have fixtures for; bring us configs we haven't tested
  yet.  Frames the matrix-honesty discipline as an invitation
  rather than a marketing claim.
* **Headline install: `docker run`** — the published GHCR image
  is the headline path (signed via Sigstore + SBOM via syft per
  Phase 6).  `pip install netcanon` is Tier-2; Windows MSI is
  Tier-3.  Each install path includes the actual command, not
  just a name.
* **Walkthroughs table promoted above the fold** — the four
  Phase-4 walkthroughs (Cisco→Junos / FortiGate→MikroTik /
  Aruba→Arista / OPNsense→Junos) are the answer to "is this the
  right tool for my migration?", and now sit prominently above
  the contributor table rather than buried in a "where to go
  next" multi-row.
* **Tier-1/2/3 summary inline** — operators learn what translates
  and what doesn't directly in the README, with a pointer to
  [`docs/CAPABILITIES.md`](docs/CAPABILITIES.md) for the full
  matrix.  The Tier-3 boundary's "we deliberately don't auto-
  render firewall / NAT / VPN" line is the load-bearing
  expectation-setter and now sits in front of every reader.
* **"Got a config that breaks it?" section** — second-from-bottom
  CTA pointing at [`BUG_REPORTING.md`](BUG_REPORTING.md) with the
  three-step workflow (sanitise → submit → fixture lands in test
  matrix).  Fixture submissions are the highest-impact
  contribution this project receives; the README now frames them
  that way.
* **Contributor scaffolding kept, demoted** — the developer-
  facing "Where to go next" table, test-suite invocation, and
  Layout block all moved under a "For contributors" heading
  below the operator content.  Nothing removed; reordered for
  audience.

#### What this wave does NOT do

* **No asciinema recording / animated GIF.**  The static
  before/after in the "See it in 10 seconds" section is the
  equivalent for a text README; the runnable `docker run ...
  python tools/demo.py` is the dynamic counterpart.  An
  asciinema upload is a follow-up if operators ask for it.
* **Per-vendor README sections.**  Per-vendor reference lives
  in [`docs/vendors/`](docs/vendors/) (Phase 5 deliverable);
  the README points there rather than duplicating.
* **Versioned `docker run` examples beyond `:0.1.0`.**  The
  README pins to `:0.1.0`, the same tag the Phase 9 soft-launch
  publishes.  Until that tag pushes, operators can substitute
  `:latest` or `:0.1.0-rc1` (already on GHCR from Phase 6).

### Public release plan — Phase 4: demo + walkthroughs

Phase 4 from [`docs/RELEASE_PLAN.md`](docs/RELEASE_PLAN.md) — the
30-second "show me what this does" path that operators see before
they read any docs.

#### `tools/demo.py`

One-command cross-vendor translation, no setup required (no devices,
no FastAPI server, no fixture files on disk — just `pip install
netcanon` and run).  Four scenarios baked in:

* `cisco__junos` — Cisco IOS-XE -> Juniper Junos (DC leaf:
  VLANs + interfaces + DNS / NTP + static route)
* `fortigate__mikrotik` — FortiGate -> MikroTik RouterOS
  (branch firewall: DNS + interfaces + DHCP pools)
* `aruba__arista` — Aruba AOS-S -> Arista EOS (switch refresh:
  VLAN-centric grammar -> per-port switchport)
* `opnsense__junos` — OPNsense -> Juniper Junos (edge migration
  with Tier-3 boundary on display: `<filter>` / `<nat>` /
  `<ipsec>` deliberately deferred)

Each scenario uses an embedded synthetic config (~10-25 lines).
Output shows source, rendered target, and the dropped-Tier-3
banner so operators see what didn't translate.

CLI:

```
python tools/demo.py                       # default: cisco__junos
python tools/demo.py --pair X__Y           # specific pair
python tools/demo.py --list                # show available scenarios
```

The demo calls `run_plan` directly through the same codec registry
the API uses — so when operators see the demo translate correctly,
the production path translates the same way.

#### `docs/walkthroughs/` (new directory)

Narrative walkthroughs paired 1:1 with the demo scenarios.  Where
[`docs/vendors/`](docs/vendors/) are reference docs ("what does
Netcanon do for vendor X?"), walkthroughs are workflow docs ("you
have a fleet of X and want to migrate to Y -- here's the path,
the friction points, and the manual review checklist").

* [`docs/walkthroughs/README.md`](docs/walkthroughs/README.md) —
  index + format spec
* `cisco_iosxe_to_junos.md` — DC leaf migration, paradigm flip
  notes, hash-portability caveat
* `fortigate_to_mikrotik.md` — branch firewall consolidation,
  honest about the ~90-95% Tier-3 split
* `aruba_to_arista.md` — VLAN-centric -> per-port grammar
  inversion (the canonical-intermediate-model headline
  transformation)
* `opnsense_to_junos.md` — edge migration with Tier-3 boundary
  showcase (the matrix-honesty discipline made visible)

Each walkthrough has a 6-section format: Scenario / What Netcanon
does / Run the demo / Tier-3 boundary / Manual review checklist /
See also.  Intentionally short — operators get a 30-second answer
to "should I be using Netcanon for this migration?" and a 5-minute
answer to "what's the actual workflow look like?".

#### Doc cross-references

* `AGENTS.md` "See also" footer extended with `docs/walkthroughs/`
* `docs/walkthroughs/` cross-links into `docs/vendors/`,
  `docs/CAPABILITIES.md`, `docs/COMPARISON.md`, `BUG_REPORTING.md`,
  and `docs/HOW_WE_TEST.md` — every walkthrough's "See also"
  closes the loop into the matrix-honesty discipline

#### What this wave does NOT do

* **Per-fixture demo recordings (asciinema).**  Phase 7's README
  rewrite will produce the headline asciinema; this wave's `tools/
  demo.py` is the underlying artefact the asciinema records.
* **HTML / web-UI demo page.**  CLI-only for v0.1.0; a web demo
  page is post-launch follow-up if operator-feedback signals
  demand it.
* **Bidirectional walkthroughs** (e.g. Junos -> Cisco).  Each pair
  has 2 directions; v0.1.0 ships one direction per scenario.  The
  reverse direction works (every codec is bidirectional and
  certified) but the walkthrough narrative for each direction
  belongs to its own page.

### Public release plan — Phase 6: packaging foundation

Phase 6 from [`docs/RELEASE_PLAN.md`](docs/RELEASE_PLAN.md) — the
Dockerfile + GHCR publish workflow + PyPI Trusted Publishing
workflow that turn a release tag into actual published artefacts.

#### Dockerfile (multi-stage, non-root, signed, healthcheck'd)

* **Multi-stage build.**  Stage 1 (`builder`) compiles wheels with
  `build-essential` + `libffi-dev` + `libssl-dev`; stage 2 installs
  prebuilt wheels with `pip install --no-index` (no compilers in
  runtime, no network during the runtime layer).
* **Base image** `python:3.13-slim-bookworm` — broad wheel support
  for paramiko / cryptography / pydantic-core; ~150MB before the
  netcanon install.
* **Non-root `app` user** (uid=1000, gid=1000) — file writes go
  through the bind-mounted volumes only.
* **Volume slots** `/app/configs` (backup output) + `/app/data`
  (jobs / devices / schedules root, mirrors `NETCANON_DATA_DIR`
  semantics).
* **`/app/definitions`** baked into the image (per-vendor
  device-definition YAMLs are tracked content, not operator state
  — they ship with the image rather than bind-mounted).
* **`HEALTHCHECK`** every 30s on `GET /health` via `curl -fsS`;
  start-period 10s; 3 retries before marking unhealthy.
* **OCI labels** for image discovery + provenance (title /
  description / source / documentation / licenses / vendor).

#### `.dockerignore`

Excludes tests, configs, devices, schedules, jobs, docs, .git,
build artefacts, .venv, the operator-local backup dir from the
Phase 1 history rewrite.  Whitelists `README.md` (the Dockerfile
COPYs it for `pyproject.toml`'s readme field).

#### `.github/workflows/docker-publish.yml`

Triggers on `v*.*.*` tag push (incl. `-rc`/`-alpha`/`-beta`
pre-releases).  Pipeline:

1. Build via buildx (cache via `type=gha`).
2. Push to GHCR (`ghcr.io/netcanon/netcanon`) with semver tags +
   `latest` on the default branch.
3. Cosign keyless sign every published tag via Sigstore + GitHub
   OIDC (no long-lived keys in the repo).
4. Generate SBOM (SPDX JSON) via syft; upload as workflow
   artefact.
5. Attach SBOM as a cosign attestation on the image digest.
6. Post-publish smoke test: `cosign verify` against the published
   digest with the GitHub Actions OIDC issuer + repository
   identity.

#### `.github/workflows/pypi-publish.yml`

Triggers on `v*.*.*` tag push.  Pipeline:

1. Build sdist + wheel via `python -m build`.
2. Validate metadata via `twine check`.
3. Upload distribution as a workflow artefact.
4. Publish to PyPI via Trusted Publishing (`pypa/gh-action-pypi-publish`)
   — no API token in the repo; OIDC-bound to the `pypi`
   environment + the repo identity declared on PyPI's side.

#### `/health` endpoint

[`netcanon/api/routes/health.py`](netcanon/api/routes/health.py) —
new lightweight readiness probe at `GET /health` (no `/api/v1`
prefix per orchestrator convention).  Returns
`{"status": "ok", "version": "<package version>"}` from
`importlib.metadata.version("netcanon")` with `"unknown"` fallback
for editable / source-only installs.

Tests: [`tests/integration/test_health_api.py`](tests/integration/test_health_api.py)
— 5 tests covering 200 status / response shape / Content-Type /
no-auth-required / version-is-string.  All pass; no regressions
in pre-existing test tiers.

#### Local build + smoke test (verified)

* `docker build -t netcanon:dev .` — built clean, 373MB image.
* `docker run -d -p 127.0.0.1:8765:8000 netcanon:dev` — startup
  in ~5s; loads 7 device definitions + 54 target profiles.
* `curl http://127.0.0.1:8765/health` →
  `{"status":"ok","version":"0.1.0"}`.
* `curl -X POST .../api/v1/sanitize -F source_vendor=aruba_aoss
  -F config=@... -F dry_run=true` → 10 substitutions, identical
  output to the CLI invocation (proves Phase 4.5 sanitiser
  pipeline works inside the container).
* `docker inspect` → `Health: healthy`, exit=0 from the probe.

#### Bug caught by local verification

The first build attempt crashed at startup with
`FileNotFoundError: Definitions directory not found: /app/definitions`.
The Dockerfile's `pip install` brings in the `netcanon` Python
package but the per-vendor `definitions/` directory at the repo
root isn't packaged with it (deliberately — those are
operational-grade YAMLs, not Python source).  Fix: explicit
`COPY definitions/ /app/definitions/` in the runtime stage.

This is the kind of bug that would have shipped to GHCR if we'd
trusted the CI build without local verification — operators
pulling the image would have hit immediate startup crashes.
Resolved before commit.

#### What you (operator) do for the first release

1. **Register PyPI Trusted Publisher** at
   https://pypi.org/manage/account/publishing/ — workflow file
   name `pypi-publish.yml`, environment name `pypi`.
2. **Create the `pypi` GitHub Actions environment** in repo
   settings (Settings → Environments → New environment → `pypi`).
3. **Push a release tag**: `git tag v0.1.0-rc1 && git push origin
   v0.1.0-rc1`.  Both workflows trigger automatically.
4. **Verify after publish** (~5 min later):
   * `docker pull ghcr.io/netcanon/netcanon:v0.1.0-rc1`
   * `docker run -p 8000:8000 ghcr.io/netcanon/netcanon:v0.1.0-rc1`
   * `curl http://localhost:8000/health`
   * `cosign verify ghcr.io/netcanon/netcanon:v0.1.0-rc1
     --certificate-identity-regexp "https://github.com/netcanon/netcanon"
     --certificate-oidc-issuer "https://token.actions.githubusercontent.com"`
   * `pip install netcanon==0.1.0rc1` (Trusted Publishing accepts
     pre-release tags).

#### What this wave does NOT do

* **Multi-arch builds** (`linux/arm64` for Apple Silicon / Pi
  hosts).  v0.1.0 ships `linux/amd64` only; multi-arch is a
  Phase 7 / post-launch follow-up.
* **Distroless base image.**  Distroless requires more careful
  dependency surgery (paramiko / cryptography pull in
  libffi / libssl + glibc); the size win (~50MB) isn't worth the
  fragility at v0.1.0.
* **Docker Hub mirror.**  GHCR only for v0.1.0; Docker Hub
  mirror is a follow-up if discovery becomes the bottleneck.
* **Reproducible build verification.**  The Dockerfile is
  reproducible-in-principle (pinned base image, pinned wheels);
  byte-identical-digest verification is a follow-up post-launch.

### AGENTS.md hard rule — review every push for PII

New hard rule codifying the discipline that surfaced from the Phase
1 PII scrub.  Applies to any push to an off-machine destination —
GitHub / GitLab / Bitbucket / GHCR / Docker Hub / PyPI, including
private repos that may later go public, including container images
and PyPI distributions.

The rule enumerates five review-scope categories:

* Operator personal data (emails, names, geographic identifiers in
  commit metadata + banner / comment text + operator-tied hostname
  patterns).
* Real-world network identifiers (public WAN IPs, real hostnames +
  MACs + serials, internal-domain references).
* Encrypted secrets + key material — explicitly noting these are
  **operator-traceable even when encrypted**; the Phase 1 scrub
  found 22 BEGIN PRIVATE KEY blocks + 14 cert chains + 48 ENC
  blobs in the leaked configs/ files.  Encrypted ≠ safe to publish.
* Accidentally-tracked operator backups under `configs/` /
  `devices/` / `schedules/` / `jobs/` (gitignore is not
  retroactive).
* Narrative-exposure — the meta-leak Phase 1 found in NOTICE.md /
  CHANGELOG.md prose: docs that "documented sanitisation" by
  literally naming the value being redacted ("real WAN IP `<X>`
  replaced with...").  The sanitisation narrative itself must not
  leak.

References the Phase 4.5 sanitiser as the canonical tool; flags
`git filter-repo` as the recovery tool when a secret has already
been committed (Phase 1 wave is the reference workflow).

Closes a discipline gap surfaced when the rebrand wave found 4
real backup files tracked at HEAD despite `.gitignore` covering
the directory — the rule prevents that recurring.

Includes judgment-based test-rerun guidance: sanitisation edits
can break tests in subtle ways (renamed fields, redacted-marker
introduction in narrative-asserting tests); the operator should
re-run the affected tier rather than assume sanitisation is
side-effect-free.

### Public release plan — Phase 5: operator-facing docs

Phase 5 from [`docs/RELEASE_PLAN.md`](docs/RELEASE_PLAN.md) — the
operator-facing doc surface that turns the matrix-honesty discipline
+ the Phase 4.5 sanitiser + the Tier-3 boundary into pages an
operator can actually read on landing.

#### Per-vendor "What works for me?" pages

Seven new operator pages under `docs/vendors/`, plus an index:

* [`docs/vendors/README.md`](docs/vendors/README.md) — index +
  certification table + page format definition.
* [`docs/vendors/cisco_iosxe.md`](docs/vendors/cisco_iosxe.md) —
  IOS-XE CLI (certified) + IOS-XE NETCONF (best_effort stub).
* [`docs/vendors/juniper_junos.md`](docs/vendors/juniper_junos.md) —
  Junos set-form, 5 distinct majors covered.
* [`docs/vendors/aruba_aoss.md`](docs/vendors/aruba_aoss.md) — AOS-S
  WB / WC / KB across 2530 / 2920 / 2930F / 2930M / 5400R.
* [`docs/vendors/arista_eos.md`](docs/vendors/arista_eos.md) — EOS
  4.21 through 4.30+ with MLAG / VXLAN / EVPN.
* [`docs/vendors/fortigate.md`](docs/vendors/fortigate.md) — FortiOS
  7.2.x / 7.6.x with explicit Tier-3 boundary call-out (firewall,
  NAT, VPN, UTM are deliberately deferred — Netcanon translates the
  shared-network-function subset).
* [`docs/vendors/mikrotik_routeros.md`](docs/vendors/mikrotik_routeros.md)
  — RouterOS 6.48.x and 7.18+ with renamed-port preservation.
* [`docs/vendors/opnsense.md`](docs/vendors/opnsense.md) — OPNsense
  25.x with paramiko-shell capture artifact handling.

Each page follows the same shape: TL;DR → translates well → lossy
paths → won't do → real-world fixtures → common gotchas → see-also.
LINKS rather than DUPLICATES the `CAPABILITIES.md` + `RESULTS.md`
sources of truth.

#### "How we test" page

[`docs/HOW_WE_TEST.md`](docs/HOW_WE_TEST.md) — operator-facing
narrative of the matrix-honesty discipline:

* The 4-tier test pyramid (unit / integration / e2e / desktop)
  + the 5th cross-mesh audit layer.
* The 8 variance classes (ALIGNED / CODEC_BUG / EXPECTED_LOSSY /
  EXPECTED_UNSUPPORTED / METHODOLOGY_ISSUE_under /
  METHODOLOGY_ISSUE_over / STRUCTURAL_ONLY / TRIVIAL_EMPTY).
* The trust claim, quantified ("zero CODEC_BUG cells across
  ~12,000 field-cells as of this commit").
* The honest follow-up: "the audit only covers cells we have
  fixtures for — submit a fixture that surfaces a path we don't
  yet test."

This is the page Phase 7's README rewrite will lean on for the
trust-signal lede.

#### Troubleshooting page

[`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) — diagnostic
flowchart for "I tried to translate a config and the result isn't
what I expected":

* Step 1: Read the migrate page banners (Tier-3 / unsupported-paths /
  lossy-paths) — most "missing content" questions answer here.
* Step 2: Is it actually a CODEC_BUG?  Symptoms that suggest yes
  vs no (the latter list is a feature: "this looks like a bug but
  isn't" patterns).
* Step 3: How to file a CODEC_BUG (delegates to BUG_REPORTING.md).
* Common error patterns + diagnoses (VLANs disappearing,
  hash-as-review-comment by design, LAG name reconciliation,
  paramiko-shell capture artifact, Tier-3 surface didn't translate).

#### `BUG_REPORTING.md` — the canonical fixture-submission workflow

[`BUG_REPORTING.md`](BUG_REPORTING.md) — top-level operator-facing
doc covering sanitise → verify → submit:

* Sanitise via the Phase 4.5 helper (CLI or HTTP API; both shown
  with concrete invocation snippets).
* Field-typed redaction table.
* Sanitiser limitations documented (sub-lossless round-trip;
  banner / comment text not redacted; IPv6-public deferred).
* Bug-report template requirements with sanitisation-check
  enforced via `bug_report.yml` checkboxes.
* Fixture-submission template requirements with provenance +
  licence confirmation enforced via `fixture_submission.yml`.
* SLA: 48hr triage, 7-day reproduction, fix wave, CHANGELOG
  credit.

This unblocks the bug-report friction collapse — operators go from
"hand-redact every line" to "30-second `curl -F` workflow."

#### AGENTS.md additions (operator-facing-doc discipline)

* New row in the Documentation Sync Checklist: "user-facing feature
  ships or changes" → update the relevant operator-facing docs
  (`docs/vendors/<vendor>.md`, `docs/CAPABILITIES.md`,
  `BUG_REPORTING.md`, `docs/TROUBLESHOOTING.md`,
  `docs/HOW_WE_TEST.md`).
* New hard rule: "Never ship a user-facing feature or capability
  change without updating the operator-facing docs that describe
  it."  Rationale: operator-facing prose drifts faster than code;
  "the docs lied to me" is the failure mode this rule prevents and
  the matrix-honesty discipline depends on.
* "See also" footer extended with the four new operator-facing
  docs (BUG_REPORTING + HOW_WE_TEST + TROUBLESHOOTING +
  vendors/).

#### What this wave does NOT do

* **Per-codec round-trip regression-guard suite for the
  sanitiser** — the plan mentioned running every real-capture
  fixture through `parse → sanitise → render → parse → assert no
  real-IPs/hashes/secrets remain`.  Phase 4.5 covered the Aruba
  pattern; expanding to all 9 codecs is a follow-up wave.
* **Walkthroughs** — `docs/walkthroughs/` (Cisco → Aruba migration,
  FortiGate → MikroTik, etc.) is Phase 4 / Phase 7 territory.
  Today's per-vendor pages are reference docs, not narrative
  walkthroughs.
* **Failure-mode showcase** — concrete examples of Tier-3 inputs
  with their banner output ("here's what FortiGate UTM looks like
  when you try to translate it; the operator sees this honest
  Tier-3 banner") is Phase 7 README content.

### Public release plan — Phase 4.5: sanitization tooling

Phase 4.5 from [`docs/RELEASE_PLAN.md`](docs/RELEASE_PLAN.md) — the
sanitization helper that gives operators a concrete invocation path
for the bug-report workflow (`BUG_REPORTING.md`, deferred to Phase 5,
depends on this existing).

The architecture is **integrated multi-invocation** (single source of
truth, three invocation paths) per the plan:

* **`netcanon.tools.sanitize` Python module** — the actual logic.
  Parses raw config via the registered codec, walks the canonical
  intent applying field-typed redactions, re-renders in the same
  vendor's format.  Counter-per-session stable: same input value
  always maps to the same redaction across the whole config (so
  cross-references survive — a hostname referenced 5 times gets the
  same redacted value 5 times).
* **CLI subcommand `netcanon sanitize`** (`netcanon/cli.py`) — for
  operators NOT running the FastAPI server (one-shot
  `pip install netcanon` users, CI / scripting).  Registered as
  `[project.scripts]` in `pyproject.toml`.
  ```
  netcanon sanitize -i my-config.txt -o sanitised.txt \
      --source-vendor cisco_iosxe_cli
  ```
* **HTTP API endpoint `POST /api/v1/sanitize`**
  (`netcanon/api/routes/sanitize.py`) — for operators running the
  server (Docker, embedded desktop, deployed instance).  Multipart
  form upload; returns text/plain sanitized config (default) or JSON
  audit (`dry_run=true`).
  ```
  curl -X POST http://localhost:8000/api/v1/sanitize \
      -F "source_vendor=cisco_iosxe_cli" \
      -F "config=@my-config.txt" \
      -o sanitised.txt
  ```

#### Field-typed redactions (counter-per-session stable)

| Canonical field | Replacement |
|---|---|
| `CanonicalIntent.hostname` | `device-N` |
| `CanonicalIntent.domain` | `example-N.test` |
| Public IPv4 anywhere | RFC 5737 docs ranges (192.0.2.x / 198.51.100.x / 203.0.113.x) |
| Private IPs (RFC 1918, ULA, link-local, loopback, multicast, CGNAT 100.64/10) | Preserved |
| `CanonicalLocalUser.hashed_password` | Format-preserving fake (Junos `$9$`, FortiGate `ENC`, crypt `$5$`/`$6$`, bcrypt `$2y$`, Cisco type-7 hex, Aruba SHA-1 hex) |
| `CanonicalSNMP.community` | `public_redacted_N` |
| `CanonicalSNMPv3User.auth_passphrase` | `REDACTED-AUTH-N` |
| `CanonicalSNMPv3User.priv_passphrase` | `REDACTED-PRIV-N` |
| `CanonicalRADIUSServer.key` | `REDACTED-RADIUS-N` |
| `CanonicalInterface.description` | `description redacted` |
| `CanonicalDHCPPool.dns_servers` (public entries) | docs range |
| `CanonicalStaticRoute.gateway` (public) | docs range |
| `CanonicalIntent.dropped_tier3_sections` | Stripped entirely |

#### `--dry-run` mode

Both CLI (`--dry-run` flag) and HTTP API (`dry_run=true` form field)
support audit-only mode: returns the full substitution table without
writing the rendered output.  Critical for trust — the operator
previews every replacement before committing.

#### Tests

* `tests/unit/tools/test_sanitize.py` (29 tests) — every redaction
  category (hostname / IPv4 public + private + docs + CGNAT /
  interface description / hash format-preserving for 5 prefix
  families / SNMP community + v3 / RADIUS / static routes / Tier-3 /
  counter stability / sanitize-purity / unknown-codec error path).
  Plus end-to-end against a real-capture Aruba fixture.
* `tests/unit/test_cli.py` (8 tests) — argparse + dry-run + write
  output + error handling + argv-list invocation.
* `tests/integration/test_sanitize_api.py` (7 tests) — HTTP endpoint
  contract (default text/plain / dry_run JSON / 400 unknown vendor /
  422 missing fields / X-Netcanon-Substitution-Count header /
  library-vs-HTTP consistency).

44 new tests; full unit + integration tiers pass clean (no
regressions in pre-existing 3,400+ tests).

#### Limitations (documented in module docstring)

* **Round-trip is sub-lossless.**  Parse drops Tier-3 content;
  render emits only what the codec models.  Sanitized output is the
  supported subset, not byte-identical with the original.
  Acceptable for bug reports — operators usually don't want to
  share Tier-3 content (firewall, NAT, VPN, QoS) anyway.
* **Banner text + raw comments not redacted.**  Not visible to the
  field-typed walk; most get parse-and-ignored.  Future enhancement
  could add a text-level post-render sweep for these surfaces.
* **One IPv6-public-redaction edge case** — current implementation
  redacts IPv4 only.  IPv6 addresses are preserved verbatim.
  IPv6-public detection + redaction is a follow-up (uses
  `ipaddress.IPv6Address` + RFC 3849 `2001:db8::/32` documentation
  range).

#### What this wave does NOT do

* **Web UI wrapper at `/sanitize`** — deferred to v0.2.0 contingent
  on operator-feedback signals showing the friction is real.  Same
  shared library; thin presentation layer if added.
* **Per-fixture round-trip regression-guard suite** — the plan
  mentioned running every real-capture fixture through
  `parse → sanitise → render → parse → assert no real-IPs/hashes/
  secrets remain`.  The end-to-end fixture test covers the Aruba
  pattern; expanding to all 9 codecs is a Phase 5 follow-up under
  the `BUG_REPORTING.md` workflow that depends on this.

### Public release plan — Phase 2: project identity foundation

Phase 2 from [`docs/RELEASE_PLAN.md`](docs/RELEASE_PLAN.md) —
establish the project identity surfaces operators encounter before
they read any code.  All in-repo deliverables; the GitHub-side
surfaces (repo description, Topics) are documented for the operator
to apply when the repo goes public.

* **Tagline locked.**  *"Multi-vendor network config translator with
  a verifiable cross-vendor audit."*  Captures action, scope, and
  differentiator in 78 chars.  Applied to:
  * `pyproject.toml` `description` field (replaces the older
    "backup and translation engine" phrasing)
  * `README.md` header (replaces the equivalent line + adds a
    one-paragraph elevator pitch with the explicit vendor list,
    Tier-3 boundary link, and `docs/COMPARISON.md` pointer)
* **`docs/IDENTITY.md`** (new) — source-of-truth for tagline,
  GitHub repo description (extended 174-char form for the About
  section), GitHub Topics list (13 topics mirroring
  `pyproject.toml` keywords), and the logo design brief.  When
  the repo goes public, the operator copies the description +
  Topics from this doc to GitHub Settings.
* **`docs/COMPARISON.md`** (new) — positioning vs adjacent tools
  (Batfish, Capirca / Aerleon, NAPALM, Netmiko / Nornir, Ansible
  network modules, NetBox / Nautobot, ciscoconfparse).  Comparison
  table + "where we compete vs where we're complementary vs what
  we won't do" sections.  Surface area for operators arriving from
  "I'm looking for a Capirca alternative" — gives the
  matrix-honesty discipline a positioning frame.
* **Logo brief** (in `docs/IDENTITY.md`) — three concept directions
  (mosaic cell / N letterform / network constellation) with
  operator-tool visual constraints (deep slate primary + amber
  accent; geometric sans wordmark; 16×16 favicon legibility; no
  clichés).  Recommends Direction A (mosaic cell — ties to the
  matrix-honesty cell metaphor).  Logo not yet commissioned;
  brief is the spec when ready.  Includes a starter Midjourney
  prompt for AI-generated drafts.
* **`AGENTS.md` "See also" footer** updated with both new docs.

#### What this wave does NOT do

* **Set the GitHub repo description / Topics.**  Those are GitHub
  Settings actions; documented in `docs/IDENTITY.md` for the
  operator to apply when the repo goes public.
* **Commission a logo.**  Brief is the spec; actual logo
  generation is a separate workflow (designer / Midjourney /
  in-house).
* **Rewrite the README.**  Phase 7 of the release plan is the
  full README rewrite (asciinema, before/after example, full
  matrix-honesty trust signal).  This wave updates the tagline
  + elevator pitch only.

### Public release plan — Phase 1.5: package directory + import + env-var rename

The Phase 1 wave deliberately deferred the directory rename to keep
the diff scope-clean.  This wave executes it.  No code-behaviour
changes; pure structural rename + import path + env var prefix.

#### What renamed

* **Package directories** (via `git mv`):
  * `netconfig/` → `netcanon/`
  * `netconfig_desktop/` → `netcanon_desktop/`
* **Import paths** in 352 files (1,508 `netconfig` references at
  word boundaries → `netcanon`; 114 `netconfig_desktop` references
  → `netcanon_desktop`).  Word-boundary regex protected
  `docs/archive/netconfigreport.txt` references from accidental
  rewrite.
* **Env var prefix** (20 occurrences): `NETCONFIG_*` → `NETCANON_*`
  in `.env.example`, `netcanon/config.py`,
  `netcanon_desktop/preferences.py`, and operator-facing prose in
  `AGENTS.md` + CHANGELOG.  This IS a breaking change for any
  operator with shell scripts or `.env` files using `NETCONFIG_*`
  — it ships pre-public so no real users are affected.
* **`pyproject.toml`** structural fields: `[tool.setuptools.packages.find]
  include = ["netcanon*", "netcanon_desktop*"]`,
  `[tool.setuptools.package-data] netcanon = [...]`,
  `[tool.coverage.run] source = ["netcanon"]`.
* **`localStorage` keys** (web platform): `netconfig.theme.v1` →
  `netcanon.theme.v1` and `netconfig.activeJob` →
  `netcanon.activeJob`.  Pre-public; no real-user state migration
  required.  The `v1` suffix is preserved as the migration anchor
  for future schema changes.
* **Logger name**: `logging.getLogger("netconfig")` →
  `getLogger("netcanon")` (one occurrence, plus its assertion in
  `tests/unit/test_logging_config.py`).
* **Log filename**: `netconfig.log` → `netcanon.log` under
  `%APPDATA%\Netcanon\` on Windows desktop.

#### Deliberately preserved

* **`docs/archive/`** (5 files) — historical reports.  Their
  reference to "netconfig" reflects project state at archive time;
  rewriting would falsify the historical record.  Future
  contributors reading the archive understand the rename happened
  in this wave; no need to retroactively rewrite the archive.
* **`docs/archive/netconfigreport.txt`** filename — same rationale.
  The word-boundary regex `\bnetconfig\b` correctly skipped this
  filename during the substitution pass.

#### Test verification

Reinstalled editable package (`pip install -e .`) — `netcanon
0.1.0` builds + installs cleanly.  PEP 639 license-classifier
collision (the deprecated
`License :: OSI Approved :: MIT License` classifier alongside the
new `license = "MIT"` field) was caught at install time and
removed from `pyproject.toml`.  Unit + integration tiers pass
clean post-rename — every import resolved correctly under the new
package name.

#### Outstanding follow-ups for the public release

* Phase 1's "history rewrite" is **already complete** (separate
  wave; ran `git filter-repo` for blob + commit-message text +
  author-email scrub + `configs/` purge).  The Phase 1 entry's
  "Pending" wording is historically accurate — the rewrite landed
  shortly after.
* Local git config still holds the personal author identity for
  this clone; future commits in this repo will reintroduce it
  unless operator runs `git config user.email
  "noreply.netcanon@gmail.com"` + `git config user.name "Netcanon
  contributor"` (repo-local; doesn't touch global config).

### Public release plan — Phase 1 pre-flight + rebrand to Netcanon

First pass at the public-launch pre-flight checklist from
[`docs/RELEASE_PLAN.md`](docs/RELEASE_PLAN.md) Phase 1, plus the
project rebrand surfaced by the name-conflict check in the same
checklist.  No code-behaviour changes; brand + structural
artefacts + PII scrub.

#### Pre-flight artefacts authored

* **`LICENSE`** — MIT.  Vendor-tooling space is overwhelmingly
  permissive-licensed (Netmiko, NAPALM, Nornir all MIT).
* **`CODE_OF_CONDUCT.md`** — Contributor Covenant 2.1, fetched
  verbatim from `contributor-covenant.org` (CC BY 4.0; standard
  OSS template).  Enforcement contact uses an `OWNER/REPO`
  placeholder to swap before public launch.
* **`CONTRIBUTING.md`** — substantive contributor guide; lifts
  from `AGENTS.md`'s doc-sync checklist +
  `docs/adding-a-canonical-field.md`.  Walks the three
  contribution paths (fixture / codec / canonical field) and
  points at matrix-honesty as the project's North Star.
* **`SECURITY.md`** — extended with two new sections:
  * "Reporting a Vulnerability" — uses GitHub's private
    vulnerability reporting flow; no email contact required.
  * "Supply-Chain Integrity (forward-looking)" — documents
    cosign + SBOM + Trusted Publishing items planned for Phase 6.
* **`.github/PULL_REQUEST_TEMPLATE.md`** — references the
  doc-sync checklist + the matrix-honesty self-check.
* **`.github/ISSUE_TEMPLATE/`** — 4 forms: `config.yml` (blank
  issues disabled + Capabilities / Contributing / private
  security advisory routing), `bug_report.yml` (sanitization
  required), `feature_request.yml` (in-scope vs Tier-3-deferred
  guidance), `fixture_submission.yml` (separate from bug
  reports — the fixture IS the contribution).
* **`.github/workflows/ci.yml`** — minimal CI: pytest unit +
  integration tiers across Python 3.11 / 3.12 / 3.13, plus
  sdist + wheel build with `twine check`.  E2E + desktop tiers
  deferred to a follow-up CI wave.
* **`pyproject.toml`** — extended with PyPI-required metadata:
  dist name (`netcanon`), license (MIT), readme, authors,
  keywords, classifiers, `[project.urls]` block (all
  `OWNER/REPO`-templated).

#### Rebrand: NetConfig → Netcanon

The pre-flight name-conflict check found `netcanon` taken on
PyPI (squatter) and `github.com/netcanon` claimed.  After
scoring candidates against availability + collision +
on-brand-ness, **Netcanon** won: 8 letters, fully available
across PyPI / GitHub / Docker Hub / relevant TLDs, and the name
carries the project's defining discipline ("canon" =
canonical-intermediate model + authoritative matrix-honesty
reference).

* **181 substitutions** across 76 tracked files: `NetConfig` →
  `Netcanon` in prose, page titles, copyright lines, mutex
  names, installer scripts.
* **PyPI dist name** flipped: `name = "netcanon"` in
  `pyproject.toml`.
* **Self-reference** `desktop-build = ["netcanon[desktop]"]`
  updated.
* **`pip install netcanon`** → `pip install netcanon` in
  install instructions (2 occurrences).

Deliberately **not yet renamed** (deferred to Phase 1.5 to keep
this wave's diff scope-clean):

* Python package directories `netcanon/` and
  `netcanon_desktop/` (and all import paths).
* Env var prefix `NETCANON_*` (operator-facing; rename =
  breaking change for any existing shell scripts).

#### PII scrub

Pre-flight real-IP / personal-identifier audit surfaced multiple
narrative-exposure points + an incomplete sanitization in a
public fixture.  Scrubbed:

* **Public fixture leak fixed.**
  `tests/fixtures/real/fortigate/user_contrib_fg100e_fos7213.conf`
  was supposed to have all real-domain occurrences sanitized
  to `example.test` per `NOTICE.md`'s claim, but 6 occurrences
  remained (lines 3827 / 28216 / 28220 / 28236 / 28495 /
  28750).  An additional bare `*.lan` form was also
  undocumented.  Both now replaced with the claimed sanitized
  values.
* **Narrative-exposure scrub.**  `NOTICE.md`, `CHANGELOG.md`,
  `CROSS_MESH_RESULTS.md`, `RESULTS.md`,
  `phase4_findings_*.md`, `user_smoke_findings.md`, and
  `_phase4_runs/latest.json` all literally NAMED the real
  values they claimed to have sanitized ("real WAN IP `<IP>`
  replaced with...").  Replaced with redaction markers
  (`[redacted-email]`, `[redacted-domain]`,
  `[redacted-WAN-IP]`) in 42 narrative occurrences.

#### Live operator backups untracked

`git rm --cached` of 4 files in `configs/` that were tracked at
HEAD despite `.gitignore` covering the directory (gitignore is
not retroactive — files in the index stay tracked):

* Two empty Cisco backup placeholders.
* One 491-line Cisco config (2 type-8/9 password hashes).
* One 35,095-line FortiGate config (48 ENC encrypted secrets,
  22 BEGIN PRIVATE KEY blocks, 14 certificate blocks, 4 SSH
  public keys, 46 MAC addresses, 5 email addresses, the
  operator's real WAN IP).

Files remain in the working tree (operator's local backups);
they are no longer in the git index.  **History still contains
them** — the next wave (history rewrite via `git filter-repo`)
closes that gap.

#### Findings surfaced for follow-up

* **Author metadata leak.**  `git log --format='%ae'` shows 352
  of 353 historical commits authored by a personal `gmail.com`
  address.  History rewrite via `git filter-repo
  --email-callback` is the next wave's main task.
* **Real-IP audit otherwise clean.**  Outside the configs/
  files now untracked, every "looks-public" IP in the working
  tree is either RFC 5737 documentation range, well-known
  public DNS/NTP retained as fixture grammar, or sourced from
  public corpora (Batfish, HPE Community, etc.) with
  documented provenance in `tests/fixtures/real/NOTICE.md`.
* **Project-name conflicts.**  PyPI: `netcanon`,
  `netcanon-translator`, `net-config` all taken (squatters).
  GitHub `/netcanon` claimed.  Resolved by rebrand.
* **44 untracked phase4-run JSONs** in
  `tests/fixtures/real/_phase4_runs/` contain PII narrative
  refs.  Already gitignored (`.gitignore` line 68 carves out
  `latest.json`); local-only ephemera, no commit-leakage risk.
  Operator may delete locally as a separate cleanup.

#### What this wave does NOT do

* **Git history rewrite.**  Pending; needs `filter-repo`
  invocation to (a) scrub `configs/Fortigate` +
  `configs/Cisco` from all historical commits, (b) replace
  personal email in author/committer metadata across all 352
  commits, (c) replace `[redacted-domain]` /
  `[redacted-WAN-IP]` / `[redacted-email]` blob text in
  history.  Destructive but safe — no external clones exist.
* **Package directory rename.**  `netcanon/` → `netcanon/`
  (and `netcanon_desktop/` → `netcanon_desktop/`) is Phase
  1.5.

Test state post-rebrand: unit + integration tiers pass clean,
no regressions from the substitution pass.

### Added (Desktop Preferences mini-wave)

The desktop-parity audit identified 7 actionable items in the
"necessary differences" category — desktop-platform-specific surfaces
the web platform doesn't need.  This wave addresses them as a coherent
feature so operator workflow on the desktop matches the configurable
surface that the web platform exposes via `NETCANON_*` env vars.

* **Item 1 — `DesktopPreferences` model** (`netcanon_desktop/preferences.py`):
  Pydantic model with optional path overrides (`configs_dir`,
  `definitions_dir`, `data_dir`), `port` (1024–65535 spinbox range),
  `open_in_editor` toggle, and a forward-compatible
  `open_browser_on_start` field.  `load()` is corruption-tolerant —
  malformed JSON returns factory defaults so the desktop never refuses
  to start because of a botched preferences file.
* **Item 2 — Preferences dialog UI** (`netcanon_desktop/preferences_dialog.py`):
  PySide6 `QDialog` with browse buttons, port spinner, toggles, and an
  "Open Configs Folder" convenience button.  Each interactive widget
  carries `setObjectName()` per the `pref-dialog-<field>-<action>`
  convention (the desktop equivalent of `data-testid`).
* **Item 3 — `Settings.data_dir` field + `effective_data_dir` property**
  (`netcanon/config.py`): backward-compatible explicit override for
  the data-root directory holding `jobs/`, `schedules/`, `devices/`.
  When `None`, falls back to the historical `configs_dir.parent`
  derivation.  `netcanon/main.py` reads through `effective_data_dir`
  at all four lifespan call sites; an `inspect.getsource` regression
  guard catches future copy-pastes of the old derivation.
* **Item 4 — Tray menu surface** (`netcanon_desktop/tray.py` +
  `app.py`): four-item menu (Show / Preferences… / Open configs folder /
  Quit).  The two new callbacks are optional kwargs so existing tray
  construction stays backward-compatible.
* **Item 5 — Single-instance enforcement** (`netcanon_desktop/single_instance.py`):
  Windows named-mutex guard (`Global\NetcanonSingleInstance_v1`)
  prevents a second `netcanon_desktop` from launching and silently
  failing on the port-bind.  No-op on non-Windows so test suite runs
  on Linux / macOS.
* **Item 6 — Documentation** (`netcanon_desktop/README.md`): module
  layout updated to list all five new files; settings table extended
  with the data-root row; new Preferences and Uninstall behaviour
  sections.
* **Item 7 — Cross-cutting docs** (`AGENTS.md`): Platform-Specific
  Exceptions section gains the Preferences dialog + single-instance
  entries; new "Deliberately omitted (preventive)" subsection
  documents telemetry / auto-update / file associations / crash
  reporting as explicit OUT OF SCOPE so future contributors don't add
  them speculatively.

The audit found zero parity violations — these are necessary
desktop-only surfaces (the web platform's equivalents are the
`NETCANON_*` env vars and the inability to launch two web servers
without picking a different port).  The wave preserves that parity by
giving desktop operators an equivalent configuration surface to what
web operators already have.

New tests added: 47 in 4 new modules (`test_preferences.py` 16,
`test_preferences_dialog.py` 16, `test_single_instance.py` 9,
`test_data_dir_resolution.py` 6) plus 14 in 3 extended modules
(`test_settings.py` +5, `test_tray.py` +4, `test_app.py` +5).  Suite
state for the unit + integration + desktop tiers post-wave: 3403
passed, 57 skipped, 0 failed.  No matrix regen.  No codec touch.
Pipeline-stage signatures unchanged.

### Validation cleanup wave (post-`170a2c2` audit follow-ups)

After the comprehensive validation pass (commit `170a2c2`), the
matrix-honesty discipline applied to the validation findings
themselves surfaced a 21-item open-work inventory.  This batch
addresses the actionable subset:

* **Commit `07086b1`** — `cisco_iosxe_cli` `/routing-instances/instance`
  declaration moved from `UnsupportedPath` to `LossyPath`.  Old
  declaration claimed "wire-up deferred" but parse + render had been
  shipping; Wave 10β-B (commit `40de39c`) re-flipped the per-pair
  YAML disposition months prior.  Codec capability matrix was
  contradicting both the codec's own code AND the cross-vendor
  expectation YAML.  6 regression-guard tests pin the corrected
  declaration shape.
* **Commit `e7c5378`** — `docs/CAPABILITIES.md` table row for the
  same field aligned with the corrected matrix.
* **Commit `298e1ca`** — Four historical `*.txt` files (12-lens
  architectural analysis, companion review report, auto-actionable
  triage, vendor-config research notes) moved from top-level to
  `docs/archive/` with a `README.md` explaining provenance and the
  current-state authoritative-source pointers.
* **Commit `7ffa1c5`** + this commit — Two E2E tests (originally
  reported as "pre-existing flakes" by Wave 11-A and Wave 7c-G)
  diagnosed as deterministic dead-premise failures: both depend on
  Cisco `Loopback0` flowing through Aruba's auto-drop path, but
  commit `5f4855a` (May 2026) added Aruba loopback support so
  `Loopback0 → loopback1` translates cleanly with no warning and no
  auto-drop.  Skip-marked with rationale; Wave-12 follow-up to
  re-author against a target codec that genuinely cannot render
  loopbacks (or delete; the equivalent UI surfaces are exercised by
  sibling tests).
* **Commit `30beb92`** — Two new canonical `CanonicalInterface`
  fields (`dhcp_client_v6` for IPv6 DHCPv6 / SLAAC mode, and
  `tunnel_type` for GRE / EoIP / IPIP / IPSEC / VXLAN encap
  discriminator) closing two long-standing schema gaps.  Wired
  across 5 codecs each (parse + render) with `LossyPath`
  declarations on codecs without native grammar.  Junos
  switch-options sub-paths reframed from "deferred" to "documented
  architectural deferral" — same Tier-3-shape rationale (EVPN-VXLAN
  underlay primitives require deployment-topology context the
  canonical model deliberately doesn't carry).  46 new tests.

Cumulative validation cleanup: matrix integrity + capability-honesty
contracts now align across codec.py declarations, parse/render code,
cross-vendor expectation YAMLs, CAPABILITIES.md table rows, and
regression-guard tests.

Suite: 3229 → 3275 passed (+46 tests across the wave), 57 skipped
(2 newly skip-marked; pre-existing E2E that no longer have a real
target configuration), 0 failed.  Matrix delta: CODEC_BUG holds at
0; minor TRIVIAL_EMPTY shift (+41) and STRUCTURAL_ONLY shift (-56)
from the new fields routing through the trivial-empty classification
correctly on fixtures that don't exercise IPv6-DHCP or non-GRE
tunnels.

### Documented (forward-looking)

* **Commit `1fe1cfd`** — `docs/RELEASE_PLAN.md` captures the
  strategic plan for taking the project public.  Pre-flight
  checklist, maximum-bug-report-surface-area framing for the network-
  engineer audience, packaging tier strategy (Docker primary, PyPI
  secondary, MSI tertiary), concrete release sequence, what-not-to-do
  guidance, when-to-start triggers.  Forward-looking; not yet
  started.  Linked from `AGENTS.md` "See also" so post-compaction
  contexts find it cold.

### Added (Wave 11 — operator-visible notification when Tier-3 sections are dropped)

The canonical model classifies firewall_rules / nat_rules / vpn /
routing_protocols as **Tier 3 — "parse for display, never auto-render"**
(see `netcanon/migration/canonical/intent.py:39-41`).  Codec parsers
silently skipped these stanzas because there's no canonical surface
to populate.  Operators pasting a Cisco config containing
`ip access-list extended OUTSIDE_IN ... ip access-group OUTSIDE_IN in`
into the migrate page got a "successful migration" with zero
notification that ACL blocks had been dropped — exactly the silent
drop the project's matrix-honesty discipline calls out as drift.

This wave closes the notification gap from two angles:

* **W11-A — capability-matrix coverage** (commit `21a0f38`).  Three
  ACL-capable codecs that didn't declare ACL/firewall xpaths as
  unsupported now do:
  * `arista_eos`: `/access-list/extended`, `/access-list/standard`,
    `/access-list/ipv6`
  * `cisco_iosxe` (NETCONF): `/access-list`, `/firewall`
  * `cisco_iosxe_cli`: `/access-list/extended`, `/access-list/standard`,
    `/access-list/ipv6`, `/firewall`, `/nat`
  This makes the existing UI "Unsupported paths" panel surface the
  ACL/firewall/NAT gap on those codecs.  Parallel coverage with the
  5 codecs that already declared (`aruba_aoss`, `fortigate_cli`,
  `mikrotik_routeros`, `opnsense`, `juniper_junos`).  20 new
  capability-matrix regression-guard tests.

* **W11-B — parser-level Tier-3 stanza detection** (commit `c632bdc`).
  New shared helper `netcanon/migration/_tier3_detection.py` with
  per-vendor pattern sets (`_iosxe_cli`, `_fortios`, `_junos`,
  `_routeros`, `_opnsense`, plus a `_iosxe_xml` no-op stub for the
  NETCONF codec).  New `CanonicalIntent.dropped_tier3_sections:
  list[str]` field populated by every parser at entry.  Operator-
  visible UI banner in `migrate.html` ("⚠ Tier-3 sections detected
  in source") with `migrate-tier3-banner` testid.  Critical scope
  property: **the new field is OUTPUT-ONLY** — no render code path
  consumes it.  This is notification, not translation.  Firewall /
  NAT auto-translation remains explicitly out of scope (see the
  Cluster E.X / firewall-translation architectural decision in the
  Wave 9 / Cluster E.1 entries).  37 new tests.

Cumulative Wave 11 matrix delta: **zero**.  Notification-only
changes; no migration semantics altered.  Suite: 3186 → 3223 passed
(+37 tests across both sub-waves), 57 skipped, 0 failed.

Honest behaviour delta: operators pasting a Cisco/Arista config
with ACL blocks now see the dropped sections enumerated in the
migrate page UI.  Previous behavior silently dropped them with no
operator-visible signal.

### Fixed (Wave 10γ — three methodology gaps closed; matrix integrity at 91% noise reduction)

Wave 10β agents flagged three methodology gaps during their
disciplined per-source-vendor investigation.  Three Wave 10γ
agents closed all of them:

* **Sub-field cascade gap in TRIVIAL_EMPTY** (commit `2fe7902`).
  Wave 10α's TRIVIAL_EMPTY classification fired only at parent-list
  level; sub-fields of parents with rows but empty per-record data
  cascaded as `preserved` and generated false METHODOLOGY_under
  signals.  Phase 1 now records `subfields_with_data: list[str]`
  per list-parent (sorted union of sub-field names with non-empty
  data on at least one record across both sides); Phase 4's
  `actual_disposition` cascade consults it and returns
  `"trivially_preserved"` for sub-fields outside the set.  Cleared
  ~1014 false METHODOLOGY_under cells.
* **`_list_drift_summary` cap-of-5 truncation** (commit `2fe7902`).
  When 6+ records drifted in a list field, records 6+ were
  invisible to Phase 4's per-record drill-down so their sub-fields
  appeared preserved.  Cap removed entirely; display-side
  truncation in `_md_inline` (200 chars) keeps the matrix `.md`
  legible without losing data.  Surfaced 5 previously-hidden
  CODEC_BUG cells which γ-3 then resolved.
* **`cisco_iosxe` (NETCONF) target codec is a Phase 0.5 stub**
  (commit `f81f3a5`).  Render only emits openconfig-interfaces
  subtree; many fields preserve trivially because the target
  literally doesn't emit.  Capability matrix now declares 16
  top-level field xpaths + 11 granular xpaths as `unsupported`
  (the un-rendered subtrees: hostname, domain, dns_servers,
  ntp_servers, timezone, syslog_servers, vlans, static_routes,
  snmp/{community,location,contact,trap-host,v3-user}, lags,
  local_users, radius_servers, dhcp_servers, routing_instances,
  vxlan_vnis, evpn_type5_routes).  New regression-guard test
  asserts every un-rendered field is declared unsupported —
  prevents future drift between render expansion and matrix.
* **5 newly-surfaced CODEC_BUG cells** (commit `2863db8`):
  * `_canonical_lag_name` regex extended to recognise `agg<N>`
    (FortiGate aggregate) and `bond<N>` (RouterOS bonding) as
    LAG-name canonical equivalents to `ae<N>` / `Port-channel<N>`
    / `trk<N>` — Wave 9β coverage gap.
  * IPv6 link-local elision on opnsense render is intentional
    vendor schema policy (FreeBSD auto-derives `fe80::/64`
    from MAC per RFC 4862 SLAAC); 2 YAML dispositions flipped
    `good → lossy` with vendor-doc rationale.
  * cisco_iosxe_cli IPv6 scope classifier now recognises
    `fe80::/10` prefix per RFC 4291 §2.4 (case-insensitive;
    tolerant of malformed double-`::` fixtures).

### Cumulative Wave 10 (α + β + γ) matrix arc

The Phase 4 matrix-honesty audit, completed across three sub-waves:

  ALIGNED                    2298 → 1210 (-1088 — false-positive
                                          ALIGNED reclassified to
                                          TRIVIAL_EMPTY)
  CODEC_BUG                  0    → 0    (preserved through wave;
                                          γ-1 surfaced 5 hidden
                                          drifts which γ-3 closed)
  EXPECTED_LOSSY             940  → 949  (+9; small wave-9β/10γ
                                          reclassifications)
  EXPECTED_UNSUPPORTED       512  → 532  (+20; cisco_iosxe NETCONF
                                          honesty correction)
  METHODOLOGY_ISSUE_under    7382 → 705  (-6677, 91% reduction)
  METHODOLOGY_ISSUE_over     23   → 23
  STRUCTURAL_ONLY            810  → 810
  TRIVIAL_EMPTY              0    → 7736 (new class, severity ok)

Honest cell breakdown:
  Real verified preservation:          1210 cells
  Real over-claims (Wave 10β residual): 705 cells
  Test-data gaps (no fixture data):    7736 cells
  Documented lossy/unsupported:        1481 cells
  Structural / methodology delta:       833 cells
  Codec bugs:                             0 cells

Total: 11965 field-cells classified across 376 cross-mesh cells.

### Added (Wave 10α — TRIVIAL_EMPTY variance class for matrix-honesty restoration)

The Phase 4 reconciliation classifier grows from 7 to 8 variance
classes with the addition of `TRIVIAL_EMPTY` (severity `ok`).
Definition: "field is empty on both source AND target sides; no
data to validate the YAML's disposition claim against; cell is
benignly aligned by absence of data".

The class addresses a methodology-noise problem that surfaced
when Wave 9γ-A flagged ~200 candidate METHODOLOGY_ISSUE_under
cells.  Audit before Wave 10β dispatch revealed the actual
candidate count was 7382 (post-Wave-7c) — and 4169 of those were
trivially-empty cells where neither source nor target had any
data for the field, so the cell aligned by absence rather than
by real cross-vendor preservation.  These cells were generating
false "YAML over-claim" signals.

The classifier now distinguishes three actual states
(`preserved` / `drifted` / `trivially_preserved`).  When both
sides have zero data (lists with `source_count == 0 AND
target_count == 0`, dicts similarly, scalars matching `_is_empty
_zero_state`), the actual state is `trivially_preserved` and
the variance is `TRIVIAL_EMPTY` regardless of YAML disposition.

ARCHITECTURE.md "Cross-mesh fidelity audit harness" subsection
gains the 8th variance-class bullet documenting the new class.

Tooling-only change.  No codec / canonical / YAML touches.

### Fixed (Wave 10β — selective YAML re-flips on residual real over-claims)

Four parallel per-source-vendor agents walked the post-Wave-10α
residual of 1769 METHODOLOGY_ISSUE_under cells (real over-claims:
populated source data preserves through round-trip, YAML claims
lossy/unsupported).  Three findings landed:

* **`cisco_iosxe_cli → opnsense / dns_servers`** (commit `40de39c`).
  YAML claimed OPNsense codec didn't parse/emit `<dnsserver>`;
  capability stale (both directions wired in earlier wave).
  Re-flip `lossy → good`.
* **`cisco_iosxe_cli → juniper_junos / routing_instances`** plus
  7 sub-fields (commit `40de39c`).  YAML cited stale capability-
  matrix entry claiming `/routing-instances/instance` parse-and-
  ignore; cisco_iosxe_cli parse populates from `vrf definition`
  blocks (rd, RT import/export, description, l3_vni); Junos
  render wires through to `set routing-instances <name>
  instance-type vrf`.  Re-flip `unsupported → good`.
* **`opnsense → {arista, aruba, cisco_iosxe_cli, fortigate} /
  snmp`** plus mikrotik_routeros source ntp_servers / opnsense
  source dns_servers across multiple targets (commit `ab4d321`).
  Multiple SNMP sub-surface preservation paths the codec wired in
  Wave 7c hadn't propagated to expectation YAMLs.  Re-flips on 16
  trios across opnsense + mikrotik sources.
* **`fortigate_cli → {arista, cisco_iosxe_cli, opnsense} / vlans`**
  (commit `888ca19`).  Top-level `vlans` list round-trips at list+id
  level across all populated fixtures; existing reasons cited
  sub-field caveats already captured at sub-field disposition
  level.  Re-flip `lossy → good`.

Wave 10β-A (arista + aruba sources) returned no-op after
disciplined cross-fixture verification — every candidate was
justified by a real vendor wire-format constraint not exercised
by current fixtures.  Same pattern as Wave 9γ-A/B.

Cumulative Wave 10 matrix delta:

  ALIGNED                    2298 → 1479 (-819, real-truth restoration)
  CODEC_BUG                  0    → 0 (preserved through wave)
  EXPECTED_LOSSY             940  → 940
  EXPECTED_UNSUPPORTED       512  → 512
  METHODOLOGY_ISSUE_under    7382 → 1716 (-5666, 77% reduction)
  METHODOLOGY_ISSUE_over     23   → 23
  STRUCTURAL_ONLY            810  → 810
  TRIVIAL_EMPTY              0    → 6485 (new)

The ALIGNED drop reflects 819 cells correctly reclassified from
false-positive ALIGNED (trivial-empty alignment) to TRIVIAL_EMPTY.
The 5666-cell METHODOLOGY_under reduction is the matrix-honesty
win the wave was scoped to deliver.

### Methodology follow-ups identified during Wave 10β (deferred)

Three comparator/codec gaps surfaced by per-source-agent
investigation, queued as Wave 10γ candidates if pursued:

* **Sub-field cascade gap in Wave 10α** — TRIVIAL_EMPTY fires
  at parent list level only; sub-fields with empty data on
  fixtures with non-empty parent lists cascade as preserved
  (e.g. `interfaces[].switchport_mode` when interfaces list has
  rows but no row populates the switchport surface).
* **`_list_drift_summary` cap-of-5 truncation** in
  `tools/run_full_mesh.py:438-443` — when 6+ records drift, the
  6+ are invisible to the per-record drift drill-down so their
  sub-fields appear preserved.
* **`cisco_iosxe` (NETCONF) target codec is a Phase 0.5 stub** —
  render only emits openconfig-interfaces subtree; many fields
  preserve trivially because the target literally doesn't emit.
  Either complete the render wire-up (substantial) or
  reclassify the codec's CapabilityMatrix more aggressively.

### Added (Cluster E.1 — DHCP-server parse + render for arista_eos and juniper_junos)

The canonical model carries `intent.dhcp_servers: list[CanonicalDHCPPool]`
(Tier 2) and four codecs (`cisco_iosxe_cli`, `fortigate_cli`,
`mikrotik_routeros`, `opnsense`) round-tripped DHCP pools before this
wave.  Two more codecs join: `arista_eos` and `juniper_junos`.

* **arista_eos** (commit `90c093c`) — `ip dhcp pool` grammar parse +
  render.  Cisco-derived syntax with one EOS-specific addition:
  inline `range <start> <end>` (Cisco relies on `ip dhcp excluded-
  address` instead).  Parse tolerates both dotted-mask
  (`network 10.0.0.0 255.255.255.0`) and CIDR (`network 10.0.0.0/24`)
  forms; render emits dotted-mask to mirror the cisco_iosxe_cli
  convention.  Lease-time emitted as full d/h/m triple
  (`lease 0 12 0`); `lease infinite` round-trips via the DHCP
  `0xFFFFFFFF` sentinel.  Cited against Arista EOS User Manual,
  "DHCP and DHCP Relay".

* **juniper_junos** (commit `5bcc89c`) — modern `set access
  address-assignment pool` + `set system services dhcp-local-server
  group` two-stage form for render; parser also accepts the legacy
  `set system services dhcp` form (deprecated on M/MX/SRX since
  ~2010 but still valid on EX 4.x trains) so old captures still
  produce `CanonicalDHCPPool` records.  Stable pool/group names
  derived from `pool.interface` (sanitising `/` and `.` to `_`)
  with `pool<idx>` fallback by list position.  The runtime's
  network-prefix matching between groups and pools is approximated
  with an equal-name link plus a 1-to-1 fallback for hand-authored
  cross-vendor inputs with mismatched names.  Multi-`range` Junos
  pools (which the canonical single `start_ip`/`end_ip` slot
  cannot fully represent) collapse to the first range on parse;
  surplus ranges drop with a comment.  Cited against Junos OS
  Network Management — "Configuring Address-Assignment Pools".

Cumulative DHCP coverage post-Cluster-E.1:

  parse  render
  ✓      ✓      cisco_iosxe_cli   (pre-existing)
  ✓      ✓      fortigate_cli     (pre-existing)
  ✓      ✓      mikrotik_routeros (pre-existing)
  ✓      ✓      opnsense          (pre-existing)
  ✓      ✓      arista_eos        (Cluster E.1)
  ✓      ✓      juniper_junos     (Cluster E.1)
  ✗      stub   aruba_aoss        (intentional — AOS-S is a relay
                                    platform per the codec's
                                    architectural decision; render
                                    emits comment block, no native
                                    server stanza)
  ✗      ✗      cisco_iosxe       (NETCONF; OpenConfig DHCP YANG
                                    is sparse; deferred)

Matrix delta from regen (commit `5fbb106`): no CODEC_BUG
regression; ALIGNED unchanged at 2298; some EXPECTED_LOSSY /
EXPECTED_UNSUPPORTED cells shifted to METHODOLOGY_under as the
new DHCP round-trips begin exercising actual translation where
they previously trivially aligned (both sides empty
`dhcp_servers`).  Suite 3091 → 3116 passed (+25 new tests).

### Architectural decision (Cluster E.X deferred — Tier 3 → Tier 2 promotion required)

The original Cluster E plan called for adding render paths for
firewall rules, NAT rules, DHCP pools, and DNS static-host
entries across six codecs.  Audit before dispatch revealed:

1. Three of the four surfaces (`firewall_rules`, `nat_rules`,
   DNS-static) do not exist on the canonical model — only DHCP
   pools have a `Canonical*` class today.
2. `firewall_rules` and `nat_rules` are explicitly classified as
   Tier 3 — "parse for display, never auto-render" — in the
   canonical model docstring
   (`netcanon/migration/canonical/intent.py:39-40`).  The
   docstring's rationale: auto-rendering firewall semantics
   across vendor-specific zone-pair vs interface-attached vs
   stateful-vs-stateless models risks shipping configs with
   unintended security holes.

Cluster E.1 (this commit) addresses only the DHCP slice — the
single surface that is both Tier 2 and canonically modelled.
Cluster E.X (firewall, NAT, DNS-static) is deferred pending a
deliberate architectural conversation about Tier 3 → Tier 2
promotion, which would require:

* New canonical classes (`CanonicalFirewallRule`,
  `CanonicalNATRule`, `CanonicalDNSStaticHost`).
* Updates to the canonical-model docstring's tiering paragraph.
* Per-codec `CapabilityMatrix.unsupported` listings flipped
  (probably to `partial` with review-banner UI flagging).
* Render-path agents only after the schema and capability-matrix
  groundwork lands.

### Fixed (Wave 7c — long-tail cross-mesh codec bugs cleared from 42 to 0)

Seven parallel agents (one per source-vendor) walked the post-
Wave-9 reconciliation matrix and resolved every remaining
`CODEC_BUG` cell.  Cumulative matrix delta:

  ALIGNED                    2242 → 2298 (+56)
  CODEC_BUG                  42   → 0    (-42, FULL CLEAR)
  EXPECTED_LOSSY             942  → 944  (+2)
  EXPECTED_UNSUPPORTED       493  → 514  (+21)
  METHODOLOGY_ISSUE_under    7350 → 7378 (+28)
  STRUCTURAL_ONLY            876  → 810  (-66)

Notable codec fixes during the wave:

* **Cisco IOS-XE CLI: `secondary` IPv4 keyword** (commit `87b2248`,
  Agent C).  Coordinated render+parse fix at
  `cisco_iosxe_cli/render.py:259-262` and `parse.py:576-585`.
  Render now emits `ip address X.X.X.X MASK secondary` for index>=1;
  parser drops the prior primary-only guard.
* **Arista EOS: phantom-VLAN guard + `switchport trunk native vlan`
  parse symmetry** (commit `87b2248`, Agent C).  Cross-vendor trunk
  configs were re-inflating canonical VLAN records on parse round-
  trip; snapshot-and-prune now mirrors the cisco_iosxe_cli pattern.
* **Cisco IOS-XE NETCONF: SVI-to-VLAN synthesis** (commit `1b1b865`,
  Agent D).  `Vlan<N>` SVI interfaces now synthesise
  `CanonicalVlan` records via the new shared
  `transforms.project_svi_to_vlan` helper.
* **Aruba AOS-S: foreign-port-name preservation + RADIUS port grammar**
  (commits `ce9725d` Agent A, `c344200` Agent G).  Junos-shape ports
  (`xe-0/0/N`, `et-0/0/N`) now survive round-trip without range-
  shredding; `radius-server host <ip> auth-port N acct-port N`
  cumulative-update grammar accepted on parse + emitted on render
  when ports differ from AOS-S defaults.
* **Aruba AOS-S: SVI absorption ignored VRF binding on `irb.<vid>`
  interfaces** (commit `7fd8b14`, Agent B).  Render now skips
  `iface.vrf`-set entries in `iface_by_vlan_id`.
* **Juniper Junos: phantom `ae<N>` interface elision** (commit
  `2d7a7f2`, Agent E).  Junos parse materialisation loop now skips
  empty iface_state entries whose names appear in lag_state — the
  CanonicalLAG record alone carries LAG identity.  This correction
  also debunked a stale Phase 4b "interface-range collapse"
  hypothesis that had been the assumed root cause for ~3 cells.
* **Arista EOS: RADIUS render + parse path** (commit `ce9725d`,
  Agent A).  Closes a count-regression on aruba→arista where source
  RADIUS servers were being silently dropped by the Arista codec.
* **Cross-vendor list-order parity** (commit `87b2248`, Agent C).
  New `transforms._natural_port_sort_key` resolves lexical-order
  drift on `vlan.tagged_ports` / `vlan.untagged_ports` across all
  codecs that produce port-list outputs (`1/1, 1/2, 1/10` rather
  than lex `1/1, 1/10, 1/2`).
* **Arista EOS: cross-vendor LAG-name normalisation** (commit
  `ce9725d`, Agent A).  Render now accepts ae/Port-channel/Trk/
  bond/lagg shapes via shared `_normalise_lag_name_to_arista`,
  emitting them as the EOS-canonical `Port-Channel<N>` form.
* **FortiGate CLI: static-route `set comment` parser-side gap**
  (commit `1b1b865`, Agent F).  `_apply_router_static` now reads
  `set comment "<text>"` back into `CanonicalStaticRoute.description`
  (the render side was already emitting; the gap was pinned by a
  test in commit `60b6199` from an earlier wave).
* **MikroTik RouterOS: hostname whitespace policy** (commit
  `1b1b865`, Agent F).  Multi-token RouterOS identity values
  reclassified `good → lossy` for arista_eos and cisco_iosxe_cli
  targets — Arista's parser regex `\s*$` rejects multi-token names,
  Cisco IOS-XE captures only the first `\S+` token; the
  `sanitise_hostname` substitution is wire-format-correct and
  documented per HPE Aruba 2930F config guide etc.

The final 3 cells were all `interfaces[].name` count drifts on the
`cisco_iosxe → {aruba, junos, mikrotik} / kitchen_sink.xml` fixture
and reflect documented vendor-wire constraints (foreign-name stub
elision on aruba/junos; phantom `bridge1` synthesised by RouterOS
to host source-side VLAN sub-interfaces).  Reclassified `good →
lossy` with explanatory rationale (commit `ec607d8`).

### Added (Wave 7c shared utilities)

Three new helpers in `netcanon/migration/canonical/transforms.py`
landed during Wave 7c, consolidating patterns that codecs had
started growing independently:

* **`project_svi_to_vlan(intent)`** — synthesise `CanonicalVlan`
  records from L3 SVI interfaces (`Vlan100`, `irb.100`, etc.).
  Codecs whose source-format carries SVIs but not their backing
  VLAN call this as a parse post-pass.
* **`_natural_port_sort_key(name)`** — natural-sort key for
  port-name strings.  Used by `project_switchport_to_vlan` and
  the Junos parser's port-list materialisation to guarantee
  cross-vendor list-order parity.
* **`_normalise_lag_name_to_arista(name)`** in
  `netcanon/migration/codecs/arista_eos/render.py` — accepts
  ae / Port-channel / Trk / bond / lagg shapes and emits as the
  EOS-canonical `Port-Channel<N>` form.

`netcanon/migration/codecs/README.md` "Cross-codec shared utilities"
section gains entries for the two `transforms.py` helpers.

### Added (Phase 1 backup-definition expansion: Arista EOS / Aruba AOS-S / Juniper Junos)

Three new device-definition YAMLs land alongside the existing
Cisco / Fortigate / MikroTik / OPNsense backup definitions, closing
the gap between migration-codec coverage (8 vendors) and backup-
collector coverage (was 4, now 7).  Each ships with a netmiko-strategy
collector wired to the appropriate driver — `arista_eos`,
`aruba_osswitch`, `juniper_junos` — so users with real hardware in
those families can now point the backup pipeline at devices and pull
their `running-config` (or `show configuration | display set | no-more`
for Junos, where the `set`-form output maps directly onto what
`juniper_junos.parse_intent` consumes).

* **`definitions/arista/eos/4.32.yaml`** — netmiko `arista_eos` with
  the standard Cisco-style `cisco_more_paging: true` (per the hard
  rule against `terminal length 0`).  Probe regexes pinned against
  DCS / vEOS / CCS chassis families.
* **`definitions/aruba/aos-s/16.x.yaml`** — netmiko `aruba_osswitch`
  (the modern AOS-S 16.x driver; `hp_procurve` documented as the
  legacy alternate for 15.x firmware).  Manager-mode escalation via
  netmiko's `secret` credential.
* **`definitions/juniper/junos/22.x.yaml`** — netmiko `juniper_junos`
  with `cisco_more_paging: false` (Junos uses `| no-more` in the
  command itself rather than space-injection).  Probe regexes pinned
  for SRX / EX / MX / QFX `show version` shapes.

Per-vendor unit tests pin the schema, probe regexes, and codec round-
trip; per-vendor integration tests cover the POST/GET happy path with
mocked `get_collector` (per the hard rule against patching
`ConnectHandler` directly); per-vendor desktop tests confirm the
embedded uvicorn serves the new definitions.

### Added (`type_key` filename-safety constraint)

Authoring a `DeviceDefinition` with `type_key` containing `_` or `.`
now raises `ValidationError` at load time (validator
`type_key_filename_safe` on `DeviceDefinition`).  These characters are
the separators the file-store filename grammar uses
(`{type_key}_{safe_host}_{ts}.{ext}`), so a `type_key` containing
either makes the parse mathematically ambiguous — `resolve_path()`
would mis-route the file because the lazy `.+?` in the device-type
group absorbs only the leading non-underscore token.

The constraint pins the established convention (single-token
CamelCase: `Cisco`, `Fortigate`, `MikroTik`, `OPNsense`, `Aruba`,
`Juniper`, `Arista`).  BD-Aruba's initial `aruba_aoss_16.x` outlier
(commit `de8e0f3`) and BD-Arista's independent rediscovery of the
trap (commit `8c9e9d4`) confirmed the rule was load-bearing.  Both
the schema-level validator and the file-store regex (now using
`[^_.]+` for the `device_type` group instead of `.+?`) enforce the
invariant from two angles.  AGENTS.md "Hard Rules" gains a new
bullet citing the rule.

### Fixed (Wave 9 cross-mesh comparator: dict-drift false-positive + LAG-rename equivalence)

Two surgical fixes to the Phase 4 reconciliation tooling at
`tools/run_phase4_reconciliation.py` that tighten matrix integrity
without touching any codec render or parse code:

* **Wave 9α — `_subfield_drift_in_dict` audit-tooling false positive.**
  Phase 1 (`tools/run_full_mesh.py::process_cell`) was storing only
  the top-level keys of dict-typed canonical fields (`snmp`, etc.)
  as `record["source"]` / `record["target"]`, which left
  `_subfield_drift_in_dict` unable to determine whether a specific
  attribute (e.g. `snmp.community`) drifted.  It conservatively
  fell through to "drifted", inflating the CODEC_BUG count by ~11
  cells across SNMP attributes for cross-vendor pairs that were
  actually translating cleanly.  Fix: store the full source/target
  dicts so the reconciler can walk per-attribute.
* **Wave 9β — Vendor-correct LAG-rename equivalence.**  Junos
  `ae<N>`, Cisco `Port-channel<N>` (and `Po<N>`), and Aruba
  `trk<N>` are the same LAG bundle in different vendor-native
  spellings.  The comparator now treats these as equivalent for
  the field-keys in `_LAG_NAME_FIELDS = {"lags[].name",
  "interfaces[].lag_member_of"}` via a `_lag_name_equivalence`
  callable plugged into `_subfield_drift_in_list` /
  `_slice_list_subfield`.  Names not matching a documented LAG
  shape fall through to raw equality, so non-LAG drift on the
  same fields still surfaces.  Defensive on current matrix state
  (no immediate cell drops) but prevents regression as future
  YAML refreshes tighten `lags` dispositions from `lossy` back
  to `good`.

### Fixed (Wave 9γ — selective re-flip of Wave 8 over-eager `lossy` reclassifications)

After Wave 8 reclassified ~120 fields from `good → lossy` based on
per-source-vendor findings reports, three per-source agents (γ-A,
γ-B, γ-C) walked their assigned scopes to verify each flip was
justified by genuine drift in at least one fixture for the pair.
γ-A (arista + aruba) and γ-B (cisco) confirmed every Wave 8 flip
was correct.  γ-C identified six over-eager flips on
`juniper_junos -> cisco_iosxe_cli / vlans[].id`: the Wave 8
hypothesis that Junos `vlan members all` would expand to
`range(1, 4095)` on the canonical `vlans[].id` projection was
incorrect — the expansion lands on `interfaces[].trunk_allowed_vlans`
instead.  Six fixtures (5 real + 1 synthetic) confirm `vlans[].id`
preserves cleanly across this pair; YAML re-flipped to `good`.

Cumulative Wave 9 matrix delta: ALIGNED 2225 → 2242 (+17), CODEC_BUG
53 → 42 (-11), METHODOLOGY_ISSUE_under 7370 → 7350 (-20).

### Refactored (extract `_migration_helpers.py` from `migration.py`)

Final cleanup pass on the `refactor/god-file-cleanup` branch.  The
god-file audit had flagged `netcanon/api/routes/migration.py` as the
remaining oversized route file (~750 LOC mixing route dispatch with
adapter resolution, input-text resolution, capability-matrix shaping,
target-profile lookup, and override-routing predicates).  Extracted
the helpers into a sibling `_migration_helpers.py` so the route
module is closer to "thin glue" and the helpers acquire focussed
unit-test coverage without spinning up a TestClient.

* **Helpers lifted verbatim** — `resolve_adapter_or_422`,
  `resolve_input_text`, `get_target_profiles`, `build_codec_info_list`,
  `request_has_overrides_or_profile`.  Names lost the leading
  underscore on the move because they're now first-class symbols of
  the helper module's public surface.  Behaviour unchanged — call
  sites updated, no signature edits, no logic rewrites.

* **Frozen pipeline signatures untouched** — `run_plan`,
  `run_plan_with_rename`, `run_plan_with_overrides` (the trio in
  `netcanon/services/migration_pipeline.py`) stay frozen per the
  hard-rule freeze-and-extend contract.  Extraction was purely on
  the request-shaping / response-shaping side, one layer above the
  pipeline.

* **Tests** — new `tests/unit/api/test_migration_helpers.py`
  covers every helper directly (23 unit tests, one class per
  helper).  `tests/integration/test_migration_api.py` zero
  deltas — proves the API boundary (URLs, request/response shapes,
  status codes) is unchanged.

* **Docs** — `netcanon/api/routes/README.md` gains a brief
  "Helper modules" section establishing the
  `_<router>_helpers.py` sibling pattern as a worked example for
  future contributors.  Module docstring on the new helper file
  enumerates its public functions.

* **Why not refactor the per-pane handlers in the same commit?** —
  The five `/plan/<category>` route bodies are nearly identical
  (5 × ~20 LOC of "resolve adapters → resolve input → call
  `run_plan_with_overrides` with one category map populated → log").
  A `dispatch_pane_plan(category, ...)` helper would shrink the
  route file substantially.  Deferred deliberately: lifting AND
  refactoring in the same commit is the wrong shape of risk.  The
  per-pane consolidation is a candidate for a follow-up commit.

### Added (EOS 4.26 EVPN/VXLAN gaps — item 3 of 5: IPv6 addresses)

Third of the five deferred translator-plans EVPN/VXLAN gaps closed.
The karneliuk EOS 4.26 fixture's
`ipv6 address fc00:192:168:100::62/64` on Management1 was silently
dropped on parse before this change; the same was true for every
real-capture fixture carrying static IPv6 (Junos `family inet6
address`, Cisco `ipv6 address X/Y`, etc.) — IPv6 addresses are
stable syntax across vendor versions in the corpus, so the wire-up
is version-agnostic.

* **Schema** — new `CanonicalIPv6Address` (ip + prefix_length +
  scope) sibling to `CanonicalIPv4Address`; new
  `CanonicalInterface.ipv6_addresses` list.  Scope discriminator
  (`"global"` / `"link-local"`) normalises the keyword form on
  Cisco / Arista against prefix-inferred form on Junos / MikroTik /
  OPNsense / FortiGate.

* **Wire-through** — parse + render landed on all 8 codecs:
  arista_eos, cisco_iosxe (NETCONF/OpenConfig), cisco_iosxe_cli,
  aruba_aoss, juniper_junos, fortigate_cli, mikrotik_routeros,
  opnsense.  Capability matrix entries added on every codec.
  Vendor-specific placeholders filtered on parse: FortiGate `::/0`
  (no-v6 default), OPNsense `dhcp6` / `idassoc6` (keyword markers),
  Aruba AOS-S `dhcp full` (stateless DHCPv6).

* **Tests** — 36 new per-codec unit tests
  (`test_ipv6_wire_through.py`); 72 new cross-mesh smoke tests
  (every bidirectional pair preserves an IPv6 address through the
  round-trip + every target codec emits canonical IPv6 input);
  real-capture regression assertion exercising karneliuk EOS 4.26
  + ntc carrier IOS-XE + buraglio Junos + batfish EVPN-Type5 Junos.

### Added (EOS 4.26 EVPN/VXLAN gaps — items 1 + 2 of 5)

Two of the five deferred translator-plans EVPN/VXLAN gaps closed.
Surfaced when an Arista EOS 4.26 → Juniper translation lost semantic
surface; both items had stable syntax across versions (EOS 4.13+ /
Junos 15.1+) so the wire-up regresses across the full fixture corpus,
not just the EOS 4.26 trigger fixture.

* **VXLAN source-interface + UDP port** (`CanonicalVxlan` schema +
  Arista + Junos parse/render).  Arista's
  `interface Vxlan1 / vxlan source-interface <name>` and
  `vxlan udp-port <N>` now populate onto every CanonicalVxlan record
  emitted from the stanza; Junos's
  `set switch-options vtep-source-interface <name>` and
  `set switch-options vxlan-port <N>` mirror the same wire-up.
  Capability-matrix entries added across all 8 codecs.

* **Arista BGP / VLAN / RD / RT EVPN MAC-VRF**
  (CanonicalRoutingInstance with `instance_type="mac-vrf"`).  The
  per-VLAN EVPN binding form (`router bgp / vlan N / rd ... /
  route-target both ...`) now populates a CanonicalRoutingInstance
  keyed by the VLAN name — previously parse-and-ignore.  The new
  `vlan <N>` recognition is depth-restricted (3-space top-level
  router-bgp sub) so nested `vlan-aware-bundle ... / vlan <N>` lines
  don't spuriously spawn MAC-VRF entries.

### Fixed (OPNsense paramiko-shell backups breaking the migrate parser)

User report: picking a stored OPNsense config in the Migrate UI
returned ``parse failed: opnsense: malformed XML: syntax error:
line 1, column 0`` even though the detection probe reported 98%
confidence the file was OPNsense XML.  Detection tolerated leading
noise (substring search for ``<opnsense>``); parse called
``ET.fromstring`` which refused anything not starting with a valid
XML prolog.

**Root cause** — the paramiko-shell collector writes the raw PTY
dump to disk without stripping BOTH:

* The echoed command at the HEAD of the buffer (``cat /conf/
  config.xml\r\r\n`` before the ``<?xml`` prolog).
* The returning shell prompt at the TAIL of the buffer
  (``root@supergate:~ # `` after ``</opnsense>``).

Detection probe (tolerant substring search for ``<opnsense>``)
happily reported 98% confidence, but ``ET.fromstring`` refused
both shapes — first failing at line 1 column 0 (leading echo), and
once the leading strip was added, failing again at line 4603
column 4 (trailing prompt residue after the close tag).
``NetmikoCollector`` never had either issue — Netmiko's
``strip_command=True`` + ``strip_prompt=True`` handle both ends
internally.  The raw paramiko path has to do them explicitly and
didn't.

**Fix** in two layers, both now trimming head + tail:

1. **Collector side** (prevents new bad backups):
   `netcanon/collectors/paramiko_collector.py::_collect_output`
   now accepts an optional ``command`` kwarg and calls the new
   module-level helper ``_strip_command_echo(buf, command)`` before
   returning.  The helper:
   - **Head**: locates the command string in the first 512 bytes
     and drops everything up to and including the echo plus
     trailing ``\r``/``\n``/tab/space.
   - **Tail**: scans the last 512 bytes for a shell-prompt shape
     (``user@host:cwd [#$>]``) via bounded regex, slices before
     the prompt if found.
   Call site in ``collect()`` now passes
   ``definition.commands.config``.  Matches Netmiko's
   ``strip_command=True`` + ``strip_prompt=True`` behaviour.
2. **Parser side** (rescues legacy backups already on disk):
   `netcanon/migration/codecs/opnsense/codec.py::_trim_xml_envelope`
   (renamed from the original ``_trim_xml_prologue``; the old name
   is kept as a backwards-compat alias).  Called at the top of
   ``parse()``:
   - **Head**: searches the first 2 KiB for the earliest ``<?xml``
     or ``<opnsense`` marker; slices from there.
   - **Tail**: locates the last ``</opnsense>`` and slices
     everything after it.
   If no markers are present, the input passes through unchanged
   so truly malformed XML still raises the expected ``ParseError``
   — the trim is bounded so operator visibility of genuine
   failures is preserved.

### Tests (OPNsense rescue)

- `tests/unit/test_paramiko_collector.py` — 10 tests for
  ``_strip_command_echo`` covering the bug-reproducing CRLF+CR
  preamble, LF-only variant, tab+space+CR mix, no-whitespace-after-
  echo edge case, command-not-in-head tolerance (preserves output
  that mentions the command deeper in the file), empty/None inputs,
  and embedded-spaces commands (``show configuration | display set``).
- `tests/unit/migration/test_opnsense.py::TestParseTolerancePreamble`
  (6 tests) + `::TestTrimXmlPrologue` (7 tests) — covers the
  canonical ``cat /conf/config.xml\r\r\n`` shape, banner/MOTD
  preamble, prolog-less input where only ``<opnsense`` marker is
  present, bounded head-scan (markers past 2 KiB are NOT stripped),
  and a regression check against the new real fixture.
- `tests/integration/test_migration_api.py::TestOpnsenseParamikoShellEchoRescue`
  — 2 end-to-end tests joining the two halves the investigation
  agent flagged as a test-coverage gap: drops a corrupt-preamble
  file directly into `test_settings.configs_dir`, hits
  `POST /api/v1/migration/plan`, asserts ``status=completed``.
  Inverse test confirms truly malformed files still return
  ``failed`` with a clear error.
- New fixture:
  `tests/fixtures/real/opnsense/opnsense_paramiko_shell_capture.xml`
  — `opnsense_core_default.xml`'s body with a
  ``cat /conf/config.xml\r\r\n`` prefix prepended, reproducing the
  exact byte shape the user reported.  Documented in NOTICE.md and
  RESULTS.md as a regression fixture; derived from a BSD-2-Clause
  upstream file.

### Docs (OPNsense rescue)

- `netcanon/collectors/README.md` — `paramiko_shell` section now
  notes the command-echo stripping behaviour and points at the
  Netmiko `strip_command=True` parallel.
- `tests/fixtures/real/NOTICE.md` — provenance row for the new
  paramiko-shell-capture fixture.
- `tests/fixtures/real/RESULTS.md` — OPNsense section gains a
  Findings paragraph for the user-reported bug; matrix row added
  for the new fixture; summary-table row updated 4→5 fixtures and
  1→2 bugs surfaced.  TOTAL row updated 38→39 fixtures and 16→17
  bugs.

### Changed (Definitions page enriched with 4 browsing sections)

The `/definitions` page was showing only 4 backup-side device
definitions, hiding the richer data Netcanon actually has loaded:
1 version overlay + 54 migration target profiles (with module
variants) + 8 vendor codec records.  The page now surfaces ALL of
it as a browsable reference — the Tier-3 rename-modal dropdowns
are no longer the only way to discover what hardware/capabilities
the app supports.

- `netcanon/api/routes/ui.py::definitions_page` — extended to
  pass four context variables instead of one: `definitions`
  (unchanged family-base backup list), `overlays` (version /
  model-pinned variants from the loader's `_variants` registry),
  `profiles_by_vendor` (54 target profiles grouped by vendor),
  `vendor_rows` (8 vendors with their registered codecs +
  certainty tier + direction + capability-matrix counts).
- `netcanon/templates/definitions.html` — rewrote with four
  `<section>` blocks, each with a `section-*` testid.  Native
  `<details>`/`<summary>` disclosure for per-vendor and per-
  profile expansion — zero custom JS for the collapsible
  behaviour.  Port chips render per-port with kind-specific
  border colours (uplink / mgmt / console) + tooltip with
  speed / PoE / SFP / notes.  Module-variant cards expose SKU
  + description + uplink port list, so the Cat 9300 NM-8X vs
  NM-2Q choice (and similar across Aruba 3810M / FortiGate
  SFP-cage variants) is finally visible outside the migrate
  modal.
- **Live filter over target profiles**: a search input at the
  top of section 3 does DOM hide/show against a pre-lowercased
  `data-haystack` attribute (`vendor + model + display_name`)
  set server-side.  Vendor groups auto-collapse when all their
  profiles are filtered out.  Live counter shows "N matches"
  as the user types.
- **Codec certainty / direction pills**: each codec row in the
  vendors section renders its certainty tier as a colour-coded
  pill (`certified` → green, `best_effort` → amber,
  `experimental` → yellow — driven by the existing badge
  theme tokens so dark mode just works) + a direction pill
  (`parse_only` / `bidirectional` / `render_only`).  Side-by-
  side summary of each vendor's codec portfolio without
  needing to hit the API.
- All new styles use the dark-mode theme tokens from `base.html`'s
  `:root` / `[data-theme="dark"]` blocks — page renders correctly
  in both themes.

### Tests (Definitions page)

- `tests/integration/test_ui_routes.py::TestDefinitionsPageEnriched`
  — 11 server-side assertion tests covering:
  - The four section containers all render
  - Device definitions section count badge present
  - Target-profiles section + vendor groups + profile rows all
    populate from the real `definitions/target_profiles/`
  - Filter input + live count elements present
  - Module-variant cards emit when profiles have modules
  - Base-ports chip list emits
  - Vendors section lists all codecs with certainty + direction
    pills using theme-token-driven CSS classes
  - Overlays section ABSENT when loader has no overlays (the
    default test harness)
  - Overlays section RENDERS when an overlay YAML is dropped
    in at runtime (regression guard against the original
    "5 loaded / 4 displayed" user report)
- `tests/testid_reference.md` — Definitions section rewritten
  into four subsections (one per page section) with 30+ new
  testid entries covering sections, filter, vendor groups,
  profile rows, module cards, port-chip containers, vendor
  rows, codec rows, direction/certainty pills.

### Added (Global dark mode with sun/moon toggle on top nav)

- `netcanon/templates/base.html` — light + dark themes via CSS
  custom properties toggled on `<html data-theme>`.  Single source
  of truth: `:root` block declares light-mode tokens; a mirrored
  `[data-theme="dark"]` block overrides for dark mode.  Tokens
  cover page background, surfaces (base + alt + elevated +
  hover), text (primary + muted + faint), borders, accent +
  focus-ring, button variants (primary + secondary + danger),
  status-badge colour pairs (failed / running / pending /
  completed / partial), alerts, shadows, `<pre>` chrome, and
  the nav bar itself.  CSS declarations across the base template
  now reference `var(--token)` rather than raw hex for the
  high-visibility surfaces.
- **FOUC-prevention boot script** inlined in `<head>` *before*
  the `<style>` block — a tiny IIFE reads
  `localStorage["netcanon.theme.v1"]` (user override), falls
  back to `window.matchMedia('(prefers-color-scheme: dark)')`,
  and sets `data-theme` on `documentElement` synchronously.
  Blocks CSS parse, no flash of wrong theme on reload.
- **Sun/moon toggle button** (`data-testid="nav-theme-toggle"`)
  right-aligned on the nav via a flex spacer.  Always visible
  on every page (lives in `base.html`).  CSS attribute-selector
  swap between `&#x263D;` (moon glyph, shown in light mode →
  click to go dark) and `&#x2600;` (sun glyph, shown in dark
  mode → click to go light).  No DOM mutation on toggle —
  glyphs flip via CSS alone.
- **Accessibility**: `<button>` (not `<input type="checkbox">`)
  with live-updating `aria-label` ("Switch to dark theme" /
  "Switch to light theme") and `aria-pressed` (`true`/`false`)
  describing the ACTION not the current state — clearer for
  screen readers than a static "dark mode on/off" label.
- **localStorage persistence** under `netcanon.theme.v1`
  (matches the existing `netcanon.X.v1` key convention used by
  the rename-ack and active-job state).
- `netcanon/templates/_partials/theme-toggle.js` — new partial
  containing the `toggleTheme()` function +
  `_updateThemeToggleAriaLabel()` + DOMContentLoaded aria
  initialiser.  Included from `base.html` alongside the
  existing `config-viewer.js` + `job-progress.js` partials.
- **Cross-cutting cleanups**:
  - `showToast()` now applies CSS classes (`.toast-info` /
    `.toast-error` / `.toast-success`) driven by theme tokens
    instead of writing inline `element.style.background` hex —
    dark mode inherits the semantic colour pair automatically.
  - Job-progress panel + toast + config-viewer-modal outer
    chrome migrated to theme tokens.
  - 150ms transition scoped to colour properties (background /
    color / border-color) so theme flip is smooth without
    juddering unrelated animations.

### Docs (dark mode)

- `tests/testid_reference.md` — added `nav-theme-toggle` to the
  Navigation section with full semantic description (aria
  semantics + localStorage key).
- `ARCHITECTURE.md` — new "Theming (dark mode)" subsection under
  Template organisation documenting the three load-bearing
  rules: inline-blocking boot script, JS-driven theme apply
  (not `@media` CSS-only), theme-aware toast/alert colour pairs
  via CSS class (never inline style).  `_partials/` inventory
  extends with `theme-toggle.js`.
- `AGENTS.md` Documentation Sync Checklist gains a new row: "A
  new CSS colour added to `base.html`" → use `var(--token)`
  from the theme set; add to BOTH `:root` and
  `[data-theme="dark"]` if no existing token fits.  Points at
  ARCHITECTURE "Theming (dark mode)" section for full rationale.

### Tests (dark mode)

- `tests/integration/test_ui_routes.py::TestThemeToggleRendered`
  — 6 server-side sanity tests: `<html data-theme="light">`
  default on 5 pages, boot script inlined in `<head>`,
  `nav-theme-toggle` button renders on every page, sun + moon
  glyphs both in markup, theme-toggle partial included,
  `:root`/`[data-theme="dark"]` CSS tokens present.  Cheaper
  than Playwright; catches template regressions early.
- `tests/e2e/test_navigation.py::TestThemeToggle` — 6
  Playwright tests: button visible on dashboard + jobs, click
  flips both `data-theme` attr + localStorage, reload persists
  the stored theme, `aria-label`/`aria-pressed` update to
  reflect next-action, body background colour actually changes
  on click (guards against the `var(--)` resolution regression
  class).

### Added (Arista EOS 4.26 real fixture — 4th EOS major)

- `tests/fixtures/real/arista_eos/karneliuk_a_eos1_eos4260.txt` —
  real **A-EOS1** vEOS fixture on **EOS-4.26.0.1F** from Anton
  Karneliuk's Batfish MVP demo (BSD-3-Clause, karneliuk-com/
  batfish-mvp @ `62e8ce7`).  82 lines covering the post-GAP-6
  canonical surface: `service routing protocols model multi-agent`,
  VLAN 100 + `Vxlan1 vni 100`, Management1 with IPv4+IPv6, router
  bgp 65033 with two eBGP neighbours + `address-family evpn` +
  `evpn redistribute-learned`, route-maps permit+deny, ip prefix-
  list.  Fourth distinct EOS major in the Arista corpus
  (4.21 + 4.22 + 4.23 + 4.26).
- `tests/fixtures/real/RESULTS.md`: Arista section gains the new
  matrix row; certification-decision paragraph updated to reflect
  the 4.26 fixture; summary-table row updated 3→4 fixtures / 3→4
  versions.  TOTAL row 37→38 fixtures.  Promotion-path paragraph
  replaced with "even-newer EOS LTS 4.28+/4.30+ fixture" as
  remaining nice-to-have.
- `tests/fixtures/real/NOTICE.md`: provenance row added for the
  new fixture with repo + commit SHA + license + grammar
  summary.  Also documents the fixture-hunt dead-ends: batfish/
  lab-validation caps out at 4.23; ksator/arista_eos_audit at
  4.22.4M; aristanetworks/avd lacks `! device:` version banners;
  newer 4.28+/4.30+ configs require authenticated GitHub search
  or lab-owned containerlab captures.

### Added (Structural apply-groups collapse — Junos interface-range)

- `netcanon/migration/codecs/juniper_junos/codec.py` — parse now
  handles ``set interfaces interface-range <rname> ...`` grammar.
  Supported sub-paths: ``member <iface>``, ``description``,
  ``mtu``, ``disable``, ``unit 0 family inet address``.  Shared
  attrs apply to each member interface at materialisation time so
  the canonical tree looks identical whether the operator wrote
  interface-range blocks or flat per-interface lines.  Per-member
  config overrides range-level defaults (member wins on conflict).
- Top-level ``set interfaces <X> mtu <N>`` also now parses and
  populates :attr:`CanonicalInterface.mtu` (previously the MTU
  field existed on the model but Junos parse didn't populate it).
- Render-side structural collapse: auto-detects ≥3 interfaces
  sharing identical ``(mtu, description, enabled)`` tuples with
  at least one non-default value and emits
  ``set interfaces interface-range AUTO-RANGE-<N>`` blocks.
  Per-interface emission of the shared attrs gets suppressed; per-
  interface specifics (IPv4 addresses, etc.) still emit normally.
- Collapse heuristics deliberately skip VRF-bound interfaces,
  switchport / trunk interfaces, access-VLAN-carrying interfaces,
  and sub-interfaces — their richer semantics warrant per-
  interface emission.  Default-everything interfaces also skip
  (nothing to hoist to the range).
- Render MTU emission newly added: ``set interfaces <X> mtu <N>``
  emits when `iface.mtu` is set and the interface isn't a range
  member (suppression via the collapse-detection pass).
- Structural collapse is COMPLEMENTARY to GAP 9b's apply-groups
  preservation: operator-authored group structure (via
  ``set groups G`` + ``set apply-groups G``) round-trips verbatim;
  auto-synthesis kicks in only for raw per-interface sharing the
  operator didn't themselves collapse.

### Tests (structural collapse)

- ``tests/unit/migration/test_junos_interface_range.py`` — 15 tests:
  - ``TestInterfaceRangeParse`` (5 tests): member collection +
    shared mtu/description/disable application + per-interface
    override wins + unknown sub-paths tolerated.
  - ``TestInterfaceRangeRender`` (7 tests): ≥3-member collapse
    happy path, 2-member no-collapse (threshold), multiple
    collapse groups with distinct tuples, per-interface
    specifics still emit, VRF-bound skipped, all-default
    skipped, sub-interfaces skipped.
  - ``TestInterfaceRangeRoundTrip`` (3 tests): flat→collapsed→
    reparse stability, range-form input survives the render
    round-trip, top-level mtu parse.

### Changed (EVPN Type-5 per-prefix path: Unsupported → Lossy)

- Moved `/evpn-type5-routes/route` from ``Unsupported`` to ``Lossy``
  on all three DC codecs (Arista EOS, Juniper Junos, Cisco IOS-XE
  CLI).  The `CanonicalEvpnType5Route` per-prefix schema is a
  lossy-by-default extension point: no codec populates it today,
  and the canonical semantic for Type-5 IP-prefix advertisements
  lives on :attr:`CanonicalRoutingInstance.l3_vni` (populated via
  GAP 6 — Arista ``vxlan vrf X vni N`` + Junos ``protocols evpn
  ip-prefix-routes vni N``).
- Reason strings across all three codecs now explicitly call out
  the VRF-property canonical model as the supported alternative
  and note that per-prefix enumeration would require route-map /
  policy-statement parsing as a future dependency.
- Consumers needing explicit per-prefix semantics should infer
  from VRF membership (`CanonicalInterface.vrf`) + l3_vni rather
  than relying on the per-prefix list.
- Also updated the obsolete GAP 4-era Junos LossyPath note for
  `/groups` — the "Apply-groups inheritance is wired only for
  host-name" text was stale post-GAP-8 (two-pass parse wires the
  full dispatch surface); reason rewritten.

### Tests (Type-5 demotion)

- ``tests/unit/migration/test_vxlan_evpn_schema.py`` —
  ``TestDCCodecsDeclareEvpnType5Unsupported`` renamed to
  ``TestDCCodecsDeclareEvpnType5Lossy``, assertions updated to
  verify ``lossy`` classification + non-empty reason mentioning
  `l3_vni` as the supported alternative.  New regression guard
  confirms the path is NOT in the Unsupported list post-demotion.
  Parametrised across Arista + Junos + Cisco IOS-XE CLI.

### Added (GAP 9b — Junos apply-groups statement + body preservation on render)

- Two new fields on :class:`CanonicalIntent`:
  - ``apply_groups: list[str]`` — operator-declared apply-groups
    statements (e.g. ``set apply-groups POC_Lab``).
  - ``group_content: dict[str, list[list[str]]]`` — per-group
    bucket of tokenised set-line tails, preserving operator-
    authored group bodies verbatim.  Only groups that appear in
    ``apply_groups`` (i.e. actually composed into the candidate
    config) get persisted; orphan groups drop.
- `netcanon/migration/codecs/juniper_junos/codec.py` — parse
  populates both fields from the GAP 8 two-pass buckets.  Render
  emits `set groups <G> <body>` lines FIRST (using
  `_quote_if_needed` per token so multi-word quoted values like
  banner messages round-trip exactly), followed by
  `set apply-groups <G>` statements.
- Operator-facing benefit: paste a Junos config with
  ``set groups POC ...`` + ``set apply-groups POC``, render it
  back out, and the output looks like hand-written Junos again
  (group-structure preserved) — previously v2a flattened
  everything into top-level lines.
- De-dup guards added alongside:
  - `_apply_routing_options` — static routes de-dup on
    ``(destination, gateway)`` pair.
  - `_apply_interfaces` unit-N IPv4 address handler — de-dup on
    ``(ip, prefix_length)`` pair.  Both prevent duplicate
    accumulation when GAP 9b's group-content + top-level emission
    would otherwise double-add on re-parse.
- Scope-cap: GAP 9b preserves what the operator wrote.  It does
  NOT synthesise groups from shared sub-configs (auto-detecting
  that N interfaces share an MTU value and collapsing them to a
  group) — that's a larger refactor requiring per-entity
  source-provenance metadata; deferred.

### Tests (GAP 9b)

- ``tests/unit/migration/test_junos_apply_groups_rich.py::TestApplyGroupsRenderPreservation``
  — 8 tests covering:
  - Parse populates `apply_groups` + `group_content`
  - Orphan group (no apply-groups) drops from persistence
  - Group body tokens preserve exactly
  - Render emits `set groups <G>` BEFORE `set apply-groups <G>`
  - Full round-trip stability (parse → render → parse produces
    identical canonical tree)
  - Multi-word quoted body (banner message) re-quotes correctly
    on render so tokeniser round-trips
  - Real-fixture regression: ksator EX4550 has 22 group-body
    lines including a banner — round-trip preserves every line
  - Fresh `CanonicalIntent` has empty `apply_groups` + `group_content`

### Added (GAP 9a — Junos block-form (curly-brace hierarchical) parse)

- `netcanon/migration/codecs/juniper_junos/codec.py` — parse now
  auto-detects block-form (hierarchical curly-brace) input and
  converts it to set-form internally via a grammar-agnostic
  walker.  The same `_dispatch_set` pipeline then applies the
  resulting set-lines to the canonical tree — no duplicated
  parse logic.
- New helpers at module scope:
  - ``_looks_like_blockform(raw)`` — heuristic detection: first
    meaningful line ends with ``{`` AND has content before it,
    lines containing ``"key":`` patterns get rejected as likely
    JSON.
  - ``_tokenise_blockform(raw)`` — tokeniser with ``{``/``}``/
    ``;`` as standalone tokens; strips ``/* ... */`` comments
    (multiline tolerated); preserves quoted strings including
    escaped inner quotes.
  - ``_blockform_to_setform(raw)`` — recursive-descent walker
    that maintains a path stack, emits ``set <path...> <value>``
    for each ``;``-terminated leaf statement, raises
    ``ParseError`` on unbalanced braces.
- JSON and malformed `{`-leading input still raise ``ParseError``
  with a helpful hint pointing at ``| display set`` for real
  Junos operators.
- Previously, the codec rejected block-form with a helpful error
  message (v1 behaviour — GAP 4 commit); GAP 9a removes that
  rejection and ships the conversion.  Operators no longer need
  to run ``| display set`` on their device to feed block-form
  output into Netcanon.

### Tests (GAP 9a)

- ``tests/unit/migration/test_juniper_junos.py::TestBlockFormParse``
  — 10 tests:
  - ``test_system_host_name``: simplest case.
  - ``test_nested_blocks``: ``system / login / user / class /
    authentication / encrypted-password`` nested 5 deep.
  - ``test_interface_with_ip``: ``interfaces / ge-0/0/0 /
    description`` + ``unit 0 / family inet / address``.
  - ``test_vlan_with_vxlan_vni``: block-form VLAN + nested
    ``vxlan / vni``.
  - ``test_routing_instance_vrf``: full VRF declaration in
    block-form.
  - ``test_apply_groups_inheritance_in_blockform``: `set
    groups G { ... }` + `set apply-groups G;` wiring through
    the GAP 8 two-pass machinery.
  - ``test_quoted_strings_preserved``: descriptions with spaces
    and `$`-special chars.
  - ``test_comments_stripped``: ``/* ... */`` both multi-line
    and inline.
  - ``test_mixed_input_still_rejected_if_not_blockform``: JSON
    input (``{"hostname": ...}``) still raises ``ParseError``.
  - ``test_unbalanced_braces_raises``: missing ``}`` raises
    with helpful message.
- ``test_block_form_rejected_with_helpful_hint`` (v1 path) has
  been swapped to ``test_block_form_now_accepted_via_gap_9a_conversion``
  to lock in the new behaviour.

### Added (GAP 8 — richer Junos apply-groups inheritance)

- `netcanon/migration/codecs/juniper_junos/codec.py` — full
  two-pass parse refactor.  Every `set groups <g> <path>` line is
  now bucketed during a first pass; the dispatcher then replays
  group content (in REVERSE apply-groups order so the first-
  declared group wins for scalars — matches Junos's first-match
  composition) followed by top-level content (so direct-intent
  overwrites group inheritance).  List-shaped fields
  (`static_routes`, `local_users`, `dns_servers`, `ntp_servers`,
  `syslog_servers`, interface `ipv4_addresses`) accumulate from
  both sources with de-dup.
- Before GAP 8, only `set groups <g> system host-name X` was
  inherited (GAP 4 narrow path).  After GAP 8 the full dispatch
  surface flows through: `set groups <g> system login user ...`,
  `set groups <g> system ntp server ...`, `set groups <g> system
  name-server ...`, `set groups <g> system syslog host ...`,
  `set groups <g> interfaces <iface> ...`, `set groups <g> snmp
  community ...`, `set groups <g> routing-options static route
  ...`, `set groups <g> routing-instances ...`, `set groups <g>
  vlans ...` — all populate canonical tree via apply-groups.
- New top-level parse surface added alongside the refactor (these
  also appear at top level in some configs, not just under
  groups): `set system domain-name`, `set system name-server`,
  `set system ntp server [prefer ...]`, `set system syslog host
  <ip> [any ...]`.
- Render extended: `set system domain-name / name-server / ntp
  server / syslog host` emitted when canonical tree has the
  corresponding data.

### Tests (GAP 8)

- ``tests/unit/migration/test_junos_apply_groups_rich.py`` —
  16 tests:
  - ``TestSyntheticGroupInheritance`` (7 tests): user, static
    route, SNMP community, NTP server, DNS name-server, syslog
    host, interface — each declared only inside a group and
    surfaced via apply-groups.
  - ``TestCompositionSemantics`` (4 tests): direct-intent
    overrides group for scalars, first-apply-group wins among
    groups for scalars, lists accumulate from both sources,
    unapplied group silently drops.
  - ``TestKsatorFixturesRichInheritance`` (5 tests): real-
    fixture regression guards asserting that QFX5100 + EX4550
    now surface hostname, user, SNMP, static routes, NTP, DNS,
    syslog, and the `vme` management interface — all previously
    empty because everything lived under `set groups POC_Lab`.

### Added (GAP 6 — Arista EOS + Juniper Junos VXLAN + VRF / EVPN codec wire-up)

- `netcanon/migration/codecs/arista_eos/codec.py` — parse + render
  now populate `CanonicalVxlan` from ``interface Vxlan1 / vxlan
  vlan <vid> vni <vni>`` lines, and `CanonicalRoutingInstance` from
  top-level ``vrf instance <name>`` + ``router bgp <asn> / vrf
  <name> / rd / route-target import|export|both`` stanzas.  L3 VNI
  (``vxlan vrf <name> vni <N>``) populates
  :attr:`CanonicalRoutingInstance.l3_vni` for EVPN Type-5 symmetric
  IRB routing.  Per-interface VRF membership (``vrf <name>`` on
  Ethernet / Port-Channel / Loopback / Vlan interfaces) populates
  :attr:`CanonicalInterface.vrf`.
- `netcanon/migration/codecs/juniper_junos/codec.py` — parse +
  render now populate the same canonical shapes from Junos's
  equivalents: ``set vlans <name> vxlan vni <vni>``, ``set
  routing-instances <name> instance-type vrf`` + ``route-distinguisher``
  + ``vrf-target target:<rt>`` (shared or split import/export) +
  ``interface <iface>`` + ``protocols evpn ip-prefix-routes vni
  <N>`` (populates l3_vni).  Junos's ``set routing-instances <name>
  interface <iface>.0`` round-trips through the codec's canonical
  naming convention (unit-0 compound names collapse to parent in
  CanonicalInterface.name; render re-adds ``.0`` to preserve valid
  Junos grammar).
- New helper `CanonicalRoutingInstance.l3_vni` field added to the
  GAP 5 schema — matches how both vendors express Type-5
  announcements (VRF property, not per-prefix record).  Schema
  commit is the same as GAP 6 since it's hidden behind the codec
  implementations; no separate ship-before-wire step needed.
- `/vxlan-vnis/vni` and `/routing-instances/instance` demoted from
  ``Unsupported`` to ``Supported`` on both Arista EOS and Juniper
  Junos capability matrices.  `/evpn-type5-routes/route` remains
  Unsupported on both with updated reason: Type-5 per-prefix
  records aren't populated by any codec; the VRF-property
  modelling via ``l3_vni`` is the supported path today.
- Arista: Vxlan interface stanzas are NOT materialised as
  CanonicalInterface records (they're VXLAN config containers, not
  real interfaces).  Sub-commands dispatch through a sentinel
  interface during parse; render reconstructs the whole ``interface
  Vxlan1`` stanza from ``tree.vxlan_vnis`` +
  ``tree.routing_instances[].l3_vni``.
- Arista: router-bgp stanza scanner correctly handles ``!``
  sub-stanza separators (previously would reset ``in_bgp`` mid-
  stanza and drop subsequent ``vrf`` blocks — bug caught during
  wire-up, regression-guard included).
- Arista: ``route-target import evpn <rt>`` / ``export evpn <rt>``
  inside router-bgp / vrf stanzas strip the ``evpn`` prefix to
  extract the actual RT value.
- Arista: the render path emits
  ``router bgp 65000`` with a placeholder ASN when any VRF carries
  RD / RTs — `CanonicalIntent` doesn't model BGP config beyond what
  VRFs need, so operators re-emit the ASN as appropriate.

### Tests (GAP 6)

- ``tests/unit/migration/test_vxlan_evpn_wire_through.py`` — 35
  tests covering Arista + Junos parse + render + round-trip for
  VXLAN/VRF/L3-VNI/RD/RT, plus real-fixture regression guards
  (batfish DC1-LEAF2A Arista, batfish EVPN-Type5 + L3VPN Junos).
- Schema tests (``test_vxlan_evpn_schema.py``,
  ``test_routing_instance_schema.py``) updated: the wired codecs
  (Arista + Junos) are removed from the "must declare
  Unsupported" parametrised list; two new test classes lock in
  the supported classification.

### Added (Certification promotion — Arista + Junos to `certified` ✅)

- `tests/fixtures/real/arista_eos/batfish_duplicateprivate_eos4211.txt`
  — real **EOS-4.21.1.1F** DuplicatePrivate vEOS (Apache-2.0, via
  batfish/lab-validation @ `d40faf6`).  64 lines.  Third Arista
  fixture from a third EOS major (4.21 + 4.22 + 4.23) closing the
  `≥3 fixtures from ≥2 versions` certified bar.
- `tests/fixtures/real/junos/batfish_evpntype5_router1_junos2541.set`
  — real **Junos 25.4R1.12** EVPN-VXLAN leaf grammar (Apache-2.0,
  batfish/lab-validation).  151 lines.  VXLAN VNI mappings, IRB
  sub-interfaces with VRRP, routing-instances with RD + vrf-target,
  `protocols evpn encapsulation vxlan`, BGP EVPN signaling.  Fourth
  Junos major in the corpus (biggest version jump: 15.1 → 17.3 →
  18.4 → 25.4).
- `tests/fixtures/real/junos/batfish_l3vpn_pe1_junos2541.set` —
  real **Junos 25.4R1.12** MPLS L3VPN PE grammar (Apache-2.0).
  34 lines.  CUSTOMERS VRF, iBGP PE mesh with
  `family inet-vpn unicast`, LDP + MPLS.  Fifth Junos fixture —
  classic (pre-EVPN) VRF-over-MPLS complements the EVPN fixture.

### Changed (Certification promotion)

- `arista_eos` codec certainty promoted from `best_effort` to
  **`certified` ✅** — three real captures across three EOS majors
  (4.21 + 4.22 + 4.23), all round-trip stable after the three bugs
  fixed on the promotion path.
- `juniper_junos` codec certainty promoted from `best_effort` to
  **`certified` ✅** — five real captures across four Junos majors
  (15.1 + 17.3 + 18.4 + 25.4), all round-trip stable.  The two 25.4
  batfish captures (EVPN leaf + L3VPN PE) close the `≥3 fixtures
  from ≥2 versions` bar with a current-LTS major on both surfaces.
- `tests/fixtures/real/RESULTS.md` — Arista + Junos sections now
  carry `certified` banners with matrix rows for the 3 new fixtures
  + updated post-cert items (no longer gating, listed as follow-on
  enrichment via GAP 6/8/9).  Summary table reflects 3→3 / 5→5
  fixture counts, version jumps 2→3 for Arista and 3→4 for Junos;
  TOTAL row updated 34→37.
- `tests/fixtures/real/NOTICE.md` — provenance rows for the 3 new
  fixtures with repo + commit SHA + license + grammar summary.

### Added (GAP 7 — per-unit 802.1Q VLAN tagging on Junos sub-interfaces)

- `netcanon/migration/codecs/juniper_junos/codec.py` — parse now
  handles `set interfaces <parent> unit <N> vlan-id <tag>`,
  populating :attr:`CanonicalInterface.access_vlan` on the
  sub-interface.  Semantically equivalent to Cisco's
  ``encapsulation dot1Q <N>`` — stores on the existing
  ``access_vlan`` field without setting ``switchport_mode``
  (Junos sub-interfaces are L3 on a tagged VLAN, not L2 access
  ports).  Malformed ``vlan-id <garbage>`` silently no-ops
  rather than crashing parse.
- Render-side: sub-interface emit path now includes
  ``set interfaces <parent> unit <N> vlan-id <tag>`` when
  ``access_vlan`` is populated.  A sub-interface carrying only
  ``access_vlan`` (no IP, no description) is now considered
  "renderable" — the vlan-id line IS the content; the bare
  placeholder line is suppressed to avoid redundancy.
- ``unit 0 vlan-id <N>`` (uncommon but legal) stores on the
  PARENT interface's ``access_vlan`` — consistent with the v1
  unit-0-collapses-into-parent convention.

### Tests (GAP 7)

- ``tests/unit/migration/test_juniper_junos.py::TestPerUnitVlanTagging``
  — 7 tests covering vlan-id parse, vlan-id-alone sub-interface
  materialisation, malformed tag silent no-op, render emits
  native ``<parent> unit <N> vlan-id <tag>`` grammar,
  vlan-id-alone sub-interface emits content line (not placeholder),
  parse → render → parse round-trip stability, and unit-0 vlan-id
  collapses into parent.

### Added (GAP 5 — canonical VRF / routing-instance schema [ship-before-wire])

- `CanonicalRoutingInstance` model in
  `netcanon/migration/canonical/intent.py`.  Cross-vendor VRF
  primitive modelling Cisco `vrf definition`, Arista `vrf instance`,
  and Juniper `routing-instances`.  Fields: `name`,
  `instance_type` (defaults to ``"vrf"``; Junos variants
  ``virtual-router`` / ``l2vpn`` / ``mac-vrf`` also accepted),
  `route_distinguisher`, `rt_imports`, `rt_exports`, `description`.
- `CanonicalInterface.vrf` field — per-interface VRF membership
  (back-pointer pattern, matches `lag_member_of`).  Empty string
  = global / default VRF.  Source of truth for membership;
  `CanonicalRoutingInstance` carries metadata only.
- `CanonicalIntent.routing_instances` top-level Tier-2 list.
- Ship-before-wire: Arista EOS, Juniper Junos, and Cisco IOS-XE CLI
  capability matrices gain ``UnsupportedPath`` entries for
  ``/routing-instances/instance`` so the pipeline's validate stage
  surfaces "VRF detected but not translated" in the UI banner.
- Documentation: ``ARCHITECTURE.md`` Layer-3 CIM table's "Tier 2
  (ship-before-wire)" row extends to include VRF schema.
  ``translator-plans.txt`` DEFERRED ROADMAP gains a ``[SHIPPED]``
  entry for GAP 5.
- Tests: ``tests/unit/migration/test_routing_instance_schema.py``
  — 20 tests covering model construction, defaults, full-field
  construct, Junos `instance_type` variants, name-required
  validation, `<ip>:<nn>` RD form, per-interface `vrf` defaults +
  serialisation, intent-level `routing_instances` round-trip,
  and the ship-before-wire contract (parametrised check that
  every DC codec declares the new path as ``unsupported``).

### Added (GAP 4 — Junos apply-groups host-name inheritance + per-unit sub-interfaces)

- `netcanon/migration/codecs/juniper_junos/codec.py` — parse now
  handles `set groups <gname> system host-name <X>` + `set apply-
  groups <gname>` pairs, populating `intent.hostname` from the
  first applied group that declared a host-name (mirrors Junos's
  own first-match composition order).  Direct `set system
  host-name X` still wins over group-scoped fallback when both
  are present.  Bracketed `set apply-groups [ g1 g2 ]` form
  tolerated: the bracket tokens are filtered out of the
  applied-groups list.
- `netcanon/migration/codecs/juniper_junos/codec.py` — parse now
  materialises per-unit sub-interfaces (unit 1+) as distinct
  `CanonicalInterface` entries named `<parent>.<unit>` (e.g.
  `ge-0/0/0.100`), matching Cisco's dot1Q convention so canonical
  consumers see the same shape across vendors.  IPv4 address +
  description + disable on the sub-interface all populate on the
  child entry; unit 0 still collapses into the parent.  Render
  splits the compound name back into native Junos grammar
  (`set interfaces <parent> unit <N> ...`) — the compound name
  never appears in emitted set-lines.
- Helper `_split_subiface_name(name)` distinguishes Junos physical
  sub-interfaces (parent contains `<media>-<fpc>/<pic>/<port>`
  slashes, e.g. `ge-0/0/0.100`) from SVI-like interfaces whose
  dot is part of the base name (`irb.10`, `vlan.100`).  The
  slash requirement prevents mis-splitting irb/vlan identities.

### Changed (GAP 4 — ksator fixture hostnames now populate)

- `tests/fixtures/real/RESULTS.md`: Junos ksator QFX5100 + EX4550
  coverage-matrix rows updated from `hostname=0` to `hostname=1`
  each; explanatory note replaced with "**populates intent.hostname
  as of GAP 4**" marker.
- `tests/fixtures/real/RESULTS.md`: Junos promotion-path paragraph
  updated — apply-groups host-name and sub-interface materialisation
  are no longer pending; richer apply-groups inheritance (interface
  config, protocols, SNMP) + routing-instances + block-form + per-
  unit VLAN tagging remain the outstanding work before `certified`.

### Tests (GAP 4)

- `tests/unit/migration/test_juniper_junos.py::TestApplyGroupsHostname`
  — 7 tests covering the happy path, top-level-wins-over-group,
  unapplied group ignored, first-applied-group-wins ordering,
  bracketed `[ g1 g2 ]` syntax, and two real-fixture regression
  guards (ksator QFX5100 + EX4550) asserting the specific
  hostname string.
- `tests/unit/migration/test_juniper_junos.py::TestSubInterfaces`
  — 7 tests covering unit-N materialisation with IP + description
  + disable, multi-subiface on same parent, `irb.N` preservation
  (not mis-split as `irb`+unit), render emits native
  `<parent> unit <N>` grammar (never the compound-name form), and
  round-trip stability through parse → render → parse.

### Added (GAP 3 — real-capture fixtures promoting Arista + Junos to best_effort)

- `tests/fixtures/real/arista_eos/batfish_labval_dc1_leaf2a_eos4230.txt`
  — real **EOS-4.23.0.1F** vEOS EVPN leaf from Arista's EVPN L3
  Design Guide lab validation (Apache-2.0, via batfish/lab-validation
  @ `d40faf6`).  429 lines, 40 interfaces, 15 VLANs, MLAG peer-link
  + 5 Port-Channels, VXLAN1 overlay, router bgp 65102, VRF
  definitions, VARP virtual-router MAC.  Second Arista fixture from
  a different EOS major (4.22 + 4.23).
- `tests/fixtures/real/junos/ksator_labmgmt_qfx5100_junos173.set` —
  real **Junos 17.3R1.10** set-form config from a QFX5100 DC
  access/leaf switch (MIT, (c) Juniper Networks 2018).  106 lines,
  11 interfaces (including `ae0`/`ae1` LAGs), 16 VLAN declarations,
  apply-groups inheritance pattern.
- `tests/fixtures/real/junos/ksator_labmgmt_ex4550_junos151.set` —
  real **Junos 15.1R6.7** set-form from an EX4550 campus/DC access
  switch (same MIT source).  52 lines, 3 interfaces, 6 VLANs,
  `chassis aggregated-devices ethernet device-count 2`.  Oldest
  Junos major in the corpus — closes ≥3-version gap for promotion.

### Changed (GAP 3 — codec certification promotions + bug fixes surfaced by real captures)

- `arista_eos` codec certainty promoted from `experimental` to
  `best_effort` — 2 real captures across EOS 4.22 + 4.23 both
  round-trip stable.
- `juniper_junos` codec certainty promoted from `experimental` to
  `best_effort` — 3 real captures across Junos 15.1 + 17.3 + 18.4
  all round-trip stable.
- `netcanon/migration/codecs/arista_eos/codec.py` — render-side
  LAG bug fixed.  The render loop emitted `interface Port-ChannelN`
  stubs but did NOT emit the matching `channel-group N mode <mode>`
  on member Ethernet interfaces.  Arista LAGs (like Cisco IOS)
  are synthesised at parse time from `channel-group` lines on the
  child side; with nothing on that side in the rendered output,
  re-parse produced zero LAGs even when the canonical tree carried
  them.  Round-trip lost all 5 MLAG Port-Channels on the batfish
  EVPN capture.  Fixed by building a `lag_mode_by_name` lookup at
  render top + emitting `channel-group N mode <mode>` on
  interfaces whose `lag_member_of` is populated, with canonical-
  to-EOS mode normalisation (`static` → `on`, `passive` →
  `passive`, else `active`).
- `netcanon/migration/codecs/juniper_junos/codec.py` — render-side
  bare-interface bug fixed.  The v2a render loop emitted interface
  set-lines only when the canonical interface carried a
  description / disable flag / IPv4 address.  The Junos parse side
  creates an interface entry for every `set interfaces <name> ...`
  line seen — including lines whose trailing tokens are all Tier-3
  grammar (e.g. `unit 0 family ethernet-switching ...`).  Those
  interfaces landed in the canonical tree with no renderable
  attributes, render dropped them silently, and round-trip showed
  the interface count shrinking on the ksator EX4550 fixture.
  Fixed by emitting `set interfaces <name>` as a placeholder line
  when no other attributes are present.

### Tests (GAP 3)

- `tests/unit/migration/test_arista_eos.py::TestRender` — two new
  regression tests:
  - `test_render_lag_member_emits_channel_group` — parse a LAG
    config, render, re-parse, assert identical LAG recovered.
  - `test_render_lag_member_mode_normalised` — canonical mode
    `static` must render as EOS `on`.
- `tests/unit/migration/test_juniper_junos.py::TestRenderInterfaces::
  test_render_bare_interface_emits_placeholder` — parse an
  attribute-less interface, render, re-parse, assert the interface
  survives.
- 9 real-capture tests newly active across the 3 new fixtures
  (parse / round-trip / canonical representation stability).

- `tests/fixtures/real/RESULTS.md` — Arista + Junos sections
  updated with new coverage matrix rows, findings (the two bugs
  above), certification-decision paragraphs promoted to
  `best_effort`, and revised promotion-path notes.  Summary table
  updated: Arista 1→2 real / 1→2 versions / 2→3 bugs surfaced;
  Junos 1→3 real / 1→3 versions / 0→1 bug; TOTAL 31→34 fixtures,
  12→16 bugs surfaced.
- `tests/fixtures/real/NOTICE.md` — provenance rows added for
  each new fixture: source repo + commit SHA + license + line
  count + grammar summary.

### Changed (GAP 2 — Juniper Junos render-side v2a [flat set-form, no apply-groups])

- `netcanon/migration/codecs/juniper_junos/codec.py` — render
  implementation replaces the previous ``NotImplementedError`` stub.
  Direction promoted from ``parse_only`` to ``bidirectional``.
  Codec remains ``experimental`` tier pending additional real
  captures (see RESULTS.md for promotion bar).
- Render emits flat set-form commands in a deterministic order —
  system / login / interfaces / vlans / routing-options / snmp —
  so repeated renders of the same tree produce byte-identical
  output (load-bearing for diff-based deploy + snapshot compare).
- Quoting strategy mirrors what Junos's ``| display set`` emits
  natively: bare tokens for simple strings, double-quoted wrappers
  for strings containing whitespace or shell-special characters,
  backslash-escaping for embedded quotes.  Two internal helpers
  (``_quote_if_needed`` for identifiers, ``_quote_always`` for
  free-text fields) split the two conventions.
- Hash round-trip: passwords stored under the ``junos:<hash>``
  vendor tag (added by parse) get the prefix stripped on render,
  producing the operator-facing ``encrypted-password "<hash>"``
  form.  Cross-vendor hashes (no ``junos:`` prefix) emit verbatim.
- Role inference for rendered ``class``: explicit ``role`` field
  wins; otherwise privilege-level ≥15 → ``super-user``, ≥5 →
  ``operator``, else ``read-only``.  Preserves cross-vendor
  round-trips that drop role during codec-to-codec translation.
- Connected/blackhole static routes (empty gateway) are skipped on
  render — Junos flat set-form requires a ``next-hop``; we'd rather
  drop them than emit invalid input.  Logged as a future
  enrichment if demand arrives.
- Apply-groups inheritance (``set groups <name> ... / set
  apply-groups <name>``) is NOT emitted in v2a.  Output is verbose
  but syntactically complete.  v2b (deferred, bundleable with
  GAP 4) will detect repeated sub-trees and collapse them for
  operator readability.

### Tests (GAP 2)

- ``tests/unit/migration/test_juniper_junos.py`` — render test
  classes added alongside existing parse tests:
  ``TestRenderBasic`` (empty tree, hostname-only, determinism),
  ``TestRenderInterfaces`` (description quoting incl. embedded
  quotes, disabled flag, multi-IP, loopback), ``TestRenderVlans``
  (named, synthetic ``VLAN-<id>`` fallback, space-quoted name),
  ``TestRenderUsers`` (super-user/read-only from privilege,
  role-wins-over-privilege, hash vendor-tag stripping, cross-
  vendor hash preservation), ``TestRenderRouting`` (static route
  + connected-route skip), ``TestRenderSnmp`` (community,
  location, contact, trap-hosts), ``TestRenderRoundTrip``
  (hostname, sample-input round-trip, user-with-hash round-trip,
  special-char description round-trip), ``TestRenderCodecMetadata``
  (direction-promoted-to-bidirectional, render idempotence).
- Previously-skipped real-capture round-trip tests for Junos
  (``test_real_captures.py``) now run and pass against the
  buraglio fixture — Junos is no longer a parse_only skip case.
- Previously-skipped ``cross_mesh`` cases that filtered on
  ``direction == "bidirectional"`` now include Junos, expanding
  the per-pane rename-overrides smoke surface.

### Added (GAP 1 — EVPN-VXLAN canonical schema [ship-before-wire])

- `CanonicalVxlan` + `CanonicalEvpnType5Route` models in
  `netcanon/migration/canonical/intent.py`.  ``CanonicalVxlan``
  carries ``vlan_id``, ``vni`` (24-bit, validated 1..16777215),
  optional ``mcast_group``, and ``flood_list`` for head-end
  replication.  ``CanonicalEvpnType5Route`` carries ``vrf``,
  ``prefix`` (CIDR, IPv4 or IPv6), and ``rt_imports`` / ``rt_exports``
  for BGP-EVPN IP-prefix advertisements.
- Two new top-level Tier-2 lists on ``CanonicalIntent``:
  ``vxlan_vnis: list[CanonicalVxlan]`` and
  ``evpn_type5_routes: list[CanonicalEvpnType5Route]``.
- Ship-before-wire contract: no codec populates the new fields in
  this commit.  Arista EOS, Juniper Junos, and Cisco IOS-XE CLI
  capability matrices gain ``UnsupportedPath`` entries for
  ``/vxlan-vnis/vni`` and ``/evpn-type5-routes/route`` so the
  pipeline validate stage surfaces "VXLAN / EVPN detected but not
  translated" in the UI banner instead of silently dropping the
  payload.  Access-switch and firewall codecs (Aruba AOS-S,
  OPNsense, MikroTik, FortiGate) don't declare — the feature is
  architecturally inapplicable to their deployment class.
- Documentation: ``ARCHITECTURE.md`` Layer-3 (CIM) section gains
  a "Tier 2 (ship-before-wire)" row in the field-tier table and a
  paragraph describing the pattern, with EVPN-VXLAN as the
  reference case.  ``docs/adding-a-canonical-field.md`` gains a
  "Two shapes of this commit" header distinguishing the MTU-style
  wire-through from the ship-before-wire shape.  ``translator-plans.txt``
  DEFERRED ROADMAP gains a ``[SHIPPED]`` entry for GAP 1.
- Tests: ``tests/unit/migration/test_vxlan_evpn_schema.py`` covers
  model construction, VNI range validation, flood-list semantics,
  IPv6 prefix acceptance, round-trip through pydantic dump/load,
  and the ship-before-wire contract (parametrised check that every
  DC codec declares the two new paths as ``unsupported``).

### Added (Phase 13 — Juniper Junos codec v1 [parse-only] + 7 switching target profiles)

- `netcanon/migration/codecs/juniper_junos/` package — first
  hierarchical-grammar vendor in the portfolio.  v1 parses
  ``set``-form configuration text (output of
  ``show configuration | display set`` on Junos EX/QFX/MX/SRX).
  Direction: parse_only — render-side Junos requires commit /
  apply-groups / candidate-config handling that warrants a
  dedicated follow-up commit.  Strategic use case: migration
  FROM Junos TO Cisco / Arista / Aruba (DC refreshes, SP-to-
  enterprise moves).
- Supported grammar:
  - ``set system host-name``
  - ``set system login user <name> class <class>`` (super-user →
    privilege 15; read-only → privilege 1)
  - ``set system login user <name> authentication
    encrypted-password "<hash>"`` (hash preserved under
    ``junos:<hash>`` vendor tag)
  - ``set interfaces <iface> unit <N> family inet address
    <ip>/<prefix>`` (unit 0 collapsed into parent in v1;
    unit 1+ deferred)
  - ``set interfaces <iface> description "<desc>"`` (top-level
    or unit-scoped)
  - ``set interfaces <iface> disable``
  - ``set vlans <NAME> vlan-id <N>``
  - ``set routing-options static route <dest> next-hop <gw>``
  - ``set snmp community <name> authorization read-only``
  - ``set snmp location "<loc>"`` / ``set snmp contact "<c>"``
  - ``set snmp trap-group <name> targets <ip>``
- Parse-tolerance (silently ignored Tier-3 stanzas):
  ``set protocols bgp`` / ``isis`` / ``ospf`` / ``mpls``,
  ``set routing-instances``, ``set groups`` + apply-groups,
  ``set firewall``, ``set policy-options``, block-form
  (curly-brace) input rejected with a helpful error hinting at
  ``| display set``.
- Port-name identity bridge: Junos media prefixes
  (``ge-`` / ``xe-`` / ``et-`` / ``fe-`` / ``mge-`` / ``xle-``)
  encode speed hints; FPC/PIC/port 3-part naming maps to
  stack/module/port; management (``em0`` / ``me0`` / ``fxp0``)
  → kind=mgmt; ``ae<N>`` → kind=lag; ``irb.<N>`` → kind=svi.
  Cross-vendor: Cisco ``GigabitEthernet1/0/24`` →
  ``ge-1/0/24``; Cisco ``TenGigabitEthernet1/0/48`` →
  ``xe-1/0/48``.
- Real-capture fixture
  `tests/fixtures/real/junos/buraglio_netlab_junos184.set` —
  Junos 18.4R1-S1.1 set-form from ES.net netlab-ns demo.  28
  lines, exercises `em0` + `lo0` with IPv4 + IPv6/ISO/MPLS
  families + root-authentication + BGP + IS-IS + MPLS + LLDP.
- 7 new switching target profiles (feature parity with other
  shipped codecs):
  - Arista: DCS-7050SX-64 (10G TOR, matches the real fixture
    class), DCS-7050CX3-32S (100G leaf), DCS-7280CR3-32P4
    (100G + 400G spine), DCS-7060CX-32S (Tomahawk 100G TOR).
  - Juniper: EX4300-48T (1G campus access + VC stacking),
    EX4600-40F (10G/40G campus aggregation), QFX5120-48Y
    (25G/100G DC leaf).
- Tests (+61 new):
  - 40 unit tests in `tests/unit/migration/test_juniper_junos.py`
    covering parse scalars / interfaces / VLANs / users / routes
    / SNMP / validation / tolerance / probe / port-names.
  - 7 per-profile shipped-profile lock-in tests in
    `test_target_profile_shipped.py`.
  - Cross-vendor port-name-translation tests: Cisco → Junos for
    ``ge-`` and ``xe-`` media prefixes via speed-hint routing.
  - Real-captures harness extended: new `junos` dispatch entry;
    `.set` extension allowlist added.
  - `max_vlans` lock-in extended for the Arista + Junos
    families (all 7 new profiles declare 4094).
- Docs synced per AGENTS.md Documentation Sync Checklist:
  - `CHANGELOG` — this entry
  - `ARCHITECTURE.md` — codec count 6 → 7
  - `netcanon/migration/codecs/README.md` — wire-format table
    gains "Flat set-form command text (Junos)" row
  - `codecs/base.py` INPUT_FORMATS — new `cli-junos-set` entry
  - `vendors/juniper_junos.yaml` — vendor declaration
  - `migrate.html` _VENDOR_LABELS — `juniper_junos: 'Juniper Junos'`
  - `tests/fixtures/real/NOTICE.md` — fixture provenance
  - `tests/fixtures/real/RESULTS.md` — new juniper_junos section
    + summary table (30 → 31 fixtures)

Strategic note on canonical-model extensibility: the Junos port-
name bridge illustrates the ``stack+module+port`` mapping
working for a non-Cisco hierarchical naming scheme — useful
precedent when NX-OS / IOS-XR get added later.  Canonical gaps
exposed that warrant follow-up: ``routing-instances`` (Junos's
VRF equivalent with richer semantics than Cisco's ``ip vrf``),
``groups`` / ``apply-groups`` inheritance, firewall filters,
and per-unit sub-interface modelling.

### Added (Phase 12 — Arista EOS codec v1: 6th shipped vendor, first DC-switching-native)

- `netcanon/migration/codecs/arista_eos/` package — bidirectional
  codec for Arista EOS ``show running-config`` text.  Closes the
  biggest cross-vendor DC migration corridor in enterprise networks
  (Cisco Catalyst 9K / Nexus → Arista 7050 / 7280 / 7500).
- New vendor YAML `netcanon/migration/vendors/arista_eos.yaml`.
- New INPUT_FORMATS entry `cli-arista` in `codecs/base.py`.
- UI vendor-labels dropdown gains `arista_eos: 'Arista EOS'` so the
  rename-modal vendor picker surfaces Arista as a target.
- Codec scope:
  - Parses hostname / dns / ntp / snmp community+location+contact+host
    / interfaces (Ethernet<N>, Ethernet<N>/<M> QSFP breakout,
    Management<N>, Loopback<N>, Vlan<N>, Port-Channel<N>) with
    CIDR `ip address 10.0.0.1/31`, quoted descriptions, shutdown,
    no switchport, switchport access/trunk allowed, channel-group
    membership / vlans / static routes / local users with
    `role <name>` and `secret <alg> <hash>` algorithms.
  - Renders all the above bidirectionally; round-trips the first
    real capture cleanly.
  - BGP / OSPF / MLAG / VXLAN / eAPI-over-HTTP / spanning-tree /
    aaa / daemon parse-and-ignore (Tier-3 grammar).
- Real-capture fixture
  `tests/fixtures/real/arista_eos/ksator_dcs_7150s64_eos4224.txt` —
  real DCS-7150S-64-CL on EOS 4.22.4M-2GB (256 lines, 66 ifaces,
  5 admins across 3 password-mode variants, QSFP breakouts, BGP
  + MLAG stanzas exercised as parse-ignore).  Certainty ships at
  `experimental` — promotion to `best_effort` after ≥1 additional
  capture from a different EOS version.
- Bugs surfaced + fixed during codec v1 development:
  1. Username regex `\s+` bled across newlines, causing users
     disappearing from finditer on multi-user blocks following a
     `secret sha512 $6$...` line.  Fixed via `[^\S\n]+`
     non-newline whitespace matcher.
  2. Render emitted `secret sha512:$6$...` (colon-separated
     canonical form) instead of `secret sha512 $6$...` (wire
     format).  Re-parse then missed the hash.  Fixed to split
     canonical tail into `alg` + `hash` on `:` and emit
     space-separated.
  Both caught by round-tripping the real fixture — textbook
  real-capture-harness validation.
- Port-name identity bridge (`arista_eos/port_names.py`) translates
  cross-vendor: Cisco `GigabitEthernet1/0/24` → Arista `Ethernet24`
  (drops stack+module indices, documented collision trade-off).
  `Port-Channel` canonical capitalisation (distinct from Cisco's
  `Port-channel`).
- Tests (+76 new):
  - 45 unit tests in `tests/unit/migration/test_arista_eos.py`
    covering parse scalars / SNMP / users / interfaces+VLANs /
    input-validation / render / round-trip / port-names / probe.
  - Real-captures harness extended with `arista_eos` in the
    `_VENDOR_TO_CODEC` dispatch map; 3 auto-discovered tests for
    the ksator fixture.
- Docs synced per AGENTS.md Documentation Sync Checklist:
  - `tests/fixtures/real/NOTICE.md` — fixture provenance row
  - `tests/fixtures/real/RESULTS.md` — new arista_eos section
    with coverage matrix + findings + cert decision;
    summary table updated (28 → 30 fixtures total, 10 → 12
    bugs surfaced)
  - Vendor-count test loosened to `>= 7` rather than `== 6`
    so adding a future codec doesn't force a mechanical edit
    (individual per-vendor presence tests lock in identity)
- Strategic note: adding Arista exposes canonical gaps in EVPN
  type-2/type-5 routes and VXLAN VNI-to-VLAN mapping — both are
  first-class in EOS and currently unmodelled in CanonicalIntent.
  Parse-and-ignore for v1; schema extension deferred to a follow-
  up commit (unblocks Arista ↔ NX-OS ↔ Junos EVPN-fabric
  migrations).

### Added (Phase 11 — 2 real-capture fixtures for grammar diversity)

- `tests/fixtures/real/aruba_aoss/hpe_community_5406rzl2_kb1515.cfg`
  — first fixture on the KB software branch + first fixture on
  the 5400R modular-chassis class.  Exercises `module A type j9534a`
  line-card declarations + letter-slot port ranges + multi-VLAN
  `ip helper-address` grammar.  Source: HPE Community forum thread
  6935784.
- `tests/fixtures/real/cisco_iosxe/cml_basic_forwarding_iosv_r1_ospf.txt`
  — first OSPFv2 multi-feature-grammar fixture in the corpus.
  `router ospf 1` + `router-id` + `passive-interface` + 5 `network`
  statements + per-interface `ip ospf cost` tuning + dot1Q
  subinterfaces.  Source: CiscoDevNet/cml-community under BSD-3-Clause.

### Changed (god-file cleanup — fortigate_cli codec split into parse.py + render.py)

- `netcanon/migration/codecs/fortigate_cli/codec.py` was the 4th-
  largest codec file at ~1068 LOC carrying both parse dispatchers
  (`_apply_<path>` functions) and render logic inline on the
  `FortiGateCLICodec` class.  Split into:
    - `parse.py` — block-model (`_ConfigBlock` / `_EditBlock`),
      tokeniser (`_parse_blocks`), per-stanza dispatchers
      (`_apply_system_global`, `_apply_system_interface`, …), IP
      mask utilities shared with render, LACP-mode maps.  Public
      entry `parse_intent(raw) -> CanonicalIntent` replaces the
      previously-inline parse method body.
    - `render.py` — canonical tree → FortiOS CLI text.  Public
      entry `render_intent(tree) -> str`.  Imports IP utilities +
      canonical-to-FortiGate LACP map from `parse.py` (one-
      directional edge, no circular risk).
    - `codec.py` — thin orchestrator retaining the
      `FortiGateCLICodec` class with metadata, probe, capability
      matrix, port-name delegates.  `parse()` and `render()` are
      one-line delegators to the sibling modules.  Re-exports
      `_parse_blocks`, `_prefix_to_mask`, `_mask_to_prefix` via
      `__all__` so `tests/unit/migration/test_fortigate_cli.py`
      (which pins internal symbols as test contracts) doesn't
      need to change.
- `vlan_heuristics.py` already existed as an earlier partial
  extraction; untouched by this commit.
- **Line counts post-split:** codec.py 246 (was 1068, −77%),
  parse.py 675, render.py 290.  Total is up slightly because
  each new module carries its own docstring + imports — the win
  is focus, not line reduction.
- `netcanon/migration/codecs/fortigate_cli/__init__.py` docstring
  refreshed: stale `certainty: best_effort` claim corrected to
  `certified` (matches RESULTS.md post-promotion), module-layout
  section added describing the new parse/render split, Tier-2
  coverage (SNMP / admin / RADIUS / DHCP) enumerated, structural
  quirks updated with the `set radius-port 0` canonicalisation.
- `netcanon/migration/codecs/README.md` Module layout section
  extended with the `parse.py` / `render.py` split pattern.
  Lists remaining split candidates (mikrotik_routeros,
  cisco_iosxe_cli, aruba_aoss, opnsense) and the re-export
  discipline that keeps tests unchanged across a split.
- **No behaviour change**: 1526 passed, 60 skipped (identical
  to pre-split).  All 26 `test_fortigate_cli.py` wire-through
  tests + real-capture harness + cross-mesh mesh still green.

### Added (P2C5 — per-pane SNMP community rename: fourth per-pane override category)

- New canonical orchestrator
  `netcanon/migration/canonical/snmp_names.py` — community-string
  rename via the same `build_X_rename_transform(rename_map) →
  (transform, result)` shape as ports / VLANs / local_users.
  Scope is scalar (`CanonicalIntent.snmp` holds a single
  `CanonicalSNMP` object) so the rename map is effectively single-
  entry but keeps the `dict[str, str | None]` shape for API
  symmetry with the list-oriented panes.
- Pipeline extension:
  `run_plan_with_overrides` grows a new optional
  `snmp_community_rename_map` parameter.  Runs last in the override
  chain (independent of ports / VLANs / users so ordering is free).
  Capture-first transform extended to populate
  `source_snmp_community` alongside `source_vlans` / `source_local_users`.
- Model fields on `MigrationJob`:
  `snmp_community_renames`, `snmp_community_drops`,
  `source_snmp_community`.  New field on `MigrationPlanRequest`:
  `snmp_community_rename_map`.
- New endpoint `POST /api/v1/migration/plan/snmp` — fourth
  concrete per-pane override endpoint.  Follows the `/plan/ports`
  pattern established in P2C1; ignores other category maps posted
  in the same body.  Main `POST /plan` routing predicate updated
  to detect `snmp_community_rename_map` (closes the same silent-
  drop class of bug the P2C4 fix caught for VLAN + user maps).
- UI:
  - New SNMP rail button in the rename modal (4th category).
  - New SNMP pane with single-row community-rewrite table.
    Structurally a table for visual parity with the list-oriented
    panes; always exactly one row.
  - New `_partials/snmp-rename-table.js` partial (~170 LOC)
    rendering the row with input + clear/un-clear/keep-verbatim
    three-state link.  The "drop" link is labelled **clear** on
    this pane because clearing the community omits the entire
    SNMP block rather than removing an identity.
  - `_renameSnmpCommunityMap` module-scope state; wired into
    localStorage ack persistence (schema-v1 additive — missing
    keys load as empty dict), reset-all, capture-for-rename gate,
    open-modal render, showRenameCategory pane switch,
    renderRenameRailCounts.
  - `rename-panel.js` preview substitution extended for SNMP
    community rename (whole-word match, same technique as VLANs
    + users).  Clear semantics still require server re-render
    (the SNMP stanza itself disappears, not a substitution).
  - `rename-panel.js` summary adds an SNMP sub-summary that
    surfaces only when SNMP activity exists (0-state gate parity
    with VLAN + user sub-summaries).
  - `rename-apply.js` POST body augmented with
    `snmp_community_rename_map` when the map is non-empty
    (same gate-on-non-empty pattern as the other categories).
- Tests:
  - 20 new unit tests in
    `tests/unit/migration/test_snmp_names.py` covering identity,
    rename, clear, no-SNMP-block warning, non-matching-source
    warning, input validation, transform builder.
  - 8 new integration tests in
    `tests/integration/test_migration_api.py` — new
    `TestPlanSnmpEndpoint` class + extensions to
    `TestSourceShapeCapture` (source_snmp_community
    populate/empty/pre-mutation contracts) +
    `TestPlanMultiCategoryRoutingSnmp` (routing + four-way
    composition).
  - Cross-mesh extension in
    `tests/unit/migration/test_cross_mesh_overrides.py`: new
    parametrized `test_snmp_community_rename_smoke_cross_codec`
    across cisco_iosxe_cli + aruba_aoss + mikrotik_routeros as
    sources, aruba_aoss + mikrotik_routeros as targets
    (cisco_iosxe_cli is parse_only, excluded from target set).
    New `test_four_category_overrides_in_one_call` locks in
    the full port+vlan+user+snmp composition contract.
- Docs synced per AGENTS.md Documentation Sync Checklist:
  - `tests/testid_reference.md` — 11 new testid rows documented
    for the SNMP pane (verified via grep self-check: reference
    count matches template/partial count).
  - `netcanon/templates/migrate.html` Contents map comment —
    adds the new partial entry.
  - Route docstring in `netcanon/api/routes/migration.py`
    (top-of-file endpoint enumeration) — lists `/plan/snmp`.
  - Pipeline docstring in
    `netcanon/services/migration_pipeline.py` — category
    support list updated; new capture-only field documented.
  - Model docstrings on `MigrationJob` + `MigrationPlanRequest`.
  - `HUMAN_TESTING.md` — new SNMP-pane manual-test scenarios.
  - `ARCHITECTURE.md` — queued "per-pane SNMP / RADIUS overrides"
    item updated to reflect SNMP is no longer queued.
- **Deferred** (explicitly NOT in this commit; reserved in
  pipeline param docstring): SNMPv3 user rename (requires
  canonical-schema extension; CanonicalSNMP models v1/v2c only),
  SNMP trap-host rename (list surface parallel to this commit's
  scalar community surface — same recipe applied again,
  reserved parameter name `snmp_trap_host_rename_map`).

### Fixed (stale cert-promotion drift in RESULTS.md / ARCHITECTURE.md / translator-plans.txt)

- `tests/fixtures/real/RESULTS.md` closing paragraph said
  "No codec is `certified` yet.  The threshold is ≥3 captures from
  ≥2 OS versions with round-trip stability — each vendor is ~2
  captures short of that bar".  Factually wrong — every shipped
  codec has since been promoted to `certified` (cisco_iosxe_cli,
  opnsense, mikrotik_routeros, fortigate_cli, aruba_aoss).  The
  Summary table above the paragraph already showed all five as
  certified ✅; the prose hadn't been updated to match.  Replaced
  with a "Certification state (April 2026)" block explaining the
  corpus purpose has shifted from promotion-driving to
  hardening-driving, with concrete grammar-diversity gaps listed
  (FortiGate multi-VDOM, FortiOS 7.4, RouterOS 7.19+, OPNsense
  25.x, AOS-S 16.11 late patches) — all opportunistic, none
  blocking.
- `ARCHITECTURE.md` "Real-capture validation" paragraph said the
  harness "is what gates codec promotion to `certified`".  Past
  tense now: all five are already at `certified`; the harness
  drives hardening rather than promotion.
- `ARCHITECTURE.md` Evolution roadmap "What's queued" listed
  "Fixture hunting to promote more codecs toward `certified`" as
  a top queue item.  Replaced with the accurate grammar-diversity
  gap list (pointers to RESULTS.md for the live version) + added
  per-pane SNMP / RADIUS override work to the queue (already
  slated via the established three-step recipe but previously
  undocumented in ARCHITECTURE).
- `translator-plans.txt` R6/7 "REAL-CAPTURE VALIDATION" block
  contained a snapshot of bug-fix session state that predates
  the cert-promotion wave — results table showed mikrotik as
  "CERTIFIED" and others as "best_effort", plus a "Still to do
  — to graduate any codec to `certified`" subsection listing
  fixture-count shortfalls.  Marked the whole block as
  SUPERSEDED with a pointer to RESULTS.md as the single source
  of truth.  Block preserved as a historical journal entry
  (per the CHANGELOG-archival-records Hard Rule exception) —
  its value now is showing which bugs each promotion surfaced,
  not current state.

This is a pure-docs fix — no code changes, no test count delta.
The drift slipped through earlier promotion commits because the
CHANGELOG + RESULTS.md tier-table got updated in each promotion
but the paragraph-form prose below the tables (and the
ARCHITECTURE queued-work + translator-plans historical block)
were left behind.  A useful illustration of why the Doc Sync
Checklist Hard Rule exists: numbers in tables fail loudly when
wrong, prose rots silently.

### Added (deferred item — FortiGate `max_vlans` version-tuning + `max_vlans_source` provenance field)

- New optional `TargetProfile.max_vlans_source: str` field carrying a
  free-text provenance string for `max_vlans` — pins which firmware
  release / datasheet the cap was validated against (e.g.
  `"FortiOS 7.2 Maximum Values Table, per-VDOM system.interface type
  vlan"`).  Makes version-tuning passes mechanically auditable via
  `grep` and lets the UI surface the source in a fit-check banner
  tooltip.
- All 3 shipped FortiGate profiles (40F / 60F / 100E) populated with
  `max_vlans_source` pinning FortiOS 7.2 as the Max Values Table
  reference.  FG-100E additionally calls out the
  `user_contrib_fg100e_fos7213.conf` real capture as the grounding
  datapoint — same capture that certifies the `fortigate_cli` codec.
- YAML comment sweep on the 3 FortiGate profiles: expanded the
  provenance block to name a specific FortiOS minor version, cite
  the Max Values Table URL, and explain that values can drift ±1
  between 7.0 / 7.2 / 7.4 / 7.6 so operators should re-verify on
  firmware upgrades.
- **Factual fix:** earlier comments said "up to N VLANs across all
  VDOMs".  Fortinet's Max Values Table publishes rows *per-VDOM*,
  not VDOM-aggregate — corrected across all three FortiGate
  profiles.  No numeric change, just clarified scoping.
- New unit test `test_fortigate_profiles_declare_max_vlans_source`
  enforces every shipped FortiGate profile declares the field and
  pins a specific FortiOS minor (rejects `"FortiOS 7.x"`-style
  vague strings).
- New integration test class `TestMaxVlansSourceSerialization`
  proves API serialization: FortiGate profiles surface
  `max_vlans_source` with a FortiOS version pin; profiles that
  haven't opted in serialize the field as `""` (not `null`).
- Other vendors (Aruba / Cisco / MikroTik / OPNsense) intentionally
  leave `max_vlans_source` empty for now — populate opportunistically
  when each vendor's caps get revisited.  The field is optional +
  defaulted so no mass-backfill is required.
- Numbers themselves unchanged: 40F / 60F still 512, 100E still 1024.
  This commit is a provenance tune, not a value tune — the existing
  values were the correct conservative floor for FortiOS 7.x; the
  improvement is that future value changes will now carry an
  audit-trail.

### Changed (kill module-variant allowlist manual-sync tax)

- The `MODULE_VARIANT_PROFILES` set was duplicated as two identical
  literals — one in `tests/unit/migration/test_target_profile_shipped.py`
  and one in `tests/integration/test_migration_target_profiles_api.py`
  — with a "kept in sync manually" comment on each and a matching
  Documentation Sync Checklist row in AGENTS.md.
- Extracted to a canonical shared module at
  `tests/fixtures/module_variants.py` (following the existing
  `tests/fixtures/` convention for test-input data).  Both test
  classes now import from there via `MODULE_VARIANT_PROFILES = MODULE_VARIANT_PROFILES`
  (class-body re-bind of the module-level constant — standard
  Python idiom).
- New CI-guard test
  `test_module_variant_allowlist_shared_with_integration_tier`
  asserts both class attributes are IDENTITY-equal (`is`) to the
  canonical set — catches accidental shadowing with a literal even
  if the content would otherwise match.
- AGENTS.md Documentation Sync Checklist row updated: single-file
  edit + CI-guard replaces the two-file manual-sync discipline.

### Added (deferred item 3 — more target profiles: Netgate ARM + Deciso DEC600)

- `opnsense_netgate_sg1100.yaml` — 3-port ARM desktop firewall,
  Marvell Armada 3720 + MV88E6141 switch chip.  Interface layout
  follows pfSense's published DSA + VLAN-tag convention
  (`mvneta0.4090` WAN, `mvneta0.4091` LAN, `mvneta0.4092` OPT).
- `opnsense_netgate_sg3100.yaml` — 5-port ARM desktop firewall
  (1 direct WAN + 4 switch LAN), Marvell Armada 38x.  Uses the
  VLAN-tag convention for the 4 LAN ports on `mvneta2`.
- `opnsense_deciso_dec600_igc.yaml` — 5-port 2.5GbE desktop
  appliance, Intel i226-V via `igc(4)`.  Complements the
  existing Netboard A-series (embedded PCB form-factor) with
  Deciso's current desktop-chassis line.
- **Caveat:** both Netgate profiles are tagged LOWER-CONFIDENCE.
  The SG-1100 and SG-3100 are primarily pfSense platforms; OPNsense
  on ARM is community-grade and interface naming may vary on stock
  FreeBSD arm64 OPNsense.  Operators running a non-Netgate-branded
  build should file an override profile if their `ifconfig`
  output disagrees.
- 3 new shipped-profile lock-in tests cover port-id list,
  kind-classification, max_vlans/max_local_users values.

### Changed (stale-comment sweep on existing OPNsense + FortiGate profiles)

- All 24 existing OPNsense profile YAMLs had a comment claiming
  "the OPNsense codec doesn't yet round-trip `<system><user>`
  blocks, so the local-users pane surfaces a compat banner".
  Option A cleared the false declaration and verified the round-
  trip works; the comment was stale.  Replaced with an accurate
  post-Option-A rationale (unbounded-limit, not codec gap).
- FortiGate 40F profile comment analogously refreshed; 60F and
  100E cross-reference 40F so they inherit the update via the
  existing `see fortigate_40f.yaml for rationale` pointers.

### Fixed (Option A — strip false `unsupported_rename_categories` on OPNsense + FortiGate)

- Both codecs already round-trip `CanonicalLocalUser` end-to-end
  (OPNsense parses + renders `<system><user>` blocks; FortiGate
  parses + renders `config system admin` blocks — 26 wire-through
  tests cover both in `test_local_users_wire_through.py`).  The
  earlier `unsupported_rename_categories = {"local_users"}`
  declarations were based on an incorrect session-compact-prompt
  assumption that users kept landing in `raw_sections`.  Cleared.
- Integration test `test_unsupported_rename_categories_exposed`
  rewritten to lock in the post-Option-A invariant — no shipped
  bidirectional codec lists any category as unsupported.  The
  attribute stays wired as an extension point for future codecs
  with genuine Tier-3-only surfaces (RADIUS, SNMP panes when
  those ship).
- Both codec capability matrices gain three supported xpaths for
  `/aaa/authentication/users/user/config/{username,password,role}`
  so the validate-stage classifier correctly reports users as
  supported rather than implicit-default.
- E2E test restructure: `TestRenameModalCompatBanner` now covers
  Cisco → {Aruba, OPNsense, FortiGate} all asserting the banner
  stays hidden; pre-fix it asserted the OPNsense banner was
  visible (now wrong post-Option-A).  The renderer's
  limit-undeclared branch for the fit-check banner is instead
  covered by the schema unit tests — dropped the stale e2e case
  that assumed OPNsense profiles omit `max_vlans` (item-7
  enrichment populated every OPNsense profile's `max_vlans=4094`).

### Added (ship `probe:` block for Cisco IOS-XE family-base + overlay inheritance note)

- `definitions/cisco/ios-xe/17.x.yaml` now declares a `probe:` block
  — the first shipped definition to opt into the P1C3 probe-phase
  machinery.  Command is `show version`; two regex patterns feed
  `DefinitionLoader.resolve()` and `DeviceProfile.detected_facts`:
    - `detected_os_version: "Version\\s+(\\d+\\.\\d+)"` — captures
      major.minor (e.g. `17.12`) from `Version 17.12.03, RELEASE …`.
      Major.minor is deliberate: the 17.12 overlay pins
      `os_version: "17.12"` and the resolver's match is exact-string,
      so capturing the full patch level (`17.12.03`) would MISS
      the overlay.
    - `detected_model: "Model Number\\s+:\\s+(\\S+)"` — captures
      hardware model (e.g. `C9300-48P`) from the `Model Number` line
      of Catalyst 9000 `show version` output.
- `definitions/cisco/ios-xe/17.12.yaml` intentionally does NOT
  declare its own `probe:` block.  The backup pipeline runs probe()
  on the family-base collector BEFORE resolving overlays, so the
  overlay inherits the family-base probe behaviour without
  duplication.  Inline comment in the overlay YAML documents this
  so a future contributor doesn't "helpfully" copy the block down.
- New integration tests in `tests/integration/test_backup_probe_wiring.py`
  (`TestShippedCiscoIOSXEProbeBlock`) lock in that the shipped
  family-base carries a non-empty `probe.command` with both
  `detected_os_version` + `detected_model` patterns, and that the
  17.12 overlay ships with NO probe block of its own.

### Changed (data enrichment — soft-limit `max_vlans` populated across MikroTik / OPNsense / FortiGate)

- Every shipped target profile now declares `max_vlans` so the
  VLAN-pane fit-check banner renders something for every
  selectable target.  New per-vendor distribution:
    - MikroTik RouterOS (2 profiles): 4094 — 802.1Q protocol
      ceiling supported universally on modern RouterOS.
    - OPNsense (24 profiles): 4094 — FreeBSD netgraph VLANs,
      protocol ceiling is the only hard limit.
    - FortiGate: 40F = 512, 60F = 512, 100E = 1024 (from
      Fortinet's FortiOS 7.x "Maximum Values Table").
  Aruba + Cisco values from prior commit unchanged.
- `max_local_users` intentionally STAYS unset on MikroTik /
  OPNsense / FortiGate profiles:
    - MikroTik: software-unbounded, no meaningful fit-check.
    - OPNsense / FortiGate: codecs don't yet round-trip
      CanonicalLocalUser, so the local-users pane surfaces a
      compat banner (see Item 1B) instead — setting the fit-check
      limit would visually overlap with that banner and confuse
      operators.
- New shipped-profile lock-in tests (3 cases) guard against
  silent drift: every profile declares `max_vlans`, distinct
  values match the documented per-vendor rationale, and
  `max_local_users` stays unset on the three soft-limit vendor
  families.

### Added (per-pane capacity fit-check banners — `TargetProfile.max_vlans` + `max_local_users`)

- Two new optional fields on `TargetProfile`:
    - `max_vlans: int | None` — active-VLAN limit for the device.
    - `max_local_users: int | None` — local-account limit.
- Each rename-modal pane (VLANs, local_users) renders its own
  fit-check banner sourced from these fields.  Three states:
  hidden (no profile picked OR limit undeclared) / green OK
  ("VLAN fit: N / limit") / red block ("VLAN OVER capacity — K
  over, pick a larger model").
- Pane-scoped: the ports fit-check banner (driven by per-kind
  capacity counts) stays in its own element; each new category's
  banner is a sibling, not an overload.  Adding a future SNMP /
  RADIUS fit-check banner follows the same pattern.
- Shipped profiles populated where datasheet numbers are
  reliable:
    - Aruba 2930F family (4 profiles): max_vlans=2048, max_local_users=16
    - Aruba 3810M (2): max_vlans=4094, max_local_users=16
    - Aruba 6300M (1): max_vlans=4094, max_local_users=64
    - Cisco C9300 family (6) + C9500 (2): max_vlans=4094
  Softer-limit vendors (MikroTik / OPNsense / FortiGate) left
  unset — populating them with best-guess values would mislead
  operators more than helping.
- 5 new unit tests (TargetProfile schema) + 3 new e2e tests
  (TestRenameModalPerPaneFitCheck).

### Added (Item 1 Option B — target-codec compatibility banners on rename panes)

- `CodecBase.unsupported_rename_categories: frozenset[str]`
  declares per-pane categories the codec doesn't round-trip.
  OPNsense + FortiGate both declare `{"local_users"}` because
  their parse+render path currently leaves user blocks in
  `raw_sections` as Tier-3 passthrough.
- `CodecInfo.unsupported_rename_categories` exposes the list via
  `GET /api/v1/migration/adapters`; UI caches it alongside the
  existing adapter metadata.
- Rename modal's local-users pane now shows an amber banner
  up-front when the active target is in the declaring set.
  Warns operators that rename overrides apply to the canonical
  tree but won't reach rendered output.  Kills the
  ghost-success bug where `job.local_user_renames` populates but
  the rendered config has no user stanzas.
- Rename-open button gate broadened (was ports-only): now shows
  when ANY pane has content (port renames/warnings, source_vlans,
  source_local_users).  Pre-P2C4 gap — becomes visible once the
  VLAN/users panes started carrying non-port-dependent content.
- Deferred: **Option A** — full parser+render wiring for
  local_users on OPNsense + FortiGate.  Tracked for the
  "certified-tier Tier-2 parity" sweep; remove the codec
  declarations when it ships.
- 2 new integration tests + 2 new e2e tests.

### Fixed (post-P2C4 rename-modal UX polish + OPNsense probe support)

- **Target-profile dropdowns scoped to ports pane.**  The vendor /
  model / module selects in the modal toolbar + the fit-check
  banner drive ports-pane-specific behaviour only.  Previously
  visible on all panes, misleading operators into thinking they
  needed to pick a profile before renaming VLANs or users.  Now
  hidden when the active rail category isn't `ports`.  New
  testid: `migrate-rename-target-profile-group`.
- **Collision blocks Apply across all panes.**  Pre-fix, ports-
  pane collisions disabled Apply but VLAN + local-user collisions
  only showed the ⛔ icon while letting Apply proceed (server
  would auto-merge).  Fixed for feature parity — any-pane
  collision now disables Apply so operators must explicitly
  resolve rather than silently ship a merged output.
- **Preview pane extended to VLAN + user renames.**  The client-
  side approximation (whole-word substitution) now covers all
  three categories' rename operations.  Drops still rely on the
  server-side re-render (Apply) since multi-line stanza removal
  isn't safe client-side.
- **ParamikoShellCollector.probe() implementation.**  OPNsense
  backups can now populate `detected_facts` — the paramiko_shell
  strategy finally has a probe override.  Handles the console
  menu opt-in; short-lived separate session with tighter idle +
  timeout bounds (~1.6s idle, 30s absolute).  Failure modes still
  never-fatal; family-base fallback unchanged.

### Added (P2C4 — local-users rename pane + three-category composition fix)

- Third per-pane override category after ports + VLANs.  New
  orchestrator `netcanon/migration/canonical/local_user_names.py`
  walks `CanonicalIntent.local_users` and applies a string → string
  (or string → None for drop) rewrite map.  Collision merge:
  highest `privilege_level` wins, first non-empty `role` wins,
  first `hashed_password` wins (hashes aren't composable).
- `run_plan_with_overrides` gains `local_user_rename_map`
  parameter.  `MigrationJob` gains `local_user_renames` +
  `local_user_drops` + `source_local_users` fields.  Capture
  transform now snapshots user names alongside VLAN IDs + hostname.
- New endpoint `POST /api/v1/migration/plan/local_users` follows
  the per-pane pattern — delegates to `run_plan_with_overrides`
  with only its category's map populated.
- **Latent /plan routing fix:** `/plan` previously only engaged
  the rename-aware pipeline when `port_rename_map` or
  `target_profile` was set.  A client posting `vlan_rename_map`
  (shipped P2C2) or `local_user_rename_map` WITHOUT also setting
  `port_rename_map` silently dropped the override.  Fixed:
  `/plan` now dispatches directly to `run_plan_with_overrides`
  whenever ANY override map is present and threads every category
  through.
- UI: left rail gains a "Local users" button + category pane
  rendered by new `_partials/local-user-rename-table.js`
  (structural copy of vlan-rename-table.js, string keys, free-text
  rewrite).  Every operator override is persisted to localStorage
  under the same `netcanon.rename-ack.v1:…` key as the other
  categories — additive schema, old payloads load unchanged.
- Apply flow: `local_user_rename_map` included in the POST body
  only when the operator actually touched a user row (gate-on-
  non-empty — same pattern as VLANs).
- 10 new testids under
  `tests/testid_reference.md` → "Left-rail category nav + VLAN pane"
  section now also covers local-users rows, override inputs, drop
  links, and the summary chip.
- 16 new unit tests (`tests/unit/migration/test_local_user_names.py`)
  + 16 new integration tests (`TestPlanLocalUsersEndpoint`,
  `TestPlanMultiCategoryRouting`, source-shape capture) + 9 new
  cross-mesh smoke cases + 6 new e2e tests
  (`TestRenameModalLocalUsersPane`).

### Added (P1C3 — pre-backup probe phase + layered-definition resolver wiring)

- New `ProbeConfig` block on `DeviceDefinition` (optional `command:` +
  `patterns:` regex map).  Empty defaults keep existing definitions
  working unchanged; only definitions that opt in by declaring a
  probe command participate.
- New pure-function parser at `netcanon/collectors/probe.py` with
  11 unit tests (`tests/unit/test_probe_parser.py`).  Handles
  multi-line output, missing captures, malformed regexes, auto-
  timestamps successful results.
- New `probe()` method on `BaseCollector` (no-op default) + concrete
  Netmiko implementation.  Short-lived separate session (30s probe
  timeout, 4x tighter than the main 120s config timeout).  Failures
  are non-fatal — log WARNING, return `{}`, proceed with family-base.
- Backup pipeline (`_process_one_device`) now threads a
  `DefinitionLoader` + device-profile store through the execution
  path: probe → resolve overlay (operator pins win over detected
  facts) → persist `detected_facts` on the linked profile → collect
  against the resolved definition.  Both interactive and scheduled
  backups get the same treatment.
- `app.state.definition_loader` exposed on the FastAPI app alongside
  the existing `app.state.definitions` dict (kept for endpoints that
  iterate type_keys).  New `get_definition_loader` dependency.

### Added (P2C3 — rename-modal left-rail category nav + VLAN pane + localStorage persistence)

- Rename modal body refactored from `table | preview` into
  `rail | table | preview`.  Left rail is a vertical category
  navigation with Ports and VLANs today; the rail is the extension
  point for future categories (local_users / SNMP / RADIUS under
  P2C4+).  Counts + badges on rail buttons show per-category row
  totals at a glance.
- New `_partials/vlan-rename-table.js` renders the VLAN pane —
  structural parallel to the existing ports table but simpler
  (integer IDs, no per-kind sections, no target-profile dropdown).
  Rows enumerate every VLAN declared in the source config; each row
  has an integer override input + drop/un-drop link.
- `MigrationJob` exposes `source_vlans: list[int]` and
  `source_hostname: str` populated by a capture transform that
  runs ahead of every user-override transform in
  `run_plan_with_overrides`.  The UI needs both — source_vlans
  drives the VLAN pane's row enumeration, source_hostname keys
  the localStorage persistence entry.
- localStorage ack persistence: overrides survive page reloads
  keyed on `(source_codec, target_codec, hostname)` under
  `netcanon.rename-ack.v1:…`.  Load on modal-open, save on every
  render-summary (universal choke point), clear on Reset-all.
  Surfaces an age hint on restore ("Restored prior overrides
  (N port, M VLAN) from 3m ago").
- ARCHITECTURE.md gains a "Per-pane overrides (Tier-3 rename
  modal)" section documenting the three-step recipe for adding a
  new category (orchestrator → pipeline wiring → UI rail + pane),
  the sentinel semantics, and the localStorage schema.
- 14 new testids documented in `tests/testid_reference.md` under
  a new "Left-rail category nav + VLAN pane" subsection.
- 7 new e2e tests (`TestRenameModalLeftRail`,
  `TestRenameModalLocalStoragePersistence`) + 5 new integration
  tests (`TestSourceShapeCapture`).

### Added (P2C2 — canonical VLAN-rename orchestrator + /plan/vlans endpoint)

- Cross-vendor VLAN-ID rewrite transform
  (`netcanon/migration/canonical/vlan_names.py`) — walks the
  canonical tree and rewrites every VLAN-referring field
  (`CanonicalVlan.id`, `access_vlan`, `trunk_allowed_vlans`,
  `trunk_native_vlan`, `voice_vlan`).  Drop semantics via `None`
  values; collision merge (union of port lists, concat of SVI
  addresses) when multiple source IDs map to the same target.
- `POST /api/v1/migration/plan/vlans` per-pane endpoint accepts
  only the VLAN override map; delegates to `run_plan_with_overrides`
  with `port_rename_map=None`.
- `run_plan_with_overrides` extended with `vlan_rename_map`
  parameter.  VLAN transform runs AFTER port rename so port-name
  rewrites don't race with VLAN-ID changes.
- Cross-mesh smoke tests
  (`tests/unit/migration/test_cross_mesh_overrides.py`) parametrised
  over every `(source, target)` capable pair; new `cross_mesh`
  pytest marker with documented 30s aggregate-runtime budget.

### Added (P2C1 — run_plan_with_overrides engine + /plan/ports per-pane endpoint)

- New `run_plan_with_overrides` in `migration_pipeline.py` — the
  shared engine for every per-pane override surface.  Grows via
  optional category-map parameters; existing `run_plan` +
  `run_plan_with_rename` signatures stay frozen per AGENTS.md's
  pipeline-signatures invariant.
- Sentinel semantics standardised across categories:
  `None` → don't engage, `{}` → engage with auto-heuristic only,
  `{src: tgt}` → engage with explicit overrides.
- `POST /api/v1/migration/plan/ports` per-pane endpoint — first
  instance of the per-pane pattern; dispatches to
  `run_plan_with_overrides` with only `port_rename_map`
  populated.

### Added (Tier 3 port-rename modal — interactive cross-vendor interface-name mapping in the /migrate UI)

- Draggable, non-blocking modal on the Migrate results view that
  exposes the Tiers 1+2 port-name translator to the operator.  The
  cross-vendor rename that used to happen silently (or fail to
  happen at all — the original complaint was Cisco ``GigabitEthernet1/0/24``
  leaking into Aruba output) is now:
    * **Auditable** — a mapping table shows every source name, the
      codec's auto-computed target, and warnings for untranslatable
      cases (breakout ports, loopbacks without target equivalents,
      uplink modules without operator input, etc.)
    * **Editable** — per-row override dropdown (profile-driven when
      a target profile is selected) or free-form input; user's
      choice wins over the auto-heuristic.
    * **Validated** — collision detection disables the Apply button
      when two sources map to the same target; warnings count shown
      in the header badge.

- Modal features:
    * Draggable (grab the ⋮⋮ grip header); non-blocking — user
      can drag aside to consult the rendered output behind it.
    * 2-pane layout: mapping table on the left (grouped by kind —
      physical / lag / svi / loopback / tunnel / breakout /
      hw_aggregate / virtual / unknown), client-side live preview
      on the right.
    * Sections collapsible; first-non-empty + any section with
      warnings/collisions auto-opens.
    * Target-profile selector in the toolbar: picking a profile
      swaps free-form inputs for dropdowns listing only ports the
      target hardware actually has; falls back to free-form when
      none selected.
    * Collision icons (⛔) with tooltips naming the colliding
      sources; warning icons (⚠) with the orchestrator's advisory
      text as tooltips.
    * "Reset all" clears user overrides; Apply POSTs to ``/plan``
      with the updated rename_map and refreshes the main output
      pane.

- The ``/migrate`` form now always sends ``port_rename_map: {}`` to
  opt into the rename-aware pipeline.  This means cross-vendor
  translations no longer leak source-vendor port names into the
  target output by default — the original Tier 3 complaint is fixed
  end-to-end.

- New e2e tests (7):
  ``tests/e2e/test_migrate_rename_modal.py`` — visibility gating,
  modal open/close, section rendering, override apply end-to-end,
  collision detection disables Apply, loopback warning row styling.

- Docs: ``translator-plans.txt`` gains a "PORT-NAME TRANSLATION
  (Tiers 1+2+3) — SHIPPED + DEFERRED WORK" section tracking the
  deferred save-as-profile, 3-pane layout, hardware fit-check,
  rename-operation diff, and target-profile expansion.
  ``HUMAN_TESTING.md`` gains a per-feature manual-verification
  checklist for the modal.

### Added (Tier 3 port-rename backend: target profiles + rename-aware /plan)

See commit ``b5cb5ca`` for the backend foundation that the frontend
consumes.

### Added (FortiGate CLI promoted to `certified` — 5th codec to reach the bar + real RADIUS round-trip bug fixed)

- User-contributed real ``show full-configuration`` from a physical
  **FortiGate 100E** running **FortiOS 7.2.13** (build 1762),
  captured via Netcanon's backup layer and sanitised per AGENTS.md
  hard rule.  ~35K lines — 34 interfaces, 5 VLANs, 2 LAGs, 6 DHCP
  servers, 3 admins, 1 RADIUS server, 1 SNMP community, full
  firewall policy table + VIPs + SDWAN + SSL-VPN.  First physical-
  appliance FortiGate capture in the corpus (existing 2 from
  KevinGuenay were a VM hub + 70G branch, both on 7.6.6); first
  FortiOS 7.2.x capture.
- ``user_contrib_fg100e_fos7213.conf`` — sanitisation inventory
  (per AGENTS.md "never commit real credential hashes" rule):
  - 48 ``ENC <base64>`` encrypted FortiGate secrets (admin
    passwords, RADIUS secret, FortiGuard proxy password, NTP
    key, DHCP option passwords, TACACS passwd1/2/3, etc.)
    replaced with ``ENC fakeEncodedSecret...`` markers
  - 14 ``-----BEGIN (ENCRYPTED) PRIVATE KEY-----`` /
    ``-----END`` blocks stripped with ``REMOVED_FIXTURE_SANITISATION``
    placeholder (public certificate chains retained for grammar)
  - 5 SSH public keys (``ssh-rsa AAAA...``) replaced with
    ``AAAAfakeSSHPublicKey...`` markers
  - 1 Firebase/APNs registration ID (``set reg-id "dxurAYJ..."``)
    sanitised
  - Real WAN IP ``[redacted-WAN-IP]`` (6 occurrences in address
    objects + VIP ``set extip`` entries) replaced with RFC 5737
    TEST-NET-3 ``203.0.113.217``
  - Real email ``[redacted-email]`` (2 occurrences in admin
    contact fields) replaced with ``netadmin@example.test``
  - All other real data retained — RFC1918 addressing, hostname
    ``fortihome``, alias ``FortiGate-100E``, interface descriptions,
    firewall policies with internal IPs, DHCP reservations,
    SD-WAN SLAs, SSL-VPN portal config.
- **Real codec bug surfaced + fixed in the same commit:** FortiOS
  uses ``set radius-port 0`` as the idiom for "use the default port
  1812" — real FortiOS exports (including the FG100E capture)
  emit this literally.  Our parser stored the 0 faithfully in
  ``CanonicalRADIUSServer.auth_port``, but the renderer had an
  early-out that omitted ``radius-port`` when ``auth_port == 1812``
  (mirroring FortiOS's own default-omission pattern).  Round-trip
  drift: first parse gave auth_port=0, render emitted nothing,
  re-parse defaulted to 1812.  Fix canonicalises ``radius-port 0``
  to 1812 at parse time in ``_apply_user_radius`` — canonical
  stores the *effective* value, not the literal 0.  Regression
  test
  ``TestRoundTrip::test_radius_port_zero_canonicalised_to_default``
  in ``tests/unit/migration/test_fortigate_cli.py`` pins the fix.
- ``certainty`` ClassVar bumped from ``best_effort`` to
  ``certified`` in
  ``netcanon/migration/codecs/fortigate_cli/codec.py``; matching
  test updated in
  ``tests/unit/migration/test_fortigate_cli.py::TestR3Fields::test_certainty``
  with comment citing the promotion evidence.
- ``fortigate_cli`` is the **5th codec to reach ``certified``**
  (after ``mikrotik_routeros``, ``aruba_aoss``,
  ``cisco_iosxe_cli``, ``opnsense``).  Only ``cisco_iosxe``
  (NETCONF OpenConfig) remains at ``best_effort``.
- Docs: ``tests/fixtures/real/NOTICE.md`` (provenance +
  sanitisation inventory), ``tests/fixtures/real/RESULTS.md``
  (per-codec section + summary total from 25 → 26 fixtures),
  ``README.md`` cert table.
- **677 migration tests passing** (+4 from the OPNsense commit),
  zero regressions.

### Added (OPNsense promoted to `certified` — 4th codec to reach the bar + real render bug fixed)

- User-contributed real ``config.xml`` from a deployed OPNsense
  instance ("supergate"), captured via Netcanon's own backup layer
  (SSH + ``cat /conf/config.xml``).  Sanitised per AGENTS.md hard
  rule — 2 bcrypt ``<password>`` hashes replaced with
  ``$2y$11$fakeBcrypt...`` markers; API keys and the QFeeds
  ``tip_...`` token replaced with synthetic placeholders; 2
  self-signed-cert private-key ``<prv>`` blobs stripped (public
  ``<crt>`` retained for grammar coverage); the real domain
  (``[redacted-domain]``) replaced with ``example.test`` across 22
  occurrences; 13 real MAC addresses sanitised with OUI-preserving
  last-3-octet anonymisation; SSH-capture artifacts (``cat
  /conf/config.xml`` prefix and trailing shell prompt) stripped.
- ``user_contrib_supergate_opn25.xml`` — 2,302 lines, 8 interfaces
  (wan/lan/opt1-5/loopback with USERVLAN/MGMTVLAN/SERVERVLAN/
  CLUSTERVLAN/IOTVLAN descriptions), 5 VLANs with ``<tag>`` +
  ``<descr>``, 2 local users w/ bcrypt hashes, extensive per-zone
  DHCP static MAC reservations (~20/zone with operational
  commentary), Unbound DNS with local overrides, IPsec, WireGuard,
  SNMP, NTP.  The first genuinely-real-deployment OPNsense fixture
  (previous 3 were all from ``opnsense/core`` upstream).
- **Real codec bug surfaced + fixed in the same commit:** the
  render path silently dropped every ``CanonicalVlan`` entry.  The
  parser correctly read ``<vlans><vlan><tag/>`` + ``<descr/>`` into
  ``intent.vlans``, but ``_render_canonical`` had no inverse block
  — so a ``parse → render → parse`` cycle on the supergate capture
  collapsed 5 VLANs down to 0.  The three upstream
  ``opnsense/core`` fixtures didn't exercise the ``<vlans>`` block
  at all, so this bug slept until real-deployment contact.  Fix:
  added a ``<vlans>`` render block in
  ``netcanon/migration/codecs/opnsense/codec.py`` that emits
  ``<tag>`` + ``<descr>`` per VLAN (round-trip-symmetric with what
  the parser reads).  Regression test
  ``TestRoundTrip::test_roundtrip_preserves_vlans`` in
  ``tests/unit/migration/test_opnsense.py`` pins the fix — the
  test constructs a 3-VLAN input, parses/renders/re-parses, and
  asserts the VLAN ids and names survive.
- ``certainty`` ClassVar bumped from ``best_effort`` to
  ``certified`` in
  ``netcanon/migration/codecs/opnsense/codec.py``; matching
  ``test_certainty_is_certified`` added in
  ``tests/unit/migration/test_opnsense.py`` with comment citing the
  promotion evidence.
- ``opnsense`` is the **4th codec to reach ``certified``** (after
  ``mikrotik_routeros``, ``aruba_aoss``, ``cisco_iosxe_cli``).
- Docs: ``tests/fixtures/real/NOTICE.md`` (provenance for the new
  file + sanitisation inventory), ``tests/fixtures/real/RESULTS.md``
  (per-codec table + summary total from 24 → 25 fixtures),
  ``README.md`` cert table.
- **673 migration tests passing** (+7 from the last cert commit),
  zero regressions.

### Added (Cisco IOS-XE CLI cert strengthened with physical Cat 9300-24UX real capture)

- User-contributed real ``show running-config`` from a physical
  **Cisco Catalyst 9300-24UX** running **IOS-XE 17.12**, captured via
  Netcanon's own backup layer against the contributor's live home-
  lab switch and sanitised per AGENTS.md hard rules.  Fills the
  physical-switch coverage gap that the earlier 3 BSD-3 racc
  captures didn't address — they were all **virtual routers**
  (CSR1000v / Cat8000V) and validated IOS-XE routing grammar but not
  physical-switch switching grammar.
- ``user_contrib_cat9300_iosxe1712.txt`` — 491 lines, 47 interfaces,
  6 VLANs, 3 LACP EtherChannels (the Fortigate uplink, NAS LAG, and
  Proxmox-blade LAG), 2 local users, 1 default-gateway static route.
  Exercises the whole physical-switch grammar surface: ``switch 1
  provision c9300-24ux``, ``TenGigabitEthernet1/0/N`` /
  ``FortyGigabitEthernet1/1/N`` / ``TwentyFiveGigE1/1/N`` /
  ``AppGigabitEthernet1/0/1`` port families, ``switchport mode
  trunk/access`` + ``switchport trunk allowed vlan <list>`` +
  ``switchport trunk native vlan``, ``channel-group N mode active``
  binding into ``interface Port-channel N``, ``interface Vlan N``
  SVIs, ``vrf forwarding Mgmt-vrf`` on the management port, the
  full Cat9k ``class-map system-cpp-police-*`` + ``policy-map
  system-cpp-policy`` CPP (control-plane policer) grammar,
  ``spanning-tree mode rapid-pvst``, 28 × ``privilege exec level 5
  show X`` delegation entries, multiple ``line vty`` ranges
  (``0 4`` / ``5 29`` / ``30 31``).
- Sanitisation: the two ``username X secret 9 $9$...`` hashes were
  replaced with synthetic-marked ``$9$fakeSalt...$fakeHash...`` per
  AGENTS.md "never commit real hashes from non-public devices" rule;
  the ``by <windows-username>`` annotation in the
  ``Last configuration change`` timestamp was updated to
  ``by netadmin``.  All other real data retained — RFC1918
  addressing, VLAN IDs, infrastructure-describing interface
  descriptions (``TRUNK - FORTIGATE`` / ``pvenas 2x 10gbe trunk`` /
  ``pveblade lacp``), and the self-signed device certificate chain.
  Same convention as the MikroTik ``user_contrib_crs310_ros7.rsc``
  precedent.
- Secondary fixture added: ``cml_saumur_iosxe1712_pvrstp.txt`` —
  BSD-3-Clause capture extracted from
  [CiscoDevNet/cml-community](https://github.com/CiscoDevNet/cml-community)
  `lab-topologies/ccna/Domain_2/2.5-interpret_stp/saumur_PVRSTP_solution.yaml`
  (the ``saumur`` node's ``configuration:`` block).  147 lines, IOS-
  XE 17.12 on a virtual ``ioll2-xe`` image (IOU port notation
  ``Ethernet0/N`` rather than physical ``Gi1/0/N``).  Complements
  the Cat 9300 capture with PVRST+ cost-tuning grammar the Cat 9300
  doesn't exercise: ``spanning-tree pathcost method long``,
  ``spanning-tree vlan 1-4094 priority 4096``, ``spanning-tree
  link-type point-to-point``, ``spanning-tree cost 2000000``,
  ``vtp version 1``, ``vlan internal allocation policy ascending``.
- **Zero codec bugs surfaced.**  Both fixtures parse cleanly on
  first contact and produce populated canonical trees.
- The cert promotion from the previous commit is now justified on
  **both** router grammar (racc corpus) and physical-switch grammar
  (Cat 9300 capture) rather than router grammar alone.  Test
  assertion comment updated in
  ``tests/unit/migration/test_cisco_iosxe_cli.py`` to reflect
  strengthened coverage.
- Docs: ``tests/fixtures/real/NOTICE.md`` (provenance for both new
  files), ``tests/fixtures/real/RESULTS.md`` (per-codec table +
  summary total from 22 → 24 fixtures; cisco_iosxe_cli row now
  shows 4 LTS OS versions and 11 fixtures), ``README.md`` cert
  table.
- **666 migration tests passing**, zero regressions.

### Added (Cisco IOS-XE CLI promoted to `certified` — 3rd codec to reach the bar)

- Three BSD-3-Clause real captures ingested from
  [nickrusso42518/racc](https://github.com/nickrusso42518/racc) —
  authored by a Cisco DevNet trainer, checked in as the playbook's
  own sample output directory, so provenance is unambiguous:
  - ``racc_csr1000v_iosxe169_bgp_ospf.txt`` — CSR1000v on
    **IOS-XE 16.9 LTS**, 280 lines.  BGP AS 65001 with ``vpnv4`` +
    ``rtfilter`` address-families, OSPF, QoS ``class-map`` +
    ``policy-map``, ``logging host``, RESTCONF + NETCONF-YANG.
  - ``racc_csr1_iosxe173_umbrella_sig.txt`` — CSR1000v on
    **IOS-XE 17.3 LTS**, 398 lines.  Cisco Umbrella SIG tunnel
    deployment: IKEv2 proposal/policy/profile + IPsec profile +
    ``tunnel protection ipsec profile`` on Tunnel100, 22 static
    routes (anycast SIG targets), EIGRP CITYNET, OSPF, SSH
    ``pubkey-chain``, guestshell app-hosting, NETCONF-YANG
    ``candidate-datastore``.
  - ``racc_cat8000v_iosxe179_netconf.txt`` — Cat8000V on
    **IOS-XE 17.9 LTS**, 343 lines.  ``ip nat inside source list
    ... overload`` (PAT), ``telemetry ietf subscription`` (YANG-
    push periodic update-policy over grpc-tcp), type-9 ``$9$...``
    hash, RESTCONF + NETCONF-YANG, app-hosting guestshell.
- **Zero codec bugs surfaced.**  All three fixtures parse cleanly
  on first contact and produce populated canonical trees —
  evidence the grammar coverage from the Batfish / NTC corpus
  already generalised to real deployed CSR1000v / Cat8000V
  configs.  Large cert chains, IKEv2 profiles, guestshell stanzas,
  telemetry subscriptions, PKI trustpoints all fell through to
  "parse-and-ignore" without tripping the parser — exactly as
  designed.
- Cert-bar for ``parse_only`` codecs: ≥3 real captures from ≥2 OS
  versions that parse cleanly and produce populated canonical
  trees (round-trip is N/A for parse-only).  The three racc
  fixtures give us 3 distinct LTS OS versions — meets the bar
  decisively.
- ``certainty`` ClassVar bumped from ``best_effort`` to
  ``certified`` in
  ``netcanon/migration/codecs/cisco_iosxe_cli/codec.py``;
  matching test renamed/updated in
  ``tests/unit/migration/test_cisco_iosxe_cli.py``
  (``test_certainty_is_certified``).
- ``cisco_iosxe_cli`` is the **3rd codec to reach ``certified``**
  (after ``mikrotik_routeros`` and ``aruba_aoss``).
- Docs: ``tests/fixtures/real/NOTICE.md`` (provenance for 3 new
  files), ``tests/fixtures/real/RESULTS.md`` (per-codec table +
  summary total from 19 → 22 fixtures), ``README.md`` cert table.
- **664 migration tests passing**, zero regressions.

### Added (Aruba AOS-S promoted to `certified` — 2nd codec to reach the bar)

- Three sanitised real captures ingested from HPE Community forum
  threads, collectively spanning **3 distinct OS versions** and
  **2 switch families**:
  - ``hpe_community_2930f_wc1607_intervlan.cfg`` — 2930F JL260A on
    **WC.16.07.0002**.  12 VLANs with per-VLAN SVIs, ``ip
    helper-address`` (DHCP relay) at scale, ``ip forward-protocol udp``
    for DNS/NTP helper forwarding, ``primary-vlan``, 4 static routes
    including ``ip default-gateway``.
  - ``hpe_community_2920_wb1608_dhcp_snooping.cfg`` — 2920 J9729A on
    **WB.16.08.0001** (different WB branch + different switch family
    to the other two).  Exercises ``dhcp-snooping`` with 13
    authorized-servers + VLAN scope + trust ports, ``ntp unicast``
    with ``iburst``, ``web-management ssl``, ``ip authorized-managers``,
    ``snmp-server host ... trap-level critical``.
  - ``hpe_community_2930f_wc1610_dhcp_server.cfg`` — 2930F JL258A on
    **WC.16.10.0005**.  Real AOS-S built-in ``dhcp-server pool``
    grammar (3 pools × ``default-router``/``dns-server``/``network``/
    ``range``), per-VLAN ``dhcp-server`` enable flag,
    ``allow-unsupported-transceiver``.
- **Zero codec bugs surfaced.**  All four fixtures (3 new + 1
  pre-existing rendered template) round-trip clean on first pass,
  parse deterministically, produce matching canonical trees — the
  harness invariants held without a single fix.
- ``certainty`` ClassVar bumped from ``best_effort`` to ``certified``
  in ``netcanon/migration/codecs/aruba_aoss/codec.py``; matching
  test updated in ``tests/unit/migration/test_aruba_aoss.py`` with
  a comment citing the promotion evidence (pattern mirrors the
  MikroTik certification commit).
- ``aruba_aoss`` is the **2nd codec to reach ``certified``** (after
  ``mikrotik_routeros``).  Cert bar: ≥3 real captures from ≥2 OS
  versions, all round-tripping clean.
- Docs: ``tests/fixtures/real/NOTICE.md`` (provenance for all 3 new
  files), ``tests/fixtures/real/RESULTS.md`` (per-codec table + summary
  total from 16 → 19 fixtures), ``README.md`` cert table.
- **658 migration tests passing**, zero regressions.

### Fixed (Bug 1: Cisco IOS-XE SVI dropped silently into Aruba render)

- **Symptom** (found via real-config dogfooding): feeding a Cisco
  9300 config containing ``interface Vlan11 / ip address
  192.168.11.252 255.255.255.0`` through the
  ``cisco_iosxe_cli -> aruba_aoss`` pipeline produced an Aruba render
  with **no ``vlan 11`` stanza at all** — the SVI's IP address
  silently vanished.
- **Root cause**: the IOS-XE CLI parser only created ``CanonicalVlan``
  records from explicit top-level ``vlan N / name X`` stanzas, not
  from ``interface Vlan<N>`` SVIs.  Aruba's renderer unconditionally
  skips interfaces named ``Vlan*`` (expecting the VLAN stanza to
  absorb the SVI's IP), so with no VLAN record the SVI + its L3
  data fell through the gap.
- **Fix**: new ``_synthesize_vlans_from_svis()`` post-pass in the
  Cisco IOS-XE CLI parser derives a ``CanonicalVlan(id=N)`` from
  each ``interface Vlan<N>`` stanza, attaches the SVI's IPv4
  addresses, and merges with any existing top-level ``vlan N``
  record (explicit ``name`` stays authoritative; SVI description
  falls back as the name when no stanza is present).
- **New cross-codec invariant** in the full-mesh matrix:
  ``test_every_source_ip_appears_in_rendered_output`` — every IP
  in the parsed-source tree MUST appear as a literal substring in
  the target codec's rendered output.  Substring-based so it
  doesn't depend on target parsers accepting foreign interface
  names (AOS-S can't re-parse ``GigabitEthernet0/0/0`` but the IP
  still reaches the rendered text, which is what matters for
  silent-drop detection).  This guard would have caught Bug 1 on
  day one.
- **8 new unit tests** (``TestSVIVlanSynthesis``) + 25 parametrized
  invariant runs.  **885 passing**, zero regressions.

### Added (Tier 2 — SNMP parse/render across all 5 real codecs)

- First Tier 2 feature wired end-to-end through every real codec:
  **SNMP** (community, location, contact, trap_hosts).  Previously
  the ``CanonicalSNMP`` model existed but no codec consumed or
  produced it.
- **Per-codec grammar coverage:**
  - ``cisco_iosxe_cli``: ``snmp-server community/location/contact/host``
  - ``opnsense``: ``<snmpd>`` plugin element (``<rocommunity>``,
    ``<syslocation>``, ``<syscontact>``, ``<traphost>``)
  - ``mikrotik_routeros``: ``/snmp set`` (sysinfo) + ``/snmp community
    set`` (community strings)
  - ``aruba_aoss``: ``snmp-server community/location/contact/host``
  - ``fortigate_cli``: ``config system snmp sysinfo`` + ``config
    system snmp community`` with nested ``config hosts`` sub-table
- Each codec's capability matrix now declares ``/snmp/community``,
  ``/snmp/location``, ``/snmp/contact``, ``/snmp/trap-host``.
- ``_walk_canonical`` emits SNMP xpaths only when populated, so
  codecs that don't carry SNMP produce no false xpath occurrences.
- 24 new unit tests in ``tests/unit/migration/test_tier2_snmp.py``:
  per-codec parse/render/round-trip + parametrized universal-render
  + universal-roundtrip across every real codec.  **852 passing.**
- Paves the way for the remaining Tier 2 features (local_users,
  LAGs, RADIUS servers, DHCP server pools) — identical shape of
  work, just different grammars.

### Added (FortiGate CLI codec — 5th real vendor, recursive grammar)

- **``FortiGateCLICodec``** in
  ``netcanon/migration/codecs/fortigate_cli/`` — parses and renders
  FortiOS 7.x CLI (``config/edit/set/next/end`` 5-keyword grammar).
  Recursive block model handles arbitrary nesting including nested
  ``config`` inside ``config`` (NTP ntpserver sub-table).
- **Parser scope:** ``system global`` (hostname), ``system dns``
  (primary/secondary), ``system ntp`` (ntpserver sub-table),
  ``system interface`` (physical + VLAN sub-interfaces via ``set
  type vlan`` + ``set vlanid`` + parent ``set interface``),
  ``router static`` (dst + gateway + device).
- **Structural handling:** quoted values with spaces, multi-token
  set values (``set allowaccess ping https ssh``), integer ``edit``
  IDs for routes + quoted ``edit`` IDs for interfaces, dotted-
  decimal mask form for IPs.
- **Capability matrix:** firewall policies + NAT rules marked
  unsupported (Tier 3); alias 25-char truncation marked lossy.
- **Auto-detection probe:** ``#config-version=`` banner (98%),
  5-keyword grammar markers (75-92%).
- **Vendor YAML** at ``netcanon/migration/vendors/fortigate.yaml``
  declaring ``[firewall, router]``.

### Added (full-mesh cross-codec matrix test)

- **``tests/unit/migration/test_cross_codec_matrix.py``** — parametrized
  pytest that auto-enumerates every ``(source, target)`` codec pair,
  filters to those sharing a ``DeviceClass``, runs each source's
  ``sample_input`` through ``run_plan``, and asserts the job
  completes.  Answers the user's question: yes, we now have
  full-mesh cross-vendor testing built into every codec addition.
  26 real pairs covered today; grows automatically with each new
  codec.
- **Latent bugs exposed + fixed on first matrix run:**
  - ``MockCodec.render()`` couldn't JSON-serialise ``CanonicalIntent``
    — now detects the type and uses pydantic's ``model_dump()``.
  - ``CiscoIOSXECodec.parse()`` still returned the legacy nested
    dict shape (never migrated during the canonical bridge work).
    Now returns ``CanonicalIntent`` like every other codec; 8 test
    assertions updated to use the canonical attribute access.
    Capability matrix updated to declare canonical xpaths.

### Added (Aruba AOS-S codec — 4th real vendor, VLAN-centric)

- **``ArubaAOSSCodec``** in ``netcanon/migration/codecs/aruba_aoss/``
  — parses and renders Aruba AOS-S (ProCurve / ArubaOS-Switch 16.x)
  ``show running-config`` text.  Architecturally the first codec
  where VLAN port membership lives naturally on the VLAN object
  (``vlan 10`` → ``untagged 1-24`` / ``tagged 25-26``), validating
  the canonical VLAN-centric design decision.
- **Parser scope (Tier 1):** hostname, VLANs (id, name, untagged/
  tagged port lists, SVI IPs), interfaces (name, enable/disable,
  routing keyword, per-port IP), static routes (``ip route`` +
  ``ip default-gateway``), SNMP community, DNS / NTP servers.
- **Structural quirks handled:**
  - ``;`` as comment character (not ``!``)
  - Stanza delimiter is ``exit`` or an un-indented line
  - Port names: bare numerics + alpha-numeric (``1``, ``A1``, ``Trk1``)
  - Port-range expansion (``1-24``) + compression on render
  - IP accepts both ``A.B.C.D/N`` and ``A.B.C.D M.M.M.M``
  - Default gateway ↔ ``0.0.0.0/0`` static route round-trip
  - ``no untagged 1-24`` port-list subtraction
- **Vendor YAML** at ``netcanon/migration/vendors/aruba_aoss.yaml``
  declaring ``[switch, router]``.
- **OPNsense renderer hardened**: bare-numeric interface names
  (legal on Aruba, invalid as XML element tags) now sanitise via
  ``_zone_tag_for()`` — ``1`` becomes ``if_1``, etc.  Closes a
  cross-vendor regression exposed by the Aruba→OPNsense pipeline.
- **Auto-detection probe** with three confidence tiers: ProCurve
  banner (98%), combined structural markers + ``;`` comment (95%),
  individual structural hits (70-88%).
- 49 new unit tests.  **760 passing, zero regressions.**

### Changed (auto-discover codec packages — zero-bookkeeping vendor add)

- **``netcanon/migration/__init__.py``** now auto-discovers every
  codec sub-package under ``netcanon/migration/codecs/`` using
  ``pkgutil.iter_modules``.  Each package's module-level
  ``@register`` decorator fires on import, populating the registry.
- Adding a new vendor is now a true drop-in: create the codec
  package directory + drop a vendor YAML — the translator picks
  both up at next import with **zero edits to any shared file**.
  (The ``INPUT_FORMATS`` frozenset and per-codec probe signatures
  remain in the codec's own module, so they're also vendor-local.)
- Broken codec packages log the failure and are skipped —
  robustness test pins that behaviour.
- 2 new unit tests: auto-discovery finds every expected codec;
  importing the package is idempotent across reloads.  **710 passing.**

### Changed (UI metadata migration — close the R5 client-side leak)

- **CodecBase gains three UI-metadata ClassVars**: ``description``,
  ``sample_input``, ``output_extension``.  Each codec class is now
  the single source of truth for its own presentation strings.
- **``CodecInfo`` pydantic model grew three fields** (same names)
  and ``GET /api/v1/migration/adapters`` surfaces them so the client
  can render format hints / load samples / pick download extensions
  without any vendor-specific JS.
- **`migrate.html` lost its 130-line ``FORMAT_CATALOGUE`` dict** and
  all six call sites that read from it.  New ``adapterEntry()`` and
  ``compatibleExtensions()`` helpers read server-provided metadata
  off the ``adapters`` array.  ``guessExtension`` and
  ``downloadMigrateOutput`` likewise delegate to
  ``target.output_extension``.
- **Adding a new codec no longer requires editing the template** —
  the codec class ships its own description + sample + download
  extension, which the UI picks up automatically.
- Every real codec (cisco_iosxe, cisco_iosxe_cli, opnsense,
  mikrotik_routeros, mock) now declares all three metadata fields.
- 3 new integration-test assertions confirm the surface
  (``test_ui_metadata_fields_surface``,
  ``test_real_codecs_have_sample_input``,
  ``test_real_codecs_have_output_extension``).  **708 passing.**

### Added (R5 — auto-detection of source codec from raw bytes)

- **`CodecBase.probe(raw_prefix)`** classmethod — new auto-detection
  hook.  Each codec overrides it to return a ``(confidence, reason)``
  tuple if the first ~500 bytes match its format, or ``None`` if it
  has no opinion.  Default base implementation returns ``None`` so
  codecs without a probe still load cleanly.
- **Per-codec probe signatures** on all four real codecs:
  - ``opnsense`` — matches ``<opnsense>`` root (98% confidence)
  - ``cisco_iosxe`` — matches OpenConfig YANG namespace (95%),
    ``<rpc-reply>`` envelope (70%), OpenConfig-shaped XML (75%)
  - ``cisco_iosxe_cli`` — matches ``Building configuration…`` banner
    (98%), strong CLI markers like ``interface Giga`` + ``ip address
    X.X.X.X Y.Y.Y.Y`` + ``no shutdown`` (90% for ≥2 hits), weaker
    fallbacks
  - ``mikrotik_routeros`` — matches ``# ... by RouterOS`` banner
    (98%), multiple ``/section`` headers + find-default-name idiom
    (97%), individual sections / idioms (80-95%)
  - ``mock`` — weak JSON-shape detection (40-55%)
- **`netcanon/services/migration_detect.py`** — pure-function
  detection service.  ``detect_codec(raw)`` walks every registered
  codec's ``.probe()``, filters by ``min_confidence``, and returns a
  ranked list.  ``best_codec(raw)`` is a convenience wrapper that
  returns only the top candidate (default strict threshold 50).
- **`POST /api/v1/migration/detect`** — new API endpoint.  Body
  accepts ``raw_text`` OR ``source_filename`` (same contract as
  ``/plan``) plus optional ``min_confidence``.  Returns
  ``list[DetectCandidate]`` sorted by descending confidence.
- **/migrate UI auto-detection** — debounced 350ms on textarea
  input, fires on stored-config selection, and on source-codec
  change.  Banner shows "Detected: \<vendor\> (\<confidence\>%)"
  with a "Use this source" button.  When the user has already
  picked the detected codec the banner goes green (confirmation).
- **MikroTik sample + format hint** — client-side
  ``FORMAT_CATALOGUE`` now has a ``cli-mikrotik`` entry (label,
  desc, sample, ``exts=['rsc']``) so the "Load sample" button and
  stored-config compatibility warning work for MikroTik too.  The
  ``guessExtension`` function was refactored to read
  ``adapter.input_format`` from the /adapters response instead of
  hard-coding vendor names — partial closure of a known client-side
  vendor-metadata leak.  Full structural fix (moving all codec
  metadata out of JS and onto the codec class) is queued as a
  standalone UI-metadata-migration session.
- **46 new tests**: 37 unit tests (per-codec probe signatures +
  detection service + robustness) + 9 integration tests (endpoint
  contract, sorting, min-confidence filtering, both-fields 422,
  missing-file 404).  Total suite: 705 passing.
- **Testid additions**: ``migrate-detect-banner`` (with
  ``data-detected-codec`` and ``data-detected-confidence`` attrs),
  ``migrate-detect-use-btn``.

### Added (MikroTik RouterOS codec — third canonical-bridged vendor)

- **``MikroTikRouterOSCodec``** in
  ``netcanon/migration/codecs/mikrotik_routeros/`` — parses and renders
  RouterOS ``/export verbose`` text through ``CanonicalIntent``.  First
  third-party validation that the canonical dict is portable across
  structurally different formats (XML / indented IOS CLI / section-
  oriented RouterOS script).
- **Parser scope (Tier 1):** system identity (hostname), DNS + NTP
  servers, ethernet-port tweaks (``set [ find default-name=ether1 ]``),
  VLAN interfaces (``add name=vlanN vlan-id=N``), bridge interfaces,
  IPv4 addresses bound to interfaces (``/ip address add``), and static
  routes (``/ip route add``).  Handles RouterOS quirks: quoted
  values with spaces, ``\`` line continuation, ``#`` banner comments.
- **Renderer scope:** emits deterministic, section-ordered output.
  Ethernet ports render as default-name tweaks; VLANs, bridges, and
  other interfaces render as `add` lines.  Round-trip invariant holds
  for the canonical subset.
- **Vendor YAML** added at
  ``netcanon/migration/vendors/mikrotik_routeros.yaml`` declaring
  device classes ``[router, firewall]``.
- **Capability matrix** marks firewall/filter rules and NAT as
  unsupported (Tier 3 — informational only), interface type as lossy
  (inferred from name prefix).
- **Cross-vendor translation proven for 3 new pairs:** Cisco IOS-XE
  CLI → MikroTik; MikroTik → OpenConfig NETCONF XML; MikroTik →
  OPNsense config.xml.  Ecosystem now supports 4! = 24 vendor-pair
  combinations (up from 6 before this codec).
- **38 new unit tests** covering R3 fields, parse, parse-errors,
  render, round-trip, iter_xpaths, capabilities, cross-adapter
  pipeline integration, and registry.  Total suite: 659 tests passing.

### Added (canonical intent dict — cross-vendor translation bridge)

- **``CanonicalIntent``** pydantic model in
  ``netcanon/migration/canonical/intent.py`` — the shared tree shape
  every codec's ``parse()`` emits and ``render()`` consumes.  Defines
  Tier 1 (auto-translatable: hostname, interfaces, VLANs, static
  routes), Tier 2 (review-required: DHCP, SNMP, LAGs, users, RADIUS),
  and Tier 3 (informational-only: firewall, NAT, VPN stored as
  raw_sections for display, never auto-rendered).
- **VLAN-centric membership model**: VLANs carry their port lists
  (tagged/untagged), not the reverse — Aruba AOS-S and OPNsense work
  this way natively; Cisco's per-interface switchport is transposed
  on parse.
- **Cisco IOS-XE CLI codec refactored** to emit ``CanonicalIntent``:
  now parses hostname, VLANs (``vlan <id>`` / ``name`` stanzas),
  static routes (``ip route``), and switchport config
  (``switchport mode``, ``access vlan``, ``trunk allowed vlan``,
  ``trunk native vlan``) in addition to interfaces.
- **OPNsense codec refactored** to emit/consume ``CanonicalIntent``:
  new ``_render_canonical()`` method renders OPNsense config.xml from
  any canonical intent (including those parsed from Cisco CLI).
- **Cisco IOS-XE NETCONF codec** gains ``_render_canonical()`` to
  produce OpenConfig XML from any ``CanonicalIntent``.
- **Cross-vendor translation proven**: ``cisco_iosxe_cli`` (source) →
  ``opnsense`` (target) completes successfully through the pipeline.
  First time in the project's history that a stored backup config
  from one vendor renders as another vendor's config format.
- **740 tests pass** — zero regressions.  All existing tests updated
  to assert against canonical model attributes instead of raw dicts.

### Added (R3+R4: codec direction/certainty fields + first CLI codec)

- **R3: three new fields on ``CodecBase``:**
  - ``direction`` — ``"bidirectional"`` (default), ``"parse_only"``, or
    ``"render_only"``.  Parse-only codecs can only be SOURCE; the
    ``/migrate`` UI now filters them out of the target dropdown.
  - ``certainty`` — ``"certified"`` / ``"best_effort"`` /
    ``"experimental"``.  Surfaced on the API and in the source
    dropdown label so users know the trust level.
  - ``canonical_model`` — which CIM the tree targets (default
    ``"openconfig-lite"``).  Informational for now; becomes load-
    bearing when cross-CIM translation lands.
  All three fields exposed on ``CodecInfo`` via
  ``GET /api/v1/migration/adapters``.

- **R4: ``CiscoIOSXECLICodec``** — first ``parse_only`` codec, first
  multi-codec-per-vendor instance.  Parses ``show running-config``
  text — the format Netcanon's existing Netmiko backup collectors
  already capture.  Shares ``vendor_id=cisco_iosxe`` with the
  NETCONF codec; both produce the same tree shape so they're
  interchangeable as pipeline SOURCEs.
  - Direction: ``parse_only`` (render raises ``RenderError``).
  - Certainty: ``experimental`` (synthetic samples only).
  - Scope: interface stanzas — name, description, ``shutdown`` /
    ``no shutdown``, ``ip address <ip> <mask>`` with mask→prefix-
    length conversion.  Infers IANA ifType from the interface name
    prefix (``GigabitEthernet`` → ``ethernetCsmacd``, ``Loopback`` →
    ``softwareLoopback``, etc.).
  - Rejects XML/JSON input with a clear error pointing at the
    NETCONF codec.  Rejects non-contiguous subnet masks.
  - ``/migrate`` page: CLI codec appears in the SOURCE dropdown
    (with ``[experimental]`` badge) but NOT in the TARGET dropdown.
    Format hint says "Paste the output of show running-config."
    The "Load sample" button provides a working IOS CLI snippet.

- **Pipeline proof:** ``cisco_iosxe_cli`` (source) → ``cisco_iosxe``
  (target) completes successfully — the first time a stored backup
  config can be translated to OpenConfig XML through the UI.

- **Tests (+23):** ``tests/unit/migration/test_cisco_iosxe_cli.py``
  covers R3 field declarations, parse (minimal, fixture, shutdown,
  loopback /32, type inference), parse errors (empty, XML, JSON,
  non-contiguous mask), render-raises, tree-shape compatibility with
  the NETCONF codec's capability matrix, pipeline integration
  (CLI→NETCONF succeeds, CLI-as-target fails with parse-only error),
  and registry (two codecs for one vendor).

- Full suite: **740 passing** (was 717).

### Added (R2: declarative vendor YAML)

- **Vendor declarations** extracted to YAML files under
  ``netcanon/migration/vendors/``.  Three shipped: ``mock.yaml``,
  ``cisco_iosxe.yaml``, ``opnsense.yaml``.  Each declares ``id``,
  ``display_name``, ``device_classes``, ``default_timeout``, ``notes``.
  No Python code — adding a new vendor is a 30-second YAML-copy
  operation.
- **``VendorInfo``** pydantic model in ``netcanon.models.migration``.
- **``load_vendors()``** function in ``netcanon.migration.vendors``
  scans the directory at startup, validates against the model, skips
  corrupt files with a log.  Loaded into ``app.state.vendors``.
- **``CodecInfo``** now carries ``vendor_id`` and
  ``vendor_display_name`` so the UI can group codecs by vendor without
  a second request.  ``vendor_display_name`` is resolved from the
  loaded YAML at response time.
- **Codec ↔ vendor linkage test:** a dedicated unit test asserts that
  every shipped codec's ``vendor_id`` resolves to a loaded vendor — a
  build-time guard against orphaned references.
- **Tests (+17):** ``test_vendors.py`` (14 unit — built-in loading,
  model shape, error resilience, corrupt/missing/duplicate YAML,
  codec linkage guard) + 3 integration (API surfaces ``vendor_id``
  and ``vendor_display_name`` for each codec).
- Full suite: **717 passing** (was 700).

### Refactored (R1: rename adapter → codec + add vendor_id)

- **`AdapterBase` → `CodecBase`** — "codec" accurately describes the
  class's job: translate between a wire format and the canonical tree.
  All related types renamed: `AdapterInfo` → `CodecInfo`,
  `AdapterError` → `CodecError`, `MockAdapter` → `MockCodec`,
  `CiscoIOSXEAdapter` → `CiscoIOSXECodec`, `OPNsenseAdapter` →
  `OPNsenseCodec`, `get_adapter` → `get_codec`, `list_adapters` →
  `list_codecs`.
- **Directory:** `netcanon/migration/adapters/` →
  `netcanon/migration/codecs/`.  `adapter.py` → `codec.py`.
- **Test files:** `test_mock_adapter.py` → `test_mock_codec.py`,
  `test_cross_adapter_pipeline.py` → `test_cross_codec_pipeline.py`.
- **`CapabilityMatrix.vendor_id: str`** — new field, links the codec
  to a vendor YAML (R2).  Set on all 3 codecs.
- **JSON back-compat:** `CapabilityMatrix.adapter` stays as the JSON
  field name so API consumers don't break.
- 700 tests pass — zero regressions.

### Refactored (god-file cleanup — zero behaviour change)

Three files identified as god-files during a structural audit;
all three refactored with zero behaviour change (674 tests pass
before and after, same count).

- **`netcanon/main.py` (539 → 208 lines).**  All 12 UI route
  handlers (``/``, ``/jobs``, ``/schedules``, ``/configs``,
  ``/configs/{L}/vs/{R}``, ``/devices``, ``/definitions``,
  ``/migrate``, ``/docs``, ``/health``) extracted to a new
  ``netcanon/api/routes/ui.py`` (406 lines).  ``_format_interval``
  and the Jinja2 ``templates`` instance moved with them.
  ``create_app`` now only wires routers and configures the lifespan.

- **`netcanon/templates/base.html` (834 → 262 lines).**  Two
  self-contained JS widgets extracted to Jinja ``{% include %}``
  partials (no ``StaticFiles`` mount needed):
  - ``_partials/config-viewer.js`` (346 lines) — syntax highlighter,
    tokenizer, cross-span search.
  - ``_partials/job-progress.js`` (231 lines) — floating progress
    panel, localStorage persistence, CustomEvent dispatch.
  Toast + timestamp localiser + config downloader remain inline
  (~80 lines; not worth a separate file at that size).

- **``tests/e2e/test_backup_flow.py`` (805 lines, 13 classes)
  split into 6 focused files:**
  - ``test_navigation.py`` (60 lines) — nav smoke tests.
  - ``test_backup_form.py`` (129 lines) — dashboard structure +
    multi-device form + backup submission.
  - ``test_pages.py`` (72 lines) — definitions + configs pages.
  - ``test_config_viewer.py`` (178 lines) — syntax highlighting +
    cross-span search.
  - ``test_progress_panel.py`` (153 lines) — floating panel
    visibility, persistence, dismiss.
  - ``test_diff.py`` (226 lines) — diff API, UI, content, context
    folding.
  Shared helpers ``ensure_cisco_config`` and
  ``ensure_n_configs_of_type`` promoted from private functions in
  the old monolith to public utilities in ``tests/e2e/helpers.py``.
  Old ``test_backup_flow.py`` deleted.

- **Import fix:** ``tests/unit/test_schedule_models.py`` updated to
  import ``_format_interval`` from its new home in
  ``netcanon.api.routes.ui`` instead of ``netcanon.main``.

### Fixed + Added (translator `/migrate` UX after manual QA pass)

Five findings from a hands-on walk-through of the page — one real UX
bug, three workflow gaps, one display issue.

- **Fixed: banner severity out-of-sync with job outcome** (manual
  QA #10b).  Previously a parse-OK / render-failed job rendered the
  GREEN "validation OK" banner because validation ran fine before
  render blew up.  Now the banner's severity follows a strict
  priority: `job.error` present → block, `failed`/`partial` status →
  block, else `validation.severity`, else `info`.  Colour can no
  longer contradict the message.  Banner also carries a
  `data-severity` attribute now so tests can assert on it
  unambiguously.
- **Added: `AdapterBase.input_format`** (str, defaults to
  `"unknown"`).  Each adapter declares what its `parse()` accepts:
  - `cisco_iosxe` → `xml-netconf` (OpenConfig NETCONF payload)
  - `opnsense` → `xml-opnsense` (`config.xml`)
  - `mock` → `json-flat`
  - reserved: `xml-panos`, `cli-ios`, `cli-fortigate`, `cli-mikrotik`
  Catalogued in `netcanon.migration.adapters.base.INPUT_FORMATS`
  (frozenset).  `AdapterInfo` now exposes the field so the UI can
  read it from `GET /api/v1/migration/adapters`.
- **Added: format-hint banner on `/migrate`** — explains in-line
  what the source adapter expects (e.g. "OpenConfig NETCONF —
  machine-readable payload from `netconf get-config`, NOT `show
  running-config`").  Addresses manual QA #4 (user confusion about
  paste-box contents).
- **Added: "Load sample for source adapter" button** with a
  working minimal payload per format.  The iosxe sample round-trips
  cleanly; the opnsense sample is a minimal `<opnsense>` tree.
- **Added: stored-config compatibility warning** — when the picked
  stored config's extension doesn't match the source adapter's
  declared `input_format`, a red in-place warn appears BEFORE submit
  ("`Fortigate_*.cfg` has extension `.cfg` but `cisco_iosxe`
  expects OpenConfig NETCONF XML — translate will almost certainly
  fail").  Addresses manual QA #12, #13.
- **Fixed: path-list de-duplication** (manual QA #11).  Three
  interfaces each with a description used to produce three visually-
  identical rows in the Supported list.  Now collapses to one row
  with an `×3` count badge.  Top stats count still reflects per-leaf
  impact (unchanged).

**Tests (+23):**

- `tests/unit/migration/test_input_format.py` (13) — catalogue
  immutability, base-class default, concrete adapter declarations,
  "every registered adapter declares a KNOWN format" guard.
- `tests/integration/test_migration_api.py` (+3) — `input_format`
  surfaces on the list endpoint for every adapter.
- `tests/e2e/test_migrate_page.py` (+10) — banner severity
  regression for failed/partial/ok jobs, format-hint visibility +
  auto-update, Load-sample button, stored-config compat warn,
  path-list coalescing.

Full project suite: **674 passing** (was 651).  Pre-existing
unrelated failure in `test_jobs_schedules.py` schedule form — same
drift flagged in earlier sessions, untouched here.

### Added (translator Phase 2, part 1 — `/migrate` workbench UI)

- **New HTML page at `/migrate`** — translator workbench.  Pick source
  + target adapter, paste raw text OR pick a stored config, optionally
  tick "Force cross-class", hit Translate.  Backed entirely by the
  already-shipped `POST /api/v1/migration/plan` endpoint.
- **Nav link:** "Migrate" after "Definitions".  Active highlighting
  via the same `active_page` convention as every other page.
- **Client-side adapter hydration**: the two dropdowns fetch
  `GET /api/v1/migration/adapters` on page load, so newly-registered
  adapters appear without a template redeploy.  Each option carries
  the adapter's device classes; the info strip below shows chip
  badges (colour-coded per class) plus supported/lossy/unsupported
  counts.  A class-guard hint renders in red BEFORE submit when the
  picked pair has no common class — user knows it'll be blocked.
- **Result surface** reuses existing components so visual language
  stays consistent across the app:
  - Banner palette mirrors the diff page (`mig-banner-ok` / `warn`
    / `block` / `info`) — user's eye already knows what those mean.
  - Rendered-output pane uses the config viewer's
    `_cvRenderHighlighted(text, ext)` helper for syntax highlighting
    — same `.tok-*` colours as every other code surface.
  - Toast notifications via `window.showToast`.
- **Paths drill-down** (collapsed by default): three buckets
  (supported / lossy / unsupported) with counts, full xpath lists,
  adapter-provided reasons, and severity chips.  Users can see every
  finding the ValidationReport carries without another request.
- **Copy button** for the rendered output — one-click clipboard
  without leaving the page.
- **Parse failures are surfaced as results, not errors.** The
  pipeline returns HTTP 200 with a `failed` job on adapter errors;
  the page renders the failure banner + status summary instead of a
  toast.  Genuine 4xx responses (unknown adapter, missing filename)
  DO toast.

- **Testids:** 29 new `migrate-*` testids promoted from reserved
  status in `tests/testid_reference.md` (nav, form, dropdowns, input
  mode toggle, result region, banner, stats, output, paths buckets).
  The reserved list for Phase 2 "transforms + deploy" remains
  (`migrate-transforms-list`, `migrate-deploy-btn`, etc.).

- **E2E tests (+13):** `tests/e2e/test_migrate_page.py` covers nav
  link, page structure, result-region hidden-on-load, adapter
  dropdown hydration, adapter-info update on change, input-mode
  toggle, iosxe round-trip happy path (ok banner), rendered-output
  panel appearance, parse-failure rendering, validation-block
  rendering (partial status).  `MigratePage` helper added to
  `tests/e2e/helpers.py` following the existing page-object pattern.

- **Total suite: 651 passing** (was 567 immediately after Phase 1
  backend).  Zero regressions; no new runtime dependencies.

### Added (translator Phase 1 — OPNsense adapter + write endpoints)

- **Second real adapter: `OPNsenseAdapter`** under
  `netcanon/migration/adapters/opnsense/`.  Parses/renders OPNsense
  `config.xml`.  Scope: system hostname/domain and interfaces
  (zone, `if`, descr, enable-flag, ipaddr, subnet).  Declares
  `device_classes=[firewall, router]`.
- **OPNsense zone-keyed interface idiom flattened** at parse time:
  native `<wan>…</wan><lan>…</lan>` children become a list of dicts
  with a synthetic `zone` key, so `iter_xpaths` can emit OpenConfig-
  style schema paths (no list keys).  The render step reverses the
  transformation.  Round-trip invariant `parse(render(tree)) == tree`
  is tested with sanitised 3-interface fixture.
- **Cross-vendor guardrail shown working:** OPNsense ∩ IOS-XE =
  `{router}`, so the class guard permits the migration; the per-
  xpath capability matrices then honestly flag firewall rules
  (`/filter/rule`, `/nat/outbound`) as unsupported by IOS-XE.
  The intended layering — class guard for coarse "is this meaningful
  at all?", capability matrix for fine "which features translate?".

- **New write endpoints:**
  - `POST /api/v1/migration/plan` — runs the full pipeline
    (class-guard → parse → transforms → validate → render) on a
    raw config payload.  Returns the `MigrationJob` as JSON, even
    on parse failure (the error is in `job.error`, not an HTTP
    status).  Callers inspect `job.status` for the outcome.
  - `POST /api/v1/migration/render` — currently an alias for
    `/plan`; kept as a separate route so Phase 2 can split plan
    (no side effects) from render (pre-deploy snapshot + diff URL)
    without another API rev.
  - Input mode toggle: request body supplies EITHER `raw_text` OR
    `source_filename` (which loads from the existing backup store).
    Exactly one MUST be set — otherwise 422.  Source-filename
    shorthand means you can migrate any stored config without
    shipping the bytes through HTTP.
  - `force=true` in the body skips the device-class guard.
- **New model:** `MigrationPlanRequest` in
  `netcanon.models.migration` — documented, tested, ready for a
  Phase 2 UI to reuse.

- **Manual testing now possible** end-to-end via curl:

      curl -X POST http://127.0.0.1:8000/api/v1/migration/plan \
           -H 'Content-Type: application/json' \
           -d '{"source":"cisco_iosxe","target":"cisco_iosxe",
                "raw_text":"<interfaces xmlns=\"http://openconfig.net/yang/interfaces\">…"}'

- **Tests (+32):**
  - `tests/unit/migration/test_opnsense.py` (21): parse, errors,
    render determinism, round-trip invariant (inline + fixture),
    iter_xpaths coverage, capability declarations, cross-adapter
    class-intersection, registry.
  - `tests/integration/test_migration_api.py` (+11): plan endpoint
    happy path, 422 variants (unknown source, unknown target,
    neither/both input modes), 404 for missing filename, parse
    failure returns 200 with failed job, force flag round-trip,
    render is alias, end-to-end integration with backup store.
  - `tests/fixtures/opnsense/config_simple.xml` — sanitised sample.

- **Total suite:** 567 passing (was 535, +32).  Migration suite
  alone: 184 tests (was 140, +44 across OPNsense + API integration).

### Added (translator: adversarial-input hardening + cross-adapter tests)

- **Strict YANG boolean parsing.** `CiscoIOSXEAdapter` used to silently
  coerce any `<enabled>` text other than literal `true` to `False` —
  meaning `<enabled>yes</enabled>` would ship a DISABLED interface.
  The parser now rejects every non-RFC-7950 spelling (`yes`, `no`,
  `1`, `0`, `on`, `off`, empty string, …) with a `ParseError` that
  names the exact xpath.
- **IPv4 prefix-length range check.** Previously values like `99`
  or `-1` were accepted silently and round-tripped into the rendered
  NETCONF payload, where the device would reject the edit at deploy
  time.  The parser now enforces the YANG `inet:ipv4-prefix` range
  (`0..32`).
- **Interface-index error paths.** Empty or missing `<name>` elements
  now raise a `ParseError` whose `path` includes the zero-based
  `interface[N]` index and whose `snippet` contains the offending
  element serialised to XML (capped at 200 chars).  A device
  returning ten interfaces with one malformed entry is now locatable
  in ~5 seconds instead of "open the XML and scroll".
- **UTF-8 BOM tolerance.** Some devices (and some editors) prepend a
  BOM to their XML declaration.  Test lock-in so this stays working.
- **Cross-adapter pipeline tests** (`tests/unit/migration/
  test_cross_adapter_pipeline.py`): prove stage transitions, error
  routing, and type boundaries that no single-adapter test touches:
  - IOS-XE → mock: class guard permits, nested walker reaches leaves,
    render produces JSON despite type-shape mismatch.
  - Mock → IOS-XE: render mismatch caught as `failed` with useful
    error; validation still ran first; `completed_at` is always set.
  - Partial-status routing: a validation `block` with a successful
    render correctly lands in `partial`, not `completed` or `failed`.
  - Stage ordering: class guard runs at stage 0, before parse — a
    disjoint-class pair with broken XML fails with the class-guard
    error, not a parser error.
- **Tests (+22)**:
  - `test_cisco_iosxe.py`: 10 new adversarial-input tests covering
    the four hardening items above.
  - `test_cross_adapter_pipeline.py`: 11 new pipeline scenarios.
  - Full migration suite now 140 tests (was 97 before this hardening
    pass, 77 before Phase 0.5's round-trip work, 30 at end of Phase 0).
- Full project suite: **535 passing** (was 513).  Zero regressions.

### Added (translator Phase 0.5 — Cisco IOS-XE adapter)

- **First real adapter: `CiscoIOSXEAdapter`** under
  `netcanon/migration/adapters/cisco_iosxe/`.  Scope:
  `openconfig-interfaces` + `openconfig-if-ip` subset (name,
  description, enabled, type, IPv4 address + prefix-length on
  subinterfaces).  Enough to prove the adapter contract against
  real OpenConfig NETCONF payloads.
- **Internal tree shape:** nested dict mirroring the OpenConfig XML
  structure, namespace-stripped for readability.  Canonical namespaces
  are re-attached on render.  Operates against captured NETCONF
  `<get-config>` responses today; live `ncclient` transport is
  Phase 1's responsibility (same split as the existing
  collectors-vs-collector-consumers layout).
- **Stdlib only** — `xml.etree.ElementTree` for parse/render.  No new
  runtime dependencies; libyang canonical validation is deferred to
  Phase 0.7 behind a "validates if installed" seam.
- **Round-trip invariant enforced:** `parse(render(tree)) == tree`
  for every supported tree.  Tested over inline samples and a real
  sanitised 3-interface fixture under `tests/fixtures/iosxe/`.
- **Capability matrix declares:**
  - 9 supported paths (name, config.name, config.description,
    config.enabled, config.type, subinterface.index, address.ip,
    address.config.ip, address.config.prefix-length).
  - Lossy: `/interfaces/interface/config/mtu` — YANG model doesn't
    round-trip every platform-specific MTU tweak.
  - Unsupported: IPv6 subtree (Phase 1 work).
  - `device_classes=[router, switch]` — IOS-XE platforms routinely
    fulfil both roles.

### Changed (translator: adapter-driven tree walker)

- **`AdapterBase` gets `iter_xpaths(tree)`** — non-abstract, defaults
  to the flat `dict[str, str]` walker so the mock adapter and any
  existing callers keep working.  Adapters with nested tree shapes
  (the new `CiscoIOSXEAdapter`) override to yield schema xpaths
  (no list-key predicates) that match their declared capability
  matrix.
- **`validate_against(tree, target)` gains an optional
  `source` adapter parameter.**  When supplied, the validator uses
  `source.iter_xpaths` to walk the tree — required for adapters
  whose internal tree shape isn't a flat dict.  Backward-compatible:
  omitting `source` keeps the Phase 0 behaviour.
- **`run_plan` threads `source` through to `validate_against`**
  automatically, so all pipeline callers get adapter-aware walking
  for free.

### Tests (+41 over Phase 0 baseline)

- `tests/unit/migration/test_cisco_iosxe.py` (30): parse (bare +
  envelope), parse errors (malformed XML, missing interfaces,
  non-integer prefix-length, interface without name), render
  determinism, round-trip invariant (inline + fixture), iter_xpaths
  predicate-freedom + matrix alignment, capability declarations,
  pipeline integration, registry.
- `tests/integration/test_migration_api.py`: new assertions that
  `cisco_iosxe` appears in the list endpoint, declares the expected
  device_classes, and exposes its full capability matrix (lossy
  MTU + unsupported IPv6) via the detail endpoint.
- `tests/fixtures/iosxe/get_config_simple.xml` — sanitised 3-interface
  NETCONF `<get-config>` response (RFC 5737 documentation IPs).

### Added (translator: cross-device-class guardrail)

- **Coarse-grained device-class compatibility check** prevents
  nonsensical migrations (e.g. trying to render a Layer-2 switch
  config through a firewall adapter).  Adapters declare one or more
  ``DeviceClass`` values on their ``CapabilityMatrix``; the pipeline
  refuses a pair with no class in common unless ``force=True``.
- **New `DeviceClass` enum** in `netcanon.models.migration`:
  ``switch``, ``router``, ``firewall``, ``load_balancer``,
  ``wireless_controller``, ``access_point``, ``waf``.  Taxonomy is
  flat and additive; multi-class devices (L3 switches, UTM
  appliances) declare multiple values.
- **`CapabilityMatrix.device_classes: list[DeviceClass]`** — empty
  default is "uncommitted" and produces a ``warn`` (not block) so
  adapters can be developed before their class declarations are
  finalised.
- **`check_class_compat(source, target) -> CompatibilityReport`** in
  `netcanon.services.migration_validate`.  Reuses the
  `CompatibilityReport` shape from the diff models so UIs can render
  both class-mismatch and xpath-mismatch banners with the same
  component.  Severity branches: same/overlapping class → `ok`;
  either side undeclared → `warn`; both declared but disjoint → `block`.
- **`run_plan` stage-0 guard**: the class check runs BEFORE parse,
  so mismatched adapters fail instantly with a clear
  ``"Device-class guard refused migration: …"`` error.  A new
  ``force: bool = False`` parameter on `run_plan` skips the guard
  for deliberate cross-class experiments (same idiom as the diff
  page's `?force=true` override).
- **API surface**: `AdapterInfo.device_classes` is now returned on
  ``GET /api/v1/migration/adapters`` so UIs can filter the target
  picker to compatible adapters before the user commits.  The
  detailed ``CapabilityMatrix`` response also surfaces the field.
- **Tests (+20)**: `tests/unit/migration/test_device_class.py`
  covers the enum shape, pydantic coercion of string values (for
  capabilities.yaml loading in Phase 1), every `check_class_compat`
  severity branch, and the `run_plan` stage-0 guard (default
  behaviour + `force=True` override + no-op when already
  compatible).  Integration test added for the new
  `device_classes` field on the list endpoint.

### Added (translator Phase 0 — adapter contract + pipeline skeleton)

- **Phase 0 of the translator / migration engine landed.**  Scope per
  `translator-plans.txt` §12: prove the shape end-to-end with a
  reference adapter, no real YANG tooling required yet.
- **New pydantic models** in `netcanon.models.migration`:
  `CapabilityMatrix` (with a `classify()` resolver using
  "strictest-wins" semantics — unsupported > lossy > supported),
  `LossyPath`, `UnsupportedPath`, `ValidationReport`, `XPathDelta`,
  `TransformSpec`, `MigrationJob`, `MigrationJobStatus`, `AdapterInfo`.
  Shape deliberately mirrors `CompatibilityReport` + `BackupJob` so UI
  banners and lifecycle conventions stay consistent.
- **`netcanon.migration` package**:
  - `adapters/base.py` — `AdapterBase` ABC + `ParseError` / `RenderError`.
  - `adapters/registry.py` — in-memory `register` / `get_adapter` /
    `list_adapters` with name-collision and missing-name guards.
  - `adapters/_mock/` — reference adapter that round-trips a flat
    `dict[str, str]` via JSON; exercises every `classify()` branch
    (supported, lossy, unsupported).
  - `canonical/loader.py` — Phase 0.5 stub; `NotImplementedError`
    with clear roadmap pointer.  `PLANNED_MODULES` tuple documents
    the OpenConfig + `netcanon-ext` modules that will be pinned
    once libyang lands.
- **New services**:
  - `services/migration_validate.py` — walks a tree's xpaths,
    classifies each against the target's `CapabilityMatrix`, returns
    a `ValidationReport` with `ok` / `warn` / `block` severity.
  - `services/migration_pipeline.py` — `run_plan(source, target,
    raw_text, transforms)` orchestrator covering stages
    parse → transform → validate → render.  Each failure class
    (`ParseError`, `RenderError`, generic `Exception`) yields a
    terminal `failed` job with a `.error` summary.  A successful
    render against a `block`-severity validation yields `partial`
    (output available for review, not safe to auto-deploy).
- **New API endpoints** (read-only Phase 0):
  - `GET /api/v1/migration/adapters` — list registered adapters
    with summary counts.
  - `GET /api/v1/migration/adapters/{name}/capabilities` — full
    `CapabilityMatrix`; 404 for unknown adapters.
- **Tests (+77)**:
  - `tests/unit/migration/test_models.py` (20) — every pydantic
    type + `classify` resolution rules.
  - `tests/unit/migration/test_registry.py` (10) — decorator
    contract, collision detection, idempotent re-registration,
    LookupError on unknown names, mock always registered.
  - `tests/unit/migration/test_mock_adapter.py` (14) — round-trip
    invariant over 5 sample trees, deterministic output, parse
    error paths, capability-matrix shape.
  - `tests/unit/migration/test_validate.py` (11) — every severity
    branch including `error`-level lossy escalation, mixed
    unsupported+lossy, empty tree, non-dict tree.
  - `tests/unit/migration/test_pipeline.py` (9) — happy path,
    transform ordering + failure, parse failure, validation
    block → partial status, failed-job timing.
  - `tests/unit/migration/test_canonical_loader.py` (4) — stubs
    raise `NotImplementedError` with roadmap pointer.
  - `tests/integration/test_migration_api.py` (9) — list + detail
    endpoints, 404 for unknown adapter, summary/detail consistency.
- **No UI in this phase.**  testids for the migration UI are
  queued for Phase 2 (`migrate-source-select`, etc. — see
  `translator-plans.txt` §11); the config diff page already
  handles rendered-output review so no second viewer is needed.

### Changed (diff page: directional paradigm — `FROM → TO`)

- **"Sides" paradigm replaced with a temporally-neutral direction.**
  The unified diff layout has directionality (`+N` added / `-M`
  removed going from one file to another), not sides.  The UI now
  surfaces that explicitly with `FROM` and `TO` role labels:
  - Each filename chip is preceded by a role badge: `FROM` (dark)
    next to the left chip, `TO` (green) next to the right chip.
  - A directional arrow (`→`) replaces the neutral "vs".
  - The stats strip is prefixed `from → to:` so `+12 / −3` reads
    naturally ("12 added, 3 removed going from the left file to the
    right file").
  - The `⇄ Swap sides` button becomes `⇋ Reverse direction`; its
    tooltip explains that the click swaps FROM/TO.
- **Why `from`/`to` instead of `baseline`/`current`?**  `current`
  implied one of the configs was from "now", which is wrong when you
  diff two old configs against each other.  `from`/`to` encodes only
  direction, not time — perfect for any pairwise comparison whether
  both configs are historical, both are fresh, or mixed.
- **Testid renames:**
  - `diff-swap-sides-btn` → `diff-reverse-btn`
  - New testids: `diff-from-label`, `diff-to-label`
- **Helper / test updates:** `DiffPage.swap_sides_btn` →
  `DiffPage.reverse_btn`; `test_swap_sides_link_reverses_url` →
  `test_reverse_direction_link_reverses_url`; new assertion
  `test_from_and_to_role_labels_visible`.

### Added (diff: collapsed-context folding for large configs)

- **Context folding** on `/configs/{left}/vs/{right}`.  Long runs of
  equal lines far from any change are squashed into a single expandable
  "… N unchanged lines …" marker, matching the convention used by git,
  GitHub, GitLab and VS Code.  Drops a real-world FortiGate vs
  FortiGate comparison from **35,422 rendered `<div>`s** to **~900** —
  a ~32× reduction in browser layout cost.
- **Zero-round-trip expansion.**  Every collapsed marker ships a
  sibling `<template>` element carrying the hidden lines as
  pre-rendered markup.  Clicking the marker clones the fragment into
  the DOM in place of the marker, applies syntax highlighting to the
  new lines, and removes the marker + template.  No network call, no
  flash of unstyled content.
- Keyboard-accessible: markers are `<button>`s so Tab / Enter / Space
  all work.
- **New model:** `netcanon.models.diff.DiffGroup` — `{kind, lines}`
  where ``kind`` is the per-line classification or the new
  ``"collapsed"`` group.
- **New service:** `netcanon.services.diff.fold_context(lines,
  context=3)` — pure, two-sweep Manhattan-style distance-to-change
  computation.  Default context (`3` lines) matches unified-diff
  convention.
- **New testids:** `diff-line-collapsed`, `diff-collapsed-template`.
- **Tests:** 9 new unit tests in `tests/unit/test_diff_service.py`
  exercising the folding algorithm (boundaries, adjacent changes,
  context=0, default=3, negative rejected, order preservation).
  3 new E2E tests in `TestDiffContextFolding` covering marker
  visibility, count attribute, and click-to-expand behaviour.

### Added (config diff — Tier 1 textual line diff with compatibility guardrails)

- **`POST /api/v1/configs/diff`** — line-level unified diff between two
  stored configurations.  Body: `{left, right, force?}`.  Returns a
  `DiffReport` containing the per-line breakdown, aggregate stats
  (`{added, removed, equal}`), and a compatibility report.  Uses
  stdlib `difflib.SequenceMatcher`; no new runtime dependencies.
- **Compatibility guardrails (defence in depth).**  Two configs are
  considered diff-compatible when `type_key` (`device_type`) AND
  `file_extension` match on both records.  Mismatches:
  - API refuses with **HTTP 422** unless the caller explicitly passes
    `force=true` in the body.
  - UI: the "Compare" button on `/configs` opens a target picker that
    lists only matching configs by default; cross-vendor options are
    hidden behind a "Show cross-vendor" toggle and dimmed.
  - `/configs/{left}/vs/{right}` page always renders, but an
    incompatible pair without `?force=true` gets a red block banner
    and a "Compare anyway" override button in place of the diff body.
  - With `force=true` the diff is computed anyway; a red banner warns
    semantic equivalence is not guaranteed.
- **Deep-linkable diff URL** at `/configs/{left}/vs/{right}` (with
  optional `?force=true`).  Reuses the config viewer's syntax
  highlighter client-side — each diff line's `<span>` goes through
  `_cvRenderHighlighted(text, ext)` post-render so cfg/xml colouring
  stays consistent between the viewer and the diff view.
- **Compare button** on every row of `/configs`; lightweight modal
  picker keyed on `type_key` + `file_extension`.
- **New models:** `netcanon.models.diff.{DiffLine, CompatibilityReport,
  DiffRequest, DiffReport}`.  **New service:**
  `netcanon.services.diff.{check_compatibility, compute_diff}` — pure,
  no I/O, easily testable.
- **New tests:**
  - `tests/unit/test_diff_service.py` (12 tests): pure-function tests
    for compat logic, add/remove/replace, force annotation, empty input,
    trailing-newline handling.
  - `tests/integration/test_configs_api.py::TestDiffCompatibility` +
    `::TestDiffOutput` (8 tests): same-type OK, cross-vendor 422,
    force override, 404 on missing filename, line-number monotonicity.
  - `tests/e2e/test_backup_flow.py::TestDiffApi` +
    `::TestDiffPageUI` + `::TestDiffPageContent` (13 tests): live-API
    wiring, Compare button and picker, cross-vendor hide/show, banner
    severity, force override, swap-sides link.
- **New testids** for Compare picker and the diff page; see
  `tests/testid_reference.md`.

### Fixed (config viewer search misses queries that cross syntax-highlight spans)

- **Cross-span search now works.** The syntax highlighter splits the
  config text into many text nodes interleaved with ``<span class="tok-*">``
  elements.  The previous per-text-node ``indexOf`` loop couldn't see a
  match that straddled a span boundary, so queries like ``64:ff9b``
  (FortiGate IPv6 NAT prefix — ``64`` is a ``tok-number`` span, ``:ff9b``
  is plain text in the next node) or ``hostname Router`` (keyword span
  followed by plain text) silently returned zero matches even when the
  substring was clearly present.
- **Fix:** ``_cvSearch`` in ``base.html`` now flattens the ``<pre>`` into
  a single string while building a ``(node, absolute_offset)`` segment
  map, finds matches in the flat text, and wraps each match across
  whatever boundaries it crosses.  Matches are processed in reverse
  document order so earlier offsets stay valid as later ones mutate
  the DOM.  A single logical match becomes a *group* of ``<mark>``
  elements; ``configViewerNav`` toggles the ``.current`` class on every
  element in the group and scrolls to the first.
- **New E2E tests** in ``tests/e2e/test_backup_flow.py``:
  - ``test_cross_span_query_finds_match`` — asserts ``"hostname Router"``
    (straddles the ``tok-keyword`` span) now matches.
  - ``test_cross_span_match_current_class_applied_to_all_pieces`` —
    asserts every ``<mark>`` in the group gets ``.current``.

### Added (parallel backup execution within a job)

- **Per-job parallelism** — `_run_backup_job` now dispatches device work
  to a bounded `ThreadPoolExecutor`.  Up to `backup_concurrency` devices
  run simultaneously; additional devices wait in the executor's FIFO
  queue and start as slots free up.  A 30-device job with 30 s per
  device now completes in ~3 × the per-device latency instead of 30 ×.
- **`Settings.backup_concurrency`** — new configurable, range `[1, 10]`,
  default `10`.  Hard-capped at `MAX_BACKUP_CONCURRENCY = 10` in
  `netcanon/config.py` to protect target SSH servers (most vendor caps
  are 5–16) and bound server thread count.  Override via
  `NETCANON_BACKUP_CONCURRENCY`; see `.env.example`.
- **Serial fast-path** — jobs with a single device (or deployments
  pinned to `backup_concurrency=1`) skip the thread pool entirely;
  traces and error paths stay unchanged for those callers.
- **Thread-safety contract** documented in the `_run_backup_job`
  docstring: results list is pre-populated and never resized, each
  worker mutates exactly one index, and `FileConfigStore` atomic writes
  handle storage concurrency.
- Tests default to serial execution (`test_settings` sets
  `backup_concurrency=1`) so the existing observation test and all
  ordering-sensitive assertions remain deterministic.  Explicit parallel
  tests in `TestBackupConcurrency` exercise the pool via `Barrier(n)`.

### Added (persistent backup-progress panel + per-device lifecycle states)

- **`BackupResult.status` lifecycle** — new intermediate values `queued`
  and `running` alongside the existing terminal `success` / `failed`.
  `_run_backup_job` now pre-populates one `BackupResult` per device in
  `queued` state, flips each to `running` when its collector is invoked,
  and sets the terminal state on completion.  Polling clients can snapshot
  the results list at any point and see exactly which device the engine is
  working on.
- **Floating job-progress panel** (`base.html` — global):
  - Bottom-right floating widget, present on every page.
  - Collapsible header showing aggregated job status + live summary
    (`2/5 complete — running: 1 — queued: 2` or `5/5 succeeded`).
  - One row per device with status icon (`○` queued, `⟳` running, `✓`
    success, `✗` failed), host label, per-device duration, and truncated
    error on failure.
  - **Persists across full page reloads** — the active job ID is stored
    in `localStorage["netcanon.activeJob"]`; on `DOMContentLoaded` the
    panel resumes polling if the stored job is still non-terminal, and
    renders the final state otherwise.
  - Explicit `Dismiss` button (no auto-dismiss) clears the panel AND the
    localStorage key.  A "View full job details" deep link jumps to the
    corresponding card on `/jobs`.
  - Dispatches `netcanon:job-started`, `netcanon:job-progress`,
    `netcanon:job-complete`, and `netcanon:job-dismissed` `CustomEvent`s
    on `document` so page-level code (e.g. the dashboard row injector)
    can react without re-polling.
- **New `data-testid`s:** `job-progress-panel`, `job-progress-header`,
  `job-progress-summary`, `job-progress-toggle`, `job-progress-body`,
  `job-progress-device-row`, `job-progress-device-status`,
  `job-progress-device-host`, `job-progress-device-duration`,
  `job-progress-device-error`, `job-progress-footer`,
  `job-progress-view-link`, `job-progress-dismiss`.  The legacy
  `job-status-banner`, `job-id-display`, and `job-status-display` testids
  are aliased onto the new panel for backward compatibility.

### Removed

- **Inline job status banner** on `index.html` — replaced by the global
  floating progress panel (above).  The dashboard's submit handler now
  delegates to `startJobProgress(jobId)` and listens for the
  `netcanon:job-complete` event for the "inject a row into the recent
  jobs table" step.

### Added (config viewer: syntax highlighting + in-modal search)

- **Syntax highlighting** in the shared config viewer modal (`viewConfig()`):
  comments, keywords, strings, IP addresses, and numbers for Cisco / Fortigate /
  Mikrotik `.cfg` output, plus tags and attributes for OPNsense XML.  Unknown
  extensions fall back to escaped plain text.  Palette is VS Code "Dark+"
  inspired; all tokens are rendered as `<span class="tok-*">` so E2E tests and
  custom themes can target them.
- **In-modal search** with live match counter, previous / next navigation
  (▲ / ▼ buttons), keyboard shortcuts (Enter = next, Shift+Enter = previous,
  Escape = clear or close), and wrap-around.  Matches are wrapped in `<mark>`
  elements; the currently-selected match gets `mark.current` for a distinct
  highlight colour and is auto-scrolled into view.
- **New `data-testid`s** for the viewer: `config-viewer`, `config-viewer-title`,
  `config-viewer-content`, `config-viewer-search`, `config-viewer-search-count`,
  `config-viewer-search-prev`, `config-viewer-search-next`, `config-viewer-close`.
  Full reference in `tests/testid_reference.md`.

### Changed (job status reflects per-device outcomes)

- **`JobStatus.partial`** — new terminal state for backup jobs where at least
  one device succeeded AND at least one failed.  Terminal-state semantics are
  now:
  - `completed` — every device succeeded.
  - `partial`   — mixed result (≥1 success, ≥1 failure).
  - `failed`    — zero successes (every device failed).

  Previously a job was marked `completed` regardless of per-device outcomes;
  users had to look at the success/total column to notice failures.  The UI
  now shows an amber `badge-partial` and a ⚠ indicator for mixed runs.

### Added (backup jobs page + recurring schedules)

- **Job persistence** — `FileJobStore` writes one JSON file per completed backup
  job to `{data_root}/jobs/`.  All jobs are reloaded into `app.state.jobs` at
  startup, so job history survives server restarts.
- **`BackupJob.schedule_id` / `schedule_name`** — new optional fields track
  which schedule triggered a job (snapshot of name at run time).  `None` for
  manually triggered runs.
- **`GET /jobs`** — dedicated Jobs page listing all backup jobs newest-first.
  Each job is a collapsible card showing: short ID, status badge, success/total
  count, timestamp, duration, and trigger (schedule name or "Manual").  Expanded
  body shows a per-device results table with View / Download / (Open) links and
  the config filename.  URL hash navigation: `/jobs#a1b2c3d4` auto-expands and
  scrolls to the matching job card.
- **`/schedules`** — Schedule management page and backing API:
  - **`GET /api/v1/schedules/`** — list all schedules
  - **`POST /api/v1/schedules/`** — create a recurring backup schedule
    (name, interval\_minutes, devices list)
  - **`DELETE /api/v1/schedules/{id}`** — delete a schedule
  - **`POST /api/v1/schedules/{id}/toggle`** — enable / disable a schedule
- **`BackupSchedule` model** (`netcanon/models/schedule.py`) — stores schedule
  metadata: id, name, enabled, interval\_minutes, devices, created\_at,
  last\_run\_at, next\_run\_at, last\_job\_id.
- **`FileScheduleStore`** (`netcanon/storage/schedule_store.py`) — persists
  schedule definitions as JSON under `{data_root}/schedules/`.
- **APScheduler integration** — `AsyncIOScheduler` (timezone=UTC) is started in
  the app lifespan.  Each enabled schedule registers an `IntervalTrigger` job.
  Blocking SSH runs via `asyncio.to_thread` so it never blocks the event loop.
  Scheduler state is purely in-memory; schedule definitions are re-loaded from
  disk and re-registered on every startup.
- **`next_run_at` tracking** — captured from APScheduler after registration and
  after each run; persisted to disk so the Schedules page always shows an
  accurate value even before the first tick.
- **Nav updated** — "Jobs" and "Schedules" links added between Dashboard and
  Configs in the nav bar (order: Dashboard | Jobs | Schedules | Configs |
  Definitions | API Docs).  Swagger nav updated to match.
- **`apscheduler>=3.10.4`** added to `requirements.txt` and `pyproject.toml`.

### Added (nav bar on API Docs page)

- **`GET /docs`** — FastAPI's built-in Swagger UI is now replaced by a
  custom route that injects the Netcanon nav bar (sticky, same style as
  all other pages) so users can always navigate back from the API explorer.
  The raw `/openapi.json` schema endpoint is unchanged.  `/redoc` is
  disabled (it was unreachable from the UI anyway).

### Changed (vendor-specific field naming)

- **`ConnectionConfig.handle_paging` → `cisco_more_paging`** — renamed to make
  clear this flag controls Cisco `--More--` space-injection specifically.
  `terminal length 0` remains deliberately avoided on all Cisco definitions.
- **`ConnectionConfig.needs_shell_menu` → `opnsense_shell_menu`** — renamed to
  make clear this flag detects and dismisses the OPNsense numbered console menu
  (sends `"8"` to enter the shell).  Not applicable to any other current vendor.
- **`ConnectionConfig.needs_enable`** — unchanged.  Enable/privileged-mode
  escalation is a cross-vendor concept in Netmiko (Cisco IOS, HP ProCurve,
  Aruba OS-CX, and others).
- Updated all four YAML definition files, both collectors, all test YAML strings,
  `tests/fixtures/definitions.py`, `Get-NetworkConfigs.ps1`,
  `Test-NetworkConfigs.ps1`, and all README/doc files to match.

### Added (config storage & open-in-editor)

- **Subdirectory storage layout** — config files are now saved under
  `{device_type}/{safe_host}/` inside `configs_dir` instead of a flat root.
  Example: `configs/Cisco/192-168-1-1/Cisco_192-168-1-1_20260414_120000.cfg`.
  The self-describing filename format is unchanged.
- **Startup migration** — `FileConfigStore.__init__` automatically moves any
  flat files left by older versions into the correct subdirectory.  Non-config
  files (log files, README) are left untouched.
- **Collision safety** — if two backups of the same device complete within the
  same second, a numeric suffix is appended (`…_1.cfg`, `…_2.cfg`, …) so no
  file is ever silently overwritten.
- **`resolve_path(filename)`** — new public method on `BaseConfigStore` and
  `FileConfigStore`.  Returns the absolute filesystem path for a given filename,
  checking the subdirectory location first then falling back to the root for
  files that pre-date migration.
- **`Settings.open_in_editor: bool = False`** — new flag.  When `True`, enables
  the `POST /api/v1/configs/{filename}/open` endpoint.  Set to `True` in
  `netcanon_desktop/settings.py`.  Can also be enabled for local web
  deployments via `NETCANON_OPEN_IN_EDITOR=true`.
- **`POST /api/v1/configs/{filename}/open`** — opens the named config file in
  the OS default text editor (`os.startfile` on Windows, `open` on macOS,
  `xdg-open` on Linux).  Returns 204 on success; 403 if disabled; 404 if not
  found; 500 if the OS refuses to open the file.  Documented as desktop-only
  in `AGENTS.md`; the web equivalent is the existing View button.
- **"Open" button** (`data-testid="config-open-btn"`) — appears in the Actions
  column of the Configs page only when `open_in_editor=True`.  Calls the open
  endpoint; shows a success or error toast via `showToast()`.

### Tests (config storage & open-in-editor)

- `tests/unit/test_storage.py` — 19 new/updated tests: subdirectory save,
  collision safety (triple-collision), `resolve_path` (subdir + flat fallback +
  missing), startup migration (multiple files, non-config left in place,
  idempotent), and `rglob`-based listing.  Existing tests updated to use
  `store.resolve_path()` instead of manually constructing paths.
- `tests/integration/test_configs_api.py` — `TestOpenConfig` (5 tests): 403
  when disabled, 404 for missing file, 204 on success, correct path passed to
  `os.startfile`, 500 when OS refuses.
- `tests/testid_reference.md` — `config-open-btn` added with conditional
  visibility note.

---

### Added (logging)

- **`netcanon/logging_config.py`** — New `configure_logging(level, log_file)` function.
  Sets up a `StreamHandler` (stderr) plus an optional `RotatingFileHandler` (5 MB, 3
  backups) on the root logger.  Idempotent: skips when real (non-pytest) handlers are
  already present.  Suppresses `paramiko`, `uvicorn.access`, `multipart`, and `asyncio`
  to `WARNING` regardless of root level to reduce noise in INFO/DEBUG runs.
- **`netcanon_desktop/__main__.py`** — `_configure_logging()` called before
  `DesktopApp()`.  In frozen (installed) mode writes to
  `%APPDATA%\Netcanon\netcanon.log`; in dev mode uses console only.  Fatal startup
  exceptions now go through `logger.critical(..., exc_info=True)` before the message
  box so the stack trace is captured in the log file.
- **`netcanon_desktop/server.py`** — `log_config=None` added to `uvicorn.Config` so
  uvicorn's startup does not call `logging.config.dictConfig()` and overwrite the root
  logger configuration set by `configure_logging()`.
- **`netcanon_desktop/settings.py`** — `log_level` default raised from `"warning"` to
  `"info"` so desktop INFO logs reach the file handler.

### Changed (logging)

- **`netcanon/api/routes/backups.py`** — Device backup failures upgraded from
  `WARNING` to `ERROR` and now include `exc_info=True` for full traceback capture.
- **`netcanon/api/routes/configs.py`** — Added module logger; all three endpoints now
  emit structured log records (`DEBUG` for list/get, `INFO` for delete success,
  `WARNING` for 404 paths).
- **`netcanon/api/routes/definitions.py`** — Added module logger; reload endpoint
  logs loaded count and source directory at `INFO`.
- **`netcanon/storage/file_store.py`** — Added module logger; `save()` logs filename
  and byte count at `INFO`, `list_configs()` at `DEBUG`, `delete()` at `INFO`.
- **`netcanon_desktop/app.py`** — Lifecycle events (start, server ready, quit, window
  closed) logged at `INFO`.
- **`netcanon_desktop/tray.py`** — Added module logger; `run_detached()` at `DEBUG`,
  Show/Quit callbacks at `DEBUG`/`INFO`, `stop()` exception swallowed at `DEBUG`
  (was silent).
- **`netcanon_desktop/window.py`** — Added module logger; `create()` and `start()` at
  `INFO`, show/hide/destroy at `DEBUG`, `on_closed` callback exception at `DEBUG`
  (was silent).

### Tests (logging)

- `tests/unit/test_logging_config.py` — 17 new unit tests across three classes:
  `TestConfigureLoggingBasic` (handler type, levels, idempotency),
  `TestFileHandler` (rotating handler, directory creation, write-through),
  `TestNoisyLoggerSuppression` (third-party loggers capped at WARNING, netcanon.*
  left at NOTSET).  `reset_root_logger` autouse fixture restores root logger state
  after each test.

---

### Security

- **Credential encryption at rest** (`netcanon/security/credentials.py`) —
  Device passwords and enable passwords are now encrypted with Fernet
  symmetric encryption before being written to disk.  The key is stored in
  the OS secure credential store (Windows Credential Manager / macOS Keychain
  / Linux SecretService) via the `keyring` library.  Existing plaintext
  profiles and schedule device lists are automatically migrated to encrypted
  storage on first load.  In-memory model objects always hold plaintext;
  encryption is a storage-layer concern only.
- **Path traversal protection** (`netcanon/storage/file_store.py`) —
  `resolve_path()` now rejects any filename that does not match the expected
  naming convention regex before touching the filesystem.  Both the
  subdirectory and flat-fallback paths are verified to lie inside the storage
  root via `Path.resolve().is_relative_to()`.
- **Open-in-editor extension whitelist** (`netcanon/api/routes/configs.py`) —
  `POST /api/v1/configs/{filename}/open` now checks the file extension against
  an explicit allowlist (`{.cfg, .conf, .txt, .xml, .log}`) and returns 400
  for any other type, preventing the OS handler from being invoked on
  executables or other unintended file types.
- **Host field validation** (`netcanon/models/device.py`,
  `netcanon/models/device_profile.py`) — `DeviceTarget.host`,
  `DeviceProfileCreate.host`, and `DeviceProfileUpdate.host` now validate
  against `ipaddress.ip_address()` or an RFC-1123 hostname regex.  Shell
  metacharacters, path separators, and other invalid values are rejected
  with HTTP 422.
- **Passwords removed from HTML DOM** — `data-password` /
  `data-enable-password` attributes removed from the Dashboard
  `<option>` elements (`index.html`).  Credentials are fetched via
  `GET /api/v1/devices/{id}` when a saved device is selected.  The
  `data-profile` attribute on Devices page cards (`devices.html`) no
  longer includes credential fields; `runDeviceBackup()` fetches the full
  profile from the API on demand.
- **Data directories added to `.gitignore`** — `devices/`, `schedules/`,
  `jobs/`, and `configs/` are now excluded from version control to prevent
  credential-bearing files from being committed.
- **`cryptography>=41.0.0` and `keyring>=24.0.0`** added to
  `requirements.txt` and `pyproject.toml` dependencies.
- **`SECURITY.md`** — new document describing the security architecture,
  threat model, implemented controls, and known limitations.  Must be kept
  up-to-date with any security-relevant change.

### Tests (security)

- `tests/unit/test_credentials.py` — 18 tests covering key initialisation
  (first run, cached reload, idempotent), `encrypt`/`decrypt` round-trip
  (empty string, unicode, uniqueness per call), `InvalidToken` on garbage
  input, and `decrypt_field()` migration helper (encrypted→True,
  plaintext→False, empty→False).
- `tests/unit/test_storage.py` → `TestResolvePathSecurity` — 7 tests
  covering `../` traversal, `.cfg`-suffixed traversal, absolute paths,
  subdir-relative paths, empty string, and a positive case asserting the
  resolved path stays inside the storage root.
- `tests/unit/test_models.py` → `TestDeviceTarget` — 7 host validation tests:
  IPv4, IPv6, hostname accepted; `../`, `/`, space, semicolon rejected.
- `tests/integration/test_configs_api.py` → `TestOpenConfig` — 2 new tests
  for extension whitelist (`.exe`, `.zip` → 400).
- `tests/integration/test_configs_api.py` → `TestPathTraversal` — 4 new
  tests: `../../etc/passwd` GET/DELETE → 404, `.cfg`-suffixed traversal →
  404, absolute path → 404.

### Added (device profiles)

- **`DeviceProfile` model** (`netcanon/models/device_profile.py`) — stores
  profile metadata: `id` (UUID), `name`, `type_key`, `host`, `port`, `username`,
  `password`, `enable_password` (optional), `notes` (optional), `created_at`.
  `DeviceProfileCreate` and `DeviceProfileUpdate` companion models.
- **`FileDeviceProfileStore`** (`netcanon/storage/device_profile_store.py`) —
  persists profiles as JSON under `{data_root}/devices/{id}.json`.
- **`GET/POST /api/v1/devices/`** and **`GET/PUT/DELETE /api/v1/devices/{id}`** —
  full CRUD for device profiles.
- **`GET /devices`** — Devices page listing all profiles as collapsible cards.
  Each card shows name, type badge, host, backup count, and actions (▶ Backup /
  Edit / Delete).  Expanding the card reveals a per-config history table.
  Inline edit panel (`device-edit-panel`) allows credential updates without
  leaving the page.
- **Dashboard — saved device select** (`data-testid="device-profile-select"`) —
  selecting a saved profile pre-fills all form fields.  Optional "Save as Profile"
  name input (`data-testid="device-profile-name-input"`) creates or links a profile
  when the backup form is submitted.
- **`ConfigRecord.device_profile_id`** — new optional field linking a stored
  config to the device profile that produced it.  Persisted as a sidecar
  `{filename}.meta.json` alongside each config file; sidecar is cleaned up on
  delete.  `list_configs()` reads sidecars to populate the field.
- **`BackupSchedule` — two-pronged targeting** — `target_type_keys: list[str]`
  (back up all profiles of matching types) and `target_device_ids: list[str]`
  (back up specific profile UUIDs); mix is permitted.  Inline `devices` list
  retained for backward compatibility.  `ScheduleCreate` validates that at least
  one target field is non-empty.
- **`GET /devices` nav link** added between Dashboard and Jobs.
  Order: Dashboard | Devices | Jobs | Schedules | Configs | Definitions | API Docs.

### Fixed (View / Download buttons — WebView compatibility)

- **`base.html`** — Added shared `viewConfig(filename)` function (fetches config
  and displays it in a new inline modal), `downloadConfig(filename)` function
  (blob-based download, works in Qt WebEngine where `<a download>` is unreliable),
  and `closeConfigViewer()`.  New config viewer modal (`#_config-viewer`) added to
  the base layout; closes on backdrop click or Escape key.
- **`configs.html`** — View (`config-view-link`) and Download (`config-download-btn`)
  changed from `<a target="_blank">` / `<a download>` to `<button>` elements
  calling `viewConfig()` / `downloadConfig()`.  Added `DOMContentLoaded` hash
  handler: navigating to `/configs#{filename}` scrolls to the matching row,
  briefly highlights it, and auto-opens the viewer modal.
- **`jobs.html`** — View (`job-config-view-link`) changed from
  `<a href="/api/v1/configs/…" target="_blank">` to `<a href="/configs#{filename}">`
  so clicking View on a job result navigates to the Configs tab with the file
  pre-selected.  Download (`job-config-download-btn`) changed from `<a download>`
  to `<button onclick="downloadConfig(…)">`.
- **`devices.html`** — Same View / Download fix as `configs.html` applied to the
  per-device config history table.

### Fixed

- **`configs.html`** — Post-delete empty-check used CSS selector `.config-row`
  (no such class) instead of `[data-testid="config-row"]`, causing the page to
  reload after *every* deletion rather than only when the last config was removed.
- **`base.html`** — Removed orphaned `.badge-success` CSS rule that duplicated
  `.badge-completed` and leaked device-result vocabulary into the job-level badge
  namespace.

### Added

- **`POST /api/v1/definitions/reload`** — New API endpoint that re-reads all YAML
  files from `definitions_dir` and replaces the in-memory registry without a server
  restart.  Returns `{ "loaded": N, "type_keys": [...] }`.
- **Definitions page** — "↻ Reload" button (`data-testid="def-reload-btn"`) that
  calls the new reload endpoint and refreshes the page on success.
- **Configs page** — "View" link (`data-testid="config-view-link"`) and download
  button (`data-testid="config-download-btn"`) are now separate explicit actions in
  the Actions column.  The filename cell is now plain text.
- **Toast notifications** (`data-testid="toast"`) — Global `showToast(msg, type)`
  function in `base.html` replaces all `alert()` calls with a non-blocking,
  auto-dismissing notification (4 s timeout).  Types: `info`, `success`, `error`.
- **Inline job results** — After a backup job completes, per-device results
  (host, type, success/failure, error message) are rendered directly in the status
  banner.  The recent-jobs table row is injected by JS; no full-page reload occurs.
- **Active nav state** — Current page is highlighted in the navbar
  (`class="active"`, `aria-current="page"`).  `active_page` context variable added
  to all three UI route responses in `main.py`.
- **UTC timestamp localisation** — All `[data-utc]` elements are converted to
  browser-local time on `DOMContentLoaded` via a global script in `base.html`.
  Server-rendered fallback (UTC string) is preserved for non-JS contexts.
- **Enable Password conditional visibility** — The Enable Password field is shown
  only for device types where `connection.needs_enable` is `true`.  Driven by
  `data-needs-enable` attributes on `<option>` elements; toggled on type change.
- **Port collapsed to Advanced** — The SSH port field (default 22, rarely changed)
  is now inside a `<details>` summary labelled "⚙ Port", reducing visual noise in
  the backup form.
- **Inline delete confirmation** — The Delete button on the Configs page now shows
  an in-row "Delete? Yes / No" prompt instead of the browser's native `confirm()`
  dialog (which can be suppressed in embedded WebView contexts).
- **Empty-state guidance** — All three pages now include actionable text in their
  empty states rather than bare declarative messages.

### Changed

- **Nav brand** (`data-testid="nav-brand"`) changed from `<span>` to `<a href="/">`
  so clicking the product name navigates home, per standard convention.
- **Submit button** (`data-testid="submit-backup-btn"`) is now disabled and labelled
  "Running…" while a backup job is in flight, preventing double-submission.
- **Polling error handling** — The job-status polling `setInterval` now counts
  consecutive fetch failures and stops after 3, showing a toast instead of silently
  looping forever.
- **Jobs table** — "Devices" column removed (redundant with "Success / Total"
  denominator).  "Job ID" column is now plain text (`data-testid="job-id-text"`)
  rather than a link to the raw JSON API response.  "Created (UTC)" header
  simplified to "Created" (timestamps are localised by JS).
- **Configs table** — "Captured (UTC)" column header simplified to "Captured".
  Filename column is now plain text; view/download actions moved to the Actions
  column.
- **Definitions table** — "Strategy" column renamed to "Collection"; strategy
  values are now human-readable ("SSH (Netmiko)", "SSH (Shell)") rather than
  internal Python identifiers.  "Ext" column header renamed to "File Ext".
  Notes cell gains a `title` tooltip showing the full (untruncated) text.
- **`button:disabled`** CSS rule added to `base.html` — disabled buttons now show
  `opacity: 0.6` and `cursor: not-allowed` globally.
- **E2E test** `test_submit_completes_and_page_reloads` renamed to
  `test_submit_completes_and_shows_job_in_table` and updated to assert that the
  jobs table becomes visible via JS injection (no `wait_for_load_state` needed).
- **Remove device button** gains `aria-label="Remove this device"` for
  accessibility.

### Tests

- `tests/integration/test_definitions_api.py` — Added `TestReloadDefinitions`
  (5 tests): 200 response, loaded count, type_keys list, post-reload registry
  accessibility, idempotency.
- `tests/testid_reference.md` — Updated for all new/changed testids: `toast`,
  `job-id-text` (replaces `job-link`), `config-view-link` (moved to Actions),
  `config-download-btn`, `config-delete-confirm-btn`, `config-delete-cancel-btn`,
  `def-reload-btn`.  Notes added for conditional visibility and `data-utc`.

---

## [0.1.0] — initial release

- Multi-vendor SSH configuration backup via Netmiko and Paramiko Shell strategies
- FastAPI + Jinja2 web UI: Dashboard, Configs browser, Definitions viewer
- Windows desktop shell: PySide6/QtWebEngine window, pystray system-tray icon,
  embedded Uvicorn server (`netcanon_desktop`)
- cx_Freeze MSI installer (`setup_desktop.py`)
- Four-layer test suite: unit, integration, E2E (Playwright), desktop
