# Package Inventory and CVE Findings Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Agents report the packages installed on each Linux server; the control plane matches them against distribution security feeds (`trivy-db`) and raises findings that link to the existing `ApplyUpdatesCmd` patching path.

**Architecture:** The agent collects packages on its existing hourly loop and offers a hash first, sending the ~150KB list only when the server says it differs. `ReportPackages` does **not** match — it upserts the list and sets `scan_pending: true`. A new `vulnsched` loop, running inside the existing `bus.RunAsLeader("housekeeping", …)`, pulls `trivy-db` to an ephemeral directory, scans pending servers, diffs findings and emits one batched digest per tick. Matching logic lives in `server/internal/vulndb` as pure functions behind a small interface.

**Tech Stack:** Go 1.26, gin, mongo-driver v2, gRPC, bbolt, Next.js 16 + TanStack Query, Tailwind 3.

**Spec:** `docs/superpowers/specs/2026-08-06-package-inventory-and-cve-findings-design.md`. Read it before starting. Where this plan and the spec disagree, the spec wins and the plan is wrong.

## Global Constraints

- **Do not write tests.** No `*_test.go`, no npm test files, no test scaffolding. This is a deliberate instruction from the repository owner, not an oversight. Verification in this plan is `go build`, `go vet`, `npm run build` and manual checks.
- **Linux only.** Windows agents must not collect packages and must not appear in findings. Do not add an MSRC source, a `Get-HotFix` collector or KB supersedence logic — that is a separate spec.
- Every service query is scoped by `instance_id`. No unscoped lookups, ever.
- Every mutating API path writes an audit event via `services.LogEvent(instanceID, eventType, actor, serverID, keyID, details string)`.
- Licence features are read with `lic.HasFeature("vuln_scanning")`. **Never switch on `Tier`.**
- Module path prefix is `gitea.hostxtra.co.uk/mrhid6/vantage/`.
- Mongo driver is **v2**: `go.mongodb.org/mongo-driver/v2/bson`, and ObjectIDs are `bson.ObjectID`, not `primitive.ObjectID`. The spec's struct listings use `primitive.` — that is spec shorthand; **write `bson.ObjectID`**.
- Collections are reached via `db.Col("name")`.
- No component in `web/` may carry a hex colour; use Tailwind token names only. `web/` is dark-only — do not add a light theme.
- Commit messages use the `feat:` / `fix:` / `docs:` prefixes already in `git log`.
- Severity strings are lowercase and fixed: `critical`, `high`, `medium`, `low`, `unknown`.
- Finding states are exactly `open`, `fixed`, `accepted`.

## Correctness risks carried without tests

These are the places where a mistake produces a **wrong answer rather than a crash**, so nothing will surface them automatically. Read the code twice at each:

1. **Version comparison (Task 1).** `dpkg` ordering has epochs and sorts `~` before the empty string, so `3.0.2-0ubuntu1.15~rc1` precedes `3.0.2-0ubuntu1.15`. `rpmvercmp` has its own segment rules. Any fallback to string comparison orders `1.10` before `1.9`. Every one of those mistakes reports a vulnerable fleet as clean.
2. **The backport case (Task 11).** Installed `1:3.0.2-0ubuntu1.15` against advisory fixed-in `1:3.0.2-0ubuntu1.15` must resolve to **not vulnerable**. Equal must not compare as less-than.
3. **`first_seen` preservation (Task 12).** An upsert that overwrites it makes every finding look discovered today.
4. **Fixed-before-reopen ordering (Task 12).** A finding both absent from the scan and past its acceptance expiry must settle `fixed`, not reopen.
5. **Log/line caps** do not apply here, but the **200k-row equivalent** does: `server_packages` is one document per server and must stay one, or the 16MB limit becomes reachable.

Manual verification for 1 and 2 is described inline in those tasks.

## Dependencies to add

Run from the repo root; `go.work` covers all modules.

```bash
cd server
go get github.com/aquasecurity/trivy-db@latest
go get github.com/knqyf263/go-deb-version@latest
go get github.com/knqyf263/go-rpm-version@latest
go get github.com/knqyf263/go-apk-version@latest
go get oras.land/oras-go/v2@latest
go get go.etcd.io/bbolt@latest
```

`oras-go` pulls the OCI artifact. `trivy-db` provides the BoltDB schema reader. The three version modules are small and each does one job.

---

## File Structure

**Create — agent:**
- `agent/internal/packages/packages.go` — `Collect()` dispatch by package manager
- `agent/internal/packages/parse.go` — pure output parsers, one per manager
- `agent/internal/packages/osrelease.go` — `/etc/os-release` parsing

**Create — server:**
- `server/internal/models/packages.go` — `ServerPackages`, `InstalledPackage`, `OSRelease`
- `server/internal/models/vuln.go` — `VulnFinding`, `Acceptance`, `VulnDBMeta`, `VulnAlertRule`
- `server/internal/vulndb/version.go` — comparator dispatch by OS family
- `server/internal/vulndb/ecosystem.go` — OS family/version → trivy-db bucket, or unsupported
- `server/internal/vulndb/pull.go` — OCI fetch to temp dir
- `server/internal/vulndb/db.go` — advisory lookup over the BoltDB
- `server/internal/vulndb/match.go` — pure matching
- `server/internal/services/packages.go` — store, hash compare, package search
- `server/internal/services/findings.go` — finding diff/state machine + acceptance
- `server/internal/services/vulnrules.go` — alert rule CRUD, digest dispatch
- `server/internal/services/vulnindexes.go` — index builder
- `server/internal/vulnsched/sched.go` — the leader-owned loop
- `server/internal/api/vulnerabilities.go` — REST handlers
- `shared/mail/vuln.go` — `SendVulnDigest`
- `shared/mail/templates/vuln_digest.html.tmpl`, `vuln_digest.txt.tmpl`

**Create — web:**
- `web/app/(app)/vulnerabilities/page.tsx` — fleet board grouped by CVE
- `web/components/vulnerabilities/FindingRow.tsx` — one CVE, expandable
- `web/components/vulnerabilities/AcceptDialog.tsx` — reason + until
- `web/components/vulnerabilities/DBFreshness.tsx` — `pulled_at` age banner
- `web/components/settings/VulnAlertRulesCard.tsx`

**Modify:**
- `proto/vantage/v1/vantage.proto` — `ReportPackages` RPC and messages; `SyncResponse.collect_packages`
- `agent/internal/sync/sync.go:370-411` — `runUpdateCheck` also reports packages
- `server/internal/grpc/server.go` — `ReportPackages` handler; `SyncKeys` sets the flag
- `server/cmd/main.go:184-206` — start `vulnsched` and the sweeper inside `RunAsLeader`
- `server/cmd/main.go` (schema setup, near the other `Ensure*Indexes` calls) — `EnsureVulnIndexes`
- `server/internal/api/handlers.go` — register routes
- `shared/models/settings.go` — `VulnFindingRetentionDays *int`
- `admin/internal/models/entitlements.go` — `vuln_scanning` toggle
- `web/lib/api.ts` — types and client methods
- `web/components/Sidebar.tsx` — Vulnerabilities entry
- `web/app/(app)/servers/[id]/page.tsx` — Vulnerabilities and Packages tabs
- `web/app/(app)/settings/notifications/page.tsx` — alert rules card
- The control plane's instance-deletion collection list — add both new collections
- `CLAUDE.md`, `docsite/docs/vantage/` — document the subsystem

---

### Task 1: Version comparators

The most correctness-critical code in the feature. Its failure mode is silent: a wrong comparison reports a vulnerable fleet as clean.

**Files:**
- Create: `server/internal/vulndb/version.go`

**Interfaces:**
- Consumes: nothing.
- Produces: `vulndb.LessThan(family, a, b string) (bool, error)` and `vulndb.ErrUnsupportedFamily`.

- [ ] **Step 1: Add the dependencies**

```bash
cd server
go get github.com/knqyf263/go-deb-version@latest
go get github.com/knqyf263/go-rpm-version@latest
go get github.com/knqyf263/go-apk-version@latest
```

- [ ] **Step 2: Write the implementation**

Create `server/internal/vulndb/version.go`:

```go
// Package vulndb matches installed packages against distribution security
// advisories.
//
// Version comparison is bought rather than written. Distribution version
// ordering is subtle in ways that are invisible until they are wrong: dpkg has
// epochs and sorts "~" before the empty string, rpmvercmp has its own segment
// rules and treats "~" and "^" differently again, and any ordering that falls
// back on string comparison puts 1.10 before 1.9. Every one of those mistakes
// produces a false negative — a vulnerable host reported clean — which is the
// failure nobody notices.
package vulndb

import (
	"errors"
	"fmt"

	apk "github.com/knqyf263/go-apk-version"
	deb "github.com/knqyf263/go-deb-version"
	rpm "github.com/knqyf263/go-rpm-version"
)

// ErrUnsupportedFamily means we hold no comparator for this distribution, and
// therefore cannot answer whether it is vulnerable. Callers must surface this
// as "unsupported" and must never treat it as "not vulnerable".
var ErrUnsupportedFamily = errors.New("unsupported OS family")

// LessThan reports whether version a sorts before version b under the ordering
// rules of the given OS family.
func LessThan(family, a, b string) (bool, error) {
	switch family {
	case "debian", "ubuntu":
		if a == "" || b == "" {
			return false, fmt.Errorf("empty deb version (a=%q b=%q)", a, b)
		}
		va, err := deb.NewVersion(a)
		if err != nil {
			return false, fmt.Errorf("parse deb version %q: %w", a, err)
		}
		vb, err := deb.NewVersion(b)
		if err != nil {
			return false, fmt.Errorf("parse deb version %q: %w", b, err)
		}
		return va.LessThan(vb), nil

	case "redhat", "centos", "rocky", "alma", "amazon", "oracle", "suse", "opensuse", "sles":
		// go-rpm-version does not error; rpmvercmp is defined over arbitrary
		// strings. Guard empties so a missing version cannot read as equal.
		if a == "" || b == "" {
			return false, fmt.Errorf("empty rpm version (a=%q b=%q)", a, b)
		}
		return rpm.NewVersion(a).LessThan(rpm.NewVersion(b)), nil

	case "alpine":
		if a == "" || b == "" {
			return false, fmt.Errorf("empty apk version (a=%q b=%q)", a, b)
		}
		va, err := apk.NewVersion(a)
		if err != nil {
			return false, fmt.Errorf("parse apk version %q: %w", a, err)
		}
		vb, err := apk.NewVersion(b)
		if err != nil {
			return false, fmt.Errorf("parse apk version %q: %w", b, err)
		}
		return va.LessThan(vb), nil

	default:
		return false, fmt.Errorf("%w: %s", ErrUnsupportedFamily, family)
	}
}
```

- [ ] **Step 3: Verify the ordering by hand**

This replaces the test that would normally guard it. Write a scratch `main.go` outside the repo (or use `go run` on a temp file), call `LessThan` with each pair below, and confirm every result. **Do not skip this** — these are the exact cases that go wrong silently.

| family | a | b | expected |
| ------ | - | - | -------- |
| ubuntu | `1:3.0.2-0ubuntu1.15` | `1:3.0.2-0ubuntu1.15` | `false` — the backport case; equal is not less-than |
| ubuntu | `1:3.0.2-0ubuntu1.14` | `1:3.0.2-0ubuntu1.15` | `true` |
| ubuntu | `1:3.0.2-0ubuntu1.16` | `1:3.0.2-0ubuntu1.15` | `false` |
| debian | `1.0~rc1` | `1.0` | `true` — tilde sorts before empty |
| debian | `1.0` | `1.0~rc1` | `false` |
| debian | `2.0` | `1:1.0` | `true` — epoch dominates |
| debian | `1.9` | `1.10` | `true` — numeric, not lexical |
| redhat | `1.2.3-4.el9` | `1.2.3-4.el9` | `false` |
| redhat | `1.2.3-3.el9` | `1.2.3-4.el9` | `true` |
| redhat | `2:1.0-1` | `1:9.0-1` | `false` |
| rocky | `1.9-1` | `1.10-1` | `true` |
| alpine | `1.2.3-r0` | `1.2.3-r0` | `false` |
| alpine | `1.2.3-r0` | `1.2.3-r1` | `true` |
| alpine | `1.9-r0` | `1.10-r0` | `true` |
| arch | `1.0` | `2.0` | error wrapping `ErrUnsupportedFamily` |

Delete the scratch file afterwards. If any row disagrees, the bug is in this file or in the chosen library — resolve it before continuing, because everything downstream inherits it.

- [ ] **Step 4: Build**

Run: `cd server && go build ./... && go vet ./...`
Expected: exit 0.

- [ ] **Step 5: Commit**

```bash
git add server/internal/vulndb/version.go server/go.mod server/go.sum
git commit -m "feat: version comparators for distro package ordering"
```

---

### Task 2: Ecosystem mapping

**Files:**
- Create: `server/internal/vulndb/ecosystem.go`

**Interfaces:**
- Consumes: `vulndb.ErrUnsupportedFamily` from Task 1.
- Produces: `vulndb.Bucket(family, versionID string) (string, error)`.

- [ ] **Step 1: Write the implementation**

Create `server/internal/vulndb/ecosystem.go`:

```go
package vulndb

import (
	"fmt"
	"strings"
)

// rhelRebuilds share Red Hat's advisory feed rather than publishing their own.
var rhelRebuilds = map[string]bool{
	"redhat": true, "centos": true, "rocky": true, "alma": true, "oracle": true,
}

// Bucket maps an OS family and version onto the trivy-db bucket that holds its
// advisories.
//
// It returns ErrUnsupportedFamily rather than a best guess when we have no
// feed. A scan that cannot be performed must say so; reporting zero findings
// for a distribution we do not cover is indistinguishable from reporting a
// clean host, and one of those is a lie.
func Bucket(family, versionID string) (string, error) {
	family = strings.ToLower(strings.TrimSpace(family))
	versionID = strings.TrimSpace(versionID)

	switch {
	case family == "debian" || family == "ubuntu":
		if versionID == "" {
			return "", fmt.Errorf("%s requires a version id", family)
		}
		return family + " " + versionID, nil

	case family == "alpine":
		if versionID == "" {
			return "", fmt.Errorf("alpine requires a version id")
		}
		return "alpine " + majorMinor(versionID), nil

	case rhelRebuilds[family]:
		if versionID == "" {
			return "", fmt.Errorf("%s requires a version id", family)
		}
		return "redhat " + major(versionID), nil

	default:
		return "", fmt.Errorf("%w: %s", ErrUnsupportedFamily, family)
	}
}

func major(v string) string {
	if i := strings.Index(v, "."); i != -1 {
		return v[:i]
	}
	return v
}

func majorMinor(v string) string {
	parts := strings.Split(v, ".")
	if len(parts) >= 2 {
		return parts[0] + "." + parts[1]
	}
	return v
}
```

Expected behaviour, for reference while reading it back: `("ubuntu","22.04")` → `ubuntu 22.04`; `("debian","12")` → `debian 12`; `("alpine","3.19.1")` → `alpine 3.19`; `("rocky","9.3")` → `redhat 9`; `("arch","")` → `ErrUnsupportedFamily`; `("ubuntu","")` → error, because Ubuntu 22.04 and 24.04 publish different fixed versions for the same CVE.

- [ ] **Step 2: Build**

Run: `cd server && go build ./...`
Expected: exit 0.

- [ ] **Step 3: Commit**

```bash
git add server/internal/vulndb/ecosystem.go
git commit -m "feat: map OS family and version to trivy-db advisory buckets"
```

---

### Task 3: Agent-side OS release detection

**Files:**
- Create: `agent/internal/packages/osrelease.go`

**Interfaces:**
- Consumes: nothing.
- Produces: `packages.OSRelease{Family, VersionID, Arch string}`, `packages.ParseOSRelease(r io.Reader) (OSRelease, error)`, `packages.DetectOS() (OSRelease, error)`.

- [ ] **Step 1: Write the implementation**

Create `agent/internal/packages/osrelease.go`:

```go
package packages

import (
	"bufio"
	"errors"
	"io"
	"os"
	"runtime"
	"strings"
)

// OSRelease identifies the distribution well enough to select an advisory
// feed. VersionID is not optional: Ubuntu 22.04 and 24.04 publish different
// fixed versions for the same CVE.
type OSRelease struct {
	Family    string
	VersionID string
	Arch      string
}

// ParseOSRelease reads the os-release format: KEY=value, one per line, with
// values optionally quoted, and # comments.
func ParseOSRelease(r io.Reader) (OSRelease, error) {
	out := OSRelease{Arch: runtime.GOARCH}
	sc := bufio.NewScanner(r)
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		key, val, ok := strings.Cut(line, "=")
		if !ok {
			continue
		}
		val = strings.Trim(strings.TrimSpace(val), `"'`)
		switch strings.TrimSpace(key) {
		case "ID":
			out.Family = strings.ToLower(val)
		case "VERSION_ID":
			out.VersionID = val
		}
	}
	if err := sc.Err(); err != nil {
		return OSRelease{}, err
	}
	if out.Family == "" {
		return OSRelease{}, errors.New("os-release has no ID")
	}
	return out, nil
}

// DetectOS reads /etc/os-release.
func DetectOS() (OSRelease, error) {
	f, err := os.Open("/etc/os-release")
	if err != nil {
		return OSRelease{}, err
	}
	defer f.Close()
	return ParseOSRelease(f)
}
```

Note the quote stripping handles both `ID=ubuntu` and `ID="rocky"`, which real distributions both emit.

- [ ] **Step 2: Build**

Run: `cd agent && go build ./...`
Expected: exit 0.

- [ ] **Step 3: Commit**

```bash
git add agent/internal/packages/osrelease.go
git commit -m "feat: agent parses /etc/os-release for distro identification"
```

---

### Task 4: Agent-side package collection

**Files:**
- Create: `agent/internal/packages/parse.go`, `agent/internal/packages/packages.go`

**Interfaces:**
- Consumes: `packages.OSRelease` from Task 3.
- Produces: `packages.Package{Name, Version, Arch, SourceName string; Epoch int}`, `packages.Collect() (OSRelease, []Package, error)`, `packages.Hash([]Package) string`.

**Why `SourceName` matters:** Debian and Ubuntu advisories are keyed on the **source** package. A CVE against `openssl` covers the binaries `libssl3`, `openssl` and `libssl-dev`; matching on binary name alone misses two of the three.

- [ ] **Step 1: Write the parsers**

Create `agent/internal/packages/parse.go`:

```go
package packages

import (
	"crypto/sha256"
	"encoding/hex"
	"sort"
	"strconv"
	"strings"
)

// Package is one installed package as the distribution reports it. Version is
// the distribution's own version string, verbatim — never normalised, because
// the advisory feeds are keyed on exactly this form.
type Package struct {
	Name       string
	Version    string
	Epoch      int
	Arch       string
	SourceName string
}

// ParseDpkg reads tab-separated output of
// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n'
func ParseDpkg(out string) []Package {
	var pkgs []Package
	for _, line := range strings.Split(out, "\n") {
		if strings.TrimSpace(line) == "" {
			continue
		}
		f := strings.Split(line, "\t")
		if len(f) < 3 {
			continue
		}
		p := Package{Name: f[0], Version: f[1], Arch: f[2]}
		if len(f) > 3 && f[3] != "" {
			p.SourceName = f[3]
		} else {
			p.SourceName = p.Name
		}
		pkgs = append(pkgs, p)
	}
	return pkgs
}

// ParseRPM reads tab-separated output of
// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n'
func ParseRPM(out string) []Package {
	var pkgs []Package
	for _, line := range strings.Split(out, "\n") {
		if strings.TrimSpace(line) == "" {
			continue
		}
		f := strings.Split(line, "\t")
		if len(f) < 4 {
			continue
		}
		epoch := 0
		// rpm prints "(none)" rather than omitting the field when there is no
		// epoch, and that must become 0 rather than failing the line.
		if f[1] != "" && f[1] != "(none)" {
			if n, err := strconv.Atoi(f[1]); err == nil {
				epoch = n
			}
		}
		p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]}
		if len(f) > 4 {
			p.SourceName = srcRPMName(f[4])
		}
		if p.SourceName == "" {
			p.SourceName = p.Name
		}
		pkgs = append(pkgs, p)
	}
	return pkgs
}

// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping
// the trailing ".src.rpm" and then the version and release segments, which are
// the last two hyphen-separated fields.
func srcRPMName(s string) string {
	s = strings.TrimSuffix(s, ".src.rpm")
	parts := strings.Split(s, "-")
	if len(parts) <= 2 {
		return s
	}
	return strings.Join(parts[:len(parts)-2], "-")
}

// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line. Alpine
// has no separate source package, so SourceName mirrors Name.
func ParseAPK(out string) []Package {
	var pkgs []Package
	for _, line := range strings.Split(out, "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		name, version := splitAPK(line)
		if name == "" {
			continue
		}
		pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name})
	}
	return pkgs
}

// splitAPK finds the version boundary from the right. The version is always the
// last two hyphen-separated fields ("<version>-r<rev>"), which is reliable where
// scanning from the left is not: names legitimately contain digits, so "musl" in
// "musl-1.2.4_git20230717-r4" cannot be found by looking for the first digit.
func splitAPK(s string) (name, version string) {
	last := strings.LastIndex(s, "-")
	if last <= 0 {
		return "", ""
	}
	prev := strings.LastIndex(s[:last], "-")
	if prev <= 0 {
		return "", ""
	}
	return s[:prev], s[prev+1:]
}

// Hash fingerprints a package set so an unchanged set never has to be sent.
// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable,
// and an ordering-sensitive hash would resend the full list every hour.
func Hash(pkgs []Package) string {
	lines := make([]string, 0, len(pkgs))
	for _, p := range pkgs {
		lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch)
	}
	sort.Strings(lines)
	h := sha256.New()
	for _, l := range lines {
		h.Write([]byte(l))
		h.Write([]byte("\n"))
	}
	return hex.EncodeToString(h.Sum(nil))
}
```

- [ ] **Step 2: Write the collector**

Create `agent/internal/packages/packages.go`:

```go
package packages

import (
	"context"
	"fmt"
	"os/exec"
	"runtime"
	"time"
)

const collectTimeout = 2 * time.Minute

