AW-13 — Notification matrix & attention budget · Task breakdown
Ordered work derived from plan.md, behaviour from spec.md. Every task carries explicit paths and a definition of "done". Execute top to bottom.
Epic ID: AW-13-attention-controls
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-09-06
How to use
- Tasks are sequential unless marked
(parallel), which means they may land alongside the task immediately above them. P1/P2/P3mark the phase. Each phase is independently shippable and must leavedevelopgreen on its own.- Every schema change ships its migration in the same PR (Constitution V). The migration task is never a follow-up.
- Formatting: tabs, width 4, 120 columns, single quotes, semicolons, no trailing commas (the root Prettier config wins). Files are kebab-case; React components are PascalCase files.
- Do not use conditional spreads (
...cond && { k: v }) in anything that emits declarations — it breaks DTS in this repo. - Run
pnpm format && pnpm lint && pnpm type-checkbefore every PR.
Phase P1 — the matrix works
Contracts
- T1 · P1. Shared notification contracts.
- Create
packages/contracts/src/notifications/attention.types.ts—AttentionTargetClass = 'email' | 'channel',ATTENTION_TARGET_CLASSES: readonly AttentionTargetClass[],AttentionBudgetSnapshot { targetClass; used; limit; held; resetsAt; enabled }. - Create
packages/contracts/src/notifications/notification-matrix.dto.tswithMatrixGroup,MatrixColumnDto,MatrixEventDto,NotificationMatrixDto— shapes in plan §3.4. - Create
packages/contracts/src/notifications/index.ts; addexport * from './notifications/index.js';topackages/contracts/src/index.ts. - Done:
pnpm --filter @ever-works/contracts buildemits declarations with no DTS error;import type { NotificationMatrixDto } from '@ever-works/contracts'resolves from bothapps/apiandapps/web.
- Create
The event catalogue
-
T2 · P1. Move the core event catalogue into the agent package and complete it.
- Create
packages/agent/src/notifications/core-event-catalogue.tsexportingCoreNotificationEventandCORE_NOTIFICATION_EVENTS: readonly CoreNotificationEvent[]with 23 rows — the 15 thatapps/api/src/notifications/notification-event-type-bootstrap.service.tsholds today, plus the 8 new keys, with the categories,urgentflags anddefaultChannelsfrom spec §4.5 FR-26. - New keys:
credits_balance_exhausted,payg_cap_80,payg_cap_100,payg_past_due,budget_threshold_warning,budget_threshold_reached,memory_consolidation_ready,digest_ready. - Corrections:
git_auth_expiredcategoryintegrations→security;agent_run_finishedcategoryagents→agent;urgentbecomestrueforagent_run_escalated,inbox_approval_requested,inbox_escalation,mission_blocked. - Export it from
packages/agent/src/notifications/index.ts. - Done: every
categoryvalue is a member ofNotificationCategoryinpackages/agent/src/entities/notification.types.ts; the file contains no delivery logic, only data.
- Create
-
T3 · P1. Point the bootstrap at the shared catalogue.
apps/api/src/notifications/notification-event-type-bootstrap.service.ts— delete the localCORE_EVENTSarray and theCoreEventRowinterface, importCORE_NOTIFICATION_EVENTSfrom@ever-works/agent/notifications, and keep the upsert loop, the plugin-manifest pass and the defensivereadManifestEventsreader exactly as they are.- Done: the service's behaviour is byte-identical for the 15 pre-existing keys and adds
the 8 new ones;
apps/apiboots on SQLite with 23 core rows innotification_event_types.
-
T4 · P1. The regression guard that stops this defect recurring.
- Create
packages/agent/src/notifications/__tests__/event-registry-coverage.spec.ts. - Read
packages/agent/src/notifications/notification.service.tsas text, extract every string literal passed aseventKey:, and assert each one has a row inCORE_NOTIFICATION_EVENTS. Handle the two interpolated forms explicitly: the pay-as-you-go key ispayg_cap_${percent}wherepercentis typed80 | 100, and the inbox key comes from theeventKeyByKindmap — assert all six resulting literals. - Assert every catalogue
categoryis aNotificationCategorymember (so it is a valid mute target) and that no two rows share akey. - Done: the spec fails if a producer gains an
eventKeywith no catalogue row, and fails if a catalogue row uses a category thatPOST /api/notifications/preferences/mutewould reject.
- Create
-
T5 · P1. Give the budget alert an event identity.
packages/agent/src/notifications/notification.service.ts—notifyBudgetThresholdCrossedgains adispatchFanoutcall aftercreate(), witheventKey: 'budget_threshold_reached'whenthresholdis'100'or'overage', else'budget_threshold_warning';urgentmirrors the same condition.- Done: the producer is the only place the threshold-to-key mapping exists;
notification.service.spec.tscovers all four threshold values.
Email as a built-in delivery target
-
T6 · P1. The sender port. - Create
packages/agent/src/notifications/notification-email-sender.port.ts—NOTIFICATION_EMAIL_SENDER = Symbol.for('NOTIFICATION_EMAIL_SENDER'),NotificationEmailInput { userId; eventKey?; title; message; actionUrl?; actionLabel? },NotificationEmailResult { status: 'delivered' | 'failed' | 'not-configured'; providerMessageId?; error? },NotificationEmailSender { deliver(input): Promise<...> }. - Export frompackages/agent/src/notifications/index.ts. - Done: the port has no import fromapps/apiand no mail-library import. -
T7 · P1. Entity changes for silent records and built-in delivery targets. -
packages/agent/src/entities/notification.entity.ts— add@Column({ default: false }) isSilent: boolean;and@Index('idx_notifications_user_silent_read', ['userId', 'isSilent', 'isRead']). -packages/agent/src/entities/notification-channel-delivery-log.entity.ts— makechannelId{ type: 'uuid', nullable: true }andstring | null; make the@ManyToOnerelation optional; add@Column({ type: 'varchar', length: 16, nullable: true }) builtInChannel?: string | null;and@Column({ type: 'uuid', nullable: true }) userId?: string | null;(no@ManyToOneonuserId— follow the Tier-C comment already ontenantId); add@Index('idx_ncdl_user_created', ['userId', 'createdAt']). -packages/agent/src/entities/notification.types.ts— add optionaleventKey?: stringtoCreateNotificationDtoand optionalincludeSilent?: booleantoNotificationQueryOptions. - Done:pnpm --filter @ever-works/agent type-checkis green and every existingNotificationService.create()call site compiles untouched. -
T8 · P1. Ship the migration for T7 and the registry data, in the same PR. - From
apps/api/:pnpm typeorm migration:generate -d typeorm.config.ts src/migrations/AttentionMatrixFoundations, then rename the emitted file toapps/api/src/migrations/1791130000000-AttentionMatrixFoundations.ts. - Hand-editup()so it contains only:ALTER TABLE "notifications" ADD COLUMN "isSilent" boolean NOT NULL DEFAULT false;ALTER TABLE "notification_channel_delivery_log" ALTER COLUMN "channelId" DROP NOT NULL;ADD COLUMN "builtInChannel" varchar(16);ADD COLUMN "userId" uuid; twoCREATE INDEX CONCURRENTLYstatements (idx_notifications_user_silent_read,idx_ncdl_user_created); the 23INSERT INTO "notification_event_types" … ON CONFLICT ("key") DO UPDATE SET "category" = EXCLUDED."category", "title" = …, "description" = …, "urgent" = …, "defaultChannels" = …rows (guarded tosource = 'core'); and the opt-out backfill —INSERT INTO "user_notification_subscriptions" ("userId","eventTypeKey","channelIds") SELECT id, 'budget_threshold_warning', '["in-app"]' FROM "users" WHERE "emailBudgetAlerts" = false ON CONFLICT DO NOTHINGand the same forbudget_threshold_reached. -down()drops the two columns, the index, restoresNOT NULLonly if no NULL rows exist, and deletes the 8 inserted keys. It does not attempt to un-correct the 2 categories or the 4 urgency flags. - Remove the migration's implicit transaction if the driver requires it forCREATE INDEX CONCURRENTLY. - Done: noDROP COLUMNon a pre-existing column, noNOT NULLadded to a populated column, noUPDATEagainstusers; running the migration twice against a seeded local DB leaves exactly 23 core rows innotification_event_types. -
T9 · P1. The email sentinel in the channel facade.
packages/agent/src/facades/notification-channel.facade.ts— insendOne, immediately after the existingif (channelId === 'in-app')branch, addif (channelId === 'email'): requireoptions.userId(same IDOR posture assendDirect/deliverToChannelOrThrow), call the optionally-injectedNOTIFICATION_EMAIL_SENDERport, write anotification_channel_delivery_logrow withchannelId: null,builtInChannel: 'email',userId: options.userId, and return{ channelId: 'email', pluginId: 'email', status }. A missing port returnsstatus: 'failed', error: 'email sender not configured'— never a silent success.- In the same file, stamp
userId: options.userId ?? channel.userIdon every delivery-log write, including the existing plugin path. - Do not change
dispatchOrSend—'email'is already!== 'in-app', so it routes through the existingNOTIFICATION_CHANNEL_DELIVERY_DISPATCHERand inherits retry, the quiet-hoursdelay, and the in-process fallback. - Done:
'email'never reachesNotificationChannelRepository;deliverToChannelOrThrow('email', …)throws on a failed send sopackages/tasks/src/tasks/trigger/notification-channel-delivery.task.tsretries it unchanged.
-
T10 · P1. The API-side sender and its template. - Create
apps/api/src/templates/notification.hbs— title, message, one primary action button, the "You get this because {{eventTitle}} is on for Email." line, and a link to the matrix. Match the visual language ofapps/api/src/templates/budget-alert.hbs. -apps/api/src/mail/templates.ts— add'notification'toKNOWN_EMAIL_TEMPLATES(the packaging spec asserts the list and the directory agree in both directions, so this is required, not optional). -apps/api/src/mail/mail.service.ts— addsendNotificationEmail(toEmail, recipientName, context)following the shape ofsendBudgetAlertEmail, including itsrequireEmailguard. - Createapps/api/src/notifications/notification-email-sender.service.tsimplementingNotificationEmailSender: resolve the user throughUserRepository, skip withnot-configuredwhen the mail transport is unavailable, skip withfailed+'address-unverified'whenemailVerifiedis false, otherwise send. Never log the address abovedebug. -apps/api/src/notifications/notifications.module.ts— importMailModule(it exportsMailServiceand imports nothing from this tree, so there is no cycle), and bind{ provide: NOTIFICATION_EMAIL_SENDER, useExisting: NotificationEmailSenderService }. - Done: a real notification withemailin its plan produces a MailHog message locally;pnpm --filter ever-works-api test -- templates.specis green.
Routing correctness
-
T11 · P1. Accept
emailas a built-in target and make "nothing" mean nothing.apps/api/src/notifications/notification-preferences.service.ts— add'email'toBUILT_IN_CHANNEL_IDS. LeaveMAX_SUBSCRIPTION_CHANNELS = 20and the per-id ownership loop untouched.packages/agent/src/notifications/user-notification-subscription.service.ts— inloadInitialChannels, change the subscription branch fromif (sub?.channelIds && sub.channelIds.length > 0)toif (sub)and return[...sub.channelIds]— an existing row wins even when empty (spec FR-13). Add a comment naming the scenario so nobody "fixes" it back.- In
resolvePlan, whenfindByKeymisses, increment annotifications.eventKey.unregisteredanalytics counter (best-effort, never throws) before returning the in-app fallback. - Done:
user-notification-subscription.service.spec.tsproves an empty stored array resolves to{ immediate: [], deferred: [] }, and that a stored['email']survives the whole chain.
-
T12 · P1. Silent in-app records.
packages/agent/src/notifications/notification.service.ts— optionally injectUserNotificationSubscriptionService. Increate(), whendto.eventKeyis present, the resolver is wired, anddto.isPersistentis not true, setisSilent = !plan.immediate.includes('in-app'). Wrap the resolution in try/catch and default tofalse(loud) on any error — spec FR-49.- Pass
eventKeyfrom all 16 producers, using the same literal each already passes todispatchFanout(and, fornotifyInboxItem, the sameeventKeyByKindlookup). packages/agent/src/database/repositories/notification.repository.ts— addisSilent: falseto the unread-count predicate and to the default list predicate; honouroptions.includeSilentto lift it.apps/api/src/notifications/notifications.controller.ts— add an optionalincludeSilentboolean query param toGET /(defaultfalse).GET /unread-countnever includes silent rows.- Done: a notification for an event with in-app off is written, is absent from
/unread-count, and is returned byGET /?includeSilent=true; a persistent notification is never silent.
-
T13 · P1. Move budget-alert email onto the matrix.
apps/api/src/budgets/budget-alert.handler.ts— remove the directmailService.sendBudgetAlertEmail(...)call and theuser.emailBudgetAlertsgate from the handler; the in-app write and the analytics track stay exactly as they are. The email now arrives via the fan-out T5 introduced.packages/agent/src/entities/user.entity.ts— add an@deprecateddoc comment toemailBudgetAlertsstating it is retained for account export/import and was folded into the matrix by AW-13. Do not remove the column.- Decide the template question from spec §9 before starting: if
Product keeps the rich
budget-alert.hbslayout, add a first-partyeventKey → templatemap insidenotification-email-sender.service.tswith exactly the two budget keys in it; otherwise the genericnotification.hbsis used for everything. - Done: a threshold crossing produces exactly one email, not two; a user whose
emailBudgetAlertswasfalsebefore the migration receives none;apps/web/e2e/flow-profile-budget-alerts.spec.tsstill passes.
API
- T14 · P1. The matrix read and reset endpoints.
- Create
apps/api/src/notifications/notification-matrix.service.ts— composes the DTO: registry rows (viaNotificationEventTypeRepository), the user's subscriptions, the user's channels (NotificationChannelRepository.findActiveByUser), quiet hours, active mutes, and the derivedgroupper event (spec FR-1). Reads run in onePromise.all. Provider labels are resolved throughPluginRegistryService— no local plugin-id map (Constitution II). - Create
apps/api/src/notifications/notification-matrix.controller.tswith@Controller('api/notifications'),@UseGuards(AuthSessionGuard), andGET /matrix+POST /matrix/reset. Reset deletes the caller's subscription rows for the named keys (all keys when the body omitseventKeys) and returns{ changed }.Cache-Control: private, no-storeon the read. - Register both in
apps/api/src/notifications/notifications.module.ts. - Done:
GET /api/notifications/matrixreturns 23 events, ≥2 columns, and the caller's selections; an unauthenticated call is rejected; a reset for another user's key changes nothing and reportschanged: 0.
- Create
Web
-
T15 · P1. Extend the typed web client.
apps/web/src/lib/api/notification-preferences.ts— addgetMatrix()andresetMatrix(eventKeys?), typed against@ever-works/contracts. Keep every existing method.- Done: no
any;pnpm --filter web type-checkis green.
-
T16 · P1. Server actions for the matrix.
- Create
apps/web/src/app/actions/notification-preferences.tswithsetEventTargets(eventKey, targetIds)andresetMatrix(eventKeys?), following theensureAuth()+revalidatePath('/', 'layout')pattern inapps/web/src/app/actions/notification-channels.ts. apps/web/src/app/actions/settings.ts— add an@deprecatedJSDoc block toupdateNotificationPreferencesnaming the new file, and a comment stating it is unreachable dead code returning a simulated success. Do not delete it.- Done: an unauthenticated invocation redirects to login; the action forwards exactly the target list it was given.
- Create
-
T17 · P1. Matrix components.
- Create
apps/web/src/components/settings/notifications/withNotificationMatrix.tsx,MatrixGroup.tsx,MatrixRow.tsx,MatrixSwitch.tsx,MatrixColumnHeader.tsx,MatrixOverflowPicker.tsx,QuietHoursRow.tsx,ResetDefaultsDialog.tsx(plan §5.2). NotificationMatrix.tsxowns: optimistic state, aMap<eventKey, …>with a 400 ms debounce (FR-9), an 8 sAbortSignal.timeoutthat reverts the row (FR-10), per-row isolation (FR-11), per-row save state that clears after 2 s (FR-12), roving tabindex with one tab stop, arrow/Home/End/Ctrl+Home/Ctrl+End navigation,Space/Entertoggle andShift+Spacerow toggle (§6.10), a polite live region, and refetch-on-focus after 30 s away (FR-15).MatrixSwitch.tsxrendersrole="switch"witharia-checked, an accessible name of "{column} delivery for {event}", and the disabled+reason states for unverified / unconfigured email (S18, S19) and locked persistent rows (FR-23).MatrixColumnHeader.tsxshows at most 6 columns and hands the rest toMatrixOverflowPicker.tsxwith a 20-target counter (FR-4, FR-5).- Done: every visible string comes from
useTranslations('dashboard.settings.notifications'); no component imports thePROVIDERSarray fromapps/web/src/components/settings/NotificationChannelsSettings.tsx.
- Create
-
T18 · P1. Rewrite the page's entry component and the page itself.
apps/web/src/components/settings/NotificationPreferencesSettings.tsx— keep the file and the export name; replace the read-only table with a composition of the T17 components. Delete thedefaultCheckedcheckboxes and the hard-coded English.apps/web/src/app/[locale]/(dashboard)/settings/notifications/page.tsx— replace the four parallel fetches withgetMatrix()plus the existing profile read for the Novu subscriber hash, keeping.catch()-style degradation so a Novu failure cannot blank the page. Render the load-error state (§6.5) when the matrix read fails.- Done: toggling a switch persists across a reload; the page renders with a broken Novu config; the empty-registry state renders when the registry is empty.
-
T19 · P1. Bell footer link.
apps/web/src/components/dashboard/NotificationDropdown.tsx— add a footer link toROUTES.DASHBOARD_SETTINGS_NOTIFICATIONS(already defined inapps/web/src/lib/constants.ts:265) usingdashboard.header.notifications.settingsLink. Nothing else in this file changes.- Done: the settings page is reachable from the product for the first time (spec FR-44).
-
T20 · P1. i18n.
apps/web/messages/en.json— add thedashboard.settings.notificationsnamespace with every key listed in plan §8, using the exact values in spec §6.9. Adddashboard.header.notifications.settingsLinkand.mutedFilter.- Add
dashboard.settings.notifications.events.<eventKey>.{title,description,alternativeSurface}for all 23 core keys. Normalise a plugin-namespaced key's:to_before lookup, and let a missing key fall back to the registry string. - Mirror the full key set into the 20 sibling locale files in
apps/web/messages/. - Done: no leaf key name contains a literal
.(next-intl throws at runtime and reds several e2e shards at once); every locale file parses;pnpm --filter web buildis clean.
P1 tests
-
T21 · P1. Agent-package unit tests.
- Extend
packages/agent/src/notifications/user-notification-subscription.service.spec.ts(empty selection,'email'survival, quiet-hours defer for'email'). - Extend
packages/agent/src/notifications/notification.service.spec.ts(isSilent, persistent-never-silent, the two budget keys). - Create
packages/agent/src/facades/__tests__/notification-channel.facade.email-sentinel.spec.ts(no repository call, correct log row, throws for the retry path, missing port fails loudly). - Done:
cd packages/agent && pnpm testis green.
- Extend
-
T22 · P1. API controller specs.
- Create
apps/api/src/notifications/notification-matrix.controller.spec.tsandapps/api/src/notifications/notification-email-sender.service.spec.ts. - Extend
apps/api/src/notifications/notification-preferences.service.spec.tsandapps/api/src/notifications/notifications.controller.spec.ts. - Create
apps/api/src/budgets/budget-alert.handler.spec.tsproving the handler no longer sends mail directly. - Done:
cd apps/api && pnpm testis green.
- Create
-
T23 · P1. Web unit tests.
- Create
apps/web/src/components/settings/notifications/NotificationMatrix.unit.spec.tsxandapps/web/src/app/actions/notification-preferences.unit.spec.ts. - Done: four rapid clicks produce one write; a rejected write reverts only its row; the
grid exposes one tab stop;
cd apps/web && pnpm testis green.
- Create
-
T24 · P1. e2e.
- Create
apps/web/e2e/flow-notification-matrix-autosave.spec.tsandapps/web/e2e/flow-notification-matrix-email-target.spec.ts(using the existingapps/web/e2e/helpers/mailhog.ts). - Done: both pass locally; the existing suite named in plan §10.4 is untouched and still green.
- Create
-
T25 · P1. Migration test.
- Add a case under
apps/api/src/migrations/__tests__/asserting the P1 migration is idempotent (23 core registry rows after two runs), contains noDROP COLUMNon a pre-existing column, and issues noUPDATEagainstusers. - Done: the case fails if someone adds a destructive statement to the migration.
- Add a case under
Phase P2 — the attention budget
Data model
-
T26 · P2. Budget columns on the preference row.
packages/agent/src/entities/user-notification-preference.entity.ts— addattentionBudgetEnabled: boolean(defaulttrue),emailDailyBudget: number(int, default10),channelDailyBudget: number(int, default20).- Done: a user with no preference row still resolves to the same three defaults in code.
-
T27 · P2. The hold entity and its repository.
- Create
packages/agent/src/entities/attention-hold.entity.ts(attention_holds) with the columns and two indexes in plan §3.2b. UsePortableDateColumnfrompackages/agent/src/entities/_types.tsfor every timestamp, asnotification-channel-delivery-log.entity.tsalready does, so the SQLite test driver keeps working. - Create
packages/agent/src/database/repositories/attention-hold.repository.tswithcreate,countOpenForUser,listOpenForUser(userId, limit),markReleased(ids, at),deleteExpired(now). - Export the entity from
packages/agent/src/entities/index.tsand the repository frompackages/agent/src/database/index.ts, beside the other notification repositories. - Done: the entity is registered in the TypeORM entity list used by both the API and the
test harness;
pnpm --filter @ever-works/agent type-checkis green.
- Create
-
T28 · P2. Ship the migration for T26 + T27, in the same PR. -
apps/api/src/migrations/1791130100000-CreateAttentionHolds.ts— threeADD COLUMN … NOT NULL DEFAULTonuser_notification_preferences,CREATE TABLE attention_holdswith its FK tousers(ON DELETE CASCADE) and its two indexes. - Done:up()has noDROP, no rename, no backfill;down()reverses exactly it; running against a seeded local DB completes without lockinguser_notification_preferences.
Admission
-
T29 · P2. The budget service.
- Create
packages/agent/src/notifications/attention-budget.service.tswithadmit(userId, plan, event, payload): Promise<{ deliver: string[]; held: string[] }>. - Counting:
COUNT(DISTINCT "messageRef")overnotification_channel_delivery_logwhereuserId = :userId,createdAt >= now() - 24h,status <> 'dropped', split bybuiltInChannel = 'email'versuschannelId IS NOT NULL. - Rules: urgent events are always admitted and still increment the count (FR-31); a target
over its class ceiling is held (FR-32);
in-appis never counted (FR-30); a disabled budget admits everything (FR-35); any error in the count admits the delivery (fail open, plan §9.2). - Create
packages/agent/src/notifications/__tests__/attention-budget.service.spec.tsfor every rule above plus the 24 h boundary and the0ceiling. - Done: the service has no HTTP, no mail and no Trigger import; the spec is green.
- Create
-
T30 · P2. Wire admission into the fan-out.
apps/api/src/notifications/notification-fanout.listener.ts— betweenresolvePlan(...)andchannelFacade.send(...), callAttentionBudgetService.admit(...); pass only the admitted targets to the facade and write anattention_holdsrow per held target. Keep thesuppressErrors: trueposture — a budget fault must never surface to the producer.apps/api/src/notifications/notifications.module.ts— provide the budget service and the hold repository.- Done: with the email ceiling at 1, two non-urgent events produce one email and one hold; an urgent event still sends.