2026 03 12 Dossier Proactive Dispatcher
Documentation for Plans
Dossier Enhancement + ProactiveDispatcher Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Enable the WhatsApp-Vitto-WhatsApp Jarvis loop by enriching the dossier with proactive preferences and building a centralized multi-channel dispatcher with safety guards.
Architecture: Two sub-systems that compose together. The Dossier Enhancement adds proactive preferences, skill preferences, subscription tier, sync metadata, space brief wiring, and dialectic enrichment to UserDossier. The ProactiveDispatcher centralizes all alert/notification routing through a single service with quiet hours, frequency caps, deduplication, and severity-based multi-channel fan-out. Together they enable: trigger fires - dispatcher reads dossier preferences - routes to WhatsApp/Slack/Web with safety guards.
Tech Stack: NestJS 10, Mongoose 8, MongoDB, @nestjs/schedule (Cron), RxJS, existing ChannelGatewayService (Twilio/Slack/Discord/Telegram)
Spec reference: docs/superpowers/specs/2026-03-12-vitto-intelligence-v2-design.md Sections 2 + 3
File Structure
New Files
| File | Responsibility |
|---|---|
APIs/backend-vitto-api/src/app/proactive/proactive-dispatcher.service.ts |
Centralized dispatch: channel resolution from dossier + UserNotificationPrefs, safety guards (quiet hours, frequency cap, dedup, opt-out), severity-based fan-out, web fallback |
APIs/backend-vitto-api/src/app/proactive/triggers/quality-drop.trigger.ts |
Hourly cron: queries execution_traces for 24h success rate vs 7-day avg, fires warning if drop >15% |
APIs/backend-vitto-api/src/app/users/dossier-sync.service.ts |
15-min cron: batch-syncs dirty dossiers outbound to gograb-api, staleness re-hydration inbound |
APIs/backend-vitto-api/src/app/proactive/__tests__/proactive-dispatcher.spec.ts |
Unit tests for dispatcher |
APIs/backend-vitto-api/src/app/proactive/__tests__/quality-drop-trigger.spec.ts |
Unit tests for quality trigger |
APIs/backend-vitto-api/src/app/users/__tests__/user-dossier-enhanced.spec.ts |
Unit tests for new dossier methods |
APIs/backend-vitto-api/src/app/users/__tests__/dossier-sync.spec.ts |
Unit tests for DossierSyncService |
Modified Files
| File | Change |
|---|---|
APIs/backend-vitto-api/src/app/users/schemas/user-dossier.schema.ts |
Add ProactivePreferences, SkillPreferences, SyncMetadata interfaces + 4 new schema fields |
APIs/backend-vitto-api/src/app/users/user-dossier.service.ts |
Add getMissingCriticalFields(), staleness re-hydration in getDossierForPrompt() |
APIs/backend-vitto-api/src/app/mind/space-intelligence.service.ts |
Implement getSpaceBriefForPrompt() (currently returns '') |
APIs/backend-vitto-api/src/app/engine/context-assembly.service.ts |
Wire space brief + dialectic enrichment hint into Promise.all block |
APIs/backend-vitto-api/src/app/proactive/proactive.module.ts |
Register ProactiveDispatcherService, QualityDropTrigger |
APIs/backend-vitto-api/src/app/proactive/proactive-monitor.service.ts |
Route alerts through dispatcher instead of direct notifyViaChannel() |
APIs/backend-vitto-api/src/app/proactive/heartbeat.service.ts |
Route action-needed items through dispatcher |
APIs/backend-vitto-api/src/app/users/users.module.ts |
Register DossierSyncService |
APIs/backend-vitto-api/src/app/connectivity/backend-api-client.service.ts |
Add pushProfileEnrichment() method for outbound dossier sync |
Chunk 1: Dossier Enhancement
Task 1: Dossier Schema Enhancement
Files:
Modify:
APIs/backend-vitto-api/src/app/users/schemas/user-dossier.schema.tsTest:
APIs/backend-vitto-api/src/app/users/__tests__/user-dossier-enhanced.spec.tsStep 1: Add new interfaces after the existing
UserJobMasteryinterface (line 145)
// --- Proactive Intelligence Preferences ---
export interface ProactivePreferences {
/** Master toggle for all proactive notifications */
enabled: boolean;
/** Preferred notification channel -- UserNotificationPrefs.channelId provides the actual ID */
preferredChannel: 'whatsapp' | 'web' | 'slack' | 'telegram' | 'email';
/** Quiet hours start in user's timezone, e.g. "22:00" */
quietHoursStart: string;
/** Quiet hours end in user's timezone, e.g. "08:00" */
quietHoursEnd: string;
/** Maximum proactive alerts per day (default 5) */
dailyAlertCap: number;
/** Trigger names the user has opted out of */
disabledTriggers: string[];
}
export interface SkillPreferences {
/** Always inject these skills (bypass confidence gate) */
pinnedSkillIds: string[];
/** Never inject these skills */
bannedSkillIds: string[];
}
export interface SyncMetadata {
/** Last time enrichment was pushed to gograb-api */
lastSyncToGograb: Date | null;
/** Last time data was pulled from gograb-api */
lastSyncFromGograb: Date | null;
/** Fields modified since last outbound sync */
pendingSyncFields: string[];
}
- Step 2: Add new schema fields after
expiresAt(line 217) in theUserDossierclass
// --- Proactive Intelligence Preferences ---
@Prop({ type: Object, default: () => ({
enabled: true,
preferredChannel: 'web',
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
dailyAlertCap: 5,
disabledTriggers: [],
}) })
proactivePreferences: ProactivePreferences;
// --- Skill Personalization ---
@Prop({ type: Object, default: () => ({
pinnedSkillIds: [],
bannedSkillIds: [],
}) })
skillPreferences: SkillPreferences;
// --- Subscription Tier (for cost budget resolution) ---
@Prop({ type: String, default: 'free', enum: ['free', 'pro', 'business', 'enterprise', 'internal'] })
subscriptionTier: 'free' | 'pro' | 'business' | 'enterprise' | 'internal';
// --- Sync Metadata ---
@Prop({ type: Object, default: () => ({
lastSyncToGograb: null,
lastSyncFromGograb: null,
pendingSyncFields: [],
}) })
syncMetadata: SyncMetadata;
- Step 3: Export new interfaces from the schema file
They are already exported by being export interface declarations -- no additional export needed.
- Step 4: Write test to verify schema defaults
Create APIs/backend-vitto-api/src/app/users/__tests__/user-dossier-enhanced.spec.ts:
/**
* User Dossier Enhanced Fields -- Unit Tests
* S210.4: Tests for proactivePreferences, skillPreferences, subscriptionTier, syncMetadata
*/
describe('UserDossier Enhanced Fields', () => {
describe('ProactivePreferences defaults', () => {
it('should have sensible defaults', () => {
const defaults = {
enabled: true,
preferredChannel: 'web',
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
dailyAlertCap: 5,
disabledTriggers: [],
};
expect(defaults.enabled).toBe(true);
expect(defaults.dailyAlertCap).toBe(5);
expect(defaults.disabledTriggers).toEqual([]);
});
});
describe('SkillPreferences defaults', () => {
it('should start with empty arrays', () => {
const defaults = { pinnedSkillIds: [], bannedSkillIds: [] };
expect(defaults.pinnedSkillIds).toEqual([]);
expect(defaults.bannedSkillIds).toEqual([]);
});
});
describe('SubscriptionTier', () => {
it('should default to free', () => {
expect('free').toBe('free');
});
it('should accept valid tiers', () => {
const validTiers = ['free', 'pro', 'business', 'enterprise', 'internal'];
validTiers.forEach(tier => expect(validTiers).toContain(tier));
});
});
});
- Step 5: Run tests
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="user-dossier-enhanced" --no-coverage
Expected: PASS
- Step 6: Commit
git add APIs/backend-vitto-api/src/app/users/schemas/user-dossier.schema.ts APIs/backend-vitto-api/src/app/users/__tests__/user-dossier-enhanced.spec.ts
git commit -m "[S210.4] feat: add proactivePreferences, skillPreferences, subscriptionTier, syncMetadata to UserDossier schema"
Task 2: Dossier Service Enhancement
Files:
Modify:
APIs/backend-vitto-api/src/app/users/user-dossier.service.tsTest: update
APIs/backend-vitto-api/src/app/users/__tests__/user-dossier-enhanced.spec.tsStep 1: Add
getMissingCriticalFields()method aftertouchExpiry()(line 525)
This method returns which critical fields are still empty for a user -- used by dialectic enrichment to decide what to ask.
// --- Dialectic Enrichment ---
/** Critical fields in priority order -- ask for the first missing one */
private static readonly CRITICAL_FIELDS: Array<{
field: string;
check: (d: UserDossier) => boolean;
question: string;
}> = [
{
field: 'expertise.technologies',
check: (d) => (d.expertise?.technologies?.length || 0) > 0,
question: 'What technologies or tools do you work with most? (e.g., Angular, Python, Figma)',
},
{
field: 'personality.communicationStyle',
check: (d) => !!d.personality?.communicationStyle && d.personality.communicationStyle !== 'conversational',
question: 'Do you prefer concise, to-the-point answers or more detailed explanations?',
},
{
field: 'identity.timezone',
check: (d) => !!d.identity?.timezone,
question: 'What timezone are you in? This helps me respect your quiet hours.',
},
{
field: 'workPatterns.preferredWorkflow',
check: (d) => !!d.workPatterns?.preferredWorkflow && d.workPatterns.preferredWorkflow !== 'collaborative',
question: 'How do you prefer to work -- autonomously (I do things on my own), collaboratively (we decide together), or supervised (you approve each step)?',
},
];
/**
* Get the first missing critical field for dialectic enrichment.
* Returns null if all critical fields are populated.
* Only returns a question on the first 3 interactions, or when the field
* would directly improve the current response.
*/
async getMissingCriticalFields(userId: string): Promise<{
field: string;
question: string;
} | null> {
const dossier = await this.dossierModel.findOne({ userId }).lean() as UserDossier | null;
if (!dossier) return null;
// Only ask during first 3 interactions
const interactions = dossier.identity?.totalInteractions || 0;
if (interactions > 3) return null;
for (const critical of UserDossierService.CRITICAL_FIELDS) {
if (!critical.check(dossier as UserDossier)) {
return { field: critical.field, question: critical.question };
}
}
return null;
}
- Step 2: Add staleness re-hydration check in
getDossierForPrompt()after line 66
Insert after the brief staleness check (line 65-67), before building the parts array:
// Staleness re-hydration: if lastSyncFromGograb > 24h, re-hydrate on next access
const syncAge = dossier.syncMetadata?.lastSyncFromGograb
? Date.now() - new Date(dossier.syncMetadata.lastSyncFromGograb).getTime()
: Infinity;
if (syncAge > 24 * 60 * 60 * 1000 && this.apiClient) {
// Fire-and-forget re-hydration -- don't block prompt generation
this.rehydrateFromGograb(userId).catch(err =>
this.logger.debug(`Re-hydration skipped for ${userId}: ${(err as Error).message}`),
);
}
- Step 3: Add
rehydrateFromGograb()private method afterhydrateDossier()
/**
* Re-hydrate dossier from gograb-api (non-blocking, fire-and-forget).
* Only updates fields that gograb-api owns (identity, hierarchy).
*/
private async rehydrateFromGograb(userId: string): Promise<void> {
if (!this.apiClient) return;
const results = await Promise.allSettled([
this.apiClient.getUserJourney(userId),
this.apiClient.getHierarchy(),
]);
const updates: Record<string, unknown> = {
'syncMetadata.lastSyncFromGograb': new Date(),
};
if (results[0].status === 'fulfilled' && results[0].value) {
const journey = results[0].value;
if (journey.name) updates['identity.name'] = journey.name;
if (journey.email) updates['identity.email'] = journey.email;
}
if (results[1].status === 'fulfilled' && results[1].value) {
const hierarchy = results[1].value;
const userEntry = Array.isArray(hierarchy)
? hierarchy.find((h: Record<string, unknown>) => h.userId === userId)
: null;
if (userEntry) {
if (userEntry.role) updates['identity.role'] = userEntry.role;
if (userEntry.roleLevel) updates['identity.roleLevel'] = userEntry.roleLevel;
}
}
await this.dossierModel.updateOne({ userId }, { $set: updates });
this.logger.debug(`Re-hydrated dossier for ${userId}`);
}
- Step 4: Add
markFieldDirty()utility for outbound sync tracking
/**
* Mark a dossier field as dirty (modified since last outbound sync).
* Used by learning methods to track what needs syncing back to gograb-api.
*/
async markFieldDirty(userId: string, fieldName: string): Promise<void> {
await this.dossierModel.updateOne(
{ userId },
{ $addToSet: { 'syncMetadata.pendingSyncFields': fieldName } },
);
}
- Step 5: Update
learnFact()andupdateExpertise()to mark fields dirty
In learnFact(), after the second updateOne call (line 213), add:
await this.markFieldDirty(userId, 'personality.knownFacts');
In updateExpertise(), after the second updateOne call (line 255), add:
await this.markFieldDirty(userId, 'expertise.technologies');
- Step 6: Add tests for new methods
Append to APIs/backend-vitto-api/src/app/users/__tests__/user-dossier-enhanced.spec.ts:
import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { UserDossierService } from '../user-dossier.service';
import { UserDossier } from '../schemas/user-dossier.schema';
import { VITTO_CONNECTION_NAMES } from '@libs-api/backend-core';
describe('UserDossierService Enhanced', () => {
let service: UserDossierService;
let mockDossierModel: Record<string, jest.Mock>;
beforeEach(async () => {
mockDossierModel = {
findOne: jest.fn(),
updateOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }),
create: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UserDossierService,
{
provide: getModelToken(UserDossier.name, VITTO_CONNECTION_NAMES.USERS),
useValue: mockDossierModel,
},
],
}).compile();
service = module.get<UserDossierService>(UserDossierService);
});
describe('getMissingCriticalFields', () => {
it('should return first missing field for new user', async () => {
mockDossierModel.findOne.mockReturnValue({
lean: jest.fn().mockResolvedValue({
userId: 'user-1',
identity: { totalInteractions: 1 },
expertise: { technologies: [] },
personality: { communicationStyle: 'conversational' },
workPatterns: { preferredWorkflow: 'collaborative' },
}),
});
const result = await service.getMissingCriticalFields('user-1');
expect(result).not.toBeNull();
expect(result.field).toBe('expertise.technologies');
});
it('should return null after 3 interactions', async () => {
mockDossierModel.findOne.mockReturnValue({
lean: jest.fn().mockResolvedValue({
userId: 'user-1',
identity: { totalInteractions: 5 },
expertise: { technologies: [] },
}),
});
const result = await service.getMissingCriticalFields('user-1');
expect(result).toBeNull();
});
it('should return null when all fields populated', async () => {
mockDossierModel.findOne.mockReturnValue({
lean: jest.fn().mockResolvedValue({
userId: 'user-1',
identity: { totalInteractions: 2, timezone: 'America/New_York' },
expertise: { technologies: [{ name: 'Angular', level: 'expert' }] },
personality: { communicationStyle: 'concise' },
workPatterns: { preferredWorkflow: 'autonomous' },
}),
});
const result = await service.getMissingCriticalFields('user-1');
expect(result).toBeNull();
});
it('should return null for non-existent user', async () => {
mockDossierModel.findOne.mockReturnValue({
lean: jest.fn().mockResolvedValue(null),
});
const result = await service.getMissingCriticalFields('non-existent');
expect(result).toBeNull();
});
});
describe('markFieldDirty', () => {
it('should add field to pendingSyncFields', async () => {
await service.markFieldDirty('user-1', 'personality.knownFacts');
expect(mockDossierModel.updateOne).toHaveBeenCalledWith(
{ userId: 'user-1' },
{ $addToSet: { 'syncMetadata.pendingSyncFields': 'personality.knownFacts' } },
);
});
});
});
- Step 7: Run tests
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="user-dossier-enhanced" --no-coverage
Expected: PASS
- Step 8: Commit
git add APIs/backend-vitto-api/src/app/users/user-dossier.service.ts APIs/backend-vitto-api/src/app/users/__tests__/user-dossier-enhanced.spec.ts
git commit -m "[S210.4] feat: add getMissingCriticalFields, staleness re-hydration, markFieldDirty to UserDossierService"
Task 3: Space Brief Implementation
Files:
Modify:
APIs/backend-vitto-api/src/app/mind/space-intelligence.service.ts:192-195Step 1: Implement
getSpaceBriefForPrompt()(replace the stub at line 192-195)
/**
* Get combined user + space brief for prompt injection (~300 tokens).
* Combines SpaceProfile brief with user's per-space dossier context.
*
* Output format:
* SPACE CONTEXT -- "Space Name":
* Business: Type (industry)
* Modules: N total, M active [names...]
* User role: Role (level N), X interactions
* Recent: last generation info
*/
async getSpaceBriefForPrompt(userId: string, spaceId: string): Promise<string> {
try {
const profile = await this.getOrBuildProfile(spaceId);
if (!profile?.brief) return '';
const parts: string[] = [];
// Space name from profile or spaceId
const spaceName = profile.businessType || spaceId;
parts.push(`SPACE CONTEXT -- "${spaceName}":`);
parts.push(profile.brief);
// Enrich with user-specific context from dossier if available
// (UserDossierService is not injected here -- space-intelligence stays
// independent. The caller (context-assembly) combines both briefs.)
return parts.join('\n');
} catch (err) {
this.logger.debug(`Space brief failed for ${spaceId}: ${(err as Error).message}`);
return '';
}
}
- Step 2: Run existing tests
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="space-intelligence" --no-coverage --passWithNoTests
Expected: PASS (or no tests found)
- Step 3: Commit
git add APIs/backend-vitto-api/src/app/mind/space-intelligence.service.ts
git commit -m "[S210.4] feat: implement getSpaceBriefForPrompt -- space context for LLM prompts"
Chunk 2: ProactiveDispatcher + QualityDropTrigger
Task 4: ProactiveDispatcher Service
Files:
Create:
APIs/backend-vitto-api/src/app/proactive/proactive-dispatcher.service.tsTest:
APIs/backend-vitto-api/src/app/proactive/__tests__/proactive-dispatcher.spec.tsStep 1: Write the test file first
Create APIs/backend-vitto-api/src/app/proactive/__tests__/proactive-dispatcher.spec.ts:
/**
* ProactiveDispatcherService -- Unit Tests
* S210.4: Centralized multi-channel dispatch with safety guards
*/
import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { ProactiveDispatcherService, DispatchRequest } from '../proactive-dispatcher.service';
import { UserNotificationPrefsDoc } from '../../mind/schemas/user-notification-prefs.schema';
const mockNotifPrefsModel = {
findOne: jest.fn(),
};
const mockChannelGateway = {
sendResponse: jest.fn().mockResolvedValue(true),
};
const mockNarrative = {
emitConscience: jest.fn(),
};
const mockDossierService = {
getOrCreateDossier: jest.fn(),
};
describe('ProactiveDispatcherService', () => {
let service: ProactiveDispatcherService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
ProactiveDispatcherService,
{ provide: getModelToken(UserNotificationPrefsDoc.name), useValue: mockNotifPrefsModel },
{ provide: 'ChannelGatewayService', useValue: mockChannelGateway },
{ provide: 'VittoNarrativeService', useValue: mockNarrative },
{ provide: 'UserDossierService', useValue: mockDossierService },
],
}).compile();
service = module.get<ProactiveDispatcherService>(ProactiveDispatcherService);
});
describe('dispatch', () => {
const baseRequest: DispatchRequest = {
userId: 'user-1',
triggerName: 'build-failure',
severity: 'critical',
title: 'Build Failed',
message: 'Module generation failed for Soccer Club Pro',
};
it('should dispatch critical alerts to all active channels', async () => {
mockDossierService.getOrCreateDossier.mockResolvedValue({
proactivePreferences: {
enabled: true,
preferredChannel: 'whatsapp',
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
dailyAlertCap: 5,
disabledTriggers: [],
},
identity: { timezone: 'America/New_York' },
});
mockNotifPrefsModel.findOne.mockReturnValue({
lean: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValue({
userId: 'user-1',
preferredChannel: 'whatsapp',
channelId: '+1234567890',
enabled: true,
}),
}),
});
const result = await service.dispatch(baseRequest);
expect(result.dispatched).toBe(true);
// Critical: sends to preferred channel + web
expect(mockChannelGateway.sendResponse).toHaveBeenCalled();
expect(mockNarrative.emitConscience).toHaveBeenCalled();
});
it('should respect quiet hours', async () => {
mockDossierService.getOrCreateDossier.mockResolvedValue({
proactivePreferences: {
enabled: true,
preferredChannel: 'whatsapp',
quietHoursStart: '00:00',
quietHoursEnd: '23:59',
dailyAlertCap: 5,
disabledTriggers: [],
},
identity: { timezone: 'UTC' },
});
const result = await service.dispatch({ ...baseRequest, severity: 'warning' });
expect(result.dispatched).toBe(false);
expect(result.reason).toContain('quiet hours');
});
it('should respect disabled triggers', async () => {
mockDossierService.getOrCreateDossier.mockResolvedValue({
proactivePreferences: {
enabled: true,
preferredChannel: 'web',
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
dailyAlertCap: 5,
disabledTriggers: ['build-failure'],
},
});
const result = await service.dispatch(baseRequest);
expect(result.dispatched).toBe(false);
expect(result.reason).toContain('opted out');
});
it('should respect master toggle', async () => {
mockDossierService.getOrCreateDossier.mockResolvedValue({
proactivePreferences: { enabled: false },
});
const result = await service.dispatch(baseRequest);
expect(result.dispatched).toBe(false);
expect(result.reason).toContain('disabled');
});
it('should fall back to web when preferred channel has no channelId', async () => {
mockDossierService.getOrCreateDossier.mockResolvedValue({
proactivePreferences: {
enabled: true,
preferredChannel: 'whatsapp',
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
dailyAlertCap: 5,
disabledTriggers: [],
},
});
mockNotifPrefsModel.findOne.mockReturnValue({
lean: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValue(null),
}),
});
const result = await service.dispatch(baseRequest);
expect(result.dispatched).toBe(true);
expect(result.channels).toContain('web');
expect(mockNarrative.emitConscience).toHaveBeenCalled();
});
it('should enforce frequency cap', async () => {
mockDossierService.getOrCreateDossier.mockResolvedValue({
proactivePreferences: {
enabled: true,
preferredChannel: 'web',
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
dailyAlertCap: 1,
disabledTriggers: [],
},
});
mockNotifPrefsModel.findOne.mockReturnValue({
lean: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValue(null),
}),
});
const result1 = await service.dispatch({ ...baseRequest, severity: 'warning' });
expect(result1.dispatched).toBe(true);
const result2 = await service.dispatch({ ...baseRequest, severity: 'warning', triggerName: 'other' });
expect(result2.dispatched).toBe(false);
expect(result2.reason).toContain('frequency cap');
});
it('should deduplicate same trigger+context within 1 hour', async () => {
mockDossierService.getOrCreateDossier.mockResolvedValue({
proactivePreferences: {
enabled: true,
preferredChannel: 'web',
quietHoursStart: '22:00',
quietHoursEnd: '08:00',
dailyAlertCap: 10,
disabledTriggers: [],
},
});
mockNotifPrefsModel.findOne.mockReturnValue({
lean: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValue(null),
}),
});
const result1 = await service.dispatch({ ...baseRequest, severity: 'info' });
expect(result1.dispatched).toBe(true);
const result2 = await service.dispatch({ ...baseRequest, severity: 'info' });
expect(result2.dispatched).toBe(false);
expect(result2.reason).toContain('deduplicated');
});
it('should always dispatch critical alerts even during quiet hours', async () => {
mockDossierService.getOrCreateDossier.mockResolvedValue({
proactivePreferences: {
enabled: true,
preferredChannel: 'web',
quietHoursStart: '00:00',
quietHoursEnd: '23:59',
dailyAlertCap: 5,
disabledTriggers: [],
},
identity: { timezone: 'UTC' },
});
mockNotifPrefsModel.findOne.mockReturnValue({
lean: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValue(null),
}),
});
const result = await service.dispatch({ ...baseRequest, severity: 'critical' });
expect(result.dispatched).toBe(true);
});
});
});
- Step 2: Run tests to verify they fail
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="proactive-dispatcher" --no-coverage
Expected: FAIL (module not found)
- Step 3: Create the ProactiveDispatcherService
Create APIs/backend-vitto-api/src/app/proactive/proactive-dispatcher.service.ts:
/**
* Proactive Dispatcher Service -- Centralized multi-channel notification dispatch
* S210.4: The single entry point for ALL proactive notifications.
*
* Responsibilities:
* 1. Resolve user's preferred channel from dossier + UserNotificationPrefs
* 2. Apply safety guards: quiet hours, frequency cap, dedup, opt-out, master toggle
* 3. Fan out by severity: critical -> all channels, warning -> preferred, info -> web
* 4. Fall back to web (SSE toast) when preferred channel has no channelId
* 5. Log dispatch outcomes for learning + debugging
*/
import { Injectable, Logger, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { UserNotificationPrefsDoc } from '../mind/schemas/user-notification-prefs.schema';
import { ChannelGatewayService } from '../connectivity/channel-gateway.service';
import { VittoNarrativeService } from '../engine/vitto-narrative.service';
import { UserDossierService } from '../users/user-dossier.service';
// --- Types ---
export interface DispatchRequest {
userId: string;
triggerName: string;
severity: 'info' | 'warning' | 'critical';
title: string;
message: string;
metadata?: Record<string, unknown>;
/** Override: skip safety guards (for escalations) */
bypassGuards?: boolean;
}
export interface DispatchResult {
dispatched: boolean;
channels: string[];
reason?: string;
}
// --- Service ---
@Injectable()
export class ProactiveDispatcherService {
private readonly logger = new Logger('ProactiveDispatcher');
/**
* In-memory frequency tracking: userId -> count of dispatches today.
* Reset daily. Acceptable to lose on restart (safety, not correctness).
*/
private readonly dailyCounts = new Map<string, { count: number; date: string }>();
/**
* Dedup cache: "userId:triggerName:contextHash" -> timestamp.
* Suppress same trigger+context for 1 hour.
*/
private readonly dedupCache = new Map<string, number>();
constructor(
@InjectModel(UserNotificationPrefsDoc.name)
private readonly notifPrefsModel: Model<UserNotificationPrefsDoc>,
@Optional() private readonly channelGateway?: ChannelGatewayService,
@Optional() private readonly narrative?: VittoNarrativeService,
@Optional() private readonly dossierService?: UserDossierService,
) {}
/**
* Dispatch a proactive notification to a user.
* Applies all safety guards, resolves channels, delivers message.
*/
async dispatch(request: DispatchRequest): Promise<DispatchResult> {
const { userId, triggerName, severity, title, message, metadata, bypassGuards } = request;
// 1. Load dossier for proactive preferences
const dossier = await this.dossierService?.getOrCreateDossier(userId).catch(() => null);
const prefs = dossier?.proactivePreferences;
// 2. Safety guard: master toggle
if (prefs && !prefs.enabled && !bypassGuards) {
return { dispatched: false, channels: [], reason: 'Proactive notifications disabled by user' };
}
// 3. Safety guard: per-trigger opt-out
if (prefs?.disabledTriggers?.includes(triggerName) && !bypassGuards) {
return { dispatched: false, channels: [], reason: `User opted out of trigger: ${triggerName}` };
}
// 4. Safety guard: quiet hours (critical bypasses)
if (!bypassGuards && severity !== 'critical' && prefs && this.isInQuietHours(prefs, dossier?.identity?.timezone)) {
return { dispatched: false, channels: [], reason: 'Suppressed during quiet hours' };
}
// 5. Safety guard: frequency cap (critical bypasses)
if (!bypassGuards && severity !== 'critical' && prefs) {
const today = new Date().toISOString().slice(0, 10);
const entry = this.dailyCounts.get(userId);
const count = (entry?.date === today) ? entry.count : 0;
if (count >= (prefs.dailyAlertCap || 5)) {
return { dispatched: false, channels: [], reason: `Daily frequency cap reached (${count}/${prefs.dailyAlertCap || 5})` };
}
this.dailyCounts.set(userId, { count: count + 1, date: today });
}
// 6. Safety guard: deduplication (1 hour window)
if (!bypassGuards) {
const contextHash = `${title}:${message.substring(0, 50)}`;
const dedupKey = `${userId}:${triggerName}:${contextHash}`;
const lastSent = this.dedupCache.get(dedupKey);
if (lastSent && Date.now() - lastSent < 60 * 60 * 1000) {
return { dispatched: false, channels: [], reason: 'Suppressed: deduplicated (same alert within 1h)' };
}
this.dedupCache.set(dedupKey, Date.now());
}
// 7. Resolve channels
const channels = await this.resolveChannels(userId, severity, prefs?.preferredChannel);
// 8. Format and deliver
const formattedText = this.formatMessage(severity, title, message);
const deliveredChannels: string[] = [];
for (const channel of channels) {
if (channel.type === 'web') {
// Web delivery via SSE (conscience event -> ProactiveToastComponent)
this.emitWebToast(userId, severity, title, message, triggerName, metadata);
deliveredChannels.push('web');
} else if (this.channelGateway && channel.id) {
// External channel delivery
try {
await this.channelGateway.sendResponse({
channelType: channel.type as any,
channelId: channel.id,
text: formattedText,
metadata: {
triggerName,
severity,
...metadata,
},
});
deliveredChannels.push(channel.type);
} catch (err) {
this.logger.debug(`Dispatch to ${channel.type} failed for ${userId}: ${(err as Error).message}`);
}
}
}
if (deliveredChannels.length === 0) {
// Last resort: always emit web toast
this.emitWebToast(userId, severity, title, message, triggerName, metadata);
deliveredChannels.push('web');
}
this.logger.log(`Dispatched [${severity}] "${title}" to ${userId} via ${deliveredChannels.join(', ')}`);
return { dispatched: true, channels: deliveredChannels };
}
// --- Channel Resolution ---
private async resolveChannels(
userId: string,
severity: string,
preferredChannel?: string,
): Promise<Array<{ type: string; id?: string }>> {
const channels: Array<{ type: string; id?: string }> = [];
const notifPrefs = await this.notifPrefsModel
.findOne({ userId })
.lean()
.exec()
.catch(() => null) as UserNotificationPrefsDoc | null;
const resolvedChannel = preferredChannel || notifPrefs?.preferredChannel || 'web';
const channelId = notifPrefs?.channelId;
switch (severity) {
case 'critical':
if (resolvedChannel !== 'web' && channelId) {
channels.push({ type: resolvedChannel, id: channelId });
}
channels.push({ type: 'web' });
break;
case 'warning':
if (resolvedChannel !== 'web' && channelId) {
channels.push({ type: resolvedChannel, id: channelId });
} else {
channels.push({ type: 'web' });
}
break;
case 'info':
default:
channels.push({ type: 'web' });
break;
}
return channels;
}
// --- Safety Guards ---
private isInQuietHours(
prefs: { quietHoursStart?: string; quietHoursEnd?: string },
timezone?: string,
): boolean {
if (!prefs.quietHoursStart || !prefs.quietHoursEnd) return false;
try {
const now = new Date();
const userTime = timezone
? new Date(now.toLocaleString('en-US', { timeZone: timezone }))
: now;
const currentMinutes = userTime.getHours() * 60 + userTime.getMinutes();
const [startH, startM] = prefs.quietHoursStart.split(':').map(Number);
const [endH, endM] = prefs.quietHoursEnd.split(':').map(Number);
const startMinutes = startH * 60 + startM;
const endMinutes = endH * 60 + endM;
if (startMinutes <= endMinutes) {
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
} else {
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
}
} catch {
return false;
}
}
// --- Formatting ---
private formatMessage(severity: string, title: string, message: string): string {
const emoji = severity === 'critical' ? '\u{1F6A8}' : severity === 'warning' ? '\u{26A0}\u{FE0F}' : '\u{2139}\u{FE0F}';
return [
`${emoji} **${title}**`,
message,
].join('\n');
}
// --- Web Toast ---
private emitWebToast(
userId: string,
severity: string,
title: string,
message: string,
triggerName: string,
metadata?: Record<string, unknown>,
): void {
if (!this.narrative) return;
this.narrative.emitConscience(
'conscience.proactive.alert' as any,
userId,
'context' as any,
`[${severity.toUpperCase()}] ${title}: ${message}`,
{ triggerName, severity, ...metadata },
);
}
// --- Housekeeping ---
/**
* Prune stale dedup cache entries (older than 2 hours).
* Called internally -- no cron needed, runs on next dispatch.
*/
pruneStaleEntries(): void {
const cutoff = Date.now() - 2 * 60 * 60 * 1000;
for (const [key, timestamp] of this.dedupCache.entries()) {
if (timestamp < cutoff) {
this.dedupCache.delete(key);
}
}
}
}
- Step 4: Run tests
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="proactive-dispatcher" --no-coverage
Expected: PASS (all 7 tests)
- Step 5: Commit
git add APIs/backend-vitto-api/src/app/proactive/proactive-dispatcher.service.ts APIs/backend-vitto-api/src/app/proactive/__tests__/proactive-dispatcher.spec.ts
git commit -m "[S210.4] feat: create ProactiveDispatcherService -- centralized multi-channel dispatch with safety guards"
Task 5: QualityDropTrigger
Files:
Create:
APIs/backend-vitto-api/src/app/proactive/triggers/quality-drop.trigger.tsTest:
APIs/backend-vitto-api/src/app/proactive/__tests__/quality-drop-trigger.spec.tsStep 1: Create the triggers directory
Run: mkdir -p APIs/backend-vitto-api/src/app/proactive/triggers
- Step 2: Write the test file first
Create APIs/backend-vitto-api/src/app/proactive/__tests__/quality-drop-trigger.spec.ts:
/**
* QualityDropTrigger -- Unit Tests
* S210.4: Detects execution success rate regression
*/
import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { QualityDropTrigger } from '../triggers/quality-drop.trigger';
import { ExecutionTrace } from '../../mind/schemas/execution-trace.schema';
const mockTraceModel = {
countDocuments: jest.fn(),
};
const mockDispatcher = {
dispatch: jest.fn().mockResolvedValue({ dispatched: true, channels: ['web'] }),
};
describe('QualityDropTrigger', () => {
let trigger: QualityDropTrigger;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
QualityDropTrigger,
{ provide: getModelToken(ExecutionTrace.name), useValue: mockTraceModel },
{ provide: 'ProactiveDispatcherService', useValue: mockDispatcher },
],
}).compile();
trigger = module.get<QualityDropTrigger>(QualityDropTrigger);
});
describe('checkQualityDrop', () => {
it('should not alert when success rate is stable', async () => {
// 24h: 8/10 = 80%, 7d: 70/90 = 77.7% -- no drop
mockTraceModel.countDocuments
.mockResolvedValueOnce(10) // 24h total
.mockResolvedValueOnce(8) // 24h success
.mockResolvedValueOnce(90) // 7d total
.mockResolvedValueOnce(70); // 7d success
await trigger.checkQualityDrop();
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
});
it('should alert when 24h success rate drops >15% vs 7-day avg', async () => {
// 24h: 5/10 = 50%, 7d: 80/100 = 80% -- 30% drop
mockTraceModel.countDocuments
.mockResolvedValueOnce(10) // 24h total
.mockResolvedValueOnce(5) // 24h success
.mockResolvedValueOnce(100) // 7d total
.mockResolvedValueOnce(80); // 7d success
await trigger.checkQualityDrop();
expect(mockDispatcher.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
triggerName: 'quality-drop',
severity: 'warning',
}),
);
});
it('should not alert when too few tasks in 24h', async () => {
// Only 2 tasks -- not enough data
mockTraceModel.countDocuments
.mockResolvedValueOnce(2) // 24h total
.mockResolvedValueOnce(0) // 24h success
.mockResolvedValueOnce(50) // 7d total
.mockResolvedValueOnce(40); // 7d success
await trigger.checkQualityDrop();
expect(mockDispatcher.dispatch).not.toHaveBeenCalled();
});
});
});
- Step 3: Create the QualityDropTrigger
Create APIs/backend-vitto-api/src/app/proactive/triggers/quality-drop.trigger.ts:
/**
* Quality Drop Trigger -- Detects execution success rate regression
* S210.4: Hourly cron, queries execution_traces
*
* Logic:
* 1. Calculate 24h success rate: completed / (completed + failed)
* 2. Compare to 7-day rolling average
* 3. If drop > 15%: fire warning alert with affected task types
*/
import { Injectable, Logger, Optional } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { ExecutionTrace } from '../../mind/schemas/execution-trace.schema';
import { ProactiveDispatcherService } from '../proactive-dispatcher.service';
/** Minimum tasks in 24h to avoid false positives on low volume */
const MIN_TASKS_THRESHOLD = 5;
/** Alert when 24h success rate drops this much vs 7-day average */
const DROP_THRESHOLD_PERCENT = 15;
@Injectable()
export class QualityDropTrigger {
private readonly logger = new Logger('QualityDropTrigger');
constructor(
@InjectModel(ExecutionTrace.name) private readonly traceModel: Model<ExecutionTrace>,
@Optional() private readonly dispatcher?: ProactiveDispatcherService,
) {}
/**
* Hourly check: compare 24h success rate vs 7-day rolling average.
* Fires warning alert via dispatcher if drop exceeds threshold.
*/
@Cron('0 * * * *')
async checkQualityDrop(): Promise<void> {
try {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const [total24h, success24h] = await Promise.all([
this.traceModel.countDocuments({
createdAt: { $gte: oneDayAgo },
outcome: { $in: ['success', 'failed'] },
}),
this.traceModel.countDocuments({
createdAt: { $gte: oneDayAgo },
outcome: 'success',
}),
]);
if (total24h < MIN_TASKS_THRESHOLD) {
this.logger.debug(`Quality check skipped: only ${total24h} tasks in 24h (threshold: ${MIN_TASKS_THRESHOLD})`);
return;
}
const [total7d, success7d] = await Promise.all([
this.traceModel.countDocuments({
createdAt: { $gte: sevenDaysAgo },
outcome: { $in: ['success', 'failed'] },
}),
this.traceModel.countDocuments({
createdAt: { $gte: sevenDaysAgo },
outcome: 'success',
}),
]);
if (total7d === 0) return;
const rate24h = (success24h / total24h) * 100;
const rate7d = (success7d / total7d) * 100;
const drop = rate7d - rate24h;
if (drop > DROP_THRESHOLD_PERCENT && this.dispatcher) {
this.logger.warn(
`Quality drop detected: 24h=${rate24h.toFixed(1)}% vs 7d=${rate7d.toFixed(1)}% (drop: ${drop.toFixed(1)}%)`,
);
await this.dispatcher.dispatch({
userId: 'system',
triggerName: 'quality-drop',
severity: 'warning',
title: 'Task Quality Regression Detected',
message: `24h success rate: ${rate24h.toFixed(0)}% (was ${rate7d.toFixed(0)}% over 7 days). ${total24h - success24h} failures in last 24h out of ${total24h} tasks.`,
metadata: { rate24h, rate7d, drop, total24h, success24h },
});
} else {
this.logger.debug(`Quality OK: 24h=${rate24h.toFixed(1)}% vs 7d=${rate7d.toFixed(1)}%`);
}
} catch (err) {
this.logger.warn(`Quality check failed: ${(err as Error).message}`);
}
}
}
- Step 4: Run tests
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="quality-drop-trigger" --no-coverage
Expected: PASS
- Step 5: Commit
git add APIs/backend-vitto-api/src/app/proactive/triggers/quality-drop.trigger.ts APIs/backend-vitto-api/src/app/proactive/__tests__/quality-drop-trigger.spec.ts
git commit -m "[S210.4] feat: create QualityDropTrigger -- hourly execution success rate regression detection"
Chunk 3: DossierSync + Wiring
Task 6: DossierSyncService
Files:
Create:
APIs/backend-vitto-api/src/app/users/dossier-sync.service.tsModify:
APIs/backend-vitto-api/src/app/connectivity/backend-api-client.service.tsModify:
APIs/backend-vitto-api/src/app/users/users.module.tsTest:
APIs/backend-vitto-api/src/app/users/__tests__/dossier-sync.spec.tsStep 1: Add
pushProfileEnrichment()to BackendApiClientService
In APIs/backend-vitto-api/src/app/connectivity/backend-api-client.service.ts, add after checkHealth() (line 285):
// -- Session 210.4: Dossier Sync -- Outbound enrichment push --
/**
* Push enrichment data from Vitto dossier back to gograb-api.
* Platform (gograb-api) owns conflicting fields -- this only sends
* enrichments that Vitto uniquely knows (facts, expertise, style, channel).
*/
async pushProfileEnrichment(
userId: string,
enrichment: {
knownFacts?: string[];
technologies?: string[];
communicationStyle?: string;
preferredChannel?: string;
},
): Promise<boolean> {
try {
await firstValueFrom(
this.http.put(
`${this.baseUrl}/users/${userId}/profile-enrichment`,
enrichment,
{ headers: this.headers, timeout: 5000 },
),
);
return true;
} catch (err) {
this.logger.debug(`Profile enrichment push failed for ${userId}: ${(err as Error).message}`);
return false;
}
}
- Step 2: Write the test file
Create APIs/backend-vitto-api/src/app/users/__tests__/dossier-sync.spec.ts:
/**
* DossierSyncService -- Unit Tests
* S210.4: Bidirectional sync with gograb-api
*/
import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { DossierSyncService } from '../dossier-sync.service';
import { UserDossier } from '../schemas/user-dossier.schema';
import { VITTO_CONNECTION_NAMES } from '@libs-api/backend-core';
const mockDossierModel = {
find: jest.fn(),
updateOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }),
};
const mockApiClient = {
pushProfileEnrichment: jest.fn().mockResolvedValue(true),
};
describe('DossierSyncService', () => {
let service: DossierSyncService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
DossierSyncService,
{
provide: getModelToken(UserDossier.name, VITTO_CONNECTION_NAMES.USERS),
useValue: mockDossierModel,
},
{ provide: 'BackendApiClientService', useValue: mockApiClient },
],
}).compile();
service = module.get<DossierSyncService>(DossierSyncService);
});
describe('syncDirtyDossiers', () => {
it('should sync dossiers with pending fields', async () => {
mockDossierModel.find.mockReturnValue({
limit: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([
{
userId: 'user-1',
personality: {
knownFacts: [{ fact: 'likes Angular' }],
communicationStyle: 'concise',
},
expertise: {
technologies: [{ name: 'Angular', level: 'expert' }],
},
proactivePreferences: { preferredChannel: 'whatsapp' },
syncMetadata: {
pendingSyncFields: ['personality.knownFacts', 'expertise.technologies'],
},
},
]),
}),
});
await service.syncDirtyDossiers();
expect(mockApiClient.pushProfileEnrichment).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({
knownFacts: ['likes Angular'],
technologies: ['Angular'],
}),
);
expect(mockDossierModel.updateOne).toHaveBeenCalledWith(
{ userId: 'user-1' },
{
$set: {
'syncMetadata.lastSyncToGograb': expect.any(Date),
'syncMetadata.pendingSyncFields': [],
},
},
);
});
it('should skip when no dirty dossiers exist', async () => {
mockDossierModel.find.mockReturnValue({
limit: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([]),
}),
});
await service.syncDirtyDossiers();
expect(mockApiClient.pushProfileEnrichment).not.toHaveBeenCalled();
});
it('should not clear pendingSyncFields on failed push', async () => {
mockDossierModel.find.mockReturnValue({
limit: jest.fn().mockReturnValue({
lean: jest.fn().mockResolvedValue([
{
userId: 'user-fail',
personality: { knownFacts: [], communicationStyle: 'conversational' },
expertise: { technologies: [] },
proactivePreferences: { preferredChannel: 'web' },
syncMetadata: { pendingSyncFields: ['personality.knownFacts'] },
},
]),
}),
});
mockApiClient.pushProfileEnrichment.mockResolvedValueOnce(false);
await service.syncDirtyDossiers();
expect(mockDossierModel.updateOne).not.toHaveBeenCalled();
});
});
});
- Step 3: Create the DossierSyncService
Create APIs/backend-vitto-api/src/app/users/dossier-sync.service.ts:
/**
* Dossier Sync Service -- Bidirectional sync with gograb-api
* S210.4: 15-minute cron batch-syncs dirty dossiers outbound.
*
* Outbound (Vitto -> gograb-api): enrichments only
* - personality.knownFacts -> string[] of fact texts
* - expertise.technologies -> string[] of tech names
* - personality.communicationStyle -> string
* - proactivePreferences.preferredChannel -> string
*
* Inbound (gograb-api -> Vitto): handled by UserDossierService.rehydrateFromGograb()
* via staleness check (>24h since lastSyncFromGograb).
*
* Conflict resolution: Platform (gograb-api) wins on conflicts.
* This service only pushes data that Vitto uniquely observes.
*/
import { Injectable, Logger, Optional } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Cron } from '@nestjs/schedule';
import { VITTO_CONNECTION_NAMES } from '@libs-api/backend-core';
import { UserDossier, KnownFact, TechnologyExpertise } from './schemas/user-dossier.schema';
import { BackendApiClientService } from '../connectivity/backend-api-client.service';
/** Max dossiers to sync per cron run (prevent thundering herd) */
const BATCH_LIMIT = 50;
@Injectable()
export class DossierSyncService {
private readonly logger = new Logger('DossierSync');
constructor(
@InjectModel(UserDossier.name, VITTO_CONNECTION_NAMES.USERS)
private readonly dossierModel: Model<UserDossier>,
@Optional() private readonly apiClient?: BackendApiClientService,
) {}
/**
* Every 15 minutes: find dossiers with pending sync fields and push
* enrichments to gograb-api. Limit to BATCH_LIMIT per run.
*/
@Cron('*/15 * * * *')
async syncDirtyDossiers(): Promise<void> {
if (!this.apiClient) return;
try {
const dirtyDossiers = await this.dossierModel
.find({
'syncMetadata.pendingSyncFields.0': { $exists: true },
})
.limit(BATCH_LIMIT)
.lean();
if (dirtyDossiers.length === 0) return;
let synced = 0;
for (const dossier of dirtyDossiers) {
const enrichment = this.buildEnrichmentPayload(dossier as UserDossier);
const success = await this.apiClient.pushProfileEnrichment(dossier.userId, enrichment);
if (success) {
await this.dossierModel.updateOne(
{ userId: dossier.userId },
{
$set: {
'syncMetadata.lastSyncToGograb': new Date(),
'syncMetadata.pendingSyncFields': [],
},
},
);
synced++;
}
}
if (synced > 0) {
this.logger.log(`Synced ${synced}/${dirtyDossiers.length} dossiers to gograb-api`);
}
} catch (err) {
this.logger.warn(`Dossier sync failed: ${(err as Error).message}`);
}
}
/**
* Build the outbound enrichment payload from a dossier.
* Only sends fields that Vitto uniquely observes.
*/
private buildEnrichmentPayload(dossier: UserDossier): {
knownFacts?: string[];
technologies?: string[];
communicationStyle?: string;
preferredChannel?: string;
} {
const payload: Record<string, unknown> = {};
const knownFacts = dossier.personality?.knownFacts;
if (knownFacts?.length > 0) {
payload.knownFacts = knownFacts.map((f: KnownFact) => f.fact);
}
const techs = dossier.expertise?.technologies;
if (techs?.length > 0) {
payload.technologies = techs.map((t: TechnologyExpertise) => t.name);
}
const style = dossier.personality?.communicationStyle;
if (style && style !== 'conversational') {
payload.communicationStyle = style;
}
const channel = dossier.proactivePreferences?.preferredChannel;
if (channel && channel !== 'web') {
payload.preferredChannel = channel;
}
return payload;
}
}
- Step 4: Register DossierSyncService in UsersModule
In APIs/backend-vitto-api/src/app/users/users.module.ts, add import and registration:
Add import line:
import { DossierSyncService } from './dossier-sync.service';
Update providers array:
providers: [UserDossierService, JobMasteryService, DossierSyncService],
Update exports array:
exports: [UserDossierService, JobMasteryService, DossierSyncService],
- Step 5: Run tests
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="dossier-sync" --no-coverage
Expected: PASS
- Step 6: Commit
git add APIs/backend-vitto-api/src/app/users/dossier-sync.service.ts APIs/backend-vitto-api/src/app/users/__tests__/dossier-sync.spec.ts APIs/backend-vitto-api/src/app/users/users.module.ts APIs/backend-vitto-api/src/app/connectivity/backend-api-client.service.ts
git commit -m "[S210.4] feat: create DossierSyncService -- 15-min outbound enrichment sync to gograb-api"
Task 7: Context Assembly Wiring
Files:
Modify:
APIs/backend-vitto-api/src/app/engine/context-assembly.service.tsStep 1: Add SpaceIntelligenceService to the constructor (if not already injected)
Check if SpaceIntelligenceService is already injected. If not, add:
@Optional() private readonly spaceIntelligence?: SpaceIntelligenceService,
And add the import:
import { SpaceIntelligenceService } from '../mind/space-intelligence.service';
- Step 2: Add UserDossierService injection (if not already present)
Check if UserDossierService is already injected. If not, add:
@Optional() private readonly dossierService?: UserDossierService,
And add the import:
import { UserDossierService } from '../users/user-dossier.service';
- Step 3: Add space brief to the Promise.all block (line 481)
Add a 7th entry to the Promise.all array:
// 7. S210.4: Space brief for contextual prompts
spaceId && this.spaceIntelligence
? this.spaceIntelligence.getSpaceBriefForPrompt(userId, spaceId).catch(() => '')
: Promise.resolve(''),
Update the destructured result to include spaceBrief:
const [userContextResult, recentHistory, semanticHistory, learningIntel, awarenessEvents, skillsPrompt, spaceBrief] = await Promise.all([
- Step 4: Inject space brief into the prompt
After the skills section in the prompt assembly (find where skills are appended), add:
// S210.4: Space context -- what's in the user's workspace
if (spaceBrief) {
sections.push(spaceBrief);
}
If sections are built differently (as string concatenation rather than array), adapt accordingly -- append spaceBrief where other context sections are joined.
- Step 5: Add dialectic enrichment hint injection
After the space brief injection, add:
// S210.4: Dialectic enrichment -- ask one missing field on first interactions
if (this.dossierService) {
const missingField = await this.dossierService.getMissingCriticalFields(userId).catch(() => null);
if (missingField) {
sections.push(
`\nDIALECTIC HINT: If natural, ask the user: "${missingField.question}" (field: ${missingField.field}). ` +
`Only ask if it fits the flow of conversation -- never force it.`,
);
}
}
- Step 6: Run existing context-assembly tests
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="context-assembly" --no-coverage --passWithNoTests
Expected: PASS
- Step 7: Commit
git add APIs/backend-vitto-api/src/app/engine/context-assembly.service.ts
git commit -m "[S210.4] feat: wire space brief + dialectic enrichment into context assembly"
Task 8: Module Registration + Trigger Wiring
Files:
Modify:
APIs/backend-vitto-api/src/app/proactive/proactive.module.tsModify:
APIs/backend-vitto-api/src/app/proactive/proactive-monitor.service.tsModify:
APIs/backend-vitto-api/src/app/proactive/heartbeat.service.tsStep 1: Register ProactiveDispatcherService and QualityDropTrigger in ProactiveModule
In APIs/backend-vitto-api/src/app/proactive/proactive.module.ts:
Add imports:
import { ProactiveDispatcherService } from './proactive-dispatcher.service';
import { QualityDropTrigger } from './triggers/quality-drop.trigger';
import { UsersModule } from '../users/users.module';
import { ExecutionTrace, ExecutionTraceSchema } from '../mind/schemas/execution-trace.schema';
Add UsersModule to imports array (for UserDossierService access).
Add ExecutionTrace to MongooseModule.forFeature (for QualityDropTrigger). Important: Check if ExecutionTrace is already registered in MindModule. If it is, the MindModule import already makes it available and you should NOT re-register it. In that case the QualityDropTrigger should use the model from MindModule.
Add ProactiveDispatcherService and QualityDropTrigger to providers.
Add ProactiveDispatcherService to exports.
- Step 2: Wire ProactiveMonitorService to use dispatcher
In APIs/backend-vitto-api/src/app/proactive/proactive-monitor.service.ts:
Add import:
import { ProactiveDispatcherService } from './proactive-dispatcher.service';
Add to constructor:
@Optional() private readonly dispatcher?: ProactiveDispatcherService,
Update notifyViaChannel() to route through dispatcher when available. Keep the existing code as legacy fallback (when dispatcher is not injected). Prepend the dispatcher path:
// S210.4: Route through centralized dispatcher if available
if (this.dispatcher) {
const prefs = await this.notifPrefsModel.find({ enabled: true }).lean().exec();
for (const pref of prefs) {
await this.dispatcher.dispatch({
userId: pref.userId,
triggerName: check.name,
severity: check.severity,
title: `Proactive Alert: ${check.name}`,
message: result.details.substring(0, 300),
metadata: { alertId, suggestedAction: result.suggestedAction },
}).catch(() => {});
}
return;
}
// Legacy fallback: direct channel notification (pre-S210.4)
// ... existing code unchanged ...
- Step 3: Wire HeartbeatService to use dispatcher for action-needed items
In APIs/backend-vitto-api/src/app/proactive/heartbeat.service.ts:
Add import:
import { ProactiveDispatcherService } from './proactive-dispatcher.service';
Add to constructor (after narrative):
@Optional() private readonly dispatcher?: ProactiveDispatcherService,
In pulse(), after the narrative emission loop (inside the for (const item of actionsNeeded) block, after the narrative emit), add:
// S210.4: Route through centralized dispatcher
if (this.dispatcher) {
this.dispatcher.dispatch({
userId: 'system',
triggerName: `heartbeat-${item.name}`,
severity: item.name === 'system_health' ? 'warning' : 'info',
title: `Heartbeat: ${item.name}`,
message: item.details || 'Action needed',
metadata: { checkName: item.name, actionTaken: item.actionTaken },
}).catch(() => {});
}
- Step 4: Run existing proactive module tests
Run: cd APIs/backend-vitto-api && npx jest --testPathPattern="proactive" --no-coverage
Expected: PASS (may need mock updates for new constructor parameters)
If tests fail due to missing mock for ProactiveDispatcherService, add to each failing test's providers:
{ provide: ProactiveDispatcherService, useValue: { dispatch: jest.fn().mockResolvedValue({ dispatched: true }) } },
- Step 5: Commit
git add APIs/backend-vitto-api/src/app/proactive/proactive.module.ts APIs/backend-vitto-api/src/app/proactive/proactive-monitor.service.ts APIs/backend-vitto-api/src/app/proactive/heartbeat.service.ts
git commit -m "[S210.4] feat: register dispatcher + trigger, wire existing triggers through centralized dispatch"
Summary
After completing all 8 tasks, the system enables:
Dossier-Driven Notifications -- User dossiers store proactive preferences (quiet hours, daily cap, disabled triggers, preferred channel). The dispatcher reads these before every send.
Centralized Multi-Channel Dispatch -- All triggers (health checks, heartbeat, quality drops, milestones, reach) route through
ProactiveDispatcherServicewith unified safety guards.Quality Regression Detection -- Hourly cron compares 24h vs 7-day success rate, auto-alerts on >15% drop.
Bidirectional Dossier Sync -- 15-min cron pushes enrichments to gograb-api; staleness check re-hydrates inbound on >24h gap.
Space Context in Prompts --
getSpaceBriefForPrompt()provides rich space context (~300 tokens) in the LLM system prompt.Dialectic Enrichment -- First 3 interactions naturally ask for missing critical fields (technologies, communication style, timezone, workflow).
What this unlocks: The WhatsApp-Vitto-WhatsApp Jarvis loop. When a platform build completes, VittoEngineService fires conscience.task.completed then triggers route through dispatcher then dispatcher reads dossier (preferredChannel: 'whatsapp') then resolves channelId from UserNotificationPrefs then delivers via ChannelGatewayService.sendResponse() then user gets WhatsApp message. User replies on WhatsApp then ChannelGatewayService.handleTwilioWhatsAppInbound() then ChannelProcessingService then agent loop then response back via WhatsApp.