Skip to content

Proposal: Reminder subsystem — agent-created, multi-stage, reliable one-shot reminders #1405

Description

@chaodu-agent

Revision 4 — addresses the external blocking review (comment): claim-generation fencing, terminal failure states, /remind migration contract (targets preserved as a transport-level feature), SQLite durability as a deployment contract, honest context-file threat model, platform-native allowed-mentions policy, agent_task provenance envelope, resource limits/backpressure, ctl version envelope, time-contract rules, and verifiable SLO counters.
Revision 3.1 / 3 / 2 — prior team-review rounds; see edit history.

Summary

Proposal for a first-class Reminder subsystem: users speak naturally to their agent ("remind me about my interview next Monday 8am"), the agent registers a reminder through a controlled OpenAB API, and OpenAB reliably delivers notifications at the right times — including multi-stage notifications (day-of, T-60, T-15), restart catch-up, and retry.

Motivation

OpenAB already has two adjacent mechanisms, but neither covers the core use case:

Existing What it does Why it's not enough
/remind (crates/openab-core/src/remind.rs) One-shot delayed mention, relative time only (30m..30d), reminders.json + tokio::sleep Discord slash command only (humans, not agents); no absolute dates; single notification; known persistence reliability gaps
Cron scheduler (crates/openab-core/src/cron.rs) Recurring, operator-configured prompts; minute-aligned; usercron hot-reload Exact-minute matching (no catch-up); stateless job model — no per-item cancel/snooze/dedup/retry; wrong fit for user-created one-shots

Known reliability gaps in the current /remind store (confirmed in code review): non-atomic persistence (crash truncation, parse-failure empty-start), snapshot race (older snapshot can win the write), no in-process retry, and a send/remove duplicate window. Atomic JSON writes alone cannot fix the duplicate window — only a durable delivery journal can distinguish "already sent" from "never sent" after a restart.

Design Overview

Hybrid: reminders become an independent domain with their own engine; recurring jobs stay on the existing cron scheduler. The reminder engine reuses shared infrastructure (minute-tick abstraction, platform adapters, shutdown plumbing) but not the cron job model — one-shot reminders are never translated into POSIX cron entries, and ReminderService must not import cron job types or depend on cron.rs internals.

Human /remind ────────────────┐
Agent CLI (`openab reminder`) ┼──► ReminderService ──► SQLite (WAL) ──► Dispatcher (1/min)
Markdown import (explicit) ───┘        (openab-core)                       │
                                                                           ▼
                                                              Platform adapters (send_notification)
                                                              or synthetic agent prompt

Key principles:

  • SQLite (WAL) is the operational source of truth. rusqlite with bundled + backup features (SQLite ≥ 3.35.0 for RETURNING); no dependency on distro SQLite.
  • ReminderService lives in openab-core; all transports are thin frontends over one typed service.
  • Single dispatcher instance; multi-instance HA is a non-goal.
  • Delivery is at-least-once with best-effort duplicate suppression. Duplicates are bounded by max_attempts and lease timing; the attempt journal records evidence for audit. Exactly-once is not claimed.
  • Direct notification by default; the agent is only woken for agent_task reminders.
  • Markdown is an export/import surface only — never a watched inbox with implicit execution authority.

Data Model

All timestamps are INTEGER Unix epoch (milliseconds, UTC).

reminders
  id                TEXT PRIMARY KEY          -- stable ULID
  principal_scope   TEXT NOT NULL             -- opaque internal scope ID (see derivation below)
  title             TEXT NOT NULL CHECK (length(title) <= 256)
  body              TEXT CHECK (body IS NULL OR length(body) <= 4096)
  kind              TEXT NOT NULL CHECK (kind IN ('notify','agent_task'))  -- unknown kinds rejected at insert
  event_at          INTEGER NOT NULL
  timezone          TEXT NOT NULL             -- IANA tz, validated via chrono_tz at insert
  precision         TEXT NOT NULL             -- 'date' | 'minute'
  notification_policy TEXT NOT NULL           -- stored JSON: stages requested (participates in payload hash)
  platform          TEXT NOT NULL
  channel_id        TEXT NOT NULL             -- bound server-side, never agent-supplied
  thread_id         TEXT
  requester_id      TEXT NOT NULL             -- user who asked (owns cancel/snooze)
  targets           TEXT                      -- JSON array of mention targets (max 10); human /remind only
  created_by_agent  TEXT                      -- audit only
  source_text       TEXT CHECK (source_text IS NULL OR length(source_text) <= 1024)
  status            TEXT NOT NULL             -- 'active' | 'completed' | 'cancelled'
  completion_reason TEXT                      -- NULL | 'all_delivered' | 'partial_failure' | 'user_cancelled'
  idempotency_key   TEXT NOT NULL
  payload_hash      TEXT NOT NULL
  created_at        INTEGER NOT NULL
  UNIQUE(principal_scope, idempotency_key)

deliveries
  reminder_id       TEXT NOT NULL REFERENCES reminders(id)
  stage             TEXT NOT NULL             -- 'once' (single-stage / legacy) | 'day' | 't60' | 't15'
  due_at            INTEGER NOT NULL
  stale_after       INTEGER NOT NULL
  state             TEXT NOT NULL             -- 'pending' | 'claimed' | 'delivered' | 'failed' | 'stale' | 'cancelled'
  attempts          INTEGER NOT NULL DEFAULT 0
  max_attempts      INTEGER NOT NULL DEFAULT 5
  next_retry_at     INTEGER
  lease_expires_at  INTEGER
  claim_generation  INTEGER NOT NULL DEFAULT 0   -- fencing token (see claim protocol)
  delivered_at      INTEGER
  last_error        TEXT
  UNIQUE(reminder_id, stage)                  -- one delivery ROW per stage; NOT a send-uniqueness guarantee

attempts                                      -- append-only send journal
  attempt_id        TEXT PRIMARY KEY
  reminder_id       TEXT NOT NULL
  stage             TEXT NOT NULL
  claim_generation  INTEGER NOT NULL          -- which claim this attempt belongs to
  batch_id          TEXT                      -- aggregated sends: one physical send covering multiple stages
  started_at        INTEGER NOT NULL
  outcome           TEXT                      -- NULL (in-flight) | 'delivered' | 'failed' | 'interrupted' (lease reclaimed)
  error             TEXT

Schema versioning: SQLite PRAGMA user_version, forward-only numbered migrations at startup. PR 1 ships single-stage reminders using stage = 'once'; multi-stage (day/t60/t15) arrives in PR 3 on the same schema. Snooze state (count, regeneration) is specified and added in PR 3's migration.

principal_scope derivation

Structured key resolved by the capability resolver — never delimiter-joined strings:

struct PrincipalScopeKey { installation: String, platform: String, requester_id: String, conversation_id: String }

Stored principal_scope = SHA-256 over the canonical JSON of this key. External UIDs are audit/display attributes; authorization and idempotency namespaces use only the derived scope ID.

payload_hash canonicalization

SHA-256 over CreateReminderCanonicalV1 = {schema_version, title, body, kind, event_at, timezone, precision, notification_policy, targets} (keys sorted, no insignificant whitespace). The same typed object drives validation, hashing, and insert. Audit-only fields (source_text, created_by_agent) excluded. Same key + different hash → 409.

Reminder status state machine

active ──(all deliveries reach a terminal state; none failed)──► completed (reason: all_delivered)
active ──(all deliveries terminal; at least one failed)────────► completed (reason: partial_failure)
active ──(user cancel)─────────────────────────────────────────► cancelled (reason: user_cancelled)
completed/cancelled ──(retention expired, default 30 days)─────► deleted

Terminal delivery states: delivered, stale, failed, cancelled. The parent transition happens in the same transaction that finalizes the last delivery — no reminder can remain active forever. partial_failure reminders are flagged in list output and counted in reminder_partial_failure_total.

Cancel: UPDATE deliveries SET state='cancelled' WHERE reminder_id=? AND state IN ('pending','claimed') in the same transaction as the status change. A send already past the platform API call may still arrive once (at-least-once); fencing prevents it from being claimed or finalized again.

Notification expansion policy

Input precision Stages
Relative delay / single-shot (--delay, legacy) once
Date only day
Date + time day + t60 + t15

