Matrix logo

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:

KindDescriptionTime fields
onceFires a single time, then transitions to fired or failedfire_at (absolute) or delay_seconds (relative, resolved at creation)
cronRecurring; reschedules to next fire after each successful deliverycron_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

StatusMeaningTransition
activeWaiting to fire or currently leasedInitial state on creation
firedA once alarm that fired successfully (retained for audit)Dispatch worker marks after successful wake delivery
cancelledExplicitly cancelled by the ownerDELETE /v1/alarms/{id}
failedWake 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/).