CADENCE — Feed Authoring Guide

The feed is the product. This is the operating manual for the person (Stoffel) who writes the advisories — the 3—​8h/week curation discipline that is the business (see ttm.md). The software just delivers and applies what you write here. Get the judgment right; the engine handles the rest.

The mental model

You are a consultant answering, per product version: "should I install this, and when?" — and encoding that answer so a customer’s appliance can apply it to their specific inventory. The value is not "an update exists" (every package manager says that). It’s the conditional judgment: hold this point release, wait for the patch, this is blocked until a dependency moves first.

A feed is a directory of hand-written YAML, one subdirectory per module (product family). The Feed Builder hashes + ed25519-signs it; the client pulls, verifies, and evaluates it against local inventory. You never see customer data — you publish judgment, they apply it locally.

Layout

feed/
  proxmox-ve/
    advisories.yaml
    service_intervals.yaml
  nextcloud/
    advisories.yaml
  postgresql/
    advisories.yaml
    service_intervals.yaml

Each module dir may have advisories.yaml (the verdicts) and/or service_intervals.yaml (recurring reminders). The dir name is the module id; an advisory’s product field is what’s matched against inventory (it need not equal the dir name — e.g. the proxmox-ve/ module also carries ceph advisories).

An advisory, field by field

- product: nextcloud           # matched against each inventory asset's product
  version: "29.0.7"            # the version this advisory is ABOUT (quote it — YAML eats 1.10)
  match: installed             # installed (default) | upgrade-to
  verdict: hold               # the base call (see verdicts below)
  severity: important         # info | recommended | important | critical
  effective: 2026-05-10       # optional: advice is inert before this date
  expires: 2026-08-01         # optional: inert on/after this date (exclusive)
  summary: "Several popular apps fail on 29.0.7; hold for 29.0.8."
  conditions:                 # optional gates — escalate the verdict when local facts hold
    - when: { app_installed: ["calendar", "groupware"] }
      then: blocked
      note: "Calendar + Groupware break on this point release."
  cves: ["CVE-2026-12345"]    # optional: surfaced as CVE highlights on the verdict
  references: ["https://..."] # CITE YOUR SOURCE — the linter warns on an uncited verdict

match — who does this apply to?

  • installed (default): "you are running exactly this version." Use for "you’re on 29.0.7 → hold." Matched when the asset’s version equals version.

  • upgrade-to: forward-looking guidance about moving up to this version. Matched when the asset’s version is below version. Use for "before you go to PVE 9, …​".

affected_max — cover a whole version range with one advisory

For an installed-match advisory, an optional affected_max turns the exact match into an inclusive range [version, affected_max]. So instead of writing one advisory per patch release to "hold all of 26.2.x", write one:

- product: nextcloud
  version: "26.2.0"
  affected_max: "26.2.4"      # applies to every 26.2.0 … 26.2.4 (inclusive)
  verdict: hold
  severity: important
  summary: "All 26.2.x point releases break several apps; hold for 26.3."

Omit affected_max for an exact-version advisory (the default). It’s ignored for upgrade-to. The linter errors on an inverted range (affected_max below version — it would match nothing).

verdict — the call (lowest → highest precedence)

| verdict | meaning | |------|------| | install | safe to install at the customer’s convenience | | install-urgent | install promptly — carries an important fix | | hold | do not move to this version yet; a known issue makes it unwise | | wait-for-patch | hold specifically until the next patch release lands | | eol | this version/line is end-of-life — plan to move off it | | blocked | hard stop: a precondition is unmet (e.g. a dependency ordering) |

Precedence matters. When several signals apply, the engine keeps the highest-precedence one. A condition can only escalate the base verdict (raise it), never lower it — so set the base to the call that holds generally, and use conditions for the stricter cases. (The linter flags a condition whose then can’t outrank the base — it would be dead.)

severity — how loud?

info < recommended < important < critical. This drives notification thresholds: the client delivers important+ verdicts as alerts. Reserve critical for security-driven install-urgent or a blocked that will cause an outage.

conditions — the conditional judgment (the whole point)

