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
- Validate intent.
- Normalize missing
OccurredAt to the service clock.
- Enrich from local durable facts only.
- Build semantic actions.
- Call the central maker for concise canonical copy.
- Build a fingerprint from meaningful content and facts.
- Upsert by
(project_id, dedupe_key).
- No-op when the stored fingerprint matches.
- Update the same logical notification when fingerprint changes.
- 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:
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:
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:
Run backend suite:
Optional broader checks:
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.
AO Notification System Implementation Plan
Current Scope
This plan covers only the backend notification foundation:
Out of scope for this implementation:
Target Architecture
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
change_log; use triggers only.Phase 1: Domain Vocabulary
Add:
backend/internal/domain/notification.goDefine app-level domain types:
Define the LCM-to-notification contract:
Recommended V1 context shape:
Define persisted canonical notification:
Define semantic actions:
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.gobackend/internal/lifecycle/reactions.goAdd a local lifecycle-owned interface:
Prefer an explicit dependency constructor while preserving the current constructor for tests:
Keep:
Newcan delegate toNewWithDepswithNotifications: nil.LCM Responsibilities
LCM sends:
lifecycle.pr_observation.LCM does not send:
LCM Emit Points
Implement intents from current lifecycle decision points:
ApplyPRObservationci.failingwarningci:{prURL}:{check}:{commit}review.changes_requestedactionreview:{prURL}:{reviewHashOrCommentIDs}merge.conflictsactionmerge-conflict:{prURL}:{baseSHA}:{headSHA}merge.readyactionmerge-ready:{prURL}:{headSHA}merge.completedinfomerge-completed:{prURL}:{mergeCommitSHA}session.needs_inputurgentsession-input:{sessionID}:{activityTimestamp}session.exitedwarningsession-exited:{sessionID}:{activityTimestamp}Important correction: split user-notification eligibility from agent-nudge eligibility. A session already in
waiting_inputmay 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.gostore.goenrich.gomaker.goactions.godedupe.goservice_test.goMain API:
Dependencies:
Local store interface:
Service Flow
OccurredAtto the service clock.(project_id, dedupe_key).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:
GetSessionGetProjectListPRsBySessionListChecksplus intent fragmentsview_ciactionListPRCommentsListPRReviewThreadsEnrichment 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.goInterface:
V1 implementation:
DefaultMakerCopy rules:
Data, not inSummary.Examples:
ci.failingCI failed{session} has {n} failing checks.open_sessionreview.changes_requestedChanges requestedReview feedback is waiting on {session}.open_sessionmerge.conflictsMerge conflicts{session} needs a rebase before it can merge.open_sessionmerge.readyReady to merge{session} is approved and green.view_prmerge.completedMerged{session} was merged.view_prsession.needs_inputInput needed{session} is waiting for you.open_sessionsession.exitedSession exited{session} stopped unexpectedly.open_sessionPhase 6: Semantic Actions
Add action construction in:
backend/internal/service/notification/actions.goRecommended V1 actions:
ci.failingopen_sessionview_ci,view_prreview.changes_requestedopen_sessionview_review,view_prmerge.conflictsopen_sessionview_prmerge.readyview_propen_sessionmerge.completedview_propen_sessionsession.needs_inputopen_sessionsession.exitedopen_sessionActions should be semantic descriptors. Do not require an API route to exist in this scope.
Example:
Phase 7: Dedupe And Fingerprint
Add:
backend/internal/service/notification/dedupe.goUse two concepts:
DedupeKey: identifies the logical notification.Fingerprint: identifies the current content version.Store behavior:
(project_id, dedupe_key): insert.updated_at, emitnotification_updatedthrough trigger.Recommended fingerprint inputs:
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.sqlDo not edit existing migrations.
Proposed table:
Indexes:
CDC requirements:
change_logto widenevent_typeCHECK with:notification_creatednotification_updatedbackend/internal/storage/sqlite/migrations/0004_scm_observer_schema.sql.change_log.Trigger payload should include:
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.sqlbackend/internal/storage/sqlite/store/notification_store.goUpdate:
backend/sqlc.yamlRecommended queries:
InsertNotificationUpdateNotificationContentResolveNotificationsGetNotificationGetNotificationByDedupeKeyListNotificationsByProjectListNotificationsBySessionRecommended store methods:
Use
writeMuand a transaction for upsert behavior. Keep domain-to-sqlc JSON mapping private to storage.Run after query or migration changes:
Phase 10: Daemon Wiring
Update:
backend/internal/daemon/lifecycle_wiring.gobackend/internal/daemon/daemon.goConstruct the notification service after SQLite opens and before lifecycle starts:
Update lifecycle startup to pass notification dependencies explicitly:
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.goCover:
Service Tests
Add:
backend/internal/service/notification/service_test.goCover:
Notifyenriches from fake session/project/PR/check/comment stores.Maker Tests
Add:
backend/internal/service/notification/maker_test.goCover:
ci.failing,review.changes_requested,merge.conflicts,merge.ready,merge.completed,session.needs_input,session.exited.Lifecycle Tests
Update:
backend/internal/lifecycle/manager_test.goCover:
waiting_inputsuppresses agent nudge but not user notification.SQLite Store Tests
Add:
backend/internal/storage/sqlite/store/notification_store_test.goCover:
resolved_at.CDC Tests
Add:
backend/internal/storage/sqlite/store/notification_cdc_test.goCover:
notification_created.notification_updated.notification_updated.change_logmanually.Integration Test
Update or add:
backend/internal/integration/lifecycle_sqlite_test.goCover:
Wiring Test
Update:
backend/internal/daemon/wiring_test.goCover:
Verification Commands
Run narrow tests first:
Run generated code after migration/query changes:
Run backend suite:
Optional broader checks:
Acceptance Criteria
change_logdirectly.Main Risks And Controls
change_logrebuild breaks existing CDC triggers.0004_scm_observer_schema.sqlpattern exactly, recreate all existing triggers, and add CDC tests.Data.(project_id, dedupe_key)uniqueness and same-fingerprint no-op behavior.Future Work To Document Separately
change_log.