Revision 5 — addresses the second external review round: claim-candidate CTE (fixes batch starvation), reclaim terminalization (exhausted rows → failed), typed mention targets (user/role) with a sealed TargetPolicy enforced in ReminderService, a migration ledger for idempotent legacy import, honest completion reasons (completed_with_misses), due-event accounting at due_at crossing, agent_task enqueue-ACK semantics + overlap protection, and burn-rate alerts demoted to optional config.
Revision 4 — addressed the first external blocking review (comment).
Revision 3.1 / 3 / 2 — prior team-review rounds; see edit history.
⚠️ Scope Contract — final (owner decision, supersedes conflicting text below)
This document records the full design space explored during two internal and two external review rounds. Implementation follows a keep-it-simple, best-effort, fast-iteration policy. Where the sections below conflict with this contract, this contract wins; the detailed mechanisms remain as design reference for future phases, not work orders.
Design boundaries (assumptions & non-goals)
- Single-owner, self-hosted deployment. Not multi-tenant; no tenant isolation.
- Single-user reminder scope. Multi-user threads fail closed for agent-created reminders; no shared/collaborative reminders.
- Single agent trust domain. The agent and its child processes are one trust boundary (same-UID model).
- Single process, single dispatcher. No HA, no multi-instance coordination, no distributed locking — and in Phase 1, no concurrent delivery workers at all.
- Local filesystem storage, deployer-owned persistence. No SLA — delivery is best effort. If the environment has no persistent storage or backups, loss is accepted and documented. No
durability_mode config; one documented default.
- Small scale. ~5 active reminders per user, order-of-hundreds system-wide. No pagination/perf/backpressure engineering.
- One-shot only. Recurring stays on cron, permanently.
- Minute-level precision.
- Destination = origin conversation only. No cross-channel/cross-platform delivery.
- Discord first. Adapter interface stays generic; only Discord is implemented.
- Natural-language time parsing belongs to the agent. OpenAB accepts structured times only (ISO 8601 / delay).
- Reminder content is data. Due notifications are direct sends; no LLM in the delivery path.
- The only reliability claim: reminders survive restarts on durable storage, and are not duplicated in normal operation. Crash-window duplicates are possible and accepted (best effort).
Phase 1 (commit now) — the smallest useful slice
- SQLite (rusqlite bundled, WAL) — single table (one reminder = one due time; multi-stage is Phase 3)
- Minute tick +
due_at <= now catch-up; sends run inline and sequentially inside the tick
- States:
pending → delivered | stale | cancelled. No retry mechanism: a failed send simply stays pending and is naturally re-attempted on the next tick until stale_after (end of the reminder's local day), then marked stale. No lease, no claim, no claim_generation fencing, no attempts journal, no backoff columns — that machinery exists only to serve concurrency and retry scheduling, which Phase 1 does not have.
- Late-after-restart policy: still within the same local day → send (marked as delayed); past it →
stale.
/remind migrates onto the new store with behavior unchanged (targets and 5/user quota untouched, mention path unchanged). Legacy reminders.json: one-time best-effort transactional import keyed on legacy stable IDs (rename to archive on success; on parse failure keep the file, log a warning, start empty — no ledger table).
k8s/deployment.yaml mount fix so .openab persists.
Phase 2 (commit next)
openab reminder create/list/cancel CLI over the ctl socket (versioned envelope), requester-only, per-turn sender binding under the same-UID trust model. allowed_mentions policy applies to this new send path.
Phase 3 (likely)
- Absolute time + timezone contract,
day/t60/t15 expansion, snooze.
Explicitly not built now (design reference only)
Retry/backoff scheduling, lease/claim/fencing protocol, attempts journal, migration ledger table, /metrics + SLO counters + burn-rate examples, quota systems beyond the existing 5/user, typed TargetPolicy machinery beyond keeping /remind as-is, agent_task, Markdown export/import, durability_mode, multi-platform delivery, native ACP/MCP tool. If enterprise-grade guarantees are ever needed, that may be a separate effort.
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 typed targets [{"kind":"user"|"role","id":"..."}] (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' | 'completed_with_misses' | '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; targets entries are typed {kind, id} pairs, so User("123") and Role("123") hash differently). 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 terminal; all delivered)──────────────────────────► completed (reason: all_delivered)
active ──(all terminal; ≥1 stale, none failed)──────────────────► completed (reason: completed_with_misses)
active ──(all terminal; ≥1 failed)──────────────────────────────► completed (reason: partial_failure)
active ──(user cancel)──────────────────────────────────────────► cancelled (reason: user_cancelled)
completed/cancelled ──(retention expired, default 30 days)──────► deleted
Precedence: partial_failure > completed_with_misses > all_delivered. all_delivered is claimed only when every stage was actually delivered — a reminder whose stages all went stale reports completed_with_misses, never all_delivered. list output maps reasons to honest user-facing labels (✅ delivered / ⚠️ completed but some notifications missed / ❌ some notifications failed / 🚫 cancelled).
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 1a: mark in-flight attempts of expired leases as interrupted (exact generations)
UPDATE attempts SET outcome='interrupted'
WHERE outcome IS NULL
AND (reminder_id, stage) IN (
SELECT reminder_id, stage FROM deliveries
WHERE state='claimed' AND lease_expires_at < :now);
-- Step 1b: reclaim expired leases; terminalize exhausted rows in the same transaction
UPDATE deliveries
SET state = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'pending' END,
lease_expires_at = NULL
WHERE state='claimed' AND lease_expires_at < :now;
-- Step 2: mark stale
UPDATE deliveries SET state='stale' WHERE state='pending' AND stale_after < :now;
-- Step 3: fenced, bounded claim — the candidate query carries EVERY eligibility
-- predicate, so ineligible head rows can never starve eligible rows behind them
WITH candidates AS (
SELECT rowid FROM deliveries
WHERE state='pending' AND due_at <= :now
AND attempts < max_attempts
AND (next_retry_at IS NULL OR next_retry_at <= :now)
ORDER BY due_at
LIMIT :claim_batch_limit
)
UPDATE deliveries
SET state='claimed', lease_expires_at = :now + :lease_ttl,
attempts = attempts + 1, claim_generation = claim_generation + 1
WHERE rowid IN (SELECT rowid FROM candidates)
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 $HOME/.openab/reminders.db — explicit $HOME expansion, ~ is not auto-expanded in Rust config), durability_mode = auto|durable|transient (auto resolves to durable; degradation to transient is only ever explicit, never guessed from the filesystem), 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. The attempt journal keeps a minimal field set (attempt/claim/batch IDs, timestamps, outcome, truncated error) with its own retention policy.
| 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. Due-event accounting happens at due_at crossing, not at terminal time, implemented as durable booking: deliveries gains a booked_at INTEGER column (indexed), and the dispatcher tick runs a Step 0 before reclaim/claim — UPDATE deliveries SET booked_at = :now WHERE booked_at IS NULL AND due_at <= :now AND state IN ('pending','claimed') — emitting reminder_due_total{stage,kind} for the rows booked in that transaction. On restart, counters are re-synchronized from the table (Counter::absolute over COUNT(*) WHERE booked_at IS NOT NULL), so catch-up neither loses nor double-counts events. A stage cancelled before its due_at has booked_at IS NULL and is never counted; cancelled after due stays in the denominator. reminder_delivered_within_slo_total{stage,kind} increments on first successful delivery within due_at + 120s. Stuck or pending-past-due work is therefore visible in the denominator during an incident, not after it resolves. Additional metrics: reminder_delivered_after_slo_total, reminder_overdue_seconds histogram (observability), quota/backpressure gauges — via a new /metrics Prometheus endpoint on the existing axum server. Burn-rate alert rules (1h > 14.4 AND 6h > 6 for the 99.9%/30-day objective) ship as optional documented examples, disabled by default — the counters are mandatory, the alerting policy is the operator's choice.
Delivery Kinds
notify (default)
Domain produces a structured notification — never raw mention syntax:
enum MentionTarget { User(PlatformUserId), Role(PlatformRoleId) }
Notification { text, mention: None | Requester | Targets(Vec<MentionTarget>), reply_scope }
A new adapter method send_notification(options) maps this to platform capabilities. Discord: allowed_mentions with parse: [], user targets into allowed_mentions.users, role targets into allowed_mentions.roles — server-side enforcement; content containing @everyone/<@…>/<@&…> cannot ping anyone outside the allowlists (a role mention can never smuggle through the users list, and vice versa). Target authorization is enforced in the service, not the transport: the sealed AuthenticatedContext carries a TargetPolicy (RequesterOnly | Verified { allowed_users, allowed_roles, max_targets }) built by the platform auth boundary — Discord /remind gets Verified from validated command options; the agent CLI, Markdown importer, and any future transport get RequesterOnly by default. ReminderService::create validates that request targets are a subset of the policy. The canonical payload hash includes target kind + ID (User("123") ≠ Role("123")). 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 ACK = the session accepts/enqueues the prompt — it never waits for turn completion (an agent turn can run for minutes, far beyond the lease TTL; backends where cancel is a no-op make this distinction critical). Per-reminder overlap protection (same pattern as cron's in-flight guard) prevents re-firing an agent_task whose previous synthetic turn is still running. 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) uses a migration ledger for provable idempotency. Legacy records already carry stable IDs; the importer strictly parses <@id>/<@!id> as user targets and <@&id> as role targets into the typed form, then records each import in:
legacy_imports
installation_id, source_kind ('reminders_json_v1'), source_id (legacy Reminder.id),
payload_hash, reminder_id, imported_at
UNIQUE(installation_id, source_kind, source_id)
Within one transaction: insert ledger row + reminder; on ledger conflict with the same payload hash → no-op replay; different hash → migration conflict, whole batch rolls back and the original file is left untouched (never silently pick a version). The source file is renamed to reminders.json.migrated.<timestamp> only after commit — a crash after DB commit but before rename is safe because re-running the import no-ops via the ledger. Malformed JSON aborts loudly with the file intact. Legacy records exceeding the quota are grandfathered but count toward the quota for new creations. Required tests: double-run produces one set; crash between commit and rename recovers; same source_id + different payload → conflict with file intact; same numeric ID as user vs role lands in the correct allowlists.
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:
test_stale_worker_update_fenced_by_generation_token — late worker after reclaim/cancel cannot overwrite newer state; its attempt rows remain for audit.
- Terminal-state completeness — any combination of
delivered/stale/failed/cancelled stages transitions the parent out of active; partial_failure set when any stage failed.
- Legacy migration — targets preserved, malformed JSON aborts loudly with original file intact, re-running the import is a no-op, crash mid-import recovers.
agent_task restricted context — synthetic turn has empty allowed_operations; provenance envelope immutable; payload cannot forge envelope fields.
- Fenced immediate retry — retry increments generation and attempts; exhausted rows are never re-claimed.
- Quota race — concurrent creates cannot exceed the per-principal quota.
- Durability modes — transient mode disables catch-up claims cleanly and logs the degradation.
- Claim starvation — batches of ineligible head rows (exhausted / backing-off) never block eligible rows behind them; final-attempt lease expiry terminalizes to
failed, never stuck pending.
- Migration ledger — double-run no-op; crash between DB commit and file rename recovers; conflicting payload for same source_id rolls back with the file intact; user vs role targets land in the correct
allowed_mentions lists.
- SLO accounting — due events recorded at
due_at crossing exactly once across restarts; cancelled-before-due never counted; stuck deliveries visible in the denominator during the incident.
agent_task overlap — a long-running synthetic turn blocks re-fire; delivery ACK completes on enqueue without waiting for turn completion.
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 two rounds of external community review. Thanks to the external reviewers for the substantive feedback incorporated in Revisions 4 and 5.
This document records the full design space explored during two internal and two external review rounds. Implementation follows a keep-it-simple, best-effort, fast-iteration policy. Where the sections below conflict with this contract, this contract wins; the detailed mechanisms remain as design reference for future phases, not work orders.
Design boundaries (assumptions & non-goals)
durability_modeconfig; one documented default.Phase 1 (commit now) — the smallest useful slice
due_at <= nowcatch-up; sends run inline and sequentially inside the tickpending → delivered | stale | cancelled. No retry mechanism: a failed send simply stayspendingand is naturally re-attempted on the next tick untilstale_after(end of the reminder's local day), then markedstale. No lease, no claim, noclaim_generationfencing, no attempts journal, no backoff columns — that machinery exists only to serve concurrency and retry scheduling, which Phase 1 does not have.stale./remindmigrates onto the new store with behavior unchanged (targets and 5/user quota untouched, mention path unchanged). Legacyreminders.json: one-time best-effort transactional import keyed on legacy stable IDs (rename to archive on success; on parse failure keep the file, log a warning, start empty — no ledger table).k8s/deployment.yamlmount fix so.openabpersists.Phase 2 (commit next)
openab reminder create/list/cancelCLI over the ctl socket (versioned envelope), requester-only, per-turn sender binding under the same-UID trust model.allowed_mentionspolicy applies to this new send path.Phase 3 (likely)
day/t60/t15expansion, snooze.Explicitly not built now (design reference only)
Retry/backoff scheduling, lease/claim/fencing protocol, attempts journal, migration ledger table,
/metrics+ SLO counters + burn-rate examples, quota systems beyond the existing 5/user, typedTargetPolicymachinery beyond keeping/remindas-is,agent_task, Markdown export/import,durability_mode, multi-platform delivery, native ACP/MCP tool. If enterprise-grade guarantees are ever needed, that may be a separate effort.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:
/remind(crates/openab-core/src/remind.rs)30m..30d),reminders.json+tokio::sleepcrates/openab-core/src/cron.rs)Known reliability gaps in the current
/remindstore (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
ReminderServicemust not import cron job types or depend oncron.rsinternals.Key principles:
rusqlitewithbundled+backupfeatures (SQLite ≥ 3.35.0 forRETURNING); no dependency on distro SQLite.ReminderServicelives inopenab-core; all transports are thin frontends over one typed service.max_attemptsand lease timing; the attempt journal records evidence for audit. Exactly-once is not claimed.agent_taskreminders.Data Model
All timestamps are INTEGER Unix epoch (milliseconds, UTC).
Schema versioning: SQLite
PRAGMA user_version, forward-only numbered migrations at startup. PR 1 ships single-stage reminders usingstage = '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_scopederivationStructured key resolved by the capability resolver — never delimiter-joined strings:
Stored
principal_scope=SHA-256over the canonical JSON of this key. External UIDs are audit/display attributes; authorization and idempotency namespaces use only the derived scope ID.payload_hashcanonicalizationSHA-256overCreateReminderCanonicalV1={schema_version, title, body, kind, event_at, timezone, precision, notification_policy, targets}(keys sorted, no insignificant whitespace;targetsentries are typed{kind, id}pairs, soUser("123")andRole("123")hash differently). 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
Precedence:⚠️ completed but some notifications missed / ❌ some notifications failed / 🚫 cancelled).
partial_failure>completed_with_misses>all_delivered.all_deliveredis claimed only when every stage was actually delivered — a reminder whose stages all went stale reportscompleted_with_misses, neverall_delivered.listoutput maps reasons to honest user-facing labels (✅ delivered /Terminal delivery states:
delivered,stale,failed,cancelled. The parent transition happens in the same transaction that finalizes the last delivery — no reminder can remainactiveforever.partial_failurereminders are flagged inlistoutput and counted inreminder_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
--delay, legacy)oncedayday+t60+t15dayfires at the configurable day-start time (default08:00, settable to00:00), interpreted as local time in the reminder's own timezone. Edge rules:t60already past at creation → one immediate catch-up, keept15; event ≤ day-start → skipday, merge intot60; simultaneous overdue stages → one aggregated message (sharedbatch_idin the journal); stages visually distinguished (📅/⏰/🔔).Dispatcher & Reliability
Claim protocol (fenced)
(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_attemptscheck) — attempt counting is consistent and exhausted rows are excluded from claiming.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.batch_id, so a physical send is traceable to all stages it covered.next_retry_at(1m, 2m, 4m, 8m, capped 1h), bounded bymax_attempts; exhaustion →failed(visible inlist, metrics, and best-effort owner notification).stale_after.Durability contract (deployment)
New
[reminder]config:database_path(default$HOME/.openab/reminders.db— explicit$HOMEexpansion,~is not auto-expanded in Rust config),durability_mode = auto|durable|transient(autoresolves todurable; degradation totransientis only ever explicit, never guessed from the filesystem),wal_checkpoint_interval_seconds(default 300). Connections open withPRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;periodicwal_checkpoint(RESTART); shutdown checkpointTRUNCATE. Backups use the SQLite backup API after a checkpoint — never file-copy a live WAL database. The attempt journal keeps a minimal field set (attempt/claim/batch IDs, timestamps, outcome, truncated error) with its own retention policy./home/agentPVC already mountedk8s/deployment.yamlto mount/home/agent(currently.openabis not persisted)durability_mode = "transient"; restart catch-up and the SLO explicitly do not apply; EFS support is post-MVPThe 99.9% SLO is only claimed for
durabledeployments.SLO measurement (verifiable)
Counters, not just a histogram. Due-event accounting happens at
due_atcrossing, not at terminal time, implemented as durable booking:deliveriesgains abooked_at INTEGERcolumn (indexed), and the dispatcher tick runs a Step 0 before reclaim/claim —UPDATE deliveries SET booked_at = :now WHERE booked_at IS NULL AND due_at <= :now AND state IN ('pending','claimed')— emittingreminder_due_total{stage,kind}for the rows booked in that transaction. On restart, counters are re-synchronized from the table (Counter::absoluteoverCOUNT(*) WHERE booked_at IS NOT NULL), so catch-up neither loses nor double-counts events. A stage cancelled before itsdue_athasbooked_at IS NULLand is never counted; cancelled after due stays in the denominator.reminder_delivered_within_slo_total{stage,kind}increments on first successful delivery withindue_at + 120s. Stuck or pending-past-due work is therefore visible in the denominator during an incident, not after it resolves. Additional metrics:reminder_delivered_after_slo_total,reminder_overdue_secondshistogram (observability), quota/backpressure gauges — via a new/metricsPrometheus endpoint on the existing axum server. Burn-rate alert rules (1h > 14.4 AND 6h > 6 for the 99.9%/30-day objective) ship as optional documented examples, disabled by default — the counters are mandatory, the alerting policy is the operator's choice.Delivery Kinds
notify(default)Domain produces a structured notification — never raw mention syntax:
A new adapter method
send_notification(options)maps this to platform capabilities. Discord:allowed_mentionswithparse: [], user targets intoallowed_mentions.users, role targets intoallowed_mentions.roles— server-side enforcement; content containing@everyone/<@…>/<@&…>cannot ping anyone outside the allowlists (a role mention can never smuggle through the users list, and vice versa). Target authorization is enforced in the service, not the transport: the sealedAuthenticatedContextcarries aTargetPolicy(RequesterOnly|Verified { allowed_users, allowed_roles, max_targets }) built by the platform auth boundary — Discord/remindgetsVerifiedfrom validated command options; the agent CLI, Markdown importer, and any future transport getRequesterOnlyby default.ReminderService::createvalidates that request targets are a subset of the policy. The canonical payload hash includes target kind + ID (User("123")≠Role("123")). 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 actorFires 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: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 ACK = the session accepts/enqueues the prompt — it never waits for turn completion (an agent turn can run for minutes, far beyond the lease TTL; backends where cancel is a no-op make this distinction critical). Per-reminder overlap protection (same pattern as cron's in-flight guard) prevents re-firing anagent_taskwhose previous synthetic turn is still running. Task outcome belongs to the agent.Agent-Facing API
ReminderService(sealed context)AuthenticatedContextis sealed (crate-private constructor; only the capability resolver / platform auth boundary builds it). Transports submit payloads only.Transports & the
/remindmigration contractTargets are a transport-level feature:
/remind(Discord): behavior preserved exactly — up to 10 arbitrary user/role targets, 5-active-per-user quota. Targets are stored and delivered through theallowed_mentionsallowlist.targetsdefaults to[requester_id], other targets rejected (cross-target delivery is a post-MVP capability).Legacy
reminders.jsonmigration (PR 1) uses a migration ledger for provable idempotency. Legacy records already carry stable IDs; the importer strictly parses<@id>/<@!id>as user targets and<@&id>as role targets into the typed form, then records each import in:Within one transaction: insert ledger row + reminder; on ledger conflict with the same payload hash → no-op replay; different hash → migration conflict, whole batch rolls back and the original file is left untouched (never silently pick a version). The source file is renamed to
reminders.json.migrated.<timestamp>only after commit — a crash after DB commit but before rename is safe because re-running the import no-ops via the ledger. Malformed JSON aborts loudly with the file intact. Legacy records exceeding the quota are grandfathered but count toward the quota for new creations. Required tests: double-run produces one set; crash between commit and rename recovers; same source_id + different payload → conflict with file intact; same numeric ID as user vs role lands in the correct allowlists.ctl protocol envelope (backward compatible)
Requestgainsversion(serde default1) andnamespace(default"core"): legacy{"action":"set",…}clients work unchanged;openab remindersendsversion: 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 includesmin_supported_versionso future bumps degrade gracefully.Identity binding — honest threat model
Consequences:
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).Idempotency
Unchanged from R3 (transport-level
client_operation_idwith pre-dispatch spool persistence andlookupreconcile; service-levelUNIQUE(principal_scope, idempotency_key)+ payload-hash comparison in-transaction; transient failures retry with the same key).Time contract
--tz→ per-user/config default ([reminder].default_timezone) → UTC (with a warning in the ack).--atwith an explicit offset: the offset defines the instant; if--tzis also given, it sets the display/expansion timezone; if the offset and--tzdisagree on the local wall time, the request is rejected as ambiguous.--atwithout offset (naive): requires--tzor a configured default; otherwise rejected.Resource Limits & Backpressure
agent_task(default 3); global active cap (default 10,000).claim_batch_limit, default 100/tick); fixed delivery concurrency with a bounded queue; platform rate-limit errors are transient failures (backoff).503+Retry-Afteron the CLI path).listis paginated. Import size bounded.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_NOFOLLOWboth directions; atomic writes). Security checklist unchanged except: mention safety is now the adapterallowed_mentionspolicy (primary) + NFKC (defense-in-depth); threat model statement replaced as above.Testing additions on top of R3.1's ten requirements:
test_stale_worker_update_fenced_by_generation_token— late worker after reclaim/cancel cannot overwrite newer state; its attempt rows remain for audit.delivered/stale/failed/cancelledstages transitions the parent out ofactive;partial_failureset when any stage failed.agent_taskrestricted context — synthetic turn has emptyallowed_operations; provenance envelope immutable; payload cannot forge envelope fields.failed, never stuckpending.allowed_mentionslists.due_atcrossing exactly once across restarts; cancelled-before-due never counted; stuck deliveries visible in the denominator during the incident.agent_taskoverlap — a long-running synthetic turn blocks re-fire; delivery ACK completes on enqueue without waiting for turn completion.Implementation Plan
[reminder]config + durability modes,/remindmigration (targets + quota preserved, idempotent import),k8s/deployment.yamlmount fix,/metrics+ SLO counters. Independently shippable; usage/failure metrics gate PR 2.openab reminderCLI + ctl version envelope + capability context file (same-UID trust model, multi-user fail-closed)day/t60/t15), absolute time & timezone contract, snooze (presets 5m/15m/1h, max 3, schema migration for snooze state), cancel UXagent_task(provenance envelope, restricted synthetic context) + Markdown export/importPost-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
/remind2.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 two rounds of external community review. Thanks to the external reviewers for the substantive feedback incorporated in Revisions 4 and 5.