# ADR-0009 — Signal normalization strategy **Status:** Accepted **Date:** 2026-04-18 **Issue:** #78 ## Context The recommender was hard-wired to Todoist: task fetch, cache, and feature extraction lived inside `recommender.ts` with no abstraction boundary. Adding Google Calendar, Apple Health, or manual input sources would have required forking the pipeline per source. ## Decision Introduce two abstractions in `packages/shared-types`: ```typescript interface Signal { id: string; source: string; kind: 'task' | 'event' | 'habit' | 'insight'; content: string; metadata: Record; // raw source fields, not used by bandit features: Record; // bandit-ready features timestamp: string; } interface SignalSource { readonly id: string; fetchSignals(userId: string): Promise; act?(userId: string, signalId: string, action: string): Promise; } ``` `SignalAggregator` calls all registered sources in parallel, isolating failures per source. `TodoistSignalSource` moves all Todoist-specific logic (fetch, 401 handling, cache, bus events) out of the recommender route. The recommender maps `Signal[]` → `TipCandidate[]` via a thin adapter and registers action dispatch through the aggregator. ## Consequences **Good:** - Adding a new signal source is a single `aggregator.register(new MySource())` call. - `TipCandidate.features` is now `Record`, matching `Signal.features`. Sources control their own feature names; the bandit serialises them as-is. - Source failures are isolated: a broken Google Calendar connector does not prevent Todoist signals from reaching the bandit. - `act()` on the aggregator routes actions back to the owning source (e.g. marking a Todoist task done), replacing ad-hoc source-specific logic in the feedback handler. **Trade-offs:** - Feature names are no longer compile-time typed. Convention: sources document their feature keys in their class JSDoc. The Python bandit already treated features as an opaque dict. - Each source is responsible for its own token lookup (DB access injected via module-level `db`). This is acceptable in a modular monolith; extract to a token vault interface if sources move to separate processes. ## Alternatives considered **Typed feature schema per source kind** — rejected: would require union types across all sources and a discriminant on every consumer. The bandit doesn't benefit from TypeScript types at runtime. **Aggregator holds tokens, passes to sources** — rejected: leaks auth concerns into the aggregator. Sources know their own auth requirements.