day fires at the configurable day-start time (default 08:00, settable to 00:00), interpreted as local time in the reminder's own timezone. Edge rules: t60 already past at creation → one immediate catch-up, keep t15; event ≤ day-start → skip day, merge into t60; simultaneous overdue stages → one aggregated message (shared batch_id in the journal); stages visually distinguished (📅//🔔).

Dispatcher & Reliability

Claim protocol (fenced)

-- Step 1: reclaim expired leases; mark orphaned attempts
UPDATE deliveries SET state='pending', lease_expires_at=NULL
WHERE state='claimed' AND lease_expires_at < :now;
UPDATE attempts SET outcome='interrupted'
WHERE outcome IS NULL AND (reminder_id, stage) IN (…reclaimed rows…);

-- Step 2: mark stale
UPDATE deliveries SET state='stale' WHERE state='pending' AND stale_after < :now;

-- Step 3: fenced, bounded claim
UPDATE deliveries
SET state='claimed', lease_expires_at=:now + :lease_ttl,
    attempts = attempts + 1, claim_generation = claim_generation + 1
WHERE state='pending' AND due_at <= :now
  AND attempts < max_attempts
  AND (next_retry_at IS NULL OR next_retry_at <= :now)
  AND rowid IN (SELECT rowid FROM deliveries WHERE state='pending' AND due_at <= :now
                ORDER BY due_at LIMIT :claim_batch_limit)
RETURNING reminder_id, stage, claim_generation;
  • Every provider call is one claim: workers hold (reminder_id, stage, claim_generation). The immediate in-process retry after a failed send goes through the same claim transition (conditional re-claim: generation+1, attempts+1, attempts < max_attempts check) — attempt counting is consistent and exhausted rows are excluded from claiming.
  • All terminal updates are fenced: UPDATE … WHERE state='claimed' AND claim_generation = :gen. affected_rows = 0 → the claim was reclaimed/cancelled; the late worker discards its result (and checks the parent's terminal state to avoid zombie retries). Attempt-journal rows keep the late worker's record for audit.
  • Aggregated sends insert one attempt row per covered stage sharing a batch_id, so a physical send is traceable to all stages it covered.
  • Transactions: claim + attempt-insert commit together; outcome update + delivery state change commit together.
  • Lease TTL 60s; graceful shutdown drains 30s then expires remaining leases immediately.
  • Backoff via next_retry_at (1m, 2m, 4m, 8m, capped 1h), bounded by max_attempts; exhaustion → failed (visible in list, metrics, and best-effort owner notification).
  • Stage-aware staleness precomputed into stale_after.

Durability contract (deployment)

New [reminder] config: database_path (default ~/.openab/reminders.db), durability_mode = auto|durable|transient, wal_checkpoint_interval_seconds (default 300). Connections open with PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; periodic wal_checkpoint(RESTART); shutdown checkpoint TRUNCATE. Backups use the SQLite backup API after a checkpoint — never file-copy a live WAL database.

Runtime Persistence SLO
Helm chart /home/agent PVC already mounted durable
Raw k8s manifest PR 1 fixes k8s/deployment.yaml to mount /home/agent (currently .openab is not persisted) durable after fix
Docker / local host filesystem durable
ECS Fargate (MVP) no persistent volume → durability_mode = "transient"; restart catch-up and the SLO explicitly do not apply; EFS support is post-MVP degraded, documented

The 99.9% SLO is only claimed for durable deployments.

SLO measurement (verifiable)

Counters, not just a histogram: reminder_due_total{stage,kind} (denominator — incremented when a stage reaches a terminal outcome, including failed and stale) and reminder_delivered_within_slo_total{stage,kind} (numerator — first successful delivery within due_at + 120s), plus reminder_delivered_after_slo_total, reminder_overdue_seconds histogram (observability), and quota/backpressure gauges. Exposed via a new /metrics Prometheus endpoint on the existing axum server. Burn-rate alerts for the 99.9%/30-day objective: 1h window burn rate > 14.4 AND 6h window > 6.

Delivery Kinds

notify (default)

Domain produces a structured notification — never raw mention syntax:

Notification { text, mention: None | Requester | Targets(list), reply_scope }

A new adapter method send_notification(options) maps this to platform capabilities. Discord: allowed_mentions with parse: [] and an explicit users allowlist (requester, plus stored targets for human /remind reminders) — server-side enforcement; content containing @everyone/<@…> cannot ping anyone outside the allowlist. NFKC normalization is retained only as defense-in-depth against visual spoofing. Platforms without mention APIs degrade to plain text.

agent_task — a delayed user request, not a privileged system actor

Fires a synthetic prompt into the reminder's origin thread with a [Reminder]-prefixed message. The synthetic event carries an immutable provenance envelope, separate from the untrusted payload:

event_type=reminder.agent_task, reminder_id, principal_scope,
original_requester_id, origin_message_id, created_at, scheduled_at

Rules: the stored body is delayed, untrusted user content authorized by the original requester — it gains no system/developer precedence and bypasses no agent permission policy; the synthetic turn's context has an empty allowed_operations (no reminder-create — prevents self-scheduling loops; broader capability requires explicit configuration); conversation/user allowlists are re-checked at fire time, and a suspended requester's reminders are cancelled rather than fired; agent unavailability follows normal retry/backpressure. Display includes attribution ("set by {requester}"). Delivery is delivered when the prompt handoff is ACK'd; task outcome belongs to the agent.

Agent-Facing API

ReminderService (sealed context)

trait ReminderService {
    async fn create(&self, req: CreateReminder, ctx: AuthenticatedContext) -> Result<CreatedReminder>;
    async fn list(&self, filter: ListFilter, page: Page, ctx: AuthenticatedContext) -> Result<PageOf<ReminderView>>;
    async fn cancel(&self, id: ReminderId, ctx: AuthenticatedContext) -> Result<()>;
    async fn snooze(&self, id: ReminderId, by: Duration, ctx: AuthenticatedContext) -> Result<SnoozeResult>; // PR 3
    async fn lookup_operation(&self, op_id: OperationId, ctx: AuthenticatedContext) -> Result<Option<CreatedReminder>>;
}

AuthenticatedContext is sealed (crate-private constructor; only the capability resolver / platform auth boundary builds it). Transports submit payloads only.

Transports & the /remind migration contract

Targets are a transport-level feature:

  • Human /remind (Discord): behavior preserved exactly — up to 10 arbitrary user/role targets, 5-active-per-user quota. Targets are stored and delivered through the allowed_mentions allowlist.
  • Agent CLI: requester-only; targets defaults to [requester_id], other targets rejected (cross-target delivery is a post-MVP capability).

Legacy reminders.json migration (PR 1): a one-time idempotent import inside a transaction — each legacy record maps to a stage='once' reminder with its targets preserved; malformed JSON aborts with the original file left untouched and an error surfaced (never silent empty-start); on success the old file is renamed to reminders.json.migrated.<timestamp>; a crash mid-migration is recoverable because the import is transactional and the rename happens last. Legacy records exceeding the quota are honored (grandfathered) but count toward the quota for new creations.

ctl protocol envelope (backward compatible)

Request gains version (serde default 1) and namespace (default "core"): legacy {"action":"set",…} clients work unchanged; openab reminder sends version: 2, namespace: "reminder", action: create|list|cancel|lookup. Unknown version/namespace → structured error (stable machine-readable codes; documented CLI exit codes; request size limits). Handshake includes min_supported_version so future bumps degrade gracefully.

Identity binding — honest threat model

Trust boundary: the capability mechanism protects against cross-session and cross-principal identity theft (other processes on the machine, other sessions, external socket callers). Same-UID processes within one agent session — including background children spawned in earlier turns — are inside the trust boundary: a stale child that keeps polling the context file path can read the next turn's handle. 0600/O_NOFOLLOW/SO_PEERCRED do not change this. This is an accepted limitation of the CLI/context-file transport; strong per-turn sender binding is a prerequisite reserved for the future host-mediated native tool.

Consequences:

  • Multi-user threads fail closed: the daemon tracks observed distinct requesters per session (server-side state, not agent claims); once a second requester appears, CLI reminder capability is disabled for that session. Opt-in re-enablement must document that session members share one execution trust domain.
  • Mechanics retained (still useful within the model): per-connection context file (OPENAB_CONTEXT_FILE, path overridable for containers), per-turn atomic handle rotation, server-side registry (session, turn, SenderContext, expiry, allowed_operations), turn-end revocation with in-flight grace, O_NOFOLLOW + regular-file checks, log redaction (capability record IDs only).
  • Routing IDs from the caller are always ignored; identity comes from the server-side lookup.

Idempotency

Unchanged from R3 (transport-level client_operation_id with pre-dispatch spool persistence and lookup reconcile; service-level UNIQUE(principal_scope, idempotency_key) + payload-hash comparison in-transaction; transient failures retry with the same key).

Time contract

  • Default timezone source: reminder-level --tz → per-user/config default ([reminder].default_timezone) → UTC (with a warning in the ack).
  • --at with an explicit offset: the offset defines the instant; if --tz is also given, it sets the display/expansion timezone; if the offset and --tz disagree on the local wall time, the request is rejected as ambiguous.
  • --at without offset (naive): requires --tz or a configured default; otherwise rejected.
  • DST: gap → first valid minute; fold → first occurrence. Ack always echoes the resolved instant, timezone, and all stage times.

Resource Limits & Backpressure

  • Per-principal active quota (default 5, config-tunable; counted inside the create transaction to prevent concurrent bypass); separate lower quota for agent_task (default 3); global active cap (default 10,000).
  • Max stages per reminder, max payload size (bounds above), max scheduling horizon (default 366 days), idempotency-key length bound.
  • Bounded claim batches (claim_batch_limit, default 100/tick); fixed delivery concurrency with a bounded queue; platform rate-limit errors are transient failures (backoff).
  • Create/cancel/lookup rate limits; quota rejection → structured error (503 + Retry-After on the CLI path).
  • list is paginated. Import size bounded.
  • Metrics: reminder_quota_rejected_total, claim batch size, oldest pending age, queue saturation gauge; alert at 80% of global cap.

Markdown's Role / Security Checklist / Testing

Markdown export/import rules unchanged (explicit import only; O_NOFOLLOW both directions; atomic writes). Security checklist unchanged except: mention safety is now the adapter allowed_mentions policy (primary) + NFKC (defense-in-depth); threat model statement replaced as above.

Testing additions on top of R3.1's ten requirements:

  1. test_stale_worker_update_fenced_by_generation_token — late worker after reclaim/cancel cannot overwrite newer state; its attempt rows remain for audit.
  2. Terminal-state completeness — any combination of delivered/stale/failed/cancelled stages transitions the parent out of active; partial_failure set when any stage failed.
  3. Legacy migration — targets preserved, malformed JSON aborts loudly with original file intact, re-running the import is a no-op, crash mid-import recovers.
  4. agent_task restricted context — synthetic turn has empty allowed_operations; provenance envelope immutable; payload cannot forge envelope fields.
  5. Fenced immediate retry — retry increments generation and attempts; exhausted rows are never re-claimed.
  6. Quota race — concurrent creates cannot exceed the per-principal quota.
  7. Durability modes — transient mode disables catch-up claims cleanly and logs the degradation.

Implementation Plan

PR Scope
PR 1 Core engine (fenced claims, terminal states, attempt journal), SQLite + [reminder] config + durability modes, /remind migration (targets + quota preserved, idempotent import), k8s/deployment.yaml mount fix, /metrics + SLO counters. Independently shippable; usage/failure metrics gate PR 2.
PR 2 openab reminder CLI + ctl version envelope + capability context file (same-UID trust model, multi-user fail-closed)
PR 3 Multi-stage expansion (day/t60/t15), absolute time & timezone contract, snooze (presets 5m/15m/1h, max 3, schema migration for snooze state), cancel UX
PR 4 agent_task (provenance envelope, restricted synthetic context) + Markdown export/import

Post-MVP: native ACP/MCP tool (strong per-turn binding), recurring reminders, cross-channel targets, EFS/ECS durable storage, per-turn context path rotation, multi-instance HA.

Alternatives Considered

(unchanged from R3 — improved /remind 2.0 only; one cron entry per reminder; agent-runtime storage; Markdown-as-database; wake-agent-for-everything; native tool as MVP transport — see edit history for rationale.)


Prepared by chaodu-agent based on multi-angle internal review and external community review. Thanks to the external reviewer for the substantive blocking feedback incorporated in Revision 4.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions