CADENCE — Architecture

The §2 mission decisions are locked. This document explains how the code realizes them.

The two halves

┌──────────────────────────────┐         signed, static feed          ┌──────────────────────────────┐
│  FEED BUILDER  (vendor side)  │  ── manifest.yaml + .sig + files ──▶ │  ADVISORY CLIENT (customer)   │
│  Stoffel-operated CLI         │      (HTTPS pull OR air-gap mirror)   │  appliance, source-available  │
│                               │                                      │                               │
│  authors advisories (YAML)    │                                      │  pull → verify sig → check    │
│  → validate against schema    │                                      │  file hashes → load feed      │
│  → SHA-256 every file         │                                      │  → read LOCAL inventory       │
│  → write manifest             │                                      │  → analyze → per-asset verdict│
│  → ed25519-sign the manifest  │                                      │  → notify (email / webhook)   │
└──────────────────────────────┘                                      └──────────────────────────────┘
     holds SECRET key                                                       ships only the PUBLIC key

Data direction is one-way, outbound-from-vendor only. The client never sends inventory, topology, or versions back. The feed is a static file tree, so a customer can mirror it to an internal server and run fully air-gapped — trust is anchored in the single shipped public key.

Crate layout (Cargo workspace)

Crate Role Status

domain

Framework-free core: feed schema, ed25519 sign/verify, manifest integrity, signed-feed pull seam, inventory adapter trait + 4 impls + mock, analysis engine, change-detection, notification formatting, feed linter. Most tests live here.

DONE

feed-builder

Vendor CLI: keygen, lint, build (hash + manifest), sign, verify, analyze.

DONE

persistence

Client-local SQLite (sqlx): reminder timestamps, last-seen versions, the notify-on-change baseline, and the tamper-evident decision audit trail.

DONE

client / app

Advisory Client appliance: pull→verify→analyze→notify, as a scheduled-refresh daemon with notify-on-change; Leptos read-only dashboard (+ optional Basic auth, freshness bar, audit badge) + a /decisions.json export; real webhook + SMTP transports.

DONE

domain depends on nothing framework-y. Adapters and notifiers are traits with mocks, so the whole workspace compiles and cargo tests offline with no live infrastructure.

The client as a daemon (run #3)

The appliance runs the §6 loop continuously. With CADENCE_REFRESH_INTERVAL set, a Tokio interval task re-runs source → analyze → deliver → persist each tick and hot-swaps the rendered dashboard behind an RwLock<Arc<Dashboard>> (read briefly per request, written briefly per refresh — never across an .await). A failed cycle is logged and skipped; the last good dashboard keeps serving. Notify-on-change (D012): each cycle delivers only the notifications new since the last (current − previous), persisted in SQLite so suppression survives restarts; failures are retried, not dropped. Every advisory decision is appended to a SHA-256 hash chain (D013) — tamper-evident, verified each cycle, surfaced on the dashboard and exported as JSON for auditors. Notifications go to both real channels (webhook via ureq, SMTP via lettre) behind the domain seams; the blocking network I/O runs off the async runtime via spawn_blocking.

Feed integrity model (D001 + D002)

  1. A feed is a directory of YAML files: manifest.yaml, and per-module <module>/advisories.yaml + <module>/service_intervals.yaml.

  2. manifest.yaml carries feed_version, modules, and a files[] list where each entry is { path, sha256, bytes }.

  3. The Feed Builder writes manifest.yaml, then ed25519-signs its exact bytesmanifest.yaml.sig (detached, base64).

  4. The client: a. reads manifest.yaml bytes, verifies manifest.yaml.sig with the shipped public key; b. for each files[] entry, recomputes SHA-256 of the named file and compares; c. only then parses the advisory/interval YAML. Any failure rejects the whole feed (fail closed). No partial trust.

This is the Linux-repo / Sonatype-Repository-Firewall pattern: central signed intelligence, local enforcement, customer data stays local.

Analysis engine (§6)

Pure function: analyze(snapshot, feed, reminder_state, now) -> Vec<AssetVerdict> + Vec<ReminderDue>.

For each inventoried asset:

  1. resolve product + installed version + facts (installed apps, Ceph version, …​);

  2. select advisories whose product + version match;

  3. evaluate optional conditions against local facts (incl. cross-product facts like "Ceph version") to pick the effective verdict;

  4. emit { asset, verdict, severity, summary, cves, blocking_requirements };

  5. service-interval reminders are computed from reminder_state (last-action timestamps) vs now — kept as a plain input so domain needs no DB.

Output is advice, never action (v1). No write-back to customer systems.

Inventory adapters (§5)

trait InventoryAdapter { fn collect(&self) -> Result<InventorySnapshot, AdapterError>; } v1 real impls (Proxmox API, Nextcloud occ, NetBox, SSH/apt) each ship with the trait; a MockAdapter (in-memory) backs all offline tests. Monitoring systems (Prometheus/Grafana) are not inventory and are never the version spine.