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:
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.
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.
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). 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
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~/.openab/reminders.db),durability_mode = auto|durable|transient,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./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:
reminder_due_total{stage,kind}(denominator — incremented when a stage reaches a terminal outcome, includingfailedandstale) andreminder_delivered_within_slo_total{stage,kind}(numerator — first successful delivery withindue_at + 120s), plusreminder_delivered_after_slo_total,reminder_overdue_secondshistogram (observability), and quota/backpressure gauges. Exposed via a new/metricsPrometheus 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:
A new adapter method
send_notification(options)maps this to platform capabilities. Discord:allowed_mentionswithparse: []and an explicitusersallowlist (requester, plus storedtargetsfor human/remindreminders) — 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 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 isdeliveredwhen the prompt handoff is ACK'd; 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): a one-time idempotent import inside a transaction — each legacy record maps to astage='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 toreminders.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)
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.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 external community review. Thanks to the external reviewer for the substantive blocking feedback incorporated in Revision 4.