// Collect enumerates installed packages. Linux only: Windows agents are
// second-class by design and vulnerability scanning needs a different source,
// a different collector and a different matcher, all of which are out of scope.
func Collect() (OSRelease, []Package, error) {
	if runtime.GOOS != "linux" {
		return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS)
	}

	osrel, err := DetectOS()
	if err != nil {
		return OSRelease{}, nil, fmt.Errorf("detect os: %w", err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), collectTimeout)
	defer cancel()

	switch {
	case have("dpkg-query"):
		out, err := run(ctx, "dpkg-query", "-W", "-f",
			`${Package}\t${Version}\t${Architecture}\t${source:Package}\n`)
		if err != nil {
			return osrel, nil, err
		}
		return osrel, ParseDpkg(out), nil

	case have("rpm"):
		out, err := run(ctx, "rpm", "-qa", "--qf",
			`%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`)
		if err != nil {
			return osrel, nil, err
		}
		return osrel, ParseRPM(out), nil

	case have("apk"):
		out, err := run(ctx, "apk", "info", "-v")
		if err != nil {
			return osrel, nil, err
		}
		return osrel, ParseAPK(out), nil

	default:
		return osrel, nil, fmt.Errorf("no supported package manager found")
	}
}

func have(bin string) bool {
	_, err := exec.LookPath(bin)
	return err == nil
}

func run(ctx context.Context, name string, args ...string) (string, error) {
	out, err := exec.CommandContext(ctx, name, args...).Output()
	if err != nil {
		return "", fmt.Errorf("%s: %w", name, err)
	}
	return string(out), nil
}
```

- [ ] **Step 3: Verify against a real host**

On any Linux machine with Docker, confirm the command shapes produce what the parsers expect:

```bash
docker run --rm ubuntu:22.04 dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\n' | head -5
docker run --rm rockylinux:9 rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n' | head -5
docker run --rm alpine:3.19 apk info -v | head -5
```

Check specifically that the dpkg output's fourth column holds a source name, that rpm prints `(none)` for packages without an epoch, and that at least one Alpine line has a name containing a digit or underscore (e.g. `musl-1.2.4_git…-r4`) so `splitAPK`'s right-to-left approach is exercised.

- [ ] **Step 4: Build**

Run: `cd agent && go build ./... && go vet ./...`
Expected: exit 0.

- [ ] **Step 5: Commit**

```bash
git add agent/internal/packages/
git commit -m "feat: agent collects installed packages per package manager"
```

---

### Task 5: Models and indexes

**Files:**
- Create: `server/internal/models/packages.go`, `server/internal/models/vuln.go`, `server/internal/services/vulnindexes.go`
- Modify: `server/cmd/main.go` (schema setup, beside the other `Ensure*Indexes` calls)

**Interfaces:**
- Consumes: nothing.
- Produces: `models.ServerPackages`, `models.InstalledPackage`, `models.OSRelease`, `models.VulnFinding`, `models.Acceptance`, `models.VulnDBMeta`, `models.VulnAlertRule`, `services.EnsureVulnIndexes() error`.

- [ ] **Step 1: Write the package models**

Create `server/internal/models/packages.go`:

```go
package models

import (
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"
)

// Scan status values for ServerPackages.
const (
	ScanStatusOK          = "ok"
	ScanStatusUnsupported = "unsupported"
)

type OSRelease struct {
	Family    string `bson:"family" json:"family"`
	VersionID string `bson:"version_id" json:"version_id"`
	Arch      string `bson:"arch" json:"arch"`
}

type InstalledPackage struct {
	Name       string `bson:"name" json:"name"`
	Version    string `bson:"version" json:"version"`
	Epoch      int    `bson:"epoch,omitempty" json:"epoch,omitempty"`
	Arch       string `bson:"arch" json:"arch"`
	SourceName string `bson:"source_name,omitempty" json:"source_name,omitempty"`
}

// ServerPackages holds one server's whole package set in ONE document rather
// than one document per package. The hash has already established that
// something changed, so a report is a single atomic upsert with no delta logic
// to get wrong. A typical Linux host is ~2000 packages and ~150KB, comfortably
// inside the 16MB document limit.
type ServerPackages struct {
	ID          bson.ObjectID      `bson:"_id,omitempty" json:"-"`
	InstanceID  string             `bson:"instance_id" json:"-"`
	ServerID    string             `bson:"server_id" json:"server_id"`
	OS          OSRelease          `bson:"os" json:"os"`
	Hash        string             `bson:"hash" json:"hash"`
	Packages    []InstalledPackage `bson:"packages" json:"packages"`
	CollectedAt time.Time          `bson:"collected_at" json:"collected_at"`
	ScanPending bool               `bson:"scan_pending" json:"scan_pending"`
	ScannedAt   time.Time          `bson:"scanned_at,omitempty" json:"scanned_at,omitempty"`
	Status      string             `bson:"status" json:"status"`
	DBVersion   int                `bson:"db_version" json:"db_version"`
}
```

- [ ] **Step 2: Write the vulnerability models**

Create `server/internal/models/vuln.go`:

```go
package models

import (
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"
)

// Finding states.
const (
	FindingOpen     = "open"
	FindingFixed    = "fixed"
	FindingAccepted = "accepted"
)

// Severities, lowest to highest. SeverityRank orders them.
const (
	SeverityUnknown  = "unknown"
	SeverityLow      = "low"
	SeverityMedium   = "medium"
	SeverityHigh     = "high"
	SeverityCritical = "critical"
)

func SeverityRank(s string) int {
	switch s {
	case SeverityCritical:
		return 4
	case SeverityHigh:
		return 3
	case SeverityMedium:
		return 2
	case SeverityLow:
		return 1
	default:
		return 0
	}
}

// Acceptance records a decision someone will be asked to justify, so who,
// why and until when all live on the document as well as in the audit log.
type Acceptance struct {
	By     string    `bson:"by" json:"by"`
	Reason string    `bson:"reason" json:"reason"`
	Until  time.Time `bson:"until" json:"until"`
	At     time.Time `bson:"at" json:"at"`
}

// VulnFinding is one vulnerable package on one server.
//
// Findings are never deleted when a package is patched: the state moves to
// "fixed" with FixedAt stamped, which is what keeps "what did we remediate
// last quarter" answerable.
type VulnFinding struct {
	ID         bson.ObjectID `bson:"_id,omitempty" json:"id"`
	InstanceID string        `bson:"instance_id" json:"-"`
	ServerID   string        `bson:"server_id" json:"server_id"`

	CVEID       string `bson:"cve_id" json:"cve_id"`
	PackageName string `bson:"package_name" json:"package_name"`
	Installed   string `bson:"installed_version" json:"installed_version"`
	// FixedIn empty means no vendor fix has been published. That is a real and
	// common state and must never be conflated with "not vulnerable" — it is
	// the finding most in need of acceptance, since there is nothing to patch.
	FixedIn    string   `bson:"fixed_in,omitempty" json:"fixed_in,omitempty"`
	Severity   string   `bson:"severity" json:"severity"`
	CVSSScore  float64  `bson:"cvss_score,omitempty" json:"cvss_score,omitempty"`
	Title      string   `bson:"title,omitempty" json:"title,omitempty"`
	References []string `bson:"references,omitempty" json:"references,omitempty"`

	State     string      `bson:"state" json:"state"`
	FirstSeen time.Time   `bson:"first_seen" json:"first_seen"`
	LastSeen  time.Time   `bson:"last_seen" json:"last_seen"`
	FixedAt   *time.Time  `bson:"fixed_at,omitempty" json:"fixed_at,omitempty"`
	Accepted  *Acceptance `bson:"accepted,omitempty" json:"accepted,omitempty"`
}

// VulnDBMeta is a singleton and deliberately carries no instance_id: the
// vulnerability database is a property of the deployment, not of a tenant.
// Same reasoning as the migrations collection.
type VulnDBMeta struct {
	ID             bson.ObjectID `bson:"_id,omitempty" json:"-"`
	DBVersion      int           `bson:"db_version" json:"db_version"`
	PulledAt       time.Time     `bson:"pulled_at" json:"pulled_at"`
	LastFullScanAt time.Time     `bson:"last_full_scan_at,omitempty" json:"last_full_scan_at,omitempty"`
	LastError      string        `bson:"last_error,omitempty" json:"last_error,omitempty"`
}

type VulnAlertRule struct {
	ID          bson.ObjectID     `bson:"_id,omitempty" json:"id"`
	InstanceID  string            `bson:"instance_id" json:"-"`
	Name        string            `bson:"name" json:"name"`
	Enabled     bool              `bson:"enabled" json:"enabled"`
	MinSeverity string            `bson:"min_severity" json:"min_severity"`
	Tags        map[string]string `bson:"tags,omitempty" json:"tags,omitempty"`
	ChannelIDs  []string          `bson:"channel_ids" json:"channel_ids"`
	CreatedAt   time.Time         `bson:"created_at" json:"created_at"`
	UpdatedAt   time.Time         `bson:"updated_at" json:"updated_at"`
}
```

- [ ] **Step 3: Write the index builder**

Create `server/internal/services/vulnindexes.go`:

```go
package services

import (
	"context"
	"log"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// EnsureVulnIndexes declares the indexes for package inventory and findings.
//
// It warns rather than being fatal, matching EnsureSecretIndexes and
// EnsureWorkflowIndexes: a missing index degrades these queries to a collection
// scan, which is no reason to refuse to serve the fleet.
func EnsureVulnIndexes() error {
	ctx := context.Background()

	pkgIdx := []mongo.IndexModel{
		{
			Keys:    bson.D{{Key: "instance_id", Value: 1}, {Key: "server_id", Value: 1}},
			Options: options.Index().SetUnique(true),
		},
		// Multikey, for fleet-wide package search: "who runs openssl 3.0.2?"
		{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "packages.name", Value: 1}}},
		{Keys: bson.D{{Key: "scan_pending", Value: 1}}},
	}
	if _, err := db.Col("server_packages").Indexes().CreateMany(ctx, pkgIdx); err != nil {
		log.Printf("warning: server_packages indexes: %v", err)
	}

	findingIdx := []mongo.IndexModel{
		{
			// This key is what makes a rescan an idempotent upsert rather than
			// a duplicate factory, and what lets first_seen survive a rescan.
			Keys: bson.D{
				{Key: "instance_id", Value: 1},
				{Key: "server_id", Value: 1},
				{Key: "cve_id", Value: 1},
				{Key: "package_name", Value: 1},
			},
			Options: options.Index().SetUnique(true),
		},
		{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "state", Value: 1}, {Key: "severity", Value: 1}}},
		{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "cve_id", Value: 1}}},
	}
	if _, err := db.Col("vuln_findings").Indexes().CreateMany(ctx, findingIdx); err != nil {
		log.Printf("warning: vuln_findings indexes: %v", err)
	}

	if _, err := db.Col("vuln_alert_rules").Indexes().CreateOne(ctx, mongo.IndexModel{
		Keys: bson.D{{Key: "instance_id", Value: 1}},
	}); err != nil {
		log.Printf("warning: vuln_alert_rules indexes: %v", err)
	}

	return nil
}
```

- [ ] **Step 4: Wire it into schema setup**

In `server/cmd/main.go`, find `runSchemaSetup` (near line 75) and the block calling the other `Ensure*Indexes` functions. Add, alongside the non-fatal ones:

```go
	if err := services.EnsureVulnIndexes(); err != nil {
		log.Printf("warning: vuln indexes: %v", err)
	}
