Skip to content

Implement LCM intent-driven notification service foundation #140

@whoisasx

Description

@whoisasx

AO Notification System Implementation Plan

Current Scope

This plan covers only the backend notification foundation:

  • Central notification system.
  • Interface for Lifecycle Manager (LCM) to push notification intent.
  • Enrichment inside the notification service.
  • Central notification maker interface inside the notification service.
  • SQLite persistence and trigger-owned CDC.

Out of scope for this implementation:

  • No API endpoint implementation.
  • No dashboard notification panel implementation.
  • No Slack, Discord, email, webhook, desktop, or notifier plugin delivery.
  • No direct port of old AO's dashboard JSONL notification store.
  • No channel-specific rendering beyond canonical fallback copy.

Target Architecture

Durable facts and observations
        |
        v
Lifecycle Manager decides user relevance
        |
        v
domain.NotificationIntent
        |
        v
service/notification
  - validate
  - enrich from local durable facts
  - create semantic actions
  - make concise canonical copy
  - dedupe by durable key + fingerprint
        |
        v
SQLite notifications table
        |
        v
SQLite triggers append notification_* rows into change_log
        |
        v
Future API/dashboard/notifier consumers

LCM remains the decision owner. The notification service becomes the product-owned notification boundary. Storage remains the durable source of truth. CDC remains trigger-owned.

Design Principles

  • LCM emits intent only. It should not render notification copy, know dashboard routes, call notifier plugins, or write notification rows directly.
  • The notification service owns enrichment, copy, semantic actions, dedupe, and persistence.
  • Notifications must be concise. Rich evidence belongs in structured JSON fields, not in the visible summary.
  • Dedupe must survive daemon restart. Do not depend on LCM's in-memory reaction state.
  • Notification persistence is synchronous in V1. External delivery is future asynchronous work.
  • SQLite store methods must not manually write change_log; use triggers only.

Phase 1: Domain Vocabulary

Add:

  • backend/internal/domain/notification.go

Define app-level domain types:

type NotificationID string
type NotificationType string
type NotificationPriority string
type NotificationStatus string

const (
    NotificationCIFailing        NotificationType = "ci.failing"
    NotificationReviewChanges    NotificationType = "review.changes_requested"
    NotificationMergeConflicts   NotificationType = "merge.conflicts"
    NotificationMergeReady       NotificationType = "merge.ready"
    NotificationMergeCompleted   NotificationType = "merge.completed"
    NotificationSessionInput     NotificationType = "session.needs_input"
    NotificationSessionExited    NotificationType = "session.exited"
)

const (
    NotificationUrgent  NotificationPriority = "urgent"
    NotificationAction  NotificationPriority = "action"
    NotificationWarning NotificationPriority = "warning"
    NotificationInfo    NotificationPriority = "info"
)

const (
    NotificationUnread    NotificationStatus = "unread"
    NotificationRead      NotificationStatus = "read"
    NotificationDismissed NotificationStatus = "dismissed"
    NotificationResolved  NotificationStatus = "resolved"
)

Define the LCM-to-notification contract:

type NotificationIntent struct {
    Type        NotificationType
    Priority    NotificationPriority
    ProjectID   ProjectID
    SessionID   SessionID
    Source      string
    DedupeKey   string
    OccurredAt  time.Time
    Context     NotificationIntentContext
}

Recommended V1 context shape:

type NotificationIntentContext struct {
    PRURL       string
    CheckName   string
    CheckURL    string
    CommitHash  string
    ReviewIDs   []string
    ThreadIDs   []string
    MergeState  string
    Reason      string
    Facts       map[string]any
}

Define persisted canonical notification:

type Notification struct {
    ID          NotificationID
    Type        NotificationType
    Priority    NotificationPriority
    Status      NotificationStatus
    ProjectID   ProjectID
    SessionID   *SessionID
    Source      string
    DedupeKey   string
    Fingerprint string
    Title       string
    Summary     string
    Body        string
    Subject     NotificationSubject
    Data        map[string]any
    Actions     []NotificationAction
    OccurredAt  time.Time
    ReadAt      *time.Time
    DismissedAt *time.Time
    ResolvedAt  *time.Time
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

Define semantic actions:

type NotificationAction struct {
    ID      string
    Label   string
    Kind    string // route, link, command, callback
    URL     string
    Route   string
    Payload map[string]any
    Primary bool
}

Important: actions are descriptors only in this scope. The dashboard/API action execution surface is future work.

Phase 2: LCM Intent Interface

Update:

  • backend/internal/lifecycle/manager.go
  • backend/internal/lifecycle/reactions.go

Add a local lifecycle-owned interface:

type notificationSink interface {
    Notify(ctx context.Context, intent domain.NotificationIntent) error
}

Prefer an explicit dependency constructor while preserving the current constructor for tests:

type Deps struct {
    Store         sessionStore
    Messenger     ports.AgentMessenger
    Notifications notificationSink
}

func NewWithDeps(deps Deps) *Manager

Keep:

func New(store sessionStore, messenger ports.AgentMessenger) *Manager

New can delegate to NewWithDeps with Notifications: nil.

LCM Responsibilities

LCM sends:

  • Notification type.
  • Priority.
  • Project ID and session ID from the current session record.
  • Source string, for example lifecycle.pr_observation.
  • Durable dedupe key.
  • Small context fragments LCM already has.

LCM does not send:

  • Final dashboard, Slack, email, or desktop copy.
  • Raw provider payloads.
  • Full CI logs.
  • Full review bodies.
  • Dashboard route contracts.
  • Storage rows.
  • Plugin routing decisions.

LCM Emit Points

Implement intents from current lifecycle decision points:

Existing LCM decision Intent type Priority Dedupe key Notes
Failed check in ApplyPRObservation ci.failing warning ci:{prURL}:{check}:{commit} Include failed check name, check URL, commit, PR URL. Keep log tail bounded in data only.
Review changes requested or unresolved comments review.changes_requested action review:{prURL}:{reviewHashOrCommentIDs} Include thread/comment ids and counts. Do not put full review body in summary.
Mergeability is conflicting merge.conflicts action merge-conflict:{prURL}:{baseSHA}:{headSHA} Fall back to PR URL when SHAs are unavailable.
PR is mergeable, green, and approved merge.ready action merge-ready:{prURL}:{headSHA} Emit only when current facts clearly support ready-to-merge.
PR merged and session is completed merge.completed info merge-completed:{prURL}:{mergeCommitSHA} Also resolve earlier action notifications for the same PR/session.
Authoritative activity signal moves to waiting input session.needs_input urgent session-input:{sessionID}:{activityTimestamp} Use only for real waiting-input signals.
Runtime/activity marks an unexpected exit session.exited warning session-exited:{sessionID}:{activityTimestamp} Do not emit on failed runtime probes. Emit only after durable termination decision.

Important correction: split user-notification eligibility from agent-nudge eligibility. A session already in waiting_input may still need a user notification even if LCM should suppress another agent nudge.

Phase 3: Central Notification Service

Add package:

  • backend/internal/service/notification/

Suggested files:

  • service.go
  • store.go
  • enrich.go
  • maker.go
  • actions.go
  • dedupe.go
  • service_test.go

Main API:

type Service struct {
    store  Store
    maker  Maker
    clock  func() time.Time
    logger *slog.Logger
}

func New(deps Deps) *Service
func (s *Service) Notify(ctx context.Context, intent domain.NotificationIntent) error

Dependencies:

type Deps struct {
    Store  Store
    Maker  Maker
    Clock  func() time.Time
    Logger *slog.Logger
}

Local store interface:

type Store interface {
    UpsertNotification(ctx context.Context, n domain.Notification) (domain.Notification, bool, error)
    ResolveNotifications(ctx context.Context, filter domain.NotificationResolveFilter, resolvedAt time.Time) (int, error)

    GetSession(ctx context.Context, id domain.SessionID) (domain.SessionRecord, bool, error)
    GetProject(ctx context.Context, id string) (domain.ProjectRecord, bool, error)
    ListPRsBySession(ctx context.Context, id domain.SessionID) ([]domain.PullRequest, error)
    ListChecks(ctx context.Context, prURL string) ([]domain.PullRequestCheck, error)
    ListPRComments(ctx context.Context, prURL string) ([]domain.PullRequestComment, error)
    ListPRReviewThreads(ctx context.Context, prURL string) ([]domain.PullRequestReviewThread, error)
}

Service Flow

  1. Validate intent.
  2. Normalize missing OccurredAt to the service clock.
  3. Enrich from local durable facts only.
  4. Build semantic actions.
  5. Call the central maker for concise canonical copy.
  6. Build a fingerprint from meaningful content and facts.
  7. Upsert by (project_id, dedupe_key).
  8. No-op when the stored fingerprint matches.
  9. Update the same logical notification when fingerprint changes.
  10. Resolve previous action notifications when lifecycle proves they are no longer relevant.

No network calls should happen inside this service.

Phase 4: Enrichment

Enrichment happens in backend/internal/service/notification/enrich.go.

Use local SQLite-backed facts:

Fact Source Used for Fallback
Session display name, kind, issue id, activity, metadata GetSession Subject label, action payloads, priority checks Session id
Project display name, path, origin URL GetProject Grouping, context, future filtering Project id
Current PR URL, number, title, head/base SHA, HTML URL ListPRsBySession PR link, check link context, dedupe/fingerprint Intent context PR URL
Failed checks ListChecks plus intent fragments Count, check names, check URLs, view_ci action Generic CI failure
Review comments ListPRComments Comment count, review action Generic review feedback
Review threads ListPRReviewThreads Thread count, resolved state, review action Comment IDs from intent

Enrichment should not fail the whole notification if optional facts are missing. Missing required facts, such as unknown session or project, should return a clear error.

Phase 5: Central Notification Maker

Add:

  • backend/internal/service/notification/maker.go

Interface:

type Maker interface {
    Make(ctx context.Context, input MakeInput) (domain.NotificationContent, error)
}

type MakeInput struct {
    Intent  domain.NotificationIntent
    Facts   EnrichedFacts
    Actions []domain.NotificationAction
}

type NotificationContent struct {
    Title   string
    Summary string
    Body    string
}

V1 implementation:

  • DefaultMaker
  • Channel-neutral canonical content only.
  • Not Slack/email/desktop formatting.
  • Not dashboard UI layout.

Copy rules:

  • Title should usually be under 40 characters.
  • Summary should usually be under 120 characters.
  • One notification should communicate one human outcome.
  • Keep visible text direct: what happened and what to do next.
  • Put full CI logs, review bodies, provider payloads, and evidence in Data, not in Summary.

Examples:

Type Title Summary Primary action
ci.failing CI failed {session} has {n} failing checks. open_session
review.changes_requested Changes requested Review feedback is waiting on {session}. open_session
merge.conflicts Merge conflicts {session} needs a rebase before it can merge. open_session
merge.ready Ready to merge {session} is approved and green. view_pr
merge.completed Merged {session} was merged. view_pr
session.needs_input Input needed {session} is waiting for you. open_session
session.exited Session exited {session} stopped unexpectedly. open_session

Phase 6: Semantic Actions

Add action construction in:

  • backend/internal/service/notification/actions.go

Recommended V1 actions:

Notification Primary action Secondary actions
ci.failing open_session view_ci, view_pr
review.changes_requested open_session view_review, view_pr
merge.conflicts open_session view_pr
merge.ready view_pr open_session
merge.completed view_pr open_session
session.needs_input open_session none
session.exited open_session none

Actions should be semantic descriptors. Do not require an API route to exist in this scope.

Example:

domain.NotificationAction{
    ID:      "view_pr",
    Label:   "View PR",
    Kind:    "link",
    URL:     pr.HTMLURL,
    Primary: true,
}

Phase 7: Dedupe And Fingerprint

Add:

  • backend/internal/service/notification/dedupe.go

Use two concepts:

  • DedupeKey: identifies the logical notification.
  • Fingerprint: identifies the current content version.

Store behavior:

  • Missing (project_id, dedupe_key): insert.
  • Existing key with same fingerprint: no-op.
  • Existing key with changed fingerprint: update row, bump updated_at, emit notification_updated through trigger.

Recommended fingerprint inputs:

  • Type.
  • Priority.
  • Title and summary.
  • Action IDs and URLs.
  • Meaningful context identifiers: check name, commit hash, review IDs, thread IDs, merge state.

Do not include volatile timestamps in fingerprint unless the timestamp is the actual event identity, such as session.needs_input.

Phase 8: SQLite Persistence

Add migration:

  • backend/internal/storage/sqlite/migrations/0005_notifications.sql

Do not edit existing migrations.

Proposed table:

CREATE TABLE notifications (
    id           TEXT PRIMARY KEY,
    project_id   TEXT NOT NULL REFERENCES projects (id),
    session_id   TEXT REFERENCES sessions (id),
    type         TEXT NOT NULL,
    priority     TEXT NOT NULL CHECK (priority IN ('urgent', 'action', 'warning', 'info')),
    status       TEXT NOT NULL DEFAULT 'unread'
                 CHECK (status IN ('unread', 'read', 'dismissed', 'resolved')),
    source       TEXT NOT NULL,
    dedupe_key   TEXT NOT NULL,
    fingerprint  TEXT NOT NULL,
    title        TEXT NOT NULL,
    summary      TEXT NOT NULL,
    body         TEXT NOT NULL DEFAULT '',
    subject_json TEXT NOT NULL CHECK (json_valid(subject_json)),
    data_json    TEXT NOT NULL CHECK (json_valid(data_json)),
    actions_json TEXT NOT NULL CHECK (json_valid(actions_json)),
    read_at      TIMESTAMP,
    dismissed_at TIMESTAMP,
    resolved_at  TIMESTAMP,
    occurred_at  TIMESTAMP NOT NULL,
    created_at   TIMESTAMP NOT NULL,
    updated_at   TIMESTAMP NOT NULL,
    UNIQUE (project_id, dedupe_key)
);

Indexes:

CREATE INDEX idx_notifications_project_created
    ON notifications (project_id, created_at DESC);

CREATE INDEX idx_notifications_project_status_created
    ON notifications (project_id, status, created_at DESC);

CREATE INDEX idx_notifications_session_created
    ON notifications (session_id, created_at DESC);

CREATE INDEX idx_notifications_project_type_created
    ON notifications (project_id, type, created_at DESC);

CDC requirements:

  • Rebuild change_log to widen event_type CHECK with:
    • notification_created
    • notification_updated
  • Follow the pattern in backend/internal/storage/sqlite/migrations/0004_scm_observer_schema.sql.
  • Drop existing CDC triggers before rebuilding change_log.
  • Recreate existing triggers.
  • Add notification triggers.
  • Do not manually emit CDC from Go.

Trigger payload should include:

  • id.
  • project id.
  • session id.
  • type.
  • priority.
  • status.
  • title.
  • summary.
  • action count.

If embedding stored JSON arrays/objects in trigger payloads, use SQLite json(...) wrappers so JSON does not become an escaped string.

Phase 9: sqlc Queries And Store Methods

Add:

  • backend/internal/storage/sqlite/queries/notifications.sql
  • backend/internal/storage/sqlite/store/notification_store.go

Update:

  • backend/sqlc.yaml

Recommended queries:

  • InsertNotification
  • UpdateNotificationContent
  • ResolveNotifications
  • GetNotification
  • GetNotificationByDedupeKey
  • ListNotificationsByProject
  • ListNotificationsBySession

Recommended store methods:

UpsertNotification(ctx context.Context, n domain.Notification) (domain.Notification, bool, error)
ResolveNotifications(ctx context.Context, filter domain.NotificationResolveFilter, resolvedAt time.Time) (int, error)
GetNotification(ctx context.Context, id domain.NotificationID) (domain.Notification, bool, error)
ListNotificationsByProject(ctx context.Context, projectID domain.ProjectID, limit int) ([]domain.Notification, error)
ListNotificationsBySession(ctx context.Context, sessionID domain.SessionID, limit int) ([]domain.Notification, error)

Use writeMu and a transaction for upsert behavior. Keep domain-to-sqlc JSON mapping private to storage.

Run after query or migration changes:

npm run sqlc

Phase 10: Daemon Wiring

Update:

  • backend/internal/daemon/lifecycle_wiring.go
  • backend/internal/daemon/daemon.go

Construct the notification service after SQLite opens and before lifecycle starts:

notificationSvc := notification.New(notification.Deps{
    Store:  store,
    Maker:  notification.DefaultMaker{},
    Clock:  time.Now,
    Logger: log,
})

lcStack := startLifecycle(ctx, store, runtimeAdapter, notificationSvc, log)

Update lifecycle startup to pass notification dependencies explicitly:

func startLifecycle(
    ctx context.Context,
    store *sqlite.Store,
    runtime ports.Runtime,
    notifications notificationSink,
    logger *slog.Logger,
) *lifecycleStack {
    lcm := lifecycle.NewWithDeps(lifecycle.Deps{
        Store:         store,
        Messenger:     nil,
        Notifications: notifications,
    })
    rp := reaper.New(lcm, store, runtime, reaper.Config{Logger: logger})
    return &lifecycleStack{LCM: lcm, reaperDone: rp.Start(ctx)}
}

The current daemon creates LCM with a nil messenger. Notification wiring must be independent of the messenger path.

Phase 11: Tests

Domain Tests

Add:

  • backend/internal/domain/notification_test.go

Cover:

  • Validation rejects missing type, project, session, priority, source, and dedupe key.
  • Priority/status constants match storage constraints.
  • Action payloads remain JSON-safe.

Service Tests

Add:

  • backend/internal/service/notification/service_test.go

Cover:

  • Notify enriches from fake session/project/PR/check/comment stores.
  • Missing optional facts still produce useful fallback copy.
  • Unknown session/project returns a clear error.
  • Each V1 type gets concise title and summary.
  • Actions are present and correctly marked primary/secondary.
  • Same dedupe key and same fingerprint is no-op.
  • Same dedupe key and changed fingerprint updates the existing logical notification.
  • Store failure returns an error.

Maker Tests

Add:

  • backend/internal/service/notification/maker_test.go

Cover:

  • ci.failing, review.changes_requested, merge.conflicts, merge.ready, merge.completed, session.needs_input, session.exited.
  • Copy length boundaries.
  • No log tail or long review body appears in summary.
  • Fallback copy when PR title/check URL/review details are missing.

Lifecycle Tests

Update:

  • backend/internal/lifecycle/manager_test.go

Cover:

  • CI failure emits correct notification intent.
  • Review feedback emits correct notification intent.
  • Merge conflict emits correct notification intent.
  • Merge ready emits correct notification intent when facts are green/approved/mergeable.
  • Merged PR emits completion intent and resolves prior action notifications through service.
  • Waiting-input activity emits user notification intent.
  • waiting_input suppresses agent nudge but not user notification.
  • Nil notification sink is no-op.
  • Notification sink failure behavior is explicit and tested.

SQLite Store Tests

Add:

  • backend/internal/storage/sqlite/store/notification_store_test.go

Cover:

  • Insert/read round trip.
  • List by project ordering.
  • List by session ordering.
  • JSON data/actions/subject round trip.
  • Duplicate key and same fingerprint no-ops.
  • Duplicate key and changed fingerprint updates same row.
  • Resolve updates status and resolved_at.

CDC Tests

Add:

  • backend/internal/storage/sqlite/store/notification_cdc_test.go

Cover:

  • Insert emits notification_created.
  • Meaningful update emits notification_updated.
  • Same-fingerprint no-op emits no CDC event.
  • Resolve emits notification_updated.
  • Trigger payload JSON contains real objects/arrays, not escaped JSON strings.
  • Store methods never write change_log manually.

Integration Test

Update or add:

  • backend/internal/integration/lifecycle_sqlite_test.go

Cover:

PR observation
  -> LCM emits notification intent
  -> notification service persists row
  -> SQLite trigger writes change_log
  -> CDC poller broadcasts notification event

Wiring Test

Update:

  • backend/internal/daemon/wiring_test.go

Cover:

  • Lifecycle is wired with a real notification sink in daemon setup.
  • Test-only nil sink remains intentional and harmless.

Verification Commands

Run narrow tests first:

cd backend
go test ./internal/domain ./internal/lifecycle ./internal/service/notification ./internal/storage/sqlite/store ./internal/integration ./internal/daemon

Run generated code after migration/query changes:

npm run sqlc

Run backend suite:

cd backend
go test ./...

Optional broader checks:

npm run lint

Acceptance Criteria

  • LCM can call one notification interface with intent.
  • LCM does not know about persistence, channels, dashboard rendering, or external notifiers.
  • Notification service enriches from local durable facts only.
  • Notification maker produces short, purposeful canonical copy.
  • Notification actions are semantic descriptors and do not require current API endpoints.
  • Notifications persist in SQLite with durable dedupe.
  • Same daemon-restarted observation does not create duplicate rows.
  • Meaningful content changes update the same logical notification.
  • SQLite triggers emit notification CDC rows.
  • Store methods do not write change_log directly.
  • No API endpoint or dashboard panel is added in this scope.

Main Risks And Controls

Risk Control
change_log rebuild breaks existing CDC triggers. Copy the 0004_scm_observer_schema.sql pattern exactly, recreate all existing triggers, and add CDC tests.
Notifications become verbose reports. Enforce title/summary limits in maker tests; keep logs/review bodies in Data.
Retry paths create duplicate notifications. Use persistent (project_id, dedupe_key) uniqueness and same-fingerprint no-op behavior.
Dedupe key is too broad and hides important events. Include stable event identity in dedupe keys, such as PR URL, check name, commit, review hash, or activity timestamp.
Notification failure corrupts lifecycle behavior. Keep notification persistence errors explicit; decide per LCM path whether to return error or log and continue. Test this.
External notifier delivery blocks lifecycle later. Keep external delivery as future async outbox/plugin work.
Dashboard/API expectations leak into backend. Store semantic actions and canonical content only; routes/endpoints remain future work.

Future Work To Document Separately

  • Notification read/update HTTP endpoints.
  • Dashboard notification center.
  • Dashboard-side UI rendering, grouping, unread state, and expansion.
  • SSE/event stream consumption over change_log.
  • Desktop notifier with action callbacks.
  • Slack, Discord, email, webhook, and OpenClaw notifier plugins.
  • External delivery outbox, retries, quiet hours, and routing configuration.

Metadata

Metadata

Assignees

Labels

coreCore FunctionalitydomainTypes in domain/enhancementNew feature or requestlcm-smLifecycle + Session Manager laneportTouches port contracts — cross-lane review requiredstoragePersistence lane

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions