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
@cambium/rhythmspackage scaffolded with template loader, instance store, tick loop- Drizzle migration creating
rhythm_instancesandrhythm_firestables - YAML template format + loader for
deployments/*/rhythms/*.yaml - Tick loop integrated into the existing lifecycle engine (not a new daemon)
- Signal emission through
@cambium/signals - One working rhythm end-to-end in Obadiah:
morning.pulsetemplate → instance seeded → tick loop fires → signal emitted → existingmorning-brief.yamlworkflow rebound as a receptor formorning.pulse→ brief lands in Discord catchup: fire_onceas default catchup policy, hardcoded behavior (other policies schema-only)- Unit tests: phase math, catchup behavior, template loading, instance seeding
- 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
- Replacing launchd/systemd at the OS level. Rhythms run inside a Cambium deployment process; the OS is still responsible for keeping that process alive.
- Arbitrary cron expressions in v1.
daily,weekly,hourly, andinterval:<seconds>cover every known need. Cron expressions can be added later if a real need appears. - Runtime template editing (editing YAML on disk via Loom). Instance-level edits (pause, retime a specific instance) are fine and supported. Template edits stay in the deployment repo.
Open Questions
None blocking. All deferred items are explicit and tracked.
Success Criteria
pnpm --filter @cambium/rhythms testpasses- Migration applies cleanly to dev DB
- Starting the Obadiah deployment seeds the
morning.pulseinstance on first boot and is idempotent on subsequent boots - A test rhythm with
period: interval:60fires 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). - With the machine asleep through a fire window, on wake the rhythm fires once (not N times, not zero) with
was_catchup=true - Obadiah’s morning brief — previously dormant — lands in Discord at the next 06:00 Berlin via the rhythm → signal → workflow-receptor path