```

- [ ] **Step 5: Build and commit**

Run: `cd server && go build ./... && go vet ./...`

```bash
git add server/internal/models/packages.go server/internal/models/vuln.go server/internal/services/vulnindexes.go server/cmd/main.go
git commit -m "feat: models and indexes for package inventory and CVE findings"
```

---

### Task 6: Proto and the hash handshake

**Files:**
- Modify: `proto/vantage/v1/vantage.proto`
- Regenerate: `agent/internal/grpc/pb/`, `server/internal/grpc/pb/`

**Interfaces:**
- Consumes: nothing.
- Produces: `pb.ReportPackagesRequest`, `pb.ReportPackagesResponse{NeedFull bool}`, `pb.InstalledPackage`, `pb.OSRelease`, `SyncResponse.CollectPackages bool`.

- [ ] **Step 1: Add the RPC and messages**

In `proto/vantage/v1/vantage.proto`, add to the `Vantage` service block:

```protobuf
  rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
```

And these messages at the end of the file:

```protobuf
// ReportPackages carries a server's installed package set.
//
// The agent calls twice at most. The first call sends only the hash; if the
// server already holds that hash it answers need_full = false and the ~150KB
// body is never sent. A machine's package set changes rarely, so almost every
// hour costs one small message.
message ReportPackagesRequest {
  string server_id   = 1;
  string agent_token = 2;
  string hash        = 3;
  OSRelease os       = 4;
  repeated InstalledPackage packages = 5;  // empty on the offer call
}

message ReportPackagesResponse {
  bool need_full = 1;
}

message OSRelease {
  string family     = 1;
  string version_id = 2;
  string arch       = 3;
}

message InstalledPackage {
  string name        = 1;
  string version     = 2;
  int32  epoch       = 3;
  string arch        = 4;
  string source_name = 5;
}
```

- [ ] **Step 2: Add the collect flag to SyncResponse**

Find `message SyncResponse` and add a field using the next free number **in that message** — check the file, do not reuse a number:

```protobuf
  // collect_packages tells the agent whether this instance's licence grants
  // vulnerability scanning. False means do not collect at all: no gRPC body,
  // no document, no storage. The server re-checks on ReportPackages — this
  // flag is the optimisation, the server check is the boundary.
  bool collect_packages = <next free number>;
```

- [ ] **Step 3: Regenerate**

Run the project's existing protoc generation command. Find it with:

```bash
grep -rn "protoc" --include=Makefile --include="*.sh" --include="*.yml" . | head
```

Generated output must land in both `agent/internal/grpc/pb/` and `server/internal/grpc/pb/`.

- [ ] **Step 4: Build both modules and commit**

Run: `cd agent && go build ./... && cd ../server && go build ./...`

```bash
git add proto/ agent/internal/grpc/pb/ server/internal/grpc/pb/
git commit -m "feat: ReportPackages RPC with hash short-circuit"
```

---

### Task 7: Agent reports packages

**Files:**
- Modify: `agent/internal/sync/sync.go:370-411` (`runUpdateCheck`), `agent/internal/grpc/client.go`

**Interfaces:**
- Consumes: `packages.Collect`, `packages.Hash` (Task 4); `pb.ReportPackagesRequest` (Task 6).
- Produces: `grpcclient.Client.ReportPackages(req *pb.ReportPackagesRequest) (bool, error)` returning `need_full`.

- [ ] **Step 1: Add the client method**

In `agent/internal/grpc/client.go`, following the shape of the existing `ReportUpdates`:

```go
// ReportPackages sends a package report and returns whether the server wants
// the full list.
func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	resp, err := c.client.ReportPackages(ctx, req)
	if err != nil {
		return false, err
	}
	return resp.GetNeedFull(), nil
}
```

Match the surrounding file's timeout and context conventions if they differ.

- [ ] **Step 2: Extend the hourly loop**

In `agent/internal/sync/sync.go`, inside `runUpdateCheck`'s `doCheck` closure, after the existing `client.ReportUpdates(...)` call succeeds:

```go
		reportPackages(client, cfg)
```

Then add to the same file:

```go
// reportPackages offers a hash of the installed package set and sends the full
// list only if the server does not already hold it. It runs on the same hourly
// cadence as the update check because a package set changes on roughly the same
// schedule, and reusing the loop means one timer rather than two.
func reportPackages(client *grpcclient.Client, cfg *config.Config) {
	if runtime.GOOS != "linux" {
		return
	}
	if !collectPackagesEnabled() {
		return
	}

	osrel, pkgs, err := packages.Collect()
	if err != nil {
		log.Printf("package collection error: %v", err)
		return
	}

	pbOS := &pb.OSRelease{
		Family:    osrel.Family,
		VersionId: osrel.VersionID,
		Arch:      osrel.Arch,
	}
	hash := packages.Hash(pkgs)

	needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{
		ServerId:   cfg.ServerID,
		AgentToken: cfg.AgentToken,
		Hash:       hash,
		Os:         pbOS,
	})
	if err != nil {
		log.Printf("ReportPackages offer error: %v", err)
		return
	}
	if !needFull {
		return
	}

	pbPkgs := make([]*pb.InstalledPackage, 0, len(pkgs))
	for _, p := range pkgs {
		pbPkgs = append(pbPkgs, &pb.InstalledPackage{
			Name:       p.Name,
			Version:    p.Version,
			Epoch:      int32(p.Epoch),
			Arch:       p.Arch,
			SourceName: p.SourceName,
		})
	}

	if _, err := client.ReportPackages(&pb.ReportPackagesRequest{
		ServerId:   cfg.ServerID,
		AgentToken: cfg.AgentToken,
		Hash:       hash,
		Os:         pbOS,
		Packages:   pbPkgs,
	}); err != nil {
		log.Printf("ReportPackages full error: %v", err)
		return
	}
	log.Printf("reported %d installed packages", len(pkgs))
}
```

- [ ] **Step 3: Store the collect flag from SyncKeys**

`SyncResponse.collect_packages` arrives on the 30s key poll, which is a different goroutine from the hourly loop — hence the atomic:

```go
var collectPackagesFlag atomic.Bool

func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
```

In `poll()` (around line 94), after a successful `SyncKeys` response:

```go
	collectPackagesFlag.Store(resp.GetCollectPackages())
```

Add `"sync/atomic"`, `"runtime"` and the `packages` import to the file's import block.

- [ ] **Step 4: Build and commit**

Run: `cd agent && go build ./... && go vet ./...`

```bash
git add agent/internal/sync/sync.go agent/internal/grpc/client.go
git commit -m "feat: agent reports installed packages on the hourly loop"
```

---

### Task 8: Server stores package reports

**Files:**
- Create: `server/internal/services/packages.go`
- Modify: `server/internal/grpc/server.go`

**Interfaces:**
- Consumes: `models.ServerPackages` (Task 5), `pb.ReportPackagesRequest` (Task 6).
- Produces: `services.HasPackageHash`, `services.StorePackages`, `services.ListPackages`, `services.SearchPackages`, `services.VulnScanningEnabled`.

**Critical:** `StorePackages` must **not** match. It upserts and sets `scan_pending: true`. Matching inline would require every replica to hold the 50MB database and would have N replicas racing on a database refresh.

- [ ] **Step 1: Write the service**

Create `server/internal/services/packages.go`:

```go
package services

