From f1ed2a5f87969c7213e3acb72b480885320770ce Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Tue, 28 Apr 2026 14:00:01 +0200 Subject: [PATCH] =?UTF-8?q?docs(spec):=20Phase=20B=20=E2=80=94=20insights,?= =?UTF-8?q?=20alerts,=20and=20operational=20awareness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full execution plan for the next phase. Closes the seven priority gaps the 2026-04-28 Nuxt→Next audit surfaced (analytics, alerts, interests-by-berth, expense dedup, EOI queue, OCR, audit log read view). Scope: - Analytics dashboard with KPI tiles, pipeline funnel, occupancy timeline, revenue breakdown, lead-source attribution; cached via `analytics_snapshots` recurring job. - Alert framework: 10-rule v1 catalog, rule engine evaluates on cron, fingerprint dedupes, auto-resolves when condition clears, surfaces in dashboard right rail + dedicated /alerts page. - Interests-by-berth tab on berth detail. - Expense duplicate detection (vendor + amount + date ±3d) with merge action. - OCR for expense receipts via Claude Vision (Haiku 4.5 + ephemeral system-prompt cache). - Audit log admin read view with tsvector search + cursor pagination. - EOI queue: saved-view tab on the documents hub. 11 PRs, ~10-13 dev days, calendar 2.5-3 weeks. Critical path graphed. Risk register includes alert false-positive mitigation, OCR cost ceiling via Haiku + cache, and audit-log scale. Four open questions for the user in the spec footer. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-04-28-phase-b-insights-alerts-design.md | 435 ++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md diff --git a/docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md b/docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md new file mode 100644 index 0000000..a4234f9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-phase-b-insights-alerts-design.md @@ -0,0 +1,435 @@ +# Phase B — Insights, Alerts, and Operational Awareness + +**Status:** Draft — awaiting review +**Date:** 2026-04-28 +**Phase:** B of D (A = Documents hub + visual polish ✓ shipped; C = Website integration; D = Pre-prod ops) + +## Overview + +Phase A made the CRM look polished and finished the documents/signing surface. Phase B turns it into a tool that _tells operators what's happening_ — instead of forcing them to navigate every list to find pipeline drift, expiring documents, or stalled reservations. It also closes the seven highest-priority Nuxt→Next gaps the 2026-04-28 audit surfaced (analytics, berth-interests, EOI queue, OCR, alerts, audit log, expense dedup). + +The product story changes from "system of record" to "system of attention." Operators land on the dashboard and immediately see what needs them today — not a flat list they have to filter. + +## Scope boundaries + +### In scope (this spec) + +- **Analytics dashboard** — chart-driven KPI page replacing the current 4-tile placeholder; pipeline funnel, occupancy timeline, revenue breakdown, lead-source attribution, with date-range and per-port filters +- **Alert framework** — rule engine that evaluates conditions on a schedule and surfaces actionable cards (alerts) in the dashboard's right rail; dismissible per-user; deep-links into the offending entity +- **Interests-by-berth view** — `/[port]/berths/[id]/interests` panel showing every interest targeting a berth, sortable by stage/score/age +- **Expense duplicate detection** — heuristic match on (vendor + amount + date ± 3 days); surfaces in expense detail with "Merge" action; background scan on new expense +- **EOI queue** — saved-view filter on the existing documents hub for `documentType='eoi' AND status IN ('sent','partially_signed')`, surfaced as a hub tab and a dashboard alert link +- **OCR for expense receipts** — Claude Vision integration on the existing `/expenses/scan` route to extract vendor, amount, date, currency, line items from uploaded receipts; user confirms before save +- **Audit log read view** — admin-gated UI for the existing `audit_logs` table with filters (user, action, entity type, date range, entity id search) and per-port + global (super-admin) scopes + +### Explicitly out of scope (deferred to later phases) + +- Custom user-defined alert rules (Phase B v1 ships with a fixed catalog of ~10 rules; user-rule creation deferred to Phase D) +- Real-time alert push notifications (only socket-fired updates of the alert list; SMS/email push deferred) +- Alert grouping / digests (each alert is its own card) +- Predictive analytics, ML scoring (separate from existing AI feature flag) +- Cross-port roll-up dashboards for super-admins (per-port only in v1) +- Full audit-log retention / archival policy (Phase D) +- OCR for PDF receipts (only image formats: jpg/png/heic; PDF expense uploads bypass OCR and stay manual until Phase D) +- Excel/CSV import for bulk expense backfill +- Country / phone / timezone work (separate cross-cutting agenda at `2026-04-28-country-phone-timezone-design.md`) + +## Information architecture + +### URL surface + +``` +/[port]/dashboard replaces existing; analytics-driven +/[port]/insights deep-link analytics page (charts only, no alerts) +/[port]/alerts full alert list (admin filter, dismissed history) +/[port]/berths/[id]/interests new tab on berth detail +/[port]/expenses/scan extend existing route with Claude Vision OCR +/[port]/admin/audit admin-gated audit log viewer +/[port]/documents extended: 'EOI queue' tab pre-filters to EOI in flight +``` + +### Schema deltas + +```sql +-- alerts: surfaces operational warnings the user should act on +CREATE TABLE alerts ( + id text PRIMARY KEY DEFAULT generate_id('alrt'), + port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE, + rule_id text NOT NULL, -- 'reservation.no_agreement', 'interest.stale', ... + severity text NOT NULL, -- 'info' | 'warning' | 'critical' + title text NOT NULL, + body text, + link text NOT NULL, -- relative path the card deep-links to + entity_type text, -- optional FK target ('interest', 'reservation', ...) + entity_id text, + fingerprint text NOT NULL, -- hash of (rule_id + entity_type + entity_id) — dedupe + fired_at timestamptz NOT NULL DEFAULT now(), + dismissed_at timestamptz, + dismissed_by text REFERENCES users(id), + acknowledged_at timestamptz, -- "I'm on it" without dismissing + acknowledged_by text REFERENCES users(id), + resolved_at timestamptz, -- auto-set when underlying condition clears + metadata jsonb DEFAULT '{}' -- per-rule extras (e.g. days_stale, amount_at_risk) +); +CREATE UNIQUE INDEX idx_alerts_fingerprint_open ON alerts (port_id, fingerprint) WHERE resolved_at IS NULL; +CREATE INDEX idx_alerts_port_fired ON alerts (port_id, fired_at DESC); +CREATE INDEX idx_alerts_port_severity_open ON alerts (port_id, severity) WHERE resolved_at IS NULL AND dismissed_at IS NULL; + +-- expense duplicate detection (column-only, no new table) +ALTER TABLE expenses ADD COLUMN duplicate_of text REFERENCES expenses(id); +ALTER TABLE expenses ADD COLUMN dedup_scanned_at timestamptz; +CREATE INDEX idx_expenses_dedup ON expenses (port_id, vendor_name, amount, expense_date) + WHERE duplicate_of IS NULL; + +-- analytics support: materialized refresh tracking (avoids recomputing on every dashboard hit) +CREATE TABLE analytics_snapshots ( + port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE, + metric_id text NOT NULL, -- 'pipeline_funnel.30d', 'occupancy_timeline.90d', ... + computed_at timestamptz NOT NULL DEFAULT now(), + data jsonb NOT NULL, + PRIMARY KEY (port_id, metric_id) +); + +-- audit_logs already exists; add a tsvector column for fast search +ALTER TABLE audit_logs ADD COLUMN search_text tsvector + GENERATED ALWAYS AS ( + to_tsvector('simple', + coalesce(action, '') || ' ' || + coalesce(entity_type, '') || ' ' || + coalesce(entity_id::text, '') || ' ' || + coalesce(actor_email, '')) + ) STORED; +CREATE INDEX idx_audit_search ON audit_logs USING gin(search_text); + +-- ocr extracted fields on receipt files (most fields already on expenses) +ALTER TABLE expenses ADD COLUMN ocr_status text DEFAULT 'pending'; -- 'pending'|'ok'|'failed'|'low_confidence' +ALTER TABLE expenses ADD COLUMN ocr_raw jsonb; -- the model's full response +ALTER TABLE expenses ADD COLUMN ocr_confidence numeric; -- 0..1 +``` + +After running migration on dev/staging, restart `next dev` to flush postgres.js prepared-statement cache (project convention). + +### Service-layer changes + +**New services:** + +- `alerts.service.ts` — CRUD + fanout: `evaluateRules(portId)`, `dismissAlert(id, userId)`, `acknowledgeAlert(id, userId)`, `resolveStaleAlerts(portId)` +- `alert-rules.ts` — fixed catalog of evaluator functions, each takes `(portId, db)` and returns `Array<{ rule_id, severity, fingerprint, ... }>` +- `analytics.service.ts` — `getPipelineFunnel(portId, range)`, `getOccupancyTimeline(portId, range)`, `getRevenueBreakdown(portId, range)`, `getLeadSourceAttribution(portId, range)`; reads `analytics_snapshots` first, recomputes if stale +- `analytics-snapshot-job.ts` — BullMQ recurring job that recomputes snapshots every 15 min per port +- `expense-dedup.service.ts` — `scanForDuplicates(expenseId)`, returns candidate matches with confidence; called from BullMQ on `expense:created` +- `expense-ocr.service.ts` — Claude Vision wrapper: takes file URL, returns parsed expense fields; uses prompt caching for the system prompt to keep cost down +- `audit-search.service.ts` — wraps drizzle query with tsvector match + filters + +**Extended services:** + +- `documents.service.ts` — adds `getEoiQueueRows(portId, opts)` that joins documents + signers + last-reminder for the EOI queue tab +- `expenses.service.ts` — `createExpense` triggers OCR + dedup BullMQ jobs after row insert +- `notifications.service.ts` — fires `alert:created` and `alert:resolved` socket events + +### Alert rule catalog (v1) + +| Rule ID | Severity | Trigger | Resolves when | Why it matters | +| ---------------------------- | -------- | -------------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------- | +| `reservation.no_agreement` | warning | active reservation > 3d old without a `reservation_agreement` doc in any non-cancelled state | doc reaches `sent` | flagged in Phase A spec | +| `interest.stale` | info | `pipelineStage IN ('details_sent','in_communication','visited')` AND last activity > 14d | activity timestamp updates | dropped leads | +| `document.expiring_soon` | warning | `expires_at` within 7 days, `status IN ('sent','partially_signed')` | doc completed/cancelled or expires_at passes | nudge before contracts lapse | +| `document.signer_overdue` | warning | signer pending > 14d AND last reminder > 7d ago | signer signs/declines | classic chase target | +| `berth.under_offer_stalled` | info | berth `status='under_offer'` > 30d | status changes | reservation never closed | +| `expense.duplicate` | info | `expense.duplicate_of IS NOT NULL` | merged or marked-not-duplicate | bookkeeping cleanup | +| `expense.unscanned` | info | expense with file but `ocr_status='pending'` > 1h | `ocr_status='ok'` | OCR failed silently | +| `interest.high_value_silent` | critical | `leadCategory='hot_lead'` AND last activity > 7d | activity update | revenue at risk | +| `eoi.unsigned_long` | warning | EOI doc `status='sent'` > 21d | doc completed/cancelled | EOI funnel leak | +| `audit.suspicious_login` | critical | >3 failed logins from same IP in 1h | manual dismiss | security awareness | + +Rules are pure functions; the engine takes their outputs, upserts on `(port_id, fingerprint)` to avoid spam, and auto-resolves alerts whose rule no longer fires. + +## Per-feature design + +### Analytics dashboard + +Replaces the current 4-tile dashboard. Layout: + +``` +[ Gradient PageHeader: "Dashboard" · last-updated stamp · Date range picker (Today / 7d / 30d / 90d / custom) ] + +[ KPI row (4 KPITiles, sparkline + delta vs prior period): + Total Clients Active Interests Pipeline Value Occupancy Rate +] + +[ Pipeline funnel (recharts FunnelChart): | Alert rail (right column): + horizontal bars per stage with conversion % | Critical (red) cards + click bar → filtered interests list | Warning (amber) cards + | Info (blue) cards + | "Show dismissed" toggle +] | + +[ Revenue breakdown (recharts BarChart, stacked by source) ] | (continues) + +[ Occupancy timeline (recharts AreaChart, daily/weekly) ] | + +[ Lead source attribution (recharts PieChart with legend) ] +``` + +Charts are server-rendered via the recharts already-in-bundle. Data comes from `analytics.service.ts` which reads `analytics_snapshots` (refreshed every 15 min by cron) — first hit warms the cache, subsequent hits are sub-100ms. + +Date-range picker re-runs `analytics.service` queries with the selected range; cache key includes the range so 30d and 90d don't fight. + +Export: each chart card has a `[...]` overflow menu with "Download as CSV" and "Download as PNG"; uses recharts' `getDataUrl()` for PNG. + +### Alert rail + +Right column on `/dashboard`, full page at `/alerts`. Each alert is a card: + +``` +[severity-color stripe-left] + [rule-icon] Title (entity name) + Body — body text describing the condition + Last fired N days ago · entity: link + [Acknowledge] [Dismiss] [Open →] +``` + +- Acknowledge: marks `acknowledged_at` but stays visible (someone's on it) +- Dismiss: hides from the rail; appears in `/alerts` "Dismissed" tab +- Auto-resolve: when the rule re-evaluates and the condition no longer fires, alert moves to "Resolved" history + +Real-time: socket emits `alert:created` / `alert:resolved` from the cron worker; React Query invalidates the alert list. + +### Interests-by-berth view + +New tab on `/[port]/berths/[id]` called "Interests" — count badge in tab. + +``` +[ Berth header (existing) ] + +[ Tabs: Overview | Reservations | Interests (N) | Notes | Files | Activity ] + +[ Interests tab body: + [Filter: All stages | Active only | Lost] [Sort: Newest | Stage progress | Lead score] + Table: client name | stage pill | source | category | last activity | score badge + Click row → interest detail +] +``` + +Pure read; no mutations. The list filters interests where `interest.berthId = berth.id`. Already exists in DB; just needs the UI tab. + +### Expense duplicate detection + +When a new expense is created, BullMQ job `expense.dedup` runs: + +```ts +async function scanForDuplicates(expenseId: string) { + const e = await db.query.expenses.findFirst({ where: eq(expenses.id, expenseId) }); + const candidates = await db.query.expenses.findMany({ + where: and( + eq(expenses.portId, e.portId), + eq(expenses.vendorName, e.vendorName), + eq(expenses.amount, e.amount), + between(expenses.expenseDate, addDays(e.expenseDate, -3), addDays(e.expenseDate, 3)), + ne(expenses.id, e.id), + ), + }); + if (candidates.length > 0) { + await db + .update(expenses) + .set({ duplicate_of: candidates[0].id, dedup_scanned_at: new Date() }) + .where(eq(expenses.id, expenseId)); + // fires `expense.duplicate` alert via rule engine on next sweep + } +} +``` + +Detail page: when `duplicate_of` is set, show a yellow banner: "Looks like a duplicate of {linked expense}. [Merge them] [Mark as not duplicate]". Merge: deletes the new expense and merges any line items into the original. + +### EOI queue tab + +Documents hub gets a new tab between "Awaiting them" and "Awaiting me": + +``` +Tabs: All | EOI queue (N) | Awaiting them | Awaiting me | Completed | Expired +``` + +`EOI queue` filters: `documentType='eoi' AND status IN ('sent','partially_signed')`. Same row chrome as the rest of the hub. Bulk-action bar adds an "EOI bulk reminder" preset that respects the rule engine's reminder cooldown. + +### OCR for expense receipts + +Existing `/expenses/scan` route — extend to call Claude Vision on upload: + +```ts +// expense-ocr.service.ts (uses Anthropic SDK; already in deps) +import Anthropic from '@anthropic-ai/sdk'; + +const client = new Anthropic(); + +const SYSTEM_PROMPT = `You extract structured expense data from receipts... +Output JSON: { vendor, amount, currency, date (ISO), lineItems: [...], confidence (0-1) } +`; /* cached via ephemeral cache_control for cost savings */ + +export async function ocrReceipt(fileUrl: string) { + const file = await fetch(fileUrl); + const base64 = Buffer.from(await file.arrayBuffer()).toString('base64'); + + const message = await client.messages.create({ + model: 'claude-haiku-4-5-20251001', // haiku for cost; sonnet if quality needed + max_tokens: 1024, + system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }], + messages: [ + { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: base64 } }, + { type: 'text', text: 'Extract expense fields from this receipt.' }, + ], + }, + ], + }); + + return parseAndValidate(message.content[0].text); +} +``` + +UI: existing scan page now shows a 3-step flow: + +1. Upload receipt photo +2. Wait for OCR (spinner; ~3s avg with Haiku) +3. Confirm extracted fields (pre-filled form, user can edit) +4. Save → existing expense create flow + +Low-confidence (< 0.6) extractions show a yellow banner "Please verify all fields" and pre-select the file uploader. + +### Audit log read view + +Admin route `/[port]/admin/audit`: + +``` +[ PageHeader: "Audit Log" · "Last 30 days · 12,847 events" ] + +[ Filter row: + Search [tsvector] Actor [combobox of users] Action [pills] Entity type [select] + Date range [picker] Severity [pills] [Reset] +] + +[ Table: + Timestamp | Actor | Action | Entity | Diff button | IP | User-agent + Click row → expand to show before/after JSON diff +] + +[ Pagination · Export CSV button (admin-gated) ] +``` + +Server-side: `audit-search.service.ts` builds a drizzle query with the tsvector match + filters; supports cursor pagination on `(created_at, id)`. + +Super-admin sees a port toggle that switches between current port and "All ports" view. + +## Test plan + +### Unit (`tests/unit/`) + +- `alert-rules-evaluators.test.ts` — each rule tested with seeded data; covers fire/no-fire cases and resolution conditions +- `expense-dedup-heuristic.test.ts` — vendor/amount/date matching with edge cases (case-insensitive, ±3d window, currency mismatch ignored) +- `analytics-pipeline-funnel.test.ts` — funnel math against fixture interests +- `analytics-occupancy-timeline.test.ts` — daily aggregation against fixture berth status changes +- `audit-search-filters.test.ts` — tsvector + filter composition +- `ocr-prompt-caching.test.ts` — assert cache_control presence on system prompt; mocked Claude response + +### Integration (`tests/integration/`) + +- `alerts-engine.test.ts` — full evaluation cycle: seed conditions, run engine, assert correct alerts upserted, run again to assert dedupe via fingerprint, mutate state, assert auto-resolve +- `analytics-snapshot-refresh.test.ts` — recurring job: snapshot row written, served from cache on next read, refreshed on next tick +- `expense-dedup-flow.test.ts` — create A, create matching B, assert B.duplicate_of=A; merge B → A absorbs line items, B archived +- `audit-search-tsvector.test.ts` — seed audit_logs, query by free-text, assert returned ids +- `eoi-queue-listing.test.ts` — extends documents-hub test; assert EOI tab returns correct subset + +### E2E smoke (`tests/e2e/smoke/`) + +- New `27-analytics-dashboard.spec.ts` — dashboard renders charts; date-range picker re-renders; KPI tiles show non-zero data after seed +- New `28-alerts.spec.ts` — alert appears after seeding stale-interest condition; click-to-deep-link; dismiss persists; resolve hides +- New `29-interests-by-berth.spec.ts` — tab visible on berth detail; lists interests; sort works +- New `30-expense-dedup.spec.ts` — create two matching expenses; banner appears; merge button works +- New `31-ocr-flow.spec.ts` — uploads fixture receipt image; extracted fields pre-filled; user can edit and save +- New `32-audit-log.spec.ts` — admin page loads; search by entity id returns expected row; date filter narrows +- Extend `04-documents.spec.ts` — EOI queue tab presence + count badge + +### E2E exhaustive (`tests/e2e/exhaustive/`) + +- `15-analytics-dashboard.spec.ts` — crawl every chart's hover tooltips, legend toggles, export menu +- `16-alerts.spec.ts` — crawl alert card actions, severity filters, dismissed history, real-time arrival via socket emit +- `17-audit-log.spec.ts` — crawl filter combos, expand row diffs, super-admin all-ports toggle + +### E2E real-API (`tests/e2e/realapi/`) + +- New `claude-vision-receipt-ocr.spec.ts` — gates on `ANTHROPIC_API_KEY`; uploads two real fixture receipts (one clean, one blurry); asserts Haiku response shape and confidence score; verifies `cache_control` headers in HTTP trace; cleanup deletes test expense + +### Test data fixtures + +`global-setup.ts` extends: + +- Seed one stale interest in `details_sent` stage with `last_activity_at = now - 20d` (fires `interest.stale`) +- Seed one active reservation without an agreement (fires `reservation.no_agreement`) +- Seed two matching expenses (fires `expense.duplicate`) +- Seed 90 days of pipeline activity for analytics charts +- Add a `tests/e2e/fixtures/receipts/` dir with two .jpg receipts for OCR tests + +## Build sequence + +| # | Title | Effort | Depends on | +| --- | ------------------------------------------------------------ | ------ | ----------------- | +| 1 | Schema + alert/analytics service skeletons | 1d | — | +| 2 | Alert rules engine + recurring evaluator + socket | 1.5d | 1 | +| 3 | Analytics snapshot job + service layer | 1d | 1 | +| 4 | Analytics dashboard page (KPI tiles + 4 charts + date-range) | 2.5d | 1, 3, A's KPITile | +| 5 | Alert rail UI + `/alerts` page | 1.5d | 2 | +| 6 | EOI queue tab on documents hub | 0.5d | A's hub | +| 7 | Interests-by-berth tab on berth detail | 0.5d | — | +| 8 | Expense duplicate detection (job + UI banner + merge) | 1.5d | 1 | +| 9 | OCR for expense receipts (Claude Vision + 3-step UI) | 1.5d | — | +| 10 | Audit log read view (admin page + filters + tsvector search) | 1.5d | 1 | +| 11 | Real-API integration tests | 1d | 9 | + +### Critical path + +``` +1 → 2 → 5 (data → alert engine → alert UI) +1 → 3 → 4 (data → analytics service → analytics page) +8 → 2 (alert rule) (dedup populates the data the alert reads) +9 (OCR) → 11 (realapi) +``` + +Wall-clock minimum ~10 days (one engineer, sequential critical path); realistic with overhead ~13 days; calendar 2.5–3 weeks. + +### Acceptance gates per PR + +- `pnpm tsc --noEmit` and `pnpm lint` clean +- Vitest unit + integration green (incl. new tests) +- Playwright smoke green for the surface touched +- Visual baselines regenerated and reviewed in PR diff +- For PRs touching external integrations (9 OCR, 11 realapi): relevant `realapi` spec verified locally before merge + +### Risk register + +| Risk | Mitigation | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Alert engine false positives spam users | Each rule has a "snooze" window in metadata; rules ship behind a feature flag `alerts.{rule_id}.enabled`; QA seeds production-shape data before flipping flags on | +| Analytics queries slow on large datasets | `analytics_snapshots` materialized cache; cron recomputes off the request path; queries use existing per-port indexes | +| Claude Vision OCR cost spirals | Default to Haiku 4.5 (~10× cheaper than Sonnet); ephemeral system-prompt cache hits ~80%; per-port quota with admin-visible meter | +| OCR low-quality on blurry receipts | Confidence threshold (< 0.6) flips to "verify mode" — user must touch every field before save; failure metric tracked in admin/monitoring | +| Audit log table large (millions of rows) | Already partitioned-friendly via the GIN tsvector index; pagination uses cursor on `(created_at, id)` not OFFSET | +| Alert socket fanout overwhelms client | Throttle the engine cron to once per 5min; client debounces React Query refetches | +| Interest stale rule fires for legitimately paused leads | Add a per-interest `paused_until` field as a follow-up if operators ask; v1 ships without | + +## Glossary + +- **Alert** — operator-facing actionable card, rule-fired, dismissible +- **Rule** — a pure-function evaluator that takes (port, db) and returns alert candidates +- **Fingerprint** — `hash(rule_id + entity_type + entity_id)` used to dedupe alerts across re-evaluations +- **Snapshot** — cached chart data row in `analytics_snapshots`, refreshed on cron +- **EOI queue** — saved-view filter on the documents hub, not a separate page +- **OCR** — Claude Vision extraction of structured expense fields from receipt images +- **Audit log** — read view of the existing `audit_logs` table; no schema change beyond a tsvector column + +## Open questions for the user + +- Which port should be the **default landing dashboard** when a super-admin logs in (currently first-port-by-name; analytics page works the same)? +- Should the alert rail be **always visible on all dashboard pages** or only on `/dashboard` (currently spec'd as the latter)? +- Do you want the **Audit log retention policy** (delete > N days old) wired in v1 or deferred to Phase D? +- Should **OCR be opt-in per port** (admin toggle) or always-on with a quota?