Data Model
The alarms table IS the durable timer (invariant i1). Every scheduled wake is a single row. State lives in Postgres, never in memory.
The alarms table IS the durable timer (invariant i1). Every scheduled wake is a single row. State lives in Postgres, never in memory, so a Chronos restart never loses a scheduled wake.
Source files: migrations/001_init.sql, pkg/types/types.go, internal/store/alarms.go.
The alarms table
CREATE TABLE IF NOT EXISTS alarms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_did TEXT NOT NULL, -- did:matrix:<user_id>:<keyfp>
user_id TEXT NOT NULL, -- Supabase user UUID = router wake target
label TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL CHECK (kind IN ('once', 'cron')),
fire_at TIMESTAMPTZ, -- once: absolute moment to fire
cron_expr TEXT NOT NULL DEFAULT '', -- cron: 5-field / @descriptor / @every
timezone TEXT NOT NULL DEFAULT 'UTC', -- IANA tz for cron evaluation
next_fire_at TIMESTAMPTZ NOT NULL, -- dispatch claim key
conversation_id TEXT NOT NULL DEFAULT '', -- conversation to resume into ('' = fresh)
wake_message TEXT NOT NULL DEFAULT '', -- agent-authored resume turn (verbatim)
payload JSONB NOT NULL DEFAULT '{}'::jsonb, -- opaque state echoed back on wake
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'fired', 'cancelled', 'failed')),
idempotency_key TEXT NOT NULL DEFAULT '', -- per-owner dedup key ('' = none)
max_failures INT NOT NULL DEFAULT 5, -- wake-delivery retry ceiling
failure_count INT NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
claimed_at TIMESTAMPTZ, -- dispatch lease (NULL = unclaimed)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_fired_at TIMESTAMPTZ
);
Indexes
Three indexes support the hot paths:
-- Dispatch worker claim path: due, active alarms ordered by next_fire_at
CREATE INDEX IF NOT EXISTS alarms_due_idx
ON alarms (next_fire_at)
WHERE status = 'active';
-- Owner-scoped listing
CREATE INDEX IF NOT EXISTS alarms_owner_idx
ON alarms (owner_did, created_at DESC);
-- Per-owner idempotency: re-posting the same key is a no-op
CREATE UNIQUE INDEX IF NOT EXISTS alarms_idempotency_idx
ON alarms (owner_did, idempotency_key)
WHERE idempotency_key <> '';
The Alarm struct
The Go types.Alarm struct maps 1:1 onto the table row. It is the internal representation used by the store, dispatch, and server packages.
type Alarm struct {
ID string
OwnerDID string // full agent DID: did:matrix:<user_id>:<keyfp>
UserID string // Supabase user UUID from the DID label
Label string
Kind string // "once" | "cron"
FireAt *time.Time // once: absolute moment
CronExpr string // cron: expression
Timezone string // IANA tz for cron
NextFireAt time.Time // dispatch claim key
ConversationID string
WakeMessage string
Payload json.RawMessage // opaque state echoed on wake
Status string // "active" | "fired" | "cancelled" | "failed"
IdempotencyKey string
MaxFailures int
FailureCount int
LastError string
ClaimedAt *time.Time // dispatch lease
CreatedAt time.Time
UpdatedAt time.Time
LastFiredAt *time.Time
}
The View projection
The types.View struct is the JSON projection returned to the agent. It omits internal fields (owner_did, user_id, fire_at, claimed_at, updated_at) and only includes next_fire_at when the alarm is active.
type View struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
CronExpr string `json:"cron_expr,omitempty"`
Timezone string `json:"timezone,omitempty"`
NextFireAt *time.Time `json:"next_fire_at,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
WakeMessage string `json:"wake_message"`
Payload json.RawMessage `json:"payload,omitempty"`
Status string `json:"status"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
MaxFailures int `json:"max_failures"`
FailureCount int `json:"failure_count"`
LastError string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastFiredAt *time.Time `json:"last_fired_at,omitempty"`
}
High-entropy fields (payload, wake_message, IDs) are passed through verbatim (invariant i4).
Alarm kinds
There are exactly two alarm kinds:
| Kind | Description | Time fields |
|---|---|---|
once | Fires a single time, then transitions to fired or failed | fire_at (absolute) or delay_seconds (relative, resolved at creation) |
cron | Recurring; reschedules to next fire after each successful delivery | cron_expr (5-field / @descriptor / @every Nm), timezone, next_fire_at |
The user cases collapse onto these two: "in 10 minutes" and "at a specific time" are both once; "every day/hour/N" is cron.
Lifecycle statuses
| Status | Meaning | Transition |
|---|---|---|
active | Waiting to fire or currently leased | Initial state on creation |
fired | A once alarm that fired successfully (retained for audit) | Dispatch worker marks after successful wake delivery |
cancelled | Explicitly cancelled by the owner | DELETE /v1/alarms/{id} |
failed | Wake delivery exhausted max_failures (once only) | Dispatch worker marks after retry ladder exhaustion |
For cron alarms, a permanently failed fire advances to the next occurrence (skip-and-advance) rather than marking failed, so one bad fire does not wedge the series.
Idempotency
When a CreateAlarm request carries a non-empty idempotency_key that already exists for the same owner_did, the existing row is returned with deduped=true and no duplicate is created. The unique partial index (owner_did, idempotency_key) WHERE idempotency_key <> '' enforces this at the database level.
Migration ledger
Chronos uses its own migration table chronos_schema_migrations to avoid colliding with the router and gateway, which share the same Postgres database. Migrations are forward-only SQL files applied in lexical order from the configured migrations_dir (default migrations/).
