# MainsKeeper prototype — shared file contract (foundation, round 6 · checkpoint 0.1.7)

**Round 6 corrections (§18 — read first):** one network identity function for duplicates (namespace|protocol|host|port|UPS name), store-enforced collector/role gating with `reconnect()`/`checkIn()`, one history inclusion rule with unknown spans carried by the shared series/lanes/aggregates/availability, freshness relative to the configured cadence (no more ≤ 60 s / ≤ 5 min), platform-specific instructions from `MK.cli.platform()`, and a runnable `state-checks.js`. Where an older section disagrees with §18, **§18 wins** (superseded lines are marked).

**Round 5 adds** the shared prototype state store (§12), the active-dataset rule (§12.3), the URL parameter contract (§8), configuration ownership and the single CLI grammar (§13–§14), staleness/alert/timestamp/backfill policies (§15), 7-day aggregates (§16) and **migration notes for every page package (§17)**.

This contract is stable. Page packages build on these files and **never edit them**. If something is missing, build it under `product/app/pages/<package>/` using the tokens and helpers below; do not fork them.

Files (owner: foundation):

```
product/brand/                      logomarks, wordmarks, lockups, app icons, favicons, mask icon, README.md
product/app/fonts/                  Sora-VF.woff2, DMMono-Regular.woff2, DMMono-Medium.woff2, OFL.txt
product/app/assets/tokens.css       all design tokens (colour roles, type, space, radii, elevation, motion, z, focus)
product/app/assets/base.css         OD layout primitives (verbatim, first) + reset, typography, utilities
product/app/assets/components.css   shared components (.mk-*)
product/app/assets/fixture.js       MK.fixture, MK.data, MK.select, MK.fmt, MK.policy  (fictional data + selectors; composes the store)
product/app/assets/store.js         MK.store                                  (shared prototype state, localStorage "mk-proto-v1")
product/app/assets/config-contract.js MK.config, MK.cli                       (config ownership table, CLI grammar/elevation/exit codes)
product/app/assets/shell.js         MK.shell, MK.url                          (chrome, review bar + dataset switch + Reset demo, theme, URL, shortcuts)
product/app/assets/components.js    MK.ui                                     (render + behaviour helpers, icon sprite)
product/app/assets/icons.svg        reference copy of the sprite (do not link it — see §5.1)
product/app/components.html         component gallery (both themes)
```

---

## 1. Build a page

### 1.1 Skeleton (copy exactly)

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fleet — MainsKeeper</title>
<link rel="icon" href="../brand/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="assets/tokens.css">
<link rel="stylesheet" href="assets/base.css">
<link rel="stylesheet" href="assets/components.css">
<link rel="stylesheet" href="pages/fleet-equipment/fleet.css">      <!-- optional, your package -->
<script src="assets/fixture.js"></script>                           <!-- synchronous, in <head>, this order -->
<script src="assets/store.js"></script>                             <!-- round 5: shared prototype state -->
<script src="assets/config-contract.js"></script>                   <!-- round 5: MK.config + MK.cli -->
<script src="assets/shell.js"></script>                             <!-- applies theme before first paint -->
<script src="assets/components.js"></script>
</head>
<body>
<main id="main">
  <div class="mk-page-head">
    <div class="mk-page-head__titles">
      <p class="mk-eyebrow">Fleet</p>
      <h1>Sites</h1>
      <p class="mk-now">Now · <span data-now></span></p>
    </div>
    <div class="mk-page-head__actions"><!-- buttons --></div>
  </div>
  <div id="content"></div>
</main>
<script src="pages/fleet-equipment/fleet.js"></script>               <!-- optional, your package -->
<script>
  MK.shell.mount({ page: 'fleet', title: 'Fleet' });                  // first, always
  // …render from MK.select / MK.ui, then:
  MK.ui.enhance(document.getElementById('main'));                     // after any later innerHTML