Each condition is when (a predicate over local facts) → then (a verdict to escalate to) + an optional note. All present sub-predicates in a when must hold (AND). Predicates:

  • app_installed: [..] — every named app/plugin is installed on the asset. (Nextcloud apps, PostgreSQL extensions, etc.)

  • product_below: { product, version } — another product’s installed version is strictly below version. This is cross-product ordering — the flagship capability. Example: PVE 9 is blocked while ceph is below 19 (Squid).

  • product_at_least: { product, version } — another product’s installed version is at least version. Example: a Ceph maintenance release is routine generally but install-urgent specifically when proxmox-ve is >= 9.

A condition that references a product absent from the inventory does not fire (we act only on positive evidence — a host with no Ceph isn’t blocked by a Ceph-ordering gate).

Dates

effective/expires bound when the advice is live (inclusive start, exclusive end). The client always evaluates the currently effective set, so you can publish advice ahead of time or let it auto-retire. The linter flags an inverted window (effective >= expires) and an already-past expires.

Service intervals (recurring reminders)

# proxmox-ve/service_intervals.yaml
- product: proxmox-ve
  interval: cluster-upgrade-review
  every: 180d                 # d / w / m(~30d) / y(~365d), or a bare number (days)
  note: "Review cluster upgrade posture; check EOL runway."

A reminder is "due" when every has elapsed since the last recorded action (or immediately if never actioned), for products actually present in inventory. The daemon notifies once when each becomes due. Pick a parseable every — the linter errors on an unparseable cadence (it would silently never fire).

The publish workflow

# 1. write/edit YAML under feed/<module>/
# 2. LINT — catch authoring mistakes before signing (dead windows, inert conditions, bad cadences/CVEs, uncited verdicts)
feed-builder lint   --feed feed                 # exits non-zero on errors
# 2b. DIFF (optional) — review what advice changed vs the last published feed before you sign
feed-builder diff   --old ../feed-2026.06.15 --new feed   # added / removed / verdict+content changes
# 3. BUILD — validate + hash every file → manifest.yaml
feed-builder build  --feed feed --feed-version 2026.06.22
# 4. VERIFY — operator self-check against the public key (dry run)
feed-builder verify --feed feed --public-key cadence-feed.pub
# 5. SIGN — detached ed25519 over the manifest → manifest.yaml.sig (needs the secret key)
feed-builder sign   --feed feed --secret-key signing.key
# 6. PUBLISH — copy the dir (manifest.yaml + .sig + module files) to the feed host / CDN
#    Customers pull + verify on their schedule; air-gap mirrors rsync the directory.

Feed version is calendar-style YYYY.MM.DD. Publish on a predictable beat (e.g. weekly) plus out-of-band for urgent verdicts. The secret signing key never leaves your machine and is never committed.

Authoring checklist (run before every publish)

  • Every advisory has a non-empty summary an operator can act on (no jargon-only notes).

  • Base verdict is the call that holds generally; conditions only escalate it.

  • Cross-product gates name the right product (matches what an adapter reports) and version.

  • CVEs are well-formed CVE-YYYY-N….

  • Every advisory cites its source (references): release notes, the CVE record, the EOL schedule — whatever backs the verdict. CADENCE sells auditable, sourced judgment, so an uncited verdict is half-built. The linter warns on a missing reference and on any reference that isn’t a real http(s)://… URL; the committed sample is held to zero such warnings.

  • Dates: effective before expires; nothing already-expired left lying around.

  • feed-builder lint is clean (zero errors), then buildverifysign.

  • Spot-check with feed-builder analyze --feed … --inventory examples/inventory.yaml — do the verdicts read like advice you’d give a client out loud?

Worked examples (in feed-sample/)

  • App-compat gate — nextcloud: hold 29.0.7, blocked if calendar+groupware are installed.

  • Cross-product ordering — proxmox-ve: upgrading to 9 is blocked while ceph < 19 (Squid).

  • product_at_least escalation — ceph 19.2.2 is routine, but install-urgent on PVE 9 clusters.

  • CVE-driven urgency — postgresql 16.3 is install-urgent/critical with CVEs surfaced.

  • Extension gate — postgresql major upgrade blocked while timescaledb is installed.

  • Flagship → long-tail — nextcloud 31 blocked when postgresql is below its required minimum.

Read those YAML files alongside this guide — they are the canonical, lint-clean reference.