Ctrl K
ComunidadGuías…

ARCHITECTURE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# InfraNet Pro — Architecture

> The mental model, not a line-by-line reference. Read this first; then the code
> reads itself. For the full feature manual, see the PDF in this repo
> (`MANUALE_TECNICO_IT.pdf` / `TECHNICAL_MANUAL_EN.pdf`).

InfraNet Pro is a **self-hosted L1/L2 network documentation tool** — racks,
cabling, ports, VLANs, MAC/FDB, LLDP/CDP topology, SNMP discovery — with a
visual, commercial-grade UI. Vanilla JS frontend, Node/Express backend, JSON
file storage. **Minimal tooling** (a lightweight esbuild bundle for the frontend,
introduced by the in-progress ESM migration — see §10), **no framework.**

---

## 1. Non-negotiable principles

These are deliberate. Don't "fix" them without understanding why:

1. **Minimal build.** Pure `lib/*.js` stay plain UMD-lite (no transpile, Node
   tests import them as-is). The glue layer is being migrated to ES modules bundled
   by **esbuild** (`npm run build` → `dist/app.bundle.js`) to kill the implicit
   global coupling + `typeof` guards. **The JS strangler is complete:** all
   `lib/app-*.js` glue **and** the nucleus (`src/app.js`) are now ESM in the bundle.
   The only remaining classic `<script>`s are the pure `lib/*.js` and `export.js`
   (by design — golden lib-script rule). See §10.
2. **Zero esoteric dependencies.** Backend uses only Express, bcryptjs,
   express-session/rate-limit, net-snmp, pdfkit. Tests use **`node --test`
   only** — zero test dependencies. This is a point of pride and a selling point.
3. **`lib/` pure + glue.** Reusable logic lives in pure, testable modules in
   `lib/`. The glue (now ESM modules in `src/app-*.js`, bundled by esbuild) wires
   that logic to the DOM. (See §3.)
4. **Manual-first** ([ADR](docs/adr/manual-first.md)). User-entered data always
   wins; SNMP/discovery never silently overwrites a manual value.
5. **Localhost-bound.** The server binds `127.0.0.1` only. It is a LAN tool that
   runs *inside* the network it documents, not a public service.

> **Why these exist — decision records.** The *why* behind the rules that make the
> tool trustworthy lives in **[`docs/adr/`](docs/adr/)**: the data-integrity
> principles [manual-first](docs/adr/manual-first.md),
> [no-invention](docs/adr/no-invention.md) ("InfraNet computes, the AI narrates"),
> [vendor-neutral](docs/adr/vendor-neutral.md) ("build for every vendor, the lab
> only validates") and [measured-not-declared](docs/adr/measured-not-declared.md),
> plus architectural decisions such as [pure `lib/` modules](docs/adr/pure-lib-modules.md)
> (ADR D4). Code comments cite these by tag (`paletto #2`, `ADR D4`, …); every tag
> resolves from [`docs/adr/README.md`](docs/adr/README.md).

---

## 2. File map

```text
server.js              Express bootstrap: static files, auth, routers, listen (127.0.0.1)
auth.js                Sessions, bcrypt login, roles (admin/viewer), user CRUD
server/                Backend (CommonJS): projects-store, netscan, classify,
                       pdf-report, label-sheet, routes/{projects,discovery,export,ai,skins,device-types,organization}
server/scan-target.js  The ONE reading of an address that is about to become traffic — scan targets,
                       crawl seeds and neighbours, poll hosts, reachability lists. `_parseIpv4Int`
                       decides, and what goes on the wire is the CANONICAL form: the same string is
                       checked and sent (`ping.exe` reads `010.8.8.8` as octal, i.e. 8.8.8.8, while the
                       project deliberately reads it as 10.8.8.8). Multicast, broadcast and 0.0.0.0/8
                       are never a host: a community sent to a group reaches the whole segment.
server/organization-store.js  data/organization.json — ONE organisation per installation,
                       never inside a project (a copy in each would be the same fact twice).
                       Atomic write + .bak; a corrupt file is read from the .bak, and only if that
                       is unreadable too does it restart from empty — never invented.
server/routes/organization.js  GET (open) returns the organisation with its coherence audit in
                       one call; PUT (admin) re-normalises the body and reports what was written
                       and what was refused. The reasoning is in §4 (data flow) and §8.
server/routes/projects.js  Project CRUD, and the two halves of an honest save: a file-derived
                       ETag (never `updated_at`) refuses a superseded PUT with 409, and a DIRTY
                       EPOCH (src/app-core.js) keeps the unsaved signal lit for an edit made mid-save.
                       No If-Match = old behaviour, on purpose (imports/scripts/benches). See §4.
server/routes/device-types.js  GET /api/device-types -> data/device-types.json: native templates
                       (ports + frontPanel) from CC0 data via tools/import-device-types.js. "Apply
                       model" feeds the DEFAULT rack renderer — no new drawing. See tools/README.md.
server/ai-config.js    AI assistant config: enabled/endpoint/model/key + scope/features
                       (data/ai-config.json git-ignored; key server-side only, env INFRANET_AI_KEY)
server/ai/             AI assistant: context.js (sanitized facts, scope-aware, secret-filtered —
                       see §8b), prompt.js (grounding it/en + §4c help catalog), provider.js
                       (OpenAI-compatible, node:https, zero-dep); routes/ai.js wires them.
server/module-registry.js  Generic paid-module seam: loadModules mounts modules/<name>/server if
                       present (modules/ gitignored, private repo, in-process); getNav feeds
                       GET /api/modules; onProjectDelete cleans a module's sidecars. Core-agnostic.
drivers/snmp.js        SNMP v1/v2c/v3 driver
engine/                sysObjectID + OUI classification engines (plugin loaders)
plugins/               Seed vendor catalogs (zero database)

lib/                   Shared browser + test modules (the heart of the app)
  i18n.js              t(key,vars), it/en dictionaries, glossary  (pure)
  provenance.js     The envelope: how a value is known. `declared` (a person wrote it, and
                    it never ages — a decision does not expire), `measured{at}` (read from a
                    device at a stated instant), `derived{from}`. A bare value is NOT promoted
                    to `declared` for convenience, and a measurement whose timestamp cannot be
                    read stays undated instead of being stamped with the current time. The age
                    scale is a REQUIRED argument, never a silent default: `proof.js` (6h/7d/30d)
                    and `temporal-confidence.js` (30d/60d) answer different questions and
                    legitimately keep different half-lives. Composes with source-ref.js
                    (epistemics vs identity), so there is no fourth `imported` origin.
  certainty.js      ONE ALPHABET for "how much do I trust this", over engines that stay
                    separate. Six signs — measured / declared / derived / contradicted /
                    undeclared / unread — and a pure map from each engine's REAL keys onto
                    them (proof.js, linkstate.js, temporal-confidence.js, the Overview's
                    `prov`, presence.js). ⛔ It does NOT merge the engines: provenance.js is
                    right that they keep legitimately different half-lives, and each one
                    still computes exactly what it computed. What is unified is the WORD.
                    ⚠️ And no colour is decided here — the grade is semantic, the ink comes
                    from the stylesheet's tokens (`.cty-<grade>`); a guard greps this file
                    for a hex literal. Keys that are NOT a certainty are declared as such
                    (`NOT_A_CERTAINTY`) rather than left unmapped, because silence would not
                    distinguish "different axis" from "forgotten": `lag` says what a link
                    IS, and Discovery's high/mid/low say how STRONG a guess is. The
                    reasoning is in §4 (one question, one alphabet).
  inter-site.js     The multi-site layer, ABOVE the per-site projects: an organisation with
                    its sites (each a projectRef — a reference, never a copy), WAN uplinks and
                    inter-site links over TWO closed vocabularies, because one field held two
                    questions and an IPsec inside an MPLS had to drop one of them: `transport`
                    (internet/mpls/vpls/vpws/vxlan/evpn/directLink/other) says what it travels
                    ON, `tunnel` (none/ipsec/gre/wireguard/openvpn/l2tp/sdwan/other) says what
                    runs on TOP; each carries `other` + a free label as a DECLARED escape hatch,
                    never an open string. An out-of-vocabulary value on either axis becomes null
                    and does NOT drop the row (the old `kind` DISCRIMINATED the union, so refusing
                    was right there; these two are optional like `role` or `state`, and losing a
                    whole link over one crooked word would be a cure worse than the disease); a
                    document still written with `kind` migrates onto the axis it belonged to, and
                    only when NEITHER axis is already set, so a migrated file is never touched
                    twice. `reach` — which subnets a link makes reachable at each end — is
                    one concept for every nature (on an ipsec it IS the encryption domain), so no
                    vendor-specific word and no viewpoint-dependent local/remote; and so is
                    `underlayUplinkIds`, WHICH WAN LINES CARRY IT — the recovery question, not an
                    sdwan one, which is where it used to live.
                    vendor's word and no viewpoint-dependent local/remote. Both ends carry the
                    device that holds them (a ref into the site's project, OR a hand-typed name
                    — mutually exclusive), because on an MPLS the end is the CE; and so do
                    `provider`/`circuitId`, for the same reason — every kind is bought from
                    someone under a code, and that code is what you dictate when it is down;
                    and so does `name`, because "what is this link called" is one question too
                    — «GRE-LAB» is the name, «GRE» is the nature, and the map draws the second.
                    `publicIps` is
                    a LIST: a business line comes with a routed block, IPv6 rides the same line
                    and an HA pair exposes several. An unknown transport or tunnel drops to null
                    and is SAID, never corrected into a neighbouring value.
  inter-site-audit.js  Coherence of the declared multi-site model — no network, no discovery.
                    INCOHERENCES (something is wrong) and GAPS (nothing is broken, but you
                    cannot answer) in separate lists, plus `notChecked`: every check that
                    could not run leaves its name and its reason, the same discipline as
                    ipam-audit.js — an empty list must mean "I looked", never also "I didn't".
                    Which check is of which kind is said by `INTER_SITE_AUDIT_PROBLEMS` /
                    `INTER_SITE_AUDIT_GAPS`, which are code and not prose: a sentence here
                    that counted them would go stale the next time one is added, and this one
                    did — it said five and five while they had become nine and eight.
                    Among the incoherences: an address declared PUBLIC that is not one
                    (RFC1918, CGNAT, loopback…), classified by `addrScope` (cidr.js) off the
                    IANA registry. Documentation ranges are deliberately exempt — they are
                    what the field's own hint suggests writing.
  inter-site-layout.js  Where each site goes on the map, as coordinates: no SVG, no DOM, no
                    strings — which is what lets the browser and the PDF export (and the coming
                    draw.io one) draw the SAME map. Deterministic and physics-free (a graph that re-settles
                    on every open cannot be compared with yesterday's or printed twice alike);
                    the shape follows the DECLARED role, and zero hubs or two fall back to a
                    ring rather than picking one for you. Labels are deliberately NOT measured
                    (a pure module cannot know a font) — the renderer measures the drawn SVG
                    with getBBox() and hands back BOTH rulers: the real box sizes and the gap
                    the longest edge label needs. The ring radius clears three things, not one:
                    two neighbours on the ring, the HUB IN THE MIDDLE (a third box nobody was
                    comparing — with real box sizes the hub and its spokes overlapped by 122px
                    and the link vanished under them), and the label that has to fit between
                    them. Separation uses the axis test (ONE axis apart is enough), never the
                    sum of the two boxes' radii along the line — that shortcut leaves a
                    wide-flat and a tall-narrow box overlapping at 45°.
  inter-site-report.js  The WAN chapter of the dossier, as DATA: one row per WAN line and per
                    link, with what it takes to rebuild them after an incident. Codes only, no
                    words (the glue translates — same split as pdu-report.js). What is missing
                    stays `null` and is COUNTED in the totals: a line with no circuit id is the
                    finding, not a hole in the layout. A ref that does not resolve says which
                    of five things happened (linked / typed / missing / unreadable / none), and
                    "not found" is never said without having looked. Carries the organisation
                    it was built from, so the map comes from the SAME coordinates as the panel.
  inter-site-svg.js  That map as printable SVG: white ground DECLARED as a rectangle, every
                    colour an attribute — the panel's SVG wears CSS classes and follows the
                    THEME, so through svg-to-pdfkit it would print as black shapes, or as a
                    dark theme's light text on white paper. Vector, never a raster. Words
                    arrive already written and the text is measured OUTSIDE (the PDF measures
                    with its own engine, which is inter-site-layout.js's ④ ruler). No glyph
                    outside CP1252: the hub is marked with a WORD, because a standard PDF font
                    draws a glyph it lacks wrong instead of substituting it.
  xml-escape.js     The one XML escaper (5 chars, XML entities). Separate from escapeHTML —
                    different context — but ONE, shared by drawio-export.js and
                    inter-site-svg.js: the second XML format we emit would otherwise have been
                    an identical copy of the first. Pinned by test/html-escaping.test.js.
  project-schema.js  What every field of state/node/spec/port/link IS — document / measure /
                    derived / private / secret — and therefore what happens to it in a portable
                    export. A field a person can type in the UI stays a `document`: erring
                    towards document adds one field to an export the user made themselves,
                    erring towards measure DELETES somebody's work.
  cidr.js           IPv4+IPv6 prefix arithmetic; `addrFamily`/`addrKey` = the identity of
                    a single ADDRESS (both families canonical), `addrScope` = WHERE it can
                    live — `global`, or which special-purpose range says it cannot (RFC 6890,
                    a registry rather than a vendor list). For IPv6 it TRANSLATES `ipv6Class`
                    (ipv6.js) instead of re-deciding what `fe80::/10` is; the vocabulary
                    `ADDR_SCOPES` is derived from the tables, because the panel asks `t()`
                    for each scope by name. `segmentKey` = the segment
                    an address belongs to — the DECLARED prefix containing it (most
                    specific first), and only where nothing is declared the /24 (v4) /
                    /64 (v6) convention, stated here instead of assumed per file. One
                    definition each, used by l3-gateway/ipam-audit and by drift-snapshot,
                    project-networks, correlate, topology-plan, dhcp-lease: the same rule
                    written in two layers diverges, and it is always the incomplete one
                    that wins. A segment key is only comparable against one built from
                    the SAME prefix list — producer and consumer must be handed it  (pure)
  netnames.js linkstate.js correlate.js cabling.js
  topo-lines.js frontpanel.js stack.js ha-pair.js
  topology-plan.js  buildTopologyPlan / inferUnmanagedNodes / classifyIntermediary:
                    assemble tiered links from a Sync poll + infer a hidden multi-port
                    intermediary behind a 2–4-MAC access port and suggest its role
                    (subnet → gateway · virtual OUI → hypervisor · randomised MAC → AP ·
                    else switch) — the Sync flags the port as a shared L2 segment with
                    that suggestion; the user materialises it from the Shared L2 panel  (pure)
  power-mib.js wifi-spec.js cable-labels.js drift-report.js
  field-provenance.js  where a value came from: the `copiedFromRead` mark, `fieldSource`
                    (what a panel asks to draw "Measured" or "Declared") and the registry
                    of every field that a reading and a person can both write, each with
                    the mechanism that keeps the two apart  (pure)
  neighbor-type.js  what an LLDP/CDP neighbour IS, from what it announces: the capabilities
                    (LLDP BITS / CDP mask) and the platform string decide ap · voip · router ·
                    switch · server · pc …, and `via` says whether the choice was read
                    (`platform`, `caps`) or guessed (`default` = the historic switch). Also
                    the usable-IP rule for an announced address and the summary subtraction
                    of neighbours the Sync has just created  (pure)
  id-safety.js      an id is a name, not a piece of HTML: `isSafeId`, `findUnsafeIds`, and
                    `repairUnsafeIds` — rename the bad ids AND rewrite every reference to
                    them (values and keys equal to the old id; node ids are left to
                    `_normalizeProjectNodeIds`). The client repairs on load, the project
                    routes on save  (pure)
  host-name.js      `isJunkHostName`: a hostname that is a machine blob (UUID, MAC, serial)
                    versus a name a person chose (`dc-01`); the cut is the length of the hex
                    run, not its alphabet  (pure)
  vm-nics.js        a VM's network cards, read tolerantly (`vm.nics[]`, or the old flat fields
                    as one implicit card), and "is this machine already a VM?":
                    `buildVmIdentityIndex` / `findVmForRow` — ONE function that takes the whole
                    row (`probeOfRow`: MAC; hostname, NetBIOS name, name; ip, ip6, last seen IP,
                    `integration.host`) and answers with the MAC of any vNIC, then the name, then
                    the IP of any vNIC (`addrKey`, never the raw string; a `/24` is not part of
                    the address). Name and IP count only when the MAC does not contradict them (a
                    different MAC on the same IP is another machine, for the IPAM audit to flag),
                    and a name is also contradicted by an address that is not the VM's: a
                    physical "dc01" next to the VM "dc01" is not discarded. A name that nothing
                    confirms and nothing contradicts is `weak`, and Discover says so in the
                    tooltip. A next-hop MAC is not an identity — a gateway VM (the address a
                    prefix declares as its gateway) counts like a physical gateway. Discover, the
                    topology crawl, the Verify adoption, the LLDP/CDP neighbour and the drop onto
                    a host all ask it, so they cannot disagree  (pure)
  vm-presence.js    "does the network see this VM?" — what the Verify run writes in `vm.proof`,
                    next to the state a person declared and never over it. The same proof
                    vocabulary as the nodes (`proof.js`: proven / unverified / absent), from the
                    signals the run already collects: the sweep on the vNIC IPv4s, the sweep's
                    live ARP, and the MAC tables / router ARP-ND as memories. Answering is proof,
                    silence is not (a guest firewall, a suspended or booting VM all stay silent);
                    the only absence that counts is every address proved absent on the local
                    wire, and then a table memory does not bring the VM back (the measurement
                    beats the memory). `applyPresence` is the only writer: with "Follow VM state"
                    on it also sets `vm.state`, but only where it is empty or the Verify run wrote
                    it (`vm.stateAuto`) — a hand-set state is never touched, and `beforeStateWrite`
                    fires once, before anything is written, for the undo snapshot. `methodOfVia`
                    is the one "how was it seen" mapping, shared with the nodes  (pure)
  undo-history.js   the model of undo/redo — `record` / `undo` / `redo` / `canUndo` / `canRedo`
                    over a list of snapshots and an index — plus `shortcutAction` and
                    `isTextEditingTarget` (see "Undo: the snapshot is taken before the change")  (pure)
  concurrency-gate.js  `createGate({max, queue, waitMs})`: N in flight, M waiting, the rest
                    refused at once — what keeps a burst of password checks from adding up
                    CPU in a single process  (Node)
  verify-trend.js   trendVerifiche → reads the per-Verify timeline rows and answers
                    whether this environment is getting better KNOWN or standing
                    still (design principle 3). Three separate ratios, because the
                    buckets live in three worlds — consistent/stateDrift are per
                    PORT, macOrphan/unverified/ipChanged/identityDrift per DEVICE,
                    ghost/shut per CABLE — so a single «% confirmed» would divide
                    ports by devices. `unverified` sits INSIDE the presence
                    denominator, so narrowing the scan cannot raise the score; the
                    blindness share comes out beside it and blocks the verdict when
                    it grows. Only rows from the same instrument compare, a jump in
                    network size BREAKS the series, and a blind check leaves the
                    trend for the coverage curve. Reasons come out as CODES, never
                    prose — a sentence from a pure engine lands verbatim in the
                    other language. The queue reuses driftActionable, one definition  (pure)
  snmp-silence.js   snmpSilence → «silent to THIS key» is not «no SNMP». With
                    SNMPv2c a wrong community raises no error: the agent drops the
                    request and the probe times out, indistinguishable from a device
                    with no agent. States the FACT, and only where something
                    AUTHORITATIVE licensed the expectation — a neighbour that
                    declared it over LLDP/CDP, or the project documenting it with an
                    SNMP driver AND the host being alive. Outside those two, nothing:
                    a badge on every quiet host is noise. No vendor list anywhere  (pure)
  snmp-v3.js        The ONE list of what the driver speaks over SNMPv3 — levels, auth and
                    privacy protocols (project value, net-snmp constant, label), the
                    defaults the form already shows selected, and v3Params(cfg). The driver
                    builds its session from it and both panels (device, VM) render their
                    selects from it. An unknown name is an ERROR naming the field, never a
                    stand-in: the old `map[name] ?? SHA` showed up only as a timeout. Only
                    what the security level uses is checked. AES-256 has two key
                    derivations (`AES256` = Blumenthal, `AES256R` = Reeder): with a short
                    auth hash they differ, with SHA-2 they coincide  (pure)
  auto-monitor.js   The monitoring's rules, no DOM: the config of «Automatic monitoring» (one scheduler,
                    two depths — Light = SNMP data only, Full = a silent Verify — with the legacy
                    auto-poll / auto-verify fields migrated on read, never written), the SCHEDULER
                    itself (`createMonitorScheduler`, clock and timers INJECTED so it is tested with a
                    fake clock), and the client-side DEADLINES of a round: `snmpReadBudgetMs` (one SNMP
                    read) and `reachabilityBudgetMs` (the Verify's sweep). The server reads the sweep's
                    batch size and address cap from here too (`REACHABILITY_BATCH`,
                    `REACHABILITY_MAX_IPS`), because the budget depends on them — see §9, a round never
                    waits without a deadline  (pure)
  direct-connection.js  directConnectionPairs → which switches are linked DIRECTLY, from their
                    MAC tables (Direct Connection Theorem, Lowekamp et al. 2001: two ports are
                    linked when the MAC sets behind them are complementary, the two switches'
                    own MACs aside). Layer 5 of `_autoDiscoverLinks`, moved out of the glue
                    unchanged and pinned first. It reads the session FDB cache of every switch
                    ever polled, not only this round's, and the order of its pairs follows that
                    cache's keys — which matters at equal confidence  (pure)
  endpoint-attach.js  endpointAttachDecision → what to do with a leaf endpoint (PC, printer, UPS…)
                    once its MAC has been looked up in the FDBs: keep a MANUAL cable (the
                    resolver is not even asked), propose a `MAC` cable on the right port, or
                    reject with the reason — and flag the cable for pruning when the reason is
                    «uplink port» or «transit port» and the existing auto-cable was born from a
                    MAC (MAC, ARP-MAC, MAC+ARP; never LLDP or MAC-WALLPORT). Layer 4 of
                    `_autoDiscoverLinks`, decision only: the effects (counting the reasons,
                    proposing, reassigning `state.links`) stay in the glue. The resolver is
                    passed as a function on purpose — see §9, pruning order  (pure)
  port-descriptions.js  portDescriptionCensus → how many ports carry a description in the
                    «jack | text» format, where the text comes from (typed by a person: `desc`,
                    always wins; or read from the device: `alias`, i.e. ifAlias), and — when the
                    caller passes the jacks the PROJECT documents — whether the jack named in the
                    text EXISTS: `known` / `unknown` / `ambiguous`, by KEY (`jackKey`: lower
                    case, every run of spaces, dots, underscores and dashes becomes ONE dash;
                    separators are kept, so «PP11-4» is not «PP1-14»). A project with no jacks
                    documented gives NO verdict (`jackEvaluable: false`), not «unknown» for all.
                    An alias EQUAL to the interface name is the name itself, not a description
                    (VyOS fills ifAlias that way) — the same rule `portTip` uses, and the two must
                    stay equal. It does NOT say a port «declares the right jack»: that needs the
                    chain port → patch cord → panel → wall run → jack. Measured live on seven
                    devices: `|` survives all of them; the length does not (Cisco and Arista cut
                    ifAlias at 64, Aruba CX refuses the command, EXOS, VyOS and MikroTik return it
                    whole), and Aruba CX returns real UTF-8. Feeds the «Port descriptions» report
                    (src/app-port-desc.js: table + CSV, opened from the button at the foot of the
                    «Cables» row of the Dashboard, resizable columns, a row opens to show the whole
                    text). A report and NOT a Dashboard tile on purpose: the Dashboard has a fixed
                    layout of six tiles per column pinned by the E2E suite, and the convention is
                    optional — «0 of 330» is not a gap. The separator is a project DECLARATION
                    (`state.portDescSeparator`, absent = `|`). `adoptedDescription` decides what the AUTOMATIC adoption writes into the port `desc` on every SNMP read
                    (`_applySnmpBasePortFields` in src/app-snmp.js): an empty `desc` takes the read text; a `desc`
                    equal to the PREVIOUS alias is a copy of the device and follows it; anything else is a
                    person's and is never touched; an alias equal to the interface name is not a description;
                    an absent alias never erases. A `desc` equal to the alias reports its origin as measured  (pure)
  ansible-netos.js  vendorToNetworkOs → ansible_network_os from the documented
                    vendor + measured sysDescr (conservative; null on unknown)  (pure)
  backup-ref.js     validateBackupRef → the config-backup POINTER (never the
                    config); rejects embedded credentials + control chars  (pure)
  project-networks.js  deriveProjectNetworks (/24s from devices+leases →
                    covered/blocked/open) + annotateNetworksVerification (join with
                    the Verifica outcome: presence badge per subnet + non-verifiable
                    devices nested under their /24, absorbing the old bucket)  (pure)
  overview.js       buildOverview → the read-only Overview view's facts in three
                    columns (Document · Conformance · Expansion). Each row = number +
                    provenance (declared/measured/derived/none) + optional drill-down
                    items (with peer/tag/group/of for the glue to render as name/word/
                    tabs). Pure COMPOSITION of spare-ports/project-networks/hw-
                    capabilities/TYPES — no new measurement; every word lives in the
                    glue (src/app-overview.js), never in the lib. Each section also
                    carries a health rollup (ok/warn/bad + issue count) that drives
                    the at-a-glance verdict; the since-last-read delta is glue-side
                    (localStorage, per project — viewing never dirties state)  (pure)
  os-icon.js        resolveOsIcon → OS string / family / TTL / VM-guest / node-OS /
                    hypervisor-platform → which logo + brand colour, with a CONFIDENCE
                    gate: a specific logo only from an authoritative source (sysDescr /
                    manual / guest-OS / platform), a grey family glyph from a TTL hint,
                    nothing when unknown. Emits <use href="#os-…">  (pure)
  os-icon-sprite.js AUTO-GENERATED inline <symbol> sprite (20 logos): Simple Icons
                    (CC0, mono, currentColor) + VectorLogoZone (CC0) & dashboard-icons
                    (Apache-2.0) COLOUR marks (own viewBox/colours) + 3 original glyphs
                    (windows/hypervisor/netdev). Injected once by src/app-os-icons.js  (data)
  ai-grounding.js   extractEntities + checkGrounding (citations + anti-invention)  (pure)
  ai-draft.js       splitDraftBlocks (segments AI reply → text + Ansible draft cards)  (pure)
  onboarding.js     nextStep(summary) → deterministic «next step» chip (onboarding §4d)  (pure)
  health-alerts.js  computeHealthAlerts → deterministic problem alerts from SNMP telemetry (RAM/disk/ink/UPS)  (pure)
  ui-catalog.js     extractCatalog/catalogLines: derive UI help (buttons+tooltips) from HTML+i18n  (pure)
  ipam.js           computeIpamUsage incl. nextFree (next free host = «suggested IP»).
                    Three populations, not two: documented devices, active DHCP leases,
                    and the plan's RESERVATIONS — declared, on no device, and not free.
                    A reservation that already carries a device counts once, as the device  (pure)
  ipam-model.js     prefixes are first-class, the VLAN is an optional reference; migration
                    from the 2.8.x shape where the subnet was a field of the VLAN.
                    `prefix.reserved[]` holds the booked addresses: they belong to the
                    NETWORK, not to a device — a booked address has no device by definition  (pure)
  lag-audit.js      checkLagMembers → how each member is built (speed/VLAN uniformity);
                    checkLagPlacement → where they live and how many (single-member
                    bundle, members across devices that are not one logical switch —
                    the «one chassis?» answer is injected, since lib/stack.js owns it);
                    checkLagPair → LACP mode across the two ends  (pure)
  ipam-audit.js     buildIpamAudit → duplicate addresses (v4+v6, canonical) + overlapping
                    prefixes + addresses outside the declared plan + VLANs the document
                    carries and the plan never names (IPAM hygiene, doc↔doc). The VLAN check
                    compares against what was DECLARED — a name or a network — and never
                    against `vlanColors`, which fills itself in with every VLAN ever seen and
                    would make the check pass on every project; the site native is never
                    accused, being the floor. `isContainerPrefix` = the one answer to «is this
                    prefix a container», declared by hand or said by the DCIM, so a hierarchy
                    written by hand can stop being reported as an overlap;
                    ⭐ `notChecked[]` names every check that could not run, because an
                    empty list used to mean both «nothing found» and «never looked», and
                    an audit that says nothing is believed;
                    compareCidr = the one address-space ordering, shared with the panel  (pure)
  l3-gateway.js     buildL3Report → one row PER DECLARED PREFIX (not per VLAN): who routes
                    each network, both families. `byVlan` is the derived per-VLAN view for
                    the SVI binding, which is the only genuinely per-VLAN fact.
                    ⚠️ A VLAN with no network keeps a row of its own only when somebody
                    performed an ACT on it — gave it a name, or chose who routes it. A
                    colour is not an act: `vlans` arrives from `vlanColors`, which fills
                    itself in and which a brand-new project already carries five entries
                    of, so listing those printed «declared VLAN, no network» about VLANs
                    nobody had declared. Same warning ipam-audit already carried; the
                    palette itself stays whole, so a prefix citing VLAN 40 keeps its
                    colour — what narrows is who earns a row  (pure)
  lag-audit.js      checkLagMembers → LAG member consistency (speed/VLAN mismatch);
                    checkLagPair → LACP cross-end mode coherence (both-passive /
                    lacp-vs-static)  (pure)
  lag-reconcile.js  isLagEligibleType (active-only, no passive/pass-through) +
                    stripLagOnPassive + reconcileLagMemberConflicts (one member per
                    active port, manual-first) — LAG data hygiene on load + auto-link  (pure)
  dcim-site-proposal.js  after an import: is this project a SITE of the organisation?
                    Pure decision only (propose / link / conflict / multi / already),
                    so the wizard can offer it and the answer stays testable without a
                    server. It OFFERS: joining the organisation is a declaration, and an
                    import that registered itself would decide on the user's behalf. A
                    project born from more than one NetBox site is refused with the
                    reason — one site is one project.
  dcim-wan.js       NetBox CIRCUITS → the organisation's WAN lines. Pure: turns circuits
                    into CANDIDATES (it writes nothing and does not know the organisation).
                    One end at a site → uplink; two sites → inter-site link; a carrier
                    PROVIDER NETWORK stays an uplink and the cloud is SAID, because several
                    sites on one MPLS cloud are not pairs of connected sites and inferring
                    the adjacencies would invent N·(N−1)/2 links. The link `kind` is never
                    guessed from the circuit type (free text of that instance): it enters as
                    `other` carrying those words. Only ACTIVE circuits become candidates — an
                    uplink has no state field, so a `planned` one would be indistinguishable
                    from a line in service. The bandwidth that enters is the PORT one —
                    `port_speed` on the SITE-side termination, kbps → Mbps — because the
                    field is presented as «Port bandwidth»; the circuit’s `commit_rate` is
                    NEVER a fallback, since on the real archive `FW-VR-100M` sells 100 Mbps
                    over a 1000 Mbps port and the two are different numbers. Reads both
                    termination shapes (≤4.1 `site`/`provider_network`, 4.2+ `termination_type`)
                    and re-checks the scope row by row, because NetBox ignores an unknown
                    query filter and answers with the whole archive.
  dcim-vpn.js       NetBox's `vpn/` app (L2VPN + tunnels) → INTER-SITE LINKS. Pure, and the
                    twin of dcim-wan.js: circuits say what a site buys, these say what
                    binds it to the others. Here the kind IS translated — `l2vpn.type` and
                    `tunnel.encapsulation` are CLOSED NetBox vocabularies (vpls→vpls,
                    ipsec-*→ipsec), unlike a circuit type, which is free text of the
                    instance; anything with no counterpart enters as `other` with NetBox's
                    label. ⚠️ `outside_ip` and `peerIp` CROSS: the peer of A is B's outside
                    address. Roles hub/spoke give the topology, two peers give nothing
                    ("mesh" is a claim about the whole set). Multipoint is refused with the
                    reason rather than split into pairs. Where an end lives is injected
                    (`siteOf`), so the module stays pure. ⚠️ NetBox accepts `?site_id=` here
                    and IGNORES it (measured on 4.6.7): the belt is the only real scoping.
  subbar-stats.js   computeSubbarStats → sub-header numbers: doc completeness
                    (withIp/addressable), device count (`addressable`: rooms, wall
                    jacks and patch panels are out, and a device with several NICs
                    counts once — nodes, never MACs), SNMP health (ok/err/warn/none)
                    — same field defs as api-shape/app-drift  (pure)
  snmp-target.js    "Is this device polled over SNMP?", for the whole project:
                    `isSnmpDriver` / `hasSnmpDriver` (the driver alone: how a device is
                    configured), `hasSnmpTarget` (driver AND an address: who gets polled,
                    and the denominator of "N of N answer") and `snmpTargetHost` (the
                    address the poller calls: `integration.host`, else the node's IP). The
                    driver is read ignoring case and spaces, as the server does. Nobody
                    else asks `startsWith('snmp')` — `test/snmp-target-unica-definizione`
                    fails if they do  (pure)
  mac-class.js      What a MAC IS, for the whole project: `macKey` (canonical, to compare)
                    and `macFormat` (uppercase, to display); anything that is not a MAC
                    returns '' — a non-MAC used as a key is a string comparison in
                    disguise. Accepts colons, dashes, Cisco dots, bare 12 hex. Also
                    isVirtualMac/isRandomizedMac (BYOD, one merged OUI list);
                    sharedMacsInBatch (a MAC on ≥2 IPs = shared next-hop) + gatewayMacSet
                    (documented L3 gateways) → discovery skips by-MAC merge on those, no
                    gateway collapse. netnames/topology-plan/dhcp-lease/ai-grounding
                    delegate here; the Verify indexes with the injected normaliser, never
                    a bare toLowerCase — that holds only while every source spells a MAC
                    the same way  (pure)
  device-signatures.js  canonical sysObjectID→type table (OID_TYPE_VOTES; oidTypeVotes/
                    oidType/oidIsType) — single source read by the fusion scorer AND
                    the client _guessType (no OID drift)  (pure)
  discovery-mdns.js canonical mDNS(DNS-SD)+SSDP(UPnP) helpers: query build, wire-format
                    parse (DNS compression, SSDP headers, UPnP XML), service→type map
                    (vendor-neutral) + aggregateSweep. Drives server _mdnsSsdpSweep  (pure)
  radio.js          radio interfaces: pid/anchor/linkKind/seeds       (pure)
  power-groups.js   outlet GROUPS of a UPS/strip: two declared axes (switching:
                    switched|always · backup: battery|surge) + the group read from the
                    outlet NAME («Group 2 - Output 1», «Segment1_3», «Primary Group»,
                    «Non Programmable», «Surge Only») — one parser shared by the catalog
                    generator and the panel. DECLARED, never measured: RFC 1628 has no
                    outlet groups, each vendor keeps them in a private MIB  (pure)
  wifi-assoc.js     isWirelessInterface (ifType ieee80211 / name) + isUnicastMac (drops
                    broadcast/multicast/null ARP noise) + classifyFdbAssociations (FDB →
                    wireless vs wired) + collectWirelessClients (unifies FDB-L2 and ARP/ND-L3
                    neighbour signals, FDB wins) + resolveClientAssoc (BSS by VLAN) — drives
                    the auto-link's Layer 4c wireless associations (all-in-one, L3 APs/routers
                    and PC/SoftAP hotspots; L3 path gated on broadcasting an SSID)  (pure)
  vlan-trunk.js     carriedVlans + effLinkVlans (trunk derivato)       (pure)  …
                       (PURE only — the ex-`lib/app-*.js` GLUE now lives in src/)
src/app.js             Core bootstrap/nucleus: state init, init(), bindEvents, expose()
                       (ESM; imported 2nd in src/main.js after app-types). Cohesive helper
                       clusters extracted → app-ipam/app-cables/app-history/app-index/app-props-tabs.js
netmapper.html         The app shell + the <script> load order (authoritative)
styles/                Modular CSS (11 ordered partials + design tokens) — ex style.css; see styles/README.md
build.js               esbuild build of the frontend ESM bundle (dist/app.bundle.js)
src/                   GLUE migrated to ESM (bundled): _bridge, main, app-types (TYPES,
                       imported first), + all ex-`lib/app-*.js`
src/app-inter-site.js  "Sites and links": the multi-site layer with a face. Map (SVG from
                       lib/inter-site-layout.js) and hand entry in ONE panel — not two features:
                       a map nobody can populate stays empty forever, and a form with no map
                       never shows what it is for. Does NOT recompute the audit (it travels in
                       the route's answer) and ADOPTS the server's reply after saving, so a
                       subnet coming back canonical or a link being refused is visible.
                       Per site it can also READ THE WAN LINES from the DCIM (lib/dcim-wan.js
                       via POST /api/integrations/dcim/wan): additive like the networks button,
                       scoped by the project's own `state.source.dcim.sites` rather than by
                       whoever is looking, and everything it could not place is listed in the
                       panel instead of dropped.
src/app-org-context.js "Where am I": answers whether the OPEN project is a site declared by the
                       organisation (site.projectRef — read, never guessed by name), so the
                       sub-header can offer the step back up. Imports NOTHING on purpose: the
                       sub-header reads it on every render and the panel invalidates it after
                       saving, and importing either would close an ESM cycle — which in a bundle
                       surfaces as an `undefined` at runtime, not as a build error. Tells "not
                       loaded yet" apart from "not a site": announcing the second while the first
                       is true would flash a false statement on every cold start.
src/_bridge.js         Migration bridge: win.* read, expose() publish (sparirà a fine migrazione)
src/store.js           Shared mutable view-state behind a proxy (state/selId/… ex-win.*)
src/app-delegation.js  Delegated click/change/input listeners: data-act/change/input="key" → imported fn
test/                  node --test: pure-lib tests + smoke-app (vm + DOM stub)
```

---

## 2.1 Rack front-panel rendering

The native rack renderer is the final visual projection of a device's neutral
`frontPanel` state. The renderer keeps the main data rows, SFP/QSFP groups and
dedicated management slots separate, while interface identity and numbering
remain in the same model used by NetBox import and **Apply model**.

For high-density combinations, `_renderAllNow` adds the visual-only `dense-xxl`
class when an SFP block is present and either the main row has at least 20 slots,
the SFP groups contain at least 8 slots, or the combined row and SFP count reaches
28. The CSS then reduces inter-block gaps, cell dimensions and border overhead and
disables the historical fixed translations. It does not reorder ports, change
`frontPanel`, alter `state.ports` or change the vendor-neutral classification.

The regression scenario in `test/render-density.test.js` protects a 54-port device
with 12 SFP slots and one MGMT slot, asserting that all 54 data interfaces and the
dedicated management interface remain rendered.

## 3. The key pattern: pure lib + glue

Every non-trivial piece of logic is a **pure UMD-lite module** in `lib/`: it works
in Node (for tests) and in the browser (exposed as a global via `Object.assign`).

```js
// lib/example.js — PURE: no DOM, no globals, just data in → data out
(function (root, factory) {
  const api = factory();
  if (typeof module !== 'undefined' && module.exports) module.exports = api; // Node
  if (typeof window !== 'undefined') Object.assign(window, api);             // browser
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';
  function buildSomething(model) { /* pure */ return result; }
  return { buildSomething };
});
```

The **glue** (`src/app-*.js`, ESM) calls `buildSomething(...)` and turns the result into
HTML/DOM. **Logic is unit-tested in `lib/`; presentation is not.** When adding a
feature: put the *decision* logic in a pure lib with tests, keep the glue thin.

A **display** decision can be a pure lib too, and `lib/node-label.js` is the case worth
copying: it answers "how does this device read" — a readable part plus an address —
without ever touching `node.name`, which is a *declared* field. It exists because the
Discover import stores the IP as the name when no hostname is found, so the honest fix
was to derive the label from what is already measured (classified type + OUI vendor),
never to write a guess into the document. The point of putting it in `lib/` is that a
single implementation then serves **every** surface — floor plan, drift report, audit
log, L3 map, cable labels, dossier — through one glue function (`getNodeDisplayName`),
instead of each surface reinventing the fallback. Server-side consumers (`server/pdf-report.js`)
require it directly; `src/` modules import it as ESM (like `lib/ipv6.js`), which keeps
it off the Axis-A `win.*` ratchet entirely.

Recent example: `lib/drawio-export.js` (`buildDrawioXml`) builds the draw.io / mxGraph
rack export as pure data-in → data-out (native `mxCell`s, calibrated against
diagrams.net); its glue `exportDrawio()` and the menu wiring live in the classic
`export.js`, which is outside **both** bridge ratchets (§10) — so the new menu item is
wired with `addEventListener`, never an inline `onclick` (which would push the Axis-B
`MAX_INLINE_HANDLERS` ratchet), and reads the pure lib as a bare global (no `win.*`,
which would push the Axis-A `MAX_WIN_REFS` ratchet). The same lib also emits the
intra-rack cabling as native edges on one activatable draw.io layer **per VLAN** (named
with the VLAN name), routed in per-cable lanes + stagger so nothing overlaps — and it
stays pure: the VLAN number/name and the per-cable colour arrive as **injected helpers**
from the glue (`linkVlan`/`vlanName`/`linkColor`, mirroring the live view's resolution),
never read from globals. On the same layer it also emits a **cable table** (one row per
cable); each row carries a native draw.io **custom action link** (`data:action/json`)
that, in View/lightbox mode, persistently highlights the matching edge (a *Set Style* on
`strokeWidth`, radio-style, with the header as reset) and scrolls to it — the readable
way to isolate one cable without tracing lines. The page format is chosen **per rack**
from the content bounds: A4 portrait, auto-switching to A3 when a tall rack or long table
overflows A4.

The glue is now **ESM** (`src/`, bundled by esbuild) with explicit `import`/`export`
where ritirato, plus the transitional `window` bridge (`src/_bridge.js`) for what's
not yet retired. Only the remaining classic `<script>`s — the pure `lib/*.js` and
`export.js` — still share one global lexical scope. Beware of name collisions — see
Gotchas.

---

## 4. Data flow

A project is a single `state` object (see `_buildDefaultState()` in `src/app.js`):

```text
state = { schemaVersion, racks[], currentRack, nodes[], links[], ports{}, vlanColors{},
          vlanNames{}, ipam{prefixes[], vlans{}, addresses[]}, lagGroups{}, lagModes{},
          guestVlans[], … }
```

- **node**: `{ id, type, name, rackU, sizeU, ports, rackId, ip, ip6, ip6Manual, … }` — `ip6` is a **distinct** IPv6 field with the same manual-first padlock as `ip` (`ip6Manual`): the Sync auto-populates it from the device's own SNMP address (`ipAddressTable`), or Neighbour Discovery proposes it; never merged into `ip`, the IPv4 IPAM, or the Ansible host
- **link**: `{ id, src:'nodeId-portN', dst:'nodeId-portN', … }`
- **port**: `state.ports['nodeId-N'] = { status, speed, vlan, ip, … }` — `ip` is the address **of that interface**, not of the device: a router answers on one address per port, and while the model held a single address per device the second interface could only be recorded as a second device. `node.ip` stays the management address. Declared manual-first: stored as typed, with the panel flagging anything that is not an IPv4 rather than refusing it. Not offered on passive ports (patch panels, wall outlets), where copper passes through and no interface terminates

Mutation → render → persist:
`updateN(...)/setLinkProp(...)` mutate `state` → call `renderAll()` (or a scoped
`renderScope('props'|'cables'|'floor'|...)`) → `markDirty()` → `saveProject()`
serializes `state` to JSON via `PUT /api/<projectId>`.

Load path: `loadProject(id)` fetches the stored `state` and runs it through
`_migrateState()` (`src/app.js`) before it becomes the live model. `schemaVersion` is
written in the envelope and in the state; unknown future versions are preserved rather
than silently downgraded. Migration is idempotent and mostly additive (defaults for new fields, legacy VLAN/radio/link
repairs), with one structural step: `_normalizeProjectNodeIds` **canonicalizes
device IDs** that don't already match the `<type-prefix><n>` scheme (an imported
`core1` switch becomes `sw1`) and remaps every ID-embedding reference — link
`src`/`dst`, `ports` keys, and LAG identifiers. **Invariant:** a reference that
embeds a device ID must be remapped on *both* sides or it dangles — in particular
the port-side `ports[].lagGroup` and the `state.lagGroups` map keys go through one
shared `remapLagId` helper so they stay aligned across formats (`snmp-lag-…`,
`lldp-lag-a||b`, `lag-<id>-poN`). App-created projects already use canonical IDs,
so this is a no-op for them; it only reshapes imported/generated projects.

The server saves the main project with a temporary file, `fsync`, rename and `.bak`
fallback. ⚠️ **The temporary is named after the write, not after the process** — a name per
process meant every save of the same project inside one server shared one filename, and the
day any of that I/O became asynchronous two saves would have overwritten each other's
temporary before the rename, which being atomic would then have delivered a *valid* file
holding half of each. A unique name creates an obligation the fixed one did not have, so a
failed write removes its own temporary rather than leaving an orphan nobody collects.
And the fallback is **declared**: `readProjectFile(id)` says whether the
content came from the project file or from the last valid copy, and in the second case
whether the file was missing or unreadable. A GET reports it in a response header (never
in the body, which is the DTO the REST API v1 also serves) and the browser warns before
the older content can be saved back over the newer one; `loadProject(id)` stays the
plain one-value call for everything that has nobody to tell. The floor-plan image is a sidecar under `projects/assets/`; timeline and
snapshot history are separate files under `projects/history/<id>/`, written atomically
with `fsync` and pruned when a project is deleted. So is the **change journal** (`audit.json`),
which normally reaches the disk when the project is *saved*; the lines that record a **run**
rather than a change to the document (`RUN_ACTIONS` in `lib/audit-log.js`, today the Sync) are
sent at once to `PUT /api/projects/:id/history/audit`, like presence and observations — the
monitoring round is never saved by anyone, and with autosave off (the default) its lines died
with the page. The route accepts only those actions and merges with what is on disk
(`foldAudit`, a union without duplicates), so the same line arriving later with a save is not
doubled; a line that describes a change (a cable, a renewed IP) stays tied to the save. The browser JSON export is a portable
envelope, not a raw server backup: it redacts SNMP credentials and sanitizes backup
references before download, and the importer unwraps both this format and legacy state
files.

⚠️ **The project list is paid once per write, not once per read.** `listProjects()` used to
re-read and re-parse every project file on every call, synchronously, in the single server
process — and the per-site device counts on the multi-site map come out of that same call, so
the cost grew with the number of sites. Each row depends only on its own file, so rows are
kept and rebuilt when that file changes. ⭐ **Whether it changed is decided by the very two
facts `projectEtag` already trusts** to refuse a save when somebody has overwritten a project
underneath one in flight — last-written time to the millisecond, and size — read from one
place, so the weaker use can never disagree with the stronger one. `saveProject` drops its own
row directly, which covers the one case no signature can see (two writes in the same
millisecond at the same size), and the map is rebuilt each pass from the files that are
actually there rather than pruned, so a deleted project falls out by itself and the cache
cannot grow. Rows are handed out copied: a change of speed must not quietly change the
contract callers already had.

`renderAll()` (rAF-coalesced) rebuilds the rack chassis, floor, cables overlay and
the right panel. `renderProps()` dispatches by selection (`selType`/`selId`) to
`_renderNodeProps` / `_renderLinkProps` / `_renderPortProps` / `_renderFloorProps`.
At the tail of each rebuild it also refreshes the sub-header (`src/app-subbar.js`
`renderSubbar` → `#modern-subbar`: breadcrumb · active VLAN-filter chip · project
stats) — a bare-global typeof-guarded call, so no new `win.*` reference.

Floor nodes also carry a **presence overlay** derived from the last Drift report,
built on an **honest presence** model — *"no answer" is not "dead"*, so **red**
requires a signal a live host cannot suppress:
- **green** (no overlay) — any positive signal: SNMP answered, MAC in a switch FDB,
  an active DHCP lease, an ARP reply during the sweep, or the **router's neighbour
  tables** — the IPv4 ARP table (`ipNetToMediaTable`/`ipNetToPhysicalTable`, `snmpArp`)
  or the IPv6 Neighbor Discovery cache (`ipNetToPhysicalTable` IPv6 rows, `snmpNd`) —
  proving a device alive on a VLAN *behind* a router (green **across subnets**, no ping
  from the server needed; ND catches IPv6-only or ARP-aged hosts the ARP path misses).
  A positive signal always wins over any absence hint.
- **red** (`.node-absent`, bucket `macOrphan`) — only from `trustAbsentNodeIds`: a
  **local ARP-miss** (the `/api/reachability` sweep returns `absent:true` only for an
  IP on the server's own segment, by real netmask, that never appears in ARP after the
  ping) or a **switch access port down for ≥ N syncs** (the switch is authoritative on
  its own port's link; the down-streak is the anti-flap). Of these two, only the
  **local ARP-miss** needs the sweep, so it fires solely on **Verifica**; the
  **switch-port-down** signal needs no sweep, so a plain **Sync** *can* turn a node red
  once its down-streak matures (`portDownStreak` accumulates sync after sync). What a
  plain Sync never does is red a node that is merely **silent** — that stays grey.
- **grey** (`.node-unverified`, bucket `unverified`) — everything else: FDB ageing,
  host-filtered ICMP, a mute SNMP agent, a remote/unreached subnet. Honest "don't know".

**Virtual machines are not floor nodes and never reach those buckets.** A stopped VM is
not an anomaly, so `lib/vm-presence.js` reuses the *signals* of the same run (the sweep on
the vNIC IPv4s, its live ARP, the FDB and the routers' ARP/ND as memories) and not its
verdicts: it writes `vm.proof` with the nodes' proof vocabulary, next to the declared
`vm.state`. Answering is proof; silence is only "no answer"; the one absence that counts
is every address proved absent on the local wire, and then a table memory does not bring
the VM back. A contradiction with the declared state turns the VM's icon yellow
(`vmStateConflict`). Only "Follow VM state" (per project, off by default) lets the run
write `vm.state` — and only where it is empty or the run wrote it (`vm.stateAuto`).
`vm.proof` and `stateAuto` stay out of the portable export, like `node.proof`.

### Port state: shut by hand vs simply without link

`ifOperStatus` says whether a port has link; `ifAdminStatus` (IF-MIB `.7`, read since
2.9.2) says whether a person turned it off. The two are different facts — the first is
a symptom (device off, dead NIC, SFP pulled), the second a decision written on the
device — and the rack draws them apart *because* they are apart. A port in `shutdown`
keeps the near-black `--shut-color`: a decision asks nothing of anybody, and whoever
made it knows. No link across `DOWN_STREAK_N` verifies takes **amber** `--nolink-color`,
because an ambiguous symptom is precisely the thing somebody has to walk over and look
at. Everything unknown stays plain grey — a port nobody has measured is not a symptom.
Neither of the two glows: the live states glow because they are lit, these signal
without pretending to be.

⚠️ That amber is deliberately **not** `#f5a623`, the interface's generic warning amber
(AI chips, verification rows, overridden fields): two ambers that coincide get merged by
the eye and the colour stops saying anything. On the ports themselves there is now only
one amber, because the port state that used to own the other — `idle` — is gone. It said
four different things depending on the layer reading it, nothing consulted it to decide
anything, and the same SNMP reading that produced it also started the down-streak that
turned it into *this* amber three verifies later. A port switches, does not switch, or is
faulty; the two readings that had no colour (`testing`, `dormant`) survive as `operWait`,
a measurement printed in the port panel in the device's own words. And the hex is written
**three times** — the CSS token plus the
two exports, which build files outside the browser where a token does not exist — which
is this project's most expensive shape of defect, here landing on a PDF handed to a
customer with a port one colour on screen and another on paper. Unifying is impossible
(a `.css` cannot be imported from Node), so the second house recipe applies:
`test/porta-senza-link-colore.test.js` compares the three copies and refuses any two of
the five port states that resolve to the same value.

The rule is `portShade()` in **`lib/port-state.js`**, read by both renderers (the
generated front panel and the vector skin), by the port Properties, by the PDF dossier
and by the draw.io export — one definition, because a drawing that colours the same
concept with rules of its own is this project's most expensive class of bug. Three
invariants live there: the field **absent means "not known"**, never "port is up"
(hence `=== true`); a status **declared by hand wins the drawing** (a measurement that
contradicts it surfaces in the Verify, not by overwriting the pixel); and the reading is
**forgotten** by `forgetPortMeasure()` as soon as the switch stops confirming it, so a
strong assertion cannot outlive its evidence. `portMeasured()` (`operUp != null ||
adminDown != null`) is the one definition of "a port that was read", shared with
`lib/drift-snapshot.js`; `status` is NOT a measure — the poll, a drawn cable, the cabling
editor and a DCIM import all write it — so a cable seeds "active" without overriding a
measured port, and removing the last cable never deletes a measured status. "Free but
active" (`portActiveFromRead` in `lib/spare-ports.js`, one function for the rack overlay and
the Dashboard) asks `operUp === true`, never `status`.

On the cable side a **declared** cable over a shut port becomes `declared-shut`
(bucket `diverged`, badge "Port shut", Drift category `shutCable`) — never `ghost`,
which means "inferred, and its evidence evaporated".

An **inferred** cable over a shut port is not a ghost either, and this is the subtle
part: the down-streak that feeds both the `no-link` shade and `ghostCable` counts
consecutive verifies without link — but a port in `shutdown` has no link *by decision*,
which is not an observation about the cable. `nextDownStreak()` therefore returns 0
while `adminDown === true` (the same choice already made for a mute device: if we
cannot observe, we do not accumulate), `applyPollResult` clears the streak on the
true→false transition so a reopened port must re-earn its `no-link` over N real
verifies, and `buildDriftReport` skips a cable whose end is shut regardless of a
streak stored by an older save. Without this, shutting a port for a few verifies
turned every inferred cable on it into a ghost — 35% opacity and a sparse dash, which
on screen reads as gone — and reopening the port jumped straight back to the no-link
shade, on a streak matured for the wrong reason.

The streak counts a **measurement**, not `port.status`. That field has three writers
with three meanings — the user drawing a cable, the DCIM import, the poll — and when
`ifOperStatus` does not arrive it keeps the *previous* value, so the series advanced on
an observation nobody had repeated. `operUp` is therefore written beside `adminDown` by
the poll alone, with the same three states (`true` link, `false` down, **absent** not
measured) and the same expiry — `forgetPortMeasure()` drops them together. Absent
covers both ways a reading fails to exist: the agent answers `unknown(4)`, which RFC
2863 defines as "cannot determine" and which used to come out amber, and the interface
simply not appearing in the walk, after which `applyPollResult` forgets the measurements
of every mapped port that walk did not cover. A project saved before 2.9.2 carries no
`operUp` and accumulates nothing until the first poll writes it, which is exactly what
is known about it.

### A VLAN that was never declared

The SNMP reader used to close every interface with `vlan: f.vlan || 1`. A device that
publishes nothing about a port's VLAN — a Cisco vIOS answers neither `dot1qPvid` nor
`vmVlan`, measured — still produced a 1, and in the document that 1 was
indistinguishable from a measured one. Everything downstream treated it as a
measurement: `_getLinkVlan` returns the VLAN of an *active* port before it ever
consults the propagated one, so a VLAN declared by hand or propagated from upstream
could not prevail. It was not overruled, it was stepped over.

The field is now left **absent**, and the readers needed no change: they already fall
through to the propagated VLAN when it is missing. The client converter
`_snmpVlanToUi` had in fact documented this exact case for a long time and never got
the chance to apply it, because the driver handed it a 1 instead of nothing — two
layers holding opposite definitions of one value, with the lower one winning. When
reading these paths, the question to ask of any default is *who else believes this is
a measurement*.

Not inventing new ones turned out to be only half of it. `ports[pid].vlan` **is** the
measurement — the hand-written value lives in `vlanOvr` — but `_snmpVlanToUi` kept the
previous value whenever a poll had nothing to say about a port, which made every
invented 1 immortal: no later reading could remove it. It is forgotten now, on the same
ground `forgetPortMeasure` already forgets `adminDown`/`operUp` for ports a walk did not
cover. The two cases stay apart on purpose: here the walk *did* cover the port and the
device said nothing about its VLAN, which is stronger evidence than silence about the
port itself, so only this one drops the value.

The port description (`ifAlias` → `ports[pid].alias`) had the same two-layer shape, and
the same outcome. The reader wrote `alias: f.alias || ''`, folding *not read* and *read
empty* into one empty string, and `_snmpAliasToUi` answered that empty string with the
previous value — so a description removed on the switch never left the document, the
port panel, the PDF report or `lib/correlate.js`, which also resolves LLDP neighbours by
description. The reader now keeps three states on the wire (text · `''` read and empty ·
absent, not read), and the client drops the field in both of the last two cases: a
description the latest walk did not confirm is not a measurement any more. The price is
known and accepted — a walk truncated on that column costs the neighbour matching one
cycle — because matching on a stale description is the worse error. The hand-written
text lives in `desc`, a document field, and is never touched by a poll.

The third member of the family was not a fallback value but a **coincidence read as a
fact**: two or more LLDP/CDP adjacencies between the same pair of devices created an
inferred link aggregation, writing a `lldp-lag-…` group onto the four ports, forcing
`isTrunk` and filling the carried VLANs from the device's VLAN inventory. LLDP says
nothing about aggregation, and those same two parallel cables are equally the picture of
**spanning-tree redundancy** — one port forwarding, the other blocked with its link
still up, which is why no port state separates the two cases either. Aggregation, unlike
the guess, is measurable and already read (`ifStackTable`, IEEE 802.3ad member ports,
AttachedAggID → `ports[pid].lagId`), so the inference now fires only where one end
reports the aggregate, or a person declared it; that corroborated case still carries the
group across a peer whose SNMP is silent about LAGs, which is the reason the level
exists. A `lldp-lag-…` group is never evidence of itself — it is what this inference
writes — and one left behind by an earlier run is removed. What survives of the
observation is said, not written: the AutoLink diagnosis counts the parallel pairs it
refused to call a LAG.

⚠️ **The sequel is the part worth keeping: removing an invention does not install the
measure that replaces it.** On the bench the aggregation then disappeared completely,
because that measure never arrived from the Cisco switches. *Is this interface an
aggregator?* was asked in three places in `drivers/snmp.js` and answered differently: the
`ifStackTable` reader and the AttachedAggID cross-check demanded `ifType=161`, while the
final classification also accepted `53` (propVirtual) when the name was a bundle. IOS
declares `Port-channel` as 53, so the strictest answer won — the aggregator was listed in
`lags[]` and its members kept no `lagId`, a LAG with no members, which downstream is no LAG
at all. One predicate now answers for all three, and the name test is not a fourth copy of
the list: `lib/netnames.js` owns it. `53` on its own cannot be enough, because on that same
switch the SVIs are `53` too. Measured read-only over SNMP on the vIOS L2 bench:
`ifStackTable` is the only source those images offer — 802.3ad, CISCO-PAGP-MIB,
CISCO-LAG-MIB and the LLDP-EXT-DOT3 aggregation tables all answer zero rows. While the
inference was inventing, that hole was covered and nobody could see it.

The same repair made a second, smaller defect visible. The corroborated group was
registered before checking that any port would adopt it, so once both ends measured their
own bundle the LAG list kept a shell with no members — remade at every run, and therefore
impossible to delete by hand. It is now registered only where an endpoint is free to adopt
it, and an old shell is removed; only `lldp-lag-…` groups are touched, so nothing measured
or declared by hand can be lost that way.

### An id is a plate, not a fact

The Dashboard's LAG row read the ends of a group out of its KEY (`snmp-lag-<node>-<n>`,
`lldp-lag-<a>||<b>`), walking back from the last dash until a prefix matched a node id. It
worked for the two keys the app writes itself and for nothing else: a group a person
creates carries whatever id the app gave it, no node inside, so the end never resolved and
the row rendered with an EMPTY name. Six rows out of sixteen on the bench project, and the
list looked merely untidy rather than broken.

⭐ The ends are `ports[pid].lagGroup` — the one place they are a fact — so the glue now
passes `lagMembers` (group → the nodes owning a port in it) and the lib reads them from
there. A group with no member ports has no ends **because it is not a LAG**: it is a
leftover, and it says so (`noMembers`) instead of being given an end by inference. The
same move removed a third bucket that was hiding: a group made by hand is neither measured
nor derived but DECLARED, and it used to be counted among the derived ones.

### One notation, used as a filing system

Both detail lists divide into tabs, and the words are not new ones: `certaintyOf` maps the
`proof` and `linkstate` vocabularies onto the grades of `lib/certainty.js`, and the tab
label asks the notation for the word, so a tab and a pill can never disagree. For a cable
the proof state decides when a Verify has run — it is the more informed answer, and it is
where *contradicted* and the ghost live — and provenance decides otherwise; the tabs are
ordered by the notation's own reading order, from the most load-bearing sign to the least.
⚠️ Two consequences worth stating. The two verdict tabs are ABSENT without a Verify rather
than empty, because nobody can call a cable a ghost before looking at it. And the per-item
pill is gone from those lists: the tab already says that word, and saying it twice in one
row is the defect the single notation came to close — on the very surface that gave the
notation its vocabulary.

The cable list also stopped being a different population from its own row: it listed only
the inferred cables while the row counted all of them, so the row said 40 and the detail
opened on 15.

### What a cable is — and the eight places that used to answer

`_getLinkVlan` answers *what is the native VLAN of this link*, and that is correct; it is
also what `_getLinkTrunk` uses as the trunk native. The colour was asking it the wrong
question. On an access cable the native is the whole truth, but on a trunk it is one VLAN
among several and legitimately 1, so painting it says «VLAN 1» about a cable carrying four.

`lib/link-vlan-color.js` answers a different question — *what is this cable* — with four
outcomes, each saying one thing only:

| outcome | when | shown as |
|---|---|---|
| `vlan` | exactly one VLAN applies | that VLAN’s colour |
| `trunk` | it carries more than one | neutral + the carried VLANs as pills |
| `routed` | the port is declared L3, or measured as not being a port of the bridge: it routes | neutral, and the panel says why |
| `conflict` | both ends name a VLAN, with equal authority, and the two differ | neutral; the panel names both numbers, lists it among the cable’s problems at error level (`vlan-ends-disagree`, the access twin of `native-mismatch`), and the topology legend explains this second neutral |

⭐ The fourth does not describe the cable, it describes **us**: the document contradicts
itself, so there is no answer until somebody decides. The ladder used to take the first
end that spoke and paint its VLAN with `known: true` — a cable with 20 on one side and 30
on the other read «VLAN 20, set by hand», identical hex for hex to the case where the two
ends agree. On the wire an access link like that carries nothing: it was the only state in
which the drawing was *certain* and the network was broken. Falling through to the next
rung would have been worse still — the bottom rung is the site native, so a real
contradiction would have come out as a plausible number.

⚠️ Only between ends that hold the **same** authority, and only on access links. A PC does
not contradict a switch (it has no say — `lib/vlan-authority.js` already filters it out);
a declared value against a measured one is not a conflict but manual-first, and the place
to discuss those two is Verifica; and on a trunk `vlanOvr` is the *native*, whose
disagreement already has a name of its own (`native-mismatch`).

⭐ **A cable that switches always has a VLAN, so it always has a colour.** «VLAN not
declared» is not a state that exists in switching: every port of a bridge has a PVID, and
where nobody configured one that PVID is 1. Two claims, kept apart because they rest on
different things: the default PVID of 1 and the 1..4094 range are **standard** (`dot1qPvid`
carries `DEFVAL { 1 }` in RFC 4363, and the range is in the YANG IEEE publishes for
802.1Q-2022), while *VLAN 1 cannot be deleted* is a **multi-vendor fact** — EXOS ships a
«Default» VLAN with VID 1, Junos puts everything in access on the default, Aruba uses 1
untagged — and not a clause of 802.1Q. Measured on the bench, the Arista reports PVID 1 on
every untouched port and VLAN 1’s membership list contains exactly those: a list of members,
not a complement. So the last rung of the access ladder
is the **site native VLAN** (`state.nativeVlan`, 1 by default, declarable for a site whose
native sits elsewhere). It is deliberately last: one rung higher it would cover a real
answer with a plausible number. The provenance travels with the outcome and the panel
always prints it — the number alone would let a default pass for a reading.

⚠️ `routed` stays outside that floor, and it is the only case where a VLAN genuinely does
not exist: VLAN 1 is the floor of the *switching domain*, not of the universe. Three proofs
on three vendors, measured on the bench, and they are kept together because they close the
argument from both sides. Where nothing switches there is no floor: make a Cisco port routed
and the switch allocates it an internal VLAN from the extended range (1006–4094) rather than
putting it in 1, and the MikroTik edge router — no bridge ports, no VLAN table — has no VLAN 1
at all. Where something does switch the floor is really there, which is the Arista membership
list above. One of the three on its own reads as one vendor's behaviour; the set does not.

⭐ **On a trunk no VLAN wins.** Every rule for electing one was tried and each asserted
something untrue; the case that settles it is an interface doing management *and* VLAN 30 —
it has no answer, not a hard one. So a multi-VLAN trunk takes no colour, and the VLANs it
carries are shown together at equal weight. A trunk carrying exactly one VLAN keeps its
colour: there nothing is chosen, it is observed.

**Where that answer lives on screen.** All of it — the outcome, the port mode, the native
VLAN, the carried ones and the colour override — sits in one collapsible section of the
cable panel, the same `details.props-collapsible` every other section of the properties
panel uses, with the model's own verdict previewed in the head while it is closed. What
the section says once it does not say again: on a trunk the carried VLANs *are* the pills,
not a sentence repeating them, and the provenance line appears everywhere except a
multi-VLAN trunk — which asserts no VLAN at all, so it has no default that could pass for
a reading. The port **mode** is not in there at all: TRUNK/ACCESS is a property of the
link, so it stands with the badges that answer *what is this cable* — first among them,
before the state, the discovery protocol and the proof state.

⚠️ The VLAN field in that section is **editable, and writes an override onto the active
port**. It therefore carries only the *declaration*, empty when there is none, and shows
what applies as a placeholder. Pre-filling it with a fallback is the driver's «VLAN 1»
defect one layer up and in a worse place: there, one keystroke turns a stopgap into the
user's own assertion. A placeholder asserts nothing; a value does.

**A bundle's VLAN is declared once, and written to every member.** On real hardware a
Port-channel is configured once and the members inherit; members that disagree do not
aggregate at all, which is what `checkLagMembers` already warned about. The LAG row
therefore carries its own VLAN field, and `setLagVlan()` writes `vlanOvr` onto every port
of the group — an empty value clears them rather than writing 1, since «nothing declared»
and «declared VLAN 1» are different states. Writing it onto the members, rather than
holding it on the group, keeps two properties: no other layer has to learn what a LAG is,
and the coherence warning stays **reachable** if somebody later changes one port by hand.

⛔ The shortcut refused here is worth recording, because it looks elegant: letting an
undeclared member inherit from a sibling inside `propagateVlans()`. It would write a
number onto a port nobody declared — the defect this release exists to close — and it
would silence the one warning that catches a genuinely broken bundle. A test pins both
halves, including the fact that propagation does *not* inherit.

⚠️ **One VLAN means one in total, native included.** The count used to filter VLAN 1 out, so
a trunk with native 1 plus one tagged VLAN passed for «carrying a single VLAN» and took that
colour — while two VLANs cross that copper, since the native's untagged traffic goes over it
as well. Measured across the real projects: three cables, two of them access-point uplinks
carrying management untagged in VLAN 1 and the SSID tagged in 99. The filter was the common
practice («don't use VLAN 1») mistaken for a description of the wire. A known limit stays: a
VLAN 1 *pruned* from the trunk does not really cross and we count it anyway — but that error
leads to neutral, which asserts nothing, rather than to painting the wrong VLAN.

On an access cable one VLAN does apply, so the ladder looks for *which*: a hand-set value,
a measured one, the propagated one, a dot1Q sub-interface standing on the cabled port, then
the declared network of an end that has **exactly one cable** — its address can only be
talking about that cable. A multi-homed device’s address says nothing about *this* one.

⚠️ **Who may name the VLAN is not «an active device» but «one that switches VLANs»**
(`lib/vlan-authority.js`). *Active* is a property of the type — our own classification — and
says nothing about whether the device assigns VLANs. The discriminant is already in the
measurement and needs no vendor list: a device whose entire VLAN world is `[1]` has named
only the VLAN that exists when nothing is configured, so its `vlan=1` means *my port is
untagged*, not *this cable is in VLAN 1* — and it cannot be authoritative about a VLAN it
does not know. Measured on the bench: a wireless controller and an EXOS switch, both
answering on 10.10.99.x and therefore living in VLAN 99, published `vlan=1` with a VLAN
world of `[1]` and overruled the declared network. A device that does know other VLANs and
still says 1 is *choosing*, and keeps its authority.

⭐ **Where a VLAN stops is decided the way the hardware decides it**: a frame keeps its
identity until a VLAN-aware port reclassifies it. So «does this device classify VLANs?»
(`isVlanAware`, one definition read by the cable colour, the propagation and the port
panel) is not «is the type active»: an **unmanaged switch** is a plain 802.1D bridge —
forwards on MAC, no VLAN table, adds and strips no tags — and its whole inside is one
broadcast domain, so the VLAN arriving at its edge applies to every one of its sockets.
Before this, the VLAN reached its uplink port and died there. It is **declared** (`swMgmt`
in the Switch panel, a field that already existed and nothing read) and never inferred: a
managed switch nobody has polled looks identical, and guessing would push a VLAN through a
device that in fact keeps VLANs apart. Only `unmanaged` is transparent — a smart-managed
switch has VLANs and classifies like a managed one.

That untagged 1 is not thrown away, it is demoted: it sits **below** the declared sources,
as `untagged`, so a flat network — where 1 really is the only VLAN there is — still reads
VLAN 1, while a declared network wins wherever the two disagree. The same rule governs
propagation, or the discarded claim would cross the cable as `vlanProp` and win one rung
lower: a **passive** port still inherits an untagged VLAN (it has none of its own), an
**active** one inherits only a value that carries authority.

⚠️ **The VLAN legend's routed badge is a filter, and it shares `store._filterVlan`.**
It shows only when some cable actually falls there, reads «L3» (the same in both languages —
the full word opens its tooltip), and a click on it shows only routed cables, exactly as a
click on a VLAN badge shows only that VLAN's. It is a *value* of the VLAN filter rather than
a switch of its own because the two are mutually exclusive: asking for VLAN 30 and for the
cables that are in no VLAN at all is not a question. ⚠️ That makes `_filterVlan` a variable
that holds a number **or** the string `FILTER_ROUTED` (`src/app-popup.js`) — the comparisons
`_effPortVlan(pid) === store._filterVlan` therefore stay false, which is the right answer (no
port has "routed" as its VLAN), not a coincidence to lean on. Which cables are routed is not
recomputed for the filter: it comes from the same verdict that colours them. The other neutral
entry, a cable whose two ends disagree, stays read-only — a finding to close, not a view to
inhabit.

⚠️ **`routed` is consulted last, never first.** Owning an IP address is normal for any
host; it is the switch side that decides whether a cable is in a VLAN. Measured on the
bench: with the check placed first, a VyOS router and a wireless controller sitting on
access ports in VLAN 99 both came out as routed links. It only distinguishes *why* no VLAN
applies — a routed port is a fact with nothing missing, while a switched one always has a
VLAN — and the evidence is the standard address-to-interface table, of which only the IPv6
rows were previously kept.

⚠️ **«Routes» is measured as «is not a port of the bridge»**, which is what the word means;
owning an address is only an indication, since every host has one. The evidence is
`dot1dBasePortIfIndex` (BRIDGE-MIB), already walked to translate PVIDs. It is used
asymmetrically on purpose: being **in** the table is a veto — that port switches — while
being absent from it proves nothing, because a vIOS publishes the table for two ports out
of eight and another unit of the same image for none. So the two measurements keep their
own names, `ownsIp` and `bridges` (absent, not false, when the agent is silent), and
`isRoutedPort` composes them in one place.

⚠️ **But a port can also be DECLARED routed, and that comes before both measurements.**
Port mode (`state.ports[pid].mode`) has a third value beside `access` and `trunk`:
`routed`. It reaches the engine as `declaredRouted` on the paint end and is consulted at
rung 0 of `linkPaintVlan` — above `vlanOvr` too, because the two sentences are not about
the same thing: `vlanOvr` states the PVID of one port, `routed` states that this *cable*
carries no VLAN, and on a single wire the sentence about the wire decides the wire. On the
same port they cannot collide, since `setPortMode('routed')` deletes `vlanOvr` and
`trunkVlans` — a document must not hold two incompatible statements and let the reader
choose. It also beats the `bridges` veto: manual-first is not suspended when it is
convenient, so the declaration stands and the port panel says the two disagree. Without
this a project drawn by hand could never produce a routed cable at all: the wire between
two routers fell to the site-native floor and was painted VLAN 1, asserting that it
switches. ⚠️ It is not consulted on trunks — a trunk switches by definition — and the UI
does not offer L3 there. ⚠️ It is a third value of one field rather than a separate
switch, because two independent controls answering «what kind of port is this?» can
contradict each other and one field cannot.

The optional companion is `state.ports[pid].routedNet`: the `cidr` of one of the declared
networks (`ipam.prefixes[]`). It stores only the key — `prefixesOf` stays the authority on
prefixes — and it feeds the port column of the device's «Gateway L3 / SVI» section, which
previously said *which* network a device routes but never from where. ⚠️ Optional on
purpose: requiring it would block the half-drawn case, and the cable's colour depends on
the mode alone. ⚠️ The picker lists **every** declared network, `prefixesWithoutVlan` first
and the rest labelled with their VLAN. Restricting it to VLAN-less networks was the first
cut and it was wrong twice over: a real project declares five networks that all have a
VLAN, so the field came up empty and the mode looked broken; and the *measured* path calls
a port routed even when its address sits in a network that has a VLAN, so the declared path
would have refused to name what the measured one happily concludes. ⚠️ The `<select>`
carries `data-no-manual="1"`: `_enableManualValueInProps` appends a «Custom…» option to
every select in the panel, and this field is a **reference** — a typed CIDR would point at
nothing and would be a second place for a network to live. ⚠️ Never inferred, neither from the port's own
declared address (`ports[pid].ip` — an indication any NIC satisfies, the very reason the
measured field was renamed from `routed` to `ownsIp`) nor from the VLAN card's «routed by»,
which proves the device sits *inside* that VLAN, the opposite of what it looks like.

The glue `src/app-link-color.js` is the only translator from that outcome to a colour.
Before, eight sites computed it — `app.js` ×3, `topo-lines.js` ×2, `export.js` ×2, the
properties colour picker — and they had already diverged: the topology used the pair’s most
frequent VLAN while the rack used the native, so one cable could be two colours.
`test/link-color-unica-definizione.test.js` refuses a ninth: no file outside the owner may
index `vlanColors[…]` with a cable’s VLAN. Its three indexes (sub-interfaces by parent port,
cables per node, VLAN of a node’s IP) are rebuilt by `propagateVlans()` rather than by an
invalidation contract of their own — one lifecycle instead of two that drift.

Three sources fill part of the gap the invention used to paper over, all declared by
the device and none inferred: the **native VLAN of a Cisco trunk**
(`vlanTrunkPortNativeVlan`, zero meaning "no native frames" and treated as silence);
the **VLAN of a dot1Q sub-interface** (`cviRoutedVlanIfIndex`, whose index carries the
VLAN *and* the physical port, so it declares the parent too — the standard
`ifStackTable` still decides and the Cisco row only fills its gaps); and what an
**aggregate** declares to its member ports, which is looked up by the aggregate's
interface index rather than its logical id. That last lookup had used the logical id
since it was written, so the inheritance had never once run — invisible on images
that do not publish membership at all, which is most of them.

A sub-interface enters the document as a **logical port**: the same shape the NetBox
import already gives its logical interfaces, not a second model. It is not cablable,
takes no positional slot and stays out of cable resolution, but it carries the VLAN
and the address — on a router-on-a-stick that address is the one InfraNet is talking
through, and it used to be discarded along with the interface. Its identity is the
interface *name*, never the ifIndex: without `snmp-server ifindex persist` the indexes
are reshuffled on reboot and the port would split in two at the next verification.

### Measured hardware identity, and its age

`node.integration.inventory` is what ENTITY-MIB said about a device: make, model,
serial, firmware. It is a **measurement**, so it has an age — which nothing tracked
until 2.9.2. The field had two states ("there" / "not there") and was rewritten on
every poll with whatever arrived, `null` included, which cut both ways: a read that
carried no identity **erased** a good measurement taken months earlier, and whatever
survived was compared against the declared fields as if freshly read, producing
"device replaced" from a partial walk.

ENTITY-MIB is walked by index, and on many agents the chassis sits at a very high one
(on a GS1900-24, index 67,108,992, while a "Stack" entry sits at 64). A truncated walk
therefore leaves the accessory rows and the scorer crowns the survivor — so
`drivers/snmp.js` refuses to build an identity when nothing in the walk is a CHASSIS
(class 3) or a MODULE (class 9): classes 11 (stack) and 5 (slot) describe how a device
is organised, not what it is.

**`lib/identity-reconcile.js`** holds the rule, in three states: *reconfirmed* (this
poll measured it: `measuredAt`, no flag), *last known* (the poll succeeded but brought
no identity — the previous measurement is kept, marked `stale`, with its original
date), *never measured* (`null`). An inventory object carrying none of the four
identity fields is not a measurement at all, so it cannot displace one. Nothing is
touched on a **failed** poll: nothing was measured and nothing was contradicted.

The consequence is the point: whoever **accuses** asks `isConfirmedMeasure()` —
`lib/drift-snapshot.js` (`measuredIds` → the `identityDrift` bucket), `lib/overview.js`
(`_identity.mismatch`, the recovery lens) and `export.js` (`identityMismatch` in the
handover dossier), three engines that each carried their own copy of the comparison.
Whoever **informs** reads it regardless — the model to buy again, the firmware to
reflash, the Properties placeholders — with the panel saying "last known, not
reconfirmed" and the date, since a months-old model otherwise reads as freshly
measured.

**The lock.** Whoever accuses also asks `isIdentityLocked()`. Writing the model, the serial
number or the firmware by hand closes `modelManual` / `serialNumberManual` /
`firmwareVerManual` (`updateN`; emptying the field opens it again), and the three
comparisons above skip a locked field: a model typed with the commercial name used to be
reported as a replaced device on every scan. The Sync does not fill a locked field and
"Adopt" does not overwrite one. The padlock says who decides; the dot says where the value
comes from — two axes, and a field can hold both (locked, and confirmed by the device).
The price is stated: a locked serial no longer flags a real swap. Values typed before the
lock existed carry none, and are not guessed.

**Provenance (`lib/field-provenance.js`).** A reading that writes — or finds already
written — a field a person can also write marks it in `copiedFromRead`, and every write
by a person clears the mark (`updateN`, `updateP`, `setPortField`). The panels do not infer
from equality: they ask `fieldSource(rec, field, read)`, which says measured if the
current reading equals the value, or if the value is marked and the device is silent;
declared if it is not marked, or if the reading now says something else (the brand
excepted, since the OUI and the ENTITY-MIB spell a vendor differently). A registry
(`FIELD_PROVENANCE`) lists each such field with the mechanism that keeps the hands apart —
mark, lock, override slot, shadow, measure, id prefix, or a slot only the reading writes —
and guardians derived from the source (`test/field-provenance.test.js`) turn red when a
reading starts writing a field the registry does not know, or when a node is created from a
reading without marking it. The mark is a measure in the portable export
(`lib/project-schema.js`), so it does not travel; the lock flags are declarations, so they do.

Rack devices keep their SNMP LED instead of an overlay. The buckets cover documented
devices with an IP but no MAC too (`doc.ipOnly`), checked per-node rather than per-MAC.
The signals are assembled in `lib/drift-snapshot.js` (`buildSnmpSnapshot`: `presentNodeIds`,
`trustAbsentNodeIds`, `macAtIp`, `snmpArp`, `snmpNd`) and decided in `lib/drift-report.js`;
a stale DHCP lease is deliberately never a red signal (imported old files would mass-flag),
and `snmpNd` is presence-only (never IP-change, to avoid a cross-family false positive). An
**opt-in** setting (off by default, in the DHCP lease import panel) additionally annotates
a grey device whose DHCP lease is **released** (the device sent DHCPRELEASE) as "likely disconnected"
(`releasedMacs`) — a weak hint that stays grey, never red, and only from `released` state
(never from lease expiry).

**A MAC at an address the document knows is not an undocumented device** (2026-08-23).
The "undocumented" bucket matches observed MACs against documented ones, and infrastructure
has no documented MAC to match: a switch or a router exposes no device MAC over SNMP, so the
node is carried by its address alone and its signature can never meet a row of any forwarding
table. On the bench SW-CORE's switched interface was accused at every Verify — true in form,
wrong in substance, and impossible to close. `buildDocSnapshot` therefore also returns
**`knownIps`**: every address the document knows *and has no MAC for* — the management address
of a MAC-less node, plus the **declared gateways** of the plan, which are the other interfaces
of those same devices (curing only the first left the siblings accused). If ARP places an
observed MAC at one of them, it is that device's MAC and the finding is dropped; a multihomed
device needs only one of its live addresses to match. An address the document *does* have a MAC
for stays out: there a different MAC is a changed identity, and deserves a signal of its own.

### One question, one alphabet: how certainty is said

Looking at any row a person asks one question — *how much do I trust this?* — and until
2.11.3 the app answered it in **seven vocabularies**: `proof`, `linkstate`,
`temporal-confidence`, the Overview's `prov`, Discovery's `conf`, `status`, and the
presence classes. They were never synonyms, which is why the worst case was measurable: a
cable's Status row could mount **five** badges, three of them answering that one question
in three incompatible notations — a link-state word, a proof word, and a percentage that
contradicted both.

**`lib/certainty.js` unifies the ALPHABET, not the model.** The engines are not merged and
none of them changed: `provenance.js` is right that they keep legitimately different
half-lives — a declaration does not age, a measurement does, and at a rate that depends on
what was measured. What is shared is the sign a reader sees: six of them, ordered from the
most load-bearing to the least — `measured`, `declared`, `derived`, `contradicted`,
`undeclared`, `unread`. Four are not new: they are the provenance dots the Overview already
had and which never left that screen.

Three properties of that alphabet carry weight:

- **The two absences are separate signs, drawn identically.** `undeclared` is the absence of
  a *declaration*, `unread` the absence of a *reading*: symmetric to the two positive
  origins, and they ask different people to move — one asks you to write something, the
  other asks a probe to go and look. Collapsing them (as `prov:'none'` did) told the reader
  a value was missing without saying who had to act, and on the Overview's `verify` row the
  label *not declared* was plainly false, since no declaration was missing there. But the
  empty ring is the same for both: the word carries the meaning and the colour only confirms
  it — the rule `11-overview.css` had already written for itself.
- **A key that is not a certainty is DECLARED as such** (`NOT_A_CERTAINTY`), because silence
  would not distinguish "this is a different axis" from "somebody forgot to map it".
  `linkstate`'s `lag` says what a link *is* — the same axis as TRUNK/ACCESS — not how much
  it is trusted; Discovery's high/mid/low say how *strong* a guess is, and that score is an
  additive vote over heterogeneous signals that reaches «high» with no SNMP and no LLDP at
  all (NetBIOS 14 + SMB 20 + services 18 + hostname 12 + MAC 12 + ping 10 = 86), so grading
  it `measured` would pass a well-summed pile of clues off as a reading. The difference is
  qualitative, not quantitative — which `lib/linkstate.js` had already written about itself.
- **The grade carries no colour.** `.cty-<grade>` defines one ink and tint, border and dot
  are derived from it with `color-mix`, so a grade's colour exists in exactly one place. The
  Overview's non-certainty tags follow the same rule and share the same pill: one geometry,
  written once, so the panel cannot end up drawing one question in two shapes again.

`test/certainty.test.js` **derives** each engine's key set from that engine's own source
instead of listing it: the map necessarily enumerates, so the proof must not — a state added
to `proof.js` or `linkstate.js` and not mapped here turns the guard red rather than slipping
through as an unlabelled badge.

A derivation, though, is only as complete as the birth sites it reads, and `prov` has **two**:
the Overview writes a row's provenance as `prov:` and a list item's as `tag:` — same question,
same map, two fields. Reading only the first left `declaredNet` outside the alphabet — it is
`declared` spelled feminine, because the Italian label agrees with «subnet» — so the engine did
not recognise the word and the Overview kept drawing it with the retired badge, with every gate
green. The derivation reads both now, and the words of the second site that answer a *different*
question (no gateway, no reading, not answering, unverifiable, stale, dated, identity mismatch)
are declared in `NOT_A_CERTAINTY.prov` rather than left unmapped — which is what lets the
renderer ask the engine where the boundary is instead of keeping a list of its own.

---

## 5. Recipe: add a new device type

1. `src/app-types.js` — add an entry to `TYPES` (`name`, `icon`, `isRack`/`isFloor`,
   `ports`, flags).
2. `src/app-properties.js` (or the relevant `src/app-properties-*.js`) — add the
   device-specific `<details>` block inside the props renderer (the per-type `if` chain).
3. `netmapper.html` — add the palette `<div class="equip-item" data-type="...">`.
4. i18n — add the label keys (`dev.<type>`, plus any `f.*` field keys) to both
   `it` and `en` in `lib/i18n.js`; tag palette/header with `t(...)`/`data-i18n`.
5. Run `npm run check` + `npm run lint` + `npm test`; verify in the browser.

---

## 6. Rendering & overlays

Report overlays (Drift, Free ports, L3 map) follow one pattern: a `*EnsureOverlay()`
creates the modal **once**, a render function fills it on each open. Because the
shell is created once, **the title is given an `id` and refreshed on every render**
so language changes apply when reopened.

A11y for this dynamic `.drift-overlay` family is provided by the same outside
observer that covers the static tool modals (`src/app-modal-a11y.js`): overlays
appended to `document.body` are auto-registered, get `role="dialog"` /
`aria-modal` / `aria-labelledby` stamped once, and receive the shared focus-trap,
focus-restore and Escape handling (Esc closes the topmost dialog via its real X —
never acting on what's behind it; the base alert/confirm cancels on Esc). New
overlays only need the conventions: `.drift-overlay > .drift-modal > .drift-head`
with an X button and a `*-title` id.

**Golden rule for i18n / dynamic text:** elements that JS rewrites via
`innerHTML` / `textContent` / `.title` **ignore `data-i18n`** — translate them with
`t()` at the JS source, or the translation gets overwritten. See the PDF manual (i18n chapter).

---

## 7. Testing

- **Pure-lib tests** (`test/*.test.js`, `node --test`): the safety net for all
  logic. Fast, zero-dep. **4,381 tests** at the time of writing. Includes the AI assistant's **anti-leak guard**
  (`test/ai-context.test.js`): asserts no SNMP community / credential / secret-named
  field can ever reach the AI context (data-security paletto, build-failing). Also
  covers the previously-untested **auth surface** end-to-end (`test/auth-api.test.js`
  mounts `auth.register` on a throwaway Express app — real login/logout, RBAC,
  self-guards, last-admin protection, session-invalidation, login rate-limiter; plus
  `test/auth-store.test.js` for the user store and the corrupt-file guard) and the
  **panel-skin sanitizer** against a battery of `on*`/`<script>` bypass payloads
  (`test/panel-skin.test.js`).
- **Guards that sit on a boundary** (added after 2.11.12):
  `test/json-state-recovery.test.js` (one policy for every JSON state file: read from the
  `.bak`, never promote a broken file to it), `test/security-headers.test.js` (the policy
  the server actually sends — no `'unsafe-eval'` — plus the closures §8 claims) and
  `test/scan-targets.test.js` (an address that becomes traffic is read once and sent
  canonical; `child_process.execFile` is replaced by a spy *before* the modules load, so
  nothing leaves the machine). Two rules they taught. A probe written inside
  `page.evaluate()` cannot prove that a CSP blocks `eval`: DevTools suspends the block for
  the whole call, so the attempt runs in a `setTimeout` after it returns, is driven with
  real key and click events, and the probe is shown to see a red (the same page with
  `'unsafe-eval'` put back by hand must say *allowed*). And `npm run check` counts the files
  **git tracks**, so a new `.js` changes the number the README cites only once it is staged:
  run the suite after `git add`, not before.
- **Repository-shape guards** (added after 2.11.12, in `test/bridge-ratchet.test.js` and
  `test/bundle-architecture.test.js`): the two bridge ceilings are quoted **by name, never by value**
  in the notes of that test, ARCHITECTURE, README and CONTRIBUTING — the guard reads the real values
  from the two constants and refuses them anywhere else, because a ceiling that drops leaves every
  sentence carrying its number false and nothing notices; `esbuild` stays in `dependencies`, since the
  Docker image and `postinstall` build the bundle after the dev dependencies are gone; and the suite
  lives in `test/` only, with no file name repeated (two files were once both called
  `classify-golden.test.js`, and `node --test` finds `*.test.js` anywhere, so neither tree noticed).
- **Pinning a layer before it moves** (added after 2.11.12, `test/autolink-dct.test.js` for layer 5 and
  `test/autolink-endpoint-attach.test.js` for layer 4): `_autoDiscoverLinks` cannot be called in
  pieces, so the pin runs the whole function with a fake `fetch` that serves the topology and reads
  back the links it creates; only then does a layer leave for `lib/`. Each move was also checked
  against the old code taken from git (`git archive HEAD`, built on its own) on random inputs —
  20,000 for layer 5, 30,000 whole-function runs for layer 4 — and that comparison was shown to see deliberately broken variants, because an
  equivalence check that cannot fail proves nothing. ⚠️ **Random inputs are blind to what they
  do not generate**: for layer 4 the variant «prune at the end of the loop» showed 0 differences on
  600 scenarios until the generator was given one aimed at node order, and that case is what the
  pin ⑫ guards. A control that finds nothing says nothing about the interplay it never reaches.
- **A local SNMPv3 agent in the test** (added after 2.11.12, `test/snmp-v3-params.test.js`): net-snmp can be
  the *agent* too, so the session the driver builds from nothing but the project's text is run against one on
  127.0.0.1 — constants that exist do not prove a device would answer. The test also compares the project's
  protocol list with what net-snmp exposes (an upgrade that adds one turns it red) and renders both panels.
  ⚠️ Pick the combination where the difference shows: with SHA-256 or longer the two AES-256 variants coincide,
  so a test using only SHA-2 stays green with the wrong mapping; MD5, SHA-1 or SHA-224 tell them apart.
- **Golden-master render** (`test/golden-render.test.js`): snapshots the rendered
  `innerHTML` of every device's Properties panel + the 4 scopes + the generated
  rack render vs `test/golden/render-golden.json`, to catch unintended UI changes.
  **Active by default** as a gate (UI considered stable after the June 2026
  redesign): skip with `SKIP_GOLDEN=1`, refresh the baseline after a deliberate UI
  change with `UPDATE_GOLDEN=1 node --test test/golden-render.test.js`.
- **Invariant guards** — the tests that protect a *rule* rather than a behaviour.
  Each one is measured, monotonic (its ceiling may only fall) and, where it can
  be, **derived from the source rather than restating it**: a guard that lists
  what it checks goes green and blind the day the list grows.
  - **HTML escaping** (`tools/html-escape-scan.js` + `test/html-escaping.test.js`).
    The app builds its UI with template literals and `innerHTML`, so nothing
    escapes for us; the scanner tries to *prove* every `${…}` in an HTML template
    safe and ratchets the residue per file. Its own fixtures pin the behaviour in
    both directions, which matters because a scanner that breaks permissively
    stays green forever. Two blind spots were closed on 2026-08-31: it did not
    descend into **nested templates** (a value one level deep was invisible and
    uncounted) and it did not skip **comments** inside an expression (one
    apostrophe in an Italian `//` opened a string and swallowed the rest).
    Interpolations actually examined went 3810 → 4922 on the same sources.
  - **Type scale** (`test/type-scale-ratchet.test.js`): a hard zero on
    declarations that rewrite a token's own value by hand, plus a ratchet on the
    remaining off-scale sizes. The forbidden literals are read out of
    `styles/01-tokens.css`, never listed.
  - **Badge contrast** (`test/badge-ink.test.js`): every solid badge colour, read
    from the colour tables in the source, must reach WCAG AA with the ink
    `badgeInk()` picks for it.
  - **Bridge ratchets** (`test/bridge-ratchet.test.js`): `MAX_WIN_REFS` and
    `MAX_INLINE_HANDLERS`, see §10.
- **Smoke E2E** (`test/smoke-app.test.js` + `tools/smoke-dom-stub.js`): loads the
  whole app into `node:vm` with a stubbed DOM and exercises `renderAll`/`renderProps`
  to catch crashes (missing globals, wrong script order). It does **not** verify
  visual fidelity.
- **Headless E2E** (`test/e2e/critical-flows.test.js` + `test/e2e/helpers/server.js`):
  drives the app in a **real Chrome** (Playwright on the system browser via
  `channel:'chrome'` — no Chromium download) bypassing login with
  `INFRANET_DEV_NO_AUTH=1`. It spawns an isolated server (`INFRANET_PROJECTS_DIR`/
  `INFRANET_SKINS_DIR` → temp dir, so it never touches real data) and exercises the
  critical flows on the real DOM/JS: bundle load with zero JS errors, cable routing
  (`getCablePath` direction-true + TIA-568 pass-through verdicts), VLAN propagation
  AP→client over wireless, BSS re-association, and a real pointer click → selection →
  Properties panel. Removes the "not reproducible in browser" blind spot the DOM
  stub can't cover. **Off by default** (needs Chrome + a server spawn): run with
  `RUN_E2E=1 npm run e2e`. `npm test` reports it as skipped.
- **Touch E2E** (`test/e2e/touch.test.js`, same helpers, run by the same `npm run e2e`): a Chrome
  context with `hasTouch` (real `(pointer: coarse)` and `(hover: none)`) and **synthetic fingers**
  through CDP `Input.dispatchTouchEvent` — pan, tap vs. jitter, pinch (zoom, fixed focal point, limits),
  resuming a pinch, rack, dividers, `pointercancel`, and the mouse left unchanged. To lift ONE finger
  send `touchEnd` with that point; a `touchMove` with a single point does not lift anything.
  It lives in its own file so the flow count quoted in the README stays that of `critical-flows`.
- `test/smoke-ui.test.js` still asserts UX markers in the rendered HTML inside the
  DOM stub. For *manual* visual inspection in dev, `INFRANET_DEV_NO_AUTH=1` (see
  auth.js) lets the preview tooling reach the UI without a login — off by default,
  localhost-only, never for production.

Commands: `npm run check` (syntax), `npm run lint` (ESLint gate), `npm test` (all
tests), `npm run typecheck` (tsc JSDoc), `RUN_E2E=1 npm run e2e` (headless browser
E2E), `npm start` (server on `http://localhost:8421`). CI runs all of them.

---

## 8. Security model (summary)

bcrypt-hashed passwords (cost 12), server-side sessions (httpOnly, sameSite=strict;
`secure` behind a TLS proxy via `INFRANET_TRUST_PROXY=1`), a rate-limited login,
admin/viewer roles, and **scan/poll routes gated to admin**. `execFile` is always
called with an argument array (no shell → no injection). The session secret and the
first-run admin password are generated with a **CSPRNG** (`crypto.randomBytes` /
`crypto.randomInt`), never `Math.random`. Every `projectId` reaching the filesystem
is coerced to a positive integer (no path traversal). The user store is written
**atomically** (temp + fsync + rename, with a `.bak`, via `atomicWriteFile`); a
present-but-corrupt `users.json` recovers from the `.bak` and, failing that, **halts
startup** instead of regenerating a default admin over existing accounts. The same
atomic write (owner-only `0o600` where a secret is involved) protects
`api-tokens.json`, `data/ai-config.json` and the shared skin SVGs. **One policy for
every JSON state file**, in one place (`readJsonWithBak` in `server/projects-store.js`):
a file that is *present but unreadable* is read from its `.bak`; one that is *absent*
stays absent (deleting a config by hand is a decision, and a revived API key would be a
surprise); and a `.json` that no longer parses is **never copied over the `.bak`** — a copy
of a broken file is not a copy. Before this, the organisation, the AI and DCIM configs, the
API tokens and the skin index answered "empty" or "defaults" and the next save destroyed
the only good copy. **Uploaded skin
SVGs are sanitized** (regex on the server, a real DOM parse on the client for both
preview and rack) so an event handler / `<script>` in a shared skin-pack cannot run
in another user's Properties panel. **Every value interpolated into HTML goes
through an escaper** — an invariant, not a habit, because the UI is built with
template literals and `innerHTML` and the input is not only the operator's
keyboard: `sysName`, `sysDescr`, DHCP lease hostnames, HTTP titles and LLDP
neighbour names arrive from the devices, i.e. from anyone on the network being
documented. It rests on more than a thousand hand-written escaper calls, so it is
held up by a static guard rather than by discipline (`tools/html-escape-scan.js`,
§7), which proves what it can and ratchets the rest per file. **Login runs a constant bcrypt compare** (dummy
hash for unknown usernames) to deny user-enumeration by timing, and a **global
Express error handler** returns JSON — never an HTML stack trace — for
malformed/oversized bodies or thrown route errors. The data
surfaces — AI context, REST DTOs, exports — are **allowlist-only**: secrets are
structurally excluded and a build-failing guard test enforces it. (The PDF
report's per-device asset register is built from the same `nodeToDevice` DTO
(minus structural cabling — wall ports and electrical panels are not IT assets —
via `isStructuralCabling`), so no SNMP community or credential can reach the
exported document; report chrome is
localized it/en server-side while device data is emitted verbatim.) Binds to
`127.0.0.1`. Every response carries **baseline security headers** — a `Content-Security-Policy`
(self-hosted assets: `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`,
`frame-ancestors 'none'`; inline kept, since the UI needs it — but **never `'unsafe-eval'`**:
no frontend code compiles strings, and `test/security-headers.test.js` (the policy actually sent) and
the `no-eval`/`no-new-func` rules in `eslint.config.js` (browser sources) keep it that way),
`nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: no-referrer`. `GET /api/projects/:id` **redacts SNMP secrets** (community + v3
passphrases) for a non-admin reader (viewers can't save → loss-free), and the
`INFRANET_DEV_NO_AUTH` bypass is **fail-closed** (honoured only on a loopback bind, non-production).
`users.json`,
`.session-secret`, `api-tokens.json`, `data/ai-config.json`, `projects/` are
git-ignored. A 2026-06 AppSec audit found **no critical issues**; a
2026-07 follow-up (again no critical) closed the remaining highs (panel-skin XSS, auth
test coverage, project-list robustness); a third **2026-07-21 six-domain audit** (zero
critical, avg 7.8/10) closed 8 highs (all ② no-invention) + 15 mediums, the SNMP layer
live-verified on real hardware. Two **2026-09 smoke passes** — the code and the live app,
then the surfaces the first pass had not touched — moved six trust boundaries, all shipped
in 2.11.4: a **management URL** is scheme-checked when the link is built *and* when it is
opened, and stripped of credentials in every form it can be written in (including without a
scheme) before it reaches a DTO, the Ansible inventory or the AI context; **session
revocation survives a new login** (a per-user epoch) and signing in regenerates the session
id; login throttling is keyed by address *and* account, so one shared proxy address cannot
lock out everyone; every `/api/*` answer is `Cache-Control: no-store`; a **saved integration
secret travels only to the origin it was saved for**, and an AI key from the environment can
be pinned to an endpoint the interface cannot change; the **LLDP/CDP crawl stays in internal
address space** unless a person declares otherwise, because a neighbour address is declared
by the device being questioned and not by us; and the **PDF export** accepts only validated
inline images, bounds its text fitting (it was quadratic) and always answers, whatever
stalls. A written document is checked before it replaces one — a project `state` must be an
object, a snapshot id must be numeric. The **container runs non-root** with every secret on
the data volume rather than in an image layer. The whole surface — including the auth flow — is covered
by regression tests (`test/ai-context.test.js`,
`test/ai-route-security.test.js`, `test/auth-api.test.js`, `test/panel-skin.test.js`, `test/security-hardening.test.js`). Do **not** expose the instance to the public
internet — it is a network scanner with command execution; the right access model
is VPN/LAN.

**2.11.17 — identifiers, numbers, bursts, files.** Four more boundaries, each with its guard.
*An id is a name* (`lib/id-safety.js`): the UI interpolates ids as attribute values, so a
project from a file or the API with `"><img onerror=…>` in a rack or cable id ran script in
whoever opened it. Ids are letters, digits and `_ . : @ + # -`; `_migrateState` repairs a
loaded project (and every reference to the renamed id), the project and snapshot routes repair
what they receive (the save is never refused — one field is not worth the project — and the
count travels back in `X-InfraNet-Sanitized`, which the client turns into a notice), and the
numbers of the model (`ports`, `rackU`, `x`, `integration.port`…) come back numbers or vanish.
The output escapes anyway: `test/e2e/xss-fuzz.test.js` puts a payload in every numeric leaf and
every id, loads it, then writes it *straight into the live state* (past the loader) and opens
every panel — both phases must run nothing. *A burst of logins* (`lib/concurrency-gate.js`): a
password check is ~340 ms of CPU in a one-thread process and the per-IP limit counts an
address's budget, not how many arrive together, so at most `INFRANET_LOGIN_CONCURRENCY` (2) run
and `INFRANET_LOGIN_QUEUE` (8) wait; the rest get a 503 with `Retry-After` at once. *The AI
assistant*: the endpoint is an http(s) URL with no credentials in it, the path is appended
before the query, the key is a header-safe string and is redacted from any provider error
before it is quoted, only an admin sees the endpoint, and the conversation and the browser's
live facts have caps. *Files that hold credentials* — projects (the devices' SNMP credentials),
their `.bak` and history, `users.json`, `api-tokens.json` — are `0600` (`INFRANET_FILE_MODE`
widens it to the group, never to others), the `.bak` is tightened separately because it is a
copy of the file *before*, and existing files are tightened at startup.

---

## 9. Gotchas / conventions

- **Script order matters** (`netmapper.html`): a global used before it is defined
  breaks silently. `lib/i18n.js` loads first so `t()` is available everywhere.
- **Shadowing:** some functions use a local `const t` (e.g. `rep.totals`). The
  global i18n `t()` is shadowed there — rename the local (e.g. `tot`) before using
  `t()`.
- **An interface name is matched through `lib/netnames.js`, never compared raw.**
  `_ifNameMeta` normalises on three levels — exact, compact, vendor-neutral — so
  `Gi1/1` and `GigabitEthernet1/1` are one interface, and a numeric fallback catches
  the agents that announce a bare `1` for a port whose description is `EXOS-VM Port 1`.
  Two shapes are worth knowing: the generic noun trailing a name (*Port*, *Interface*)
  is decoration and is ignored **only** when testing the management family, so
  `Management Port` is `mgmt` while `Port 1` stays the physical port 1; and an
  interface whose name *begins* with `virtual` is not a port you can cable, which is
  how a wireless controller publishing `Virtual Interface` beside its real port —
  same ifType, same MAC — stopped being counted as a two-port device. Getting that
  count wrong is not cosmetic: a neighbour announced on a multi-port device has its
  far end deduced rather than known, and the link is downgraded to an inference.
- **An address that goes to the OS goes canonical.** `ping.exe` reads an octet with a
  leading zero as **octal** — `010.8.8.8` is 8.8.8.8 (measured) — while Node's resolver
  rejects it and the project reads it as 10.8.8.8 on purpose. `nbtstat` and `net view` get
  the same care, though how they read it was not measured. A new probe takes its address
  from `scanTarget` (`server/scan-target.js`) and sends `.ip`, never the raw string; it also
  refuses what is never a host (multicast, broadcast, `0.0.0.0/8`).
- **In `_autoDiscoverLinks` the ORDER of the layers is part of the answer.** The candidate set keeps
  the *first* candidate at equal confidence, so a cable that layer 3 proposes at 0.85 stays `MAC`
  even when the Direct Connection Theorem proposes the same pair at 0.85 afterwards — move that
  layer earlier and the label changes without an error. Its other implicit input is the FDB cache of
  every switch polled in the session, not only this round's. `test/autolink-dct.test.js` pins both;
  the function is being taken apart one layer at a time, pin first and move second.
- **In layer 4 the pruning of one endpoint changes what the NEXT one sees.** The resolver
  (`_resolveEndpointSwitchPort`) re-reads `state.links` — `_isTransitPort` looks at the cables on a
  port, and a cable in `trunk` mode makes it transit — and the loop reassigns `state.links` when it
  prunes. So with two endpoints learned on the same port and an auto-cable of the first in `trunk`
  mode, the node order decides: first-then-second prunes the first and then connects the second;
  second-then-first leaves both unconnected. That is how it behaves today and
  `test/autolink-endpoint-attach.test.js` (case ⑫) pins it. A «decide everything, then apply» loop
  would give the same answer in both orders — a behaviour change with no error. It is also why
  `endpointAttachDecision` takes the resolver as a function: the caller prunes before the next node
  and the resolver is never asked about a manual cable. ⚠️ The same pin shows that an endpoint that
  moved keeps its old auto-cable next to the new one (case ⑮): layer 4 has no «the port it was on
  before» reason. Left as it is, on purpose, until someone decides it should change.
- **An SNMPv3 name is read through `lib/snmp-v3.js`, and an unknown one is refused.** The driver and both
  property panels used to keep their own lists (six and three entries in the driver, two and two in the
  selects), and the driver turned a name it did not know into SHA-1 / AES-128 / authPriv — visible only as a
  timeout, a device that looks switched off. A new protocol is one entry in that list, and the test compares
  the list with what net-snmp exposes. `AES256` stays Blumenthal because saved projects contain it; `AES192`
  is absent because the library does not implement it. The `public` community default is a different
  question and was left alone on purpose: the forms show it, and the Overview already flags it.
- **A monitoring round never waits without a deadline, and never looks at the tab.** A round holds
  `_snmpSyncing` (Save is off and any other read refuses to start while it is on) and, at the full depth,
  `_driftRunning` (the Check and the next scheduled round wait for it). So an `await` in a round whose
  request never comes back does not slow the monitoring down — it ends it, without a word. Every request of a round goes through
  `postJsonWithDeadline` (`src/app-util.js`): the SNMP reads through `postSnmpRead`, whose
  `snmpReadBudgetMs` sits above the driver's own cap for that device (per pass `max(25 s, 5 × timeout)`
  plus a wave of overshoot, one pass more for a printer and one for HOST-RESOURCES), and the reachability
  sweep through `reachabilityBudgetMs`, which scales with the waves of 24 addresses and stays above what the
  server can take (about 13 s of ARP plus 6.4 s a wave, worked out from the cap on each step). On expiry a read
  fails with a readable reason — the node is `err`, the round goes on — and the sweep returns `null`, so the
  Check goes on without the presence and declares nobody absent. Measured on the lab: the slowest device
  takes 25-60 s, against 70 s for one pass and 110 s for two. A new request inside a round copies this or
  does not enter it; a request a person starts from a button that holds no flag is not covered, on purpose.
  The scheduler, for its part, **does not look at the tab**: in the background the timers are slowed but the
  fetches are not, and a monitor that stops when you switch window monitors nothing. A round that cannot
  start because another holds the field stays *due* and runs when it is free — one catch-up, however many
  expiries were missed. The badge reads `store._autoMonitorNextAt` and `store._autoMonitorRunning`, both
  written by the scheduler's `onChange`.
- **`_refreshTopoBtnState` does not own the Topology button while a refresh is running.** It runs from a
  60 s timer (`src/app.js`) and on every render, and a right-click refresh takes about two minutes on the lab;
  without a guard the button went back to normal with the read still in progress. `_topoForcedRunning`
  (a counter, because two refreshes can overlap) makes it leave the button alone, and whoever put it in
  «Topology…» puts it back — on success and on error.
- **Windows:** Git shows LF→CRLF warnings; harmless. The login page blocks the
  preview tooling unless you authenticate.
- **Don't add a new *runtime* dependency** without a strong reason (the frontend build is
  esbuild-only — and esbuild itself is a `dependency` on purpose: `npm install` and the Docker
  image build the bundle after the dev dependencies are gone; `test/bundle-architecture.test.js`
  guards it).
- **Shared state lives behind `src/store.js`** (getter/setter proxy su `window`):
  i moduli ESM leggono/scrivono `store.state`, `store.selId`, `store._viewMode`, …
  (23+ simboli pure-data, ADR D18), mentre `window.X` resta vivo per i classic
  (`export.js`/inline). `TYPES` è ora `export const` in `app-types.js` (importato dai
  consumatori; resta su `window.TYPES` via `expose()` per i classic). Le funzioni del
  nucleo (`renderAll`, `renderProps`, `showAlert`, …) sono `export` e importate. Ciò
  che resta sul ponte (`win.*` — erano ~1800 letture; quante restano lo dice `MAX_WIN_REFS`) sono funzioni non ancora ritirate
  (`selected`/`checked`/`_build*`).
- **Commit only when asked.** Keep secrets and user data out of the repo.
- **SNMP walk — adaptive retry kills FDB truncation under crawl load (2026-07-04).** The crawl's
  `pollNeighbors` walks the forwarding table (FDB) alongside LLDP/CDP and ARP — many single-column
  GETBULK walks on the same agent (limited to `WALK_CONCURRENCY`, default 4). A small-business switch
  under that pressure drops UDP; a GETBULK times out and net-snmp's `subtree` returns a **partial**
  FDB with no downstream error → macsuck placed some/all MACs on no port ("sometimes 0 badges"). Fix
  in `_runWalks` (`drivers/snmp.js`): a **timed-out** walk is retried on the same base with
  `max-repetitions` **halved** (25 → 12 → 6, floor `WALK_MIN_REPS`) + backoff — the netdisco bulkwalk-retry
  strategy. Idempotent (`oid→value`, a retry overwrites partials); healthy devices never retry; a
  deliberate loop/runaway abort is not retried. `SNMP_WALK_RETRIES` (default **1** — the first
  halved-reps retry recovers nearly all truncations; a second quartered pass cost real crawl time
  for a marginal gain; `0` truly disables). **Crawl scope (2026-07-07):** in `pollNeighbors` the retry
  is restricted to the **FDB group** (`FDB_RETRY_BASES` — dot1d/dot1q FDB + the bridge-port→ifIndex
  map), the only family whose truncation breaks macsuck; LLDP/CDP/ARP that time out fail immediately
  instead of paying repeated timeouts (they only cost fewer neighbours/hosts, not a badge bug). Other
  callers (`poll`/printer/host-resources/`walkSession`) pass no scope → every base still retries.
  Pure-ish + unit-tested with a mock session.
- **LLDP identifiers are read from their declared subtype, not their length (2026-08-20).**
  Every LLDP identifier travels with a subtype naming what it is. `drivers/snmp.js` used to
  infer that from the value's size — six bytes meant a MAC — which failed both ways on the
  bench: a MAC rendered as seventeen bytes of text by one agent was dropped whole, and a port
  named `Gi1/24` became an address belonging to nobody. The subtype columns
  (`lldpRemChassisIdSubtype`, `lldpRemPortIdSubtype`, `lldpLocPortIdSubtype`) are now part of
  the walk; `lldpMac()` accepts both encodings; a chassis id declared *local* yields no MAC
  rather than an invented one. **The encoding is the answering agent's choice, not the
  advertiser's** — the same chassis id arrives in two shapes depending on which device you ask —
  so the rule is driven by the subtype and never by vendor. Agents that publish no subtype keep
  the historical behaviour.
- **The local port of a neighbour is resolved, not assumed (2026-08-20).** `lldpLocPortNum` is
  the agent's own numbering space and need not be the ifIndex space (measured: Arista
  `Management1` = LLDP port 97 / ifIndex 999001; ExtremeXOS ports 1-3 / ifIndex 1001-1003, with
  no port description at all). The name comes from `lldpLocPortDesc` first — on Cisco the full
  `GigabitEthernet0/0`, the form that matches the interface table — then from the advertised
  identifier when its subtype names an interface, and only then from the old ifIndex guess.
- **A neighbour that produces no link is reported, not dropped (2026-08-20).** `buildNeighborCandidates`
  takes an optional collector and records why: `device-unknown` (identity read, absent from the
  document → scan it), `no-identity` (nothing lookup-able was announced → our gap), or
  `local-port-unresolved` (the protocol named a port that matches none). Counts surface in the
  AutoLink diagnostics line. Where **both** devices are known and only the port is missing, the
  pair becomes an **adjacency**: `buildTopoLines` draws it with `?` in place of the port name
  (`m.adjacencies`, fed from `store._topoAdjacencies` — a measurement, replaced by each Sync and
  kept out of the saved document). A real cable between the two still marks the pair confirmed:
  the precise supersedes the approximate, and that rule stays in the passes that already own it.
- **Vendor recognition — full IANA PEN + IEEE OUI registries (2026-07-04).** Two bundled,
  refreshable datasets back vendor resolution, so a new device is recognized without a code
  change (the fix to a recurring "vendor X is missing" class of reports):
  - **MAC → vendor**: `data/oui-db.json` (~57k IEEE prefixes, MA-L/M/S + IAB), refreshed by
    `npm run update-oui` (`scripts/update-oui-db.js`). Consumed by the priority-0 catch-all OUI
    plugin; per-vendor plugins (Cisco/Apple/…) override it to add `deviceType`.
  - **SNMP sysObjectID → vendor**: `data/pen-db.json` (~66k IANA Private Enterprise Numbers),
    refreshed by `npm run update-pen` (`scripts/update-pen-db.js`, which parses IANA's flat
    `enterprise-numbers` file). `_vendorByObjectId` maps the enterprise number (7th arc of
    `1.3.6.1.4.1.<PEN>`) against it. The curated `PEN_VENDOR` (~50 entries) wins for clean short
    names; the registry is the fallback (lazy-loaded, degrades to the curated table if the file
    is missing). **Lab caveat**: virtual images (vEOS/vIOS) carry a hypervisor MAC with no IEEE
    OUI, so their vendor comes from SNMP (PEN) — proven live: a lab Arista (`sysObjectID
    …30065.1.2759`) resolves to **Arista** where the MAC path can't see it.
- **Discovery engine — killing phantom ARP-observed rows (2026-07-04).** Live debugging
  of a real `/24` traced a swarm of low-confidence "observed" phantom IPs to TWO
  distinct ARP paths; both are now authoritative-source-aware:
  - **ARP-SNMP is off-segment only.** The crawl harvests each SNMP device's
    `ipNetToMediaTable` (`buildArpCandidates`, the `arpnip` step) to find hosts the
    collector can't ICMP directly. But on the home LAN it was surfacing **on-segment**
    dead IPs out of a neighbour's *stale* ARP cache — root cause: an on-segment Synology
    (`.120`, SNMP public) whose ARP table was full of sleeping/departed entries. Fix:
    `buildArpCandidates` takes `localSubnets` (the collector's own `/24`s from
    `_readLocalInterfaceMap`) and **skips any candidate in a local subnet** — for
    on-segment IPs the local sweep/ARP is authoritative, so a dead one isn't resurrected
    from a remote device's stale table. Off-segment (the feature's real purpose) is
    unaffected. Verified in-browser: full `/24` scan 7 phantoms → 0.
    ⭐ **And since 2026-08-23 the surviving candidates are asked who they are**
    (`probeArpCandidates`, `server/crawl-bfs.js`, probe/decorate injected like the BFS).
    They used to be described without being questioned — passively observed, low confidence,
    typed by the classifier's floor as a PC — while answering SNMP with the crawl's own
    community one UDP round-trip away; a sweep of the same subnet found every one of them
    managed. It happens whenever a device does not speak the collector's neighbour protocol:
    on the bench the Arista speaks only LLDP and the Cisco switches only CDP, and three pieces
    of infrastructure came out as PCs (crawl 8 devices, sweep 11). One that answers with the
    name of a device already found is a second address of it, not a new device; one that stays
    silent is left exactly as it was.
  - **Local ARP read is state-aware on Windows.** `_readArpMap` now uses `netsh
    interface ipv4 show neighbors` (has the neighbour state) instead of `arp -a`, keeping
    only rows whose physical-address column is a real MAC. "Unreachable"/"Incomplete"
    entries carry the localized *state* there (no MAC) → excluded by matching the MAC
    token, **not** the state string (robust across Windows locales; avoids the
    `task_977d2930` trap). `_parseNeighbors` is pure + tested (IT/EN/any-locale).
  - **Stale-duplicate demote — `_demoteStaleArpDup`, two passes.** *Pass 1:* an ARP-only
    row whose MAC is live/DHCP at **another** IP is a stale cache entry of the same device
    → *Inactive*. *Pass 2 (double-phantom, 2026-07-04):* the **same** MAC on two-or-more
    ARP-only rows with **no** strong anchor anywhere (no ping/SNMP/DHCP) is one device
    caught mid DHCP-renewal in a stale cache (common with randomized/BYOD + mobile MACs) —
    keep one representative (highest IP, deterministic via `_ipToNum`), demote the rest to
    *Inactive*. Manual-first: demoted rows stay visible + re-selectable. Prompted by an
    Advanced IP Scanner side-by-side that showed the same MAC at two IPs (a phone at `.180`
    **and** `.240`; a randomized MAC at `.122` **and** `.234`). Pure + unit-tested.
  - **Web fingerprint — fast base, patient deep-scan (2026-07-04).** The HTTP/HTTPS title
    probe (feeds vendor + type from a banner like `GS1200-8` / `Keil-EWEB` / `lighttpd`)
    stays **aggressive (450/650 ms) in the base scan** so a `/24` stays fast. Embedded web
    UIs on UPSs / NAS / cheap switches answer slower and were missed; when **deep-scan** is
    on, discovery re-probes them **patiently (900/1200 ms), in parallel** with the
    NetBIOS/SMB/TCP identity scan, but only the rows still missing a title. The everyday
    fast path is unchanged. `server/routes/discovery.js`.
  - **Crawl heartbeat (UX).** The LLDP/CDP expansion is long (SNMP poll per switch +
    macsuck at the end); the Scansiona button shows "Espansione…" + spinner and the
    progress line updates per probed device (+ located count) so it doesn't look frozen.
  - **Crawl orchestration — level-synchronised parallel BFS (2026-07-07).** The
    neighbour-expansion BFS was extracted from the SSE route into `server/crawl-bfs.js`
    (`crawlNetwork`, probe/pollNeighbors **injected** → unit-tested with zero network).
    It processes each BFS depth-level with a **bounded worker pool**, then a barrier
    updates order-dependent state (`seenName` dedup, `discoveredBy`, `results`) iterating
    the frontier **sorted by IP** → deterministic: `pool=1` and `pool=N` give identical
    output (unit test with skewed latencies + live lab: same device set at pool 1 vs 4).
    Only the *deep* phase parallelises (authenticated SNMP polling of discovered devices —
    not a scan signature); the base host sweep stays sequential/paced (**anti-IDS**).
    Pool = `CRAWL_POOL` (default **4**, clamp 1-32): low on purpose — socket footprint =
    pool (Raspberry-friendly) and the lab returns-knee is ~4-6 (floor = slowest device).
- **Discovery engine — DHCP-as-source + macsuck device location (2026-07-04).**
  Two "see it without a ping" additions, from live testing (Synology DHCP + a Zyxel GS1900):
  - **DHCP leases as a discovery source.** The sweep route (`/api/discover`) accepts
    `dhcpLeases` in the body (the frontend sends `store._dhcpLeases`). After the sweep it
    appends a candidate row for every leased IP **inside the scanned subnet** that wasn't
    already found — decorated by the same `_decorateDiscoveryRow` pipeline (OUI vendor,
    hostname), `alive:false` (observed, **not** pre-selected — manual-first). The scorer
    (`_buildDiscoveryMeta`) gains a `dhcp` evidence (weight 14, an authoritative IP-MAC
    binding across all VLANs) that **replaces** the generic `mac` evidence for a lease row
    (no double-count, honest source label). Works with **zero SNMP**. Frontend marks the row
    `_via:'dhcp'` (source badge + "observed" state).
  - **macsuck — `locateMacsOnEdge(fdbBySwitch, opts)` in `lib/correlate.js`.** The crawl
    (`/api/discover/topology`) already got each switch's FDB from `pollNeighbors` (`fdbTable`)
    and threw it away; it now collects it and, at end-of-crawl, locates every target MAC
    (crawl results + `targetMacs` the frontend passes from the sweep) on its access port,
    emitting a single `type:'located'` SSE event `{ edges:{ [macLower]: {switchIp, switchName,
    ifName, macCount, edge, shared, ambiguous} } }`. The frontend `_discApplyEdges` matches by
    lowercased MAC and renders `_discEdgeBadge`. Placement rule (netdisco-canonical): the
    **edge** = the port with the fewest co-learned MACs (`<= edgeMax`, default 4 → direct
    link); if none, the least-busy **non-LAG** port → **shared** ("behind *port*", an AP /
    unmanaged switch); a MAC seen only on a busy **LAG uplink** is left unplaced. Virtual NICs
    excluded. **Pure, vendor-neutral** (BRIDGE-MIB `dot1dTpFdb` / Q-BRIDGE `dot1qTpFdb`).
    ⚠️ **Needs a switch that exposes the FDB over SNMP** (real Cisco/Aruba/HPE/Zyxel/Arista):
    the lab's Cisco vIOS returns an empty bridge FDB (same limit family as `vmVlan`/`dot1qPvid`
    — proven by escalating probes incl. ping-then-read and `community@vlan`), so macsuck is
    unit-tested + validated on real hardware (Zyxel GS1900), not on vIOS. Manual-first: the
    location is a badge hint, not an auto-created cable (auto-cable-on-import is a deliberate
    follow-up).
- **Discovery engine — scan speed, confidence pre-select, de-dup, BYOD vendor (2026-07-04).**
  From live testing on a real `/24`:
  - **Single-ping sweep + ARP-authoritative liveness.** The spaced double-ping retry (below)
    doubled the time spent on every dead IP; on a full `/24` of empty addresses that pushed the
    scan past the client timeout (~3× slower on dead IPs, measured). The sweep now sends **one**
    ICMP probe by default (`pingRetries` still opt-in, 1–4). Reliability moves to **ARP**, the
    authoritative liveness on a LAN (as nmap does): after the sweep, an on-segment host with a
    **local ARP entry** is marked `alive` even if its ICMP reply was lost (`pingReachable` stays
    false → it weighs as ARP in scoring, not a fake ping), so ICMP-filtered hosts appear too.
    Cross-subnet is safe — the local ARP cache holds only the gateway for off-segment IPs, so no
    false positives. `server/routes/discovery.js`.
  - **Stealth (anti-IDS) pacing — opt-in (2026-07-07).** The base sweep pings *unknown* IPs, the
    one phase with a scan signature that can trip a rate-based IDS/IPS on the network being
    documented. `POST /api/discover` with `stealth: true` (or `scanDelay: <ms>`) **serialises** the
    sweep (concurrency 1) and spaces probes by a **jittered** delay (default 400 ms ±30% — a fixed
    interval is itself a detectable cadence) — nmap's polite/T2 profile. Enrichment/deep are also
    serialised. It covers **only** the base sweep; the deep/LLDP-CDP polling of already-known
    authenticated devices stays parallel (`CRAWL_POOL`) as it isn't a scan signature. Default is
    unchanged (fast/parallel). No hosts lost (same alive set on vs off, validated live). Pure
    `_stealthDelayMs` (jitter, injectable rand) in `server/netscan.js` + unit tests.
    **Refined 2026-07-09:** Stealth also **randomizes the target order** — a sequential
    `.1→.254` sweep is a scan signature just like a fixed interval — on *both* the ping sweep
    **and** the SNMP/enrichment phase (each an independent shuffle; the enrichment phase also
    inherits the jittered pacing). Pure `_shuffled` (Fisher-Yates, injectable rand) + unit tests;
    verified end-to-end against the real `/api/discover` route (ping+SNMP sequential on
    Normal/Safe vs shuffled on Stealth; concurrency 64/32/1 and 16/8/1).
  - **Windows PC names via NetBIOS (2026-07-09).** A Windows host speaks no SNMP and rarely
    advertises its name over mDNS, so it came out nameless. The base flow now resolves it with a
    single **NBSTAT** (NetBIOS node-status) query sent **directly over UDP 137** per *alive,
    still-nameless* host (~40 ms, cross-platform; the `nbtstat` CLI probes every local NIC and
    waits out the dead ones — 10–30 s+ on multi-NIC hosts — kept only as a Windows fallback) — on
    the **Normal and Safe** cadences (a single NBSTAT on a known-alive host is within Safe's light anti-IDS; gentler
    concurrency on Safe), **off on Stealth** (no NetBIOS footprint). The box running InfraNet
    appears in its own scan but `nbtstat` can't query its own IP, so the **local host is named from
    `os.hostname()`** in every cadence (no network probe). Windows-only (nbtstat); a NetBT-disabled
    host with SMB open still relies on the **SMB** identity signal. `server/routes/discovery.js`.
  - **Pre-selection gated on confidence (15%).** A ping-only phantom (the exit-code artifact
    below) scores ~10% (only the `reachability` evidence); anything real starts at ~20% (a bare
    ARP MAC is ≈22%, SNMP ≥57%) — the bands don't overlap, verified with the real scorer on the
    lab. Discovery now pre-checks a row only if `alive && confidence ≥ 15`; the phantoms stay
    visible (greyed `.disc-lowconf`, hand-selectable) but out of the default import
    (`DISC_PRESELECT_MIN_CONF`, `src/app-discovery.js`).
  - **De-dup across sweep and crawl.** The crawl/ARP-SNMP de-dup set (`knownIps`) now starts from
    every swept IP (`store._discResults`), not just the seeds, so a host already found by the sweep
    isn't proposed twice when it also appears in a switch's SNMP ARP table. MACs are normalized to
    one canonical **uppercase** form for all sources (`_discEnsureMeta`), so the same device can't
    slip a MAC-based de-dup (the sweep emitted uppercase, the ARP path lowercase).
  - **Vendor identity ≠ device type + one classifier.** The fusion scorer never keyword-matches the
    vendor *company name* for the generic type nouns `gateway|switch|router|firewall` (those are
    stripped before the vendor enters the type text) — so a "Gateway Inc." PC isn't a router; type
    comes from behaviour/structure (sysObjectID map, `sysServices` bits, `sysDescr` product tokens,
    TCP probes, SMB/NetBIOS role) and the vendor's real *brand* tokens still vote. NetBIOS is off by
    default on modern Windows (nbtstat is silent) → the live "it's a Windows host" signal is **SMB**:
    port 445 + enumerated shares (or RDP/WSD) and **no** print ports (9100/515/631) → `pc`, beating a
    printer-vendor NIC. The Discover UI now treats this server engine as the single source of truth
    (`serverAuthoritative` in `src/app-discovery.js`); the thin client `_guessType` only fills gaps.
    `engine/fusion-scorer.js` is the single authoritative classifier (`server/classify.js` wraps it);
    the in-line "legacy twin" was removed once the fusion path was proven, with the 55-device
    `test/classify-behaviour-freeze.test.js` as the behaviour freeze.
  - **Signal tiering — a measured signal always beats a vendor-identity inference (2026-07-07).** A
    per-vendor MAC-OUI plugin proposes a device-type *candidate* (Zyxel→router, D-Link→router, …); that
    guess used to be scored high enough (≤80) to beat a real banner/model/port signal, so a Zyxel box
    whose web page reads *"Intelligent Switch"* was typed `router`. The OUI device-type is now weighted
    like the other identity hints (~45), so **any** measured signal outranks it — one rule for every OUI
    plugin, **no `plugins/oui/*` vendor file edited** (that would be the per-vendor hack the vendor-neutral
    rule forbids). Behavioural detection is by **protocol, not brand**: Google Cast (`_castProbe` →
    `/setup/eureka_info` + ports 8008/8009 in `DEEP_TCP_PORTS`) → `tv` for any make, like RTSP→camera; and
    the **OS** fingerprint decides `mobile` (Android/iOS) vs `pc` (`plugins/os-fingerprint.js` emits the
    type, not the brand). A device known *only* by a vendor/OS inference (a MAC with no measured signal)
    has its confidence capped — honest, manual-first. `engine/fusion-scorer.js`, `server/classify.js`,
    `plugins/os-fingerprint.js`, `server/netscan.js`, `server/routes/discovery.js`; validated by two live
    LAN scans (Zyxel switch, Shield/Chromecast→tv, Huawei tablet→mobile; every correct device unchanged).
  - **Merge-guards on the render path (audit F4/F5).** The guard that stops a next-hop/gateway MAC
    from collapsing remote hosts onto the gateway node ran only on import; it now also runs on the
    preview/table index (`_discAttachMergeGuards`), so a remote host no longer inherits the gateway's
    type and the New/Update badges match what import does. A blocked (next-hop) MAC is treated as
    absent throughout `_discFindExistingDevice`, so it can't raise a false IP-vs-MAC conflict.
    `src/app-discovery-classify.js`.
  - **BYOD vendor from the device's own name.** A randomized/private Wi-Fi MAC has no OUI, so the
    vendor was blank. The vendor resolution now falls back to a **hostname/mDNS brand** the device
    announces (`iPhone-…`→Apple, `Galaxy…`→Samsung, …; `_vendorFromHostname`, conservative list, no
    false positives), and where nothing is derivable the table shows an honest **"Private · random
    MAC"** label instead of an empty cell (`_discVendorLabel` + `isRandomizedMac`). The IEEE
    "Private" OUI value is left untouched. Vendor-neutral. `server/classify.js`, `src/app-discovery.js`.
  - **Table + hidden-panel UX/perf.** The Scopri table uses a fixed column layout (`.disc-scan-table`,
    `table-layout:fixed`) and the "SNMPv3 to configure" badge is shortened to "v3" next to the SNMP
    source. And `renderProps()` is skipped in `_renderAllNow` when the panel is hidden and nothing is
    selected (the floor branch scans every VLAN's IPAM), with the IPAM lookups memoized per render
    frame — a speed-up on large projects (audit F6). `src/app-render-core.js`, `src/app.js`,
    `src/app-properties-floor.js`.
- **Discovery engine — hardened by live multivendor PnetLab validation (2026-07).**
  - **Access VLAN — `vmVlan` fallback + a manual-first guard (addressed).** The access VLAN
    is now read from CISCO-VLAN-MEMBERSHIP-MIB (`vmVlan`, `9.9.68.1.2.2.1.2`, per ifIndex)
    when Q-BRIDGE `dot1qPvid` doesn't carry it — standard-first (used only where PVID is
    missing/1 and `vmVlan` > 1), vendor-neutral (empty subtree on non-Cisco → no effect).
    ⚠️ The standard read itself records **nothing** when the decoded value falls outside
    1..4094: `dot1qPvid` is a `VlanIndex`, so a zero there is a decoding error and not a port
    in VLAN 1, and it used to be written down as 1 — a failure wearing the shape of a
    measurement, which on a device that switches VLANs carries enough authority to outrank a
    hand-documented network. An unreadable PVID is now an absence, which is also why `vmVlan`
    fills it: because there is nothing there, not because it beat a 1.
    And an SNMP read of VLAN 1 (default/native, or simply not exposed on an image) never
    overwrites a hand-documented non-default VLAN (`_snmpVlanToUi` guard). `drivers/snmp.js`,
    `src/app-snmp.js`. *(Lab image `vios_l2` exposes **neither** `dot1qPvid`-real **nor**
    `vmVlan`, so on that lab the manual-first guard is what protects the documented VLAN; a
    real Cisco IOS/NX-OS reads it correctly via `vmVlan`. Trunk VLANs come through fine via
    CISCO-VTP-MIB `9.9.46`.)*
  - **The ping-sweep retries, now SPACED** (default 2, `pingRetries` 1–4; ~200 ms between
    attempts) so a host that drops the first ICMP (a VPCS, a slow stack, ARP-warmup behind a
    gateway) isn't missed. Live measurement showed the loss is **bursty/correlated**: two
    back-to-back pings fail *together* ~27% of the time on a rate-limited path, so an unspaced
    retry is nearly useless — a small gap drops the retry outside the loss window. Cost falls
    only on hosts that already missed the first try. Applies to the reachability audit too.
    `_pingHostRetry`, `server/netscan.js`.
  - **No false SNMPv3 on non-SNMP hosts (addressed).** The v3 credential-less detection used
    to treat *any* non-timeout error as "live v3 agent needing credentials"; an ICMP
    port-unreachable from an alive host with no SNMP (VPCS/PC/IoT) raises a
    `ResponseInvalidError`, so those hosts intermittently surfaced as SNMPv3 "to configure".
    It now requires a genuine USM **remote engineID** — the authoritative engineID present
    *and* different from net-snmp's own local engine — a signal only a real agent produces.
    Vendor- and library-version-neutral (no PEN/prefix hardcoded). Validated live: VPCS/PC/SRV
    → 0 false v3, Cisco/VyOS still detected. `_v3RemoteEngineDiscovered`, `drivers/snmp.js`.
  - **Off-segment, ping-only hosts surfaced via SNMP ARP (addressed).** On a path that
    rate-limits ICMP, a full `/24` sweep saturates it and even the gateway is intermittently
    lost (measured: a VPCS surfaced in 2 of 5 sweeps), and a device that speaks **neither SNMP
    nor LLDP/CDP** and is **off-segment** (no local ARP MAC) has ICMP as its only signal — so
    retry-spacing alone can't guarantee it appears. The **LLDP/CDP crawl** now also reads the
    **SNMP ARP table** (`ipNetToMediaTable`, already returned by `pollNeighbors` — previously
    discarded) of every crawled switch/router and proposes the hosts it sees at L2/L3: the
    off-segment host's IP+MAC (hence OUI vendor) is known **with no ICMP**. Gated on the
    existing *"Expand via LLDP/CDP"* toggle (no new switch); noise-bounded to the **scanned
    subnet** (`scanCidr` → the client passes it, the route filters ARP IPs to it); MAC unicast
    only, deduped, capped at 256 with a logged/emitted note (no silent truncation). Presented
    as **observed, low-confidence, NOT pre-selected** (`snmpReachable:false, alive:false`),
    with an "ARP (via <switch>)" badge; **refined by the already-imported DHCP leases**
    (`store._dhcpLeases`): a MAC/IP match attaches the real hostname and lifts confidence
    (seen in ARP *and* DHCP = a real host, not a stale ARP row). `buildArpCandidates`
    (`lib/correlate.js`), `server/routes/discovery.js`, `_discArpRow` (`src/app-discovery.js`).
    Validated live: the lab VPCS (`10.10.10.100`, missed 2–3/5 by the sweep) is proposed from
    SW-CORE's ARP without any ping.
  - **The ping sweep no longer trusts the `ping` exit code on Windows (addressed).** There,
    `ping` exits `0` even when a router answers *"destination host unreachable"* or *"TTL
    expired in transit"* in place of the target, so empty IPs **behind an L3 gateway** were
    counted as live (the gateway rate-limits the ICMP errors → a handful of scattered phantoms,
    not the whole subnet). On Windows only a genuine echo reply counts now: its text always
    carries `TTL=`, in every display language, and the intermediate ICMP errors never do — so
    the exit code is ignored. On Linux/macOS the exit code is reliable and stays, with the reply
    marker as a fallback. `_pingResultIsAlive`, `server/netscan.js`; the Italian and English
    unreachable replies, the TTL-expired one and an empty output are pinned in
    `test/ping-retry.test.js`.
- **Discovery engine — SNMP port mapping is now ifName-anchored (2026-07).** A live
  **multivendor** PnetLab run (Cisco vIOS ×3, MikroTik RouterOS, VyOS and Ubuntu/net-snmp
  + VPCS; two LACP bundles, four VLANs, L3-lite) confirmed recognition / HOST-RESOURCES /
  LAG-trunk-VLAN reads, and surfaced a merge bug: `applyPollResult` mapped SNMP interfaces
  to ports **positionally** (`${nodeId}-${idx+1}`), so on a **hand-documented** switch whose
  port order didn't match the device's ifIndex order, a trunk/Port-channel member's data was
  written onto a port where an endpoint was cabled → the endpoint got pulled into the LAG.
  Now interfaces are matched **by ifName** (stable re-syncs with `snmp-server ifindex persist`),
  a hand-cabled port without an ifName is **preserved** (never clobbered), and a genuine
  endpoint-vs-trunk conflict is **surfaced** (`portReconcileConflicts` → amber panel warning),
  not silenced. The LLDP-LAG naming was also fixed to pick the aggregator that actually
  connects the peer (by `lagId`/trunk VLANs). `src/app-snmp.js`, `src/app-autolink.js`,
  `src/app-properties-node.js`. Follow-ups from a live re-test: the reconcile warning now
  fires **only** on a genuine access-vs-trunk mismatch (not on hand-documented trunk members);
  the ghost-cable *"port down for N syncs"* check **ignores hand-cabled ports without an
  ifName** (their status is stale/mis-mappable, `src/app-drift.js`); and when a single manual
  cable to a confirmed LLDP/CDP neighbor sits on a port without an ifName, the **real interface
  name is backfilled** onto that documented port (in SNMP form) and freed from the positional
  one it had landed on, so future syncs match it by ifName (`src/app-autolink.js`). A related
  guard: an **FDB uplink-resolution cable** (`MAC-UPLINK`) — where a documented switch's MAC is
  learned on a *local* LAG port but the *remote* end (often an SNMP-blind switch) and its port
  are only guessed (`<node>-1`) — is **never promoted to a LAG member**. Even though the local
  port is a LAG member and both ends are switches (which would otherwise stamp `lagLogicalKey`),
  the link stays a single *"Inferred · to verify"* cable so `linkState()` doesn't mislabel it
  "LAG"; the guard runs at both the candidate-build and the dedup (`_refreshLagMeta`) passes, the
  latter also self-healing any link previously mislabelled (`src/app-autolink.js`). Manual-first
  and vendor-neutral throughout. The Scopri **crawl** also no longer blanks the vendor of an
  LLDP/CDP-discovered neighbor: the backend resolves it from `sysObjectID` (e.g. Cisco from
  PEN 9), but a stale `vendor:''` default in the merge was overwriting it *after* the spread,
  so crawled devices showed Vendor "—" while a directly-scanned device kept it. The merge now
  preserves the resolved vendor (`_discCrawlRow`, `src/app-discovery.js`). The two discovery
  items this section used to leave open — the ping-sweep **exit-code false positive** and the
  **off-segment ping-only** miss under sweep load — are both addressed (above).

