# Lsdsk

> Use when inspecting or diagnosing storage hardware - which disk hangs off which controller, whether a SATA, SAS or NVMe link runs at the speed both ends support, whether a controller sits in a slot worthy of it, how worn an SSD is, whether reallocated or pending sectors are climbing, drive temperatures, controller firmware, or where there is room for another drive. Also use when reaching for lspci, lsblk, smartctl or nvme-cli to answer any of those, and when reading an lsdsk report and deciding what to actually do about a finding.

- Skill: `bitranox/lsdsk` (Agent Skill, multi-file: 31 files)
- Install (CLI): `npx skillmds@latest add bitranox/lsdsk`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bitranox/lsdsk/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: bitranox (https://skillmd.com/u/bitranox)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/bitranox/lsdsk

---


# lsdsk

Groups disks by the controller they hang off, compares every link against what
both ends of it could do, and reports what is worth acting on. It also names the
mainboard, from DMI, which is what makes its placement advice actionable. Linux
and Windows, no subprocesses, no network. It runs anywhere `--replay` is all you
need, macOS included; only reading real hardware is the two.

**Bare `lsdsk` gives a PERSON the interactive view and a PROGRAM the printed
page.** It looks at whether both ends are a terminal: where they are it opens
the full-screen application, because the whole machine on one page is more than
a reader takes in at once; where they are not - a pipe, a redirect, a CI log -
it prints the page. The exit code is the findings' in both, so `lsdsk; echo $?`
means the same thing either way. `lsdsk report` asks for the page by name,
whatever the terminal looks like.

**Being a subprocess does NOT mean you get the page. Say `lsdsk report` in
anything unattended.** Some callers hand their child a pseudo-terminal on both ends, and
there lsdsk cannot tell a program from a person: it opens the application and
waits for a keypress nobody can send. Measured in a notebook, where a CI job
hung on a bare `!lsdsk` for 900 seconds and was killed - IPython runs `!cmd`
under pexpect, so every notebook frontend does this. `script`, expect and
pty-allocating job runners allocate the same way, so treat them the same.

So `!lsdsk report` in a notebook cell, and `lsdsk report` in any scheduled or
unattended job, rather than reasoning about whether that caller counts as a
terminal. Getting it wrong costs a hang, and getting it needlessly right costs
nothing.

`lsdsk report` takes `--replay` like any other command, and the global options
still come first: `lsdsk --no-record report --replay file.json`.

That distinction matters when you TELL somebody to run it. If they want the
page on screen rather than the application, say `lsdsk report`.

For a TICKET or a handover, still send a snapshot rather than redirected text:
`lsdsk snapshot -o file.json` captures the raw reading, so the recipient can
replay every section at any width and any privilege question is settled by the
capture itself. A `lsdsk > report.txt` is a picture of one moment at one width.
A snapshot names the machine and every drive in it, so check who can read the
ticket before attaching one; `lsdsk snapshot` says the same on stderr when it
writes the file.

The page is the whole report: mainboard, problem summary, the PCI fabric,
the controller table, disk identities, wear and error counters, SMART
attributes, PCIe slots, counter trends, and every finding with its reasoning, in
that order. It already contains `trend` and `slots`, so running those again
after a bare `lsdsk` adds nothing. Run it first and nothing else, for a handover, a
ticket, or any machine you do not already know. Every subcommand is one section
of it, for when you already know which section you want. Unprivileged it still
renders every section and never aborts; what it could not read shows `-` and the
header names it, so check that line before quoting a counter as zero.

Bare `lsdsk` takes no `--format`: it is a group rather than a command. For a
structured capture of everything, use `lsdsk snapshot -o file.json`; for one
section's envelope, `lsdsk <section> --format json`.

## Running it

```bash
uvx lsdsk                     # at a terminal the interactive view, elsewhere the page
uvx lsdsk report              # everything on one page, by name. Start here
uvx lsdsk topology            # the problem summary and the PCI fabric, storage-only by default
uvx lsdsk findings            # every finding, with reasoning and a remedy
uvx lsdsk health              # wear, temperature, hours, error counters
uvx lsdsk smart               # every disk's SMART attributes against its thresholds
uvx lsdsk controllers         # controllers, PCIe placement, free ports
uvx lsdsk slots               # every PCIe port: what holds it, what is free
uvx lsdsk disks               # one row per disk
uvx lsdsk trend               # what each error counter is DOING, not just its total
uvx lsdsk record              # store one reading, print nothing, for a timer
uvx lsdsk tui                 # the same eight views, interactive (1-8, left/right, q)
uvx lsdsk snapshot -o m.json  # capture the raw reading
uvx lsdsk info                # version, homepage and the shell command name
uvx lsdsk config              # show effective configuration (--section thresholds for one)
uvx lsdsk config-deploy --target user   # write ~/.config/lsdsk so it can be edited (app|host need root)
uvx lsdsk config-generate-examples --destination DIR   # scaffold examples
uvx lsdsk --replay m.json     # render a capture from any machine
```

`--replay` works in either position on every command that reads a machine and
means the same thing, so `lsdsk --replay m.json health` and
`lsdsk health --replay m.json` are the same run; give both and the command's own
wins. `snapshot` is the exception: it captures the machine it runs on and
refuses `--replay` outright, as below. `--profile` is accepted after `config` and
`config-deploy`, but `config-generate-examples` takes it only before the command
and exits `2` otherwise. `--history-file` and `--no-record` exist only before the
command, so `lsdsk --history-file /var/lib/lsdsk/history.json record` is right and
`lsdsk record --history-file ...` is the usage error that exits `2`. Give that
path in full: a bare filename writes the store into whatever directory you
happened to be in. When in
doubt, `lsdsk <command> --help` lists what that command takes.

`fail` and `logdemo` also exist. They are not diagnostic commands: they are the
vehicles the traceback and logging tests drive through the real entry point.
Never reach for them to answer a question about hardware.

## Reading `lsdsk topology`

It is the PCI fabric, drawn root-down. Every root complex is a port of the
processor on the board, the bridges between are the PATH a drive's traffic
takes, and a controller's drives are its own table nested under it rather than
another level of fabric.

```text
showing storage and the bridges above it; --tree-density to change the detail level
legacy = no PCIe capability
linux-sas-hba   2 root complexes (0000:00, 0000:ff)   root ports to PCIe Gen3x8   95 PCI devices
   |     address       capable                running                name
   +-+-- 0000:00:03.0  Gen3x8 (7.88 GB/s)     Gen3x8 (7.88 GB/s)     Intel Corporation Xeon E5 v2 PCI Express Root Port
~  | +-- 0000:03:00.0  Gen4x8 (15.75 GB/s)    Gen3x8 (7.88 GB/s)     Broadcom / LSI Fusion-MPT 12GSAS/PCIe Secure SAS38
   |     device        model                         size  kind  bus   port    disk    link    temp  worn
~  |     /dev/sda      Samsung SSD 870 EVO 4TB     3.6TiB  SSD   SATA  12G     6G      6G       36C    1%
```

**The first line is the BOARD, not a bus.** It names the board where DMI gave a
name and the machine where it did not, how many root complexes the MACHINE has,
the best link the board's own root ports publish where one was read, and how
many PCI devices the capture holds. A tree whose first line were a bus label
would start one level below the thing that explains it.

**The default draws the LEAST of the fabric, so a missing device is usually the
setting rather than the tool.** The note above the tree says which:

- `storage-only`, the shipped default - storage and the bridges above it.
- `storage-and-siblings` - those plus the devices sharing a bridge with storage.
- `full` - every PCI device.

So a graphics card, a NIC or a Thunderbolt leg is absent by design at the
default. Ask for more with `--tree-density full` before concluding lsdsk cannot
see a device, and in the interactive view press `d`, which cycles the three from
least detail to most. Do not report "lsdsk does not show my GPU" as a defect
without saying which density you ran.

**The two hop columns are what the DEVICE can do and what it NEGOTIATED**, in
the tool's own words:

- `capable` - the best link this device publishes.
- `running` - what it actually negotiated.

Where the two differ the device is below its own maximum, which is the
comparison this view exists to draw. Read them against the PORT above it, which
is the row one level up: in the sample the HBA is `capable` of `Gen4x8` and
running `Gen3x8` because the root port above it is a Gen3 port, so the card is
not faulty and the board is the ceiling.

**A hop column holds a SYMBOL when there is no figure, and the section spells
out whichever one it drew.** Neither is a fault, and reading one as a fault is a
work order against hardware that is fine:

- `- = not read` - nobody published the register. Windows exposes a PCIe link
  for endpoints and for BRIDGES not at all, so every bridge row reads this
  there. It means UNMEASURED, never "down".
- `legacy = no PCIe capability` - the device has no PCIe capability at all,
  which is an ordinary legacy PCI part. It is a reading, not a gap.

The distinction matters because those two look alike and mean opposite things
about the EVIDENCE. `legacy` is a positive reading - the platform answered, and
the answer was "this part has no PCIe capability". `-` is the platform declining
to answer, so nothing has been established about that link either way. No rule
fires on either, and no device is ever called slow on a symbol.

**The detail panel adds two more, and each panel explains only the ones it
drew.** A cell reading `n/a` is a value that CANNOT exist for that subject,
which is not the same fact as a reading nobody took: the four numbered ATA
attributes on an NVMe drive, which publishes one fixed log page and no attribute
table at all, and the occupant fields of a socket the panel has just reported
empty. Read as a missed reading it sends somebody looking for a fault.

A figure written `<= Gen3x8 (7.88 GB/s)` is a CEILING rather than a placeholder.
An uplink is the lower of what the card supports and what the bridge above it
does, so with one end unread it is whatever the other end said and can only be
too high. The end that WAS read is a real measurement, which is why the figure
is marked instead of dashed. On Windows this is every PCIe controller, because
Windows publishes a link capability for an endpoint and none for a bridge.

**The marker column on the far left is the finding, not the link.** A `!` or `~`
sits on the row a finding names, so a drive with a perfectly good link can carry
one for wear or for CRC errors. Read `lsdsk findings` for what it is about
rather than inferring it from the row it sits on.

## Devices with no hardware behind them

A zram swap device, a loop mount, a ZFS zvol, a device-mapper or mdraid node:
the kernel provides these itself, so they have no controller, no link and no
SMART. lsdsk reads them, says so, and does not judge them - no rule fires on one
and no finding names one, because there is nothing to compare against.

It decides by asking the kernel where the device sits, never by its name. A
device with no hardware parent resolves under `/sys/devices/virtual`; a real one
resolves under its PCI path. So an optical drive, named `sr0`, is ordinary
hardware occupying a real port and is counted as one.

They are folded away rather than listed, because a Proxmox host has more of them
than drives. The topology tree and the disk table each end with a line saying
how many and of what:

```text
kernel-virtual devices, with no controller and no counters
   12 not listed: 8 loop, 3 zd, 1 zram   (--expand-virtual lists them)
```

`--expand-virtual` gives each one a row, before or after `topology`, `disks` and
`tui`. To make that the default on a host whose zvols are the point, set the
configuration key - `lsdsk --set display.expand_virtual=true disks` for one run,
or `expand_virtual = true` under `[display]` in the deployed
`config.d/70-display.toml` to keep it:

```bash
uvx lsdsk config-deploy --target user     # then edit ~/.config/lsdsk/config.d/70-display.toml
```

**This setting changes the screen and nothing else.** The devices are always
read, always counted in the header, and always present in `--format json`. A
program never has to ask for them.

`--format json` gives a machine-readable envelope on every command that
produces data, including `info`, `snapshot`, `record` and all three
`config` commands. `tui`, `fail` and `logdemo` have none, having no data to
structure, and `report` has none deliberately: it is the whole page, whose
machine-readable form is `snapshot`.
It carries `ok`, `command`, `data` and `skipped`,
so a caller can tell a complete answer from a partial one.

**The envelope's four keys, and what a caller may rely on.** `ok` is true when
the command did everything asked of it, and false when something was left
undone - it is NOT "the hardware is healthy", so never alert on it. `skipped` is
a list of sentences saying what was not done and why, empty when nothing was. A
reading one DEVICE refused appears there too, as `<reading>: <subject> - <reason>`,
so a run made as root can still be incomplete: a drive behind some RAID drivers
refuses SMART passthrough, and an AHCI port count is read by mapping the
controller's own registers, which some hosts deny outright. Those entries name
the drive or the PCI address, so a check does not have to interrogate the machine
again to find out which one said no.
`command` names the command that produced the payload. `data` is that command's
own result.

**A finding, inside `data.findings`, has five string fields: `severity`,
`subject`, `title`, `detail`, `action`.** `severity` is exactly one of
`critical`, `warning`, `hint` - those three words, lower case, and no others.
That is what a monitor branches on, and it is the only way to separate critical
from warning, because the exit code cannot: `1` means "warning OR critical", so
a check that must fire only on critical has to read the field.

```bash
lsdsk findings --format json |
python3 -c 'import json,sys
d = json.load(sys.stdin)
sys.exit(any(f["severity"] == "critical" for f in d["data"]["findings"]))'
```

Keep every continuation line at column 0. An indented one inside `python3 -c` is
an `IndentationError`, which exits `1` - the same code these checks use for
"found", so the monitor reports a critical on a machine that has none.

**A disk, inside `data.disks`, carries `node`, `path`, `model`, `serial`,
`firmware`, `wwn`, `size_bytes`, `kind`, `bus`, `controller_address`, `link`,
`pcie`, `health` and `readings_refused`.** Every one but `node`, `path`,
`model`, `bus` and `readings_refused` may be `null`, which means it was not read
rather than that it is zero. `readings_refused` is a list, empty on a drive that
answered everything, and each entry is an object of `reading` and `reason`: what
was asked for, and what the operating system said when it would not give it. `bus` is one of
`sata`, `sas`, `nvme`, `usb`, `virtual`, `unknown`; `kind` is `ssd`, `hdd` or
`unknown`. `link` is an object of `negotiated_gbps`, `drive_max_gbps` and
`port_max_gbps`, and a speed rule only fires when both ends are known.

**A controller, inside `data.controllers`, carries `kind`**, which is one of
`ahci`, `sas`, `nvme`, `raid`, `ide`, `other` or `unknown` - read from the PCI
class code, so `unknown` means unclassified rather than absent. It carries
`address`, `name`, `vendor`, `driver`, `firmware`, `link`, `port_count`,
`ports_used`, `upstream`, `upstream_address`, `upstream_name` and
`readings_refused` beside it. A filter for "the controllers that can carry disks"
is `is_storage_controller` below, not a list of these values spelled out again.

**`data.virtual_disks` is a second list of the same shape**, holding the devices
with no hardware behind them. It is always populated, whatever `--expand-virtual`
or `display.expand_virtual` says, and `data.disks` never contains one - which is
what a check for "nothing without a transport among the real drives" reads:

```bash
lsdsk disks --format json |
python3 -c 'import json,sys
d = json.load(sys.stdin)["data"]
sys.exit(any(x["bus"] == "virtual" for x in d["disks"]))'
```

That check is Linux-only, deliberately. On Windows `bus` is `virtual` for a
HYPERVISOR disk, which is the machine's real storage, so the same line would
fail on a healthy guest. It is `data.virtual_disks`, not the bus value, that
means "provided by the kernel with nothing behind it" - so the check that holds
on either platform asserts the two lists stay disjoint:

```bash
lsdsk disks --format json |
python3 -c 'import json,sys
d = json.load(sys.stdin)["data"]
virtual = {x["node"] for x in d["virtual_disks"]}
sys.exit(any(x["node"] in virtual for x in d["disks"]))'
```

**`snapshot` is the exception to all of this.** With `-o` it writes the raw
reading - the bytes the platform gave, for `--replay` - which is a different
document from the envelope above and is not a list of disks.

**A monitor must read `data.privileged` too, or it reports clean on a blind
run.** Unprivileged, no SMART is read, so no wear or counter finding is ever
raised and the check above passes on a machine nobody looked inside.
`data.privileged` and `data.devices_accessible` are both booleans in the same
payload: treat `privileged` false as "unknown", never as "healthy".

**In a Python program, skip the subprocess.** `lsdsk.adapters.hw.snapshot`
gives an inventory and `lsdsk.domain.diagnostics.diagnose` gives the findings as
objects, with the same rules the CLI runs:

```python
from pathlib import Path

from lsdsk.adapters.hw import snapshot
from lsdsk.domain.diagnostics import diagnose

inventory = snapshot.load(Path("capture.json"))  # or snapshot.collect() for this machine
findings = diagnose(inventory)  # each has .severity, .subject, .title, .detail, .action
```

Both calls raise `lsdsk.domain.errors.ConfigurationError` - `load` for a file
that is unreadable, malformed, or not a snapshot this version parses, and
`collect` for a platform with no hardware reader, which raises the
`UnsupportedPlatformError` subclass. A path that is not there at all raises the
`MissingFileError` subclass, so a caller that wants to tell an absent snapshot
from a present but malformed one catches that before the base class rather than
reading the message. `collect` also lets a `PermissionError`
through when the read is refused outright, which is the exit 13 the CLI leaves.
Catch `ConfigurationError` and `PermissionError` around either call in anything
long-running.

`diagnose` returns a tuple and takes two more keyword arguments the CLI fills
in: `thresholds`, and `history` for the trend rules. Omit them and you get the
SHIPPED defaults with no history, which is not what the same machine's `lsdsk`
would report if its configuration deploys different thresholds.

**Do not hand-roll what `lsdsk.domain.diagnostics` already exports.**
`count_by_severity(findings)` returns a count per `Severity` including the zeros,
which is the whole split at once rather than the one-severity test the shell
check above can make.
`is_storage_controller(kind)` is the test the CLI filters controllers with.
`format_pcie_sentence(speed_gtps, width)` writes a link the one way the whole tool
writes it, so a sentence you compose agrees with the table beside it.
`interface_demand_gbytes(disk)` and `attached_demand_gbytes(controller, inventory)`
are the demand figures the oversubscription rule reasons from. Every rule is
callable on its own, which is how you run one without the rest:
`diagnose_disk_link(disk, inventory)`,
`diagnose_controller_link(controller, inventory)`,
`diagnose_controller_oversubscription(controller, inventory)`,
`diagnose_port_allocation(inventory)`, `diagnose_health(disk, series, thresholds)`
and `diagnose_firmware_consistency(inventory, thresholds)`. `DEFAULT_THRESHOLDS` is
what those rules fall back to when no `Thresholds` is passed, and its fields carry
the shipped figures: `wear_warning_percent` 80, `wear_critical_percent` 95,
`crc_errors_significant` 100. Build your own with `Thresholds(...)` and pass it,
rather than reading a field off the default and comparing yourself.

**`lsdsk.adapters.hw.snapshot` holds more than `load` and `collect`.**
`read_current_machine()` returns the raw reading as a dict and `save(capture,
path)` writes it, which is the pair `lsdsk snapshot` is made of.
`parse_capture(reading)` types that dict and `build_from(reading)` turns it into an
inventory, so a capture already in memory never has to reach a file.
`current_platform()` names the reader this machine has. `SCHEMA_VERSION` and
`OLDEST_READABLE_SCHEMA` are the capture version this build writes and the oldest
it still reads, which is what tells you whether an archived capture will replay
before you try it.

The package's own `__all__` holds `get_config` and `print_info`, which are the
configuration loader and the `info` command's printer; neither is what you want
for hardware. Reading a machine needs privileges exactly as the CLI does, and
`snapshot.load` needs none. Run `lsdsk --help` and
`lsdsk <command> --help` for current options rather than trusting a list here.

The global options are `--replay`, `--profile`, `--history-file`,
`--no-record`, `--expand-virtual`, `--tree-density`, `--traceback`, `--env-file`,
`--version` and `--set SECTION.KEY=VALUE`, the last being how you move a
judgement for one run. Four also work after the subcommand: `--replay` on any
command that reads a machine, `--profile` on the `config` commands,
`--expand-virtual` on `topology`, `disks` and `tui`, and `--tree-density` on
`topology` alone - given globally it reaches every view that draws the fabric,
including a bare `lsdsk`. Every other global option is refused after the
subcommand with exit `2`.

The figures the rules turn on are all seven `[thresholds]` keys, so none of them
is fixed: `wear_warning_percent` 80, `wear_critical_percent` 95,
`crc_errors_significant` 100 (below it a CRC count is a hint), `quiet_expected_min`
10.0 (the line the whole "were due" idea rests on: fewer expected than this and
the tool refuses to call a counter quiet), `wear_projection_min_points` 2 (wear is
an integer, so one point is one unit of resolution and a rate from it is noise -
under this much measured movement no wear-out date is projected), `min_span_hours`
1 and `mixed_firmware_threshold` 2. Override one for a run with
`lsdsk --set thresholds.crc_errors_significant=10 findings`, or permanently by
editing the deployed `config.d/60-thresholds.toml`.

Exit codes: `0` nothing actionable, `1` a warning or critical. Hints never set a
non-zero code; a hint is a ceiling, not a fault.

**Only the eight section commands, `report` and bare `lsdsk` set `0`/`1` from findings.**
`record`, `snapshot` and the `config-*` commands exit `0` on success whatever the
hardware says, so never read their code as a verdict about the machine. They are
not always `0`, though: one that cannot write what it was asked to write leaves
`13` or `1`, and for `record` that code is the only channel there is, since it
prints nothing at all in human mode. An internal error is NOT one of the things
`1` can mean: a crash leaves `70`, so a check can act on `1` as a verdict
without reading stderr first to find out whether the tool merely broke. A crash
writes no failure envelope of its own, because the handler that answers it never
reads `--format`: the exception goes to stderr in both modes and stdout holds
nothing, or a truncated report if the crash landed mid-write.

| Code  | Means                                                                                                                                                                                                                                                                                                                                        |
|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `0`   | A reporting command found nothing actionable. `record`, `snapshot` and `config-*` exit `0` on success regardless                                                                                                                                                                                                                             |
| `1`   | A reporting command found a warning or a critical. So does a `record` or `snapshot` whose write failed for a reason other than permission; see below. NOT a crash - that is `70`                                                                                                                                                             |
| `2`   | The command line was wrong. `USAGE_ERROR` in the envelope. See below, this one is misread constantly                                                                                                                                                                                                                                         |
| `13`  | Something needed privilege this run lacks: `config-deploy --target app` or `host` without root, a diagnostic run whose hardware read the kernel refused outright, or a `snapshot` or `record` whose destination refuses to be written. A field that merely could not be read is different - it degrades to `-` and names itself in `skipped` |
| `22`  | `lsdsk config --section` named a section that does not exist, a `--profile` was rejected, or a global `--replay` was given to `snapshot`. `--set SECTION.KEY=VALUE` is a different option and is not what produces this                                                                                                                      |
| `70`  | An error inside `lsdsk` itself: an exception no command handled. A bug to report and never a statement about the machine, so route it to whoever owns the tool rather than to storage. An `OSError` keeps its own code instead, because its errno means something - EPERM is itself `1`                                                      |
| `78`  | The file is not a snapshot this version reads, or this platform has no hardware reader                                                                                                                                                                                                                                                       |
| `141` | The process reading the output closed the pipe before the command finished. Neither a verdict nor a refusal; see the ranking below                                                                                                                                                                                                           |

**`2` does not mean the file was missing.** It is the CLI framework's usage
error and an absent `--replay` path is only one of its causes: an unknown
option, an unknown command, a missing required argument, an invalid `--format`
value, a `--history-file` that exists but cannot be opened (a store whose CONTENT is
wrong is a different thing: it warns and the run continues), a malformed `--set`
and a
`--history-file` or `--no-record` placed after the subcommand all produce it
too. A wrapper that reads `2` as "the capture is
absent" will page whoever owns the capture pipeline when the actual fault is a
typo in the wrapper's own command line, and will keep doing so until somebody
reads the message on stderr. Read that message before concluding anything; it
names which it was.

**A usage error answers in JSON too, when the command line asked for it.**
`lsdsk disks --bogus --format json` writes one object on stdout and exits `2`:

```json
{"ok":false,"command":"disks","error":{"type":"USAGE_ERROR","message":"No such option '--bogus'."}}
```

with the framework's own usage text on stderr, where it does not disturb a
parser. The parser refuses before any command callback runs, so there is no
`--format` value to consult and the intent is read from the command line itself:
`--format json` anywhere before a bare `--` gets the envelope, and anything
ambiguous gets none. `error.type` is always the exit code's own name, so
`USAGE_ERROR` is `2`, `PERMISSION_DENIED` is `13`, `INVALID_ARGUMENT` is `22` and
`CONFIG_ERROR` is `78`; a caller may branch on the name or on the code and the two
cannot disagree.

**A failed write is reported as a SKIP, not as an error, because the command
still did something.** `lsdsk record --format json` whose store cannot be written
exits `13` and still prints its action envelope - `ok` false, `data` naming the
store and how many drives were read, and the refusal as a sentence in `skipped` -
rather than the `error` object above. So a caller reading `error.type` alone sees
nothing here: read `ok` first, then `skipped` for why.

**`141` is what a departed reader leaves, and which code wins does not depend on
the format.** `lsdsk findings ... | head -5` leaves `141` rather than the verdict
it reached, because a verdict that was not delivered is not a verdict: leaving `1`
there would tell a monitoring check it had a complete answer when it had five
lines of one. So `0` and `1` yield. A refusal is the other way round - `2`, `13`,
`22` and `78` stand whoever was reading, because there was never any output for
that reader to lose; an unknown option is an unknown option whether it went into
`head` or into a file, and `lsdsk disks --bogus | head -c 0` duly leaves `2` -
read through `${PIPESTATUS[0]}` or under `set -o pipefail`, because a bare `$?`
after a pipe is the READER's status and reports `head` succeeding whatever
`lsdsk` left. `70`
stands too, for its own reason rather than that one: a crash says nothing about
what the output CONTAINED, so the reason a verdict yields does not reach it, and a
check piping `lsdsk` into `head` or `jq` reads the crash rather than its own reader
leaving. The one crash that does NOT leave `70` is the one the departed reader
CAUSED - a failed write raises a broken pipe, which carries its own code, so that
leaves `141`. Both halves hold identically in human and
`--format json` output, which is the point of them: an exit code that changed with
the output format would be useless to a wrapper that uses both.

That ranking is about a code the run DECIDED. A command already writing when the
reader leaves exits `141` at that failing write, before it has decided anything -
so `lsdsk config --section nosuch` piped into a reader that has gone leaves `141`
and not `22`, because it prints the configuration before it discovers the section
is missing. Judge a command line by running it with its output going somewhere
that stays.

Do not parse what did arrive, either. Whether any of the output reached the
reader before the pipe closed is a question about buffering rather than about the
command, so a run that leaves `141` may have delivered a whole document, half of
one, or nothing at all. `141` means "what you asked for was not delivered"; the
only sound response is to run it again somewhere the output survives.

**The exit code can fall from `1` to `0` with the hardware untouched**, and if
somebody alerts on it they need to know. Two ways. A fault the recorded history
proves is over is downgraded to a hint, and a hint is not actionable, so a host
whose only complaint was a dead cable fault goes quiet - correctly. And an
unprivileged run reads no SMART at all, so the findings are never raised in the
first place; that one is a blind run, not good news. Tell them to pin the
privilege level and read the header for `-` columns rather than trusting `0`.

**An ELEVATED run writes to disk.** Reading the counters needs root, so an
unelevated run records nothing at all and its store never appears. A run that
can read them records every drive it read, owner readable only and capped per
drive (`history.max_samples_per_drive`), and only when some drive's own clock -
its power-on hours - has moved on since the last one. That covers the drives
whose own clock stood still too, and such a drive has its newest row REPLACED
rather than gaining one: two readings inside one power-on hour hold one hour of
information. So the store does not grow a row per drive per run, and a drive's
newest row is always its latest reading. That is what makes `lsdsk trend`
possible.

**Where it lives depends on who is running.** A root run on Linux or macOS uses
`/var/lib/lsdsk/history.json`; anyone else gets the per-user state directory:
`$XDG_STATE_HOME/lsdsk/history.json` or `~/.local/state/lsdsk/history.json` on
Linux, `~/Library/Application Support/bitranox/lsdsk/` on macOS,
`%LOCALAPPDATA%\bitranox\lsdsk\` on Windows. Reading
the counters needs root, so on a server it is the ROOT path that fills while the
per-user one stays empty. When a store is refused it is therefore
`/var/lib/lsdsk/history.json` you move aside on a server. The two paths are
separate files, so an unprivileged run neither reads nor is refused by the root
one; it has its own, which stays empty because recording needs root. `lsdsk record --format json` prints the path this run resolved, under
`store`.

A REPORTING
command does not write when replaying somebody else's snapshot or when
`--format json` is asked for. Global `--no-record` turns it off and
`--history-file` moves it; both go before the subcommand. `--no-record` still
*reads* the history and still grades against it, so it suppresses the write
without blinding the verdict.

**`lsdsk record` is the exception, and a read-only pipeline must exclude it.**
Storing a reading is its entire purpose, so it writes even under `--replay` and
even under `--format json` - `lsdsk record --replay other.json` is how you fold
somebody else's capture into the history deliberately. It prints nothing in its
human form, which is what suits it to a timer, but `--format json` gives it the
same envelope every other command has, with `recorded`, `store` and `drives`
inside `data`.
So the rule is "a REPORTING command asked for JSON does not mutate state", not
"lsdsk does not mutate state": put `topology`, `disks`, `health`, `smart`,
`findings`, `slots`, `controllers` or `trend` in that pipeline, and leave
`record`, `snapshot` and the two `config-*` commands out of it, all four of
which write by design whatever `--format` says. `snapshot` also REFUSES a global
`--replay` with exit `22` rather than obeying it: it always captures the machine
it runs on, so there is no snapshot of somebody else's capture to take. Copy the
file instead. `info` and plain `config` write
nothing either and are safe to include; of the three `config` commands, `config-deploy`
and `config-generate-examples` are the two that create files.

**A run that cannot READ the history will not WRITE it either, and says so.**
On stderr you get `Warning: ignoring counter history: <why>` followed by
`Not recording this run, so <path> is left as it is.` The causes are a store
belonging to a different hostname, one written by a newer lsdsk, one too large
to read, and one that is not valid JSON. Two things follow, and both matter to
whoever is paged. The hardware is still diagnosed and the exit code still
reflects the findings, so this is not a failed run. And the file is INTACT:
nothing was overwritten, so there is nothing to restore from backup, and the
counters simply stop accumulating until somebody acts. To resume recording,
move the file aside or point `--history-file` somewhere else - a renamed host
is the common cause, because the default path carries no hostname.

Topology, link speeds, capacity and firmware read unprivileged. It never guesses
a value it could not read, so anything below shows `-` instead, and the header
says so.

Four things need root or Administrator, not one:

| Needs privilege                         | Because                                   | Costs you                                        |
|-----------------------------------------|-------------------------------------------|--------------------------------------------------|
| SMART attributes and wear               | ATA and NVMe passthrough ioctls           | Those columns, and every finding drawn from them |
| Error counters, so `trend` and `record` | Same passthrough read                     | An unelevated run records nothing at all         |
| PCIe slot numbers, connector detection  | Config space past the first 64 bytes      | The `slot` column, `FREE`, and any card move     |
| The AHCI ports-implemented bitmap       | A memory mapping of the controller's BAR5 | A SATA controller's free-port count              |

The last one is refused on some hosts even as root, so a `-` there is not proof
the run was unprivileged.

**Root does not always help.** In a container the device nodes do not exist, so
elevating changes nothing. Check what kind of machine you are on before
arranging access you cannot use.

**On Windows a port's own speed and width are not a privilege question.** Windows
publishes link registers for PCIe endpoints and none at all for bridges, so a
port's `capable` and `running` columns stay `-` and `upstream` stays null however
the run was started. Measured on one board: eight bridges, not one with a link
speed, and no registry key, WMI class or user-mode API that has them. Never tell
a Windows user to re-run elevated to reveal a port's capability - they will come
back with the same `-` and think something is broken.

**An unprivileged run that reports nothing is not a clean bill of health.** It
never read SMART, so those findings were never raised. That is a blind run, not
a quiet one.

## Check what kind of machine you are on first

The banner says so, and it changes the whole reading:

- **Bare metal.** Everything applies.
- **A container.** The disks and controllers shown belong to the host, seen
  through a shared kernel. The faults are real and worth reporting, but they are
  the host's faults, so investigate and act there. Health data is usually absent
  because the device nodes do not exist in the container, and elevating does not
  change that.
- **A virtual machine.** The disks, controllers and link speeds are the
  hypervisor's invention, so the link and placement rules are suppressed
  entirely: a cable warning about an emulated controller is noise. Health data
  can still be real if a device was passed through. Diagnose the host.

A container or a guest adds a caveat line under the banner; **bare metal adds
nothing**, so silence is the bare-metal answer rather than a missing check.
`--format json` states it outright in `data.environment`, one of `bare_metal`,
`virtual_machine`, `container` or `unknown`, with `data.environment_detail`
naming the hypervisor. Treat `unknown` as "not established", not as bare metal.

Never carry a link or placement recommendation from a guest to the host. Run it
on the host.

## A count is not a rate. Run `lsdsk trend` before advising anything

**Every error counter is a lifetime total held in the drive's own non-volatile
table.** It survives reboots, power cycles and reinstalls, and the host cannot
clear it. So a large number tells you how much damage there has ever been and
nothing at all about when. A fault that ended two years ago and one corrupting
data right now produce the same figure.

`lsdsk trend` is the answer, and it answers today:

```bash
uvx lsdsk trend
```

```
device        counter           total  change  span  per hour  verdict
/dev/sdd      interface CRC   2196127  +16642   15h      1109  rising
/dev/sdj      interface CRC    462640      +0   16h         -  no new in 16h, 235 were due
/dev/sde      interface CRC       430      +0   15h         -  too soon to say, this drive's rate would not have produced even one in 15h
```

Those two top 

…(truncated)