import (
	"context"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// HasPackageHash reports whether we already hold this exact package set, which
// is what lets the agent skip sending ~150KB it has already sent.
func HasPackageHash(instanceID, serverID, hash string) (bool, error) {
	err := db.Col("server_packages").FindOne(context.Background(), bson.M{
		"instance_id": instanceID,
		"server_id":   serverID,
		"hash":        hash,
	}, options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
	if err == mongo.ErrNoDocuments {
		return false, nil
	}
	return err == nil, err
}

// StorePackages replaces a server's package set and marks it for scanning.
//
// It deliberately does NOT match against the vulnerability database. Matching
// happens in vulnsched, on the leader, for two reasons: every replica would
// otherwise need the ~50MB database resident, and a database refresh would have
// N replicas racing to rescan the same fleet and sending N digests.
func StorePackages(instanceID, serverID string, os models.OSRelease, hash string, pkgs []models.InstalledPackage) error {
	now := time.Now()
	_, err := db.Col("server_packages").UpdateOne(context.Background(),
		bson.M{"instance_id": instanceID, "server_id": serverID},
		bson.M{"$set": bson.M{
			"os":           os,
			"hash":         hash,
			"packages":     pkgs,
			"collected_at": now,
			"scan_pending": true,
		}},
		options.UpdateOne().SetUpsert(true),
	)
	return err
}

func ListPackages(instanceID, serverID string) (*models.ServerPackages, error) {
	var sp models.ServerPackages
	err := db.Col("server_packages").FindOne(context.Background(), bson.M{
		"instance_id": instanceID,
		"server_id":   serverID,
	}).Decode(&sp)
	if err == mongo.ErrNoDocuments {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return &sp, nil
}

type PackageHit struct {
	ServerID string `json:"server_id"`
	Name     string `json:"name"`
	Version  string `json:"version"`
}

// SearchPackages answers "which servers run package X" across the fleet — the
// question people actually ask during an incident.
func SearchPackages(instanceID, name string) ([]PackageHit, error) {
	ctx := context.Background()
	cur, err := db.Col("server_packages").Find(ctx, bson.M{
		"instance_id":   instanceID,
		"packages.name": name,
	}, options.Find().SetProjection(bson.M{"server_id": 1, "packages": 1}))
	if err != nil {
		return nil, err
	}
	defer cur.Close(ctx)

	var docs []models.ServerPackages
	if err := cur.All(ctx, &docs); err != nil {
		return nil, err
	}

	hits := []PackageHit{}
	for _, d := range docs {
		for _, p := range d.Packages {
			if p.Name == name {
				hits = append(hits, PackageHit{ServerID: d.ServerID, Name: p.Name, Version: p.Version})
			}
		}
	}
	return hits, nil
}

// VulnScanningEnabled reports whether this instance's licence grants
// vulnerability scanning. It reads the feature by name and never switches on
// tier, so changing what a tier includes needs no server release.
func VulnScanningEnabled(instanceID string) bool {
	lic, err := GetLicense(instanceID)
	if err != nil || lic == nil {
		return false
	}
	return lic.HasFeature("vuln_scanning")
}
```

**Before building:** find the existing licence accessor in `server/internal/services/licence.go` — the one `RequireActiveLicense` in `server/internal/api/licence.go` uses — and call **that** rather than assuming `GetLicense` exists. Adjust the name and signature to match.

- [ ] **Step 2: Add the gRPC handler**

In `server/internal/grpc/server.go`, following the shape of the existing `ReportUpdates` (near line 87):

```go
func (s *vantageServer) ReportPackages(ctx context.Context, req *pb.ReportPackagesRequest) (*pb.ReportPackagesResponse, error) {
	srv, err := services.ValidateAgentToken(req.GetServerId(), req.GetAgentToken())
	if err != nil {
		return nil, status.Error(codes.Unauthenticated, "invalid agent credentials")
	}

	// The agent flag is an optimisation; this check is the boundary.
	if !services.VulnScanningEnabled(srv.InstanceID) {
		return &pb.ReportPackagesResponse{NeedFull: false}, nil
	}

	// The offer call: no packages, just a hash.
	if len(req.GetPackages()) == 0 {
		known, err := services.HasPackageHash(srv.InstanceID, req.GetServerId(), req.GetHash())
		if err != nil {
			return nil, status.Error(codes.Internal, "package hash lookup failed")
		}
		return &pb.ReportPackagesResponse{NeedFull: !known}, nil
	}

	pkgs := make([]models.InstalledPackage, 0, len(req.GetPackages()))
	for _, p := range req.GetPackages() {
		pkgs = append(pkgs, models.InstalledPackage{
			Name:       p.GetName(),
			Version:    p.GetVersion(),
			Epoch:      int(p.GetEpoch()),
			Arch:       p.GetArch(),
			SourceName: p.GetSourceName(),
		})
	}

	os := models.OSRelease{
		Family:    req.GetOs().GetFamily(),
		VersionID: req.GetOs().GetVersionId(),
		Arch:      req.GetOs().GetArch(),
	}

	if err := services.StorePackages(srv.InstanceID, req.GetServerId(), os, req.GetHash(), pkgs); err != nil {
		return nil, status.Error(codes.Internal, "failed to store packages")
	}
	return &pb.ReportPackagesResponse{NeedFull: false}, nil
}
```

Match the neighbouring handlers exactly on how `ValidateAgentToken` is called and what it returns in this file.

- [ ] **Step 3: Build and commit**

Run: `cd server && go build ./... && go vet ./...`

```bash
git add server/internal/services/packages.go server/internal/grpc/server.go
git commit -m "feat: store agent package reports and mark them for scanning"
```

---

### Task 9: Serve the collect flag from SyncKeys

**Files:**
- Modify: `server/internal/grpc/server.go` — `SyncKeys` (near line 47)

- [ ] **Step 1: Set the flag**

In `SyncKeys`, where the `SyncResponse` is constructed, add — using whatever the response variable is actually called:

```go
	resp.CollectPackages = services.VulnScanningEnabled(srv.InstanceID)
```

- [ ] **Step 2: Build and commit**

Run: `cd server && go build ./...`

```bash
git add server/internal/grpc/server.go
git commit -m "feat: tell agents whether to collect packages via SyncKeys"
```

---

### Task 10: trivy-db puller and advisory lookup

**Files:**
- Create: `server/internal/vulndb/pull.go`, `server/internal/vulndb/db.go`

**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `vulndb.Pull(ctx, dir string) (version int, err error)`, `vulndb.Open(dir string) (*Store, error)`, `(*Store).Advisories(bucket, srcName string) ([]Advisory, error)`, `(*Store).Vulnerability(cveID string) (VulnInfo, error)`, `(*Store).Close() error`, `vulndb.Ref()`, `vulndb.Disabled()`.

- [ ] **Step 1: Write the puller skeleton**

Create `server/internal/vulndb/pull.go`:

```go
package vulndb

import (
	"archive/tar"
	"compress/gzip"
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

// DefaultRef is the published trivy-db OCI artifact, rebuilt every six hours.
const DefaultRef = "ghcr.io/aquasecurity/trivy-db:2"

// SupportedSchema is the trivy-db schema version this code understands.
const SupportedSchema = 2

// Ref returns the artifact reference, honouring VANTAGE_TRIVY_DB_REF so an
// air-gapped deployment can mirror the artifact into its own registry, and so
// a busy deployment can avoid the anonymous ghcr rate limit.
func Ref() string {
	if v := os.Getenv("VANTAGE_TRIVY_DB_REF"); v != "" {
		return v
	}
	return DefaultRef
}

// Disabled reports whether the puller and scheduler are switched off entirely.
// Findings already written are still served, and still marked stale.
func Disabled() bool {
	return strings.EqualFold(os.Getenv("VANTAGE_VULNDB_DISABLED"), "true")
}

// Pull fetches the trivy-db artifact into dir and returns the schema version
// recorded in its metadata.
//
// Implementation requirements:
//   - Use oras.land/oras-go/v2 with a remote repository for Ref().
//   - The artifact has a single layer: a gzipped tar containing "db/trivy.db"
//     and "db/metadata.json".
//   - Extract both into dir via extractTarGz below, writing to a temp path and
//     renaming into place, so a failed pull cannot leave a half-written
//     database that Open would accept.
//   - Read metadata.json for "Version" (an int) and return it.
//   - REFUSE a version other than SupportedSchema rather than mis-parsing it.
func Pull(ctx context.Context, dir string) (int, error) {
	return 0, fmt.Errorf("not implemented")
}

// extractTarGz writes the artifact layer into dir. Paths are flattened and
// checked so a crafted archive cannot write outside dir.
func extractTarGz(r io.Reader, dir string) error {
	gz, err := gzip.NewReader(r)
	if err != nil {
		return err
	}
	defer gz.Close()

	tr := tar.NewReader(gz)
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		if hdr.Typeflag != tar.TypeReg {
			continue
		}
		name := filepath.Base(hdr.Name) // flatten; the archive is two files
		if name == "." || name == ".." || name == "" {
			continue
		}
		dst := filepath.Join(dir, name)
		if !strings.HasPrefix(dst, filepath.Clean(dir)+string(os.PathSeparator)) {
			return fmt.Errorf("archive entry escapes destination: %q", hdr.Name)
		}
		f, err := os.Create(dst)
		if err != nil {
			return err
		}
		if _, err := io.Copy(f, tr); err != nil {
			f.Close()
			return err
		}
		if err := f.Close(); err != nil {
			return err
		}
	}
}
```

- [ ] **Step 2: Implement `Pull` with oras**

Replace the `Pull` body following the requirements in its doc comment. The exact `oras-go` v2 call shape depends on the version `go get` resolved, so write it against the resolved API rather than from memory.

- [ ] **Step 3: Verify the pull by hand**

This is the one piece that cannot be verified by reading. From a scratch `main.go`:

```go
dir, _ := os.MkdirTemp("", "trivydb-")
v, err := vulndb.Pull(context.Background(), dir)
fmt.Println(v, err)
// then: ls the dir — expect trivy.db and metadata.json
```

Confirm the returned version equals `SupportedSchema`, both files exist, and `trivy.db` is tens of MB rather than a few bytes. Delete the scratch file afterwards.

- [ ] **Step 4: Write the advisory store**

Create `server/internal/vulndb/db.go`:

```go
package vulndb

import (
	trivydb "github.com/aquasecurity/trivy-db/pkg/db"
)

// Advisory is one fixed-version statement for one source package.
type Advisory struct {
	CVEID string
	// FixedVersion empty means no vendor fix has been published. It is a real
	// state, not an absence of data, and callers must treat it as vulnerable.
	FixedVersion string
	Severity     string
}

// VulnInfo is the CVE's own metadata, shared across every server it affects.
type VulnInfo struct {
	Title      string
	Severity   string
	CVSSScore  float64
	References []string
}

// Store reads a pulled trivy-db.
type Store struct {
	cfg trivydb.Config
}

// Open opens the database in dir read-only.
func Open(dir string) (*Store, error) {
	if err := trivydb.Init(dir); err != nil {
		return nil, err
	}
	return &Store{cfg: trivydb.Config{}}, nil
}

func (s *Store) Close() error { return trivydb.Close() }

// Advisories returns every advisory for a source package in a bucket.
func (s *Store) Advisories(bucket, srcName string) ([]Advisory, error) {
	raw, err := s.cfg.GetAdvisories(bucket, srcName)
	if err != nil {
		return nil, err
	}
	out := make([]Advisory, 0, len(raw))
	for _, a := range raw {
		out = append(out, Advisory{
			CVEID:        a.VulnerabilityID,
			FixedVersion: a.FixedVersion,
			Severity:     severityName(int(a.Severity)),
		})
	}
	return out, nil
}

// Vulnerability returns a CVE's shared metadata.
func (s *Store) Vulnerability(cveID string) (VulnInfo, error) {
	v, err := s.cfg.GetVulnerability(cveID)
	if err != nil {
		return VulnInfo{}, err
	}
	return VulnInfo{
		Title:      v.Title,
		Severity:   severityName(int(v.Severity)),
		References: v.References,
	}, nil
}

// severityName maps trivy-db's integer severity onto our lowercase strings.
//
// Severity resolves vendor → NVD → unknown and is never invented. This will
// surface as "why is this critical CVE marked low": Debian and Red Hat
// routinely downgrade an NVD score because the vulnerable path is not
// reachable in their build, and their rating is the accurate one for that
// package.
func severityName(n int) string {
	switch n {
	case 4:
		return "critical"
	case 3:
		return "high"
	case 2:
		return "medium"
	case 1:
		return "low"
	default:
		return "unknown"
	}
}
```

Verify `GetAdvisories`, `GetVulnerability`, `Init` and `Close` against the version `go get` actually resolved. Adjust the wrappers, **not** the `Advisory`/`VulnInfo` shapes, which later tasks depend on.

- [ ] **Step 5: Build and commit**

Run: `cd server && go build ./...`

```bash
git add server/internal/vulndb/pull.go server/internal/vulndb/db.go
git commit -m "feat: pull trivy-db and read its advisories"
```

---

### Task 11: The matcher

**Files:**
- Create: `server/internal/vulndb/match.go`

**Interfaces:**
- Consumes: `vulndb.LessThan` (Task 1), `vulndb.Bucket` (Task 2), `vulndb.Advisory` (Task 10), `models.InstalledPackage` (Task 5).
- Produces: `vulndb.AdvisorySource` (interface), `vulndb.Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error)`, `vulndb.Result`.

- [ ] **Step 1: Write the implementation**

Create `server/internal/vulndb/match.go`:

```go
package vulndb

import (
	"fmt"
	"log"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)

// AdvisorySource is the advisory lookup the matcher needs. *Store satisfies it.
// The seam keeps the matching logic independent of how the database is opened.
type AdvisorySource interface {
	Advisories(bucket, srcName string) ([]Advisory, error)
}

// Result is one vulnerable package on one server, before it becomes a finding.
type Result struct {
	CVEID       string
	PackageName string // the BINARY package, which is what is installed
	Installed   string
	FixedIn     string
	Severity    string
}

// Match returns every advisory that the installed packages do not satisfy.
//
// Vulnerable means: no fix has been published, or the installed version sorts
// strictly before the fixed version under the distribution's own ordering.
// Equal is NOT vulnerable — that is the backported-fix case, where a
// distribution patches in place without changing the upstream version, and
// treating it as vulnerable reports a patched fleet as exposed.
func Match(src AdvisorySource, os models.OSRelease, pkgs []models.InstalledPackage) ([]Result, error) {
	bucket, err := Bucket(os.Family, os.VersionID)
	if err != nil {
		return nil, err
	}

	var out []Result
	for _, p := range pkgs {
		// Debian and Ubuntu advisories are keyed on the source package: one
		// advisory against "openssl" covers libssl3, openssl and libssl-dev.
		srcName := p.SourceName
		if srcName == "" {
			srcName = p.Name
		}

		advs, err := src.Advisories(bucket, srcName)
		if err != nil {
			return nil, fmt.Errorf("advisories for %s: %w", srcName, err)
		}

		for _, a := range advs {
			// No published fix. Vulnerable, and the finding most in need of
			// acceptance, since there is nothing to patch.
			if a.FixedVersion == "" {
				out = append(out, Result{
					CVEID: a.CVEID, PackageName: p.Name,
					Installed: p.Version, Severity: a.Severity,
				})
				continue
			}

			older, err := LessThan(os.Family, p.Version, a.FixedVersion)
			if err != nil {
				// Skip this one advisory rather than failing the whole server:
				// one unparseable version must not blind us to every other CVE
				// on the host. Log it — a silent skip is a silent false
				// negative, which is the direction that hurts.
				log.Printf("vulndb: compare %s %s vs %s: %v", p.Name, p.Version, a.FixedVersion, err)
				continue
			}
			if older {
				out = append(out, Result{
					CVEID: a.CVEID, PackageName: p.Name,
					Installed: p.Version, FixedIn: a.FixedVersion, Severity: a.Severity,
				})
			}
		}
	}
	return out, nil
}
```

- [ ] **Step 2: Verify the matching by hand**

From a scratch `main.go`, define a small in-memory `AdvisorySource` and confirm each case. **These are the behaviours that silently produce wrong answers.**

```go
type fake map[string][]vulndb.Advisory
func (f fake) Advisories(bucket, src string) ([]vulndb.Advisory, error) {
    return f[bucket+"\x00"+src], nil
}
```

| Setup | Expected |
| ----- | -------- |
| advisory `openssl` fixed `1:3.0.2-0ubuntu1.15`; installed `libssl3` at `1:3.0.2-0ubuntu1.15`, source `openssl`, ubuntu 22.04 | **0 results** — the backport case |
| same advisory; installed at `1:3.0.2-0ubuntu1.14` | 1 result, `PackageName` = `libssl3` (the binary, not the source), `FixedIn` set |
| same advisory; three binaries `libssl3`, `openssl`, `libssl-dev` all at `…1.14`, all source `openssl` | **3 results** — one advisory covers every binary of the source |
| advisory with `FixedVersion: ""` on `bash` | 1 result with empty `FixedIn` |
| os family `arch` | error wrapping `ErrUnsupportedFamily` |
| alpine 3.19.1, package `openssl` at `3.1.4-r5`, **no** `SourceName` set, advisory fixed `3.1.4-r6` | 1 result — the fallback to `Name` works |

Delete the scratch file afterwards.

- [ ] **Step 3: Build and commit**

Run: `cd server && go build ./...`

```bash
git add server/internal/vulndb/match.go
git commit -m "feat: match installed packages against distro advisories"
```

---

### Task 12: Finding state machine

**Files:**
- Create: `server/internal/services/findings.go`

**Interfaces:**
- Consumes: `vulndb.Result` (Task 11), `models.VulnFinding` (Task 5).
- Produces: `services.DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now time.Time) FindingDiff`, `services.FindingDiff`, `services.ApplyFindingDiff`, `services.ListFindings`.

- [ ] **Step 1: Write the pure diff**

Create `server/internal/services/findings.go`:

```go
package services

import (
	"context"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulndb"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// FindingDiff is what one server's scan changes.
type FindingDiff struct {
	Upserts   []models.VulnFinding
	FixedIDs  []bson.ObjectID
	ReopenIDs []bson.ObjectID
	// NewlyOpened is what the digest reports: findings that were not open
	// before this scan. A finding that was already open must not re-alert every
	// tick, or the digest becomes noise and stops being read.
	NewlyOpened []models.VulnFinding
}

func findingKey(cveID, pkg string) string { return cveID + "\x00" + pkg }

// DiffFindings computes the state changes for one server's scan.
//
// Pure by design: no database, no clock of its own. The ordering below is
// load-bearing — see the comment above the second loop.
func DiffFindings(existing []models.VulnFinding, results []vulndb.Result, now time.Time) FindingDiff {
	var d FindingDiff

	byKey := make(map[string]models.VulnFinding, len(existing))
	for _, f := range existing {
		byKey[findingKey(f.CVEID, f.PackageName)] = f
	}

	seen := make(map[string]bool, len(results))
	for _, r := range results {
		key := findingKey(r.CVEID, r.PackageName)
		seen[key] = true

		prev, had := byKey[key]

		f := models.VulnFinding{
			CVEID:       r.CVEID,
			PackageName: r.PackageName,
			Installed:   r.Installed,
			FixedIn:     r.FixedIn,
			Severity:    r.Severity,
			State:       models.FindingOpen,
			FirstSeen:   now,
			LastSeen:    now,
		}

		if had {
			f.ID = prev.ID
			// Preserved, never overwritten: an upsert that moves first_seen
			// forward makes every finding look discovered today.
			f.FirstSeen = prev.FirstSeen

			// A live acceptance survives the scan untouched: it is suppressed
			// from counts and alerts until its expiry, then reopens on its own.
			if prev.State == models.FindingAccepted && prev.Accepted != nil {
				if now.Before(prev.Accepted.Until) {
					continue
				}
				d.ReopenIDs = append(d.ReopenIDs, prev.ID)
				continue
			}

			if prev.State != models.FindingOpen {
				d.NewlyOpened = append(d.NewlyOpened, f)
			}
		} else {
			d.NewlyOpened = append(d.NewlyOpened, f)
		}

		d.Upserts = append(d.Upserts, f)
	}

	// Anything we hold that this scan did not produce is fixed. This runs AFTER
	// the loop above, and the ordering matters: a finding that is both absent
	// and past its acceptance expiry must settle as fixed rather than reopening
	// on a package that no longer carries it.
	for _, f := range existing {
		if seen[findingKey(f.CVEID, f.PackageName)] {
			continue
		}
		if f.State == models.FindingFixed {
			continue
		}
		d.FixedIDs = append(d.FixedIDs, f.ID)
	}

	return d
}
```

- [ ] **Step 2: Add the persistence half**

Append to the same file:

```go
// ListFindings returns every finding held for one server.
func ListFindings(ctx context.Context, instanceID, serverID string) ([]models.VulnFinding, error) {
	cur, err := db.Col("vuln_findings").Find(ctx, bson.M{
		"instance_id": instanceID,
		"server_id":   serverID,
	})
	if err != nil {
		return nil, err
	}
	defer cur.Close(ctx)

	var out []models.VulnFinding
	if err := cur.All(ctx, &out); err != nil {
		return nil, err
	}
	return out, nil
}

// ApplyFindingDiff writes a diff. Thin on purpose — the logic worth reading
// twice is all in DiffFindings.
func ApplyFindingDiff(ctx context.Context, instanceID, serverID string, d FindingDiff, now time.Time) error {
	col := db.Col("vuln_findings")

	for _, f := range d.Upserts {
		_, err := col.UpdateOne(ctx,
			bson.M{
				"instance_id":  instanceID,
				"server_id":    serverID,
				"cve_id":       f.CVEID,
				"package_name": f.PackageName,
			},
			bson.M{
				"$set": bson.M{
					"installed_version": f.Installed,
					"fixed_in":          f.FixedIn,
					"severity":          f.Severity,
					"state":             models.FindingOpen,
					"last_seen":         now,
				},
				// first_seen is written only on insert, so a rescan cannot move
				// it forward.
				"$setOnInsert": bson.M{
					"instance_id":  instanceID,
					"server_id":    serverID,
					"cve_id":       f.CVEID,
					"package_name": f.PackageName,
					"first_seen":   f.FirstSeen,
				},
				"$unset": bson.M{"fixed_at": "", "accepted": ""},
			},
			options.UpdateOne().SetUpsert(true),
		)
		if err != nil {
			return err
		}
	}

	if len(d.FixedIDs) > 0 {
		if _, err := col.UpdateMany(ctx,
			bson.M{"_id": bson.M{"$in": d.FixedIDs}},
			bson.M{"$set": bson.M{"state": models.FindingFixed, "fixed_at": now}},
		); err != nil {
			return err
		}
	}

	if len(d.ReopenIDs) > 0 {
		if _, err := col.UpdateMany(ctx,
			bson.M{"_id": bson.M{"$in": d.ReopenIDs}},
			bson.M{"$set": bson.M{"state": models.FindingOpen, "last_seen": now}, "$unset": bson.M{"accepted": ""}},
		); err != nil {
			return err
		}
	}

	return nil
}
```

- [ ] **Step 3: Read the diff back against these cases**

No test, so verify by reading. Walk the function with each and confirm the branch taken:

| Input | Expected |
| ----- | -------- |
| no existing, one result | one upsert, state `open`, one `NewlyOpened` |
| existing open with `first_seen` 30 days ago, same result | one upsert with the **old** `first_seen`, `LastSeen` = now, **zero** `NewlyOpened` |
| existing open, **no** results | one `FixedIDs` entry, zero upserts |
| existing accepted with `until` in the past, still in results | one `ReopenIDs` entry |
| existing accepted with `until` in the future, still in results | zero `ReopenIDs`, zero `NewlyOpened` |
| existing accepted with `until` in the past, **not** in results | one `FixedIDs` entry, **zero** `ReopenIDs` |

The last row is the ordering trap: it only holds because the fixed loop runs after the results loop.

- [ ] **Step 4: Build and commit**

Run: `cd server && go build ./... && go vet ./...`

```bash
git add server/internal/services/findings.go
git commit -m "feat: finding state machine with acceptance expiry"
```

---

### Task 13: The scheduler

**Files:**
- Create: `server/internal/vulnsched/sched.go`
- Modify: `server/cmd/main.go:184-206`

**Interfaces:**
- Consumes: `vulndb.Pull`, `vulndb.Open`, `vulndb.Match`, `services.DiffFindings`, `services.ApplyFindingDiff`, `services.ListFindings`.
- Produces: `vulnsched.Start(ctx, deps Deps)`, `vulnsched.Deps`.

**Read `server/internal/workflowsched/sched.go` first.** Same `Start`/`tick` shape, same `Deps` injection, same "return when the context is cancelled" contract.

- [ ] **Step 1: Write the scheduler**

Create `server/internal/vulnsched/sched.go`:

```go
// Package vulnsched owns the vulnerability scan loop.
//
// It runs inside bus.RunAsLeader("housekeeping", …) alongside monitorsched,
// workflowsched and the sweepers: one role, one lock. N replicas each running
// this loop would mean N copies of the ~50MB database resident, N rescans of
// the same fleet on every database refresh, and N digests reaching the
// customer for one set of findings.
package vulnsched

import (
	"context"
	"errors"
	"log"
	"os"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/vulndb"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

const (
	tickInterval = 60 * time.Second
	// trivy-db is rebuilt every six hours; pulling more often buys nothing.
	dbMaxAge = 6 * time.Hour
)

// Deps are injected from main.go rather than imported, following
// workflowsched. It keeps this package's reach explicit and reviewable.
type Deps struct {
	LogEvent   func(instanceID, eventType, actor, serverID, keyID, details string)
	SendDigest func(instanceID string, newly []models.VulnFinding)
}

type scheduler struct {
	deps    Deps
	dir     string
	store   *vulndb.Store
	version int
	pulled  time.Time
}

func Start(ctx context.Context, deps Deps) {
	if vulndb.Disabled() {
		log.Println("vulnsched: disabled by VANTAGE_VULNDB_DISABLED")
		return
	}

	dir, err := os.MkdirTemp("", "vantage-vulndb-")
	if err != nil {
		log.Printf("vulnsched: temp dir: %v", err)
		return
	}

	s := &scheduler{deps: deps, dir: dir}

	go func() {
		defer os.RemoveAll(dir)
		defer s.closeStore()

		ticker := time.NewTicker(tickInterval)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				s.tick(ctx)
			}
		}
	}()
}

func (s *scheduler) tick(ctx context.Context) {
	if err := s.ensureDB(ctx); err != nil {
		// Keep the last good database and carry on scanning against it. A
		// network blip must never clear findings or read as "all fixed".
		log.Printf("vulnsched: database unavailable: %v", err)
		s.recordDBError(ctx, err)
		if s.store == nil {
			return
		}
	}
	s.scanPending(ctx)
}

// ensureDB pulls a fresh database when the local copy is stale, and marks the
// whole fleet for rescanning when the version changes — which is what makes a
// newly published CVE flag existing servers within a minute rather than at the
// next agent report.
func (s *scheduler) ensureDB(ctx context.Context) error {
	if s.store != nil && time.Since(s.pulled) < dbMaxAge {
		return nil
	}

	version, err := vulndb.Pull(ctx, s.dir)
	if err != nil {
		return err
	}

	s.closeStore()
	store, err := vulndb.Open(s.dir)
	if err != nil {
		return err
	}
	s.store = store
	s.pulled = time.Now()

	changed := version != s.version
	s.version = version

	_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
		bson.M{"$set": bson.M{"db_version": version, "pulled_at": s.pulled}, "$unset": bson.M{"last_error": ""}},
		options.UpdateOne().SetUpsert(true),
	)

	if changed {
		res, err := db.Col("server_packages").UpdateMany(ctx,
			bson.M{"status": bson.M{"$ne": models.ScanStatusUnsupported}},
			bson.M{"$set": bson.M{"scan_pending": true}},
		)
		if err != nil {
			log.Printf("vulnsched: mark fleet pending: %v", err)
		} else {
			log.Printf("vulnsched: database version %d, %d servers marked for rescan", version, res.ModifiedCount)
		}
	}
	return nil
}

