cambium ▸ docs/superpowers/specs/2026-04-11-cambium-rhythms-design.md
updated 2026-04-12

Cambium Rhythms — Design

Date: 2026-04-11 Status: Approved for implementation planning Package: @cambium/rhythms

Purpose

Give Cambium a native scheduling substrate so workflows can fire on recurring cycles (daily, weekly, hourly) without relying on OS-level cron or launchd. The first concrete need is Obadiah’s morning brief firing at ~6am Berlin every day, but the design serves any deployment that needs time-driven pulses.

Framing: Rhythms, not Cron

This is not a cron engine. It is a rhythms engine.

A rhythm is a living cycle in the system — it has a period, a phase, state, and (eventually) entrainment. When its phase arrives, it emits a signal onto the bus. Downstream receptors respond. The scheduler’s only job is turning time and environmental context into pulses the rest of Cambium can feel.

This framing matters because it keeps Rhythms consistent with the rest of the nature-based substrate (Genesis, Signals, Stigmergy, Health) and avoids reintroducing flat cron semantics into a system that has deliberately moved past them.

Core Concepts

Rhythm template. A declarative definition shipped with a deployment (YAML). Describes an intended cycle: name, period, default phase, default timezone, the signal it emits, catchup policy, and an optional entrainment binding (stubbed in v1).

Rhythm instance. A living row in the database, created when a deployment seeds its templates. Each instance tracks current phase, last fired, next fire, state (active/paused/quarantined), and fire history. Instances are editable at runtime — pause, resume, phase-shift — without requiring a redeploy.

Seeding. On deployment boot, the Rhythms package loads templates from deployments/<name>/rhythms/*.yaml and ensures a matching instance exists in the DB for each. This mirrors how Genesis seeds agent instances from templates — templates are the replicable shape, instances are the living thing.

Tick loop. A single loop inside each deployment’s existing lifecycle engine wakes on a short interval (~30s), queries for any rhythm instances whose next_fire_at <= now, emits their signal, writes a fire record, advances phase, and recomputes next_fire_at. No new daemon, no external process.

Signal emission. When a rhythm fires, it emits a signal through the existing @cambium/signals bus. The signal payload includes the rhythm name, the intended phase time, the actual fire time, and any metadata from the template. Receptors are bound to these signals the same way they bind to any other Cambium signal — workflows become receptors for rhythm signals instead of being invoked directly by a scheduler.

Catchup policy. When a fire window is missed (deployment was down, machine asleep, etc.), the rhythm consults its template’s catchup field: - fire_once — fire the signal once on next tick, late. This is the v1 default. - skip — record the miss, move on. (Deferred — schema-ready, not implemented in v1.) - coalesce — one signal for N missed fires. (Deferred — schema-ready, not implemented in v1.)

Data Model

Template (YAML)

# deployments/obadiah/rhythms/morning-pulse.yaml
name: morning.pulse
period: daily
phase: "06:00"
timezone: "Europe/Berlin"
emits:
  signal: morning.pulse
  payload:
    intent: "morning brief"
catchup: fire_once
entrainment: null  # v1: stubbed, always null

Instance (Drizzle schema, new migration)

rhythm_instances
  id                uuid pk
  deployment_id     fk
  template_name     text        -- matches template `name` field
  period            text        -- "daily" | "weekly" | "hourly" | "interval:<seconds>"
  phase             text        -- e.g. "06:00"
  timezone          text
  signal_name       text        -- what signal to emit
  signal_payload    jsonb
  catchup_policy    text        -- "fire_once" | "skip" | "coalesce"
  entrainment       jsonb       -- null in v1; reserved for later
  state             text        -- "active" | "paused" | "quarantined"
  last_fired_at     timestamptz
  next_fire_at      timestamptz
  created_at        timestamptz
  updated_at        timestamptz

rhythm_fires
  id                uuid pk
  rhythm_id         fk
  intended_at       timestamptz -- phase time in the deployment timezone
  actual_at         timestamptz -- when it actually fired
  signal_id         fk          -- link to the emitted signal
  was_catchup       boolean

Package Boundaries

@cambium/rhythms depends on: - @cambium/core — branded IDs, contracts - @cambium/db — Drizzle schemas, migrations - @cambium/signals — to emit on the bus - @cambium/lifecycle — to hook its tick loop into the existing engine

@cambium/rhythms does NOT depend on: - @cambium/hypha — rhythms don’t know about workflows. Workflows become receptors for rhythm signals. This keeps the layering clean and means rhythms serve any downstream system, not just Hypha.

What Ships in v1

  1. @cambium/rhythms package scaffolded with template loader, instance store, tick loop
  2. Drizzle migration creating rhythm_instances and rhythm_fires tables
  3. YAML template format + loader for deployments/*/rhythms/*.yaml
  4. Tick loop integrated into the existing lifecycle engine (not a new daemon)
  5. Signal emission through @cambium/signals
  6. One working rhythm end-to-end in Obadiah: morning.pulse template → instance seeded → tick loop fires → signal emitted → existing morning-brief.yaml workflow rebound as a receptor for morning.pulse → brief lands in Discord
  7. catchup: fire_once as default catchup policy, hardcoded behavior (other policies schema-only)
  8. Unit tests: phase math, catchup behavior, template loading, instance seeding
  9. Integration test: full Obadiah morning-pulse → signal → workflow receptor → Discord output path

Deferred (intentional)

Each deferred item has an intentional reason and a trigger for pickup. These are captured in GSD’s seed/backlog system separately so they surface at the right moment.

Item Why deferred Trigger for pickup
Entrainment (rhythms phase-shift based on environmental signals like user.awake) Core tick loop must prove reliable first. Schema field is reserved; additive when ready. Rhythms v1 has been ticking reliably in Obadiah for ~1 week with no missed fires.
Loom Rhythms page (clock-face visualization, pause/resume/phase-shift UI) Headless rhythms work fine for v1. Loom UI is additive and doesn’t block Obadiah. Next time significant Loom UI work is scheduled.
catchup: skip and catchup: coalesce fire_once handles every v1 use case. Schema is ready. A concrete use case appears where fire_once is wrong.
Cross-deployment rhythm sharing (one rhythm drives signals into multiple deployments) Out of scope. Each deployment has its own rhythms. Not planned. Revisit only if a real need emerges.
Distributed scheduling (multi-node tick loop with leader election) Single process per deployment is sufficient and simpler. Not planned until multi-node deployments exist.

Non-Goals

Open Questions

None blocking. All deferred items are explicit and tracked.

Success Criteria

  1. pnpm --filter @cambium/rhythms test passes
  2. Migration applies cleanly to dev DB
  3. Starting the Obadiah deployment seeds the morning.pulse instance on first boot and is idempotent on subsequent boots
  4. A test rhythm with period: interval:60 fires a signal every ~60 seconds for at least 5 minutes. Fires land within one tick-interval of intended time (i.e. ≤ 30s late given a 30s tick loop), with no cumulative drift — the 5th fire is at t=300s ±30s, not at t=300s + (5 × jitter).
  5. With the machine asleep through a fire window, on wake the rhythm fires once (not N times, not zero) with was_catchup=true
  6. Obadiah’s morning brief — previously dormant — lands in Discord at the next 06:00 Berlin via the rhythm → signal → workflow-receptor path