CADENCE — Decisions & Assumptions
Every fork, assumption, and locked choice with its rationale. Append-only-ish: supersede rather than delete, and note how a human could overturn an assumption.
Locked by the mission (not re-litigated here)
-
Product: productized upgrade-advisory consultancy delivered as software. The feed is the IP; the code is delivery. (§1)
-
Architecture: two halves — Feed Builder (vendor, signs) + Advisory Client (customer, pulls & verifies). One-way outbound data flow; air-gap mirrorable. (§2)
-
Stack: Rust workspace,
domain/persistence/appsplit; Leptos for the client UI; SQLite via sqlx for client state; Feed Builder is a CLI. (§8)
Decisions taken this build
D001 — Detached signature, not an inline signature: field
The mission’s schema sketch shows signature: inside manifest.yaml. You cannot sign a document that contains its own signature without a canonicalization dance. The mission’s signing paragraph itself says "default to detached ed25519." Decision: the signature is a separate manifest.yaml.sig file (base64 ed25519 over the exact manifest bytes). This is how minisign/signify work and removes all canonicalization ambiguity — the client verifies the literal bytes it received, then parses them. Overturn: if interop with a specific tool needs an inline field, add it as a non-signed convenience mirror, never as the source of truth.
D002 — Feed integrity model: sign a manifest that commits to every file by SHA-256
A feed is a directory of YAML files (per-module advisories + service intervals). The manifest lists feed_version, modules, and for every content file its path + SHA-256. The Feed Builder signs the manifest bytes. The client (a) verifies the signature over the manifest, then (b) verifies each file’s SHA-256 against the manifest before parsing it. Any mismatch rejects the whole feed (fail closed). This makes the feed air-gap mirrorable exactly like a Linux repo: copy all files + manifest + sig to an internal mirror; trust is anchored in the one shipped public key. (Mirrors the Sonatype Repository Firewall model from §2.)
D003 — ed25519 via ed25519-dalek v2; keys are 32-byte seeds, base64 on disk
Simple, fast, one static keypair. Secret key = 32-byte seed in signing.key (server-side, gitignored, never shipped). Public key ships base64 with the client. SecretKey::from_seed makes signing deterministic and offline-testable (no RNG in tests). Future: PGP/smartcard signing is viable later (Stoffel runs a ~200-key Nitrokey 3C OpenPGP fleet) and minisign-format compatibility would let customers verify with off-the-shelf tools — both noted as enhancements, not v1.
D004 — SecretKey is deliberately NOT Debug/Serialize
Avoids ever logging or accidentally serializing secret key material. Only PublicKey and Signature are Debug. Verification errors are coarse (BadSignature) so callers can’t branch on why a signature failed — fail closed.
D005 — Run #1 crates: domain + feed-builder only; persistence + client deferred
The mission’s proven domain/persistence/app split is honored as crates are added. Run #1’s Definition of Done (§19) needs schema + sign/verify + one adapter + analysis engine — all of which live in domain, demonstrated by a feed-builder CLI. SQLite reminder state (persistence) and the Leptos read-only UI (client/app) are explicitly later runs (§19 says "UI … follow in later runs"). KISS/YAGNI: no empty crates. To keep domain framework-free and pure, the analysis engine takes last-action timestamps as a plain input (ReminderState), so service-interval reminders need no DB in domain.
D006 — serde_yaml 0.9 for the feed format
Feed authors (Stoffel) hand-write advisories; YAML is the friendliest authoring format and matches the mission’s schema sketch. serde_yaml 0.9 is battle-tested but in maintenance mode; if a security audit flags the dependency, migrate to the maintained serde_yml fork or switch the on-disk format to JSON (the types are format-agnostic serde structs).
D007 — Client UI: Leptos SSR-render over axum (not cargo-leptos hydration) for v1
The mission locks Leptos for the client UI. For a read-only dashboard there is nothing to hydrate, so v1 uses real Leptos view! components rendered server-side via RenderHtml::to_html() (leptos 0.8) and served by a plain axum binary. This builds as a normal, fast binary that reliably serves (verified: curl returns the rendered dashboard) — prioritizing the work-ladder’s "it must actually serve" over hydration ceremony. Full cargo-leptos SSR+hydrate is a clean later enhancement if/when the UI gains interactivity (filtering, live refresh). This honors "Leptos is the locked choice" (it’s genuinely Leptos components) without re-litigating.
D008 — Client demo mode signs+verifies the sample feed at runtime (no committed artifacts)
cargo run -p cadence-client self-contains: it copies feed-sample/ to a tempdir, builds + ed25519-signs it with a throwaway dev key, and load-and-verifies through the exact production domain path before rendering. So the running app really exercises signature+hash verification, and the repo stays free of committed generated feed artifacts. Asset paths are env-overridable (CADENCE_DEMO_FEED_DIR / CADENCE_DEMO_INVENTORY) for containerization. The production path (pull a vendor-signed feed over HTTP + live adapter) reuses the same verify/analyze code, wired in a later run.
D009 — Persistence: sqlx SQLite with runtime-checked queries (not the query! macro)
The mission’s stack note says "SQLite via sqlx (compile-time-checked queries)". Compile-time checking needs a DATABASE_URL (or a committed .sqlx offline cache) at build time, which adds CI friction and conflicts with the hard constraint that everything builds and tests offline with no live infrastructure. So persistence uses runtime-checked sqlx::query/query_as, and tests run against an in-memory SQLite (:memory:). The hard constraint wins. Overturn: add cargo sqlx prepare + a committed .sqlx cache in CI to regain compile-time checks without a live DB — a clean later enhancement, not a blocker.
D010 — Live feed pull: fetch into a local mirror dir, then re-verify with the existing gate
S18 makes the client a real pulling appliance. Rather than write a parallel in-memory verifier, domain::fetch::fetch_feed_to_dir downloads the signed feed (manifest + sig + every committed file) into a local directory, then the caller runs the existing load_and_verify_feed over that dir. Consequences:
-
One audited trust gate. The fail-closed verifier (signature-before-parse, per-file SHA-256, path-traversal/size/count ceilings) stays the single source of truth; the fetch step is defense-in-depth (it verifies the manifest signature only, to decide which files to download).
-
The cache dir IS the air-gap mirror (§2). What the client writes is a complete, signed, independently-re-verifiable copy — exactly what a customer syncs to an internal mirror. A proven test (
downloaded_corruption_is_caught_by_reverification) shows a transport that swaps a file is still rejected on re-verify. -
The transport is a seam, untrusted by design.
FeedFetcher(sync, runtime-free, mirrors the adapterHttpGetter/CommandRunnerseams) keepsdomainframework-free; the fetch logic is fully offline-tested withMockFetcher. The real HTTP impl lives in the client.
D011 — Client transport: ureq (bundled rustls), and TLS is not the trust anchor
The production FeedFetcher is client::live::HttpFeedFetcher, a thin blocking ureq GET run off the async runtime via spawn_blocking. Chosen over reqwest for: minimal deps / small attack surface (mission §8), a self-contained static binary (bundled rustls + webpki roots, no OpenSSL, no system cert store), and a trivial blocking API for a one-shot startup pull. The ed25519 feed signature — not TLS — is the trust anchor, so even a plain-HTTP or air-gap-mirrored source is safe (a tampered byte fails the signature/hash gate). This is why the appliance can pull from an untrusted/offline transport and still be secure — a genuine architectural strength, not a shortcut. A MAX_FETCH_BYTES read cap guards against an unbounded response at the transport layer. Overturn: swap ureq for reqwest/hyper (e.g. for HTTP/2 or async streaming) behind the same FeedFetcher trait without touching domain.
D012 — Notify-on-change is edge-triggered over the delivered notification set, persisted
The appliance must become a daemon (re-pull + re-evaluate on a timer, CADENCE_REFRESH_INTERVAL) without re-spamming the same alerts every cycle. The change signal is the set of delivered (Important+) notifications, diffed cycle-over-cycle: deliver only current − previous (a pure domain::new_notifications), then persist current as the new baseline (persistence::replace_notified).
-
Why the notification set, not just the version? A CADENCE advisory most often speaks about a version you already run ("hold 26.2.3"). A feed refresh can introduce a new BLOCKED/CVE verdict for an unchanged installed version — version-only change detection (the
seen_versionstable) would miss exactly the case that is the product’s core value. The notification subject already encodes{verdict}:{asset}({product} {version}), so set-difference catches both a version change and a feed-driven judgment change, and a changed body (e.g. a newly-attached CVE) re-alerts. -
Edge-triggered semantics. We persist the currently-active set each cycle (not an append-only ledger), so a resolved alert ages out and a recurrence re-fires — the standard Alertmanager model. Stored as a
notifiedtable (sha256(payload) → JSON), bounded by the number of active alerts. -
Reminders are intentionally out of the change-gated path. Delivery filters at
Important, and reminders are emitted atRecommended, so they were never in the delivered set; their volatile "Nd overdue" subject would otherwise defeat the dedup key. A daily reminder digest is a separate (deferred) concern. -
seen_versionsis now live, not dead code. Each refresh records per-asset versions and logs detectedold → newchanges (operator-facing insight; the table’s documented "groundwork for change detection"). It is informational here — the notification diff, not this table, drives alerting — which is why feed-only changes still alert correctly. -
Resilience. A failed cycle (feed pull / adapter error) is logged and skipped; the last good dashboard keeps serving. With
CADENCE_REFRESH_INTERVALunset the client stays a one-shot (backward compatible). Overturn: to fire reminders periodically, add a separate digest schedule; to re-fire on a cadence regardless of change, switchreplace_notifiedto a TTL’d ledger.
D013 — Decision audit trail is a SHA-256 hash chain; canonical is JSON (delimiter-safe)
The §14 compliance pitch ("we held this upgrade for a documented reason") needs a verifiable record, not just a durable one. Each genuinely-new decision_log entry stores entry_hash = sha256(prev_hash \n canonical(entry)), forming an append-only chain; verify_audit_chain() recomputes it and flags any altered entry, reorder, or mid-deletion.
-
Threat model. This is tamper-evidence against post-hoc edits of the customer-local SQLite file (an operator quietly softening a recorded verdict), not a defense against a malicious feed author — the feed is the trusted, ed25519-signed input. It catches "someone changed the record after the fact."
-
Truncation. Lopping off the tail can’t be detected from the chain alone (the remaining prefix is internally consistent).
audit_chain_tip()exposes the head hash so an auditor records it out-of-band and notices if entries later vanish. Documented, not hidden. -
Canonical is JSON, not a
|-join. A delimiter inside a field (e.g. a summary containing|) must not let an attacker forge a different decision with the same canonical bytes; JSON-encoding a fixed field array removes that collision. The format is frozen (changing it invalidates chains). -
Append-distinct.
INSERT OR IGNOREon(asset,product,version,verdict)keeps the chain from growing on unchanged daily cycles; a changed verdict is a new dated link.recorded_onis the first-seen date. -
Export.
GET /decisions.json(auth-gated) serves the whole trail + chain verdict + tip for an auditor/SIEM — auditors ingest exports, not dashboards. Overturn: for cross-host integrity (not just local tamper-evidence), periodically ed25519-sign the chain tip with the feed/Nitrokey key and publish it; for true immutability, ship entries to an append-only external log.
D014 — Citations are a feed-quality gate; the sample feed is sourced curation, not invention
CADENCE’s product is auditable, sourced judgment (§14: "we held this upgrade for a documented reason"). A verdict an operator or regulated auditor cannot trace to an upstream source is worth little. So (run #4):
-
The linter requires citations.
lint_feedwarns on an advisory with noreferencesand on any reference that isn’t a well-formedhttp(s)://<dotted-host>URL (placeholder text andlocalhostrejected). Both are warnings — consistent with the existing malformed-CVE check (theWarningchannel = "probable authoring mistake worth a human’s eyes"); a curator can knowingly ship without one, but the default nudges toward a sourced verdict. A feed-builder regression test asserts the committed flagship feed has zero such warnings — the reference feed must practice what the pitch sells. -
Why warning, not error. An
Errorblocks the release. Citations are a strong norm, not a hard schema invariant (a curator might, rarely, have a defensible uncited call). Keeping it a warning avoids over-blocking while the regression test still holds our feed to 100%. -
The sample content is now sourced, not illustrative. Every advisory’s facts (versions, EOL dates, CVEs, upgrade gotchas) are drawn from the cited upstream references and were current as of 2026-06-22. It is still a sample (not a paid subscription), and specific app-compatibility pairings illustrate the conditional-gate mechanism against a real cited issue — but the feed is now checkable, not hand-wavy. (Supersedes A2.) Overturn: to hard-require citations, promote the missing-reference finding to
Error; to verify a URL actually resolves (not just parses), add an optional, online check in the Feed Builder — kept out oflint_feed, which must stay pure/offline.
D015 — The client changelog is a per-pull delta against a retained feed baseline (run #5)
The dashboard now shows "what advice changed in this feed pull" — the client-side analog of the feed-builder diff (D013’s audit trail records what was decided; this records what the feed changed). Design choices:
-
Retain the previous feed locally, diff each pull. The appliance persists the previously-pulled feed’s advisory set in a
feed_baselinesingleton row (persistence), and each cycle diffs the freshly-pulled feed against it via the existing puredomain::diff_advisories, then advances the baseline. The baseline lives inCADENCE_STATE_DB, so the comparison point survives a restart (the next pull diffs against the last feed seen before the restart, not a blank slate). This matches the run-4 morning-report spec ("retain the previous feed to diff against each refresh"). -
Per-pull semantics (not a persisted change-history). The panel shows the delta of this pull vs the immediately-previous one — the visual analog of notify-on-change (D012). After an unchanged pull it correctly reads "no changes since the last pull (vN)". Historical change-tracking is already covered by the feed-builder
diff(vendor side) and the decision audit trail (client side), so the client panel deliberately stays a live per-pull view rather than storing a serialized diff history (KISS — no new serializedFeedDiffstate). -
Surface safety regressions prominently. Any
caution_reduced()change (a hold/block/EOL softened toward install) gets a ⚠ banner + an inline row flag — the operator’s "did the feed just quietly downgrade a warning?" check before trusting the pull. A tightening is not flagged. -
Demo seeds a plausible previous baseline (
demo::seed_feed_baseline) so the changelog is non-empty and sellable on first load (a softened verdict, a newly-published CVE advisory, a severity tightening, a withdrawn advisory). The seed is used only for the diff, never analyzed, so live verdicts still come from the real current feed. Overturn: to make the changelog persist across no-op refreshes (show "the last feed change" until the next one), store the computedFeedDiff+ its date and only recompute on change — at the cost of serializing the diff and a slightly subtler "what does this panel mean" story.
D016 — Anti-rollback: refuse a validly-signed but older feed (freshness ≠ authenticity) (run #7)
Signature + hash verification (D001/D002) proves a pulled feed is authentic (Stoffel signed it), not that it is current. Because the client pulls the feed over HTTP and the feed is air-gap mirrorable (§2), a hostile mirror / CDN edge / MITM can replay an older, still-validly-signed feed to suppress a newly-published advisory — e.g. hide this month’s install-urgent critical-CVE verdict so a vulnerable host keeps running. The signature gate waves it straight through; the old feed really was signed. This is the textbook update-system rollback/downgrade attack (cf. apt Valid-Until, TUF timestamp/snapshot roles). Decisions:
-
Monotonic high-water mark. The client persists the highest
feed_versionit has ever trusted (feed_trustsingleton,persistence) and refuses any pull whose version is strictly older (domain::check_no_rollback). Equal is accepted — re-pulling the current feed every interval is the steady state, and a vendor may legitimately re-publish the same version. The mark only ratchets up (domain::advanced_high_water), never on a rejected feed, so a repeated replay stays blocked. -
Numeric comparison. Versions compare via
domain::version::compare(dotted-numeric), so2026.06.9is correctly older than2026.06.10— a naïve lexical compare gets that backwards. Operational constraint: the feed_version scheme must stay monotonic dotted-numeric (the2026.06.DDconvention + thefeed-builder --feed-versioninput already are). Switching to a scheme that parses numerically lower (e.g. av-prefix → leading component parses to 0) would look like a massive rollback and brick clients; treat feed_version as a frozen monotonic counter. -
Separate table from the changelog baseline.
feed_trustis a security record (only ratchets, must never silently read as "any feed is fresh");feed_baseline(D015) is a convenience cache (regressable, self-heals a corrupt row to "absent"). Opposite trust semantics → two singletons, never shared. Persistence holds no version-ordering logic — the client ratchets via the domain helper. -
Fail-closed by skipping the cycle. On a detected rollback
run_oncereturnsErr, so the daemon logs a louderror!and keeps serving the last-good dashboard — exactly its existing behavior for a failed pull. The stale feed never advances the changelog baseline, never re-analyzes, never softens a verdict. Trade-off: a rollback at the very first cycle after a restart propagates and refuses startup (cycle 0 inmainuses?). That is a deliberate, defensible fail-closed posture — halting loudly under an active downgrade beats silently serving stale advice — though it does let an attacker who can pin a stale mirror also block a restart (a visible DoS, not a silent compromise). Scope / deferred (the natural follow-ups): -
Anti-freeze (
valid_until) — rollback protection stops going backwards but not a freeze (pinning the client forever on the current feed). (Updated run #11 — domain core shipped, see D017.)domain::check_not_expirednow refuses a feed whose signedvalid_untilhas passed (S44a); the manifest schema field + builder (S44b) and the client enforcement + banner (S44c) are the remaining wiring. -
Operator-visible alerting. (Updated run #8 — S45a shipped.) A detected rollback is no longer log-only:
enforce_feed_freshnessnow pushes an Importantrollback_notification(domain) through the configured channels viaclient::page_rollback, riding the notify-on-change baseline so a replayed stale feed pages once and a failed page retries next cycle. Paging is best-effort and runs before the gate returnsErr, so the fail-closed semantics are untouched (the cycle still skips, last-good dashboard stands). The complementary dashboard security banner's state channel (S45b) shipped run #9: a persistedrejected_rollbacksingleton (persistence) the gate sets on a refused downgrade (best-effort, before the fail-closedErr) and clears on the next accepted pull — deliberately transient (opposite of the ratchetingfeed_trustmark), so it tracks an active attack window. The data path is wired + tested through the gate; only the UI render (S45c) — readingload_rejected_rollback()in the index handler + a banner inui.rs— remains. Overturn: to make a startup rollback non-fatal, soften cycle 0 inmainto log + serve a clearly "feed rejected (possible downgrade) — no trustworthy feed yet" placeholder dashboard instead of?.
D017 — Anti-freeze: refuse a feed whose signed valid_until has passed (freeze ≠ rollback) (run #11)
The anti-rollback gate (D016) stops a mirror serving an older feed, but not a freeze: a mirror that simply stops publishing keeps serving the current, equal-version feed forever. The version never goes backwards, so D016 waves it through — yet a newly-published install-urgent advisory never reaches the host. This is the second half of what apt’s Valid-Until and TUF’s timestamp-role expiry defend: a signed staleness bound the vendor refreshes on cadence. Decisions:
-
Domain core first (S44a, this run).
domain::freshness::check_not_expired(valid_until, now)is the pure security judgment:now > valid_until⇒FreshnessError::Expired.nowis injected, not read from the clock, so the judgment is offline-testable IP (matchescheck_no_rollback). Wiring the field into the manifest schema + builder (S44b) and enforcing it inenforce_feed_freshness+ paging/banner (S44c) follow — same S41→S42→S43 decomposition as the rollback gate. -
Orthogonal to rollback.
Expiredis a distinct variant fromRollback; a feed must pass both gates (a newest-ever-version feed can still be expired). They compose, they do not subsume. -
None⇒ accept (opt-in, backward compatible). A feed that declares novalid_untilis accepted, so feeds signed before the field existed still parse and run, and the field rides the existing signature (the manifest signs its raw YAML bytes — adding a serde-optional field needs no canonicalization change). The vendor enables freeze-detection simply by signing a bound. Trade-off: freeze-detection is therefore opt-in — a feed that never carries the field can be frozen indefinitely. Right for v1 (apt’sValid-Untilis likewise optional; avoids bricking pre-field feeds), but a future hardening could make a bound mandatory once every published feed carries one. -
Inclusive last valid day.
valid_untilis the last day the feed is good for (now == deadlineaccepts;now > deadlinerejects), so re-pulling on the deadline day never bricks a still-valid client. Overturn: to make the bound mandatory, rejectvalid_until: Noneincheck_not_expired(or in the client wiring) once the flagship feed and all live feeds always carry one.
D018 — Anti-freeze delivery: explicit --valid-until <date>, not relative --valid-for Nd; a sibling banner, not a generalized one (S44b/S44c)
Completing D017’s wiring (run #12, on top of run #11’s domain core):
-
build --valid-until <YYYY-MM-DD>(explicit date), not--valid-for Nd(relative). The mission sketch suggested--valid-for Nd, but a relative bound computed from today makes the manifest non-reproducible — re-building the same content on a different day yields different bytes, hence a different signature, breaking the builder’s byte-stable-output guarantee and any manifest diffing. An explicit date is deterministic and re-signable.build_manifestitself stays clockless (leaves the fieldNone); only the CLI, which may read the clock, sets it. Overturn: add--valid-forlater as sugar that resolves to an explicit date at invocation, if operators want it — but keep the stored value a concrete date. -
A sibling
expired_feedbanner +expiry_notification, not a generalized "freshness rejection". This is the second freshness-rejection surface (rollback was first). Per the project’s own DRY rule (extract on the third repetition), two is not three: duplicating the transient-singleton banner pattern (parallel torejected_rollback) is safer than refactoring the shipped, tested S45b/c security path. The one thing genuinely reused twice — the notify-on-change paging mechanic — was extracted intopage_freshness_alert, which bothpage_rollbackandpage_expirydelegate to ("reuse the S45 alert seam"). Overturn: if a third freshness banner ever appears, unify the three into one parameterized banner record + renderer. -
The expiry push alert is date-free (fires once); the banner carries the detection date. A freeze persists day after day; a date in the alert subject/body would re-fire it every stale day. Keeping the alert stable per
(feed_version, valid_until)pages the operator once (via notify-on-change), while the transient dashboard banner shows the exactdetected_on— same split as the rollback alert/banner.
D019 — Docker build (S22) verified on a real host; the CI blocker was external to the code (run #12)
S22 was stuck DRAFTED/UNVERIFIED across runs #1--#11 for one reason: no Docker daemon in the CI sandbox. Run #12 executed on a Docker-capable host (Docker 29.6.2): the multi-stage docker build completes, the container boots in ~2s, /healthz→200 "ok" and /→200 with zero errors in logs. Marking S22 DONE — the code and Dockerfile were never the blocker, only the environment. Overturn: none; if the Dockerfile changes, re-run docker build + a container smoke before re-asserting.
Open assumptions (a human can overturn any of these)
-
A1: First curated module for run #1 is Nextcloud (richest conditional-judgment example in the mission sketch). Proxmox/Ceph (with cross-product ordering) follows. Both are in v1 scope; order is just sequencing.
-
A2 (superseded by D014, run #4): Sample advisory content was illustrative in runs #1--#3. As of run #4 it is sourced curation — every fact is cited to an upstream reference and was current as of 2026-06-22. It remains a sample (not a paid subscription) and is not a substitute for the operator’s ongoing out-of-band curation, but it is now checkable against its sources rather than invented.