func (s *scheduler) recordDBError(ctx context.Context, err error) {
	_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
		bson.M{"$set": bson.M{"last_error": err.Error()}},
		options.UpdateOne().SetUpsert(true),
	)
}

func (s *scheduler) scanPending(ctx context.Context) {
	cur, err := db.Col("server_packages").Find(ctx, bson.M{"scan_pending": true})
	if err != nil {
		log.Printf("vulnsched: find pending: %v", err)
		return
	}
	defer cur.Close(ctx)

	var pending []models.ServerPackages
	if err := cur.All(ctx, &pending); err != nil {
		log.Printf("vulnsched: decode pending: %v", err)
		return
	}

	// Newly opened findings are collected across the whole tick and sent as one
	// digest per instance. A database refresh can open several hundred findings
	// at once; one message per finding would rate-limit the webhook or get the
	// channel muted, and either way the alerts stop being read.
	newly := map[string][]models.VulnFinding{}

	for _, sp := range pending {
		if ctx.Err() != nil {
			// Leadership lost. scan_pending is still set, so the next leader
			// picks these up — which is why it lives on the document.
			return
		}
		opened := s.scanOne(ctx, sp)
		newly[sp.InstanceID] = append(newly[sp.InstanceID], opened...)
	}

	for instanceID, findings := range newly {
		if len(findings) > 0 && s.deps.SendDigest != nil {
			s.deps.SendDigest(instanceID, findings)
		}
	}

	_, _ = db.Col("vulndb_meta").UpdateOne(ctx, bson.M{},
		bson.M{"$set": bson.M{"last_full_scan_at": time.Now()}},
		options.UpdateOne().SetUpsert(true),
	)
}

func (s *scheduler) scanOne(ctx context.Context, sp models.ServerPackages) []models.VulnFinding {
	now := time.Now()

	results, err := vulndb.Match(s.store, sp.OS, sp.Packages)
	if err != nil {
		// We hold no feed for this distribution, so we cannot answer whether it
		// is vulnerable. Say "unsupported" — reporting zero findings here would
		// be indistinguishable from reporting a clean host, and one of those is
		// a lie.
		status := models.ScanStatusUnsupported
		if !errors.Is(err, vulndb.ErrUnsupportedFamily) {
			log.Printf("vulnsched: scan %s: %v", sp.ServerID, err)
			status = sp.Status
		}
		s.clearPending(ctx, sp.ID, status, now)
		return nil
	}

	existing, err := services.ListFindings(ctx, sp.InstanceID, sp.ServerID)
	if err != nil {
		log.Printf("vulnsched: list findings %s: %v", sp.ServerID, err)
		return nil
	}

	diff := services.DiffFindings(existing, results, now)
	if err := services.ApplyFindingDiff(ctx, sp.InstanceID, sp.ServerID, diff, now); err != nil {
		log.Printf("vulnsched: apply diff %s: %v", sp.ServerID, err)
		return nil
	}

	s.clearPending(ctx, sp.ID, models.ScanStatusOK, now)

	for i := range diff.NewlyOpened {
		diff.NewlyOpened[i].ServerID = sp.ServerID
	}
	return diff.NewlyOpened
}

func (s *scheduler) clearPending(ctx context.Context, id bson.ObjectID, status string, now time.Time) {
	_, _ = db.Col("server_packages").UpdateOne(ctx,
		bson.M{"_id": id},
		bson.M{"$set": bson.M{
			"scan_pending": false,
			"status":       status,
			"scanned_at":   now,
			"db_version":   s.version,
		}},
	)
}

func (s *scheduler) closeStore() {
	if s.store != nil {
		_ = s.store.Close()
		s.store = nil
	}
}
```

- [ ] **Step 2: Wire it into main.go**

In `server/cmd/main.go`, inside `bus.RunAsLeader(ctx, "housekeeping", …)` (line 184), after `workflowsched.Start(...)`:

```go
		vulnsched.Start(jobCtx, vulnsched.Deps{
			LogEvent:   services.LogEvent,
			SendDigest: services.SendVulnDigest,
		})
		services.StartVulnSweeper(jobCtx)
```

Add the `vulnsched` import. `services.SendVulnDigest` and `services.StartVulnSweeper` arrive in Tasks 14 and 15 — stub them as no-ops in `server/internal/services/vulnrules.go` now and fill them in there.

- [ ] **Step 3: Build and commit**

Run: `cd server && go build ./... && go vet ./...`

```bash
git add server/internal/vulnsched/ server/cmd/main.go
git commit -m "feat: leader-owned vulnerability scan loop"
```

---

### Task 14: Alert rules and the digest

**Files:**
- Create: `server/internal/services/vulnrules.go`, `shared/mail/vuln.go`, `shared/mail/templates/vuln_digest.html.tmpl`, `shared/mail/templates/vuln_digest.txt.tmpl`

**Interfaces:**
- Consumes: `models.VulnAlertRule`, `models.VulnFinding`, `services.ResolveTargets`.
- Produces: `services.SendVulnDigest`, `services.ListVulnRules/CreateVulnRule/UpdateVulnRule/DeleteVulnRule`, `mail.Sender.SendVulnDigest`.

- [ ] **Step 1: Write the rule service and dispatch**

Create `server/internal/services/vulnrules.go` with CRUD for `vuln_alert_rules` following the shape of the existing notification-channel service in `server/internal/services/channels.go`, plus:

```go
// SendVulnDigest delivers one message per rule per tick — never one per
// finding. See vulnsched for why the tick is the batch boundary.
func SendVulnDigest(instanceID string, newly []models.VulnFinding) {
	rules, err := ListVulnRules(instanceID)
	if err != nil {
		log.Printf("vuln digest: list rules: %v", err)
		return
	}

	for _, rule := range rules {
		if !rule.Enabled {
			continue
		}

		matched := filterBySeverity(newly, rule.MinSeverity)
		if len(matched) == 0 {
			continue
		}

		if len(rule.Tags) > 0 {
			// ResolveTargets is already the single answer to which servers a
			// selector touches. A rule that disagreed with a workflow about
			// what env:prod means would be worse than no filter at all.
			allowed, err := ResolveTargets(instanceID, nil, rule.Tags)
			if err != nil {
				log.Printf("vuln digest: resolve targets: %v", err)
				continue
			}
			matched = filterByServers(matched, allowed)
			if len(matched) == 0 {
				continue
			}
		}

		for _, chID := range rule.ChannelIDs {
			dispatchVulnDigest(instanceID, chID, rule.Name, matched)
		}
	}
}

func filterBySeverity(findings []models.VulnFinding, min string) []models.VulnFinding {
	floor := models.SeverityRank(min)
	out := make([]models.VulnFinding, 0, len(findings))
	for _, f := range findings {
		if models.SeverityRank(f.Severity) >= floor {
			out = append(out, f)
		}
	}
	return out
}

func filterByServers(findings []models.VulnFinding, allowed []string) []models.VulnFinding {
	set := make(map[string]bool, len(allowed))
	for _, id := range allowed {
		set[id] = true
	}
	out := make([]models.VulnFinding, 0, len(findings))
	for _, f := range findings {
		if set[f.ServerID] {
			out = append(out, f)
		}
	}
	return out
}
```

Implement `dispatchVulnDigest` following how `server/internal/notify` already dispatches a monitor alert to a channel by ID, with a summary line of the form `"12 new critical, 4 new high across 6 servers"`. **Verify `ResolveTargets`' actual signature** in `server/internal/services/targets.go` and adjust the call.

Also add `StartVulnSweeper` here as a no-op stub if Task 13 has already referenced it; Task 15 replaces it.

- [ ] **Step 2: Write the mail templates**

Create `shared/mail/templates/vuln_digest.txt.tmpl`. **`subject` is defined in the txt file only** — `html/template` would escape an ampersand in an instance name, and mail clients show subjects verbatim:

```
{{define "subject"}}{{.Count}} new {{if eq .Count 1}}vulnerability{{else}}vulnerabilities{{end}} on {{.InstanceName}}{{end}}
{{define "title"}}New vulnerabilities detected{{end}}
{{define "pill"}}{{.TopSeverity}}{{end}}
{{define "body"}}
{{template "lead" .Summary}}

{{range .Rows}}- {{.CVEID}} ({{.Severity}}) — {{.PackageName}} on {{.ServerName}}{{if .FixedIn}}, fixed in {{.FixedIn}}{{else}}, no fix published{{end}}
{{end}}
{{if .More}}...and {{.More}} more.{{end}}

Scanned against vulnerability database pulled {{.DBAge}} ago.
{{end}}
```

Create `shared/mail/templates/vuln_digest.html.tmpl` defining `title`, `pill` and `body` only, composing the existing `p`, `lead`, `button`, `rows` and `chip` helpers from `layout.html.tmpl`. Read `shared/mail/monitor.go`'s template pair and match it exactly. **Carry no new hex colours** — every colour in the email system lives in `layout.html.tmpl`.

Create `shared/mail/vuln.go` with `VulnDigestData` and `SendVulnDigest`, following `shared/mail/monitor.go`.

- [ ] **Step 3: Verify the templates parse**

`shared/mail` parses its templates in `init()`, so a mistyped field is a **boot-time panic**, not a runtime error. Confirm before committing:

```bash
cd shared && go build ./mail/
cd ../server && go build ./... && go run ./cmd --help 2>&1 | head -3
```

If the binary starts far enough to print usage, the template set parsed. Then send one digest through a real channel from a dev instance and read the delivered message — the template can parse and still render nonsense, and nothing else will catch that.

- [ ] **Step 4: Commit**

```bash
git add server/internal/services/vulnrules.go shared/mail/
git commit -m "feat: vulnerability alert rules and batched digest"
```

---

### Task 15: REST API, retention setting and sweeper

**Files:**
- Create: `server/internal/api/vulnerabilities.go`
- Modify: `shared/models/settings.go`, `server/internal/api/handlers.go`, `server/internal/services/findings.go`

- [ ] **Step 1: Add the retention setting**

In `shared/models/settings.go`, beside `WorkflowLogRetentionDays`:

```go
	// VulnFindingRetentionDays is a pointer for the same reason
	// WorkflowLogRetentionDays is: absent must mean the default, not zero.
	// Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept.
	VulnFindingRetentionDays *int `bson:"vuln_finding_retention_days,omitempty" json:"vuln_finding_retention_days,omitempty"`
```

- [ ] **Step 2: Add the sweeper**

In `server/internal/services/findings.go`, following `StartAuditSweeper` in `audit_retention.go`:

```go
// StartVulnSweeper deletes old FIXED findings. Open and accepted findings are
// never swept at any setting: retention is about history, and an unresolved
// vulnerability is not history.
func StartVulnSweeper(ctx context.Context) {
	go func() {
		ticker := time.NewTicker(6 * time.Hour)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				sweepFixedFindings(ctx)
			}
		}
	}()
}
```

Implement `sweepFixedFindings` to read each instance's setting, resolve nil to 90 and skip 0, then delete `{instance_id, state: "fixed", fixed_at: {$lt: cutoff}}`. Model it on `StartAuditSweeper`. Remove the Task 14 stub.

- [ ] **Step 3: Write the handlers**

Create `server/internal/api/vulnerabilities.go` following the conventions in `server/internal/api/handlers.go` — `auth.InstanceID(c)` for scoping, `actorFromCtx(c)` for the audit actor:

- `listVulnerabilities` — `GET /api/vulnerabilities`, query params `severity`, `state`, `server`, plus tag filters. **Groups by CVE** in the response: `{cve_id, severity, title, server_count, servers: [...]}`.
- `vulnerabilitySummary` — `GET /api/vulnerabilities/summary`: severity counts for open findings, plus `{db_version, pulled_at, last_error}` from `vulndb_meta`.
- `rescanVulnerabilities` — `POST /api/vulnerabilities/rescan`: sets `scan_pending: true` on every `server_packages` document for the instance. Audit `vuln.rescan`.
- `acceptFinding` — `POST /api/vulnerabilities/:id/accept`, body `{reason string, until time}`. **Reject an empty reason and a past `until` with 400.** Sets state `accepted` with the `Acceptance` block. Audit `vuln.accepted` with reason and expiry in the details.
- `unacceptFinding` — `DELETE /api/vulnerabilities/:id/accept`: back to `open`. Audit `vuln.unaccepted`.
- `listServerVulnerabilities` — `GET /api/servers/:id/vulnerabilities`.
- `getServerPackages` — `GET /api/servers/:id/packages`.
- `searchPackages` — `GET /api/packages/search?name=`, calls `services.SearchPackages`.
- Alert rule CRUD — `GET,POST /api/vuln-rules`, `PUT,DELETE /api/vuln-rules/:id`.

- [ ] **Step 4: Register the routes**

In `server/internal/api/handlers.go`, inside the `apiGroup` block:

```go
		apiGroup.GET("/vulnerabilities", listVulnerabilities)
		apiGroup.GET("/vulnerabilities/summary", vulnerabilitySummary)
		apiGroup.POST("/vulnerabilities/rescan", auth.RequireRole("owner", "admin"), rescanVulnerabilities)
		apiGroup.POST("/vulnerabilities/:id/accept", auth.RequireRole("owner", "admin"), acceptFinding)
		apiGroup.DELETE("/vulnerabilities/:id/accept", auth.RequireRole("owner", "admin"), unacceptFinding)
		apiGroup.GET("/servers/:id/vulnerabilities", listServerVulnerabilities)
		apiGroup.GET("/servers/:id/packages", getServerPackages)
		apiGroup.GET("/packages/search", searchPackages)
		apiGroup.GET("/vuln-rules", listVulnRules)
		apiGroup.POST("/vuln-rules", auth.RequireRole("owner", "admin"), createVulnRule)
		apiGroup.PUT("/vuln-rules/:id", auth.RequireRole("owner", "admin"), updateVulnRule)
		apiGroup.DELETE("/vuln-rules/:id", auth.RequireRole("owner", "admin"), deleteVulnRule)
```

**Check `auth.RequireRole`'s actual variadic signature** before using it with two arguments.

- [ ] **Step 5: Verify by hand**

With a dev instance running, confirm each: an accept with an empty reason returns 400; an accept with a past `until` returns 400; a member session gets 403 on accept and 200 on the list; a rescan sets `scan_pending` (check in Mongo).

- [ ] **Step 6: Build and commit**

Run: `cd server && go build ./... && go vet ./...`

```bash
git add server/internal/api/ shared/models/settings.go server/internal/services/findings.go
git commit -m "feat: vulnerability REST API, retention setting and sweeper"
```

---

### Task 16: Web UI

**Files:**
- Create: `web/app/(app)/vulnerabilities/page.tsx`, `web/components/vulnerabilities/{FindingRow,AcceptDialog,DBFreshness}.tsx`, `web/components/settings/VulnAlertRulesCard.tsx`
- Modify: `web/lib/api.ts`, `web/components/Sidebar.tsx`, `web/app/(app)/servers/[id]/page.tsx`, `web/app/(app)/settings/notifications/page.tsx`

- [ ] **Step 1: Add API client types and methods**

In `web/lib/api.ts`, add `VulnFinding`, `VulnGroup`, `VulnSummary`, `ServerPackages` and `VulnAlertRule` types mirroring the Go JSON tags exactly, plus one client method per Task 15 route. Follow the file's existing conventions.

- [ ] **Step 2: Build the fleet board**

Create `web/app/(app)/vulnerabilities/page.tsx`. **Grouped by CVE, one row per CVE with an affected-server count, expandable to the servers.** The same CVE across 40 servers is one decision; a flat list makes it look like forty.

Requirements:
- Filter bar: severity, state (default `open`), tag filter following `TagFilterBar`'s pattern from the servers page.
- `DBFreshness` banner at the top, always visible, showing `pulled_at` age and `last_error` when set. A fleet scanning against a three-week-old database must say so rather than quietly report all-clear.
- A server whose `status` is `unsupported` renders as **"unsupported"**, never as zero findings.
- Every severity pill carries a distinct shape and a text label — state never reads by colour alone, matching the existing monitor pills.
- Findings with no `fixed_in` show "no fix published" rather than an empty cell.
- Tailwind token names only. No hex values.

- [ ] **Step 3: Build the accept dialog**

Create `web/components/vulnerabilities/AcceptDialog.tsx`: required reason textarea, required `until` date defaulting to 30 days out, and copy stating the finding reopens automatically on that date.

- [ ] **Step 4: Add the remediation action**

On a finding with `fixed_in`, render an **Apply updates** button calling the existing `POST /api/servers/:id/apply-updates`. No new endpoint and no new mechanism.

- [ ] **Step 5: Add the tabs and nav**

Add **Vulnerabilities** and **Packages** tabs to `web/app/(app)/servers/[id]/page.tsx`, following the existing tab pattern. Add a **Vulnerabilities** entry to `web/components/Sidebar.tsx`. Mount `VulnAlertRulesCard` on `web/app/(app)/settings/notifications/page.tsx` beside the channels.

- [ ] **Step 6: Verify**

Run: `cd web && npm run lint && npm run build`
Expected: exit 0, no type errors.

Then load the pages against a dev instance and confirm: the CVE grouping expands, the freshness banner shows an age, an unsupported server says so, and the accept dialog rejects an empty reason.

- [ ] **Step 7: Commit**

```bash
git add web/
git commit -m "feat: vulnerability findings UI"
```

---

### Task 17: Instance deletion, entitlement and documentation

**Files:**
- Modify: the control plane's instance-deletion collection list, `admin/internal/models/entitlements.go`, `CLAUDE.md`, `docsite/docs/vantage/`

- [ ] **Step 1: Add the new collections to instance deletion**

Find the instance-deletion routine — the one place that knows which collections carry `instance_id`:

```bash
cd server && grep -rn "instance_id" internal/services/ | grep -i "delete\|purge\|reap"
```

Add `server_packages` and `vuln_findings` to its collection list. **Missing this orphans a tenant's package data indefinitely**, and it is the easiest thing in this feature to forget.

- [ ] **Step 2: Add the admin entitlement toggle**

In `admin/internal/models/entitlements.go`, add `vuln_scanning` to the feature toggles, following the pattern the existing per-instance toggles use. It must be **off for both Free plans**. Confirm the licence issue path copies it into `License.Features`.

- [ ] **Step 3: Verify the whole tree builds**

From the repo root:

```bash
go build ./... && go vet ./...
```

Expected: exit 0.

- [ ] **Step 4: End-to-end check**

Against a dev instance with one Linux agent: enable `vuln_scanning`, wait for the hourly report (or restart the agent to force one), confirm a `server_packages` document appears with `scan_pending: true`, and that within ~60s `vulnsched` clears the flag and writes findings. Confirm a second agent report with an unchanged package set sends no body.

- [ ] **Step 5: Document it**

Add a **Package inventory and CVE findings** subsystem section to `CLAUDE.md` covering: the backport trap and why matching uses distro feeds; why only the leader matches; that findings go `fixed` rather than being deleted; the two new environment variables. Add a user-facing page under `docsite/docs/vantage/`.

While in `CLAUDE.md`, correct the `shared/mail/render_test.go` claim — no such file exists, and this plan does not create one.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "docs: document package inventory and CVE findings"
```

---

## Spec coverage

- Backport trap → Tasks 1 and 11, with the equal-version case in both manual checks.
- trivy-db as the source, ephemeral storage, `VANTAGE_TRIVY_DB_REF` → Task 10.
- Leader-only matching, `scan_pending` persisted, tick-boundary batching → Tasks 8 and 13.
- Hash short-circuit wire path → Tasks 6 and 7.
- All four collections and their indexes → Task 5.
- `SourceName` covering every binary of a source package → Tasks 4 and 11.
- Unsupported distro never reads as clean → Tasks 2, 13 and 16.
- Severity vendor → NVD → unknown → Task 10 (`severityName`).
- Findings lifecycle with acceptance expiry, fixed-before-reopen ordering → Task 12.
- Alert rules through `ResolveTargets`, one digest per tick → Task 14.
- Entitlement gated at collection, `HasFeature` not tier → Tasks 8, 9 and 17.
- REST API, retention, sweeper → Task 15.
- CVE-grouped board, DB freshness on screen, remediation via the existing endpoint → Task 16.
- Instance-deletion collection list → Task 17.

## Known gaps the executing engineer must close

- **`vulndb.Pull` ships as a stub** with implementation requirements rather than working oras code (Task 10). It is the one piece that cannot be written blind: the exact `oras-go` v2 call shape depends on the resolved version. Verify it by hand (Task 10, Step 3) before Task 13.
- **Six call sites are written against signatures that must be confirmed first**, each flagged at the point of use: the licence accessor (Task 8), `ValidateAgentToken`'s return shape (Task 8), `ResolveTargets` (Task 14), `auth.RequireRole`'s variadic form (Task 15), the `trivy-db` API names (Task 10), and `shared/mail`'s render conventions (Task 14).
- **No automated tests exist for any of this**, by instruction. The manual verification tables in Tasks 1, 11 and 12 are the substitute and are the difference between shipping this and shipping something that reports a patched fleet as vulnerable, or worse, the reverse. Do not skip them.