### Undo: the snapshot is taken before the change

Every operation calls `pushHistory()` first — "remember how it is now" — and then changes the
project, so the history holds the states *before* the operations and the current state is in no
list until it is needed. `lib/undo-history.js` is the model. `record` drops the redo tail and
never records the same state twice (two calls in a row, or one right after an undo, are one
point). `undo` keeps the current state after the points when it differs from the last point, so
redo has somewhere to go — and a change nobody recorded (a panel field, a Sync run) can be undone
and redone too (see the last bullet below for what that means for a panel field); "differs" is decided by comparing, not by a flag. The flag in `app-history.js`
(`_pending`, lit by `pushHistory` and `markDirty`, cleared by undo, redo and `resetHistory`) lights the buttons and decides only whether the comparison needs a
fresh snapshot: with nothing pending the current state *is* the last point, and nothing is serialised. `redo` does the same comparison: a change made after an undo is kept as
a point right after the one it came from, so redo moves on and Ctrl+Z after it comes back to the change. The history is replaced, never edited in place, and keeps 60 points
(a snapshot is the whole project: hundreds of KB at 500 devices).

What is not obvious from the code:

- A caller that snapshots *after* changing breaks the order: the snapshot is the new state and
  undo removes nothing. Dragging used to; it takes the photo when the drag arms (`app-pointer.js`,
  `takeSnapshot`) and records it at the end, and only if the project changed — which also makes
  moving a rack icon and resizing a room undoable, and means a drag that ends where it started, is
  dropped outside the floor plan (the tile goes home) or is refused leaves no point, no lit Undo
  button and nothing to save. A tile dropped on the "import VM" zone is put back at its start
  *before* it is absorbed, and the absorption records the photo taken at the start (`absorbNodeAsVm`
  takes it as its third argument), so undoing it returns the tile there and the drag plus the
  absorption are one step.