</script>
</body>
</html>
```

Rules:

- Pages live in `product/app/*.html`. Asset paths are relative (`assets/…`, `../brand/…`). Package CSS and JS go only in `product/app/pages/<package>/`.
- Load the five scripts **synchronously in `<head>`** in the order fixture → store → config-contract → shell → components. Call `MK.shell.mount()` from a script at the end of `<body>`, before you render content.
- The page provides `<main id="main">`. The shell adds the skip link, review bar, header, sub-nav, footer, mobile bottom nav, inspector host and toast host around it.
- Wrap page content in nothing else. `.mk-main` already sets max-width (1440), padding and centring.
- Onboarding pages (`sign-in`, `welcome`, `install`, `enroll`) get the reduced shell automatically: lockup, fictional tag, theme toggle and review bar, with no nav.
- Every count, summary and headline comes from `MK.select.*`. Never hard-code "4 of 6".
- All prototype changes go through `MK.store` actions (§12). No sessionStorage, `?ack=` or page-local entity data.
- A lookup that returns `null` renders `MK.ui.notFound()` — never substitute another entity.
- Every user- or fixture-sourced string goes through `MK.ui.esc()` when you build HTML yourself.

### 1.2 Responsive targets

375 / 768 / 1024 / 1440. The page must never scroll horizontally. Wide tables and charts scroll inside `.mk-table-wrap` and `.mk-chart__frame`.

- **≥1024:** top nav in the header.
- **768–1023:** nav collapses behind a "Menu" button.
- **≤767:** bottom nav (Fleet · Incidents · Equipment · History · More). Body gets `mk-has-bottom-nav` and bottom padding.
- **Touch targets:** 44 px on touch and mobile; buttons, chips and segmented controls grow automatically under `(pointer: coarse)`.

### 1.3 Layering of CSS

1. `base.css` begins with the `@layer od-layout { … }` primitives (verbatim from `.od-frames/layout.css`). These are `.od-stack`, `.od-row`, `.od-cluster`, `.od-grid` (`--od-cols`), `.od-stat`/`.od-field`/`.od-cell` (one item per line), `.od-fill`, `.od-nowrap`, `.od-truncate`, `.od-scroll`, `.od-rail` and `.od-touch`. Use them for structure.
2. Everything else is unlayered and always wins over the primitives.
3. Package CSS loads after `components.css`. Scope it under a page class (e.g. `.pg-fleet …`) and use only tokens (`var(--mk-…)`). Never use raw hex values.

---

## 2. Conventions (product truths, applied in UI)

- **Monitoring only.** There are no shutdown, reboot, outlet, self-test or remote-command controls anywhere. There is therefore no danger button style. Thresholds only configure notifications.
- **Status = glyph + word.** Colour reinforces and never carries meaning alone. Use `MK.ui.statusBadge(key)`.
- **Sodium is for selection and focus only, never a status.** Selected rows, cards and nodes get a sodium ring plus a subtle tint (`--mk-selection`, `--mk-selection-tint`, `.mk-selectable[aria-selected=true]`).
- **Warning** (yellow) is a different hue from sodium (orange): Δ24° dark, Δ25° light, plus a different glyph (triangle).
- **Contact lost is not a connectivity failure.** "Contact lost — cause unknown" (server-observed) uses warning colour with the dashed-circle "?" glyph. "Connectivity failure confirmed by local probe" uses connectivity blue with the bars-and-x glyph.
- **Collector contact age is not UPS reading age.** Use `MK.ui.freshness()`. Current is not last known; use `MK.ui.lastKnown()` → "Last known · 15:10 · 12 min ago".
- **Missing is never 0.** Use `MK.ui.value()` / `MK.ui.reading()`. Unsupported shows "Unsupported"; absent shows "Not reported". Charge is not battery health.
- **UPS runtime is a prediction:** label it "UPS estimate" with "est.". Observed duration is a separate value ("Observed on battery 13 min"). For unknown ends, say "At least 27 minutes on battery; endpoint unknown".
- **Times are observation times.** Show the received time when it explains a delay. Flag uncertain times with ±90 s. Replayed history is tagged "Received late · replayed" and never shown as a fresh emergency.
- **Acknowledgment, recovery and resolution are separate.** Incident attributes are six cells (`MK.ui.incidentAttrs`).
- **Recovery is declared on fresh evidence.** History sync appears separately and never holds an incident open.
- **No site battery %, no summed runtime.** Site summaries list mixed UPS and circuit states.
- **Availability** always shows period, coverage, unknown time and maintenance exclusions (`MK.select.availability`).
- **Charts** draw each segment on its own (no interpolation across gaps). Estimates are dashed with "est.", delayed points are hollow with received time, gaps are hatched "No records", pending history is hatched in periwinkle as "History arriving", plus uncertain brackets, maintenance bands and the live edge.
- **Never substitute.** Unknown ids show "<Kind> not found" (`MK.ui.notFound`).
- **Freshness (round 6, §18.4):** relative to the source's reporting interval I: On schedule ≤ I + g · Overdue ≤ 2I + g · "Stale · last known <time>" (g = max(30 s, I/2)); always with the absolute age ("read 42 s ago").
- **Site witness vs ordinary workstation:** workstations sleeping is normal — "Asleep (ordinary workstation)", no incident, not counted as expected. A silent witness while another witness reports is a collector failure ("Site still reachable: … reporting"), not site contact loss.
- **Illustrative:** ESP32 board values carry "Illustrative — validated board/UPS list pending".
- **Secrets are local-only:** the dashboard shows Configured / Missing / Rejected, never a secret field.
- **Fictional data:** the shell shows a persistent "Fictional demonstration data" tag and footer. Never show tokens, polling secrets or credentials. Short user codes such as `WDJB-MJHT` are fine.
- **Names:** "MainsKeeper", `mainskeeper` (CLI), `mainskeeperd` (daemon). Never show concept names in the UI.
- **Numbers with units do not wrap:** `.mk-num` / `.od-nowrap`. Data uses DM Mono with tabular numerals (`.mk-mono`, `.mk-data`).
- **Text sizes:** body 16 px. Dense table cells may be 14 px. Nothing below 12 px.
- **Motion:** animate only `transform` and `opacity`. `prefers-reduced-motion` and `?motion=reduced` remove motion.

---

## 3. Tokens (`tokens.css`)

Dark is the default on `:root`. Light applies with `[data-theme="light"]`; the attribute works on any element, which is how the gallery shows both themes. With no `data-theme` attribute, `prefers-color-scheme: light` applies the light set. The shell always sets `data-theme` at parse time, using this order: `?theme=` param → `localStorage["mk-theme"]` → OS preference.

### 3.1 Colour roles

| Token | Dark | Light | Meaning |
|---|---|---|---|
| `--mk-bg` | `#101418` | `#F4F6F8` | page ground |
| `--mk-surface-1` | `#171D23` | `#FFFFFF` | cards, panels, table body |
| `--mk-surface-2` | `#1F272F` | `#EDF0F3` | hover, table header, raised |
| `--mk-surface-3` | `#2A343E` | `#E1E6EB` | pressed, tracks |
| `--mk-border` | `#36424E` | `#CBD3DB` | dividers (decorative) |
| `--mk-border-strong` | `#64737F` | `#7A8691` | input/control borders (≥3:1 on bg, s1, s2) |
| `--mk-text` | `#E9EEF2` | `#101418` | primary text |
| `--mk-text-muted` | `#A3B0BC` | `#4A5865` | secondary text |
| `--mk-link` | `#7FE0C4` | `#0B7358` | links |
| `--mk-brand-mint` | `#7FE0C4` | `#0B7358` | live/observed relationships, measured lines |
| `--mk-brand-sodium` | `#FF9F43` | `#A34D00` | brand accent |
| `--mk-selection` | `#FF9F43` | `#A34D00` | selection ring + focus ring (never a status) |
| `--mk-critical` | `#FF6B6B` | `#C02F2F` | critical / on battery — octagon or battery glyph |
| `--mk-warning` | `#E8D85C` | `#6E6100` | warning / contact lost — triangle or dashed-? glyph |
| `--mk-ok` | `#7FE0C4` | `#0B7358` | OK / on utility — filled circle |
| `--mk-unknown` | `#A3B0BC` | `#4F5C69` | unknown / last known — dashed hollow circle |
| `--mk-connectivity` | `#5CB8F5` | `#1D66A8` | local connectivity evidence — signal bars |
| `--mk-sync` | `#A99CF7` | `#5747C2` | history sync / pending / replay — circular arrows |
| `--mk-maintenance` | `#B4BFCA` | `#56626E` | maintenance — wrench, hatched band |
| `--mk-recovered` | `#7FE0C4` | `#0B7358` | recovered — outlined check |
| `--mk-unsupported` | `#A3B0BC` | `#4F5C69` | unsupported — hatched dash |
| `--mk-power-line` | `#C5CFD8` | `#56626E` | "powers" relationship lines |
| `--mk-btn-primary-bg` / `-fg` | `#E9EEF2` / `#101418` | `#101418` / `#FFFFFF` | primary button |
| `--mk-review-bg` | `#0A0D10` | `#E6EAEE` | review bar (prototype chrome) |

Each status role also has a `--mk-<role>-tint` (14% dark, 10% light) for badge backgrounds: critical, warning, ok, unknown, connectivity, sync, maintenance, recovered, unsupported, selection.

Other colour tokens:
- Hatching: `--mk-hatch`, `--mk-hatch-sync`.
- Overlays and elevation: `--mk-scrim`, `--mk-shadow-1/2/3`.
- Skeletons: `--mk-skeleton-a/b`.
- Logo loops: `--mk-logo-loop-a/b`.

### 3.2 Contrast (computed arithmetically, WCAG 2.x relative luminance)

**Dark (default)** — ratio against bg / surface-1 / surface-2 / surface-3 (WCAG 2.x relative luminance)

| Role | Hex | Use | Target | bg | s1 | s2 | s3 |
|---|---|---|---|---|---|---|---|
| text | `#E9EEF2` | text | 4.5 | 15.83 | 14.54 | 12.94 | 10.83 |
| text-muted | `#A3B0BC` | text | 4.5 | 8.36 | 7.68 | 6.83 | 5.72 |
| link | `#7FE0C4` | text | 4.5 | 11.77 | 10.81 | 9.62 | 8.05 |
| brand-mint | `#7FE0C4` | text/glyph | 4.5 | 11.77 | 10.81 | 9.62 | 8.05 |
| brand-sodium | `#FF9F43` | ring/glyph (+ text) | 3 (4.5 text) | 9.07 | 8.32 | 7.41 | 6.20 |
| critical | `#FF6B6B` | text + glyph | 4.5 | 6.67 | 6.12 | 5.45 | 4.56 |
| warning | `#E8D85C` | text + glyph | 4.5 | 12.69 | 11.65 | 10.37 | 8.68 |
| ok | `#7FE0C4` | glyph | 3 | 11.77 | 10.81 | 9.62 | 8.05 |
| unknown | `#A3B0BC` | text + glyph | 4.5 | 8.36 | 7.68 | 6.83 | 5.72 |
| connectivity | `#5CB8F5` | text + glyph | 4.5 | 8.48 | 7.79 | 6.93 | 5.80 |
| sync | `#A99CF7` | text + glyph | 4.5 | 7.74 | 7.11 | 6.32 | 5.29 |
| maintenance | `#B4BFCA` | glyph | 3 | 9.91 | 9.10 | 8.09 | 6.78 |
| recovered | `#7FE0C4` | glyph | 3 | 11.77 | 10.81 | 9.62 | 8.05 |
| unsupported | `#A3B0BC` | text + glyph | 4.5 | 8.36 | 7.68 | 6.83 | 5.72 |
| power-line | `#C5CFD8` | line | 3 | 11.71 | 10.75 | 9.57 | 8.01 |
| border-strong | `#64737F` | input borders | 3 | 3.79 | 3.48 | 3.10 | 2.59 |

Status colour on its own tint (tint = role at 14% over surface-1) — badge glyph and badge text:

| Role | Tint composite | glyph on tint | text (`text`) on tint |
|---|---|---|---|
| critical | `#37282D` | 5.03 | 11.95 |
| warning | `#34372B` | 8.34 | 10.41 |
| ok | `#26383A` | 7.82 | 10.52 |
| unknown | `#2B3238` | 5.87 | 11.12 |
| connectivity | `#213340` | 5.97 | 11.15 |
| sync | `#2B2F41` | 5.54 | 11.34 |
| maintenance | `#2D343A` | 6.76 | 10.80 |
| recovered | `#26383A` | 7.82 | 10.52 |
| unsupported | `#2B3238` | 5.87 | 11.12 |
| selection | `#372F27` | 6.44 | 11.25 |

Primary button: `#101418` on `#E9EEF2` = 15.83. Focus ring (selection) vs bg: 9.07.
Hue separation warning vs sodium: 53° vs 29° (Δ 24°); warning vs critical Δ 53°; connectivity 204° vs sync 249° (Δ 45°).

**Light** — ratio against bg / surface-1 / surface-2 / surface-3 (WCAG 2.x relative luminance)

| Role | Hex | Use | Target | bg | s1 | s2 | s3 |
|---|---|---|---|---|---|---|---|
| text | `#101418` | text | 4.5 | 17.07 | 18.50 | 16.17 | 14.73 |
| text-muted | `#4A5865` | text | 4.5 | 6.74 | 7.30 | 6.39 | 5.82 |
| link | `#0B7358` | text | 4.5 | 5.37 | 5.82 | 5.09 | 4.63 |
| brand-mint | `#0B7358` | text/glyph | 4.5 | 5.37 | 5.82 | 5.09 | 4.63 |
| brand-sodium | `#A34D00` | ring/glyph (+ text) | 3 (4.5 text) | 5.36 | 5.80 | 5.07 | 4.62 |
| critical | `#C02F2F` | text + glyph | 4.5 | 5.25 | 5.69 | 4.98 | 4.53 |
| warning | `#6E6100` | text + glyph | 4.5 | 5.75 | 6.23 | 5.44 | 4.96 |
| ok | `#0B7358` | glyph | 3 | 5.37 | 5.82 | 5.09 | 4.63 |
| unknown | `#4F5C69` | text + glyph | 4.5 | 6.32 | 6.85 | 5.99 | 5.45 |
| connectivity | `#1D66A8` | text + glyph | 4.5 | 5.51 | 5.97 | 5.22 | 4.75 |
| sync | `#5747C2` | text + glyph | 4.5 | 6.28 | 6.81 | 5.95 | 5.42 |
| maintenance | `#56626E` | glyph | 3 | 5.76 | 6.24 | 5.45 | 4.96 |
| recovered | `#0B7358` | glyph | 3 | 5.37 | 5.82 | 5.09 | 4.63 |
| unsupported | `#4F5C69` | text + glyph | 4.5 | 6.32 | 6.85 | 5.99 | 5.45 |
| power-line | `#56626E` | line | 3 | 5.76 | 6.24 | 5.45 | 4.96 |
| border-strong | `#7A8691` | input borders | 3 | 3.43 | 3.72 | 3.25 | 2.96 |

Status colour on its own tint (tint = role at 10% over surface-1) — badge glyph and badge text:

| Role | Tint composite | glyph on tint | text (`text`) on tint |
|---|---|---|---|
| critical | `#F9EAEA` | 4.88 | 15.84 |
| warning | `#F1EFE6` | 5.40 | 16.06 |
| ok | `#E7F1EE` | 5.05 | 16.04 |
| unknown | `#EDEFF0` | 5.94 | 16.04 |
| connectivity | `#E8F0F6` | 5.18 | 16.05 |
| sync | `#EEEDF9` | 5.87 | 15.96 |
| maintenance | `#EEEFF1` | 5.42 | 16.08 |
| recovered | `#E7F1EE` | 5.05 | 16.04 |
| unsupported | `#EDEFF0` | 5.94 | 16.04 |
| selection | `#F6EDE6` | 5.02 | 16.01 |

Primary button: `#FFFFFF` on `#101418` = 18.50. Focus ring (selection) vs bg: 5.36.
Hue separation warning vs sodium: 53° vs 28° (Δ 25°); warning vs critical Δ 53°; connectivity 208° vs sync 248° (Δ 39°).

Notes:
- `--mk-border` is decorative (dividers) and is not a 3:1 boundary.
- `--mk-border-strong` passes 3:1 on bg, surface-1 and surface-2. Do not put inputs on surface-3.
- Dark `--mk-critical` on surface-3 is 4.56 (passes, narrowly). Prefer critical text on bg, s1 or s2.

### 3.3 Type, space, radii, motion, z-index, focus

| Group | Tokens |
|---|---|
| Fonts | `--mk-font-ui` (Sora), `--mk-font-data` (DM Mono, use with `font-variant-numeric: tabular-nums`) |
| Sizes | `--mk-fs-12` 12 · `-13` 13 · `-14` 14 · `-16` 16 (body) · `-18` · `-20` · `-24` · `-30` (h1; 24 on mobile) · `-40` |
| Line height | `--mk-lh-tight` 1.2 · `--mk-lh-snug` 1.35 · `--mk-lh-body` 1.5 |
| Weights | `--mk-fw-light` 300 · `-regular` 400 · `-medium` 500 · `-semibold` 600 |
| Tracking | `--mk-tracking-caps` .08em · `--mk-tracking-tight` -.01em |
| Space (4/8) | `--mk-space-1` 4 · `-2` 8 · `-3` 12 · `-4` 16 · `-5` 24 · `-6` 32 · `-7` 48 · `-8` 64 · `--mk-page-pad` 24 (16 mobile) · `--mk-content-max` 1440 |
| Radii | `--mk-radius-xs` 3 · `-sm` 4 · `-md` 8 · `-lg` 12 · `-xl` 16 · `-pill` |
| Elevation | `--mk-shadow-1` (cards) · `-2` (toasts) · `-3` (menus, dialogs, inspector) |
| Motion | `--mk-dur-fast` 120ms · `-base` 200ms · `-slow` 300ms · `--mk-ease`, `--mk-ease-in`, `--mk-ease-out` |
| z-index | `--mk-z-sticky` 10 · `-header` 20 · `-nav` 30 · `-inspector` 40 · `-scrim` 50 · `-dialog` 60 · `-menu` 70 · `-toast` 80 · `-skip` 90 |
| Focus | `--mk-focus-width` 2px · `--mk-focus-offset` 2px · `--mk-focus-ring` (box-shadow form). `:focus-visible` = 2px sodium outline, never removed |
| Layout | `--mk-header-h` 56 · `--mk-review-h` 40 · `--mk-bottom-nav-h` 64 · `--mk-inspector-w` 440 · `--mk-touch` 44 |

---

## 4. CSS classes

### 4.1 base.css

| Class | Use |
|---|---|
| `.mk-h1` … `.mk-h4`, `h1`–`h4` | headings |
| `.mk-eyebrow` | 12 px uppercase label |
| `.mk-lead`, `.mk-body`, `.mk-small`, `.mk-caption`, `.mk-muted`, `.mk-prose` | text |
| `.mk-link`, `.mk-link-quiet` | links (default `a` is already styled) |
| `.mk-mono`, `.mk-data`, `.mk-data-lg`, `.mk-data-xl`, `.mk-num`, `.mk-unit` | data type (tabular numerals) |
| `kbd`, `.mk-kbd` | keys |
| `.mk-visually-hidden` (alias `.vh`) | screen-reader-only |
| `.mk-skip-link` | added by the shell |
| `.mk-container`, `.mk-section`, `.mk-surface`, `.mk-card`, `.mk-card--raised`, `.mk-divider` | layout |
| `.mk-stack-1` … `.mk-stack-6` | vertical grid gaps 4…32 |
| `.mk-grid-auto` (`--mk-min`) | auto-fill cards |
| `.mk-scroll-x` | horizontal scroll container |
| `.mk-hide-mobile`, `.mk-only-mobile` | ≤767 visibility |
| `.mk-c-critical`, `.mk-c-warning`, `.mk-c-ok`, `.mk-c-unknown`, `.mk-c-connectivity`, `.mk-c-sync`, `.mk-c-maintenance`, `.mk-c-recovered`, `.mk-c-unsupported`, `.mk-c-selection`, `.mk-c-muted`, `.mk-c-mint`, `.mk-c-power` | colour utilities (always paired with glyph + word) |
| `.mk-icon`, `.mk-icon--20`, `.mk-icon--24`, `.mk-link-glyph` | icon sizing |

### 4.2 components.css

| Component | Classes |
|---|---|
| Shell (shell.js) | `.mk-review` (`__label`, `__group`, `__note`) · `.mk-header` (`__inner`, `__spacer`, `__tools`) · `.mk-brand` · `.mk-nav`, `.mk-nav__link`, `.mk-nav__count` · `.mk-fict-tag` · `.mk-account`, `.mk-avatar`, `.mk-menu` (`__head`, `__item`, `__sep`) · `.mk-navpanel` · `.mk-main` (`--full`) · `.mk-bottom-nav` · `.mk-footer` · `.mk-shell-pre` |
| Page head | `.mk-page-head`, `__titles`, `__actions` · `.mk-now` · `.mk-breadcrumb` · `.mk-subnav` |
| Buttons | `.mk-btn` + `--primary` / `--secondary` / `--quiet` · `--sm` / `--lg` · `--icon` · `--block` · `[aria-pressed=true]` · `.mk-btn-group` |
| Chips | `.mk-chips` · `.mk-chip` · `.mk-chip--filter[aria-pressed]` · `.mk-chip__box` · `.mk-chip__count` · `.mk-chip__remove` |
| Status badge | `.mk-badge` + `--<status>` (see §5.3 keys) · `__glyph` · `__label` · `__sub` · `--plain` · `--lg` |
| Tags | `.mk-tag` + `--est` (`.mk-est`) / `--replay` / `--uncertain` / `--configured` / `--live` |
| Freshness | `.mk-fresh` (`--inline`) · `__item` (`--stale`) · `__label` · `__value` |
| Last known | `.mk-lastknown` · `__time` · `__age` · `.mk-is-lastknown` (dim a whole block) |
| Values | `.mk-value` (`--estimate`, `--lastknown`) · `__num` · `__unit` · `.mk-nr` (Unsupported / Not reported, hatched) |
| Key/value | `.mk-kv` (dl > div > dt + dd) · `.mk-stat`, `__label`, `__value`, `__note` |
| Incident attrs | `.mk-attrs` (`--compact`) · `.mk-attr` · `__k` · `__v` · `__sub` · `__t` |
| Sync meter | `.mk-sync` + `--healthy` / `--offline-recording` / `--draining` / `--stalled` / `--pressure` / `--dropped` / `--unknown` · `--compact` · `__head` · `__title` · `__bar` · `__fill` (`--pending`, `--dropped`) · `__scale` · `__facts` · `__fact` · `__gap` · `__note` |
| Table | `.mk-table-toolbar` · `.mk-table-wrap` (`--mk-table-max-h`) · `.mk-table` (`--mk-table-min`, `--dense`) · `.mk-num` cells · `.mk-col-sticky` · `.mk-table__check` · `.mk-sort` · `tr[aria-selected]` · `.mk-row--open` · `.mk-table__primary` · `.mk-table__secondary` · `.mk-colchooser`, `__panel` |
| Inspector | `.mk-inspector[data-open]` · `__head` · `__title` · `__body` · `__section` · `body.mk-inspector-open` |
| Overlays | `.mk-scrim` · `.mk-dialog` (`--wide`) · `__head` · `__title` · `__body` · `__foot` · `.mk-sheet` · `__grip` |
| Tabs | `.mk-tabs` (role=tablist) · `.mk-tab` (`[aria-selected]` or link `[aria-current]`) · `.mk-tabpanel` |
| Segmented | `.mk-seg` > `button[aria-pressed]` |
| Toasts | `.mk-toasts` · `.mk-toast` + `--ok` / `--warning` / `--critical` / `--sync` · `__msg` |
| States | `.mk-empty` · `__icon` · `__title` · `__body` · `.mk-error-state` · `.mk-skel` · `__line` (`--title`, `--short`) · `__block` |
| Forms | `.mk-form` · `.mk-field` (`--invalid`) · `.mk-label` · `__opt` · `.mk-help` · `.mk-input` (`--mono`) · `.mk-select` · `.mk-textarea` · `.mk-input-group`, `__addon` · `.mk-field__error` · `.mk-field__ok` · `.mk-check` · `.mk-switch` · `.mk-fieldset` |
| Code | `.mk-code` (`--terminal`) · `__head` · `__title` · `.mk-prompt` · `.mk-out` (+ `-ok`, `-warn`, `-crit`, `-sync`, `-conn` inside terminal) · `.mk-devcode` |
| Timeline | `.mk-timeline` · `.mk-tl-item` (`--gap`, `--replayed`, `--uncertain`, `--selected`) · `.mk-tl-time` · `.mk-tl-glyph` · `.mk-tl-body` · `.mk-tl-title` · `.mk-tl-detail` · `.mk-tl-meta` |
| Charts | `.mk-chart` · `__title` · `__frame` (`--mk-chart-min`) · `.mk-chart-legend` · SVG: `.mk-ch-grid` · `.mk-ch-axis` · `.mk-ch-line` (`--est`, `--delayed`, `--battery`) · `.mk-ch-delayed` · `.mk-ch-measured-pt` · `.mk-ch-gap` · `.mk-ch-pending` · `.mk-ch-maint` · `.mk-ch-battery-band` · `.mk-ch-now`, `-label` · `.mk-ch-unc`, `-band` · `.mk-ch-lane-bg` · `.mk-ch-lane--<state>` · `.mk-ch-lane--delayed` · `.mk-ch-seg-text` · patterns `#mk-hatch-gap`, `#mk-hatch-pending` |
| Relationships | `.mk-rel` + `--reads` / `--powers` / `--checks` / `--lost` · `__line` · `__verb` · `__iface` · `__configured` · `.mk-rel-row`, `__end`, `__mid` · SVG map paths: `.mk-path-reads`, `.mk-path-powers`, `.mk-path-checks`, `.mk-path--lost` · `.mk-node`, `.mk-node--selected` |
| Coverage | `.mk-coverage` + `--direct` / `--relationship` / `--unsupported` / `--missing` · `__swatch` · `.mk-coverage-legend` |
| Round 5 | `.mk-review__dataset`, `.mk-review__reset`, `.mk-review__gallery` · `.mk-notfound` · `.mk-legend-list` (`--compact`) · `.mk-ts` (timestamp row, on `.mk-attrs`) · `.mk-policy` (`__glyph`, `__title`) · `.mk-cap` · `.mk-tag--illustrative` · badge modifiers for the new status keys (§5.3) |
| Misc | `.mk-banner` + `--critical` / `--warning` / `--ok` / `--sync` / `--connectivity` / `--maintenance` · `__body` · `__title` · `.mk-progress` · `.mk-selectable` (`[aria-selected]`, `.is-selected`) · `.mk-dot` · `.mk-shortcuts` |

---

## 5. JavaScript APIs

All helpers are globals on `window.MK`. Render helpers return **HTML strings**; behaviour helpers take **elements**. After inserting HTML that has copy buttons, `[data-mk-tabs]`, `[data-mk-seg]` or internal links, call `MK.ui.enhance(root)`.

### 5.1 Icons — `MK.ui.icon(name, {size:16|20|24, cls, label})`

components.js injects the sprite inline (`#mk-sprite`) so `file://` works. **Never reference `icons.svg` by URL.** You can also write `<svg class="mk-icon"><use href="#mk-i-fleet"/></svg>`.

- UI icons (`mk-i-*`, 1.5 stroke): `fleet equipment ups device incidents history collector settings more menu search sun moon user sign-out chevron-right chevron-left chevron-down chevron-up arrow-right close check plus edit copy external filter columns sort sort-asc sort-desc compare info help bell mail phone wrench calendar link usb network target site key refresh terminal keyboard battery plug pause eye github`
- Status glyphs (`icon('g-…')`): `critical warning ok unknown last-known contact-lost connectivity connectivity-fail sync maintenance recovered unsupported on-battery on-utility estimate delayed gap pending uncertain ack not-ack notify-failed delivered scheduled info no-agent revoked sleeping live`
- Link glyphs (`icon('l-…')`, 24×16): `reads powers checks`

### 5.2 Shell — `MK.shell`, `MK.url`

| API | Notes |
|---|---|
| `MK.shell.mount({page, title, nav=true, reduced=false, breadcrumb:[{label,href}], subnav=true, footer=true, review=true, section})` | Call first. `page` is a key of `MK.shell.PAGES` (below). Sets `<title>`, the favicon if missing, fills every `[data-now]` element with `nowLabel()`, `main#main.mk-main[tabindex=-1]`, skip link, review bar, header, nav, sub-nav (collectors/settings), footer and bottom nav. Returns `{page, section, main, header, review}` and fires `mk:mounted`. `reduced:true` = onboarding shell. |
| `MK.shell.breadcrumb(items)` | → `<nav class="mk-breadcrumb">` HTML (sticky params kept) |
| `MK.shell.subnav(section, pageId)` | → sub-nav HTML (`'collectors'` / `'settings'`); mount inserts it automatically |
| `MK.shell.lockup({height})`, `MK.shell.logomark({size})` | inline theme-aware SVG |
| `MK.shell.setTheme('dark'|'light')`, `.toggleTheme()`, `.theme()` | stores `localStorage["mk-theme"]`; updates `?theme` if present; fires `mk:theme` |
| `MK.shell.nowLabel()` | `'Wed 30 Sep 2026 · 15:22 PDT'` |
| `MK.shell.showShortcuts()` | shortcuts dialog |
| `MK.shell.PAGES`, `.NAV`, `.SUBNAV`, `.SHORTCUTS`, `.mounted()` | registries |
| `MK.url.get(name, fallback)` | string or fallback |
| `MK.url.all()` | object of params |
| `MK.url.set({k:v|null}, {replace=true, reload=false})` | null removes a key; `reload:true` navigates (use it for state/scenario) |
| `MK.url.link(href, extra)` | keeps sticky params `state`, `scenario`, `theme`, `motion` (only valid values) and merges `extra` (null removes). Entity/flow params are never carried (§8). |
| `MK.url.go(changes)` | review-bar navigation: applies `{k:v|null}`, drops entity params unknown in the target dataset, reloads. Never touches `auth`/`enroll`/page params. |
| `MK.url.without(keys)` | current search minus keys |
| `MK.url.parseFocus(v?)` | `?focus=col:bw-pi-01` → `{kind:'col', id:'bw-pi-01'}` (kinds site, col, ups, eq, tgt) or null |
| `MK.url.PARAMS`, `.STICKY`, `.RESERVED`, `.FLOW`, `.ENTITY` | the §8 contract as data |
| `MK.url.decorate(root)` | adds sticky params to every internal `a[href$=.html]` (opt out with `data-mk-nokeep`). The shell also does this on click. |

**Pages** (`page` keys → file, section): `fleet` index.html · `site` · `site-setup` (settings › Sites) · `equipment` · `ups` · `incidents` · `incident` · `notifications` (settings) · `maintenance` (settings) · `alert-rules` (settings landing) · `history` · `collectors` · `collector` · `relationships` (collectors sub-view) · `sources` · `collector-config` · `sync` · **`network-source`** (network-source.html, collectors) · `sign-in` · `welcome` · `install` · `enroll` · `components`.

**Review bar (every page, incl. onboarding):** "Review" label · **Dataset: Demo fleet | Your account (first run)** · Demo state (Incident | Calm) · Scenario select · **Reset demo** (confirmation dialog → `MK.store.reset()` → reload without dead entity params) · note · Component gallery. It wraps on ≤767 px; controls are 44 px under `(pointer: coarse)`. A remote store change (another tab) raises a "Reload" toast.

**Primary nav:** Fleet (index.html) · Equipment (equipment.html) · Incidents (incidents.html, shows the open-incident count) · History (history.html) · Collectors (collectors.html; sub-nav Collectors · Relationships · Sources · History sync) · Settings (alert-rules.html; sub-nav Alert rules · Notifications · Maintenance · Sites).

**Account menu:** jp → Notification channels, Settings, Keyboard shortcuts, Sign out (`sign-in.html?auth=signed-out`).

**Mobile bottom nav:** Fleet · Incidents · Equipment · History · More (a sheet with Collectors, Relationships, History sync, Alert rules, Notifications, Maintenance, Sites, theme, sign out).

**Keyboard shortcuts:**
- `g` then `f`, `e`, `i`, `h`, `c` or `s` goes to Fleet, Equipment, Incidents, History, Collectors or Settings.
- `/` focuses the element marked `[data-mk-search]`. Mark your search input with this attribute.
- `?` opens the shortcuts dialog.
- `Esc` closes the inspector, menu, dialog or sheet.
- Shortcuts are ignored while typing in a field.

### 5.3 Components — `MK.ui`

| API | Returns / notes |
|---|---|
| `esc(s)`, `cx(...classes)` | escaping, class join |
| `statusBadge(key, {label, sub, size:'lg', plain, title, cls})` | badge HTML. **Keys:** `critical warning info ok on-utility on-battery unknown last-known contact-lost comms-lost collector-revoked connectivity connectivity-fail sync pending replayed maintenance recovered unsupported not-reported reporting revoked acknowledged not-acknowledged delivered notify-failed failed scheduled sleeping no-agent estimate delayed uncertain gap live selected`. Default words are in `MK.ui.STATUS[key].word`. |
| `glyph(key, {cls, label})` | coloured status glyph only |
| `tag(text, kind)`, `estTag()`, `replayTag()`, `uncertainTag(sec)`, `configuredTag()` | small descriptors |
| `button(label, {kind, size, icon, iconOnly, href, id, attrs, cls})` | button/link HTML |
| `value(v, unit, {state:'current'|'last-known'|'unsupported'|'not-reported', kind:'measured'|'estimate', digits})` | value cell; `null` → "Not reported"; unsupported → "Unsupported"; estimate → dashed underline + "est." |
| `reading(ups, metric, {data})` | value cell from `MK.select.reading`. Metrics: `powerState charge runtime load input temperature`. |
| `lastKnown(t, {value, compact, now})` | "Last known · 15:10 · 12 min ago" |
| `freshness(upsOrId \| {contactAt, readingAt, current}, {inline, data, now})` | collector contact vs UPS reading |
| `incidentAttrs(incident\|id, {compact, data})` | `<dl class="mk-attrs">` with Severity · State · Acknowledgment · Recovery · Notifications · History sync |
| `syncMeter(buffer, {title, compact, now})` | backlog meter for `collector.buffer` or `MK.fixture.meta.bufferExamples.*` |
| `chip(label, {filter, count, pressed, value, glyph, icon, cls})` | chip / filter-chip HTML |
| `filterChips(el, [{value,label,count,pressed,glyph}], {onChange(values), multi=true, label})` | → `{get(), set(values)}` |
| `coverageBadge(state \| coverageResult, {detail, short})` | 4-state coverage badge |
| `relLink(type, {iface, lost, configured=true, verb})` | link-style sample: reads / powers / checks, labelled "configured" |
| `relRow(relationship, {lost})` | from · link · to row |
| `emptyState({icon, title, body, action:{label, href \| id, kind, icon}})`, `errorState({...})`, `skeleton({lines, block, label})` | state blocks |
| `field({id, label, type:'text'|'email'|'number'|'select'|'textarea', value, help, error, ok, required, optional, options:[{value,label}], placeholder, addon, mono, attrs, name})` | labelled field with help and error wiring |
| `setFieldError(inputOrId, message\|null)` → boolean, `validate(form, {id: (value, el) => message\|null})` → boolean | validation; the first invalid field gets focus |
| `codeBlock(text, {title, terminal, copy=true, html})` | lines starting `$ ` get a prompt; Copy button with toast |
| `timeline(events, {linkIncidents, selectedId, showDate})` | event list: observation time, received time when late, "Received late · replayed", ±s, source |
| `chart.series(el\|null, {from, to, now, lines:[metric], domain:[min,max], yTicks, unit, height, bands:[{from,to,kind:'battery'|'maintenance',label}], title, subtitle, ariaLabel, legend, legendKinds})` | SVG line chart; `metric` = `MK.select.upsSeries(id).metrics.<k>`. Returns HTML and renders into `el` if given. Includes screen-reader text. |
| `chart.lanes(el\|null, {from, to, now, lanes:[{label, sub, intervals:[{from,to,state,label,delayed,uncertain,receivedAt}]}], bands, title, subtitle, ariaLabel, legendKinds})` | state lanes. States: `utility battery none unknown pending nocontact commslost ok fail partial contact lost revoked maintenance` |
| `chart.legend(kinds?)` | `measured estimate delayed gap pending uncertain maintenance now battery utility fail lost` |
| `chart.window(hours, now?)` | `{from, to}` ending at data now |
| `table(container, {columns:[{key,label,render(row),sortValue(row),num,hidden,required,sticky,sortable,width,cls}], rows, rowId, sort:{key,dir}, selectable, selected, onSelect(ids), onOpen(row, tr), onSort(sort), caption, columnChooser, storageKey, toolbar, empty, dense, rowAttrs(row), minWidth, maxHeight})` | → `{update(rows), sort(key,dir), getSort(), getSelection(), setSelection(ids), setOpen(id), focusRow(id), visibleRows(), render()}`. Keys: ↑/↓/Home/End move, Enter opens, Space selects. Missing values sort last. The wrap scrolls internally (both axes) with a default max-height of `100vh − header − 96px`, so the header stays sticky; pass `maxHeight` to change it (e.g. `'none'` for short tables). |
| `inspector.open(contentFn(bodyEl) → html\|void, {title, returnFocus, onClose, key, label, focus=true})` | Side panel (bottom sheet ≤767). Never touches page DOM, so filters, sort, scroll and selection persist. Re-calling swaps the content. |
| `inspector.close()`, `.isOpen()`, `.key()`, `.body()` | Esc or close returns focus to `returnFocus` (or whatever had focus at open). Fires `mk:inspector`. |
| `dialog.open({title, body: html\|fn(bodyEl), actions:[{label, kind, onClick(api) → false keeps open, close, autofocus}], wide, returnFocus, onClose, dismissible=true, role})` | → `{el, body, close}`. Modal with focus trap, Esc and scrim click. |
| `sheet.open(same)` | bottom sheet (centred on ≥768) |
| `tabs(tablistEl, {onChange})` | ARIA tabs (arrow keys, Home, End). Auto-wired by `[data-mk-tabs]`. |
| `seg(items, current, {label})` → HTML, `segmented(el, {onChange(value)})` → `{get, set}` | segmented control. Auto-wired by `[data-mk-seg]`. |
| `toast(message, {tone:'ok'|'warning'|'critical'|'sync', timeout=5000 (0 = sticky), action:{label,onClick}})` | polite live region |
| `announce(text)` | screen-reader-only polite message |
| `enhance(root)` | wires `[data-mk-copy]`, `[data-mk-tabs]`, `[data-mk-seg]` and decorates links |
| **Round 5** | |
| `notFound({kind, id, backHref, backLabel})` | "<Kind> not found" block (role=alert) naming the dataset; back link keeps sticky params |
| `stalenessBadge(ageSeconds \| freshness, {at, interval, ups, sub})`, `stalenessLegend({compact, interval})` | **round 6:** On schedule / Overdue / Stale · last known + "read 42 s ago · every 30 s"; cadence legend (§18.4). New: `freshnessBadge(ups)`, `heartbeatBadge(collector)`, `sourceStateBadge(source)`, `testResultBadge(test)` |
| `configState(state \| MK.config.effective(...), {sub, label})` | Desired · Pending · Applied · Failed (+ reason) · Local override · Set locally · Configured · Missing · Rejected · Capability |
| `secretBadge('configured'|'missing'|'rejected', {testedAt})` | local secret status (+ "last test 15:20:10") |
| `capabilityBadge(cap)`, `illustrativeTag(text?)` | capability chip, with "Illustrative — validated board/UPS list pending" when `cap.illustrative` |
| `contactBadge(collector, {data, sub})` | Reporting · Asleep (ordinary workstation) · Not reporting (site witness) · Contact lost — cause unknown · Credentials revoked |
| `reachabilityBadge(site, {data, at, detail})` | Site reachable · Site still reachable: … reporting · Contact lost — cause unknown · Connectivity failure confirmed by local probe |
| `incidentTimestamps(incident, {data, compact})` | six-cell row: First evidence · Grace period started · Declared · Acknowledged · Recovered · Resolved |
| `backfillNote({replay})` | the exact backfill policy + replay definition |
| New `statusBadge` keys | `asleep not-reporting fresh reading-delayed stale waiting cfg-desired cfg-pending cfg-applied cfg-failed cfg-override cfg-local secret-configured secret-missing secret-rejected capability illustrative` |
| `incidentAttrs` | the State cell now shows "Declared 15:10:00" and "First evidence 15:09:00" |
| `focusables(el)`, `nextId(prefix)` | utilities |

---

## 6. Fixture — `MK.fixture`, `MK.data(state, scenario)`

All data is fictional. All timestamps are **epoch ms**. "Now" is `MK.fixture.meta.now` = Wed 30 Sep 2026 15:22:00 PDT, except: 15:32:00 in `revoked`; 15:32:00 / 15:37:00 after the INC-0412 walkthrough reaches *restored* / *resolved* (§12.6); the account dataset's virtual clock (§12.5). Always compute ages against `meta.now`, never `Date.now()`. Format with `MK.fmt` (fixed to America/Los_Angeles).

**Round 5 change — `MK.fixture` is the ACTIVE dataset**: a getter that returns the pristine fixture build **merged with `MK.store`** for the dataset the URL + store select (§12.3). It is recomputed after every store change, so **re-read `MK.fixture` (or call selectors without a dataset argument) after an action; don't cache it across actions**. Treat it as read-only — change data only through `MK.store` actions.

| API | Returns |
|---|---|
| `MK.fixture` | active composed dataset (getter) |
| `MK.data(state, scenario)` | composed dataset for that demo state/scenario (`scenario='empty'` → the account dataset) — includes store changes |
| `MK.data.pristine(state, scenario)` | the raw fixture build, no store (tests/diagnostics only) |
| `MK.data.context(search?)` | `{dataset:'demo'|'account', state, scenario, reason:'param'|'store'|'default', stored}` — the dataset rule (§12.3) |
| `MK.data.current()` | same as `MK.fixture` |
| `MK.data.restorationSteps` | `[{id:'alert'|'investigate'|'ack'|'restored'|'resolved', label, at, clock}]` |
| `MK.policy` | staleness thresholds, replay definition, **backfill policy string** (`MK.policy.backfill`), timestamp labels, resolution rule, alert-rule summary, illustrative label (§15) |
| `MK.datasets` | `{demo:{label:'Demo fleet'}, account:{label:'Your account (first run)'}}` |

| Key | Shape |
|---|---|
| `meta` | `{now, tz, tzLabel:'PDT', today, state:'incident'|'calm'|'empty'|'account', requestedState, scenario, scenarioLabel, scenarioNote?, dataset:'demo'|'account', datasetLabel, datasetReason, restoration?:step, label:'Fictional demonstration data', account, members, bufferExamples}`. In the account dataset `state` is `'empty'` until a site exists, then `'account'`. Test emptiness with `fleetCounts().sites === 0`, not `meta.state`. |
| `sites[]` | `{id, name, tz, witnessIds[], circuits:[{id,name}], created?, createdAt?}`. **Home `witnessIds` = `['home-studio','home-esp32-garage']`** (both designated site witnesses). |
| `collectors[]` | `{id, name, siteId, kind:'pi'|'mac'|'linux'|'win'|'esp32', hardware, os, agent, version, roles[], `**`availability:'witness'|'workstation'`**`, witness (= availability==='witness'), `**`expected`**` (false for workstations), alwaysOn, qualifiedProbe?, heartbeat (s), interval (reporting, s), lastContactAt, `**`contact:'live'|'lost'|'asleep'|'revoked'`**`, asleepSince?, restartedAt, enrolledAt, credential, upsIds[], replaced?, rejected?, buffer, `**`capabilities:[{key,label,value,unit?,illustrative,note}]`**`, `**`local:{storage_path, disk_cap_mb, retention_target, log_level, log_path, illustrative?}`**`, illustrative? + illustrativeLabel (ESP32), created?, approvedAt?, firstContactAt?, reenrolledAt?}` |
| `collector.buffer` | as round 4 + `illustrative`/`illustrativeLabel` on ESP32 (`~384 KB flash ring`, `~6 h at 30 s` are illustrative) |
| `ups[]` | round-4 shape + `serial`, `illustrative` (read by an ESP32 → "Illustrative — validated board/UPS list pending"), `created?`, `monitoredFrom?`, `firstReadingAt?`, `profile?`, and `comm:'reporting'|'last-known'|'comms-lost'|`**`'waiting'`** (created, no reading yet: `lastReadingAt` null) |
| `equipment[]`, `relationships[]`, `targets[]` | as round 4. `home-laptop` has 3 targets (state `unknown`, last result 13:05:30) |
| `sources[]` | round-4 shape + `endpoint:{protocol:'nut'|'snmp', host, port, upsName?, version?}|null`, `secret:{kind:'nut'|'snmp-v3'|'snmp-community', label, state:'configured'|'missing'|'rejected', testedAt, setAt}|null`, `test:{state, message, at}|null`, `identity|null`, `status` adds `'discovered'|'draft'|'tested'`. **BW Office SNMP card endpoint = `10.0.20.15:161`** (v3, secret configured, last test 15:20:10). |
| `incidents[]` | **canonical timestamps**: `firstEvidenceAt, graceStartAt, declaredAt, recoveredAt, resolvedAt` + `ack:{state,by,at,note}` and `acknowledged:{by,at,note}|null` (same record) + `evidence:{first, firstReceivedAt, grace, declared}` + `lastContactAt?` (contact incidents) + `reachability?`. `startedAt` (= firstEvidenceAt) and `openedAt` (= declaredAt) remain as **deprecated aliases**. Kinds: `on-battery`, `contact-lost`, `ups-comms-lost`, **`collector-not-reporting`** (witness-down). |
| `notifications[]`, `events[]`, `episodes[]`, `maintenance[]`, `records[]`, `channels[]` | as round 4 (times updated, §6.1). Events gain kinds `declared`, `missed-heartbeat`, `resolved`, `collector-not-reporting`, `acknowledged`, `site-created`, `collector-approved`, `source-selected`, `first-readings`, `reenrolled`; `e-dx-probe-fail` has `endAt` 14:30. Episodes may carry `ruleEvaluationId`. |
| `alertRules[]` | `rule-on-battery` **delay "After 60 s on battery" (delaySec 60)**, `rule-low-runtime` < 10 min (immediate), `rule-contact-lost` "After the contact grace — max(5 min, 3 × heartbeat)", `rule-ups-comms` "After 2 min", `rule-backlog-stalled` "After 30 min stalled". |
| `ruleEvaluations[]` | `{id, ruleId, at, siteId, subject, from, to, observedSec, delaySec, outcome:'event-only'|'incident', incidentId?, label, detail}`. **`eval-home-0642`: label "Below 60 s delay — event only"** (Office Desk UPS 06:42:10–06:42:48, 38 s). `eval-bw-1510`: "Still on battery after 60 s — incident declared". |
| `config` | `{records:{<collectorId>:{<key>:{value, previous, unit?, state:'pending'|'applied'|'failed', by, requestedAt, deliveredAt, appliedAt, failedAt, reason, waiting}}}, overrides:{<collectorId>:{<key>:{value, unit, setAt, by}}}}` — seeds: shop-esp32-net `connectivity_targets` **failed** ("Collector rejected: max 3 targets on this board (capability)", 14:20:00 → 14:20:31); home-laptop `reporting_interval` 60 → 120 s **pending** (asleep); bw-pi-01 `heartbeat_interval` **local override 15 s** (desired 30 s). Read through `MK.config.effective`. |
| `enrollment` | round-4 shape (`pending.command: 'mainskeeper enroll'`; `reenroll` state uses `mainskeeper enroll --reenroll`) + **`devices:{<code>:{code, collectorId, platform, kind, hardware, os, agent, version, defaultRoles, availability, siteHint, discovered:[source], existing?, reenroll?}}`** — WDJB-MJHT studio-mbp (macOS workstation) · BKTN-XRWP garage-pi-02 (Pi, finds a USB CyberPower CP1500PFCLCD) · MZQD-TCVF duplex-esp32-02 (ESP32, finds a USB APC Back-UPS 600, illustrative) · PLHV-GWCS den-win-01 (Windows workstation) · TJNR-FBQZ rack-fedora-01 (Fedora, finds a USB APC SMT1000) · KQPT-RNXV bw-pi-01 (re-enroll). HQTV-LMSW is the expired code. |
| `networkEndpoints[]` | simulated LAN for the network-UPS journey: `10.0.20.15` SNMP v3 (= BW Office UPS → duplicate) · `192.168.1.20:3493` NUT `rackups` CyberPower OR1500LCDRM1U (success) · `192.168.1.25` SNMP v3 APC SRT 2200/AP9641 (success) · `192.168.1.26` SNMP v2c generic UPS-MIB (unsupported — partial) · `192.168.1.22:3493` NUT `office` Tripp Lite (credentials rejected). Any other host → unreachable. |
| `series.ups(id)`, `series.site(id)` | 24 h at 1 min, as round 4. New gap kind `unmonitored` ("Not monitored yet") before a created UPS's `monitoredFrom`. INC-0412 restoration changes the BW Rack series (battery 15:09–15:31, then recharging). |

Other globals as round 4: `MK.scenarios`, `MK.states`, `MK.coverageStates`, `MK.readings`, `MK.time`.

### 6.1 Scenario values

**Incident** (default):
- **Counts:** "4 of 6 expected collectors reporting · 1 workstation asleep" · 5 of 6 UPSs communicating · 1 UPS on battery · 2 open incidents (1 critical, 1 warning) · 1 recovery today.
- **BW Rack UPS / INC-0412 (critical):** first evidence 15:09:00 (received 15:09:01) · grace started 15:09:00 · **declared 15:10:00** (still on battery after the 60 s delay) · email 15:10:04, push 15:10:05. Live: 13 min observed · 71% · UPS estimate 18 min (initial 34) · load 38% · input 0 V · 31 °C.
- **Shop / INC-0413 (warning):** last contact 15:10:02 (shop-esp32-ups) / 15:09:58 (shop-esp32-net) · grace started 15:10:02 · **first evidence 15:10:32** (first missed heartbeat) · declared 15:15:02 · email 15:15:03 · push failed 15:15:04, retry 1 failed 15:17:04, retry 2 scheduled 15:23:04. Reachability: **site contact lost** (all witnesses silent).
- **Duplex / INC-0411 (resolved):** **first evidence 13:41:00** (local probe; received 15:02:06) · grace started 13:44:00 (server: first missed heartbeat) · **declared 13:49:00** (email 13:49:03) · acknowledged jp 13:52 "ISP outage reported by neighbor" · recovered 15:02:06 · **resolved 15:07:06** (stable 5 min). History gap 14:30–14:52:10 · 1,248 pending (38.2 MB of 512 MB) · oldest 13:41 · last upload 15:21:48 · batch 16 of 64 · retry 15:19 (503, accepted) · drain ~22 min est. · 0 dropped. 14:03 battery start received 15:17:40 (replayed). First post-gap observation 14:52:10 (±90 s until 14:54).
- **Home:** 06:42:10–06:42:48 on battery, 38 s → **"Below 60 s delay — event only"** (no incident, no notification; `eval-home-0642`). **home-laptop** (macOS, Connectivity, ordinary workstation) **asleep since 13:05**, last contact 13:05:40 → "Asleep (ordinary workstation) — no incident", not counted as expected.
- **Yesterday:** INC-0409, Duplex 21:14–21:31 (17 min, measured) · declared 21:15:00 · email 21:15:04 / push 21:15:05 · recovered 21:31:00 · resolved 21:36:00.

**Calm:** all 6 expected collectors reporting (Shop 5 s / 7 s) · home-laptop still asleep ("6 of 6 expected collectors reporting · 1 workstation asleep") · all UPSs on utility 100% (BW Rack est. 41, Duplex est. 36) · no backlog · incidents: INC-0409 only.

**Scenarios** (`?scenario=`):
- `ups-comms-lost` (overlays the state): home-esp32-garage contact 9 s; Garage UPS last reading 15:16:40, USB HID read timeout; INC-0414 (warning) first evidence 15:17:10 · grace 15:16:40 · declared 15:18:40.
- `revoked` (calm baseline, clock 15:32): bw-pi-01 credentials revoked by jp 15:30; uploads rejected (HTTP 401 ×3); both Broadwater UPSs Last known 15:29:58, coverage missing; no contact-lost incident. Approving KQPT-RNXV (`MK.store.approveEnrollment`) restores it (sources and history kept).
- **`witness-down`** (overlays the state): **home-studio** (site witness, Mac mini) last contact **15:04:12** → INC-0415 "Collector not reporting — home-studio" (warning; first evidence 15:04:42 · grace 15:04:12 · **declared 15:09:12** · email 15:09:13 · push 15:09:14). **home-esp32-garage** (site witness, UPS-only) reporting (11 s) → Home reachability **"Site still reachable: home-esp32-garage reporting"** (collector failure — distinct from Shop's site contact loss and Duplex's 13:41 locally observed connectivity failure). Office Desk UPS Last known 15:04:12. Counts "3 of 6 expected collectors reporting · 1 workstation asleep".
- `empty` = **the account dataset** (§12.3): new account (jp only), no sites/collectors/UPSs/incidents, default alert rules, email channel only — plus anything created in the first run.

---

## 7. Selectors — `MK.select.*`

Every selector takes an optional **last argument** `arg`: omitted = the active dataset (`MK.fixture`); `'incident'`/`'calm'` = that state with the current scenario (composed with the store); or a dataset object from `MK.data()`. Entity arguments accept an id or the object. **Lookups return `null` for unknown ids — never a fallback**; derived selectors that take one entity (`collectorStatus`, `contactState`, `upsStatus`, `freshness`, `reading`, `siteSummary`, `availability`, `siteReachability`, `incidentTimestamps`, `aggregate`, `restoration`, `configRecords`…) also return `null`. Pages must render `MK.ui.notFound()` (§5.3) for a null lookup.

Round-4 selectors (unchanged signatures; behaviour notes):

| Selector | Returns / round-5 notes |
|---|---|
| `fleetCounts(arg)` | adds `collectorsTotal, workstations, workstationsAsleep, workstationsReporting`; **`collectorsExpected`/`collectorsReporting`/`collectorsContactLost` count expected collectors only (workstations excluded)**. `text.collectors` = **"4 of 6 expected collectors reporting · 1 workstation asleep"**; `text.collectorsShort` = "4 of 6 collectors reporting"; `text.workstations` = "1 workstation asleep" \| null. |
| `siteSummary(site)` | adds `reachability` (= `siteReachability`), `counts.workstationsAsleep`; lines "1 of 2 expected collectors reporting", "1 workstation asleep", and in witness-down "Site still reachable: home-esp32-garage reporting". New headline for empty sites: "No collectors yet". |
| `collectorStatus(c)` | `{key:'reporting'|'not-reporting'|'contact-lost'|'asleep'|'revoked'|'waiting', tone (statusBadge key), label, sub, ageMs, at, role, expected}` (derived from `contactState`) |
| `upsStatus(u)` | adds key `'waiting'` ("Waiting for first reading", `current:false`) for created UPSs without readings |
| `freshness(u)` | adds `staleness` (see below); `collectorId`/`contactAt` may be null |
| `reading(u, metric)` | `state:'not-reported'`, `at:null` when no reading yet |
| `coverage(item)` | asleep workstation → `relationship` ("not expected to report"); waiting UPS → `missing` |
| `availability(site, period)` | contact loss now counts only `lost`/`revoked` minutes |
| `site / ups / collector / equipment / incident (id)`, `byId`, `typeOf` | `null` for unknown ids |
| all other round-4 selectors | unchanged (`siteSummaries`, `coverageSummary`, `openIncidents`, `recentRecoveries`, `resolvedIncidents`, `incidentsForSite`, `incidentsFor`, `upsForSite`, `collectorsForSite`, `equipmentForSite`, `equipmentForUps`, `targetsFor`, `sourcesFor`, `sourceForUps`, `episodesFor`, `recordsFor`, `maintenanceFor`, `activeMaintenance`, `relationshipsFor`, `notificationsFor`, `channel`, `rule`, `eventsFor`, `upsSeries`, `siteSeries`, `buffer`, `lastKnown`) |

New in round 5:

| Selector | Returns |
|---|---|
| `dataset()` | `{dataset:'demo'|'account', label:'Demo fleet'|'Your account (first run)', state, scenario, reason, stored, now}` |
| `staleness(ageSeconds, {at})` | `{key:'fresh'|'delayed'|'stale'|'unknown', label:'Fresh'|'Delayed'|'Stale · last known 15:10', tone, ageSec, age, thresholds}` — **≤ 60 s Fresh · ≤ 5 min (300 s) Delayed · > 5 min Stale · last known** (boundaries inclusive: 60 → Fresh, 300 → Delayed, 301 → Stale) | **[Superseded — §18.4: cadence-relative; legacy key kept]]**
| `readingStaleness(ups)` | staleness of the UPS's last reading, or null |
| `contactState(collector)` | `{key:'reporting'|'delayed'|'not-reporting'|'contact-lost'|'asleep'|'revoked'|'waiting', tone, label, sub, role:'witness'|'workstation', roleLabel:'Site witness'|'Ordinary workstation', expected, at, ageMs, age, since?}`. Labels: "Reporting" · "Reporting · delayed" (> 60 s) · **"Asleep (ordinary workstation)"** (sub "no incident · since 13:05") · **"Not reporting (site witness)"** (sub "Site still reachable: home-esp32-garage reporting") · "Contact lost — cause unknown" (all expected collectors at the site silent) · "Credentials revoked". |
| `siteReachability(site, {at})` | `{key, tone, label, detail, witnesses:{total, reporting, silent}, evidence:[{collectorId, name, state:'reporting'|'silent', at, roles}], source?, eventId?}`. Keys: `reachable` ("Site reachable" · "2 of 2 site witnesses reporting") · **`collector-failure`** ("Site still reachable: home-esp32-garage reporting" · "Collector not reporting — home-studio (collector failure, not site contact loss)") · **`site-contact-lost`** ("Contact lost — cause unknown" · "All 2 site witnesses silent · last contact 15:10:02 · no local evidence of cause") · **`connectivity-failure`** ("Connectivity failure confirmed by local probe" — local-probe evidence; use `{at: T('13:45')}` for Duplex) · `no-witness`. |
| `capabilities(collector)` | `[{key:'usb_host'|'flash_ring'|'retention'|'ups_classes'|'max_targets', label, value, unit?, illustrative, note}]` — ESP32 rows `illustrative:true`, note "Illustrative — validated board/UPS list pending" |
| `incidentTimestamps(inc)` | six rows `{key, label, at, detail, receivedAt?, by?, note?}` labelled exactly **First evidence · Grace period started · Declared · Acknowledged · Recovered · Resolved** |
| `ruleEvaluations({ruleId, siteId, upsId})` | rule evaluation records (§6) |
| `isReplay({observedAt, receivedAt, reconnecting})` | true only if received > 2 min after observation while reconnecting (§15.4) |
| `restoration('INC-0412')` | `{incidentId, step, available, now, steps:[{id,label,at,clock,done,current}]}` or null |
| `configRecords(collector)`, `configOverrides(collector)` | raw config records/overrides (prefer `MK.config.effective`) |
| `duplicateOf({host, port, serial})` | `null` or `{upsId, upsName, collectorId, by:'endpoint'|'serial', message:'Already monitored by bw-pi-01 as BW Office UPS'}` | **[Superseded — §18.1: wrapper over networkDuplicate; host alone never matches]]**
| `networkEndpoint(host, port)` | simulated endpoint or null (= unreachable) | **[Superseded — §18.1: namespace-aware, `networkEndpoint(host, port, {namespace, protocol, upsName})`]]**
| `targetsForCollector(collector)` | that collector's targets only |
| `history({siteId, upsId, from, to})` (subject-less events count as the UPS's own when their incident is about that UPS or its collector) | `{upsId, siteId, primary:{episodes, events}, siteContext:{label:'Site context', episodes, events}}` — with a UPS selected, its own evidence (its episodes, events about it or its collector) is primary; **other UPSs' episodes at the site go only to `siteContext`** | **[Superseded — §18.3: inclusion rule + unknown spans]]**
| `aggregate(upsOrSite, {range:'7d'|'24h', from, to})` | hourly aggregates (§16) |

`MK.fmt` as round 4.

---

## 8. URL parameters

| Param | Values | Carried by `MK.url.link` / `decorate` | Owner / effect |
|---|---|---|---|
| `state` | `incident` (default) · `calm` | **yes** (only valid values) | review bar — demo state. Never `signed-out` any more. |
| `scenario` | `ups-comms-lost` · `revoked` · `witness-down` · `empty` | **yes** (only valid values) | review bar — overlay; `empty` = account dataset |
| `theme` | `dark` · `light` | **yes** | shell |
| `motion` | `reduced` | **yes** | shell |
| `auth` | `signed-out` · `redirecting` · `callback` · `access-denied` · `expired-session` · `error` | **no** | sign-in.html states (was `?state=`) |
| `return` | page URL | no | sign-in.html → where to go after sign-in |
| `enroll` | `waiting` · `approved` · `expired` · `denied` · `offline` · `revoked` · `reenroll` | no | enroll.html states |
| `id` | entity id | no | site.html, ups.html, collector.html, incident.html, **collector-config.html** |
| `site` | site id | no | filters (history, incidents…); relationships.html cluster filter |
| `collector` | collector id | no | **sources.html, sync.html, network-source.html** (unknown → not found) |
| `ups` | UPS id | no | history.html |
| `focus` | `site:<id>` · `col:<id>` · `ups:<id>` · `eq:<id>` · `tgt:<id>` | no | relationships.html selected node (`MK.url.parseFocus()`) |
| `range` / `from` / `to` | `1h` · `6h` · `24h` · `7d` / epoch ms or ISO | no | history.html |
| page-specific | e.g. `tab`, `q`, `sort`, `sel`, `step`, `case`, `view` | no | owned by the page |

Rules:
- Only `state`, `scenario`, `theme`, `motion` are ever carried implicitly. `auth`, `return`, `enroll`, `id`, `site`, `collector`, `ups`, `focus` must be passed explicitly in `MK.url.link(href, {…})`.
- The review bar never writes `auth`/`enroll` and never removes page params; it only changes `state`/`scenario` (and drops entity params whose ids don't exist in the target dataset) via `MK.url.go()`.
- `MK.url.PARAMS` (shell.js) mirrors this table; `MK.url.STICKY`, `.FLOW` (`auth`, `return`, `enroll`), `.ENTITY` (`id`, `site`, `collector`, `ups`, `focus`).
- Direct links: `incident.html?id=INC-0412&theme=light` · `ups.html?id=duplex-utility-ups` · `collector.html?id=bw-pi-01&scenario=revoked` · `sync.html?collector=duplex-pi-probe` · `relationships.html?site=home&focus=col:home-studio&scenario=witness-down` · `history.html?site=broadwater&ups=bw-office-ups&range=7d` · `sign-in.html?auth=expired-session&return=incidents.html` · `collector-config.html?id=shop-esp32-net`.


---

## 9. Wording glossary (use exactly)

| Term | Use for |
|---|---|
| **Contact lost — cause unknown** | Server stopped hearing from a collector; no local evidence of cause |
| **Connectivity failure confirmed by local probe** | Local probe records show its targets failing |
| **Last known · 15:10** (+ age) | Value not current; always show time and age |
| **Unsupported** | The UPS or source cannot report this measurement |
| **Not reported** | A supported value missing from a record |
| **UPS estimate** (+ "est.") | Runtime predicted by the UPS; never call it "runtime remaining" without "estimate" |
| **Observed on battery** | Measured duration between observed transitions |
| **At least 27 minutes on battery; endpoint unknown** | Battery episode without an observed end |
| **Received late · replayed** | Uploaded history older than its receipt; never a fresh alert |
| **Recovered** | Condition cleared on fresh evidence (not the same as acknowledged or resolved) |
| **History arriving** (n pending) | Records exist on a collector, not yet uploaded |
| **No records** | No records exist for the period (e.g. probe offline) |
| **No records received** | Nothing received since last contact; local buffer state unknown |
| **UPS communication lost** | Collector reports, but its UPS source stopped answering |
| **Acknowledged / Not acknowledged** | Someone has seen it; changes nothing on equipment |
| **Directly monitored · Relationship recorded — no direct monitoring expected · Measurement unsupported · Expected collector/source missing** | the four coverage states |
| **configured** | relationship links (reads / powers / checks) are configured, not measured |
| **Site witness** | always-on collector carrying site expectations; workstations are ordinary and sleeping is normal |
| **Asleep (ordinary workstation)** | workstation collector not reporting; no incident; not counted as expected |
| **Not reporting (site witness)** · **Site still reachable: <name> reporting** | one witness silent while another reports — collector failure |
| **Fresh · Delayed · Stale · last known <time>** | reading age ≤ 60 s · ≤ 5 min · > 5 min | **[Superseded — §18.4: On schedule · Overdue · Stale · last known]]**
| **First evidence · Grace period started · Declared · Acknowledged · Recovered · Resolved** | incident timestamps (§15.3) |
| **Below 60 s delay — event only** | on-battery shorter than the rule delay |
| **Pending · Applied · Failed** (+ reason) · **Local override** · **Set locally** | config states |
| **Configured · Missing · Rejected** | local secret status |
| **Illustrative — validated board/UPS list pending** | unvalidated ESP32 board values |
| **Demo fleet · Your account (first run) · Reset demo** | review chrome only |
| **Fictional demonstration data** | persistent tag (shell) |

Avoid: "offline" for contact lost (unknown cause), "down", "battery health" for charge, "0%"/"0 min" for missing, "runtime" without "estimate", and any control verbs (shut down, restart, switch, test).

---

## 10. Page-owner map

| Package | Owns |
|---|---|
| **foundation** | `product/brand/**`, `product/app/assets/**` (incl. store.js, config-contract.js), `product/app/fonts/**`, `product/app/components.html` |
| **fleet-equipment** | `index.html`, `site.html`, `site-setup.html`, `equipment.html`, `ups.html` + `pages/fleet-equipment/**` |
| **incidents-history** | `incidents.html`, `incident.html`, `notifications.html`, `maintenance.html`, `alert-rules.html`, `history.html` + `pages/incidents-history/**` |
| **collectors-sources** | `collectors.html`, `collector.html`, `relationships.html`, `sources.html`, `collector-config.html`, `sync.html`, **`network-source.html`** + `pages/collectors-sources/**` |
| **onboarding** | `sign-in.html`, `welcome.html`, `install.html`, `enroll.html` + `pages/onboarding/**` |
| **cli** | `product/cli/**` |
| **integrate** | root `PROJECT/index.html` only |

Shared page keys for `mount({page})` are listed in §5.2. Settings has no hub page: its nav item lands on `alert-rules.html`, and the settings sub-nav links the other three pages.

## 11. Known limits

- Chart hatch patterns are defined once in the injected sprite, so their stroke colours follow the document theme, not a nested `[data-theme]` pane. This affects only the gallery's side-by-side view.
- `MK.fmt` assumes PDT (UTC−7) for every fixture date. This is correct for all dates in the data (Mar 2024 → Oct 2026).
- `revoked` advances the clock to 15:32; ages in that scenario are relative to 15:32. The INC-0412 walkthrough advances it to 15:32 / 15:37; the account dataset has its own virtual clock.
- The store is per browser profile (localStorage); private windows start pristine. Two tabs converge through `storage` events; the other tab shows a Reload toast rather than re-rendering itself.
- `pending → applied` happens on the next page load (or `MK.store.checkIn`) because the fixture clock is frozen.
- Created entities have 24 h series with "Not monitored yet" before `monitoredFrom`; 7-day aggregates before that are "Not monitored yet" hours.
- `MK.fixture` is recomputed (deep clone + merge) after every store change — fast for this fixture, but don't call store actions in tight loops.

---

## 12. Shared prototype state — `MK.store` (store.js)

### 12.1 Purpose and loading

`MK.store` persists everything a reviewer does in the prototype in **`localStorage["mk-proto-v1"]`** and feeds it back through `MK.fixture` / `MK.select` / `MK.data`, so every page in every tab sees the same created entities, acknowledgments, INC-0412 walkthrough state and configuration states. Pages **never** keep their own copies (no sessionStorage, no `?ack=`, no page-local fixtures for entities).

Load order (in `<head>`, synchronous): `fixture.js → store.js → config-contract.js → shell.js → components.js`. (If a page omits store.js/config-contract.js, fixture.js inserts them with `document.write` as a safety net and `MK.shell.mount()` warns in the console — add the tags anyway.) In node (CLI build scripts, tests) provide `globalThis.localStorage` before requiring store.js; config-contract.js also exports via `module.exports`.

### 12.2 Generic API

| API | Notes |
|---|---|
| `get(path)` | deep copy at a dot path, e.g. `get('dataset')`, `get('demo.acks')`, `get('account.created.sites')`; `get()` = whole state |
| `set(path, value)` | writes, persists, notifies; `undefined` deletes. Prefer domain actions. |
| `update(fn)` | `fn(draft)` mutates a copy (or returns a new state); persisted + notified |
| `subscribe(fn)` → `unsubscribe()` | `fn(state, info)`; `info.source` = `'local'` (this tab) or `'remote'` (another tab, via the `storage` event); `info.reset`, `info.action`, `info.path`. Re-read `MK.fixture` inside the callback. The shell already shows a "Prototype data changed in another tab · Reload" toast for remote changes. |
| `reset()` | clears the key; pristine fixture; dataset = demo |
| `confirmReset({returnFocus})` | dialog "Reset demo?" → `reset()` → reloads this page without entity params (`id`, `site`, `collector`, `ups`, `focus`) that no longer exist (other params, incl. `scenario`, `auth`, `enroll`, are kept) |
| `pruneSearch(search[, data])` | search string minus entity params unknown in the dataset that search resolves to |
| `dataset()` / `setDataset('demo'|'account')` | the stored dataset preference (§12.3) |
| `active()` | the dataset actually shown (URL + store rule) |
| `now()` | the active dataset's clock (account virtual clock; demo meta.now incl. restoration clock) |
| `hasAccount()` | true once the first-run workspace has a site |
| `rev()`, `loads()`, `workspace(id)`, `snapshot()` | internals for composition/debugging |

State shape:

```
{ v: 1, dataset: 'demo' | 'account', loads: <page loads>,
  demo:    Workspace,          // applies to every demo state/scenario where the referenced ids exist
  account: Workspace }         // first-run workspace on top of the empty account, with clock
Workspace = {
  created:  { sites[], collectors[], ups[], sources[], targets[], equipment[], relationships[], events[], maintenance[] },  // full objects, created:true
  patches:  { <collection>: { <id>: {…} } },          // shallow patches to fixture entities
  acks:     { <incidentId>: { by, at, note } },       // ONE record per incident
  restoration: { 'INC-0412': 'investigate'|'ack'|'restored'|'resolved' },
  config:   { <collectorId>: { <key>: { value, previous, unit, requestedAt, seq, checkIns, by, server } } },
  rules:    { <ruleId>: { …patch, changedAt, changedBy } },
  enrollments: { <code>: { state:'approved', collectorId, at } },
  reenrolled:  { <collectorId>: at },
  clock: <epoch ms> | null,  n: <counter> }
```

### 12.3 Datasets — which data a page shows

| Situation | Active dataset |
|---|---|
| URL has `?scenario=empty` | **account** (empty account ⊕ `store.account`) |
| URL has a valid `?state=` or `?scenario=` (other than `empty`) | **demo** (`build(state, scenario)` ⊕ `store.demo`) |
| No valid `state`/`scenario` param and `store.dataset() === 'account'` | **account** |
| Otherwise | **demo**, incident state |

- `MK.store.createSite()` while the account dataset is active sets `store.dataset = 'account'`. From then on **every page without explicit state/scenario params shows the account dataset until Reset demo or the Dataset switch**.
- **"Go to fleet" from the first run** must link `MK.url.link('index.html', {scenario: null, state: null})` → index.html shows the account dataset with the created site, collector and UPS.
- The review bar shows **"Dataset: Demo fleet | Your account (first run)"** (segmented switch) and **Reset demo** on every page (including onboarding). Switching to the account removes `state`/`scenario`; switching to demo removes `scenario=empty`. Choosing a demo state or scenario also sets the dataset to demo.
- `MK.fixture.meta.dataset` / `.datasetLabel` and `MK.select.dataset()` tell a page which one it is showing.
- Demo-workspace changes (acks, restoration, config, network sources added on demo collectors) apply in every demo state/scenario where the ids exist (e.g. an INC-0413 acknowledgment shows in `incident` and `ups-comms-lost`, not in `calm` where INC-0413 doesn't exist).

### 12.4 Domain actions

All actions act on the **active** dataset's workspace (pass `{dataset:'demo'|'account'}` as the last argument to override), persist, notify subscribers, and return deep copies. Then re-read `MK.fixture`.

| Action | Returns | Behaviour |
|---|---|---|
| `createSite({name, timezone})` | site `{id, name, tz, witnessIds, circuits:[{id:'<id>-main', name:'Main circuit'}], created, createdAt}` | id = slug of name (unique); switches `dataset` to account when created in the account |
| `createCollector({id?, name, siteId, kind, platform, hardware, os, agent, version, roles, availability:'witness'|'workstation', interval})` | collector (reporting) | first contact = approval + 6 s; connectivity role adds gateway/DNS/ingest targets + `checks` relationships; witnesses join `site.witnessIds`; account clock +30 s. Unknown `siteId` throws. |
| `approveEnrollment(code, {siteId, roles, witness})` | `{ok:true, collector, sources:[discovered]}` · `{ok:false, error:'unknown-code'|'expired'|'already-approved'|'no-site', message}` | looks up `enrollment.devices[code]`; creates the collector (defaults from the device: roles, availability, site hint); adds the device's discovered USB sources (status `discovered`) when the UPS role is on. **KQPT-RNXV** re-enrolls bw-pi-01 (revoked scenario → reporting again; `{reenrolled:true}`). HQTV-LMSW → `expired`. |
| `addSource(collectorId, source)` | source | generic; `status` defaults to `discovered` |
| `removeSource(collectorId, sourceId)` | boolean | removes an unchosen created source (e.g. cancel after a duplicate) |
| `selectSource(collectorId, sourceId, {readings=true, name})` | `{ok:true, ups, source}` · `{ok:false, error:'duplicate', duplicate}` · `{ok:false, error:'unreachable'|'rejected'|'unknown-source'}` | creates the UPS asset (`monitoredFrom` = now, `reads` relationship, `collector.upsIds`), marks the source chosen; **never creates a second asset for a UPS already monitored** (serial/endpoint). With `readings !== false` calls `recordReadings`. | **[Superseded — §18.2: gated; soft serial matches need confirmDuplicate]]**
| `recordReadings(upsId, reading?)` | ups | deterministic first readings from the source profile (e.g. CyberPower CP1500PFCLCD: 100% · est. 38 min · load 18% · 121 V · temp Unsupported); unsupported metrics stay null. Account clock +30 s; the reading is 3 s old; live created collectors keep reporting (contact age 4 s). Created UPSs only. |
| `acknowledge(incidentId, {by='jp', note})` | `{by, at, note}` | `at` = dataset now. One record per incident: if already acknowledged (fixture or store) returns the existing record with `existing:true`. Adds an "Acknowledged by jp" event. Returns null for unknown ids. |
| `advanceRestoration('INC-0412', step)` | `MK.select.restoration()` \| null | steps `alert` → `investigate` → `ack` → `restored` → `resolved` (§12.6). `ack` and later acknowledge INC-0412 (jp, note "Utility outage on circuit A — checking with the building") if not yet acknowledged. `alert` clears the walkthrough. Incident state only. |
| `setDesired(collectorId, key, value)` | `{ok:true, record}` · `{ok:false, error:'not-dashboard-managed'|'unknown-collector', owner, message}` | dashboard-managed keys only (§13). Lifecycle: **desired value saved → `pending` → `applied` at the collector's next check-in** (the next page load, or `checkIn()`), or **`failed`** with the collector's reason (capability check, e.g. > 3 targets on an ESP32). Contact-lost, asleep or revoked collectors **stay `pending`** with a `waiting` reason. `alert_rules` is server-side → `applied` immediately. Applied values update the entity (name, roles, availability, heartbeat, reporting interval, site). |
| `checkIn(collectorId)` | config records | simulates the next check-in now | **[Superseded — §18.2: only reporting collectors check in; runs queued tests and starts waiting sources]]**
| `addNetworkSource(collectorId, {protocol:'nut'|'snmp', host, port?, upsName?, version?:'3'|'2c'})` | `{ok:true, source, authCommand:'sudo mainskeeper sources auth <source>'}` · `{ok:false, error:'duplicate', duplicate}` · `{ok:false, error:'protocol'|'host'|'upsName', message}` | creates a `draft` source with `secret.state:'missing'`. Endpoint duplicates (e.g. `10.0.20.15`) are refused before a draft exists. | **[Superseded — §18.1/§18.5: identity-based duplicates; platform authCommand]]**
| `simulateLocalAuth(sourceId, 'configured'|'rejected'|'missing')` | secret status | stands in for the operator running `sudo mainskeeper sources auth <source>` on the collector — the browser never handles a secret. Network-source pages poll by re-reading `MK.select`/`MK.config.effective`. |
| `testNetworkSource(sourceId)` | `{state:'success'|'unreachable'|'rejected'|'unsupported'|'duplicate', message, at, missing?, partial?, duplicate?}` | unknown host → unreachable; secret missing → rejected with `missing:true`; 192.168.1.22 → rejected; 192.168.1.26 → unsupported (partial); duplicate endpoint → duplicate; else success | **[Superseded — §18.2: `unavailable` (queued) when the collector is not reporting]]**
| `identifyNetworkSource(sourceId)` | `{ok:true, identity:{vendor, model, serial, capabilities, reading, partial}, duplicate}` | after a success/unsupported test; duplicate check by serial |
| `setRule(ruleId, patch)` | rule | alert rules/thresholds — server-side, immediate |
| `addMaintenance({siteId, title, start, end, suppresses?})` | window | |

### 12.5 Account virtual clock (first run)

The account dataset starts at the empty account's now (Wed 30 Sep 15:22:00). `createCollector`/`approveEnrollment` and `recordReadings` each advance it 30 s; `createSite` does not. Example: create site → approve BKTN-XRWP at 15:22:00 (first contact 15:22:06) → select the USB CyberPower → first reading 15:22:57, now 15:23:00 → Fleet shows "1 of 1 expected collectors reporting", "1 of 1 UPSs communicating", reading Fresh (3 s), contact 4 s ago, and history "Not monitored yet" before 15:22.

### 12.6 INC-0412 restoration walkthrough

| Step | Clock | Data |
|---|---|---|
| `alert` | 15:22 | fixture (declared 15:10:00) |
| `investigate` | 15:22 | no data change (page shows evidence) |
| `ack` | 15:22 | acknowledged jp 15:22:00 "Utility outage on circuit A — checking with the building" (unless already acknowledged) |
| `restored` | **15:32** | utility restored **15:31:00** (observed; received 15:31:01) · **22 min observed on battery** · recovered 15:31:00 (automatic) · recovery email 15:31:04 / push 15:31:05 · readings **54% · UPS estimate 21 min · load 37% · input 120 V · 31 °C** · incident still open until stable · last prediction 15:30 "UPS estimate 11 min" |
| `resolved` | **15:37** | resolved **15:36:00** after 5 min stable · readings 59% · est. 23 min |

Live collectors' contact/reading times move with the clock, so ages stay seconds.

---

## 13. Configuration ownership — `MK.config` (config-contract.js)

`MK.config.contract` is the authoritative table: rows `{key, label, owner:'dashboard'|'local-secret'|'local'|'capability', editLocation, effectiveSource, application:'check-in'|'server'|'local'|'none', applicationText, overrideAllowed, unit?, cli, toml, secretKind?, overrideHow?}`. collector-config.html renders it; the CLI spec uses the same rows.

| Owner | Keys | Edit location | Application |
|---|---|---|---|
| **dashboard** | `site`, `display_name`, `roles`, `expected_availability`, `heartbeat_interval` (override allowed), `reporting_interval`, `connectivity_targets` | Dashboard › Collectors › collector › Configuration | desired → pending → applied on next check-in, or failed with reason |
| **dashboard (server)** | `alert_rules` (alert rules & notification thresholds) | Dashboard › Settings › Alert rules | server-side, immediate, no collector involvement |
| **local-secret** | `nut_credentials`, `snmp_v3_secrets`, `snmp_community` | on the collector: `sudo mainskeeper sources auth <source>` (hidden prompt) | stored in the local secret store — Linux `/etc/mainskeeper/secrets.d` (0600), macOS System Keychain, Windows DPAPI. **Dashboard shows only Configured / Missing / Rejected + last test time. No dashboard credential entry or delivery.** | **[Superseded — §18.5: instructions per platform via MK.config.contractFor / MK.cli.credentialHelp]]**
| **local** | `storage_path`, `disk_cap_mb`, `retention_target`, `log_level`, `log_path` | `/etc/mainskeeper/collector.toml` or `sudo mainskeeper config set <key> <value>` | dashboard shows the effective value read-only, "Set locally" |
| **capability** | `usb_host`, `flash_ring`, `ups_classes`, `max_targets` | not editable | shown as capabilities; ESP32 values carry "Illustrative — validated board/UPS list pending" |

Overrides: only `heartbeat_interval`, via `sudo mainskeeper config set heartbeat_interval 15 --override` → `effective().text` = **"Effective: 15 s (local override) · Desired in dashboard: 30 s"** (bw-pi-01 in the fixture).

| API | Returns |
|---|---|
| `MK.config.effective(collectorId, key[, data])` | `{key, label, owner, ownerLabel, editLocation, application, overrideAllowed, desired, effective, source, state, stateLabel, badge, text, record?, override?, reason?, testedAt?, sourceId?, command?, illustrative?}` · `state`: `pending`·`applied`·`failed`·`override` (dashboard) · `configured`·`missing`·`rejected`·`not-applicable` (secrets) · `local` · `capability`. `null` for unknown collector/key. |
| `MK.config.forCollector(collectorId[, data])` | every contract row + `value: effective(...)` |
| `MK.config.row(key)`, `.format(key, value)`, `.OWNERS`, `.STATES`, `.SECRET_STORE`, `.platformOf(collector)` | helpers |

Fixture examples: shop-esp32-net `connectivity_targets` → failed "Collector rejected: max 3 targets on this board (capability)"; home-laptop `reporting_interval` → pending "Waiting for next check-in — workstation asleep since 13:05"; bw-pi-01 `snmp_v3_secrets` → Configured · last test 15:20:10; bw-pi-01 `log_level` → debug · Set locally; home-esp32-garage `flash_ring` → ~384 KB flash ring buffer · Illustrative.

---

## 14. CLI grammar — `MK.cli` (config-contract.js)

Single grammar everywhere (web onboarding, collector pages, CLI spec, examples). **Never** `--replace` (use `enroll --reenroll`), `scan` (use `sources discover`), `select` (use `sources use`) — `MK.cli.never`.

`MK.cli.grammar[]` rows: `{id, usage, summary, elevation:'none'|'admin'|'group', readOnly, exitCodes[], notes?, json?, localKeys?, overrideKeys?, dashboardManaged?}`:

| Command | Elevation |
|---|---|
| `mainskeeper` · `help <cmd>` · `status [--json] [--watch]` · `sources discover` · `sources list` · `sources show <source>` · `roles show` · `roles set ups=on|off connectivity=on|off` (prints "managed in the dashboard" + URL, exit 1) · `config show [--effective]` · `config validate` · `queue [--json]` · `doctor` · `service status` · `version` | none |
| `enroll [--headless] [--reenroll] [--site NAME] [--roles ups,connectivity]` · `sources add --nut HOST[:PORT] --ups NAME` · `sources add --snmp HOST [--version 3]` · `sources auth <source>` · `sources test <source>` (reads the local secret store) · `sources use <source>` · `sources remove <source>` · `config set <local-key> <value>` (dashboard keys → exit 1 + dashboard link; `heartbeat_interval … --override` allowed) · service actions | admin |
| `logs [--since DURATION] [--follow]` | group (`mainskeeper` group membership, or elevation) |

`MK.cli.elevation.platforms`: macOS, Fedora, Raspberry Pi OS → prefix `sudo `; Windows → "Run in an elevated terminal (Administrator)" (no prefix). ESP32 probes have no CLI. `MK.cli.cmdFor(commandId, platformId, args)` → `{line, level, note}`, e.g. `cmdFor('sources auth','pi','src-garage-pi-02-nut-192-168-1-20').line` = `sudo mainskeeper sources auth src-garage-pi-02-nut-192-168-1-20`. **[Superseded — §18.5: MK.cli.platform(); cmdFor accepts {platform}; ESP32 → no CLI]]**

`MK.cli.exitCodes`: **0** ok · **1** general (incl. dashboard-managed key rejected) · **2** usage · **3** not enrolled / credential invalid · **4** service not running · **5** permission · **6** hardware/source · **7** cloud unreachable · **10** partial/degraded. **`status` exits 0 whenever the query succeeds**; UPS on battery/stale is reported in output and JSON (`ups[].power`: utility|battery|unknown, `ups[].freshness`: fresh|delayed|stale), not via the exit code.

---

## 15. Policies — staleness, alerts, timestamps, backfill (`MK.policy`)

### 15.1 Staleness
Reading age **≤ 60 s Fresh** · **60 s – 5 min Delayed** · **> 5 min "Stale · last known <time>"**. Collector contact lost after the **contact grace = max(5 min, 3 × heartbeat)** (site witnesses; workstations never alert). Show the legend (`MK.ui.stalenessLegend()`) on UPS and collector detail and wherever ages are listed. **[Superseded — §18.4]]**

### 15.2 Alert rules
On battery → notify after **60 s** on battery (critical) · Low runtime → UPS estimate **< 10 min** (critical) · Contact lost → after the **contact grace, max(5 min, 3 × heartbeat)** (warning) · UPS comms lost while the collector reports → after **2 min** (warning) · Backlog stalled **> 30 min** (info). Home 06:42:10–06:42:48 (38 s) is below the 60 s delay → **"Below 60 s delay — event only"** (`MK.select.ruleEvaluations({ruleId:'rule-on-battery'})`); show this example on the alert-rules page.

### 15.3 Incident timestamps
Labels exactly: **First evidence · Grace period started · Declared · Acknowledged · Recovered · Resolved** (`MK.ui.incidentTimestamps(inc)`). First evidence may be received later (show the received time when > 2 min later). Declared = rule fired and the incident opened; notifications follow. Recovered = cleared on fresh evidence. **Resolved = recovered and stable for 5 min.** Never say "opened" for any of these.

### 15.4 Backfill and replay
`MK.policy.backfill` — use this exact wording in UI and notes:

> Historical backfill updates history at observation time and does not generate duplicate or obsolete notifications. Fresh evidence of an active condition remains alertable while a backlog drains. Ordinary transmission latency does not make a reading historical replay.

**Replay** = a record received **more than 2 min** after its observation time while the collector was reconnecting and draining its local buffer (`MK.policy.replay.thresholdSec = 120`; `MK.select.isReplay()`). Normal latency (seconds) is not replay. Do not write "replay never alerts". Render with `MK.ui.backfillNote()`.

### 15.5 Operational scenarios
- **Workstation sleep** (all demo states): home-laptop asleep since 13:05, last contact 13:05:40 → "Asleep (ordinary workstation) — no incident"; excluded from expected counts.
- **Witness down** (`?scenario=witness-down`): see §6.1. Three distinct reachability stories: Home = collector failure ("Site still reachable: home-esp32-garage reporting"); Shop = site contact lost (all witnesses silent, cause unknown); Duplex 13:41 = locally observed connectivity failure (probe evidence).
- **ESP32 values** (384 KB flash ring, ~6 h retention at 30 s, USB HID classes on ESP32-S3 with USB-OTG host, max 3 targets) are labelled **"Illustrative — validated board/UPS list pending"** (`collector.illustrative`, `capabilities[].illustrative`, `buffer.illustrative`, `ups.illustrative`; `MK.ui.illustrativeTag()`). Never imply that arbitrary boards or UPSs are supported.

---

## 16. History aggregation (7 d) — `MK.select.aggregate`

1 h / 6 h / 24 h use the 1-minute series (`upsSeries`, `siteSeries`). **7 d uses hourly aggregates — never draw 24 h for 7 d.**

`MK.select.aggregate(upsId, {range:'7d'})` →
```
{ kind:'ups', id, range, from, to, step: 3600000, supported:{charge, load}, illustrative, note,
  buckets: [{ from, to, minutes, coverage (0..1), noRecords, pendingShare,
              power:{utility, battery, unknown} (shares 0..1),
              charge:{min,max,mean}|null, load:{min,max,mean}|null,
              label: null | 'No records' | 'History arriving' | 'Not monitored yet' }],
  gaps: [{from, to, label}] }                    // consecutive noRecords hours merged
```
`MK.select.aggregate(siteId, {range:'7d'})` → `{kind:'site', id, buckets:[{from, to, minutes, coverage, noRecords, connectivity:{ok, fail, unknown}, contact:{contact, lost}, label}], gaps, ups:{<upsId>: <ups aggregate>}, note}` — no site battery % (per-UPS aggregates only).

Deterministic: the last 24 h are reduced from the 1-minute series (so today's episodes, the Duplex gap and pending history agree with the 24 h charts); earlier days are synthesised (utility, charge 100%, seeded load). **Shop: no records Sat 26 Sep 02:00–06:00** (both UPS and site). Home maintenance 18 Aug is outside the range. Draw coverage as a band; hatch `noRecords` hours "No records"; show min–max as a range and the mean as the line; power-state share per hour as a lane. Use `opts.from/to` to zoom within 7 d.

---

## 17. Migration notes for page packages (round 5)

Everyone:
1. Add `<script src="assets/store.js"></script>` and `<script src="assets/config-contract.js"></script>` between fixture.js and shell.js on every page you own.
2. Don't cache `MK.fixture` across store actions; after an action re-read it (or re-render from selectors). If a page should follow other tabs, `MK.store.subscribe((s, info) => render())`.
3. For every `?id=` / `?collector=` / `?site=` / `?ups=` / `?focus=` lookup that returns null, render `MK.ui.notFound({kind, id, backHref, backLabel})` — **never substitute** another entity (e.g. no fallback to bw-pi-01 or to the first open incident).
4. Remove page-local stores: sessionStorage, `?ack=`, overlay datasets, page-local entity fixtures. Use `MK.store` actions and selectors.
5. Collector counts: use `fleetCounts().text.collectors` ("4 of 6 expected collectors reporting · 1 workstation asleep"); show contact with `MK.ui.contactBadge` / `MK.select.contactState`.
6. Use `MK.ui.incidentTimestamps` and the six labels; stop using `openedAt` as "opened/started" (it is now = declaredAt; `startedAt` = firstEvidenceAt).
7. Use `MK.policy.backfill` / `MK.ui.backfillNote()` wherever replay/backfill is explained; drop "replay never alerts".
8. CLI strings come from `MK.cli` (grammar, `cmdFor`, elevation, exit codes) — no `--replace`, `scan`, `select`, no `mainskeeperd enroll`.

**fleet-equipment** (index, site, site-setup, equipment, ups):
- index/site: show the active dataset; account dataset with no sites → empty state linking to welcome (`fleetCounts().sites === 0`). Site cards: `siteSummary().reachability` (witness-down Home shows "Site still reachable: home-esp32-garage reporting"), `counts.workstationsAsleep`.
- ups.html / site.html: unknown `?id` → `MK.ui.notFound`. Add `MK.ui.stalenessBadge` / `stalenessLegend` to UPS detail; label ESP32-read UPSs with `illustrativeTag()` when `ups.illustrative`. Handle `upsStatus().key === 'waiting'` for newly created UPSs.
- site-setup.html: creating a site uses `MK.store.createSite({name, timezone})`.
- Equipment/UPS lists include created UPSs automatically (they are in `MK.fixture.ups`).

**incidents-history** (incidents, incident, notifications, maintenance, alert-rules, history):
- Replace `MK.ih` sessionStorage/`?ack=`/overlay with `MK.store.acknowledge(id, {note})` and plain selectors (`ack` is already merged). One record per incident.
- incident.html: unknown `?id` → notFound (no default incident). INC-0412 walkthrough: drive `?step=` (optional, page-owned) through `MK.store.advanceRestoration('INC-0412', step)` and render from `MK.select.restoration()` + the merged data (clock 15:32 / 15:37, readings, events, notifications come from the fixture). Delete the page-local journey overlay. Add `MK.ui.incidentTimestamps`. Handle kind `collector-not-reporting` (INC-0415, witness-down) in headlines.
- alert-rules.html: delays from the fixture ("After 60 s on battery"); show the Home 38 s example from `ruleEvaluations` ("Below 60 s delay — event only"); edits via `MK.store.setRule` (server-side, immediate); drop sessionStorage.
- maintenance.html: new windows via `MK.store.addMaintenance`; drop sessionStorage.
- history.html: `site`, `ups` and `range`/`from`/`to` filter charts, episodes and events alike. Use `MK.select.history({siteId, upsId, from, to})`: **selecting BW Office UPS must not show BW Rack's battery episode as its own** — it goes in a separate, labelled "Site context" section. **7d uses `MK.select.aggregate(id, {range:'7d'})`** (hourly min/max/mean, power share, coverage, hatched "No records" incl. Shop 26 Sep 02:00–06:00) — never the 24 h series. Unknown `?site`/`?ups` → notFound.
- Notes/explanations: backfill policy string + replay definition (§15.4).

**collectors-sources** (collectors, collector, relationships, sources, collector-config, sync, + new network-source.html):
- sources.html: **remove the `bw-pi-01` fallback**; `?collector=` unknown → notFound. "Add network UPS" links `network-source.html?collector=<id>`.
- sync.html: **read `?collector=`** and show that collector's buffer (`MK.select.buffer`); unknown → notFound.
- relationships.html: **read `?site=`** (cluster filter) and **`?focus=<kind>:<id>`** (`MK.url.parseFocus()`; kinds site, col, ups, eq, tgt) to preselect a node; unknown → notFound/ignore with message.
- collector.html / collector-config.html: `?id=` unknown → notFound; created collectors resolve through the store. collector-config renders `MK.config.contract` with `MK.config.forCollector(id)`: dashboard keys editable via `MK.store.setDesired` showing `MK.ui.configState` (pending/applied/failed + reason; override text); secrets **status only** via `MK.ui.secretBadge` + the `sudo mainskeeper sources auth <source>` instruction (**delete any credential-entry or delivery flow**); local keys read-only "Set locally"; capabilities via `MK.ui.capabilityBadge` (Illustrative on ESP32).
- collectors.html / collector.html: `MK.ui.contactBadge` (Asleep (ordinary workstation) for home-laptop; Not reporting (site witness) in witness-down), staleness legend, capabilities.
- network-source.html (new, page key `network-source`): protocol → endpoint (`addNetworkSource`) → local credential step (show `authCommand`; status poll via `MK.config.effective(collectorId, 'nut_credentials'|'snmp_v3_secrets'|'snmp_community')`; the review-only "Simulate: ran sources auth" control calls `MK.store.simulateLocalAuth`) → `testNetworkSource` (success / unreachable / rejected / unsupported / duplicate) → `identifyNetworkSource` → duplicate check ("Already monitored by bw-pi-01 as BW Office UPS" → keep existing / cancel = `removeSource`) → `selectSource` → readings appear. Reachable from onboarding step 7 and Sources with `?collector=`.

**onboarding** (sign-in, welcome, install, enroll):
- sign-in.html: use **`?auth=`** (`signed-out|redirecting|callback|access-denied|expired-session|error`) + `return=`; never `?state=` (reserved for the review bar). The shell's sign-out link is now `sign-in.html?auth=signed-out`.
- welcome.html: start from `?scenario=empty`; step 1 `MK.store.createSite({name, timezone})` (switches to the account dataset); approval `MK.store.approveEnrollment(code, {siteId, roles, witness})` using `MK.fixture.enrollment.devices` (same codes as before — delete the page-local DEVICES catalogue or derive from it); first readings via `MK.store.selectSource(...)` / `recordReadings(...)`; step 7 "Add a network UPS" → `network-source.html?collector=<id>`. **"Go to fleet" → `MK.url.link('index.html', {scenario: null, state: null})` with `data-mk-nokeep`** (otherwise the shell re-adds the sticky `scenario=empty`; it still lands on the account dataset either way) — shows the created site, collector and UPS.
- enroll.html: `?enroll=` states; the re-enroll path is `mainskeeper enroll --reenroll` and `MK.store.approveEnrollment('KQPT-RNXV')` in the revoked scenario. Elevation text from `MK.cli.elevation` / `cmdFor` (sudo on macOS/Fedora/Pi; "Run in an elevated terminal (Administrator)" on Windows).
- install.html: commands from `MK.cli` (no `--replace`).

**cli** (product/cli/**):
- Read the grammar, elevation and exit codes from `product/app/assets/config-contract.js` (`require()` works: `module.exports = {config, cli}`); config ownership from `MK.config.contract`.
- Rename the three examples (error-credential-revoked.json → error-enrollment-revoked.json; sources-auth.80/120.txt → sources-access-rejected.80/120.txt) and update references.
- `status` exit 0 when the query succeeds; `ups[].power` / `ups[].freshness` (fresh|delayed|stale per §15.1). **[Superseded — §18.4: ups[].freshness is an object]]**

---

## 18. Round 6 corrections (checkpoint 0.1.7)

Owner: foundation (shared-logic package). Files: `fixture.js`, `store.js`, `config-contract.js`, `components.js`, `components.css` (two lane classes), `components.html` (gallery), **`state-checks.js` (new)**. Everything below is shared logic — **pages must not re-implement any of it**: no page-local duplicate checks, range filters, chart special cases, freshness thresholds or platform strings.

### 18.1 Network UPS identity and duplicates

**Identity key** = `namespace | protocol | host | port | upsName` (NUT) or `namespace | snmp | host | port |` (SNMP). A server address alone is never a UPS identity.

- **Namespace** = `collector.networkNamespace` (optional per-collector override) else `site.networkNamespace`. Fixture sites: `broadwater-lan`, `home-lan`, `shop-lan`, `duplex-lan`. `MK.store.createSite()` sets `<site id>-lan` (e.g. "Cabin" → `cabin-lan`); `createCollector({networkNamespace})` sets an override. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) are per machine: namespace `host:<collectorId>`.
- **Hard duplicate** = the same key is already registered by any network source — monitored or a draft (except the source being checked) → **blocked**: "Already monitored by bw-pi-01 as BW Office UPS" (draft: "Already added on <collector> as <label> (draft <id>, not monitored yet)").
- **Soft duplicate** = a **trustworthy** serial that matches a monitored UPS (or an identified draft) **at a different key**, with the same vendor and base model (model text before " · ") → **warning, never a block**: "Possibly the same UPS as BW Rack UPS (serial match) — confirm". The user proceeds (`selectSource(…, {confirmDuplicate:true})`) or cancels (`removeSource`).
- **Trustworthy serial** (`serialTrustworthy(serial, {model})`): not empty; not a placeholder (`0`, `000000000`, `UNKNOWN`, `Default`, `123456789`, `N/A`, `To be filled by O.E.M.`, …); not one repeated character (`000000000000`); not a sequential run; ≥ 6 alphanumerics; not equal to the model; and vendor + model known. Untrustworthy serials never decide anything.

| Selector | Returns |
|---|---|
| `networkNamespace(collector\|site)` | namespace string (override → site default) or null |
| `networkIdentity({collectorId\|siteId\|namespace, protocol, host, port?, upsName?})` | `{key, namespace, protocol:'nut'\|'snmp', host, port, upsName, collectorId, complete, missing[], label}` — `key` null while incomplete |
| `sourceIdentity(source)` | identity of a registered network source |
| `networkDuplicate(input, {collectorId, excludeSourceId})` | `{identity, hard, soft, serial}` · `hard = {by:'identity', key, sourceId, upsId, upsName, collectorId, draft, message}` · `soft = {by:'serial', serial, upsId, upsName, collectorId, sourceId, message}` · `serial = {value, trustworthy, reason}` (when `input.serial` given; pass `vendor`, `model` too) |
| `serialTrustworthy(serial, {model})` | `{ok, reason}` |
| `networkEndpoint(host, port, {namespace, protocol, upsName})` | simulated endpoint or null (unreachable). Legacy 2-arg form searches every fixture LAN. |
| `networkEndpoints(namespace)` / `networkServer(namespace, host, port)` | endpoints a collector in that namespace can reach (review pickers) / `{host, port, upsNames[]}` |
| `duplicateOf(q)` **legacy wrapper** | hard match in the old shape (`by:'endpoint'`) only when the identity is complete (so `duplicateOf({host})` → **null**); serial-only queries return the soft match with `soft:true`. Prefer `networkDuplicate`. |

All of `addNetworkSource`, `testNetworkSource`, `identifyNetworkSource` and `selectSource` call `networkDuplicate`; the endpoint (`source.endpoint`) stores `namespace` and `key`.

**Simulated LAN** (`MK.fixture.networkEndpoints`, every entry has `namespace`):

| Namespace | Endpoint | Outcome |
|---|---|---|
| broadwater-lan | NUT `192.168.1.20:3493` ups `rackups` — CyberPower OR1500LCDRM1U, serial CRMA2231045 | success (a) |
| broadwater-lan | NUT `192.168.1.20:3493` ups `deskups` — APC Back-UPS RS 1500, serial `000000000000` (untrustworthy) | success, registrable separately (a) |
| broadwater-lan | SNMP v3 `10.0.20.15` — Eaton 5S 700 card = BW Office UPS (bw-pi-01) | **hard duplicate** from any Broadwater collector (c) |
| broadwater-lan | SNMP v3 `10.0.20.16` — APC SMT1500 AP9631 card, serial AS1811240377 (= BW Rack UPS, read over USB) | **soft** "Possibly the same UPS as BW Rack UPS (serial match) — confirm" |
| broadwater-lan | SNMP v3 `192.168.1.25` — APC SRT 2200 · AP9641 | success |
| home-lan | NUT `192.168.1.20:3493` ups `rackups` — Eaton 5P 1550, serial G355K12345 (different physical UPS) | success — allowed, different namespace (b) |
| home-lan | NUT `192.168.1.22:3493` ups `office` | rejected (credentials) |
| home-lan | SNMP v2c `192.168.1.26` | unsupported (partial) |
| duplex-lan | NUT `10.0.30.5:3493` ups `duplex-rack` — APC SMT1000RM2U | success (CLI example) |
| `*` (namespaces without a fixture LAN, e.g. account sites) | `192.168.1.20` rackups (CyberPower) · `.22` office (rejected) · `.25` SRT 2200 · `.26` partial | as listed |
| any | `192.168.1.99`, anything else | unreachable · a NUT server without the named UPS → unreachable with `upsNotFound:true` and the server's UPS list |

### 18.2 Collector availability and effective roles (store-enforced)

Rules live in `MK.store` actions — buttons can't bypass them:

1. **Saving** a draft (or a disabled/waiting source) is always allowed (`addNetworkSource`, `simulateLocalAuth`).
2. **Testing** needs a **reporting** collector (`contact === 'live'`). Otherwise `testNetworkSource` returns `{state:'unavailable', queued:true, reason:'home-laptop is asleep — test will run when it reports'}` — never success. Queued tests run at the collector's next check-in.
3. **Monitoring** (`selectSource`) needs a reporting collector **and** an applied UPS role. Otherwise the choice is **saved** and returned as `{ok:false, saved:true, error/state:'waiting-for-collector'|'waiting-for-role', label, reason}`; monitoring starts automatically at the check-in where both hold (if the read test succeeded). Not reporting takes precedence over the role in the label.
4. **Readings** (`recordReadings`) are recorded only when both hold (else `{gated:'collector'|'role'}`); the first reading is **≥ max(monitoredFrom, reconnectedAt, role appliedAt)**.
5. Witness-down: no observation (reading, heartbeat, collector/UPS/local-probe event, series point, power state, probe result) is ever synthesized after a silent collector's last contact. Composition drops such events and treats readings the collector could not have sent as not received.

| API | Returns / behaviour |
|---|---|
| `MK.store.reconnect(collectorId)` | Review simulation: the collector reports again. Advances the virtual clock **30 s** (account clock or the new **demo clock offset**), sets `contact:'live'`, `reconnectedAt`, resumes targets/heartbeats from that time, then `checkIn()`. A fixture UPS that was last known resumes with an **outage span** (unknown) from its last reading to the reconnect. → `{ok, at, collector, checkIn}` · `{ok:false, error:'revoked'}` (re-enroll instead) · `{ok:true, already:true}` |
| `MK.store.checkIn(collectorId)` | Only a reporting collector checks in: pending desired values → applied (or failed) **at this time**; seeded fixture pending records (home-laptop `reporting_interval` 120 s) apply too; queued tests run; waiting sources start. → `{ok:true, reporting:true, at, records, tests:[{sourceId, state}], activated:[upsId]}` · `{ok:false, error:'not-reporting', reason, records}` |
| `MK.store.disconnect(collectorId)` | Review aid: a workstation goes to sleep (`asleep`) / a witness goes silent (`lost`) now |
| `MK.select.collectorReporting(c)` | `{reporting, contact, reason, since}` |
| `MK.select.effectiveRoles(c)` | `{applied[], desired[], ups:'on'\|'off'\|'pending'\|'pending-off', connectivity, state, record, appliedAt}` |
| `MK.select.sourceGate(source)` | `{key:'configured'\|'waiting-for-role'\|'waiting-for-collector'\|'monitoring', label, reason, waitingFor:['collector','role'], reporting, upsRole, testQueued}` — labels **Configured · Configured — waiting for UPS role · Configured — waiting for collector · Monitoring** |
| `MK.select.upsStatus(u)` | `waiting` now has `gate:'collector'` + sub "<collector> is not reporting — readings start when it reports" |

**Demo virtual clock** (new): the demo workspace keeps `clockOffset`; `reconnect` and `recordReadings` add 30 s to every demo state/scenario's now (the INC-0412 walkthrough clock 15:32/15:37 still wins when later). `MK.store.now()` reflects it.

**Demonstration — home-laptop** (demo, macOS ordinary workstation, connectivity only, asleep since 13:05; `network-source.html?collector=home-laptop`):
configure NUT `192.168.1.20` ups `rackups` (home-lan) → draft saved · `sudo mainskeeper sources auth <source>` → test → **unavailable** "home-laptop is asleep — test will run when it reports" · Configuration: roles UPS + Connectivity → **pending** · `reconnect('home-laptop')` (15:22:30) → check-in applies the role (applied 15:22:30) and the 120 s reporting interval; the queued test runs → success · test again → success · use → **first readings 15:22:57** (≥ reconnect) · reading On schedule at a 120 s cadence.

**Account connectivity-only path**: create site → approve **WDJB-MJHT** (studio-mbp, workstation, connectivity) → add NUT `192.168.1.20` rackups → test success (reporting) → use → "Configured — waiting for UPS role" → roles UPS on → pending → `checkIn('studio-mbp')` → applied; monitoring starts, first reading ≥ the check-in. `disconnect`/`reconnect` repeat the collector gate.

### 18.3 History time filtering and missing evidence

**One inclusion rule** (`MK.select.span(item)`, `MK.select.inRange(item, from, to)`), used by `history()` and `eventsFor()`:
- **Point events** (no `endAt`, not `ongoing`): included iff `from ≤ at ≤ to`.
- **Intervals**: included iff `start ≤ to` AND `end ≥ from`, with `end = endAt` (events) / `end` (episodes), else the episode's last observed record (`lastRecordAt`, e.g. Duplex 14:30), else **now only when `ongoing === true`**, else `end = start`. There is no implicit "until now".
- Placement is observation time (`at` / `start`); replayed records keep their observation time (Duplex 14:03 record received 15:17:40 sits at 14:03). Receipt events (`kind:'replay'`) sit at their receive time.
- 15:00–15:22 today **excludes** yesterday's Duplex episode (INC-0409), this morning's Home 06:42 event and the Duplex 14:03–14:30 episode; it includes the ongoing BW Rack episode and the 14:54–15:02:06 probe interval.

**Unknown is data.** Missing-evidence states — `nocontact` ("No records — collector not reporting"), `none` ("No records"), `pending` ("History arriving"), `commslost`, `unmonitored` ("Not monitored yet"), `awaiting` ("Not reported yet — next report due by 15:25:00 (every 600 s)") — are carried by every shared output with `unknown:true`, and are **never filled, joined or counted as up/down**. Fixture branches are clipped at the last observation (the witness-down Office Desk UPS bug lived in `buildPowerIntervals`).

| Selector | Returns |
|---|---|
| `history({site\|siteId, ups\|upsId, from, to})` | as round 5 + `unknown:[{subjectType, subjectId, subjectName, from, to, state, label}]` (primary subject(s), clipped) + `rule`; **null** for unknown ids |
| `series(ups, metric, {from, to})` | `{upsId, metric, label, unit, kind, supported, from, to, segments:[{kind, from, to, points}], gaps:[{from, to, kind, label, unknown:true}], uncertain, unknownMs, coverage, beforeSeries}` — 1-min data (last 24 h); `metric:'power'` → `lanes()` |
| `lanes(ups\|site, {from, to})` | UPS `{kind:'ups', power:[intervals]}` · site `{kind:'site', contact, connectivity, ups:{<id>: power}}` — clipped, `unknown` flagged; pass straight to `MK.ui.chart.lanes` |
| `aggregate(ups\|site, {range, from, to})` | buckets add `unknownMinutes`, `unknownState`, `unknownLabel`; `label` for empty hours uses the dominant unknown kind; UPS adds minute-precision `unknown:[{from, to, state, label}]` (0.1.10: `disabled` spans are the exact persisted intervals and bucket minutes are fractional, §21) |
| `availability(ups, '24h'\|'7d'\|'30d'\|{from, to})` **new for UPSs** | `{kind:'ups', utilityMin, batteryMin, unknownMin, unknownByState, unmonitoredMin, coverage, onUtility, text:{coverage, power, unknown}}` (0.1.10: fractional minutes from exact intersections, `exact:true`, §21) |
| `availability(site, {from, to})` | site availability for any range from the 1-min series (period strings unchanged) |
| `observations(collector, {after})` | evidence after a time (checks use it to prove nothing is synthesized) |

**Witness-down** (`?scenario=witness-down`): Office Desk UPS power lane `utility … → 15:04:12`, then `nocontact` **15:04:12–15:22** "No records — collector not reporting · last known on utility"; charge/load series end 15:04:00 (last point ≤ 15:04:12) with the gap to now; hourly 15:00 bucket coverage 5/22 with `unknownLabel` "No records — collector not reporting"; 24 h UPS availability counts those 17 min as unknown; Home site probe results are unknown after 15:04. home-esp32-garage reporting establishes **site reachability only** — never Office Desk UPS power.

### 18.4 Freshness vs configured cadence

`MK.freshness` (config-contract.js — also `require()`-able by the CLI) and `MK.select`:

- **I** = effective reporting interval of the source/collector (`MK.select.effectiveInterval(ups|source|collector)`: `source.reportingInterval`, else `collector.interval`, else 30 s; clamped to 10–600 s). **g** = max(30 s, I/2).
- **On schedule**: age ≤ I + g · **Overdue**: I + g < age ≤ 2I + g · **Stale · last known <time>**: age > 2I + g, or the collector's contact is lost. 30 s → ≤ 60 / ≤ 90 / > 90 s · 600 s → ≤ 15 / ≤ 25 / > 25 min.
- Always show the absolute age separately: `readAgo` "read 42 s ago".
- **Heartbeat** (separate): H = effective heartbeat (`effectiveHeartbeat(c)`, bw-pi-01 = 15 s override; default 30 s) · **Late** when age > 2H · **Contact lost** after max(5 min, 3H). `contactState()` uses it (label "Reporting · heartbeat late", `heartbeat` object).
- Fixture: **BW Office UPS** (`src-bw-office-snmp.reportingInterval = 600`) last reading **420 s** old → **On schedule** (`expectedIntervalSeconds 600`, `allowanceSeconds 300`); its series shows `awaiting` after the last report rather than a filled value.

| API | Returns |
|---|---|
| `MK.select.readingFreshness(ageSec, intervalSec, {at, contactLost})` = `MK.freshness.reading` | `{state:'on-schedule'\|'overdue'\|'stale'\|'unknown', label, badge, tone, ageSeconds, age, readAgo, expectedIntervalSeconds, allowanceSeconds, onScheduleMaxSeconds, overdueMaxSeconds, clamped, reason, text, json}` |
| `MK.select.heartbeatFreshness(ageSec, hbSec)` = `MK.freshness.heartbeat` | `{state:'on-time'\|'late'\|'contact-lost', label, badge, ageSeconds, age, heartbeatSeconds, lateAfterSeconds, graceSeconds, text, json}` |
| `MK.select.upsReadingFreshness(ups)` | for the UPS's last reading with its interval (null when never read) |
| `MK.select.freshnessJson(ups)` | **CLI/JSON `ups[].freshness`** = `{state, ageSeconds, expectedIntervalSeconds, allowanceSeconds}` e.g. `{"state":"on-schedule","ageSeconds":420,"expectedIntervalSeconds":600,"allowanceSeconds":300}` |
| `MK.select.freshness(ups)` | adds `reading`, `heartbeat`, `expectedIntervalSeconds` |
| `staleness(ageSec, {at, interval\|ups})`, `readingStaleness(ups)` **legacy wrappers** | the new object + legacy `key` (`fresh`=on-schedule, `delayed`=overdue, `stale`), `ageSec`, `thresholds`. A bare age uses 30 s — pass `{ups}`. |
| `MK.ui.stalenessBadge(age \| freshness, {interval, ups, at})` | "On schedule · read 42 s ago · every 30 s" (badge keys `on-schedule`, `overdue`, `stale`) |
| `MK.ui.freshnessBadge(ups)`, `MK.ui.heartbeatBadge(collector)` | convenience badges |
| `MK.ui.stalenessLegend({interval})` | cadence legend (reading + heartbeat rows + worked numbers) |
| `MK.policy.staleness` | `{defaultIntervalSec:30, minIntervalSec:10, maxIntervalSec:600, allowance, heartbeatDefaultSec:30, contactGraceMinSec:300, legend[]}` — **`freshMaxSec`/`delayedMaxSec` removed** |

Copy: "Fresh / Delayed" → **"On schedule / Overdue"**; never "stale after 5 min".

### 18.5 Platform-specific instructions

`MK.cli.platform(id | collector)` → `{id, label, hasCli, elevation, elevate(cmd), elevationNote, configPath, secretStore, secretItem(sourceId), logs, service, shell, setupPage?}`; `MK.cli.platformFor(collector)` → id; `MK.cli.platforms` = ids.

| Platform | Elevation | Config | Secrets | Logs |
|---|---|---|---|---|
| `macos` | `sudo mainskeeper …` | `/Library/Application Support/MainsKeeper/collector.toml` | macOS Keychain — System keychain item "MainsKeeper source <id>" | `/Library/Logs/MainsKeeper/` |
| `fedora`, `pi` | `sudo mainskeeper …` | `/etc/mainskeeper/collector.toml` | `/etc/mainskeeper/secrets.d/` (0600) | `journalctl -u mainskeeperd` |
| `windows` | `mainskeeper …` in an **elevated terminal (Administrator PowerShell)** — no sudo | `C:\ProgramData\MainsKeeper\collector.toml` | DPAPI (LocalMachine) | Event Viewer → Applications and Services → MainsKeeper · `C:\ProgramData\MainsKeeper\logs` |
| `esp32` (illustrative) | **no host CLI** (`elevate()` → null) | device's local setup page (setup access point / `http://<device>.local` during provisioning) | on the device, entered on its setup page | status only in the dashboard |

| API | Returns |
|---|---|
| `MK.cli.cmdFor(id, platformId \| {platform\|collector, args}, args)` | `{line, level, note, platform, hasCli, windows, esp32, elevated, shell}` — ESP32 → `line:null`, setup-page note. The `--nut`/`--snmp` flag of `sources add` is now kept in `line`. Positional form unchanged. |
| `MK.cli.credentialHelp(collector\|id\|platformId, sourceId)` | `{platform, hasCli, command, testCommand, commandNote, secretStore, secretItem, logs, setupPage?, steps[], short, text}` — use for every credential error, rejected/missing test result and copy button |
| `MK.config.contractFor(collector\|id)` | contract rows with `editLocation`, `effectiveSource`, `overrideHow` resolved for that collector's platform (static `MK.config.contract` rows are now platform-neutral) |
| `MK.config.effective(…)` | secrets: `source` = platform secret store, `command`/`help` from `credentialHelp`; `forCollector()` rows are platform-resolved |
| `MK.config.platformOf(c)` | now the platform id (`pi`/`fedora` instead of `linux`; `SECRET_STORE` keeps a `linux` key) |

Store messages (`addNetworkSource().authCommand`, `testNetworkSource()` rejected/missing messages) are platform-specific. Local log paths: Linux `journald (journalctl -u mainskeeperd)`, Windows Event Viewer + ProgramData, ESP32 status only.

### 18.6 Checks — `state-checks.js`

From the project root: `"$OD_NODE_BIN" product/app/assets/state-checks.js` (`--quiet` → failures + summary). Loads fixture → store → config-contract with an in-memory localStorage shim, drives the store like the pages, prints `PASS`/`FAIL` per check, exits 1 on any failure. Covers §18.1–§18.5 and regressions (first run persisted, acknowledgment propagation, INC-0412 steps, CLI example count ≥ 121 and guard-safe names using `cli-screens.js` `GUARD_RE`). Run it after any shared-logic change.

### 18.7 Migration notes per page package

**Everyone**
1. Do not compute duplicates, ranges, unknown spans, freshness thresholds, platform commands or paths in pages. Use §18 selectors/actions; delete local copies (e.g. hard-coded `sudo`, `/etc/mainskeeper/…`, `≤ 60 s`, `> 300000`, SIM endpoint lists).
2. Freshness: replace `stalenessBadge(ageSec, {at})` with `MK.ui.freshnessBadge(ups)` or `stalenessBadge(MK.select.readingStaleness(ups))` — a bare age assumes 30 s and would show BW Office UPS (600 s, 420 s) as Stale. Words: On schedule / Overdue / Stale · last known, plus "read N ago · every I".
3. Every instruction line comes from `MK.cli.cmdFor` / `credentialHelp` / `platform()` for **that collector's** platform.

**network-collectors** (collectors-sources: network-source, sources, collector, collectors, collector-config, sync, relationships)
- network-source: precheck with `MK.select.networkDuplicate({collectorId, protocol, host, port, upsName})` — **not** `duplicateOf({host})` (now returns null). Hard → blocked card with `hard.message`; after identify, `identify.soft` → confirm step ("Possibly the same UPS as … (serial match) — confirm" · Proceed = `selectSource(…, {confirmDuplicate:true})`, Cancel = `removeSource`). Show the identity (`identity.label`/`key`, namespace).
- Replace the page SIM list with `MK.select.networkEndpoints(MK.select.networkNamespace(collectorId))`.
- Test step: handle `state:'unavailable'` (`queued`, `reason`) with `MK.ui.testResultBadge`; never present it as failure or success. Use step: handle `{saved:true, state:'waiting-for-collector'|'waiting-for-role'}` → show `MK.ui.sourceStateBadge(source)` + reason, link to Configuration for the role; `error:'not-tested'`, `'confirm-duplicate'`.
- Add review controls "Simulate: collector reports again" → `MK.store.reconnect(id)` and "Check in now" → `MK.store.checkIn(id)` (+ `disconnect` for the sleep demo); render `checkIn().tests/activated`.
- Credential step and errors: `MK.cli.credentialHelp(collector, sourceId)` (`command`/`commandNote`/`secretStore`/`text`); ESP32 → setup page text, no command. Replace `SECRET_STORE[platformOf(c)]` with `MK.cli.platform(c).secretStore`.
- collector-config: render `MK.config.forCollector(id)` (already platform-resolved) or `contractFor(id)`; show `effectiveRoles()` (desired vs applied) and `heartbeatBadge`. Sources/collector pages: `sourceStateBadge` per source.
- sync.js: `cmdFor('queue', <collector platform>)` instead of `'pi'`; logs via `platform(c).logs`.

**history-fleet** (incidents-history: history, incident; fleet-equipment: ups, site, fleet, equipment, fe-common)
- history.js: call `MK.select.history({siteId, upsId, from, to})` only; draw lanes from `MK.select.lanes()` / series from `MK.select.series()`; show `history().unknown` spans as "No records — collector not reporting" etc. **Remove every special-case chart branch** (e.g. per-UPS / witness-down overrides) — the shared data already carries the gap. 7 d stays `aggregate()`; use `unknownLabel` / `unknown[]`.
- Coverage % and availability for a UPS: `MK.select.availability(upsId, period|{from,to})` (unknown minutes reported, not counted up/down); site: `availability(site, period|{from,to})`.
- Never derive Office Desk UPS power from home-esp32-garage; reachability stays `siteReachability`.
- fe-common `stalenessHtml`: `U.freshnessBadge(u)`; legend `U.stalenessLegend({interval})`; fleet/equipment sort by `readingStaleness(u).state` not raw age thresholds. Lane state `unmonitored` replaces `none` for "Not monitored yet"; `awaiting` is new (CSS provided).

**onboarding** (welcome, enroll, install, sign-in)
- `cmd()` helper: call `MK.cli.cmdFor(id, platform, args)` directly — do **not** map ESP32 to `pi`; ESP32 returns `line:null` → show the setup-page text (`MK.cli.platform('esp32').setupPage`). Windows: elevated terminal, no sudo (`elevationNote`); paths/logs from `platform()`.
- welcome: USB discovery still only happens with the UPS role on, so `selectSource` there behaves as before; handle a `{gated}` return from `recordReadings` defensively (collector asleep). Step 7 network UPS: the network-source page now handles queued tests and "Configured — waiting …" states. Account sites get namespace `<slug>-lan`; a site named "Home" lands in `home-lan`, any other name uses the `*` simulated LAN — list reachable endpoints with `MK.select.networkEndpoints(namespace)` rather than hard-coding addresses.
- Freshness copy in step 8: `freshnessBadge`.

**cli** (product/cli/**)
- `require('../app/assets/config-contract.js')` now exports `{config, cli, freshness}`: use `cli.platform(id)` for elevation/config path/secret store/logs on every screen (macOS Keychain item "MainsKeeper source <id>", Windows DPAPI + Event Viewer, Fedora/Pi `/etc/mainskeeper/…` + `journalctl -u mainskeeperd`); `cli.credentialHelp(platformId, sourceId)` for access-rejected screens.
- `status` text and JSON: `ups[].freshness = {state:'on-schedule'|'overdue'|'stale', ageSeconds, expectedIntervalSeconds, allowanceSeconds}` via `freshness.reading(age, interval).json`; text "On schedule · read 3 s ago · every 30 s". Remove `FRESH_DEF` (≤ 60 / ≤ 300). Add a 600 s example (BW Office UPS SNMP 420 s → on-schedule).
- Network examples: duplicate wording "Already monitored by bw-pi-01 as BW Office UPS" for `10.0.20.15` (same namespace); identity shown as namespace|protocol|host|port|ups; optional soft-warning example (`10.0.20.16`). Unavailable test example: "home-laptop is asleep — test will run when it reports" (exit code per your grammar; never 0-success).
- `cmdFor` now keeps `--nut`/`--snmp` in `line`. Keep example count ≥ 121 and names guard-safe (state-checks verifies).

### 18.8 Known limits (round 6)
- 1-minute series cover the last 24 h; longer ranges use hourly aggregates (minute-precision `unknown` spans there come from the same sampler). Earlier days are synthesized utility except known gaps (Shop 26 Sep).
- The demo clock offset applies to all demo states/scenarios; each reconnect / first reading adds 30 s (INC-0412 summaries computed at 15:22 do not re-word themselves).
- Reconnecting a witness in `witness-down` (review action) resumes it with the 15:04:12 → reconnect span kept unknown.
- Per-source `reportingInterval` is a fixture/source property (shown read-only); it has no dashboard edit flow in this prototype.

## 19. Round 7 (checkpoint 0.1.8) — role transitions and deferred activation

- **Effective UPS role governs observation.** `applyWorkspace` step 9 (`applyMonitoringWindows`) derives UPS-role windows from the fixture roles, `ws.roleLog` (earlier applied `roles` records, pushed by `setDesired`) and the current applied record. While the role is applied off, every UPS of that collector gets `ups.monitoring = {state:'disabled', disabledFrom, lastReadingAt, reason}`, `comm:'last-known'`, a frozen `lastReadingAt` and reading. After a re-enable is applied: `state:'resuming'` until `MK.store.recordReadings` records a reading at or after that time (`ws.readingsAt`), then `state:'on'` with `resumedAt`, and `ups.outage = {from, to, kind:'disabled'}`.
- **Unknown kind `disabled`** ("No records — UPS monitoring disabled") is part of `UNKNOWN_STATES`. Series, power lanes, aggregates, `history().unknown` and availability count it as unknown.
- **Selectors:** `upsStatus` → key `last-known`, label "Monitoring disabled · last known HH:MM" (or "Re-enabled · awaiting reading …"); `upsReadingFreshness` → state `stale`, `json.monitoring`; `sourceGate` → keys `monitoring-disabled`, `confirm-duplicate`, `duplicate`.
- **Store:** `checkIn` runs one reading pass for `resuming` UPSs → `{resumed:[upsId]}`. `selectSource` and deferred check-in activation share `activationPrerequisites(source)`: test success/partial → identify (`identifyNetworkSource`, run if needed) → `networkDuplicate` (hard: blocked; soft: needs `{confirmDuplicate:true}`; the choice is kept with `status:'confirm-duplicate'`, `pendingDuplicate`) → `activate`. `checkIn` returns `{blocked:[{sourceId, error, message, duplicate}]}`. `selectSource` merges the saved `useOptions`. `recordReadings` never defaults charge (null = Not reported) and accepts fixture UPS ids. `advanceClock(sec)` is a review aid.
- **Checks:** `state-checks.js` groups 6 and 7.

## 20. Round 8 — persistent monitoring gaps

- `ws.monitorStops[upsId] = [{offAt, last}]` is recorded by `checkIn` (`recordStops`) when a role-off applies; `ws.monitorResumes[upsId] = [t]` by `recordReadings` for the first reading after a re-enable. `readingsAt` is informational only.
- `applyMonitoringWindows` builds one gap per role-off window: `{from: last observation before off, to: first observation after on | null}`. Closed gaps go to `ups.outages[]` (all carved from lanes; series class `disabled`); `ups.monitoring.gaps` lists all. Aggregate `unknown` spans of state `disabled` snap to those exact boundaries.
- Checks: `state-checks.js` group 8 (reloads the store; `reload()` rebinds `MK`/`S`/`ST` for later groups).
- Superseded in 0.1.10 (§21): aggregate spans no longer snap to the first overlapping stop.

## 21. Round 9 (checkpoint 0.1.10) — exact monitoring-gap intervals

- **Authority.** The persisted intervals are authoritative for UPS-monitoring-disabled time: `monitorStops` / `monitorResumes` → `ups.outages[]` (closed, `kind:'disabled'`), plus an open gap `[monitoring.lastReadingAt, now)` while disabled. `monitoringGaps(u, now)` returns them sorted. Nothing discovers these gaps from the 1-minute sample grid.
- **Series.** For each supported metric, every exact gap clipped to the detailed window is added to `gaps[]` as `{kind:'disabled', from, to, label, unknown:true, exact:true}`, replacing any sampled `disabled` run. Points strictly inside a gap are dropped. A segment is split wherever two consecutive points straddle a gap, so a line never bridges it. Short observed intervals between gaps get no invented points.
- **Lanes / history.** Unchanged: `buildPowerIntervals` already carves each outage at sub-minute precision, and `history().unknown` reads the lanes.
- **Aggregate.** `unknown` spans of state `disabled` are exactly the gaps clipped to the aggregation window; all gaps are kept and the 0.1.9 first-overlap matching is removed. Bucket `minutes`, `coverage`, `power.*`, `pendingShare` and `unknownMinutes` come from `stateMinutes(d, u, from, to)`. Charge and load statistics still come from the 1-minute samples.
- **Availability.** `stateMinutes` gives time per power state in `[from, to)`: exact intersections with `ser.power` inside the detailed window, and partial-minute-weighted 1-minute samples before it. Detailed time with no lane counts as unknown (`none`). `availability()` returns fractional minutes (`exact:true`); `utilityMin + batteryMin + unknownMin + unmonitoredMin = minutes` (allow 1e-9 for float addition). Only display text is rounded (`fmt.duration`, `fmt.minutes`). `text.unknown` is “No unknown time” only when `unknownMin === 0`.
- **Display helper.** `fmt.minutes(m)`: `< 1 min` → seconds (“30 s”, “< 1 s”); otherwise one decimal (“17.8 min”). Bucket rows and tooltips on the UPS and history pages use it.
- **Checks.** `state-checks.js` group 9 runs after group 8. It covers reproduction A (30 s gap 15:22:27–15:22:57) and B (15:22:27–15:28:27 and 15:28:42–15:34:12), on the created UPS and the fixture BW Rack UPS on bw-pi-01, with clipped ranges, cross-selector equality, later readings, a third cycle and a reload. Corrected expectations: group 3 is 4.2 / 17.8 min (was 5 / 17); group 8 availability is exact (tolerance removed).
