Last updated: 2026-06-29 Analyst: Codebase audit (full source + spec review)
Phase Status
| Phase | What | Status |
|---|---|---|
| Phase 1 | Schema — 5 new tables + 11 new columns | ✅ COMPLETE — migrations 0001–0005 applied, all tables live |
| Phase 2 | Wrangler bindings — CONSOLIDATION_QUEUE + weekly cron |
✅ COMPLETE — queue active, Sunday 2am UTC cron |
| Phase 3 | Lib files — extraction, entity-graph, consolidation, relationship | ✅ COMPLETE |
| Phase 4 | Routes — L1/L2 context, session-close ingest, /correct, queue consumer |
✅ COMPLETE |
| Phase 5 | CE integration — update orchestrator to call GET /context/:id instead of /mood |
❌ NOT STARTED — CE still calls /mood stub |
| Phase 6 | Dashboard — contact list, memory viewer, correction UI | ❌ NOT STARTED |
Scorecard
| # | Parameter | Score | Notes |
|---|---|---|---|
| 1 | Multi-tenancy | 10.0 | Every query scoped by tenant_id; no cross-tenant data leakage possible |
| 2 | Data Model | 9.5 | 12 well-designed tables, correct indexes, CASCADE deletes, pgvector for embeddings, 1:1 state table |
| 3 | GDPR Compliance | 9.5 | Soft-delete + hard-delete of memories; data export endpoint; outreach_disabled flag honoured globally |
| 4 | Performance | 9.0 | Context hot path uses 5 parallel queries (< 30ms target); mood-only path < 5ms; async interaction logs |
| 5 | Security | 8.5 | PBAC with contacts: namespace; gateway key header auth; no SQL injection surface |
| 6 | API Design | 8.5 | Clean RESTful routes; consistent response shapes; proper HTTP status codes; paginated lists |
| 7 | Business Logic | 8.5 | Mood classifier with keyword fallback; 6-stage relationship state machine; churn risk formula; outreach scanner with pre-delivery checks |
| 8 | Integration | 8.0 | Clean context/ingest contract with Chat Engine; LLM multi-provider (Anthropic → Gemini → OpenAI) |
| 9 | Testing | 8.0 | 86 vitest tests across 6 files (contacts, context, ingest, outreach, mood, relationship); PGlite integration DB; gap: extraction/entity-graph/consolidation lib files have no tests |
| 10 | Background Jobs | 7.5 | Cron every 6h (outreach scan); weekly consolidation queue active |
| 11 | Error Handling | 7.5 | LLM timeout falls back to keyword classifier; outreach pre-delivery checks for all terminal states; fire-and-forget logging |
| 12 | Documentation | 8.0 | Eight docs (README, api-spec, memory-spec, domain-model, outreach-spec, architecture, user-journey, backlog); current as of 2026-06-29 |
| 13 | Feature Completeness | 7.0 | L1/L2 context complete; session-close extraction complete; memory correction complete; Phase 5 CE integration not started |
| 14 | Observability | 5.5 | ci_interaction_logs table exists; no structured logging, Worker tail, or alerting |
Overall: 8.2 / 10
Bugs
B1 — L2 semantic search returns null for first call after new ingest
Severity: Low (expected async behavior, documented) Status: PARTIALLY RESOLVED — embeddings now generated; timing gap remains
Previously: Embeddings were never generated at all (queue binding commented out). The feature was silently broken.
Now: Embeddings are generated via c.executionCtx.waitUntil() in the ingest route (fire-and-forget). The first GET /context/:id?message=... call after a new ingest returns message_relevance: null because the embedding hasn't landed yet. A second call a few seconds later will return the semantic result.
Remaining gap: No retry queue for failed embedding generation. If the Gemini/OpenAI call fails, that memory's embedding stays null permanently (no dead-letter path).
B2 — Memory importance never decays
Severity: Medium Status: OPEN
The decay function (importance *= (1 - decayRate)^days) is implemented in the consolidation pipeline (step 1 of 9). The weekly cron enqueues work to CONSOLIDATION_QUEUE, and the queue consumer runs the pipeline. However, the consolidation job has not been stress-tested in production — decay has not been observed for existing contacts. Old memories may retain full importance until the first successful consolidation run.
B3 — Outreach message is generated but never delivered
Severity: High (feature silently broken) Status: OPEN (Phase 5 dependency)
The outreach scanner generates messages and marks triggers fired, but delivery requires calling CHAT_ENGINE_SERVICE.fetch() to push the message to the bot's channel. CHAT_ENGINE_SERVICE binding exists in wrangler.toml but the delivery call is not implemented in the scanner — it stores the message in outreach_triggers.message and marks the trigger fired without actually sending anything. Phase 5 must implement this.
B4 — Milestone detector has no idempotency lock
Severity: Low Status: OPEN
The milestone check is an exact match (WHERE total_messages = 100). If two cron cycles fire within seconds of each other, both runs find total_messages = 100 and create duplicate milestone triggers before either inserts. The contact receives two identical messages.
Fix: insert the trigger with a unique constraint on (contact_id, trigger_type, milestone_value) and use ON CONFLICT DO NOTHING.
B5 — Entity names are case-sensitive
Severity: Low Status: OPEN
The ci_entities unique index is on (contact_id, type, name) with no normalization. "Bruno" and "bruno" create separate entity rows. The upsertEntity() function does fuzzy dedup (Levenshtein ≥ 0.85) in the weekly consolidation pass, but the case-split persists until that job runs.
B6 — Recurring trigger type has no scheduler
Severity: Medium Status: OPEN
The schema defines trigger_type: "recurring" and outreach_config has max_per_day/min_gap_days, but the outreach scanner has no createRecurringTriggers() step. The recurring_cron expression field in the old spec is not in the actual config table. This feature is non-functional.
B7 — Fire-and-forget interaction log silently drops write failures
Severity: Low Status: OPEN
Interaction log insertion is kicked off via waitUntil() without a catch that bubbles metrics. A DB error (connection timeout, schema mismatch) silently disappears. Analytics counts become under-counted with no alert.
Areas of Improvement
A1 — Zero test coverage ✅ RESOLVED
Resolution (2026-06-29): 86 vitest tests added across 6 files.
| File | Tests | What's covered |
|---|---|---|
contacts.test.ts |
22 | CRUD, soft delete, correct, memories, tenant isolation |
context.test.ts |
11 | L1 cold-start, response shape, /mood |
ingest.test.ts |
9 | Per-message path, session_close, duplicate prevention |
outreach.test.ts |
12 | Create, list, cancel, config CRUD |
mood.test.ts |
13 | Pure function, all moods, case-insensitive, confidence range |
relationship.test.ts |
19 | computeRelationshipStage × 9, computeChurnRisk × 7, updateStreak × 4 |
Run tests: cd apps/contact-intelligence && npm test
Remaining gap: src/lib/extraction.ts, src/lib/entity-graph.ts, and src/lib/consolidation.ts have no unit tests. These are the most complex lib files and the most likely to have regression risk.
A2 — No database-level RLS
All isolation is app-level (WHERE tenant_id = ?). A route that forgets the filter (or a new developer adding a direct query) leaks cross-tenant data. Adding RLS policies on all CI tables would guarantee isolation as a defense-in-depth layer.
A3 — Cross-channel contact merging not implemented
A customer on WhatsApp AND Telegram is two separate CI contacts. There is no link, no shared memory. When they switch channels, they start fresh. ci_contact_channels was added as the data model for this feature, but no merge logic exists.
A4 — Language detection and adaptation absent
CI has no language detection. The bot responds in one language regardless of the contact's messages. Adding detection from the first 3 messages and storing it as a preference would allow Chat Engine to respond in the contact's preferred language.
A5 — Preferred hours inference not automated
outreach_config.quiet_hours_start/end is tenant-wide and static. ci_interaction_logs.created_at records every message timestamp. A background job could compute per-contact preferred contact hours from this log — making outreach arrive when the customer is actually online.
A6 — Crisis detection not fully implemented
The crisis_level field exists on ci_contacts and is returned in context responses. The session-close LLM extraction is supposed to set it, but the extraction prompt doesn't explicitly instruct the model to evaluate crisis signals. The field stays at "none" for most contacts regardless of conversation content.
A7 — Contact list has no name search
GET /contacts supports channel, stage, sort, limit, offset — but no search parameter. Finding a specific contact by name requires paginating through the full list.
Deferred (not bugs, notable gaps)
| Item | Status |
|---|---|
Phase 5: CE orchestrator calls GET /context/:id |
Not started — CE still calls /mood |
| Phase 6: Contact intelligence dashboard | Not started |
| Recurring outreach scheduler | Not implemented (B6) |
| Delivery feedback loop (read receipts) | Not implemented |
| Timezone inference per contact from activity | Not implemented |
| Embedding retry queue | Not implemented (B1 gap) |