Document · doc-0002

Wave operating model

This document carries only what is specific to transceiver-exporter. The campaign model itself — run contract and run modes, the routing contract, authority and the thread pool, child lane briefs, external-contract freezing, the structural failure patterns, the unattended blocker contract, the goal-file template and the pre-flight checklist — lives in the Agent fan-out protocol (canonical) document. Read that first. Nothing here restates it; if a section below could be pasted into another project unchanged, it is in the wrong document.

The exclusive resource is hardware, and no agent has it

The exporter reads transceiver EEPROM through ethtool ioctls against real pluggable optics on a Linux host. No lane can reach that. The dev machine is macOS, CI is a GitHub ubuntu-24.04 runner with no optics, and there is no simulator, no capture corpus and no fixture set of real EEPROM dumps in the repo. CONTRIBUTING.md says as much: the unit tests require no hardware because they cannot exercise the part that matters.

Consequences, and they are binding:

Recurring defects in this codebase

Each of these has actually happened here, and each is shaped to happen again.

Per-scrape mutation of package-global descriptors. transceiver-collector/collector.go declares ~60 package-level *prometheus.Desc variables and builds every one of them in init(). They were previously rebuilt or mutated during a scrape, which is a genuine data race under concurrent /metrics requests (fixed in 36800a4). A new metric is a new package-global built once in init(). Never assign to a descriptor from Collect or from NewCollector, and never make a descriptor depend on per-scrape state. The concurrency test in collector_test.go exists to catch this and must keep running under -race.

Metric names drift between the code and the published docs. const prefix = "transceiver_exporter_" at transceiver-collector/collector.go:16 is the only source of the prefix, but docs/metrics.md restates every full metric name by hand. They have already disagreed once — emitted metrics used transceiver_ while the docs promised transceiver_exporter_, and fixing it was a breaking change for every existing dashboard and alert. So: any change to a metric name, label set or help text is a two-file change, code and docs/metrics.md, in the same commit. A lane that touches one and not the other has not finished.

Unit-pair metrics get added by half. Laser power is exposed in both milliwatts and dBm, converted by milliwattsToDbm in transceiver-collector/util.go. The descriptor names carry the unit as a suffix (…DescMw) and the _dbm family mirrors it. Adding a power metric in one unit and not the other compiles, lints and tests clean, and is wrong. The same applies to the alarm/warning high/low threshold quartets — they come in fours, and three of four is the failure shape.

Version strings hardcoded in source. main.go once carried a literal 1.5.1 fallback that outlived the release it named. It is now dev; real builds inject the version through ldflags. Do not put a release number in Go source, CHANGELOG.md, or .release-please-manifest.json by hand — release-please owns all three, derives the bump from Conventional Commit subjects, and a hand edit either loses to it or corrupts its state. A ! or BREAKING CHANGE: in a commit subject is what produces a major bump, and one is already pending as an open release PR.

Container-runs-as-root looks like a defect and is not. The image deliberately runs as root because EEPROM access through ethtool needs it. A lane doing security hardening will find this and try to “fix” it; that change breaks the exporter’s only function. If it is to be revisited, the route is capabilities (CAP_NET_ADMIN/CAP_NET_RAW) actually tested against hardware — which, per the section above, no lane can do.

Lane conventions

transceiver-collector/collector.go is a single-owner file and cannot be split. It is one 544-line file holding every descriptor declaration, its init(), Describe and Collect. Two lanes editing it in parallel is not a merge problem, it is a semantic one: both will add descriptors to the same var block and the same init(). One lane owns the collector per wave, full stop.

The three genuinely disjoint lanes here:

Do not create the hub-injected paths. The published site is built by the m7kni/m7kni-net-site hub from docs/ plus docs.toml; the hub generates zensical.toml and copies in docs/overrides/, docs/stylesheets/brand.css, the project icons, the social card and docs/fonts/. All of those are gitignored on purpose. A lane that “fixes the missing theme files” by creating them commits drift that the manifest model exists to prevent.

Ownership and the escape hatch

A lane owns the files its brief names and nothing else. When a lane finds that finishing its task requires a change outside its boundary:

It stops and returns the question — it does not reach across. The reach-across is what produces two lanes silently editing one file. The return names the file, the change needed, and whether the lane is blocked or merely degraded without it.

The root then either widens that lane’s boundary explicitly, files the out-of-boundary change as its own task, or serialises it into the wiring pass. A boundary with no escape hatch is a stop condition wearing a safety label — if a lane cannot ask, it will either guess or stall, and both are worse than one round-trip.

The root owns every commit. Lanes never commit, never push, and never touch git state.

Run-end against this tracker

Task state is the record, so a run ends when the board is true:

Because this repo’s gate cannot see the hardware, the run-end report must state which claims are gate-backed and which are reasoned. “Tests pass” and “the decode is correct” are different statements here, and collapsing them is the specific dishonesty this repo is prone to.

View the source file on GitHub