Skip to content

Design: notifications v1 #179

@whoisasx

Description

@whoisasx

Design: notifications v1

Status: proposed

This design adds a small notification service to AO. The service is intentionally
centralized: lifecycle decides which provider facts deserve a notification, then
passes a notification intent to the notification service. The notification
service enriches that intent, persists an unread notification row, and dispatches
it through a minimal dashboard dispatcher.

The v1 notification kinds are:

  • needs_input
  • ready_to_merge
  • pr_merged
  • pr_closed_unmerged

Goals

  1. Keep notification policy in lifecycle. Providers continue to report facts;
    lifecycle decides whether a fact transition is user-notifiable.
  2. Make stored notifications durable and dashboard-readable.
  3. Keep enrichment centralized so dashboard notifications read like user-facing
    notifications, not raw provider reports.
  4. Add a minimal dispatcher tied to the dashboard/SSE boundary. Future channels
    stay as comments/follow-up work, not a v1 plugin system.
  5. Implement only the unread list route now. Define the stream route scope, but
    treat stream implementation as follow-up unless the implementation slice
    explicitly includes it.

Non-goals

  • Do not move SCM, tracker, or activity policy into providers.
  • Do not make the dashboard infer notifications from raw session or PR state.
  • Do not store derived session display status.
  • Do not implement notification preferences, retries, fan-out history per
    channel, or external channels in v1.
  • Do not implement read-state mutation unless a follow-up explicitly adds it.
    The v1 table has a status column, but the immediate API only lists unread
    rows.

Current Signal Flow

Agent hooks do not live inside lifecycle. Agent adapters install native hooks
that call the hidden CLI command ao hooks <agent> <event>. The CLI derives an
activity signal and posts it to the daemon activity route. The daemon controller
then calls lifecycle.

flowchart LR
  AgentHook["native agent hook"] --> CLI["ao hooks"]
  CLI --> Derive["activitydispatch.Derive"]
  Derive --> HTTP["POST /api/v1/sessions/{id}/activity"]
  HTTP --> LCM["lifecycle.Manager.ApplyActivitySignal"]
  LCM --> Sessions["sessions table"]
Loading

SCM polling already normalizes provider payloads into ports.SCMObservation.
The observer persists PR facts first, then calls lifecycle with the same
observation. The observation contains:

  • provider, host, and repo identity
  • PR URL, number, state, draft/merged/closed flags, title, branches, author,
    diff stats, and provider timestamps
  • aggregate CI summary, check details, failed checks, failure fingerprint, and
    failure log tail
  • review decision, review threads, and comments
  • mergeability state and blockers
  • Changed flags for metadata, CI, and review semantic buckets
flowchart LR
  Provider["SCM provider"] --> Observer["observe/scm.Observer"]
  Observer --> Normalize["ports.SCMObservation"]
  Normalize --> Store["WriteSCMObservation"]
  Store --> PRFacts["pr / checks / review tables"]
  Store --> ChangeLog["change_log triggers"]
  Normalize --> LCM["lifecycle.Manager.ApplySCMObservation"]
  LCM --> Reactions["agent nudges + notifications"]
Loading

V1 Notification Policy

Lifecycle is the policy gate. It should emit a notification intent only after the
underlying durable lifecycle or PR fact write has succeeded.

Type Source Lifecycle condition Required intent fields
needs_input agent activity hook session transitions into waiting_input and is not terminated session id, project id, observed time
ready_to_merge SCM observation fetched PR is open, not draft, mergeability is mergeable, and CI/review facts do not block merge session id, project id, PR URL, PR number/title when available
pr_merged SCM observation fetched PR has Merged=true session id, project id, PR URL, PR number/title when available
pr_closed_unmerged SCM observation fetched PR has Closed=true and Merged=false session id, project id, PR URL, PR number/title when available

Important details:

  • Same-state activity hook repeats must not create more needs_input
    notifications. ApplyActivitySignal already treats same activity states as
    no-op writes; the notification call should follow the same transition.
  • Unknown or failed runtime probes never create notifications.
  • ready_to_merge should align with the existing derived mergeable session
    status rather than inventing a second interpretation in the notification
    service.
  • The notification service may validate an intent, but it must not decide from
    raw SCM facts whether an event deserves a notification. That decision belongs
    to lifecycle.

Architecture

