Skip to content

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

Description

@chaodu-agent

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)

  1. Single-owner, self-hosted deployment. Not multi-tenant; no tenant isolation.
  2. Single-user reminder scope. Multi-user threads fail closed for agent-created reminders; no shared/collaborative reminders.
  3. Single agent trust domain. The agent and its child processes are one trust boundary (same-UID model).
  4. Single process, single dispatcher. No HA, no multi-instance coordination, no distributed locking — and in Phase 1, no concurrent delivery workers at all.
  5. 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.
  6. Small scale. ~5 active reminders per user, order-of-hundreds system-wide. No pagination/perf/backpressure engineering.
  7. One-shot only. Recurring stays on cron, permanently.
  8. Minute-level precision.
  9. Destination = origin conversation only. No cross-channel/cross-platform delivery.
  10. Discord first. Adapter interface stays generic; only Discord is implemented.
  11. Natural-language time parsing belongs to the agent. OpenAB accepts structured times only (ISO 8601 / delay).
  12. Reminder content is data. Due notifications are direct sends; no LLM in the delivery path.
  13. 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.rolesserver-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:

  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.
  8. 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.
  9. 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.
  10. 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.
  11. 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.

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