You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Keep notification policy in lifecycle. Providers continue to report facts;
lifecycle decides whether a fact transition is user-notifiable.
Make stored notifications durable and dashboard-readable.
Keep enrichment centralized so dashboard notifications read like user-facing
notifications, not raw provider reports.
Add a minimal dispatcher tied to the dashboard/SSE boundary. Future channels
stay as comments/follow-up work, not a v1 plugin system.
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.
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:
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.
Manager: validates the lifecycle intent, calls enrichment, persists the
row, and asks the dispatcher to deliver it.
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.
Store: writes and lists notification rows.
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.
ports.NotificationIntent should be a lifecycle-to-service contract, not an
HTTP DTO. It should carry the facts lifecycle already has:
typeNotificationIntentstruct {
Type domain.NotificationTypeSessionID domain.SessionIDProjectID domain.ProjectIDPRURLstringCreatedAt time.Time// Enrichment hints. These avoid storage reads on the hot path.SessionDisplayNamestringPRNumberintPRTitlestringPRSourceBranchstringPRTargetBranchstringProviderstringRepostring
}
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:
CREATETABLEnotifications (
id TEXTPRIMARY KEY,
session_id TEXTNOT NULLREFERENCES sessions(id) ON DELETE CASCADE,
project_id TEXTNOT NULLREFERENCES projects(id) ON DELETE CASCADE,
pr_url TEXTNOT NULL DEFAULT '',
type TEXTNOT NULLCHECK (
type IN (
'needs_input',
'ready_to_merge',
'pr_merged',
'pr_closed_unmerged'
)
),
title TEXTNOT NULL,
body TEXTNOT NULL DEFAULT '',
status TEXTNOT NULL DEFAULT 'unread'CHECK (status IN ('read', 'unread')),
created_at TIMESTAMPNOT NULL
);
CREATEINDEXidx_notifications_unreadON notifications(status, created_at DESC);
CREATEINDEXidx_notifications_project_unreadON notifications(project_id, status, created_at DESC);
CREATEINDEXidx_notifications_sessionON notifications(session_id, created_at DESC);
CREATEUNIQUE INDEXidx_notifications_unread_dedupeON 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.
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.
-- name: CreateNotification :oneINSERT INTO notifications (...)
VALUES (...)
RETURNING *;
-- name: ListUnreadNotifications :manySELECT*FROM notifications
WHERE status ='unread'ORDER BY created_at DESCLIMIT ?;
-- name: ListUnreadNotificationsByProject :manySELECT*FROM notifications
WHERE project_id = ? AND status ='unread'ORDER BY created_at DESCLIMIT ?;
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".
The service manager can accept ports.NotificationIntent directly or map it to
an internal Intent type at the service boundary. Keep HTTP DTOs separate.
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.
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.
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
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.
Stream implementation timing: the route scope is defined here, but the first
implementation slice can ship only the GET route.
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.
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_inputready_to_mergepr_mergedpr_closed_unmergedGoals
lifecycle decides whether a fact transition is user-notifiable.
notifications, not raw provider reports.
stay as comments/follow-up work, not a v1 plugin system.
treat stream implementation as follow-up unless the implementation slice
explicitly includes it.
Non-goals
channel, or external channels in v1.
The v1 table has a
statuscolumn, but the immediate API only lists unreadrows.
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 anactivity 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"]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:
diff stats, and provider timestamps
failure log tail
Changedflags for metadata, CI, and review semantic bucketsV1 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.
needs_inputwaiting_inputand is not terminatedready_to_mergemergeable, and CI/review facts do not block mergepr_mergedMerged=truepr_closed_unmergedClosed=trueandMerged=falseImportant details:
needs_inputnotifications.
ApplyActivitySignalalready treats same activity states asno-op writes; the notification call should follow the same transition.
ready_to_mergeshould align with the existing derivedmergeablesessionstatus rather than inventing a second interpretation in the notification
service.
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"]The service has four internal pieces:
Manager: validates the lifecycle intent, calls enrichment, persists therow, and asks the dispatcher to deliver it.
Enricher: turns lifecycle intent data into a user-facing notificationDTO. It prefers data already provided by lifecycle and only reads storage
when required fields are missing.
Store: writes and lists notification rows.Dispatcher: a tiny internal boundary that forwards created notificationsto 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 tothe existing agent messenger dependency.
ports.NotificationIntentshould be a lifecycle-to-service contract, not anHTTP DTO. It should carry the facts lifecycle already has:
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 notificationThe notification call should happen after the session update succeeds and
outside the lifecycle mutex. If the current
mutatehelper 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
SCM notifications should use the
ports.SCMObservation.Changedflags wherethey 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
titleandbodywith thenotification 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:
needs_input<session> needs inputready_to_mergePR #<number> is ready to mergepr_mergedPR #<number> was mergedpr_closed_unmergedPR #<number> was closed without mergingStorage fallback rules:
ProjectID, do not read the session row just to confirmit.
enricher.
rather than failing the notification.
Storage
Add a new migration, not an edit to existing migrations:
backend/internal/storage/sqlite/migrations/0009_notifications.sqlV1 table:
pr_urlis an empty string for non-PR notifications. This keeps the dedupeindex 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:
waiting_inputhooks do not create multiple unreadneeds_inputnotifications;
PR and type;
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 endIf 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:
The v1 implementation is tied to dashboard/SSE delivery:
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:
Rules:
statusdefaults tounread.unreaduntil read-state mutation isimplemented.
projectIdis optional. When present, return unread notifications for thatproject only. When absent, return unread notifications across all projects the
local daemon knows about.
limitdefaults to a conservative value such as 50 and should have a max.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.
Scope:
notification_created.projectIdis present, stream only that project.projectIdis absent, stream all projects.GET /api/v1/notifications?status=unreadfirst,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.gobackend/internal/service/notification/types.gobackend/internal/service/notification/store.gobackend/internal/service/notification/service.gobackend/internal/service/notification/enrich.gobackend/internal/service/notification/dispatcher.gobackend/internal/storage/sqlite/migrations/0009_notifications.sqlbackend/internal/storage/sqlite/queries/notifications.sqlbackend/internal/storage/sqlite/store/notification_store.gobackend/internal/httpd/controllers/notifications.goChange:
backend/internal/lifecycle/manager.go: emitneeds_inputintents on thetransition into
waiting_input.backend/internal/lifecycle/reactions.go: emit SCM notification intents forready/merged/closed transitions.
backend/internal/daemon/lifecycle_wiring.go: wire the notification managerinto 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 andoperations, then run
npm run api.Generate:
npm run sqlcnpm run apiImplementation Sequence
1. Add Domain Vocabulary
Create
backend/internal/domain/notification.go.Define:
Keep validation helpers small:
unreadorread;session_id,project_id,title, andcreated_atare required.2. Add SQLite Migration
Add
backend/internal/storage/sqlite/migrations/0009_notifications.sql.Create the
notificationstable with:idsession_idproject_idpr_urltypetitlebodystatuscreated_atAdd indexes:
(status, created_at DESC)(project_id, status, created_at DESC)(session_id, created_at DESC)Do not add notification CDC triggers in v1.
3. Add sqlc Queries
Add
backend/internal/storage/sqlite/queries/notifications.sql.Required queries:
The insert path should let the store detect the partial-unique dedupe violation.
Do not use
INSERT OR IGNOREunless the store can still tell the differencebetween "inserted" and "duplicate".
Run:
4. Add Notification Store
Add
backend/internal/storage/sqlite/store/notification_store.go.Store methods:
CreateNotificationreturns:created=truewhen a row was inserted;created=false, err=nilwhen the unread dedupe index already has the row;err!=nilfor real storage failures.Map sqlc rows to
domain.NotificationRecord. Keep generated sqlc types behindthe store boundary.
5. Add Service Interfaces
Add
backend/internal/service/notification/store.go.Define the service-facing store interface:
Add
backend/internal/service/notification/types.go.Define:
The service manager can accept
ports.NotificationIntentdirectly or map it toan internal
Intenttype at the service boundary. Keep HTTP DTOs separate.6. Add Enrichment
Add
backend/internal/service/notification/enrich.go.Implement:
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 inputready_to_merge:PR #<number> is ready to mergepr_merged:PR #<number> was mergedpr_closed_unmerged:PR #<number> was closed without mergingIf 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:
V1 dispatcher implementation:
Behavior:
8. Add Notification Manager
Add
backend/internal/service/notification/service.go.Manager shape:
Required methods:
Notifybehavior:CreatedAtif missing.status='unread'.Lifecycle callers should treat notification errors as non-fatal to lifecycle
state changes.
9. Wire Lifecycle Sink
Add a lifecycle dependency:
Update
lifecycle.New(...)or add an option/setter so production wiring canpass the notification manager and tests can pass a fake.
Do not call the sink while holding
lifecycle.Manager.mu.10. Emit
needs_inputUpdate
ApplyActivitySignal.Behavior:
domain.ActivityWaitingInput, emit aneeds_inputintent.waiting_input.Intent should include:
Type=needs_inputSessionIDProjectIDSessionDisplayNameCreatedAt11. Emit SCM Notifications
Update
ApplySCMObservationor the PR reaction path inbackend/internal/lifecycle/reactions.go.Behavior:
Fetched=false.ProjectIDand termination guard.pr_mergedwheno.PR.Mergedpr_closed_unmergedwheno.PR.Closed && !o.PR.Mergedready_to_mergewhen PR is open, not draft, mergeability isdomain.MergeMergeable, CI is not failing, and review is not requestingchanges
branch when available.
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:
Keep controller DTOs separate from service/domain types.
13. Add GET Controller
Add
backend/internal/httpd/controllers/notifications.go.Route:
Validation:
statustounread;unreadstatus in v1;limitto 50;limitat a small maximum such as 100;projectId;Register the controller in
backend/internal/httpd/api.go.14. Wire Daemon
In daemon wiring:
15. Add API Spec Generation
Update
backend/internal/httpd/apispec/specgen/build.go:status,projectId, andlimit.Run:
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:
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:
Then run the broader backend check if the implementation touches shared wiring:
Test Plan
Lifecycle:
needs_inputnotification is emitted only when activity transitions intowaiting_input.pr_merged.pr_closed_unmerged.ready_to_mergeonly when CI/review factsare not blocking.
Notification service:
SQLite store:
HTTP:
GET /api/v1/notificationsreturns unread notifications.projectIdfilter andlimitvalidation work.Suggested narrow verification:
Open Decisions
status, but this design does not add a mark-read routeunless the implementation slice expands. The obvious follow-up is
POST /api/v1/notifications/{id}/reador a bulk read endpoint.implementation slice can ship only the GET route.
status logic as much as possible so dashboard status and notifications do not
disagree.