flowchart TB
  subgraph Inputs
    Hooks["agent hooks"]
    SCM["SCM polling"]
    Tracker["tracker facts (future)"]
  end

  Hooks --> LCM["lifecycle.Manager"]
  SCM --> LCM
  Tracker --> LCM

  LCM --> Intent["NotificationIntent"]
  Intent --> Manager["notification.Manager"]
  Manager --> Enricher["enricher"]
  Enricher --> Store["notification store"]
  Store --> SQLite["notifications table"]
  Manager --> Dispatcher["minimal dispatcher"]
  Dispatcher --> Dashboard["dashboard SSE publisher"]

  Dashboard --> SSE["/api/v1/notifications/stream (future scope)"]
  SQLite --> GET["GET /api/v1/notifications"]
Loading

The service has four internal pieces:

  1. Manager: validates the lifecycle intent, calls enrichment, persists the
    row, and asks the dispatcher to deliver it.
  2. Enricher: turns lifecycle intent data into a user-facing notification
    DTO. It prefers data already provided by lifecycle and only reads storage
    when required fields are missing.
  3. Store: writes and lists notification rows.
  4. Dispatcher: a tiny internal boundary that forwards created notifications
    to the dashboard/SSE publisher. It is not a plugin registry in v1.

Lifecycle Integration

Add an optional notification sink to lifecycle.Manager, similar in spirit to
the existing agent messenger dependency.

type NotificationSink interface {
    Notify(ctx context.Context, intent ports.NotificationIntent) error
}

ports.NotificationIntent should be a lifecycle-to-service contract, not an
HTTP DTO. It should carry the facts lifecycle already has:

type NotificationIntent struct {
    Type      domain.NotificationType
    SessionID domain.SessionID
    ProjectID domain.ProjectID
    PRURL     string
    CreatedAt time.Time

    // Enrichment hints. These avoid storage reads on the hot path.
    SessionDisplayName string
    PRNumber           int
    PRTitle            string
    PRSourceBranch     string
    PRTargetBranch     string
    Provider           string
    Repo               string
}

Keep HTTP DTOs separate from this lifecycle boundary type.

Activity Sequence

sequenceDiagram
  participant Hook as Agent hook
  participant API as SessionsController
  participant LCM as lifecycle.Manager
  participant DB as SQLite
  participant NS as notification.Manager

  Hook->>API: POST activity {state: waiting_input}
  API->>LCM: ApplyActivitySignal(sessionID, signal)
  LCM->>DB: GetSession
  LCM->>DB: UpdateSession(activity_state=waiting_input)
  LCM->>NS: Notify(needs_input intent)
  NS->>DB: Insert unread notification
Loading

The notification call should happen after the session update succeeds and
outside the lifecycle mutex. If the current mutate helper hides old/new state,
change it to return transition data or add a post-commit callback. Do not run
notification dispatch while holding m.mu.

SCM Sequence

sequenceDiagram
  participant SCM as SCM observer
  participant PR as PR store
  participant LCM as lifecycle.Manager
  participant NS as notification.Manager
  participant DB as SQLite

  SCM->>PR: WriteSCMObservation(PR/check/review facts)
  PR->>DB: Persist facts and CDC rows
  SCM->>LCM: ApplySCMObservation(sessionID, observation)
  LCM->>LCM: decide notification type
  LCM->>NS: Notify(intent)
  NS->>DB: Insert unread notification
Loading

SCM notifications should use the ports.SCMObservation.Changed flags where
they help avoid duplicate semantic notifications, but correctness must also be
enforced by notification storage dedupe.

Notification Enrichment

Enrichment is the step that turns an intent into a meaningful dashboard item.
The notification service stores the enriched title and body with the
notification row. The API response returns those stored fields.

Example response item:

{
  "id": "ntf_123",
  "sessionId": "ses_123",
  "projectId": "proj_123",
  "prUrl": "https://github.com/acme/repo/pull/42",
  "type": "ready_to_merge",
  "status": "unread",
  "createdAt": "2026-06-11T10:00:00Z",
  "title": "PR #42 is ready to merge",
  "body": "checkout-flow has passing checks and no blocking review feedback.",
  "target": {
    "kind": "pr",
    "sessionId": "ses_123",
    "prUrl": "https://github.com/acme/repo/pull/42"
  }
}

Default display rules:

Type Title Target
needs_input <session> needs input session
ready_to_merge PR #<number> is ready to merge PR
pr_merged PR #<number> was merged PR/session
pr_closed_unmerged PR #<number> was closed without merging PR/session

Storage fallback rules:

  • If lifecycle provides ProjectID, do not read the session row just to confirm
    it.
  • If lifecycle provides PR URL, number, and title, do not read PR storage.
  • If required route fields are missing, read session or PR storage once in the
    enricher.
  • If optional display hints are missing, return a generic but useful title/body
    rather than failing the notification.

Storage

Add a new migration, not an edit to existing migrations:

backend/internal/storage/sqlite/migrations/0009_notifications.sql

V1 table:

CREATE TABLE notifications (
    id TEXT PRIMARY KEY,
    session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
    project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    pr_url TEXT NOT NULL DEFAULT '',
    type TEXT NOT NULL CHECK (
        type IN (
            'needs_input',
            'ready_to_merge',
            'pr_merged',
            'pr_closed_unmerged'
        )
    ),
    title TEXT NOT NULL,
    body TEXT NOT NULL DEFAULT '',
    status TEXT NOT NULL DEFAULT 'unread' CHECK (status IN ('read', 'unread')),
    created_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_notifications_unread
    ON notifications(status, created_at DESC);

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

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

CREATE UNIQUE INDEX idx_notifications_unread_dedupe
    ON notifications(session_id, type, pr_url)
    WHERE status = 'unread';

pr_url is an empty string for non-PR notifications. This keeps the dedupe
index simple and avoids forcing a nullable unique-index policy in SQLite.

The notification table does not need CDC triggers in v1. The table is used as
durable notification storage. Live dashboard delivery is owned by the
notification manager through the minimal dispatcher.

Dedupe Semantics

V1 should prevent notification spam without adding a large policy engine.

Rule: at most one unread notification can exist for the same
(session_id, type, pr_url).

This means:

  • repeated waiting_input hooks do not create multiple unread needs_input
    notifications;
  • repeated SCM polls do not create multiple unread PR notifications for the same
    PR and type;
  • after a future read API marks a notification read, the same condition may
    create a fresh unread notification if lifecycle emits a new transition.

Lifecycle should still avoid emitting obvious repeats. Storage dedupe is the
last line of defense across daemon restarts and poll retries.

Dispatch Semantics

Persist before dispatch.

sequenceDiagram
  participant LCM as lifecycle.Manager
  participant NS as notification.Manager
  participant Store as notification.Store
  participant Dispatch as dispatcher
  participant Dash as dashboard SSE publisher

  LCM->>NS: Notify(intent)
  NS->>Store: InsertUnread(enriched row)
  Store-->>NS: row or duplicate
  alt inserted
    NS->>Dispatch: Dispatch(notification)
    Dispatch->>Dash: Publish(notification)
  else duplicate
    NS-->>LCM: nil
  end
Loading

If persistence fails, lifecycle should log the failure and keep the underlying
lifecycle/SCM state successful. A notification miss must not roll back the
durable session or PR fact update.

If dashboard dispatch fails after persistence, the unread row remains available
through GET /api/v1/notifications. This is why storage comes first.

The dispatcher should stay deliberately small:

type Dispatcher interface {
    Dispatch(ctx context.Context, n Notification) error
}

The v1 implementation is tied to dashboard/SSE delivery:

type DashboardPublisher interface {
    Publish(ctx context.Context, n Notification) error
}

If the stream route is not implemented in the first slice, the dashboard
publisher can be a no-op. Future channels can be added behind a richer
dispatcher later without changing the lifecycle intent contract.

HTTP API

List Unread Notifications

Implement now:

GET /api/v1/notifications?status=unread&projectId=<project-id>&limit=50

Rules:

  • status defaults to unread.
  • v1 may reject any status other than unread until read-state mutation is
    implemented.
  • projectId is optional. When present, return unread notifications for that
    project only. When absent, return unread notifications across all projects the
    local daemon knows about.
  • limit defaults to a conservative value such as 50 and should have a max.
  • GET must not mark notifications read.

Response:

{
  "notifications": [
    {
      "id": "ntf_123",
      "sessionId": "ses_123",
      "projectId": "proj_123",
      "prUrl": "",
      "type": "needs_input",
      "status": "unread",
      "createdAt": "2026-06-11T10:00:00Z",
      "title": "checkout-flow needs input",
      "body": "The agent is waiting for your response.",
      "target": {
        "kind": "session",
        "sessionId": "ses_123"
      }
    }
  ]
}

Stream Notifications

Define scope now; implementation can follow after the GET slice.

GET /api/v1/notifications/stream?projectId=<project-id>

Scope:

  • Server-sent events only.
  • Event name: notification_created.
  • Data shape: the same notification item returned by the list route.
  • If projectId is present, stream only that project.
  • If projectId is absent, stream all projects.
  • The dashboard should call GET /api/v1/notifications?status=unread first,
    then open the stream for new notifications.

The stream should be fed by the notification manager's dashboard publisher, not
by notification-table CDC triggers. On reconnect, the dashboard should call the
GET unread route again before reopening the stream.

Package Plan

Add:

  • backend/internal/domain/notification.go
  • backend/internal/service/notification/types.go
  • backend/internal/service/notification/store.go
  • backend/internal/service/notification/service.go
  • backend/internal/service/notification/enrich.go
  • backend/internal/service/notification/dispatcher.go
  • backend/internal/storage/sqlite/migrations/0009_notifications.sql
  • backend/internal/storage/sqlite/queries/notifications.sql
  • backend/internal/storage/sqlite/store/notification_store.go
  • backend/internal/httpd/controllers/notifications.go

Change:

  • backend/internal/lifecycle/manager.go: emit needs_input intents on the
    transition into waiting_input.
  • backend/internal/lifecycle/reactions.go: emit SCM notification intents for
    ready/merged/closed transitions.
  • backend/internal/daemon/lifecycle_wiring.go: wire the notification manager
    into lifecycle.
  • backend/internal/httpd/api.go: register the notifications controller.
  • backend/internal/httpd/controllers/dto.go: add notification response DTOs.
  • backend/internal/httpd/apispec/specgen/build.go: register schemas and
    operations, then run npm run api.

Generate:

  • npm run sqlc
  • npm run api

Implementation Sequence

1. Add Domain Vocabulary

Create backend/internal/domain/notification.go.

Define:

type NotificationType string

const (
    NotificationNeedsInput       NotificationType = "needs_input"
    NotificationReadyToMerge     NotificationType = "ready_to_merge"
    NotificationPRMerged         NotificationType = "pr_merged"
    NotificationPRClosedUnmerged NotificationType = "pr_closed_unmerged"
)

type NotificationStatus string

const (
    NotificationUnread NotificationStatus = "unread"
    NotificationRead   NotificationStatus = "read"
)

type NotificationRecord struct {
    ID        string
    SessionID SessionID
    ProjectID ProjectID
    PRURL     string
    Type      NotificationType
    Title     string
    Body      string
    Status    NotificationStatus
    CreatedAt time.Time
}

Keep validation helpers small:

  • type must be one of the four v1 values;
  • status must be unread or read;
  • session_id, project_id, title, and created_at are required.

2. Add SQLite Migration

Add backend/internal/storage/sqlite/migrations/0009_notifications.sql.

Create the notifications table with:

  • id
  • session_id
  • project_id
  • pr_url
  • type
  • title
  • body
  • status
  • created_at

Add indexes:

  • unread list index: (status, created_at DESC)
  • project unread list index: (project_id, status, created_at DESC)
  • session history index: (session_id, created_at DESC)
  • partial unique dedupe index:
CREATE UNIQUE INDEX idx_notifications_unread_dedupe
    ON notifications(session_id, type, pr_url)
    WHERE status = 'unread';

Do not add notification CDC triggers in v1.

3. Add sqlc Queries

Add backend/internal/storage/sqlite/queries/notifications.sql.

Required queries:

-- name: CreateNotification :one
INSERT INTO notifications (...)
VALUES (...)
RETURNING *;

-- name: ListUnreadNotifications :many
SELECT *
FROM notifications
WHERE status = 'unread'
ORDER BY created_at DESC
LIMIT ?;

-- name: ListUnreadNotificationsByProject :many
SELECT *
FROM notifications
WHERE project_id = ? AND status = 'unread'
ORDER BY created_at DESC
LIMIT ?;

The insert path should let the store detect the partial-unique dedupe violation.
Do not use INSERT OR IGNORE unless the store can still tell the difference
between "inserted" and "duplicate".

Run:

npm run sqlc

4. Add Notification Store

Add backend/internal/storage/sqlite/store/notification_store.go.

Store methods:

CreateNotification(ctx context.Context, rec domain.NotificationRecord) (domain.NotificationRecord, bool, error)
ListUnreadNotifications(ctx context.Context, limit int) ([]domain.NotificationRecord, error)
ListUnreadNotificationsByProject(ctx context.Context, projectID domain.ProjectID, limit int) ([]domain.NotificationRecord, error)

CreateNotification returns:

  • created=true when a row was inserted;
  • created=false, err=nil when the unread dedupe index already has the row;
  • err!=nil for real storage failures.

Map sqlc rows to domain.NotificationRecord. Keep generated sqlc types behind
the store boundary.

5. Add Service Interfaces

Add backend/internal/service/notification/store.go.

Define the service-facing store interface:

type Store interface {
    CreateNotification(ctx context.Context, rec domain.NotificationRecord) (domain.NotificationRecord, bool, error)
    ListUnreadNotifications(ctx context.Context, limit int) ([]domain.NotificationRecord, error)
    ListUnreadNotificationsByProject(ctx context.Context, projectID domain.ProjectID, limit int) ([]domain.NotificationRecord, error)
}

Add backend/internal/service/notification/types.go.

Define:

type Intent struct {
    Type      domain.NotificationType
    SessionID domain.SessionID
    ProjectID domain.ProjectID
    PRURL     string
    CreatedAt time.Time

    SessionDisplayName string
    PRNumber           int
    PRTitle            string
    PRSourceBranch     string
    PRTargetBranch     string
    Provider           string
    Repo               string
}

type Notification struct {
    domain.NotificationRecord
    Target NotificationTarget
}

The service manager can accept ports.NotificationIntent directly or map it to
an internal Intent type at the service boundary. Keep HTTP DTOs separate.

6. Add Enrichment

Add backend/internal/service/notification/enrich.go.

Implement:

func enrich(intent Intent) (domain.NotificationRecord, error)

V1 enrichment should use the fields lifecycle passed in. Do not add broad
storage fallback logic unless a required field is missing and there is already a
simple store method available.

Default titles:

  • needs_input: <session> needs input
  • ready_to_merge: PR #<number> is ready to merge
  • pr_merged: PR #<number> was merged
  • pr_closed_unmerged: PR #<number> was closed without merging

If PR number or session display name is missing, fall back to generic text.

7. Add Minimal Dispatcher

Add backend/internal/service/notification/dispatcher.go.

Define:

type Dispatcher interface {
    Dispatch(ctx context.Context, n Notification) error
}

type DashboardPublisher interface {
    Publish(ctx context.Context, n Notification) error
}

V1 dispatcher implementation:

type DashboardDispatcher struct {
    publisher DashboardPublisher
}

Behavior:

  • if publisher is nil, dispatch is a no-op;
  • if publisher returns an error, return that error to the manager;
  • do not add channel registration or plugin lookup in v1.

8. Add Notification Manager

Add backend/internal/service/notification/service.go.

Manager shape:

type Manager struct {
    store      Store
    dispatcher Dispatcher
    clock      func() time.Time
    newID      func() string
}

Required methods:

Notify(ctx context.Context, intent Intent) error
ListUnread(ctx context.Context, filter ListFilter) ([]Notification, error)

Notify behavior:

  1. Validate the intent.
  2. Fill CreatedAt if missing.
  3. Enrich into a stored notification record with status='unread'.
  4. Insert through the store.
  5. If the store reports duplicate, return nil and do not dispatch.
  6. If inserted, dispatch the notification.
  7. If dispatch fails, return/log the error, but do not delete the stored row.

Lifecycle callers should treat notification errors as non-fatal to lifecycle
state changes.

9. Wire Lifecycle Sink

Add a lifecycle dependency:

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

Update lifecycle.New(...) or add an option/setter so production wiring can
pass the notification manager and tests can pass a fake.

Do not call the sink while holding lifecycle.Manager.mu.

10. Emit needs_input

Update ApplyActivitySignal.

Behavior:

  1. Detect old activity state and new activity state.
  2. Persist the session update first.
  3. If the transition is into domain.ActivityWaitingInput, emit a
    needs_input intent.
  4. Do not emit when:
    • the signal is invalid;
    • the session is terminated;
    • the state did not change;
    • the state is anything other than waiting_input.

Intent should include:

  • Type=needs_input
  • SessionID
  • ProjectID
  • SessionDisplayName
  • CreatedAt

11. Emit SCM Notifications

Update ApplySCMObservation or the PR reaction path in
backend/internal/lifecycle/reactions.go.

Behavior:

  1. Ignore observations where Fetched=false.
  2. Load the session record once for ProjectID and termination guard.
  3. Build at most one notification intent for each relevant semantic state:
    • pr_merged when o.PR.Merged
    • pr_closed_unmerged when o.PR.Closed && !o.PR.Merged
    • ready_to_merge when PR is open, not draft, mergeability is
      domain.MergeMergeable, CI is not failing, and review is not requesting
      changes
  4. Include PR URL, number, title, repo, provider, source branch, and target
    branch when available.
  5. Persisted notification dedupe handles repeated polls. Lifecycle should still
    use obvious transition/changed information where available to avoid work.

Do not let notification failure prevent current PR lifecycle reactions such as
agent nudges or merged-session termination.

12. Add HTTP DTOs

Update backend/internal/httpd/controllers/dto.go.

Add:

type NotificationResponse struct {
    ID        string             `json:"id"`
    SessionID string             `json:"sessionId"`
    ProjectID string             `json:"projectId"`
    PRURL     string             `json:"prUrl"`
    Type      string             `json:"type"`
    Title     string             `json:"title"`
    Body      string             `json:"body"`
    Status    string             `json:"status"`
    CreatedAt time.Time          `json:"createdAt"`
    Target    NotificationTarget `json:"target"`
}

type ListNotificationsResponse struct {
    Notifications []NotificationResponse `json:"notifications"`
}

Keep controller DTOs separate from service/domain types.

13. Add GET Controller

Add backend/internal/httpd/controllers/notifications.go.

Route:

GET /api/v1/notifications?status=unread&projectId=<project-id>&limit=50

Validation:

  • default status to unread;
  • reject any non-unread status in v1;
  • default limit to 50;
  • cap limit at a small maximum such as 100;
  • accept optional projectId;
  • GET does not mark rows read.

Register the controller in backend/internal/httpd/api.go.

14. Wire Daemon

In daemon wiring:

  1. Construct the SQLite notification store.
  2. Construct the notification manager with:
    • store
    • minimal dispatcher
    • no-op dashboard publisher unless the SSE stream is implemented
  3. Pass the manager to lifecycle as the notification sink.
  4. Pass the manager to HTTP API deps for the GET route.

15. Add API Spec Generation

Update backend/internal/httpd/apispec/specgen/build.go:

  • add notification DTO schema names;
  • add the GET notifications operation;
  • include query parameters for status, projectId, and limit.

Run:

npm run api

Commit generated OpenAPI and frontend schema changes with the Go changes.

16. Keep SSE As Future Scope

Do not implement the stream route in the minimal GET slice unless explicitly
included.

The future stream route remains:

GET /api/v1/notifications/stream?projectId=<project-id>

When implemented, it should receive created notifications from the dashboard
publisher behind the minimal dispatcher. It should not depend on notification
table CDC triggers.

17. Run Focused Verification

Suggested order:

npm run sqlc
npm run api
cd backend && go test ./internal/service/notification ./internal/storage/sqlite/... ./internal/lifecycle ./internal/httpd/...

Then run the broader backend check if the implementation touches shared wiring:

cd backend && go test ./...

Test Plan

Lifecycle:

  • needs_input notification is emitted only when activity transitions into
    waiting_input.
  • Same-state activity does not emit a duplicate notification.
  • Terminated sessions do not emit activity notifications.
  • SCM merged emits pr_merged.
  • SCM closed and not merged emits pr_closed_unmerged.
  • SCM open/non-draft/mergeable emits ready_to_merge only when CI/review facts
    are not blocking.

Notification service:

  • Manager validates known notification types.
  • Enrichment uses lifecycle-provided fields without storage reads.
  • Enrichment falls back to storage when required fields are missing.
  • Duplicate unread notifications are ignored cleanly.
  • Persistence happens before dispatch.
  • Dispatch failure leaves the unread row available for GET.

SQLite store:

  • Insert/list unread round trip.
  • Project filter works.
  • Type/status check constraints reject invalid values.
  • Partial unique index blocks duplicate unread rows.

HTTP:

  • GET /api/v1/notifications returns unread notifications.
  • projectId filter and limit validation work.
  • Unsupported statuses return a usage/API validation error.
  • Nil service returns the existing 501-style controller response used elsewhere.
  • OpenAPI route/spec tests pass after generation.

Suggested narrow verification:

npm run sqlc
npm run api
cd backend && go test ./internal/lifecycle ./internal/service/notification ./internal/storage/sqlite/... ./internal/httpd/...

Open Decisions

  1. Read API: v1 stores status, but this design does not add a mark-read route
    unless the implementation slice expands. The obvious follow-up is
    POST /api/v1/notifications/{id}/read or a bulk read endpoint.
  2. Stream implementation timing: the route scope is defined here, but the first
    implementation slice can ship only the GET route.
  3. Ready-to-merge edge cases: the policy should reuse the existing display
    status logic as much as possible so dashboard status and notifications do not
    disagree.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
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