Compare commits

..
12 changed files with 473 additions and 259 deletions
+1
View File
@@ -1,4 +1,5 @@
__pycache__/ __pycache__/
.pi/
*.pyc *.pyc
.commandcode/ .commandcode/
data/fenris.pid data/fenris.pid
+9
View File
@@ -0,0 +1,9 @@
## Agent skills
### Issue tracker
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
### Domain docs
This is a single-context repository. See `docs/agents/domain.md`.
+73
View File
@@ -0,0 +1,73 @@
# Fenris
Fenris observes an NVMe drive’s real-world use and translates that history into an understandable endurance outlook.
## Language
**Observation history**:
The persisted record of drive activity gathered while Fenris monitoring is enabled, retained across restarts and reboots.
_Avoid_: Calibration data, temporary history
**Observed usage habit**:
The pattern of active, idle, and powered-off hours represented by the observation history, with recent sustained behavior carrying more relevance than distant behavior.
_Avoid_: Current usage, benchmark workload
**Usage-adjusted theoretical lifespan**:
The theoretical time until the drive’s write endurance is exhausted if its observed usage habit continues; it is an endurance projection, not a predicted hardware-failure date.
_Avoid_: Future life, actual lifespan, failure date
**Projection confidence**:
The degree to which the observation history is sufficiently long, complete, and stable to support the usage-adjusted theoretical lifespan.
_Avoid_: Accuracy percentage, certainty
**Monitoring period**:
A span during which Fenris monitoring is enabled; powered-off time remains part of the usage habit, while deliberately disabled time does not.
_Avoid_: Daemon uptime, calibration window
**Observation store**:
The single SQLite database at `/var/lib/fenris/observations.db` that persists the observation history, monitoring periods, hour observations, day aggregates, and endurance baseline.
_Avoid_: Data directory, history.jsonl, the database (generic)
**Store fault**:
The condition where the observation store is present but cannot be read or trusted — unreadable, corrupt, or written by a newer Fenris — degrading every view that depends on it rather than crashing or guessing.
_Avoid_: Database error, corruption, broken data
**Hour observation**:
One row per UTC hour in the observation store, recording that hour's usage-habit split into active, idle, powered-off, and unknown seconds, plus write/read deltas, thermal evidence, and coverage.
_Avoid_: Hourly record, hourly.jsonl entry
**Day aggregate**:
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
_Avoid_: Daily summary, daily stats
**Controller segment**:
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
_Avoid_: Counter reset handling, drive swap detection
**Endurance baseline**:
The write-endurance value a projection consumes: a verified rated-TBW override stored with provenance when one exists, otherwise a coarse implied baseline derived from vendor wear and labeled as such.
_Avoid_: TBW value, failure threshold, max writes
**Sustained regime**:
The most recent stretch of the observation history over which the observed usage habit has been stable; the interval whose write rate the usage-adjusted theoretical lifespan consumes.
_Avoid_: Current window, detection period
**Habit change**:
A sustained divergence between recent and earlier daily write rates that starts a new sustained regime.
_Avoid_: Spike, anomaly
**Scenario range**:
The spread of lifespan projections computed from the 7-, 28-, and 90-day horizons of the observation history, shown in place of a statistical interval.
_Avoid_: Confidence interval, error bar
**Coverage**:
The share of wall-clock seconds inside monitoring periods whose usage-habit classification is known rather than unknown.
_Avoid_: Uptime, sample count
**Collection run**:
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
_Avoid_: Poll, daemon tick
**Deliberate disable**:
A monitoring pause made through Fenris's own control path, closing the monitoring period so the paused time is excluded from the usage habit.
_Avoid_: Manual stop, service stop
+42
View File
@@ -0,0 +1,42 @@
# 1. Observation store: a single SQLite database
## Status
Accepted — resolves [Define the persistent observation store and legacy migration](https://git.bongbetic.com/xavierk/Fenris/issues/2) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
## Context
Fenris today persists full SMART samples to an append-only `data/history.jsonl` beside a derived `data/hourly.jsonl`, both in the checkout, with no schema versioning and silent skipping of malformed lines. The redesign replaces the HTML dashboard with a keyboard-first TUI backed by a short-lived privileged collector on a systemd timer and an unprivileged TUI ([lifecycle research](https://git.bongbetic.com/xavierk/Fenris/src/branch/research/systemd-privilege-lifecycle/docs/research/systemd-privilege-lifecycle.md)), and projects a usage-adjusted theoretical lifespan from Data Units Written over wall-clock time with categorical confidence ([endurance research](https://git.bongbetic.com/xavierk/Fenris/src/branch/research/nvme-endurance-signals/docs/research/nvme-endurance-signals.md)). The store must support a root writer appearing every few minutes while an unprivileged reader queries concurrently, must migrate the legacy observation history idempotently and interruption-safely, and must version its schema.
## Decision
1. **Substrate**: one SQLite database in WAL mode at `/var/lib/fenris/observations.db`. WAL gives the unprivileged reader a consistent snapshot while the collector writes; migration and schema changes are single transactions.
2. **Access**: the database is root-owned and group-readable through the `fenris` read group created by packaging; the TUI opens it read-only. No `/run` snapshot or export layer.
3. **Entities**:
- `samples` — recent raw SMART samples: timestamp, controller identity, raw `data_units_written`/`data_units_read` integers, `percentage_used`, `available_spare`, `media_errors`, `power_on_hours`, `power_cycles`, `unsafe_shutdowns`, temperature, `critical_warning`.
- `hour_observations` — one row per UTC hour: the usage-habit split (`seconds_active`, `seconds_idle`, `seconds_powered_off`, `seconds_unknown`), DUW/DUR deltas, temperature min/avg/max, sample count, coverage flag. Classification thresholds belong to the projection model, not the store.
- `day_aggregates` — one row per UTC day; the habit-evidence grain.
- `monitoring_periods` — `started_at`, `ended_at` (NULL = open), `end_cause` enum (`user_disabled`, `migrated`, …). Powered-off time stays inside a period; deliberately disabled time does not.
- `controller_segments` — boundaries where controller identity changes or DUW decreases; write deltas are never computed across a segment.
- `endurance_baseline` — verified rated-TBW override in bytes plus provenance (source URL, document revision, entry date).
- Projections are not stored; they are recomputed on read. There is no separate latest-status table.
4. **Day boundary**: UTC, matching hours, so day derivation from hour rows is monotonic and DST-ambiguous or 23/25-hour days never exist in the store.
5. **Retention**: raw samples are kept 14 days and pruned opportunistically by the collector; hour observations and day aggregates are retained indefinitely.
6. **Migration** (first new-version collection run):
1. If the database already carries the legacy-import marker, do nothing.
2. `history.jsonl` is the sole authority: import raw samples, derive hour observations and day aggregates from them, and ignore `hourly.jsonl` as derived data (diff and log mismatches; do not trust).
3. One implicit `monitoring_periods` row opens at the first legacy sample and closes with `end_cause = migrated` at the migration moment. Pre-migration hours carry an unknown activity split except directly evidenced facts (samples present ⇒ powered on; DUW deltas ⇒ writes occurred).
4. The import is a single transaction: interruption leaves the database fully pre- or post-migration.
5. Only after commit are legacy files renamed to `*.migrated` (never deleted).
6. Malformed legacy lines are quarantined with a logged count, never silently dropped.
7. **Projection inputs**: the `endurance_baseline` table lives in the database and is edited via the CLI; `/etc/fenris/` holds only operational configuration.
8. **Versioning**: `PRAGMA user_version` plus ordered migration steps in code, each in its own transaction; the collector refuses to run against an unknown newer version.
9. **Collector health**: not stored. Failures go to the journal (per the lifecycle decision); the freshest sample timestamp is the store's own staleness signal.
## Consequences
- Backups and state migration are copying one file (plus its WAL sidecars).
- SQLite becomes a runtime dependency of both the collector and the TUI (Python `sqlite3` stdlib suffices; no server).
- The collector's prune, import, and version steps are all transactional, so a killed timer run cannot leave partial state.
- Legacy checkout-relative `data/` files stop being authoritative at migration; the migration ticket's rename-after-commit rule keeps them as a recovery trail.
- The active/idle/powered-off classification contract with the projection model is the `hour_observations` column set, keeping storage and model decisions separable.
@@ -0,0 +1,49 @@
# 2. Projection model: sustained-regime rate with categorical confidence
## Status
Accepted — resolves [Define the lifespan projection and confidence model](https://git.bongbetic.com/xavierk/Fenris/issues/4) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
## Context
Fenris's current `compute_summary` projects from a single trailing-24-hour write rate against endurance inferred as `DUW / Percentage Used` or synthesized as `capacity × 600`, alongside a second linear regression of Percentage Used toward 100. The [endurance research](https://git.bongbetic.com/xavierk/Fenris/src/branch/research/nvme-endurance-signals/docs/research/nvme-endurance-signals.md) established which signals can defensibly support a projection, and [ADR 0001](0001-observation-store-sqlite.md) fixed the observation store while leaving classification thresholds and every projection rule to this model. This decision defines the algorithm and the user-facing contract the TUI consumes.
## Decision
1. **One projection.** The usage-adjusted theoretical lifespan is computed once, against the endurance baseline chosen by precedence (verified rated TBW → unverified manual override → Percentage-Used-implied → projection unavailable). Percentage Used is context, never a second projection: it renders as a vendor wear line, and when the wear it implies disagrees with the observed write rate by more than a factor of 2, a note says so. The current PU-slope regression (`wear_days`) and the `capacity × 600` synthesis are dropped.
2. **Headline rate from the sustained regime.**
```text
rate = regime DUW delta bytes / in-period wall-clock seconds
projected = max(E_baseline − W_t, 0) / rate (rate > 0)
E_rated = entered_TBW × 10¹² bytes
E_implied = 100 · W_t / p (1 ≤ p ≤ 254)
```
The default regime is the full observation history capped at 90 days. The 7-, 28-, and 90-day rates are computed independently of the regime and shown as a **scenario range**; only horizons the history actually covers appear (no placeholders).
3. **Habit change.** A change is declared when the trailing 7-day mean of daily written bytes stays ≥ 2× (or ≤ 0.5×) the mean of the preceding 28 days for 3 consecutive days. The new regime starts at the first day of divergence and is adopted automatically, labeled "usage habit changed N days ago"; the scenario range keeps the longer horizons visible. A regime younger than 7 days caps projection confidence at Limited evidence.
4. **Hour classification** (named constants, no configuration surface):
- **Powered-off**: the hour's power-on-hours delta is below 90% of its wall-clock span.
- **Active**: DUW delta ≥ 256 MiB in the hour.
- **Idle**: powered on, sampled, below the active threshold.
- **Unknown**: everything else — unsampled without power-on-hours evidence (machine-off and collector failure are indistinguishable), or inconsistent counters.
- Disabled time is not an hour state: it is wall-clock outside monitoring periods.
5. **Denominator.** Wall-clock seconds inside monitoring periods, including powered-off and unknown time. Disabled periods are excluded from numerator and denominator. Unexplained gaps inside a period keep the aggregate counter delta, remain in the denominator as unknown seconds, and reduce coverage.
6. **Minimum evidence.** Warming up until there are 14 distinct UTC day aggregates of which at most 2 fall below 50% coverage. The projection still renders while warming up, labeled with its facts. Unavailable conditions (no baseline, unsupported DUW, zero rate over the regime, identity change) render no lifespan number.
7. **Staleness.** A newest day aggregate older than 48 hours drops confidence one level (Supported → Limited) and is shown as a contributing fact.
8. **Confidence rule table.**
- **Unavailable**: no applicable baseline; DUW unsupported; zero rate over the regime; controller-identity change.
- **Supported**: verified baseline **and** ≥ 14 qualifying days **and** coverage ≥ 80% **and** fresh (< 48 h) **and** 7/28/90 rates within a factor of 2 across existing horizons **and** no single day ≥ 50% of trailing 28-day bytes **and** regime ≥ 7 days old.
- **Limited**: every other case with a baseline and a positive rate; the failing facts are shown.
- Confidence always renders as state plus contributing facts, never a percentage.
9. **Segment breaks.** A DUW decrease with unchanged controller identity quarantines nothing: prior day aggregates remain habit evidence and the projection is Unavailable only until the new segment re-warms. A controller-identity change quarantines prior history from projection entirely — it describes a different drive.
10. **Implied-baseline eligibility.** The Percentage-Used-implied baseline is computed only after ≥ 2 Percentage Used increments within the current controller segment; until then the projection is Unavailable with "vendor wear estimate too coarse to imply endurance".
11. **Uncertainty.** The scenario range is the only spread shown; no statistical confidence interval appears anywhere. Zero rate → "no finite projection from this history", never infinity or zero.
12. **Language.** The endurance research's required wording and six disclosures are adopted verbatim as the specification's language section.
13. **Contract.** The projection function hands the TUI: the confidence state, the contributing facts, the headline remaining time when one exists, the scenario range, the Percentage-Used context line, and the disclosure text. Projections are recomputed on read, never stored.
## Consequences
- The TUI information-architecture prototype (its ticket) consumes a fixed contract rather than inventing presentation states.
- `compute_summary`'s wear-slope regression and capacity-synthesized endurance disappear; migration must not synthesize baselines for legacy history.
- Coverage becomes a first-class displayed fact rather than an internal heuristic.
- All guardrail thresholds live as documented constants in one projection module; tuning demand, if it ever appears, is a future decision rather than a config surface.
- Two follow-on decisions surfaced and are ticketed separately: the controller-identity key that segments history, and endurance-baseline provenance validation.
@@ -0,0 +1,31 @@
# 3. Service lifecycle: timer-driven collection with a sanctioned control path
## Status
Accepted — resolves [Define the collector, service, and CLI lifecycle](https://git.bongbetic.com/xavierk/Fenris/issues/8) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1). Amends the toggle mechanism of [Verify systemd lifecycle and privilege constraints](https://git.bongbetic.com/xavierk/Fenris/issues/7); its spirit — scoped, explicit, authenticated, no generic `manage-unit-files` grant — is intact.
## Context
Fenris's current single process combines daemonization, a PID file, an HTTP dashboard, and control (`fenris.py start/stop/status/sample`) over checkout-relative state. [ADR 0001](0001-observation-store-sqlite.md) fixed the observation store, including `monitoring_periods` whose `user_disabled` end cause records deliberate pauses, and the [systemd lifecycle research](https://git.bongbetic.com/xavierk/Fenris/src/branch/research/systemd-privilege-lifecycle/docs/research/systemd-privilege-lifecycle.md) fixed the timer + oneshot architecture, standard paths, journal diagnostics, allow-listed status reads, and polkit-mediated startup toggles — while leaving cadence mechanics, the configuration surface, CLI compatibility, staleness thresholds, and the mechanism that records a deliberate disable open. In particular, `systemctl enable`/`disable` cannot write a monitoring-period row, so a direct-systemctl toggle cannot satisfy the store's semantics.
## Decision
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
2. **Cadence.** Default five minutes: `OnBootSec=2min`, `OnUnitInactiveSec=5min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger and monitoring-period bookkeeping; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
6. **Period rows.** Idempotent matrix: a first-ever enable opens a period at the enable moment (hours before the first successful sample are unknown-but-inside, correctly so when the device errors); a resume with an open period — a raw `systemctl stop` intervened — changes no row, the gap remaining inside as unknown seconds; a resume with no open period opens a new row at the resume moment; a pause with an open period closes it `user_disabled` at the pause moment; a pause otherwise is a no-op. A raw stop or disable outside the helper is an unexplained gap, never `user_disabled`: only the sanctioned path can record intent.
7. **On-demand collection.** `fenris sample` and the TUI's collect-now route through `fenris-monitor` → `systemctl start fenris-collect.service`, which blocks until the oneshot exits, and the outcome (freshness line or journal hint) is reported synchronously. No code path outside `fenris-collect` touches the device; the TUI never samples in-process; no confirmation is required.
8. **TUI controls.** Pause asks for confirmation; Resume does not (benign — friction invites raw-systemctl escapes). Boot enablement and current runtime activity are always displayed as separate facts, next to last collect outcome and freshness. No bare start/stop exists anywhere.
9. **CLI compatibility.** `status` is a pure read-only composition of the observation store and allow-listed `systemctl show` properties: projection facts, enabled/active, last collect outcome, and a `journalctl -u fenris-collect.service` hint on failure or staleness; it never auto-samples and never prompts. `sample` is retained via the helper path; `--device` is rejected with a pointer to the configuration file. `start`, `stop`, and `run` are rejected with one-line migration pointers, not aliased — an alias would silently change meaning. `fenris.sh` is retired: not shipped, removed from the repository, and the README maps its five menu options to their successors.
10. **Freshness constants.** Documented once, consumed by TUI and CLI alike: fresh means the newest sample is within 2× cadence + `AccuracySec` + 60 s; between that and 48 h the store is missed (a contributing fact); at ≥ 48 h it is stale, matching [ADR 0002](0002-projection-model-sustained-regime.md)'s evidence gate; an empty store reads "no observations yet" with an enable hint.
## Consequences
- Polkit ships one Fenris-specific policy authorizing exactly one fixed-operation binary; the collector itself is never polkit-reachable.
- Monitoring-period boundaries are exact at toggle moments; approximation never enters the habit record.
- Interval tuning is a systemd drop-in documented in the README; `/etc/fenris` stays a one-key file.
- Headless administration has full parity: every TUI action has a CLI twin.
- The TUI must run privileged operations through a terminal-attached subprocess so the platform polkit agent can prompt; the TUI prototype ticket validates this in practice.
- Nothing survives of the prototype's daemonization, PID files, or HTTP server; their commands fail with pointers instead of quiet behavior changes.
@@ -0,0 +1,31 @@
# 4. Installation lifecycle: Makefile-delivered venv, dormant install, sanctioned teardown
## Status
Accepted — resolves [Define installation, upgrade, and removal behavior](https://git.bongbetic.com/xavierk/Fenris/issues/9) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
## Context
Fenris runs today from the source checkout (`fenris.py`, `fenris.sh`, `data/`): code, state, and control all live relative to wherever the checkout sits. The redesign fixes system artifacts — helpers in `/usr/libexec/fenris` ([ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)), the observation store at `/var/lib/fenris/observations.db` ([ADR 0001](0001-observation-store-sqlite.md)), configuration at `/etc/fenris/fenris.conf` ([ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)), Textual on Python 3.9+ ([framework decision](https://git.bongbetic.com/xavierk/Fenris/issues/6)) — but nothing says how those artifacts are delivered, upgraded, or removed, or what happens when the checkout moves or disappears.
## Decision
1. **Delivery.** `sudo make install` builds a wheel from the checkout and installs it, with pinned dependencies, into a dedicated Fenris-owned venv at `/opt/fenris`; a `/usr/local/bin/fenris` wrapper makes the unprivileged TUI/CLI a PATH command. The checkout is build-time input only: after install, nothing references it.
2. **Layout and manifest.** Units in `/etc/systemd/system` (`fenris-collect.{timer,service}`, [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)); helpers in `/usr/libexec/fenris`; polkit policy in `/usr/share/polkit-1/actions/`; configuration and observation store in their ADR-fixed locations. The installer records every file it places in an explicit manifest consumed by upgrade and uninstall.
3. **Privilege.** One root installer (`sudo make install`); at runtime, elevation is exclusively polkit (`auth_admin`, `fenris-monitor` only, [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)). The installer never enables or starts units.
4. **Dormant install.** A fresh install is fully dormant: units present but disabled, nothing running, no monitoring period. The only opt-in is the sanctioned toggle (`fenris monitor resume [--now]`, or the first-run TUI prompt), which enables the timer and opens the first period in one step.
5. **Legacy import.** The installer detects `./data/history.jsonl` beside the source (or accepts an explicit path), runs [ADR 0001](0001-observation-store-sqlite.md)'s idempotent single-transaction import, and reports imported counts — existing observations never depend on checkout survival. `fenris import <path>` remains available for later finds.
6. **Upgrade.** `sudo make upgrade` builds and installs the new wheel into the same venv, syncs units and polkit against the manifest (`daemon-reload`; restart the timer only if unit contents changed and it is active — safe with `Persistent=no`), leaves timer state untouched, and never kills an in-flight collection run: a running oneshot finishes on its mapped interpreter, so at worst one old-code run completes to the store and the next run uses the new code. It then applies forward-only observation-store schema migrations governed by a `schema_version` table. `/var/lib/fenris` is never rebuilt.
7. **Rollback.** Best-effort by design: before migrations run, the installer snapshots `observations.db` to a one-generation `observations.db.bak`; rollback means reinstalling the previous version and restoring the backup. Automatic schema downgrade is explicitly unsupported.
8. **Removal.** `make uninstall` first performs the sanctioned disable (`fenris-monitor disable --now`) so an open monitoring period closes `user_disabled` — removal is deliberate, and only the sanctioned path records intent — then stops and disables the units and removes the venv, helpers, units, polkit policy, and wrapper, **keeping** `/etc/fenris` and the observation store. `make purge` additionally removes configuration and store. Journal entries age out naturally.
9. **Dependencies.** Exact pins in a committed lockfile; install and upgrade both install from it. Refreshing pins is an explicit developer step (`make update-deps`, committed), never a side effect of installing.
10. **Scaffolding and floor.** The installer creates `/var/lib/fenris` with [ADR 0001](0001-observation-store-sqlite.md)'s root-written group-read permissions and verifies `python3 ≥ 3.9`, failing cleanly otherwise — the Textual contingency becomes an install-time gate rather than a runtime crash. The database file itself is created lazily by the first write, so "no observations yet" remains a real state the TUI can greet.
## Consequences
- Installed Fenris survives checkout deletion; the checkout is only where builds happen.
- Teardown preserves monitoring-period semantics: deliberate removal excludes the uninstalled span from the usage habit instead of leaving it as unknown-inside.
- Installs are reproducible; dependency drift cannot ride in on an upgrade.
- Reinstall after uninstall resumes from the preserved observation store; only purge erases history.
- Rollback support is exactly one generation deep, no further.
- The README documents install, upgrade, uninstall/purge, and legacy import alongside [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)'s menu-successor mapping.
@@ -0,0 +1,27 @@
# 5. Failure and recovery: visible degradation, never fabrication
## Status
Accepted — resolves [Define failure and recovery behavior](https://git.bongbetic.com/xavierk/Fenris/issues/10) on the [Wayfinder map](https://git.bongbetic.com/xavierk/Fenris/issues/1).
## Context
The observation store ([ADR 0001](0001-observation-store-sqlite.md)) and the service lifecycle ([ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)) settled single-writer transactions, bounded single failed runs, freshness grading (fresh / missed / stale), and absent-hour classification through power-on-hours evidence. Left open by the [failure ticket](https://git.bongbetic.com/xavierk/Fenris/issues/10): behavior per failure class — malformed observations inside the store, missed observations, store faults (unreadable, corrupt, or newer-schema database), and repeated collector failures — and how the habit record re-anchors after the store itself is lost.
## Decision
1. **Malformed observations — refuse at the write boundary.** The collector validates every row it would write against the store's domain invariants (hour seconds sum to 3600, non-negative DUW delta within a controller segment, coverage consistent with sample count). A violating run writes nothing for that run, logs the refused row to the journal for post-mortem, and fails visibly — retried next interval. Readers (TUI, `status`) defensively exclude and count malformed rows as a contributing fact, but under a single trusted writer they should never see one. Store invariant: everything persisted is well-formed.
2. **Missed observations — never backfill.** Fenris never interpolates, estimates, or fabricates an hour. Gaps remain unknown seconds; degradation flows exclusively through coverage, freshness facts, and confidence categories; recovery is the timer's next successful run. [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md)'s power-on-hours classification is the only inference admitted.
3. **Store faults — degrade, never recreate over.** An unreadable or corrupt database is a store fault: readers surface a "observation store unreadable" fact with the journal hint and show nothing else that depends on the store; the collector treats it as a bounded failed run and never recreates or overwrites an existing file. Recovery is human-sanctioned and documented: back up or move the corrupt file aside, the next run starts a fresh store, and if the legacy import never completed, the still-present `history.jsonl` is re-imported. No built-in destructive command exists.
4. **Newer schema — readers refuse symmetrically.** The TUI and `status` detect a `user_version` newer than they understand and display "observation store written by a newer Fenris — upgrade Fenris" without partial interpretation, matching the collector's refusal in [ADR 0001](0001-observation-store-sqlite.md) and the forward-only upgrade rule of [ADR 0004](0004-install-upgrade-removal-lifecycle.md).
5. **Repeated collector failures — flat cadence, no escalation.** The timer's retry is the recovery path; the settled freshness grading walks fresh → missed → stale as failures persist, so degradation is visible without new state. No backoff and no notification machinery; a persistent failure reads as stale exactly like any other gap.
6. **Drive-reported anomalies — facts, not alerts.** `critical_warning`, media errors, and unsafe shutdowns surface as ordinary facts in the TUI and `status`; no alerting or notification surface exists. Fenris observes and projects; it does not alarm. The projection is unaffected: endurance math consumes writes, not warnings.
7. **Orphaned samples — the collector re-anchors observed fact.** When a collection run finds no open monitoring period (fresh store after a store fault, completed legacy re-import, or first-ever run), it opens one at the run moment, never backdated. This records observed fact, not intent: only the sanctioned path of [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) records a `user_disabled` close. Coverage semantics stay intact without requiring a re-run of `fenris-monitor enable` after recovery.
## Consequences
- Validation lives at one boundary — the collector — so the store's contract is "everything in it is well-formed" and readers only defend against the impossible.
- No synthetic data can ever enter the habit record; confidence categories can be trusted to reflect real evidence.
- Store-fault recovery can lose history; the mitigation is the one-file backup story of [ADR 0001](0001-observation-store-sqlite.md), kept human-sanctioned so loss is never silent.
- Period bookkeeping splits by epistemics: the helper records intent, the collector records observed fact.
- Fenris stays fully local and silent: no notification, escalation, or alerting machinery anywhere.
+26
View File
@@ -0,0 +1,26 @@
# Domain Docs
How engineering skills should consume this repository’s domain documentation.
## Layout
This is a single-context repository:
```text
/
├── CONTEXT.md
├── docs/adr/
└── ...
```
## Before exploring
Read `CONTEXT.md` and relevant ADRs under `docs/adr/` when they exist. If they do not exist, proceed silently. Domain-modeling skills create them lazily when terminology or durable architectural decisions are resolved.
## Use the glossary’s vocabulary
Use terminology defined in `CONTEXT.md` consistently. If required terminology is missing or contradictory, raise it through domain modeling rather than silently inventing synonyms.
## Flag ADR conflicts
If proposed work contradicts an existing ADR, identify the conflict explicitly instead of silently overriding it.
+85
View File
@@ -0,0 +1,85 @@
# Issue tracker: Gitea
Issues for this repository live in Gitea at:
https://git.bongbetic.com/xavierk/Fenris/issues
Use the authenticated `tea` CLI from the repository root. The configured login is `xavierk`.
## General operations
- List: `tea issues list`
- Read: `tea issues <index> --comments`
- Create: `tea issues create --title "<title>" --description "<body>"`
- Edit: `tea issues edit <index> --title "<title>" --description "<body>"`
- Assign: `tea issues edit <index> --add-assignees "<username>"`
- Add labels: `tea issues edit <index> --add-labels "<labels>"`
- Comment: `tea comments add <index> --description "<comment>"`
- Close: `tea issues close <index>`
- Reopen: `tea issues reopen <index>`
Use `--output json` for machine-readable list and read operations. Use `tea api` when the high-level issue commands do not expose a native Gitea operation.
## When a skill says “publish to the issue tracker”
Create a Gitea issue in this repository. Preserve Markdown formatting in its body and apply any labels required by the invoking skill.
## When a skill says “fetch the relevant ticket”
Read the named issue with comments. The user may provide its URL, title, or index. In user-facing output, refer to issues by their linked titles rather than bare indices.
## Wayfinding operations
Wayfinder maps and decision tickets are Gitea issues.
### Map and ticket grouping
- A map has the label `wayfinder:map`.
- Create one milestone named `Wayfinder: <map title>` for the effort.
- Assign the map and all its tickets to that milestone.
- Every ticket links its parent by name near the top: `Parent map: [<map title>](<map URL>)`.
- Every ticket has exactly one type label: `wayfinder:research`, `wayfinder:prototype`, `wayfinder:grilling`, or `wayfinder:task`.
The shared milestone and explicit parent link express the child relationship, because this Gitea version has no native parent/child issue API.
### Blocking
Use Gitea’s native issue-dependency relationship. To make `<blocked>` depend on `<blocker>`:
```bash
tea api -X POST \
repos/{owner}/{repo}/issues/<blocked>/dependencies \
-F index=<blocker> \
-f owner=xavierk \
-f repo=Fenris
```
List blockers:
```bash
tea api repos/{owner}/{repo}/issues/<index>/dependencies
```
Remove the relationship with the same payload and `-X DELETE`.
### Frontier
List open issues in the map’s milestone. Exclude:
- the issue labelled `wayfinder:map`
- assigned tickets, because assignment is the claim
- tickets whose dependency query returns any open issue
The remaining open, unassigned, unblocked tickets are the frontier. Choose the oldest first unless the user names one.
### Claim
Before doing any ticket work, assign it to the current `tea whoami` user. An open ticket without an assignee is unclaimed.
### Resolve
1. Add the answer as a resolution comment.
2. Close the ticket.
3. Re-fetch the map immediately before editing it.
4. Append a linked one-line context pointer to `Decisions so far`.
5. Create newly visible tickets, then wire dependencies in a second pass.
+99
View File
@@ -0,0 +1,99 @@
# Controller identity for observation-history segmentation
Research for [Verify the controller identity that segments observation history](https://git.bongbetic.com/xavierk/Fenris/issues/11).
## Decision
Record the **subsystem NQN as exposed by the Linux kernel** — normalized, trailing-space stripped — as the controller-segment identity key:
```text
identity_key = strip(subnqn) # /sys/class/nvme-subsystem/…/subsysnqn,
# identical to /sys/class/nvme/nvmeX/subsysnqn
fallback: "nqn.2014.08.org.nvmexpress:" + hex4(vid) + hex4(ssvid)
+ raw20(sn) + raw40(mn) # byte-for-byte the kernel's synthesized NQN
last resort: strip(mn) + "|" + strip(sn) # when only smartctl-style fields exist
```
Store the raw `subnqn`, `sn`, `mn` strings (normalized) plus `fr` (firmware revision) as **segment metadata**, never `fr` inside the key: firmware revision is the one mandatory field that legitimately changes on the same drive ([Base Spec 2.0e §5.17.2.1: FR is the *currently active* firmware revision](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)). Namespace identifiers (NGUID, EUI-64, UUID) are excluded from the key: they are namespace-scoped while the SMART counters being segmented are controller-scoped, and each may be absent or reused. When the key is blank, record an empty key with a degraded marker and rely on the DUW-monotonic rule; a same-model same-serial replacement is unobservable by any identifier and is caught — as a boundary, not an identity change — by the DUW-decrease rule of ADR 0001.
This key satisfies the ticket's asymmetry: a drive replacement changes `subnqn` (real NQNs are unique per subsystem; kernel-generated ones embed serial+model), while firmware quirks and counter resets on the same drive leave it unchanged — counter discontinuities are already ADR 0001's second segmentation axis.
## What each candidate is
| Candidate | Spec definition | Scope | Mandatory | Verdict for the key |
|---|---|---|---|---|
| **SN + MN** | ASCII strings assigned by the vendor in Identify Controller, bytes 23:04 and 63:24; §4.3 shows them left-justified and space-padded ([2.0e §5.17.2.1, Identify Controller data structure](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf), [§4.3 Identifier Format and Layout](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)) | NVM subsystem | Mandatory for I/O and Admin controllers | Core of the fallback; uniqueness explicitly not guaranteed by the spec |
| **FR** | Currently active firmware revision, ASCII, bytes 71:64 ([2.0e §5.17.2.1](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)) | Domain (subsystem) | Mandatory | Never in the key — it is *meant* to change on the same drive |
| **SUBNQN** | NVM Subsystem NQN, UTF-8 null-terminated, bytes 1023:768; mandatory if the controller is ≥ 1.2.1, otherwise may be all zero ([2.0e §5.17.2.1](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)) | NVM subsystem (shared by all its controllers) | Mandatory ≥ 1.2.1, optional below | **Chosen key**; the spec says hosts *should* use it as the subsystem's unique identifier |
| **NGUID / EUI-64** | IEEE-based identifiers in Identify Namespace, bytes 119:104 / 127:120 ([§4.3.4, §4.3.5](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)) | Namespace | Optional; may both be zero | Rejected — wrong scope, may be missing, may be reused |
| **CNTLID** | Controller ID, unique only *within* a subsystem ([§4.5.1](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)) | Controller | Mandatory | Rejected — not unique across subsystems (this host's drive reports 0) |
A note on the PDF: figure numbers in the table of contents of the 2.0e revision are offset from the body captions (e.g. the SN/MN figure is "Figure 128" in the TOC but "Figure 130" in the body), so this document cites section numbers, which are stable.
## Scope: the counters being segmented are controller-scoped
SMART / Health Information (LID 02h) is scope **Controller** (mandatory) with an optional namespace view ([2.0e §5.16.1, Get Log Page – Log Page Identifiers](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)). Section 5.16.1.3: "The information provided is over the life of the controller and is retained across power cycles"; hosts request the controller log page with NSID `FFFFFFFFh`/`0h`, the per-namespace view is optional (LPA bit 0), and "the controller log page and namespaces specific log page contain identical information" in 2.0e ([§5.16.1.3](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)). Data Units Written therefore accumulates per controller/subsystem, not per namespace — the identity key must be subsystem-scoped, and SN/MN/SUBNQN are all defined as NVM-subsystem fields ([§5.17.2.1: SN/MN are "the serial number/model number for the NVM subsystem"](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf); the Persistent Event Log repeats that its SN/MN/SUBNQN copies are the same subsystem values).
NGUID and EUI-64 live in Identify **Namespace**, not the controller structure ([§4.3.4, §4.3.5](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf); libnvme documents them on `struct nvme_id_ns`, while `sn`/`mn`/`fr` live on [`struct nvme_id_ctrl`](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/src/nvme/types.h#L1467-L1477) and `subnqn` on the same structure ([types.h](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/src/nvme/types.h#L1576))). Segmentation keyed on a namespace identifier would split or merge history whenever namespaces are attached, detached, formatted, or recreated, while the DUW counter — the thing being differenced — sails on unchanged. The spec's own namespace-identity guidance (§3.2.1.6: NSIDs "may change across power off conditions"; to detect the same namespace use UUID, NGUID, or EUI-64) addresses a different problem from ours.
## Stability verdicts (reboots, firmware, replacement)
1. **Reboots, same drive** — SN, MN, SUBNQN are stable: they are vendor-assigned subsystem fields, and an NQN "is permanent for the lifetime of the host or NVM subsystem" ([§4.5](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)). SMART data "is retained across power cycles" ([§5.16.1.3](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)).
2. **Firmware update, same drive** — FR changes by definition (it reports the active revision). The spec guarantees persistence of SMART data across *power cycles*, and says nothing about firmware commits resetting counters — vendor behavior, which is precisely why ADR 0001 keeps the DUW-monotonic rule as an independent boundary. Identity (SN/MN/SUBNQN) is not specified to change with firmware; keeping FR out of the key means a firmware update never quarantines history as a "new drive", and a firmware-induced counter reset is caught by the DUW rule instead.
3. **Drive replacement, different model** — every candidate changes.
4. **Drive replacement, identical model** — MN unchanged; SN changes *if* vendor serials are unique; SUBNQN changes because both real NQNs (empirically this host's Micron embeds the serial: `nqn.2016-08.com.micron:nvme:nvm-subsystem-sn-233542F44436`) and kernel-generated NQNs (which concatenate SN and MN, [drivers/nvme/host/core.c nvme_init_subnqn](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/drivers/nvme/host/core.c#L3164-L3194)) derive from the serial.
5. **NGUID/EUI-64 under namespace churn** — not stable in the needed sense: if the UIDREUSE bit is 0 "a controller **may reuse** a non-zero NGUID/EUI64 value for a new namespace after the original namespace using the value has been deleted" ([§4.5.1](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)); libnvme's own field docs say the values hold only "throughout the life of the namespace", "preserved across namespace and controller operations" ([types.h nguid/eui64](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/src/nvme/types.h#L2648-L2656)).
## Availability and known pathologies
- **SN/MN**: mandatory for I/O and Admin controllers ([§5.17.2.1](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)) and exposed by the kernel since 4.5 ([sysfs-nvme: /sys/class/nvme/nvmeX/{model,serial,firmware_rev}](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/Documentation/ABI/stable/sysfs-nvme#L1-L10)). But uniqueness is disclaimed: "The mechanism used by the vendor to assign Serial Number and Model Number values to ensure uniqueness is outside the scope of this specification" ([§4.5.1](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)). Duplicate serials across units are therefore spec-legal.
- **SUBNQN**: zero on pre-1.2.1 subsystems ([§5.17.2.1](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf); the Persistent Event Log likewise defines the not-supported case as all bytes cleared to 0h), and some real devices report garbage: the kernel carries `NVME_QUIRK_IGNORE_DEV_SUBNQN` for, among others, Intel P4500/P4600, Intel 760p/Pro 7600p, and a Silicon Motion device ([pci.c quirk table](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/drivers/nvme/host/pci.c#L4121-L4144), [nvme.h flag definition](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/drivers/nvme/host/nvme.h#L103-L106)). This is why Fenris should read the **kernel-exposed** value rather than the raw Identify bytes: when the device NQN is missing, invalid, or quirk-ignored, `nvme_init_subnqn` synthesizes `nqn.2014.08.org.nvmexpress:{vid}{ssvid}{sn}{mn}` — mirroring the spec's own construction for pre-1.2.1 subsystems ([§4.5.1, "NQN Construction for Older NVM Subsystems"](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf), which composes the NQN starting string, VID, SSVID, SN, MN) — so `/sys/.../subsysnqn` is populated on every kernel ≥ 4.8 for every controller ([sysfs-nvme subsysnqn entry, added 4.8](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/Documentation/ABI/stable/sysfs-nvme#L48-L60)). The kernel also uses the NQN as *the* subsystem key when building multipath heads ([core.c: subsystems are matched by `subsys->subnqn`](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/drivers/nvme/host/core.c#L3345-L3350)).
- **Virtual controllers / blank serials**: the kernel's own NVMe target (nvmet, the `loop` transport) sets model number to the literal `"Linux"` and generates a **random** serial per subsystem "as our controllers are ephemeral" ([target/core.c nvmet_subsys_alloc](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/drivers/nvme/target/core.c#L1840-L1856), [nvmet.h `NVMET_DEFAULT_CTRL_MODEL`](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/drivers/nvme/target/nvmet.h#L31)); Identify then reports those values verbatim ([target/admin-cmd.c](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/drivers/nvme/target/admin-cmd.c#L670-L677)). For such devices `model|serial` is unstable across target re-creation, while the NQN is the configured subsystem name. No identifier can make an ephemeral virtual drive look like stable hardware; the degraded marker covers it.
- **NGUID/EUI-64**: optional — the kernel sysfs attributes are documented as "Hidden if all zeros" ([sysfs-nvme nguid/eui entries](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/Documentation/ABI/stable/sysfs-nvme#L259-L293)), and the spec requires only that *at least one* of EUI64/NGUID/UUID be valid at namespace creation ([§4.5.1](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)).
## The key, normalization, and failure handling
1. **Primary key**: `strip(subnqn)` read from the kernel path (via libnvme; see next section). Values are ASCII/UTF-8 with code values 0x20–0x7E, left-justified and space-padded per the spec's string rules ([§1.4.2 ASCII/UTF-8 string conventions](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)); normalize by stripping trailing (and leading) spaces only. **No case folding**: NQNs are compared "as binary strings without any text processing (e.g., case folding)" ([§4.5, NQN processing rules](https://nvmexpress.org/wp-content/uploads/NVM-Express-Base-Specification-2.0e-2024.07.29-Ratified.pdf)), and SN/MN have no canonical case either.
2. **Fallback ladder** (defensive; on Linux ≥ 4.8 the kernel fallback already fires before Fenris ever sees an empty value): (a) device-provided SUBNQN; (b) the kernel's composite `nqn.2014.08.org.nvmexpress:{vid}{ssvid}{sn}{mn}` built from Identify — the kernel spells the date with dots and concatenates the raw fixed-width SN and MN, "slightly different from the format specified" in §4.5.1's NQN construction "for historic reasons" ([core.c nvme_init_subnqn](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/drivers/nvme/host/core.c#L3164-L3194)); Fenris's fallback reproduces the kernel spelling so a fallback-built key equals the sysfs value byte-for-byte; (c) `strip(mn)|strip(sn)`. Every rung changes on drive replacement and survives firmware updates; the ladder exists so a missing rung never yields a blank key from a perfectly good physical drive.
3. **Blank key** (all components empty — e.g. a virtual controller reporting nothing): record `identity_key = ""` plus a `degraded` marker and segment by DUW monotonicity alone; surface as a fact, never fabricate uniqueness (matches ADR 0005's "facts, not alerts" posture).
4. **Duplicate keys across physical units** are undetectable by construction when both units report identical SN/MN/SUBNQN. The mitigation already exists in ADR 0001: a replacement drive almost certainly reports a **lower** DUW than the accumulated history, and any DUW decrease forces a segment boundary regardless of identity. Write deltas remain quarantined even though identity cannot distinguish the units.
5. **Recorded metadata per segment**: normalized `subnqn`, `sn`, `mn`, `fr`, plus `transport` — diagnostics for humans, not key components.
## How the collector obtains the key via libnvme
Three concrete paths, all first-party:
1. **libnvme Python bindings** (`from libnvme import nvme`, official SWIG bindings, ["python bindings for libnvme"](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/pyproject.toml#L1-L8)). `nvme.root()` scans sysfs ([nvme.i: nvme_root() calls nvme_scan](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/libnvme/nvme.i#L492-L501)); controller objects expose `model`, `serial`, `firmware`, `subsysnqn`, `name`, `sysfs_dir` as attributes ([nvme.i struct nvme_ctrl attributes](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/libnvme/nvme.i#L402-L424)); namespace objects expose `nsid`, `nguid`, `eui64`, `uuid` ([nvme.i struct nvme_ns](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/libnvme/nvme.i#L481-L490)). These getters read sysfs via `nvme_get_ctrl_attr` ([tree.c populates ctrl fields from sysfs attributes](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/src/nvme/tree.c#L2085-L2097)), and `__nvme_get_attr` **strips the trailing newline and trailing spaces and returns NULL when the result is empty** ([linux.c L526–552](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/src/nvme/linux.c#L526-L552)) — i.e., the bindings deliver exactly the normalization this decision requires, and a blank field arrives as `None`.
2. **Admin passthrough** for raw Identify: `nvme_ctrl_identify(c, &id)` fills `struct nvme_id_ctrl` ([man page: "Issues an 'identify controller' command"](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/doc/man/nvme_ctrl_identify.2#L13-L17)), whose `sn[20]`/`mn[40]`/`fr[8]` and `subnqn[256]` members are documented in the [nvme_id_ctrl(2) man page](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/doc/man/nvme_id_ctrl.2#L5-L15) ([subnqn member](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/doc/man/nvme_id_ctrl.2#L616-L617)); namespace identifiers come from `nvme_ns_identify` filling `struct nvme_id_ns` ([tree.h nvme_ns_identify](https://github.com/linux-nvme/libnvme/blob/ad61ac8a319ad0823c1c9861eecbf66125f8b9a1/src/nvme/tree.h#L823-L832)). Here the collector must strip trailing spaces itself.
3. **nvme-cli JSON** (built on libnvme): `nvme id-ctrl -o json` emits `sn`, `mn`, `fr`, `cntlid` with strings copied verbatim *including padding* ([nvme-print-json.c L409–L422](https://github.com/linux-nvme/nvme-cli/blob/c8ec7e41f3738b20828849228a549f4ed1d03fc0/src/nvme-print-json.c#L409-L422)) plus `subnqn`, which is included only when non-empty ([L522–L523](https://github.com/linux-nvme/nvme-cli/blob/c8ec7e41f3738b20828849228a549f4ed1d03fc0/src/nvme-print-json.c#L522-L523)); `nvme list -o json` emits per device `DevicePath`, `Firmware`, `ModelNumber`, `SerialNumber` ([v2.16 json_list_item_obj](https://github.com/linux-nvme/nvme-cli/blob/faf7326a2997dea91687fd3daa17fc405910a4c1/nvme-print-json.c#L4669-L4693)). So stripping trailing spaces is the collector's job in both nvme-cli paths.
What libnvme/nvme-cli offer beyond the current `smartctl -j` collector: `subnqn` (the chosen key), `cntlid`, `vid`/`ssvid`, and the namespace identifiers — smartmontools' NVMe JSON device section carries `model_name`, `serial_number`, `firmware_version` (from `id_ctrl.mn/sn/fr`, [nvmeprint.cpp print_drive_info](https://github.com/smartmontools/smartmontools/blob/9f83095a631ff71df44f8065c7a4a00134d3d404/smartmontools/nvmeprint.cpp#L108-L121)) and no NQN. smartmontools also trims the strings it copies ([utility.cpp format_char_array strips leading/trailing spaces](https://github.com/smartmontools/smartmontools/blob/9f83095a631ff71df44f8065c7a4a00134d3d404/smartmontools/utility.cpp#L692-L708)), so normalization is compatible across both collectors.
## Empirical check (one real drive, sysfs only)
```console
$ cat /sys/class/nvme/nvme0/{model,serial,firmware_rev,subsysnqn}
Micron_2400_MTFDKBA512QFM<spaces to 40> # sysfs preserves Identify padding
233542F44436<spaces to 20>
V3MA001<space> # FR padded to 8
nqn.2016-08.com.micron:nvme:nvm-subsystem-sn-233542F44436
$ cat /sys/block/nvme0n1/{nguid,eui,wwid}
00000000-0000-0001-00a0-752342f44436
00 a0 75 01 42 f4 44 36
eui.000000000000000100a0752342f44436
```
Confirms: raw sysfs keeps the spec's trailing-space padding (strip before storing); the vendor NQN embeds the serial; NGUID/EUI-64 are present here but are per-namespace; `/sys/class/nvme-subsystem/nvme-subsys0/{model,serial,firmware_rev,subsysnqn}` carries the same subsystem-level values ([sysfs-nvme nvme-subsystem entries, added 4.15](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/Documentation/ABI/stable/sysfs-nvme#L425-L434)). The kernel's own namespace `wwid` uses the priority ladder `uuid.{UUID}` → `eui.{NGUID}` → `eui.{EUI64}` → `nvme.{VID}-{SERIAL}-{MODEL}-{NSID}` ([sysfs-nvme wwid](https://github.com/torvalds/linux/blob/cee9395acd8043be0644b25c34bfa86623f2b935/Documentation/ABI/stable/sysfs-nvme#L277-L285)) — a first-party precedent for a serial+model fallback when better identifiers are absent.
## What ADR 0001's segment and migration rules must assume
1. **Legacy `history.jsonl` cannot carry a full identity.** The legacy collector records `device` (`/dev/nvme0`), `model` (smartctl `model_name`), `capacity_bytes`, and the counters — **no serial, no NQN, no firmware** ([fenris.py sample(): the identity-adjacent fields are `device` and `model` only](https://git.bongbetic.com/xavierk/Fenris/src/commit/d45d931a0deaaa4ff8c8ba0ff2454c0d2a211ef9/fenris.py#L73-L76)). Migration may therefore only import legacy samples under a **model-scoped legacy identity**, explicitly labeled incomplete. It cannot prove the legacy samples came from the current drive, cannot detect a same-model swap that happened before migration, and must not backfill serials retroactively.
2. **The first new-version collection run starts a new controller segment** for the monitored drive, recording full `identity_key` plus `sn`/`mn`/`fr`/`subnqn` metadata, because the legacy and new identities are not comparable — the identity change is an epistemic boundary, not a detected drive swap. Imported legacy rows keep their legacy segment; the DUW-monotonic rule already governs deltas within it.
3. **Two independent segmentation axes stay independent**: identity-key change ⇒ boundary (drive replacement); DUW decrease ⇒ boundary (counter reset, firmware quirk, or same-identity replacement). Neither implies the other; ADR 0001's `controller_segments` wording ("boundaries where controller identity changes or DUW decreases") already encodes this, and this research fixes "controller identity" to mean the normalized kernel-exposed subsystem NQN with the fallback ladder above.
4. **The key is a string with rules, not just a field choice**: normalization (space-stripping, no case folding) must be specified once and applied at write time, or the same drive could split its own history across a collector implementation change (smartctl, nvme-cli, and libnvme deliver differently-trimmed values, as shown above).
## Newly surfaced questions
- Should the store record `vid`/`ssvid`/`cntlid` alongside segment metadata now (cheap) for future composite needs, or keep segments minimal?
- Should a detected blank/degraded identity degrade the projection confidence category (it weakens the "identity/counter discontinuity" axis of the Unavailable state)?
- Does the collector pin to one acquisition path (libnvme bindings vs `nvme list -o json` vs continued `smartctl -j` plus a sysfs read for `subnqn`) — an operational choice this research does not settle?
@@ -1,259 +0,0 @@
# Fenris: systemd lifecycle and privilege constraints
**Ticket:** “Verify systemd lifecycle and privilege constraints”
**Status:** Research and planning only; no product implementation is included
**Research date:** 2026-08-31
## Executive recommendation
Run collection as a **system timer plus a short-lived system service**, not as a user service and not as the TUI's child process. Enable `fenris-collect.timer` at installation so PID 1 schedules a collection shortly after every boot and thereafter at the configured interval. Keep the interactive TUI an ordinary, on-demand, unprivileged process.
The collection service should invoke an absolute, administrator-owned `smartctl` binary directly—never `sudo`—and should have no listener or TUI code. It should read root-owned configuration from `/etc/fenris/`, use `/var/lib/fenris/` for durable history, use `/run/fenris/` only for ephemeral status/locking, and log to the journal. `StateDirectory=` and `RuntimeDirectory=` create and lifecycle-manage those standard locations and add the mount dependencies needed to reach them; state directories persist after service stop, while runtime directories normally do not. [systemd.exec(5), directory options](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=)
The TUI should:
1. inspect a deliberately small, non-secret status surface without elevation;
2. display **runtime state**, **boot enablement**, **last sample outcome**, and **freshness** separately;
3. offer only explicit “Enable collection at boot” and “Disable collection at boot” actions, with confirmation; and
4. ask systemd to make that change, allowing the platform's normal polkit authentication to occur.
Do **not** install a permissive polkit rule granting `org.freedesktop.systemd1.manage-unit-files` to a Fenris group. systemd uses that action for enable/disable/mask/preset and related unit-file operations generally, and its current unit-file authorization check supplies no unit detail with which a rule could safely limit authorization to Fenris. [systemd D-Bus API, Security](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html#Security); [systemd `dbus-util.c`, pinned source: `manage-unit-files` has `details = NULL`](https://github.com/systemd/systemd/blob/a6a831d0d9ce304619b8937b27b1286109b5e625/src/core/dbus-util.c#L209-L223). If passwordless delegated startup control becomes a requirement, add a purpose-built, root-owned helper exposing only the two fixed Fenris operations and authorize that helper with a Fenris-specific polkit action; do not grant the generic systemd action.
## Product context observed in this repository
The current prototype combines sampling, persistence, HTTP serving, process daemonization, PID-file management, status, and control in [`fenris.py`](../../fenris.py). It starts a detached Python process itself, stores history/PID/log files under the checkout's `data/`, binds the dashboard to `0.0.0.0`, and runs `sudo -n smartctl -a -j DEVICE`. [`fenris.sh`](../../fenris.sh) starts/stops that process and currently recommends a passwordless sudoers entry for `/usr/sbin/smartctl` without argument constraints. The README describes the intended five-minute continuous collection and on-demand menu/dashboard.
That prototype shape is unsuitable for a boot-persistent privileged installation: a privileged process would also contain the HTTP server and large dashboard surface, checkout-relative state has no system ownership boundary, PID files duplicate service-manager state, and the broad sudoers example permits more than Fenris's read-only query. The recommendation below separates those concerns rather than wrapping the existing `start` command in a unit.
## Verified constraints
| Area | Verified constraint | Design consequence |
|---|---|---|
| System vs. user manager | A non-root user service cannot switch to another identity with `User=`; system services default to root and may select another user. [systemd.exec(5), `User=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#User=) | A normal user service is not a reliable privilege boundary for SMART access. Collection belongs in the system manager. |
| User-service persistence | User lingering causes that user's manager to be spawned at boot and kept after logout; without this extra policy, a user manager is session-oriented. [loginctl(1), `enable-linger`](https://www.freedesktop.org/software/systemd/man/latest/loginctl.html#enable-linger%20USER%E2%80%A6) | A user unit either fails the reboot/no-login requirement or requires lingering while still not solving device privilege. Reject it for the collector. |
| Boot enablement | `[Install]` directives do not execute at runtime; `enable` materializes them as symlinks. `WantedBy=` creates a `.wants/` link, and `timers.target` is the recommended boot target for application timers. [systemd.unit(5), `[Install]`](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#%5BInstall%5D%20Section%20Options); [systemd.special(7), `timers.target`](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html#timers.target) | Install with `WantedBy=timers.target`, then explicitly enable the timer. Merely shipping the files does not enable startup. |
| Enabled vs. running | `systemctl enable` does not start a unit, and `disable` does not stop it; `--now` couples those otherwise separate changes. [systemctl(1), `enable`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#enable%20UNIT%E2%80%A6); [systemctl(1), `disable`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#disable%20UNIT%E2%80%A6); [systemctl(1), `--now`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#--now) | The TUI must label startup and immediate runtime effects separately. Default startup actions should not silently use `--now`. |
| Timer recurrence | Combining `OnBootSec=` with a relative trigger provides post-boot and recurring activation. Timer expiry is coalesced within `AccuracySec=` (one minute by default). [systemd.timer(5), monotonic timers](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#OnActiveSec=); [systemd.timer(5), `AccuracySec=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#AccuracySec=) | Use `OnBootSec=` plus `OnUnitInactiveSec=` (or `OnUnitActiveSec=` after overlap behavior is tested), and choose accuracy deliberately. Do not promise exact-second polling. |
| Missed runs | `Persistent=` records the last trigger and can fire when an inactive timer is reactivated; its documented clock behavior must be considered with monotonic timers. [systemd.timer(5), `Persistent=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#Persistent=) | Fenris reads lifetime counters, so replaying every missed five-minute sample is neither possible nor useful. One prompt post-boot sample is sufficient; decide whether suspend catch-up warrants `Persistent=yes`. |
| Boot ordering | `timers.target` exists to activate timers after boot. `StateDirectory=`/`RuntimeDirectory=` automatically add `Requires=` and `After=` dependencies for mounts needed by their paths. `network-online.target` is for consumers that strictly require configured networking. [systemd.special(7), `timers.target`](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html#timers.target); [systemd.exec(5), implicit dependencies](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Implicit%20Dependencies); [systemd.special(7), `network-online.target`](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html#network-online.target) | Do not order local SMART collection after the network. Let managed data directories establish filesystem ordering. Treat a not-yet-present NVMe device as a failed sample retried at the next interval unless a fixed device-unit dependency is proven necessary. |
| `smartctl` Linux path | smartmontools opens a Linux NVMe device read-only and sends `NVME_IOCTL_ADMIN_CMD`; failure is returned from the ioctl. [smartmontools `os_linux.cpp`, pinned source](https://github.com/smartmontools/smartmontools/blob/618fcaede4478bc7d17fa2a8db5fd18af3744e20/lib/os_linux.cpp#L2817-L2861) | Ordinary file read permission alone does not prove the admin ioctl will be authorized. Validate the shipped unit on every supported kernel/device transport. Root execution is the robust initial compatibility choice. |
| Capability substitution | `CAP_SYS_ADMIN` is intentionally overloaded and is described as plausibly “the new root”; Linux man-pages explicitly advise avoiding it where possible. [capabilities(7), `CAP_SYS_ADMIN`](https://man7.org/linux/man-pages/man7/capabilities.7.html#CAP_SYS_ADMIN); [capabilities(7), developer notes](https://man7.org/linux/man-pages/man7/capabilities.7.html#NOTES) | Do not move `CAP_SYS_ADMIN` into the long-lived TUI or combined web process merely to avoid UID 0. A short-lived, sandboxed root collector has a smaller practical exposure. A minimal capability set remains a test item, not an assumption. |
| Device sandboxing | `PrivateDevices=yes` supplies only pseudo-devices and excludes physical devices. [systemd.exec(5), `PrivateDevices=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#PrivateDevices=) | The collector must not set `PrivateDevices=yes`. Prefer a device cgroup allow-list for the configured node where supported and tested. The TUI/dashboard can use `PrivateDevices=yes`. |
| sudo non-interactivity | `sudo -n` never prompts and fails when authentication is required. [sudo(8), pinned upstream source](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudo.man.in#L629-L635) | `sudo -n` prevents a daemon hang but does not create authorization. It is unnecessary inside a root system service and gives poor interactive UX in the TUI. |
| sudoers command scope | If a sudoers command omits arguments, the user may supply any arguments; argument wildcards require care. `NOPASSWD` removes authentication for matching entries. [sudoers(5), pinned upstream source](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudoers.man.in#L1107-L1129); [sudoers(5), `PASSWD`/`NOPASSWD`](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudoers.man.in#L2063-L2089) | The README's path-only `NOPASSWD: /usr/sbin/smartctl` rule is too broad. Do not retain it as the installed architecture. If sudo is retained as a fallback, use a root-owned fixed-argument helper, not user-controlled smartctl arguments. |
| systemd authorization | Read access to systemd's D-Bus objects is generally available; state-changing unit operations require `manage-units`, while enablement operations require `manage-unit-files`. [systemd D-Bus API, Security](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html#Security) | Status needs no blanket elevation. Starting/stopping and enabling/disabling are separate privileged action classes. |
| polkit rules | polkit loads JavaScript rules from `/etc/polkit-1/rules.d` and `/usr/share/polkit-1/rules.d`; a rule may return `AUTH_ADMIN`, while `AUTH_ADMIN_KEEP` caches authorization briefly for the same action/subject. [polkit(8), authorization rules](https://polkit.pages.freedesktop.org/polkit/polkit.8.html#AUTHORIZATION-RULES) | Prefer normal admin authentication and avoid cached authorization for a sensitive toggle. A custom delegated helper needs its own narrow action and rule. |
| Status parsing | `systemctl status` is human-readable and may include recent journal lines; `systemctl show` is the computer-parsable interface and supports selecting properties. [systemctl(1), `status`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#status%20PATTERN%E2%80%A6); [systemctl(1), `show`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#show%20PATTERN%E2%80%A6) | Never scrape `status` in the TUI. Query an explicit property allow-list and do not echo arbitrary logs in the default status screen. |
| Durable and ephemeral paths | systemd maps `StateDirectory=` to `/var/lib/` and `RuntimeDirectory=` to `/run/`; state/config/log directories remain after stop, while runtime directories are normally removed. [systemd.exec(5), directory table and lifecycle](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=) | Durable samples belong in `/var/lib/fenris`; locks/sockets belong in `/run/fenris`. A checkout-relative `data/` directory and PID file should not survive migration. |
## Proposed lifecycle and control boundary
```text
boot
└─ system systemd
└─ enabled fenris-collect.timer (unprivileged users may inspect)
└─ periodically activates fenris-collect.service
├─ short-lived privileged collector only
├─ reads /etc/fenris/fenris.conf
├─ runs /usr/sbin/smartctl with fixed read-only query arguments
├─ validates and sanitizes JSON
└─ atomically updates /var/lib/fenris/{history,latest,status}
interactive login (independent of collection)
└─ fenris TUI, ordinary invoking user
├─ reads safe status and permitted data
├─ queries selected systemd read-only properties
└─ on explicit confirmed request only:
└─ systemctl enable|disable fenris-collect.timer
└─ system bus → polkit admin authentication → PID 1
```
### Boundary rules
- **Collector:** may access the configured block/controller device and write only Fenris state. It must not bind a network socket, render a UI, edit its configuration, alter unit enablement, or run caller-supplied commands.
- **TUI/dashboard:** may read sanitized state. It must not inherit collector privilege. If a dashboard remains, run it as a separate unprivileged process bound to loopback by default; the current `0.0.0.0` binding must not become part of the privileged unit.
- **systemd/PID 1:** owns process lifecycle and boot enablement. Remove application daemonization, kill-by-PID, and checkout PID files. A service process should remain in the foreground; systemd's service model assumes the started process remains until termination unless a forking type is deliberately used. [systemd.service(5), examples](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Examples)
- **Administrator:** owns installation, `/etc/fenris`, unit files, authorization policy, membership in any read group, and startup-state changes.
A timer/oneshot split is preferable to a continuously privileged daemon because Fenris samples cumulative device counters and needs privilege only during a sample. If later requirements demand a live HTTP server, keep it in a distinct unprivileged service rather than extending collector lifetime.
## Unit sketch (planning only)
The following is intentionally illustrative. Paths, device cgroup syntax, hardening compatibility, and interval behavior must be validated on supported distributions before shipping.
```ini
# /usr/lib/systemd/system/fenris-collect.service
[Unit]
Description=Fenris NVMe SMART sample collector
Documentation=man:smartctl(8)
[Service]
Type=oneshot
ExecStart=/usr/libexec/fenris/fenris-collect --config /etc/fenris/fenris.conf
# Packaging creates the non-privileged read group; the process remains UID 0.
Group=fenris-readers
StateDirectory=fenris
StateDirectoryMode=0750
RuntimeDirectory=fenris
RuntimeDirectoryMode=0750
UMask=0027
# Hardening candidates; validate with smartctl on every supported transport.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=no
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictAddressFamilies=AF_UNIX
# DevicePolicy=closed
# DeviceAllow=/dev/nvme0 r
# CapabilityBoundingSet=... # unresolved; do not guess CAP_SYS_ADMIN-only portability
```
`ProtectSystem=strict` makes the hierarchy read-only except API filesystems, while managed state/log directories are excluded so they remain writable; systemd recommends the protection for long-running services, and it is still useful defense-in-depth for this short-lived one. [systemd.exec(5), `ProtectSystem=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#ProtectSystem=) `NoNewPrivileges=yes` prevents this process and its descendants from gaining new privilege through `execve` mechanisms such as set-user-ID bits or file capabilities. [systemd.exec(5), `NoNewPrivileges=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#NoNewPrivileges=)
```ini
# /usr/lib/systemd/system/fenris-collect.timer
[Unit]
Description=Periodically collect Fenris NVMe SMART samples
[Timer]
OnBootSec=2min
OnUnitInactiveSec=5min
AccuracySec=30s
Unit=fenris-collect.service
[Install]
WantedBy=timers.target
```
`OnUnitInactiveSec=` measures from deactivation, which avoids overlapping a slow oneshot at the cost of interval drift; `OnUnitActiveSec=` measures from activation. Those distinct bases are defined by `systemd.timer(5)`. [systemd.timer(5), monotonic timer table](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html#OnActiveSec=) Choose between them after measuring collection duration and confirming the desired interval semantics.
Do not add `After=network-online.target`; collection is local. Do not add `Requires=/dev/...` as pseudo-syntax. If strict device binding is needed, use the escaped `.device` unit generated for the configured node only after testing replacement/hotplug behavior; otherwise record a bounded failure and let the next timer activation retry.
## Authorization and TUI control sketch (planning only)
### Default: systemctl plus normal polkit authentication
Read path:
```text
systemctl show fenris-collect.service \
--property=LoadState,ActiveState,SubState,Result,ExecMainStatus
systemctl show fenris-collect.timer \
--property=LoadState,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,NextElapseUSecMonotonic
```
Mutation path, only after a confirmation screen naming the exact effect:
```text
Enable at future boots: systemctl enable fenris-collect.timer
Disable at future boots: systemctl disable fenris-collect.timer
```
Do not silently append `--now`. “Disable at boot” does not mean “cancel a currently executing sample,” and systemctl explicitly keeps enablement separate from start/stop unless `--now` is requested. [systemctl(1), `--now`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#--now)
The TUI must pass a fixed unit name and fixed verb without a shell. On cancellation, authentication failure, timeout, or non-zero exit, it should report no successful change and re-read `UnitFileState`; it must not infer success from the requested action.
### Why not a broad polkit group rule
`manage-units` calls can include `unit` and `verb` details in current systemd source, but the `manage-unit-files` check currently has no details. [systemd `dbus-util.c`, pinned source](https://github.com/systemd/systemd/blob/a6a831d0d9ce304619b8937b27b1286109b5e625/src/core/dbus-util.c#L160-L223) Therefore a rule such as “members of `fenris` may perform `org.freedesktop.systemd1.manage-unit-files`” would delegate generic unit enablement/masking operations, not only Fenris startup state. That is outside the required boundary.
### If delegated passwordless control is later required
Use a small root-owned D-Bus/helper mechanism with a Fenris-specific action, for example `com.bongbetic.fenris.manage-startup`. Its API should accept only an enum `{enable, disable}`, internally target the constant `fenris-collect.timer`, reject options/paths/extra units, perform the unit-file operation, and return the observed resulting state. A polkit rule may then grant that custom action to a designated local group, optionally requiring `subject.local && subject.active`; polkit exposes subject locality/activity and action details to JavaScript rules. [polkit(8), authorization rules and `Subject`](https://polkit.pages.freedesktop.org/polkit/polkit.8.html#AUTHORIZATION-RULES)
A sudo fallback should follow the same fixed helper design. Do not authorize `/usr/bin/systemctl` or `/usr/sbin/smartctl` without exact argument control: sudoers explicitly permits arbitrary arguments when none are specified. [sudoers(5), command arguments](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudoers.man.in#L1107-L1129)
## Ownership and path choices
| Path | Proposed owner/mode | Purpose and rationale |
|---|---|---|
| `/usr/libexec/fenris/fenris-collect` (or distribution-equivalent) | `root:root`, `0755`, package-managed | Privileged entry point must not be writable by TUI users or the service's read group. Use an absolute `ExecStart`; systemd does not provide shell syntax by default. [systemd.service(5), command lines](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Command%20lines) |
| `/usr/lib/systemd/system/fenris-collect.{service,timer}` | `root:root`, `0644`, package-managed | Vendor unit definitions. Local administrator overrides belong under `/etc/systemd/system/`, which has higher load-path precedence. [systemd.unit(5), unit load path](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html#Unit%20File%20Load%20Path) |
| `/etc/fenris/fenris.conf` | `root:root`, `0644` if strictly non-secret; otherwise `0640` | Persistent host configuration. The collector reads but cannot write it under `ProtectSystem=strict`. The TUI should receive only safe fields through status rather than requiring config write access. |
| `/var/lib/fenris/` | created by `StateDirectory=fenris`, `0750`; `root:fenris-readers` if direct group reads are retained | Durable history, aggregates, latest sample, and machine-readable sample status. systemd maps state directories here and leaves them after stop. [systemd.exec(5), directory table/lifecycle](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectory=) |
| `/run/fenris/` | created by `RuntimeDirectory=fenris`, `0750` | Ephemeral lock/socket only. Do not use a PID file as authority; systemd already tracks the process/unit. Runtime directories are removed on stop by default. [systemd.exec(5), `RuntimeDirectoryPreserve=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#RuntimeDirectoryPreserve=) |
| Journal | journal ACL/policy | Operational diagnostics. Avoid a separate root-owned `/var/log/fenris.log` unless retention/export requirements demand it. Do not expose arbitrary journal messages through safe status. |
These locations also match FHS semantics: `/etc` holds host-specific configuration, `/run` is run-time variable data cleared at boot, and `/var/lib` holds application state that persists across restarts. [FHS 3.0, `/etc`](https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch03s07.html); [FHS 3.0, `/run`](https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch03s15.html); [FHS 3.0, `/var/lib`](https://refspecs.linuxfoundation.org/FHS_3.0/fhs/ch05s08.html)
Prefer a narrow read-only IPC/status endpoint over group-readable raw files if multi-user confidentiality matters. The sketch instead uses a dedicated `fenris-readers` primary group for the UID-0 collector so managed directories become `root:fenris-readers`; packaging must create that group, and only approved users should join it. Do not reuse the collector's privileged identity as a user-facing authorization group. Use atomic replace for `latest.json`/`status.json`, append safely for history, set a restrictive umask, and omit serial numbers, command lines, environment, and raw stderr from the shared surface.
## Safe status design
The default TUI status should be an allow-listed composition, not a dump of `systemctl status`, journal output, raw smartctl JSON, configuration, or process command lines.
Recommended fields:
```text
Installation: loaded | not-installed | error
Startup: enabled | disabled | static | masked | unknown
Scheduler runtime: active | inactive | failed
Collection runtime: active | inactive | failed
Last attempt: RFC3339 timestamp
Last success: RFC3339 timestamp
Freshness: fresh | stale | never (show threshold and age)
Last result: success | device-unavailable | permission-denied |
timeout | invalid-output | storage-error | internal-error
Next scheduled: timestamp if systemd reports one
Samples/history: count and range, if readable
Device: configured stable identifier or node; no serial by default
```
Design requirements:
- Treat `UnitFileState` (startup policy), `ActiveState`/`SubState` (runtime), and last sample result as independent axes. `is-enabled` documents multiple states—including enabled, disabled, static, indirect, generated, transient, and masked—so a Boolean loses actionable information. [systemctl(1), `is-enabled` state table](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#is-enabled%20UNIT%E2%80%A6)
- Parse only `systemctl show --property=...` or the equivalent D-Bus properties. `status` is explicitly human-oriented and includes journal data. [systemctl(1), `show`](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#show%20PATTERN%E2%80%A6)
- Compute freshness from `last_success`, not merely timer activity. An active timer can coexist with repeated collection failures.
- Publish bounded error categories and a short administrator hint; keep raw smartctl stderr and tracebacks in the journal. This prevents device identifiers, paths, malformed device output, or command details from crossing the read boundary.
- Never report “enabled” immediately after a requested mutation without re-querying the authoritative state. Never equate “disabled” with “stopped.”
- If status data is unreadable, say `permission-denied`/`unknown`; do not elevate merely to render the screen.
## Alternatives and trade-offs
### Long-running system service
A foreground `fenris-collector.service` with `Restart=on-failure` and `WantedBy=multi-user.target` also satisfies reboot persistence; `Restart=` controls automatic restart after process failure, while `WantedBy=multi-user.target` is the standard installation relationship for a multi-user service. [systemd.service(5), `Restart=`](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Restart=); [systemd.special(7), `multi-user.target`](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html#multi-user.target)
Trade-off: it preserves today's loop model and precise in-process scheduling, but leaves a privileged Python process resident continuously and requires restart/backoff handling. Use it only if future collection needs persistent in-memory state that cannot be reconstructed from durable samples.
### Unprivileged daemon plus privileged smartctl helper
This can reduce privileged code if the helper accepts no uncontrolled path or options, validates a configured device allow-list, emits bounded sanitized output, and exits. It adds an IPC/protocol and another authorization surface. Giving the whole Python process `CAP_SYS_ADMIN` is not an equivalent reduction because that capability is exceptionally broad. [capabilities(7), notes](https://man7.org/linux/man-pages/man7/capabilities.7.html#NOTES)
### User service with lingering
Lingering can run a user manager from boot through logout, but the user manager cannot switch an ordinary user's unit to root and some system-service sandboxing features are unavailable in user services. [loginctl(1), lingering](https://www.freedesktop.org/software/systemd/man/latest/loginctl.html#enable-linger%20USER%E2%80%A6); [systemd.exec(5), user-service sandboxing limitations](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Sandboxing) This adds account coupling and still needs a separate privilege mechanism. Reject for collection; it remains acceptable for an optional per-user dashboard client.
### Root daemon calling `sudo -n smartctl`
This is redundant: root already crosses the privilege boundary, while sudo introduces policy/path/configuration failure modes. For an unprivileged daemon, `sudo -n` avoids blocking but only works with pre-authorized sudoers policy and produces no authentication opportunity. [sudo(8), `--non-interactive`](https://github.com/sudo-project/sudo/blob/194042b55c54055c7337fbbd93a518e8da69866f/docs/sudo.man.in#L629-L635) Reject as the installed systemd architecture.
### Direct broad polkit delegation
Convenient but unsafe for startup-state delegation: `manage-unit-files` covers generic enable/disable/mask/preset operations and currently lacks unit-scoping details in systemd's authorization call. Use normal administrator authentication or a custom narrow action instead. [systemd D-Bus API, Security](https://www.freedesktop.org/software/systemd/man/latest/org.freedesktop.systemd1.html#Security); [pinned systemd authorization source](https://github.com/systemd/systemd/blob/a6a831d0d9ce304619b8937b27b1286109b5e625/src/core/dbus-util.c#L209-L223)
## Unresolved questions and required validation
1. **Supported distributions/systemd floor:** What is the minimum systemd version? Confirm every selected hardening and directory directive exists there; unknown directives can weaken the intended sandbox.
2. **Device identity:** Is configuration a mutable `/dev/nvmeN` node, namespace node, controller, `/dev/disk/by-id` link, or discovered set? smartctl documents Linux NVMe controller and namespace forms, but the stable product identity and hotplug behavior remain a product decision. [smartctl(8), pinned source](https://github.com/smartmontools/smartmontools/blob/618fcaede4478bc7d17fa2a8db5fd18af3744e20/src/smartctl.8.in#L72-L86)
3. **Privilege matrix:** On each supported kernel, packaging of smartmontools, NVMe/SATA/USB bridge, and device permission setup, record which open/ioctl fails as an unprivileged service and which exact capability/device allow-list is sufficient. Do not generalize an NVMe result to every smartmontools transport.
4. **Root vs. reduced-capability collector:** After that matrix exists, decide whether a non-root static service user plus a minimal capability/device set works portably. Reject any result that requires putting broad capability into the TUI/dashboard.
5. **Timer semantics:** Should five minutes be measured from sample start or completion? What maximum runtime and timeout are acceptable? Should resume from suspend trigger immediately? Decide `OnUnitActiveSec` vs. `OnUnitInactiveSec`, `Persistent=`, and `AccuracySec` from those answers.
6. **Startup toggle semantics:** Does “disable startup” leave the timer active until reboot, or should the TUI offer a separate, clearly labeled “disable and stop now”? The systemctl semantics intentionally separate these effects. [systemctl(1), enable/disable](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#enable%20UNIT%E2%80%A6)
7. **Who may read health history:** All local users, a `fenris-readers` group, or only an authenticated local client? This determines state modes and whether a read-only Unix socket is preferable to files.
8. **Dashboard scope:** Is the HTTP dashboard retained, and if so must it be local-only or remotely accessible? Remote access needs a separate threat model, authentication, transport security, and unprivileged service; it must not enlarge the collector boundary.
9. **Packaging paths:** Confirm `/usr/libexec` and `/usr/lib/systemd/system` equivalents per target distribution, the absolute smartctl path, and whether administrator overrides use environment files or a dedicated validated config format.
10. **Data durability:** Define atomicity, fsync policy, retention, corruption recovery, migration from checkout-relative `data/`, and behavior on read-only/full filesystems.
11. **Authorization UX:** Is ordinary admin authentication acceptable? If not, specify exactly which local principals may toggle startup and commission the custom helper/action rather than broad `manage-unit-files` delegation.
12. **Status confidentiality:** Decide whether model name, device path, capacity, wear, temperatures, and error counters are safe for every local reader; serial numbers and raw smartctl output should remain excluded by default.
## Decision summary
The verified boundary is: **system systemd owns scheduling and boot state; a short-lived privileged collector owns only device interrogation and state writes; an unprivileged on-demand TUI owns presentation; polkit/admin authentication mediates explicit startup changes.** This satisfies collection across reboot without tying it to login, avoids embedding sudo in unattended code, keeps generic service control out of the TUI's ambient privilege, and makes startup state observable and alterable without conflating it with current execution.