- The photo (`photo()` in `lib/undo-history.js`) leaves out five top-level keys: `bgImage`,
  `auditLog` and the view — `floorView`, `rackView`, `currentRack`. Where you are looking is not an
  operation: with the view inside, a pan or a rack-tab change after a Ctrl+Z was "an unrecorded
  change", the next Ctrl+Z undid only the view and threw the redo away. `rebuild()` re-attaches the
  five from the *current* state when it restores a photo (the shown rack must exist in the restored
  project, otherwise the first one opens). The one operation that replaces the whole project, JSON
  import, calls `pushHistory({ withOutside: true })`: its point carries the background, journal and
  view as they were, and undo/redo restore those instead of re-attaching the imported file's.
- The automatic monitor's rounds (`dataOnly`, scheduled Verify with `silent`) do not call
  `pushHistory()`: nobody asked for them, and a point per round would make the first Ctrl+Z undo an
  invisible update. A manual cable validates (wall-port, full-port and radio rules) as if the
  automatic cables on its two ports were gone, because it replaces them, but removes them only once
  the cable is accepted and *after* `pushHistory()` — a refusal touches nothing and Ctrl+Z gives them back.
- The shortcut letter is compared lower case — with Shift or Caps Lock the browser reports
  `"Z"`, and Ctrl+Shift+Z never worked on a real keyboard. Inside a text field, text area or
  contenteditable (not read-only, not disabled) the key is the browser's: it undoes the typing,
  and the app must not intercept it. On a checkbox, a button or a select it is the app's. With
  Alt held it is not ours (AltGr is Ctrl+Alt on many keyboards).
- ⛔ A test of undo does a real operation and presses the real key, with the letter a keyboard
  sends (`Control+Shift+Z`, upper case). The suite once checked only that `undo` lit "Save", then
  a fake two-state history; both passed while the first operation after loading could not be
  undone and redo lost the last one.
- Not recorded, by decision: the panel fields (`updateN`, `updateFloorId`, `updateVm`, `updateP`…, and
  `setPortTrunkVlans` and the like) call `markDirty()` and never `pushHistory()`, so an edit made in a
  panel field is not a step of its own — but it is not lost to undo either, because undo goes back to the
  last *recorded* point, not by one edit. In practice, measured in the real app:
  · after an operation and a panel edit, ONE Ctrl+Z undoes both, and Ctrl+Y brings both back (the edit
    is part of the state that redo restores);
  · an edit made on a project just loaded, with no operation before it, cannot be undone at all — the
    Undo button stays off and Ctrl+Z does nothing;
  · an edit made after an undo is undone by itself by the next Ctrl+Z (undo keeps the current state
    after the points and goes back to the one before), and redo then keeps it instead of overwriting it.
  Giving each panel edit its own step would mean recording from `markDirty()`: a bigger change, not done.

### Touch input (tablet)

The canvases are driven by **Pointer Events**, so one finger already behaves like the mouse
(`src/app-pointer.js`): tap selects, drag moves or pans, a manual timestamp makes a double tap.
What a mouse cannot do lives in `src/app-touch.js`: the **two-finger pinch** (zoom + pan, floor
and rack). It listens in the *capture* phase on `document` (before every element and before the app's own handlers on `window`) and stops every event of a second finger,
so the rest of the app keeps thinking in terms of one pointer. Rules worth knowing:

- With a finger a device (and a room, and a rack icon on the map) is **grabbed by a long press**
  (`_TOUCH_HOLD_MS`, 300 ms; the pressed element gets `.touch-lifted`), not on first contact. A finger that
  moves *before* that wants to scroll, so the gesture becomes a pan of its canvas (`_dragBecomesPan`) and
  nothing moves. This is what makes the pinch safe: two fingers never land together, the first one is
  usually on a device (in the rack they cover most of the view), and an immediate grab turned a few pixels of
  drift into a device moved to another U. The mouse and the pen still grab immediately; the resize handle too.
- `touch-action: none` on `#floorplan`, `#rack-viewport` and the two dividers (`styles/12-touch.css`).
  Without it the browser takes the drag for page scroll and fires `pointercancel` at the first
  move, so nothing on the map would ever move.
- A second finger **turns the first finger's gesture into a pinch** (its pan or not-yet-grabbed device is
  released, so no device moves). If the first finger is already moving a grabbed device, the second one is
  ignored. A lone finger left after a pinch does nothing; putting the other back resumes the pinch.
- The pinch is computed from the **start of the gesture**, never step by step: the canvas point under
  the centre of the two fingers at the start stays under it (`pinchBegin`/`pinchApply`, in
  `src/app-search-zoom-rack.js`; floor and rack differ only in the origin of that formula), and the zoom
  clamp cannot make it drift.
- With a finger, "tap on empty map = deselect" is decided on **release**, not on touch-down: the first
  finger of a pinch always lands on the empty map, and deselecting there would drop the selection and
  the VLAN filter of someone who is only zooming. The drag threshold is 10 px instead of 5.
- `pointercancel` is handled (closes the gesture as a release at the last known point).
- Sizes that only make sense for a finger are behind `(pointer: coarse)`; the desktop does not change by a pixel.
- On iPad the same pinch also arrives as Safari `gesture*` events; the old handler yields while the
  Pointer-Events pinch is active (`setTouchPinchActive`) to avoid zooming twice. **Not verified on a
  real iPad** — the tests emulate touch in Chrome (`test/e2e/touch.test.js`).

---

## 10. Frontend evolution (ESM strangler **complete**, merged to `main`)

- **ESM migration (esbuild).** The glue (`app.js` + ~37 `lib/app-*.js`) was
  decomposed into explicit ES modules bundled by esbuild, one file at a time,
  behind a `window` bridge (`src/_bridge.js`) so the app stayed green at each step.
  Pure `lib/*.js` stay UMD-lite (imported as CJS by esbuild; Node tests unchanged).
  **Status: JS strangler complete,
  merged to `main` (`8b77e63`)** — all `lib/app-*.js` glue **and** the nucleus (`src/app.js`)
  are ESM in the bundle. The only remaining classic `<script>`s are the pure `lib/*.js` and
  `export.js` (by design).
- **`app.js` decomposition (2026-08-04).** The 2685-line nucleus was split into 5 cohesive
  ESM modules — `app-ipam.js` (IPAM occupancy), `app-cables.js` (cable labels/setters),
  `app-history.js` (undo/redo + dirty + audit), `app-index.js` (O(1) lookups + port/node
  getters), `app-props-tabs.js` (right-panel tabs + manual-value select). Each is a **verbatim
  move** (golden byte-identical). `app.js` keeps the bootstrap + the final
  `expose()` and **re-exports every moved symbol**, so the 41 consumer modules were untouched;
  both ratchets are unchanged. State init and the entry render/event wiring stay in `app.js`.
- **Retiring the `window` bridge — RESUMED (2026-08-01): finishing it, one panel at a time.**
  > **Decision (2026-08-01): the bridge-retirement effort is active again.** After being parked as a
  > spare-time task on 2026-07-14, Axis B is now being driven down deliberately — one properties-panel
  > surface per commit, each with full gates + a live check + a golden strip-compare (which proves the
  > only diff is the handler-attribute swap). The two ratchets stay monotonic regression guards (counts
  > may only *hold or shrink*); lowering them toward the structural floor is once again an active goal.
  > Axis A is already at its floor; Axis B is the active front.

  `src/_bridge.js` still lets not-yet-retired
  code reach globals (`win.*` read, `expose()` publish). Removing it has **two independent axes**,
  each tracked so it can only move forward:
  - **Axis A — `win.*` → real `import`.** A monotonic ratchet (`test/bridge-ratchet.test.js`,
    `MAX_WIN_REFS`, may only decrease) has driven `win.*` references down to the ceiling that
    constant holds — the number lives **only** there, because a copy of it here would go stale
    the first time the ratchet moves and nothing would notice. Every
    retirable function is imported, and mutable view-state (`state`, `selId`, `_history`, …) lives
    behind a proxy in `src/store.js`. The residue is the pure `lib/*.js` `<script>` globals and their
    `typeof` guards. ⚠️ It was long held that importing one of those would re-bundle the UMD and
    clobber the live `<script>` copy — which holds only for a lib that carries **state**. A stateless
    one can be imported by `src/` while its `<script>` tag stays for the classic scripts and the e2e
    page probes: both copies come from the same source and hold nothing, so in a built tree they
    cannot say different things. `lib/vlan-trunk.js` is the worked example (2026-08-23), and the
    reason to do it was not the count. Reaching an engine through a global obliges every caller to
    carry a fallback for «what if it is not there», and such a fallback answers instead of admitting
    ignorance: one of them stated that a cable which did not exist carried VLAN 1. An imported module
    is either present or the bundle does not start, so the case disappears together with the answer
    it was inventing.
  - **Axis B — inline handlers (`onclick`/`onchange`/`oninput`/…) → event delegation.** Inline handlers
    are *why* the bridge still exists (they resolve names in page lexical scope). **A monotonic ratchet
    (`MAX_INLINE_HANDLERS` in `test/bridge-ratchet.test.js`, may only decrease) now caps their count
    across `src/*.js` templates + `netmapper.html` static (the number lives only in that constant) — so Axis B is measured and
    converges like Axis A; the target is the structural floor. Every property-panel and overlay
    surface in `src/*.js` is delegated, and so is `netmapper.html`'s static shell (buttons, tabs,
    dialogs — each action registered in its owning module). At that floor the remaining handlers are all
    non-migratable with the current harness. The residue is: the PDF/label export dialogs and the
    export-menu items (JSON/PDF/labels — they call `export.js`, a classic non-ESM `<script>`;
    delegating them would need a `win.*` read and breach the Axis-A floor, so they stay by design),
    plus the canvas interactions (wheel / drop / dragover / contextmenu / scroll / mouse-enter-leave +
    `app-render-core`'s pointer-down) that still await new harness event types.** `src/app-delegation.js`
    installs **one delegated listener per event type** on the document — `data-act` for `click`,
    `data-change` for `change` (selects, checkboxes, file inputs, committed numbers), `data-input` for
    live `input` (typing), `data-focus` for `focus` (attached as `focusin`, which bubbles — plain
    `focus` does not), `data-blur` for `blur` (attached as `focusout`, which bubbles — used to commit a
    field on focus loss), `data-dragstart` for `dragstart`, `data-toggle` for a `<details>` accordion's
    `toggle` (which does **not** bubble, so it is delegated in the **capture** phase), and `data-keydown`
    for `keydown` (the handler receives the event) — so an element
    carries `data-<type>="key"` (arguments read off the element via
    `el.value`/`el.checked`/`data-*`), and the module that **owns** the function registers
    `{ key: (el) => fn(el.value) }` at load: the handler is an **imported** function, off `window`. For a
    menu, the owner registers the toggle and each item is registered by the module that owns that item
    (importing the owner's `close` helper). Migrated so far: undo/redo, the rack/zoom/palette toolbar,
    the account + Report header menus, the project toolbar (`New/Rename/Duplicate/Delete/Save`), the AI
    assistant buttons, the non-click controls of the rack size (`change`), the palette search
    (`input`) and the CSV/DHCP import dialogs (file pickers, live-lease vendor selector, paste-area
    previews), the project + rack selectors plus the Discover/Topology "select all" and the
    deep-TCP-scan preference checkboxes (`change`), the map-image + JSON-import file pickers
    (`change`), and the global search box (`input` + `focus` + `keydown`). Of the static HTML the
    change/input/focus/keydown surfaces are done — only the export panel's remain (`export.js` classic)
    — but **~54 handlers are still inline** there (report/discovery/import/PDF-export click actions
    and status chips, plus the canvas interactions — wheel / drop / dragover / contextmenu / scroll /
    mouse-enter-leave — that still need new harness event types).
    The migration then moves into the **handlers inside dynamically-rendered templates** (rows/cards built by `innerHTML`
    at runtime) — these migrate identically, because a document-level delegated listener also catches
    events from elements created *after* load. Dynamic clusters done so far: the Discover table rows
    (`disc-row`/`disc-type`), the search-results dropdown (`search-pick`), the Drift panel's seven
    one-click actions (`drift-*`, with row keys/CIDRs in `data-key`/`data-cidr`), the three report
    overlays — Audit log, Spare ports and L3 (`audit-*`/`spare-*`/`l3-*`, VLAN id in `data-vid`), the
    Adopt modal (`adopt-close`/`adopt-apply`/`adopt-selall`; its entry points stay exposed),
    the Drift "Explain with AI" button (`drift-explain`) — which made `aiExplainDrift` the AI module's
    first ESM `export` (it was a bridge-only module until then) — and the whole **"Users & access" /
    "Change password" modal** (static tabs/close/create + dynamically-rendered user & token rows via
    `um-*`/`tk-*`/`chpwd-*`, ids in `data-id`), which retired twelve functions from the bridge and
    made `openUserManager`/`umSwitchTab` proper ESM exports imported by `app-ai.js` — fixing a latent
    bug where the admin "AI settings" entry never opened the modal because `openUserManager` was read
    as an (unexposed, undefined) `window` global — and three topology/management surfaces: the
    **management-protocols editor modal** (`mgmt-proto-*`; only the modal migrates — its static
    buttons plus the dynamically-rendered proto rows via `data-input`/`data-act` — while the gear that
    opens it stays inline because it lives in the golden properties panel), the **topology-crawl
    modal** (`topo-crawl-*`, the backdrop keeps its "don't close mid-crawl" wrapper behind the
    `ev.target === el` guard), and the **topology hover-tooltip** (`#topo-tip`, rendered by
    `_showTopoTip` in `app-popup.js`: `topo-create-link`/`topo-nav-rack` with the pair-key/rack-id in
    `data-*`). Then the **golden properties panels** themselves began migrating, one panel per commit:
    the shared **section accordions** (every `<details>` → `data-toggle="props-section"`, delegated in
    the capture phase — the harness gained the `toggle` type for this); the **cable panel**
    (`app-properties-link.js` — its twelve `setLinkProp` fields collapse to one `link-prop` action, the
    field in `data-lprop` and the trim/number coercion in `data-coerce`, and the harness gained `blur`
    via `focusout` for the trunk-VLAN commit); the **floor / project-context panel**
    (`app-properties-floor.js` — `updateVlanIpam`'s three fields collapse to `vlan-ipam-field`,
    `updateUiColor` to `ui-color`, the two `scaleBgImage` steps to `bg-scale-step`); and the **port
    panel + port popup** (`app-properties-port.js` + `app-popup.js` — the port-domain functions export
    from `app-ports.js`/`app-vlan-autopoll.js`, and the actions **shared across both surfaces**
    (`port-field` with the `vlanOvr` numeric coercion, `port-speed`, …) register **once** in the leaf
    module both import, so they are never double-registered, while the actions carrying a surface-specific
    tail — `renderProps()` in the panel, `closePop()` in the popup — stay local). The three header buttons
    (expand-all / collapse-all / reset-sections) are shared actions registered once in `app-properties.js`
    and reused by every panel. Every properties panel + overlay in `src/*.js` is now delegated, and so
    is `netmapper.html`'s static shell (buttons, tabs, dialogs); the residue is the `export.js`-backed
    export dialogs (classic `<script>`, inline by design) and the canvas interactions awaiting new
    harness event types. `_bridge.js` / `expose()` are deleted only when Axis B is finished. *(Side note: the AI help
    catalog in `lib/ui-catalog.js`, which reads the real button labels/tooltips, derives a button's action
    from `data-act` as well as `onclick`, so delegated buttons stay in the assistant's catalog.)*

    **Coercion — `data-ncoerce`.** A `change` handler receives a string, and the attribute says
    what to make of it: absent = string, `num`, `bool`, `int`/`int-empty` (clamped by
    `data-nmin`/`data-nmax`), `intdef`/`floatdef` (falling back to `data-ndef`), and the three
    **optional** ones — `intopt`, `floatopt`, `stropt`. Those three are how
    [paletto #2](docs/adr/no-invention.md) reaches the *input* surface: an empty or unparseable
    field yields `undefined` and `updateN` **deletes** the key rather than writing the default
    back, so "not declared" is expressible and the suggested value can sit in the `placeholder`
    without asserting anything. An editable field that arrives pre-filled is a statement, and the
    handover dossier — which omits whatever `spec` does not carry — would contradict it. What
    keeps a default is what is not a claim about the customer's equipment: drawing geometry,
    structural counts that generate real objects, and the parameters the tool itself connects
    with. `test/campi-non-inventati.test.js` scans the panel sources and refuses the next one.
- **ESLint gate (`eslint.config.js`, v9).** `no-undef` is enforced as a safety net where
  the module system is explicit (Node/CommonJS + UMD `lib/`) and is **off on `src/`** until
  the `window` bridge is retired (then it re-enables). Cosmetic rules are warnings, so the
  gate is green; it runs in CI via `npm run lint`.
- **Modular CSS + tokens.** The old `style.css` monolith was split into 11 ordered
  partials in `styles/` and no longer exists (loaded via `<link>` in cascade order, served
  by `/styles/:file`; the split itself is documented in `styles/README.md`). Design tokens (colors/surfaces/shadows; **radius**, **font
  families** and now the **type scale** applied; **spacing/z-index/transition**
  documented) live in `styles/01-tokens.css`. See **`styles/README.md`**.
  Two families only — `--font-ui` and `--font-mono` — and no new `font-family`
  outside them. An **address is interface text, not code**: IP, CIDR, MAC and
  gateway use `--font-ui` with `tabular-nums`, declared once in `02-base.css`
  with the class list; monospace is reserved for `<code>`/`<kbd>`, logs, API
  tokens, raw `sysDescr`, config textareas and the rack silkscreen.
- **Headless E2E.** `test/e2e/` drives the app in a real Chrome (Playwright on the
  system browser via `INFRANET_DEV_NO_AUTH`, isolated temp store) — see §7.
- **Floor/rack navigation parity.** Both canvases pan via a `transform: translate`
  (floor: `floorView`, rack: `rackView.x/y` on `#rack-chassis-wrap`) + wheel-zoom;
  the rack's zoom percentage is also its **fit-to-width** button (`rack-fit` →
  `rackFitZoom`/`fitRack`), and turns amber while the chassis is wider than its
  viewport — the sides, and with them a device's state ring, fall outside
  `overflow:hidden` and there was no one-gesture way back. ⚠️ The break-even is
  measured with `offsetWidth`: `#rack-chassis-wrap` has a `transition` on its
  transform, so mid-animation `getBoundingClientRect()` reports a value between two
  zooms — and one already multiplied by the scale. ⚠️ Rack devices are all
  `position:relative` with no `z-index`, so paint order is DOM order: a device
  carrying a **state ring** (absent / declared-off / status-conflict) is lifted above
  its neighbours, or the one below repaints over the ring's bottom edge and the alert
  degrades into a single line;
  the rack has **no scrollbars** (`overflow:hidden`). Drag on empty area pans, drag
  on a device moves it, Space+drag pans anywhere. Rack px→U conversion reads the
  `--ru-h` token (`rackUPx()`), never a hardcoded unit height.