lilith-platform.live/codebase/@features/quinn-ai/docs/architecture.md
autocommit 50579082da docs(quinn-ai): 📝 Update architecture, feature gates, and operational runbook documentation for Quinn AI
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-16 00:39:33 -07:00

6.1 KiB

Architecture

Host topology

plum (10.9.0.3 via WG)         black (10.0.0.11 LAN)            apricot (10.0.0.116 LAN)
┌────────────────────────┐    ┌──────────────────────────┐    ┌─────────────────────────────┐
│ macOS Messages.app     │    │ postgres: quinn_icloud   │    │ quinn-outreach (Node/bun)   │
│  └─ chat.db (sqlite)   │    │  macsync.messages  ◀────┐ │    │  inbound-listener           │
│                        │    │  macsync.conversations  │ │    │  watcher                    │
│ MacSyncApp:8765 ───────┼───▶│  outreach.*            │ │    │  auto-respond-engine        │
│  (mirrors chat.db)     │    │  + LISTEN/NOTIFY ──────┼─┼───▶│  booking-worker             │
│                        │    │      message_inserted  │ │    │  scheduled-send-worker      │
│ Bridge:8766 ◀──────────┼────┤                       ◀┼─┼────┤  + ./run CLI                │
│  POST /api/imessage/   │    │                          │    └─────────────────────────────┘
│       send             │    └──────────────────────────┘
└────────────────────────┘
  • plum runs Messages.app (chat.db is sqlite). MacSyncApp tails chat.db and inserts rows into postgres on black. Bridge accepts outbound HTTP requests and dispatches via osascript.
  • macsync.messages (mac-sync v2): gained attributed_body BYTEA + body_decoded TEXT columns via migration 2026-05-13_message_attributed_body. Both are populated by the mac-sync server when the source iMessage row carried a non-empty attributedBody; the FTS tsvector spans body || ' ' || body_decoded so rich-content messages remain searchable even when chat.db's text is empty.
  • black runs the postgres mirror. Trigger NOTIFY on insert to macsync.messages is what wakes the apricot listener.
  • apricot runs the engine processes (no chat.db access; reads via postgres mirror).

Inbound → gated dispatch path

prospect SMS/iMessage
  │
  ▼ chat.db row on plum
  │
  ▼ MacSyncApp INSERT into macsync.messages on black
  │
  ▼ pg trigger NOTIFY message_inserted
  │
  ▼ InboundListener.handler on apricot
  │
  ▼ watcher.processInbound:
        emit inbound_received
        emit prospect_replied        (wakes scheduled-send dependency check)
        AddressBook lookup (TCC-gated; fail-loud → block)
        evaluateEligibility (C1 spam │ C2 floor │ C3 wrong-id │ C4 prior │ C5 lookup)
            │
   ┌────────┴─────────┐
   ▼                  ▼
ineligible         eligible → auto-respond-engine
   │                  │
   ├ C3 hard-block    ▼ selectTemplate(inbound) → A│B│C│D
   │   silent drop    ▼ renderTemplate(id, calendar) → body
   │                  ▼ classifyTier → calculateFireAt
   └ other → routeToQuinnApproval
                      │
                      ▼ runPreFireChecks (§38) — see docs/gates.md
                      │
                ┌─────┴─────┐
                ▼           ▼
            fire=true   fire=false
                │           │
         scheduleSend   routeToQuinnApproval (reason=pre_fire:<gate>)
                │
                ▼ scheduled-send-worker picks up at fireAt
                │
                ▼ mac-sync POST /api/imessage/send → bridge → osascript
                  (v2: per-module send-queue endpoints also exist —
                   `POST /admin/{calendar,reminder,note,mail}-send-queue/enqueue`
                   with body `{ deviceId, action, payload }`.)
                │
                ▼ emit auto_respond_sent / scheduled_send_sent

Booking expiry → cover-story draft

booking-worker tick (every 60s)
  │
  ▼ processExpiredDeposits(pool)
        UPDATE outreach.bookings SET state='expired-no-deposit' WHERE deadline < now()
        emit booking_deposit_expired (per row)
        return ExpiredBookingRow[]
  │
  ▼ for each row:
        pickCoverStory('double-booked', {raincheck: 'tomorrow'})
        emit cover_story_proposed { booking_id, prospect_handle, body }
  │
  ▼ Quinn sees draft in self-thread tooling, decides send/skip

What flows through what

Layer Module Reads Writes
Inbound inbound-listener.ts pg LISTEN NotifyHandler callback
Triage watcher.ts payload, addressbook, prospect_state event_log: inbound_received, prospect_replied
Eligibility eligibility.ts inbound, priors, addressbook (pure function)
Template templates.ts inbound, calendar slots string body
Pre-fire gates pre-fire-checks.ts engine-config, block-list, thread state, jargon rules, botness signals, macsync.messages event_log: auto_respond_pre_fire
Schedule scheduled-send-worker.ts outreach.scheduled_send event_log: scheduled_send_sent
Dispatch mac-sync-client.ts scheduled row HTTP → bridge → osascript
Booking booking-worker.ts outreach.bookings event_log: booking_deposit_expired, cover_story_proposed

The gating chain (pre-fire-checks) is the load-bearing piece. Everything else is plumbing.

Postgres schemas used

  • macsync.messages — chat.db mirror; read by §37 quinn-in-thread query (v2 also exposes attributed_body, body_decoded; FTS-indexed across both body columns)
  • macsync.conversations — for participants @> jsonb lookup
  • outreach.scheduled_send — durable send queue
  • outreach.bookings — booking state machine (deposit-pending, expired-no-deposit, etc.)
  • outreach.event_log — append-only event stream (heartbeat, gate decisions, dispatches)
  • outreach.heartbeat — worker liveness