Skip to content

Pearl Analytics

Overview

Pearl Analytics is Pearl's Slack-integrated system for collecting, validating, and auditing revenue growth metrics across multiple analytics dashboards. It provides slash commands under /pearl analytics that allow team members to trigger metric collection, validate live data against stored snapshots, audit dashboard health, and explore metric coverage across the organization's dashboard ecosystem. It also provides /pearl goals commands for managing Matomo conversion goals, /pearl experiment commands for managing A/B experiments, /pearl intent, /pearl effect, and /pearl council commands for interacting with the M6 Commander intent inbox, and /pearl invoice commands for originating and tracking invoices with founder-approval gating.

Architecture

Pearl Analytics is composed of fifteen main components:

  1. Command Handler (src/handlers/analytics_command_handler.py) — Routes /pearl analytics <subcommand> Slack commands to the appropriate service. Depends on the validation service, metric collector, vector memory (Weaviate), and audit service.

  2. Metric Collector Service (src/services/metric_collector_service.py) — Polls TheSmithy's agent metrics endpoint (/api/metrics/agent/) on a schedule and records snapshots into Pearl's database via RevenueAnalyticsService. Deduplicates same-day values automatically. Collects four core metrics:

    • waitlist_size (from waitlist)
    • beta_activations (from beta_users)
    • pre_orders (from preorders)
    • revenue_monthly (from cash)
  3. Matomo Collector Service (src/services/matomo_collector_service.py) — Polls Matomo's Reporting API for web traffic metrics and records them via RevenueAnalyticsService. Runs on a schedule (default: every 6 hours, with a 30-minute stagger after the TheSmithy collector). Collection is organized into two tiers:

    Tier 1 — Calls VisitsSummary.get and records five metrics directly from the response: - mau (from nb_uniq_visitors) - bounce_rate (from bounce_rate) - total_visits (from nb_visits) - avg_session_duration (from avg_time_on_site) - actions_per_visit (from nb_actions_per_visit)

    Tier 3 — Calls additional Matomo API methods and derives scalar metrics. Errors in Tier 3 collectors are non-fatal and do not break Tier 1 collection: - VisitFrequency.getnew_visitor_ratio (percentage of visits from new visitors) - Referrers.getReferrerTypereferrer_search_pct, referrer_direct_pct, referrer_social_pct (visit percentages by referrer type) - DevicesDetection.getTypedevice_desktop_pct (percentage of visits from desktop devices)

    The service tracks Matomo authentication state internally (auth_status: unchecked, valid, or invalid). On scheduler startup, validate_auth() is called to verify the token before entering the collection loop. If auth is invalid, the collector automatically retries validation on the next collection cycle — a restart is no longer required. See Error Handling for details.

  4. Analytics Validation Service (src/services/analytics_validation_service.py) — Cross-references live growth data from TheSmithy's API against Pearl's stored metric snapshots. Reports per-metric status as one of: ok, drift, stale, missing_live, or missing_stored. Also surfaces which dashboards Pearl can and cannot reach.

    • Staleness threshold: 24 hours
    • Drift tolerance: 1%
  5. Dashboard Registry (src/services/dashboard_registry.py) — Single source of truth for all known analytics dashboards, their auth requirements, integration status, and metric mappings. Currently tracks five dashboards:

    • Grit Growth — integrated (agent key auth)
    • Matomo Web Analytics — integrated (Matomo token auth) — 4 API endpoints, 10 metrics
    • Virginia Pipeline — blocked (staff session required)
    • Campaign Performance — blocked (upstream TheSmithy fixes needed: #687, #688, #689)
    • Conversion Funnel — blocked (upstream TheSmithy fix needed: #686)
  6. Matomo Goals Service (src/services/matomo_goals_service.py) — Manages Matomo conversion goals via the Goals Admin API and collects conversion data via the Goals Reporting API. Stores goal definitions and conversion history in PostgreSQL (matomo_goals and matomo_goal_conversions tables) for trend analysis and Slack reporting. Phase 2 of Matomo integration, building on the Matomo Collector Service (Phase 1). Capabilities include:

    • Creating, listing, deleting, and syncing goals with Matomo
    • Collecting per-goal conversion data (conversions, conversion rate, revenue)
    • Formatting goal summaries for Slack
  7. Experiment Service (src/services/experiment_service.py) — Lightweight A/B experiment framework that uses Matomo segments for measurement. Pearl owns the experiment definitions, variant segments, and statistical analysis — it does not depend on Matomo's A/B testing plugin. Phase 3 of Matomo integration. Stores data in PostgreSQL (experiments, experiment_variants, experiment_measurements tables). Capabilities include:

    • Experiment lifecycle management (draftrunningcompleted | cancelled)
    • Variant management with Matomo segment mapping (each variant maps to a Matomo segment)
    • Metric collection per variant via Matomo VisitsSummary.get with variant-specific segments
    • Statistical significance analysis using a two-proportion z-test (p < 0.05 threshold)
  8. Dashboard Audit Service (src/services/dashboard_audit_service.py) — Runs cross-dashboard health checks and aggregates findings into an AuditReport. Depends on the validation service (for Grit Growth auditing) and the Matomo collector service (for Matomo auditing). Findings are categorized by status (passed, failed, warning, blocked) and severity (info, warning, critical). Currently audits:

    • Grit Growth — validates live metrics against stored snapshots via AnalyticsValidationService, checking for drift, staleness, and missing data
    • Matomo Web Analytics — performs live auth validation via validate_auth() and a data freshness check via collect(). If auth fails, data collection checks are skipped. If the Matomo collector is not configured, returns a blocked finding
    • Campaign Performance — blocked (upstream TheSmithy fixes #687, #688, #689)
    • Conversion Funnel — blocked (upstream TheSmithy fix #686)

    Actionable findings (failed or warning) are posted to the #revenue Slack channel via the notification service.

  9. Goals Command Handler (src/handlers/goals_command_handler.py) — Routes /pearl goals <subcommand> Slack commands to the Matomo Goals Service. Provides a dedicated command namespace for managing conversion goals, separate from the /pearl analytics commands. Depends on the MatomoGoalsService; if the service is unavailable, all subcommands return a "not available" message directing the user to check MATOMO_URL, MATOMO_TOKEN_AUTH, and PEARL_MATOMO_GOALS_ENABLED.

  10. Experiment Command Handler (src/handlers/experiment_command_handler.py) — Routes /pearl experiment <subcommand> Slack commands to the Experiment Service. Provides a dedicated command namespace for managing A/B experiments, separate from the /pearl analytics and /pearl goals commands. Depends on the ExperimentService; if the service is unavailable, all subcommands return a "not available" message directing the user to check MATOMO_URL, MATOMO_TOKEN_AUTH, and PEARL_EXPERIMENTS_ENABLED.

  11. Intent Command Handler (src/handlers/intent_command_handler.py) — Routes /pearl intent <subcommand> Slack commands to the IntentReceiver mixin methods on PearlSlackBot. Provides a Slack surface for the M6 Commander intent inbox (pearl#96), building on the mixin wired in pearl#95. Subcommands include viewing assigned intents, inspecting intent detail, submitting back-briefs, and logging deviations. Each subcommand parses --flag "value" style arguments via shlex. If the IntentReceiver mixin is unavailable (e.g., coworkers_shared database unreachable), operations surface the error when invoked. NotAssignedError is caught per-subcommand and returned as a user-friendly refusal message.

  12. Effect Command Handler (src/handlers/intent_command_handler.py) — Routes /pearl effect <subcommand> Slack commands to IntentReceiver.report_effect_evidence(). Provides a dedicated command namespace for recording outcome evidence against intent effects. Accepts numeric or text values; numeric values are attempted first via float() and fall back to text. Shares the same module and error-handling patterns as the Intent Command Handler.

  13. Council Command Handler (src/handlers/intent_command_handler.py) — Routes /pearl council <subcommand> Slack commands to IntentReceiver.propose_council_terms(). Provides a dedicated command namespace for submitting proposals in council conflict-resolution rounds. Validates proposal types against a fixed set (give_up, defer, share, compromise) before forwarding to the mixin. Shares the same module and error-handling patterns as the Intent Command Handler.

  14. Invoice Service (src/services/invoice_service.py) — Pearl originates and tracks invoices on the shared finance_invoices table in the coworkers_shared database (schema from agents-shared migration 006, applied on boot via ensure_schema_current). Pearl writes invoices; she does not write finance_transactions — that ledger is Chisel's (SINGLE_WRITER='chisel'). A paid invoice is the durable revenue event; Chisel's reconciler books the revenue from it. Phase pearl#204 of the invoicing & receivables feature. The invoice lifecycle is a guarded state machine:

    draft ──approve──▶ approved ──send──▶ sent ──▶ paid
      │                  │                  │  └──▶ overdue ──▶ paid
      └──────────────────┴──────────────────┴──▶ void
    

    Every state change goes through _transition() — the single choke point that (a) validates the transition against the allowed-transitions map and (b) enforces the founder-approval gate. No outbound status (approved, sent, paid, overdue) is reachable without an authorized human on record, matching the finance_invoices CHECK constraint (status IN ('draft','void') OR approved_by IS NOT NULL) for defense-in-depth. Money is integer cents through the shared strict parser — never floats.

    Approval authorization is delegated to the ApproverRegistry (from services.content_drafts.approvers) keyed on ContentType.INVOICE. Only Slack users listed in config/content_approvers.yaml with invoice in their can_approve list can approve invoices. Currently, only founders have this permission.

    Capabilities include: - Creating draft invoices with auto-generated invoice numbers (INV-00001, INV-00002, …) - Founder-only approval (the sole path that sets approved_by, unlocking every outbound status) - State transitions: send, mark_paid (with optional paid_transaction_id), mark_overdue, void - Listing invoices (optionally filtered by status) - Receivables aging report via the finance_ar_aging database view (buckets: current, 1-30, 31-60, 61-90, 90+)

  15. Invoice Command Handler (src/handlers/invoice_command_handler.py) — Routes /pearl invoice <subcommand> Slack commands to the Invoice Service. Provides a dedicated command namespace for originating and tracking invoices. The agents_shared.finance module is imported lazily inside each method so the handler stays import-safe against a stale agents-shared wheel (the handler is imported eagerly at bot startup). Depends on the InvoiceService; if the service is unavailable (e.g., CoworkersDB unreachable or PEARL_INVOICE_ENABLED not set), all subcommands return a "not available" message. Ships dark — the command is only registered with the command router when PEARL_INVOICE_ENABLED is set to "true" AND the InvoiceCommandHandler was successfully initialized.

PearlSlackBot and the IntentReceiver mixin

The PearlSlackBot class (src/slack/main.py) — which hosts all of the above components — now inherits from IntentReceiver (provided by agents_shared.coworkers). This wires Pearl into the M6 Commander loop (pearl#95), giving her a subordinate-side interface for the Bungay alignment-gap closure system shared across all Smithy agents. The mixin exposes six methods on PearlSlackBot:

  • list_inbox(status=...) — list intents assigned to Pearl, optionally filtered by status
  • view_intent(intent_id) — view full detail of an intent (row, effects, briefbacks)
  • submit_backbrief(intent_id, understanding, plan, risks) — post a back-brief for an assigned intent
  • log_deviation(briefback_id, original_plan, actual_action, rationale) — log a deviation from a briefed plan
  • report_effect_evidence(intent_id, criterion, actual_value, ...) — report measurement evidence for an intent's effects
  • propose_council_terms(council_id, round_number, proposal_type, proposal_terms) — post a proposal in a council conflict-resolution round

The agent identity is registered as "pearl" (lowercase) at init time. Write methods (submit_backbrief, log_deviation, report_effect_evidence) enforce that Pearl is listed in the intent's assignees before allowing writes, raising NotAssignedError otherwise.

If the coworkers_shared database is unreachable at startup, IntentReceiver.__init__ is caught and logged as a warning — the bot continues to start normally and all other components (analytics, goals, experiments, invoicing, etc.) remain fully functional. Intent-related operations will surface the error when invoked. See Error Handling for details.

After the IntentReceiver mixin is initialized, PearlSlackBot.__init__ calls ensure_schema_current() (also from agents_shared.coworkers) to apply any pending coworkers_shared database migrations on boot (pearl#126). This is the architecture-recurrence guard introduced by agents-shared#25: every agent that uses the coworkers_shared schema calls this function on startup, so new migrations land automatically on the next deploy of any agent. The function has never-raises semantics — migration failures are logged at ERROR level with a full traceback, but the bot continues to boot. An opt-out is available via the COWORKERS_SKIP_MIGRATIONS env var for test environments and ephemeral pods that must not mutate the shared schema. See Configuration and Error Handling for details.

The three M6 Commander command handlers (components 11–13) are instantiated in _init_handlers() with pearl_bot=self and registered with the command router as synchronous handlers (no requires_async). The Invoice Command Handler (component 15) is also instantiated in _init_handlers(), where it is constructed with an InvoiceService backed by CoworkersDB(). If the CoworkersDB or InvoiceService import fails, the handler is set to None and a warning is logged — the bot continues to start normally. The handler is registered with the command router only when PEARL_INVOICE_ENABLED is "true" and the handler was successfully initialized.

Data flow walkthroughs

The following walkthroughs describe how data moves through Pearl Analytics for each major operation. All flows originate from either a scheduled background task (in src/mvp_slack_bot.py) or a manual Slack command (routed by src/handlers/analytics_command_handler.py).

TheSmithy metric collection

  1. Scheduler start — On bot startup, start_slack_bot() creates an asyncio task for _metric_collector_scheduler() (gated by PEARL_METRIC_COLLECTOR_ENABLED). The scheduler sleeps 30 seconds to let initialization complete, then enters an infinite loop polling at the configured interval.
  2. Interval parsing — The scheduler reads PEARL_METRIC_COLLECTOR_INTERVAL via _interval_seconds() (from core/scheduler_utils.py), which parses the env var as an integer number of minutes, falls back to 360 (6 hours) on a missing or malformed value, and floors the result at 60 seconds to prevent busy-looping.
  3. Collection — Each cycle calls pearl.metric_collector.collect() on MetricCollectorService. The service issues a GET request to the TheSmithy agent metrics endpoint (THESMITHY_METRICS_URL) with an X-Agent-Key header (THESMITHY_METRICS_API_KEY).
  4. Metric mapping — The JSON response is split into cumulative fields (waitlistwaitlist_size, beta_usersbeta_activations, preorderspre_orders, cashrevenue_monthly) and top-level fields (library_purchases, library_revenue_cents, paragon_purchases). Each field maps to a Pearl metric name.
  5. Deduplication — For each metric, _is_duplicate() checks whether the latest stored record has the same value and was recorded today. If so, the metric is skipped.
  6. Storage — Non-duplicate values are persisted via RevenueAnalyticsService.record_metric() (PostgreSQL), tagged with recorded_by="pearl-collector" and source="thesmithy_api".
  7. Result — The collect method returns a dict with recorded, skipped, and errors lists. The scheduler logs the counts.

A manual /pearl analytics collect command follows the same path from step 3 onward, bypassing the scheduler.

Matomo web traffic collection

  1. Scheduler start_matomo_collector_scheduler() is created as an asyncio task (gated by PEARL_MATOMO_COLLECTOR_ENABLED). It sleeps 1830 seconds (30 minutes and 30 seconds) as a stagger after the TheSmithy collector to spread API load.
  2. Auth validation — On the first cycle, the scheduler calls pearl.matomo_collector.validate_auth(), which issues a lightweight VisitsSummary.get POST to the Matomo Reporting API. The result sets the internal auth_status to valid, invalid, or leaves it at unchecked on non-auth errors. If auth is invalid, the collector logs a warning and retries on the next cycle — no restart required.
  3. Tier 1 collectioncollect() calls _fetch_visits_summary() (POST to VisitsSummary.get) and maps five response fields to Pearl metrics: nb_uniq_visitorsmau, bounce_ratebounce_rate, nb_visitstotal_visits, avg_time_on_siteavg_session_duration, nb_actions_per_visitactions_per_visit. Values are sanitized (e.g., "65%"65.0) via _to_float().
  4. Tier 3 collection — Three additional API methods are called independently, each wrapped in a try/except so a Tier 3 failure never breaks Tier 1:
    • VisitFrequency.get_extract_visit_frequency() derives new_visitor_ratio from nb_visits_new / total visits.
    • Referrers.getReferrerType_extract_referrer_breakdown() derives referrer_search_pct, referrer_direct_pct, referrer_social_pct from per-referrer-type visit counts.
    • DevicesDetection.getType_extract_device_breakdown() derives device_desktop_pct from device-type visit counts.
  5. Dedup and storage — Same dedup and record_metric() pattern as TheSmithy collection, tagged with recorded_by="pearl-matomo-collector" and source="matomo_api".
  6. Auth failure alerting — If auth_status is invalid after collection, the scheduler posts a warning to PEARL_MATOMO_ALERT_CHANNEL via the notification service, deduplicating alerts to at most once per 24 hours. When auth recovers, a resolution notice is posted.

Validation

  1. Trigger/pearl analytics validate routes to AnalyticsCommandHandler._handle_validate().
  2. Live fetchAnalyticsValidationService.validate_growth_data() calls collector._fetch() to get live data from the TheSmithy agent metrics endpoint (the same endpoint used by collection).
  3. Stored lookup — For each metric in the cumulative mapping, revenue_service.get_latest() retrieves the most recent stored snapshot from PostgreSQL.
  4. Comparison — Each metric is classified:
    • ok — live and stored values match within 1% drift tolerance.
    • drift — values differ by more than 1%.
    • stale — the stored snapshot is older than 24 hours.
    • missing_stored — no stored snapshot exists (suggests running /pearl analytics collect).
    • missing_live — the live API returned no value for this metric.
  5. Dashboard coverage — The report also lists dashboards from the DashboardRegistry as either REACHABLE (integrated) or BLOCKED (not yet integrated).
  6. Response — The handler formats the ValidationReport as a Slack message with per-metric status lines and dashboard coverage.

Dashboard audit

  1. Trigger/pearl analytics audit routes to AnalyticsCommandHandler._handle_audit().
  2. Full auditDashboardAuditService.run_full_audit() runs four audit steps sequentially:
    • Grit Growth — calls validation_service.validate_growth_data() and converts each ValidationResult into an AuditFinding (passed/failed/warning based on metric status).
    • Campaign Performance — checks the agent endpoint's campaigns section for well-formed records with visits and conversion_rate fields. Currently returns blocked if the dashboard registry marks it as not integrated.
    • Conversion Funnel — checks the agent endpoint's funnel section for monotonically non-increasing steps (the hallmark of correct email deduplication). Currently returns blocked if the dashboard registry marks it as not integrated.
    • Matomo Web Analytics — validates Matomo auth via validate_auth(), then runs a live collect() as a data freshness check. If auth fails, data collection checks are skipped.
  3. Reporting — Findings are aggregated into an AuditReport. The handler formats it for Slack and, if actionable findings exist (failed or warning), posts to the #revenue channel via the notification service.

Invoice lifecycle

  1. Trigger/pearl invoice <subcommand> routes to InvoiceCommandHandler.handle_command() via the command router (only when PEARL_INVOICE_ENABLED is "true").
  2. Createcreate parses the counterparty and amount (via parse_amount_to_cents() from agents_shared.finance), then calls InvoiceService.create(). The service validates the Invoice model (positive integer cents, due_on >= issued_on), auto-generates an invoice number (INV-NNNNN), and inserts a draft row into finance_invoices. Default payment terms are net-30.
  3. Approveapprove calls InvoiceService.approve(invoice_id, approver_slack_id). The service checks the ApproverRegistry (keyed on ContentType.INVOICE) to verify the Slack user is authorized — only founders listed in config/content_approvers.yaml with invoice in their can_approve list may approve. On success, approved_by is set and the status moves to approved. This is the only path that sets approved_by, and thus the only way to unlock outbound statuses.
  4. Send / Paid / Overdue / Void — Each calls InvoiceService._transition(), which validates the transition against _ALLOWED_TRANSITIONS and updates the row. mark_paid optionally records a paid_transaction_id linking to Chisel's ledger.
  5. Listlist [status] calls InvoiceService.list_invoices() with an optional status filter and returns a formatted Slack message.
  6. AR agingar calls InvoiceService.ar_aging(), which queries the finance_ar_aging database view (sent/overdue invoices bucketed by days overdue) and returns a formatted receivables aging report.
flowchart TD
    subgraph Schedulers ["Background Schedulers (mvp_slack_bot.py)"]
        S1["_metric_collector_scheduler\n30s delay → 6h loop"]
        S2["_matomo_collector_scheduler\n30min stagger → 6h loop"]
    end

    subgraph Commands ["Slash Commands"]
        C1["/pearl analytics collect"]
        C2["/pearl analytics validate"]
        C3["/pearl analytics audit"]
        C4["/pearl invoice create/approve/send/paid/void/list/ar"]
    end

    subgraph Services
        MCS["MetricCollectorService\n.collect()"]
        MACS["MatomoCollectorService\n.collect()"]
        AVS["AnalyticsValidationService\n.validate_growth_data()"]
        DAS["DashboardAuditService\n.run_full_audit()"]
        IS["InvoiceService\n.create() / .approve() / …"]
    end

    subgraph External ["External APIs"]
        TS["TheSmithy\n/api/metrics/agent/"]
        MA["Matomo\nReporting API"]
    end

    RAS["RevenueAnalyticsService\n.record_metric()\nPostgreSQL"]
    CDB["CoworkersDB\nfinance_invoices\nPostgreSQL"]

    S1 --> MCS
    C1 --> MCS
    MCS --> TS
    MCS --> RAS

    S2 --> MACS
    MACS --> MA
    MACS --> RAS

    C2 --> AVS
    AVS --> TS
    AVS --> RAS

    C3 --> DAS
    DAS --> AVS
    DAS --> MACS
    DAS -->|"posts findings"| NS["NotificationService\n→ #revenue"]

    C4 --> IS
    IS --> CDB

Usage

All analytics commands are invoked via the /pearl analytics Slack slash command. Goals management commands use the /pearl goals command. Experiment commands use the /pearl experiment command. M6 Commander commands use /pearl intent, /pearl effect, and /pearl council. Invoice commands use /pearl invoice:

Analytics Commands

Command Description
/pearl analytics audit Run a full dashboard audit (Grit Growth + Matomo) and post the report to #revenue
/pearl analytics validate Validate live growth data against stored snapshots
/pearl analytics collect Manually trigger metric snapshot collection
/pearl analytics dashboards List all known dashboards with status, URL, auth type, and metrics
/pearl analytics overlap Show which Pearl metrics are covered by which dashboards
/pearl analytics index-dashboards Index dashboard documentation and data flow into Weaviate
/pearl analytics help Show available analytics subcommands

Goals Commands

Command Description
/pearl goals list List all Matomo conversion goals
/pearl goals create <name> <url_pattern> Create a new goal (last argument is the URL pattern, all preceding arguments form the name)
/pearl goals delete <goal_id> Delete a goal by its Matomo ID
/pearl goals sync Sync goals from Matomo into Pearl's local database
/pearl goals conversions Collect current month conversion data for all goals
/pearl goals help Show available goals subcommands

Experiment Commands

Command Description
/pearl experiment list List all experiments
/pearl experiment create <metric> <name> Create a draft experiment (first argument is the Matomo metric name, remaining arguments form the experiment name)
/pearl experiment variant <id> <name> <segment> [--control] Add a variant with a Matomo segment to an experiment (use --control to designate the control group)
/pearl experiment start <id> Start a draft experiment (begins metric collection)
/pearl experiment collect <id> Manually collect metrics for all variants in an experiment
/pearl experiment results <id> Show results with statistical significance analysis
/pearl experiment view <id> Show experiment details, variants, measurements, and significance
/pearl experiment stop <id> Mark a running experiment as completed
/pearl experiment help Show available experiment subcommands

Intent Commands

Command Description
/pearl intent inbox [--status <status>] List intents assigned to Pearl, optionally filtered by status (pending, confirmed, in-execution)
/pearl intent view <id> Show full detail of an intent (title, tier, status, why, assignees, effects, back-briefs, parent)
/pearl intent backbrief <id> --understanding "..." --plan "..." [--risks "..."] Submit a back-brief on an assigned intent
/pearl intent deviate <briefback_id> --original-plan "..." --actual-action "..." --rationale "..." Log a deviation from a briefed plan
/pearl intent help Show available intent subcommands

Effect Commands

Command Description
/pearl effect record <intent_id> <criterion> <value> [--notes "..."] Record measurement evidence for an intent's effect criterion (numeric or text value)
/pearl effect help Show available effect subcommands

Council Commands

Command Description
/pearl council propose <council_id> <type> "<terms>" [--round N] Submit a proposal in a council conflict-resolution round (type: give_up, defer, share, or compromise; round defaults to 1)
/pearl council help Show available council subcommands

Invoice Commands

Command Description
/pearl invoice create <counterparty> <amount> [notes] Draft an invoice (net-30 payment terms), e.g. create @northwind 1200.00 monthly retainer
/pearl invoice approve <id> Founder-only approval (unlocks send)
/pearl invoice send <id> Mark an approved invoice as sent
/pearl invoice paid <id> [txn_id] Mark a sent or overdue invoice as paid (optional transaction ID links to Chisel's ledger)
/pearl invoice overdue <id> Flag a sent invoice as overdue
/pearl invoice void <id> Cancel an invoice (allowed from any non-terminal status)
/pearl invoice list [status] List recent invoices, optionally filtered by status (draft, approved, sent, paid, overdue, void)
/pearl invoice ar Show receivables aging report (buckets: current, 1-30, 31-60, 61-90, 90+ days)
/pearl invoice help Show available invoice subcommands

Example: Seeding initial baselines

When Pearl has no stored snapshots yet, validation will report all metrics as missing_stored and suggest:

Run `/pearl analytics collect` to seed initial baselines.

After collecting, /pearl analytics validate will compare live values against the stored snapshots.

Example: Validation output

*Analytics Validation* — 3/4 OK
  1 drift

  [OK] *waitlist_size* — live: 45, stored: 45 (0.0% drift)
  [DRIFT] *beta_activations* — live: 12, stored: 10 (20.0% drift)
  [OK] *pre_orders* — live: 3, stored: 3 (0.0% drift)
  [OK] *revenue_monthly* — live: 500, stored: 500 (0.0% drift)

*Dashboard Coverage*
  REACHABLE  Grit Growth
  REACHABLE  Matomo Web Analytics
  BLOCKED  Virginia Pipeline
  BLOCKED  Campaign Performance
  BLOCKED  Conversion Funnel

Example: Creating a conversion goal

/pearl goals create Library Catalog View /library/

Response:

Created goal #5: *Library Catalog View* (pattern: `/library/`)

Example: Running an A/B experiment

Create the experiment, add variants, then start it:

/pearl experiment create nb_visits TikTok vs LinkedIn Chapter 1

Response:

Created experiment #1: *TikTok vs LinkedIn Chapter 1*
  Metric: `nb_visits` | Status: draft
Add variants with `/pearl experiment variant 1 <name> <matomo_segment>`
Then start with `/pearl experiment start 1`

Add a control variant and a test variant:

/pearl experiment variant 1 LinkedIn utm_source==linkedin --control
/pearl experiment variant 1 TikTok utm_source==tiktok

Start the experiment:

/pearl experiment start 1

After collecting data, view results with significance analysis:

/pearl experiment results 1

Response:

*Experiment #1 Results*

  Control (*LinkedIn*): 320 (n=1500)
  *TikTok*: lift +12.5%, p=0.0312 (significant)

Example: Viewing the intent inbox and submitting a back-brief

Check which intents are assigned to Pearl:

/pearl intent inbox --status issued

Response:

:mailbox_with_mail: *Intent inbox* (1 intent(s))
• #8 _strategic_ *Secure first paying household* — `issued`

Use `/pearl intent view <id>` for full detail, `/pearl intent backbrief <id>` to submit a back-brief.

View details of a specific intent:

/pearl intent view 8

Response:

:scroll: *Intent #8*
*Title:* Secure first paying household
*Tier:* strategic
*Status:* `issued`
*Why:* north star
*Assignees:* pearl, arturo

*Effects (1):*
  • `paying_households` → 1

Submit a back-brief:

/pearl intent backbrief 8 --understanding "I will execute the first-household conversion strategy" --plan "Step 1: identify top prospects, Step 2: run targeted campaign" --risks "delay\nbudget"

Response:

:white_check_mark: *Back-brief submitted* on intent `#8` (briefback #42).
Taryn will review and either confirm or request clarification.

Example: Recording effect evidence

/pearl effect record 8 paying_households 3 --notes "first cohort"

Response:

:bar_chart: *Effect recorded* on intent `#8` criterion `paying_households` = 3.0.

Example: Submitting a council proposal

/pearl council propose 5 compromise "split the budget 60/40" --round 2

Response:

:handshake: *Proposal submitted* to council `#5` (round 2, type `compromise`).

Example: Creating and sending an invoice

Draft a net-30 invoice:

/pearl invoice create @northwind 1200.00 monthly retainer

Response:

🧾 Drafted *INV-00001* (#1): $1200.00 to @northwind, due 2026-08-13.
_Founder approval required before sending: `/pearl invoice approve 1`._

A founder approves the invoice:

/pearl invoice approve 1

Response:

✅ Approved *INV-00001* (#1) — ready to send with `/pearl invoice send 1`.

Send the invoice:

/pearl invoice send 1

Response:

📮 *INV-00001* (#1) sent.

Mark it paid (with optional transaction ID linking to Chisel's ledger):

/pearl invoice paid 1 42

Response:

💰 *INV-00001* (#1) marked paid ($1200.00).

Example: Receivables aging report

/pearl invoice ar

Response:

*Receivables aging*

  current: 2 invoice(s), $200.00
  90+: 1 invoice(s), $50.00

*Total outstanding: $250.00*

Configuration

Pearl Analytics requires the following environment variables:

Variable Purpose Required for
THESMITHY_METRICS_URL URL of TheSmithy's agent metrics endpoint Metric collection and validation
THESMITHY_METRICS_API_KEY API key for the agent metrics endpoint Metric collection and validation
MATOMO_URL Matomo instance URL Matomo metric collection, goals, experiments, audit
MATOMO_TOKEN_AUTH Matomo API authentication token (must be non-empty) Matomo metric collection, goals, experiments, audit
MATOMO_SITE_ID Matomo site ID (default: 1) Matomo metric collection, goals, experiments
PEARL_MATOMO_GOALS_ENABLED Enable Matomo goals service (true or 1) /pearl goals commands
PEARL_EXPERIMENTS_ENABLED Enable experiment service (true or 1) /pearl experiment commands
PEARL_INVOICE_ENABLED Enable invoice service (true or 1; ships dark, default false) /pearl invoice commands
PEARL_REVENUE_CHANNEL_ID Slack channel ID for audit reports Dashboard audit reporting
COWORKERS_PG_HOST Hostname for the coworkers_shared database (default: POSTGRES_HOST or postgres) Intent inbox (M6 Commander loop), invoicing
COWORKERS_PG_PORT Port for the coworkers_shared database (default: POSTGRES_PORT or 5432) Intent inbox (M6 Commander loop), invoicing
COWORKERS_PG_USER Database user (default: coworkers) Intent inbox (M6 Commander loop), invoicing
COWORKERS_PG_PASSWORD Database password (required for non-localhost) Intent inbox (M6 Commander loop), invoicing
COWORKERS_PG_DB Database name (default: coworkers_shared) Intent inbox (M6 Commander loop), invoicing
COWORKERS_SKIP_MIGRATIONS Opt out of boot-time schema migrations (true, 1, or yes) Boot-time migration runner (pearl#126)

If THESMITHY_METRICS_URL or THESMITHY_METRICS_API_KEY are not set, the metric collector, validation service, and audit service will all be unavailable and commands will return a "not configured" message.

If MATOMO_TOKEN_AUTH is set but empty or contains only whitespace, the Matomo collector is disabled at startup and a warning is logged. When a valid token is provided, Pearl validates it on the first scheduler run via validate_auth() — if the token is rejected by the Matomo API (HTTP 401/403 or a token_auth-related API error), the collector's auth_status is set to invalid and collection is skipped for that cycle. On the next collection cycle the collector automatically retries auth validation, so recovery does not require a restart.

The Matomo Goals Service requires MATOMO_URL, MATOMO_TOKEN_AUTH, and PEARL_MATOMO_GOALS_ENABLED to be set. If any of these are missing or PEARL_MATOMO_GOALS_ENABLED is not true/1, the goals service is not initialized and /pearl goals commands will return a "not available" message.

The Experiment Service requires MATOMO_URL, MATOMO_TOKEN_AUTH, and PEARL_EXPERIMENTS_ENABLED to be set. If any of these are missing or PEARL_EXPERIMENTS_ENABLED is not true/1, the experiment service is not initialized and /pearl experiment commands will return a "not available" message.

The Invoice Service requires PEARL_INVOICE_ENABLED to be set to true and a reachable coworkers_shared database (via the COWORKERS_PG_* variables). The service reads and writes the finance_invoices table (schema from agents-shared migration 006, applied on boot via ensure_schema_current). If PEARL_INVOICE_ENABLED is not true, the /pearl invoice command is not registered with the command router. If CoworkersDB is unreachable during handler initialization, the handler is set to None and a warning is logged — all other Pearl features remain functional. Approval authorization is read from config/content_approvers.yaml; only users with invoice in their can_approve list may approve invoices.

The COWORKERS_PG_* variables are read lazily by CoworkersDB (from agents_shared.coworkers) on first use. If these are not set or the database is unreachable, IntentReceiver initialization degrades gracefully — the bot starts normally and all analytics features remain available, but intent-inbox and invoice operations will be unavailable until the database becomes reachable.

The COWORKERS_SKIP_MIGRATIONS variable controls the boot-time migration runner (ensure_schema_current()). When set to true, 1, or yes, the function logs and returns without touching the database. This is intended for test environments and ephemeral pods that must not mutate the shared schema. When unset or set to any other value, migrations are applied normally on boot.

Deployment

Pearl runs as a single-replica Kubernetes Deployment in the ai-coworkers namespace with a Recreate strategy (ensures clean shutdown/startup and personality consistency). The pod has init containers that copy Claude OAuth credentials and wait for PostgreSQL and Redis readiness before the main container starts.

Container image — Built from Dockerfile, which layers app code on top of a pearl-base image (carrying Node.js, Claude Code CLI, glab, and Playwright Chromium). The entry point is python src/mvp_slack_bot.py. All non-secret config is injected via the pearl-config ConfigMap (kubernetes/configmap.yaml); secrets come from pearl-secrets.

CI/CD auto-deploy — Merges to main trigger the GitLab CI deploy stage, which posts to the CI deploy webhook receiver. The receiver runs kubectl rollout restart deployment/pearl -n ai-coworkers. CI pipelines only run for trigger events (agent self-maintenance), scheduled pipelines, or explicit opt-in (git push -o ci.variable=RUN_FULL_CI=true); routine pushes skip cloud CI and rely on the local pre-push hook.

Local developmentdocker-compose.yml provides Pearl alongside Redis, PostgreSQL, and Neo4j containers. Configuration is read from a .env file.

Maintenance commands:

# View logs
kubectl logs -f -n ai-coworkers deployment/pearl

# Restart after config change
kubectl apply -f kubernetes/configmap.yaml
kubectl rollout restart deployment/pearl -n ai-coworkers

# Emergency stop / resume
kubectl scale deployment/pearl --replicas=0 -n ai-coworkers
kubectl scale deployment/pearl --replicas=1 -n ai-coworkers

Scheduling configuration

All background schedulers live in src/mvp_slack_bot.py and are launched as asyncio tasks in start_slack_bot(). Each scheduler is independently gated by an _ENABLED env var and reads its poll interval via _interval_seconds() (from core/scheduler_utils.py), which parses the env var as an integer number of minutes, falls back to the coded default on missing or malformed values, and floors the result at 60 seconds to prevent busy-looping.

Schedulers use staggered initial delays after pod boot to spread API load and avoid thundering-herd effects on startup.

Scheduler Env var (enabled) Interval env var Default (min) Boot stagger Purpose
TheSmithy metric collector PEARL_METRIC_COLLECTOR_ENABLED PEARL_METRIC_COLLECTOR_INTERVAL 360 (6h) 30s Polls TheSmithy agent metrics
Matomo collector PEARL_MATOMO_COLLECTOR_ENABLED PEARL_MATOMO_COLLECTOR_INTERVAL 360 (6h) 30 min 30s Polls Matomo Reporting API
Matomo goals collector PEARL_MATOMO_GOALS_ENABLED PEARL_MATOMO_COLLECTOR_INTERVAL 360 (6h) 40 min Collects goal conversion data
Experiment collector PEARL_EXPERIMENTS_ENABLED PEARL_MATOMO_COLLECTOR_INTERVAL 360 (6h) 45 min Collects A/B experiment metrics
LinkedIn collector PEARL_LINKEDIN_COLLECTOR_ENABLED PEARL_LINKEDIN_COLLECTOR_INTERVAL 1440 (24h) 60 min 30s Polls LinkedIn engagement metrics
TikTok collector PEARL_TIKTOK_COLLECTOR_ENABLED PEARL_TIKTOK_COLLECTOR_INTERVAL 1440 (24h) 90 min 30s Polls TikTok creator analytics
Self-maintenance PEARL_SELF_MAINTENANCE_ENABLED PEARL_SELF_MAINTENANCE_INTERVAL_MINUTES 1440 (24h) 2h Autonomous fix MRs on Pearl's repo
Intent adapter PEARL_INTENT_ADAPTER_ENABLED PEARL_INTENT_ADAPTER_INTERVAL 30 15 min M6 Commander intent decomposition
Auto-backbrief PEARL_AUTO_BACKBRIEF_ENABLED PEARL_AUTO_BACKBRIEF_INTERVAL 15 30 min Auto-draft back-briefs for Taryn
Effect evidence reporter PEARL_EFFECT_REPORTER_ENABLED PEARL_EFFECT_REPORTER_INTERVAL 360 (6h) 30 min Reports outcome evidence to intents
OAuth health PEARL_OAUTH_HEALTH_ENABLED PEARL_OAUTH_HEALTH_INTERVAL 60 (1h) 3 min Checks Agent SDK token health
MR reminders PEARL_MR_REMINDERS_ENABLED PEARL_MR_REMINDERS_INTERVAL 240 (4h) 45 min Nudges stale MR reviewers
Morning briefing PEARL_MORNING_BRIEFING_ENABLED PEARL_MORNING_BRIEFING_INTERVAL 60 (1h) 55 min Daily 9 AM PST revenue digest
Auto-rollout PEARL_EXPERIMENT_AUTO_ROLLOUT_ENABLED PEARL_EXPERIMENT_AUTO_ROLLOUT_INTERVAL 60 (1h) 75 min Experiment winner promotion

The Matomo goals collector, experiment collector, and auto-rollout scheduler share PEARL_MATOMO_COLLECTOR_INTERVAL for their loop interval (they are downstream of Matomo data and run at the same cadence). The staggered boot delays ensure they do not all hit the Matomo API simultaneously.

To override any interval, set the corresponding env var in kubernetes/configmap.yaml (value is in minutes) and restart the pod:

# Example: collect TheSmithy metrics every 2 hours instead of 6
kubectl edit configmap pearl-config -n ai-coworkers
# Set PEARL_METRIC_COLLECTOR_INTERVAL: "120"
kubectl rollout restart deployment/pearl -n ai-coworkers

Error Handling

  • Service not configured: Each subcommand checks for its required service and returns a clear message if unavailable (e.g., "Metric collector is not configured — check THESMITHY_METRICS_URL and THESMITHY_METRICS_API_KEY.").
  • Goals service not available: If the MatomoGoalsService is not initialized (missing MATOMO_URL, MATOMO_TOKEN_AUTH, or PEARL_MATOMO_GOALS_ENABLED), all /pearl goals subcommands return: "Goals service is not available — check MATOMO_URL, MATOMO_TOKEN_AUTH, and PEARL_MATOMO_GOALS_ENABLED."
  • Experiment service not available: If the ExperimentService is not initialized (missing MATOMO_URL, MATOMO_TOKEN_AUTH, or PEARL_EXPERIMENTS_ENABLED), all /pearl experiment subcommands return: "Experiment service is not available — check MATOMO_URL, MATOMO_TOKEN_AUTH, and PEARL_EXPERIMENTS_ENABLED."
  • Invoice service not available: If the InvoiceService is not initialized (e.g., PEARL_INVOICE_ENABLED not set to true, or CoworkersDB unreachable), all /pearl invoice subcommands return: "Invoice service is not available." When the feature is disabled, the /pearl invoice command is not registered with the command router at all.
  • Invoice handler init failure: If CoworkersDB or InvoiceService raises an exception during _init_handlers(), the error is caught and logged as a warning. The bot continues to start — all other features remain fully functional. The invoice handler is set to None and the command is not registered.
  • Unauthorized invoice approval: InvoiceService.approve() checks the ApproverRegistry (keyed on ContentType.INVOICE). If the Slack user is not in config/content_approvers.yaml with invoice in their can_approve list, UnauthorizedApprover is raised. The handler catches this and returns a user-friendly message (e.g., "🔒 U2 is not authorized to approve invoices.").
  • Illegal invoice transition: InvoiceService._transition() validates every state change against _ALLOWED_TRANSITIONS. Invalid transitions (e.g., sending a draft without approval, voiding a paid invoice) raise IllegalTransition, which is surfaced as a clean Slack error.
  • Invoice not found: InvoiceService._transition() raises InvoiceNotFound if the given invoice ID does not exist in the database.
  • Invoice bad input: The handler's _BadInput exception covers malformed arguments (bad invoice ID, invalid amount, unknown status filter, too few arguments). These are returned directly to Slack as user-facing messages.
  • Invoice unexpected errors: Any unhandled exception in a /pearl invoice subcommand is caught by the top-level handler, logged at ERROR level with a full traceback, and returned as "❌ Invoice error: …". These errors do not affect other Pearl services.
  • IntentReceiver init failure: If CoworkersDB raises an exception during PearlSlackBot.__init__ (e.g., coworkers_shared database unreachable), the error is caught and logged as a warning. The bot continues to start — all analytics, goals, experiment, and invoice features remain fully functional. Intent-inbox operations (list_inbox, submit_backbrief, etc.) will surface the error when invoked.
  • Boot-time migration failure: ensure_schema_current() (pearl#126) is called during PearlSlackBot.__init__ after IntentReceiver initialization. It has never-raises semantics: if migration application fails (e.g., database unreachable, SQL error), the error is logged at ERROR level with a full traceback, but the bot continues to boot normally. Intent-loop paths may fail at query time if the schema is not current. Migrations that succeeded before a mid-sequence failure are committed and retained — only the return value is lost. Set COWORKERS_SKIP_MIGRATIONS=true to opt out entirely (see Configuration).
  • Intent command errors: Each /pearl intent subcommand catches exceptions from the IntentReceiver mixin and returns a descriptive Slack-formatted error (e.g., "⚠ Inbox unavailable: …", "⚠ View failed: …", "⚠ Back-brief failed: …"). These errors do not affect other Pearl services.
  • Effect command errors: The /pearl effect record subcommand catches exceptions from report_effect_evidence() and returns a descriptive error (e.g., "⚠ Evidence record failed: …"). These errors do not affect other Pearl services.
  • Council command errors: The /pearl council propose subcommand catches exceptions from propose_council_terms() and returns a descriptive error (e.g., "⚠ Council proposal failed: …"). Invalid proposal types (not one of give_up, defer, share, compromise) are rejected before calling the mixin.
  • Intent authority errors: Write methods (submit_backbrief, log_deviation, report_effect_evidence) raise NotAssignedError if Pearl is not in the intent's assignees list. This is a PermissionError subclass. The intent, effect, and council command handlers catch NotAssignedError and return a user-friendly refusal message (e.g., "⛔ Pearl is not in the assignees of intent #8 — back-brief refused.").
  • Experiment API errors: Each experiment subcommand catches exceptions from the ExperimentService and returns a descriptive error message (e.g., "Failed to create experiment: Connection refused", "Failed to collect metrics for experiment #1: ..."). These errors do not affect other Pearl services.
  • Goals API errors: Each goals subcommand catches exceptions from the MatomoGoalsService and returns a descriptive error message (e.g., "Failed to list goals: Connection refused"). These errors do not affect other Pearl services.
  • Matomo authentication errors: The Matomo collector distinguishes auth failures from other errors using the MatomoAuthError exception. HTTP 401 (invalid/expired token) and 403 (insufficient permissions) responses, as well as Matomo API-level token_auth errors, raise MatomoAuthError. When a MatomoAuthError is caught, the collector's auth_status is set to invalid and collection is skipped for that cycle. On the next collection cycle, the collector automatically retries auth validation — a restart is no longer required for recovery. Non-auth errors (network timeouts, other API errors) do not disable future collection attempts. The MatomoGoalsService and ExperimentService also raise MatomoAuthError on 401/403 responses from the Matomo API.
  • Tier 3 collector failures: Errors in Tier 3 collectors (VisitFrequency.get, Referrers.getReferrerType, DevicesDetection.getType) are logged as warnings but do not prevent Tier 1 metrics from being collected. Each Tier 3 collector is isolated — a failure in one does not affect the others.
  • Live fetch failure: If the live API call fails during validation, all metrics are marked missing_live and the error is included in the report.
  • Collection errors: The collect command reports per-metric results as recorded, skipped (duplicate), or errors.
  • Matomo audit failures: During a dashboard audit, if the Matomo collector is not configured, the audit returns a blocked finding. If auth validation fails (invalid token or network error), the audit returns a failed finding with critical severity and skips data freshness checks. Collection errors during the audit produce a warning-level finding with a recommendation to check the Matomo API.
  • Unknown subcommands: Returns an error message with a hint to run /pearl analytics help, /pearl goals help, /pearl experiment help, /pearl intent help, /pearl effect help, /pearl council help, or /pearl invoice help.

Prerequisites

  • Redis + PostgreSQL — Required by MemoryManager and RevenueAnalyticsService for metric storage. Also required by MatomoGoalsService and ExperimentService for goal and experiment data.
  • coworkers_shared PostgreSQL database — Required by the IntentReceiver mixin (via agents_shared.coworkers.CoworkersDB) for the M6 Commander intent inbox, and by the InvoiceService for the finance_invoices table and finance_ar_aging view. Connection is configured via COWORKERS_PG_* env vars. If unavailable at startup, the bot degrades gracefully — analytics features are unaffected. On boot, ensure_schema_current() applies any pending migrations from agents-shared to this database (pearl#126), including migration 006 which creates the finance_invoices table. Migration failures are non-fatal — see Error Handling.
  • Weaviate — Required for the index-dashboards subcommand (vector memory).
  • Network access — Pearl must be able to reach the TheSmithy metrics endpoint and (optionally) the Matomo instance. The Matomo collector now calls four API methods (VisitsSummary.get, VisitFrequency.get, Referrers.getReferrerType, DevicesDetection.getType). The dashboard audit service also requires Matomo network access to perform live auth validation and data freshness checks.
  • agents-shared package — Must include the coworkers module (added in agents-shared !25) and the finance module (providing Invoice, InvoiceStatus, parse_amount_to_cents, format_cents). The CI pipeline installs agents-shared with --force-reinstall --no-deps to ensure new modules are picked up even when the package is cached in the CI venv. The package also provides ensure_schema_current() and the migration files applied on boot.

Testing

Pearl Analytics tests use pytest with pytest-asyncio for async service methods. Services are isolated using unittest.mock.AsyncMock (for async methods like collect() and validate_auth()) and MagicMock (for sync dependencies like revenue_service). External APIs are never called in tests — all HTTP calls are patched at the service boundary.

Test files and coverage:

Test file Covers Key scenarios
tests/test_metric_collector_service.py MetricCollectorService Successful collection of all 7 metrics, dedup skipping, fetch failures, per-metric error handling, correct source/recorded_by tagging
tests/test_matomo_collector_service.py MatomoCollectorService Tier 1 collection (5 metrics), Tier 3 isolation (failures don't break Tier 1), auth validation, MatomoAuthError handling, bounce_rate string parsing, dedup
tests/test_analytics_validation_service.py AnalyticsValidationService All-ok validation, drift detection, staleness detection, missing_stored/missing_live states, dashboard coverage reporting
tests/test_dashboard_audit_service.py DashboardAuditService Grit Growth audit (maps validation results to findings), Matomo audit (auth + data freshness), blocked dashboard findings, Slack report formatting, channel posting
tests/test_analytics_command_handler.py AnalyticsCommandHandler Subcommand routing, not-configured messages, help output, unknown subcommand error, formatted report output
tests/test_scheduler_functions.py Background schedulers Initial delays, interval defaults, custom intervals from env vars, loop termination via CancelledError
tests/test_invoice_service.py InvoiceService Draft creation, sequential invoice numbering, negative amount rejection, due-before-issued rejection, founder-approval gate (authorized/unauthorized/empty approver), state machine transitions (full lifecycle, overdue→paid, void from any non-terminal, terminal state enforcement), missing invoice handling, list/status-filter reads, AR aging bucket ordering, Postgres round-trip (integration, skipped in CI)
tests/test_invoice_command_handler.py InvoiceCommandHandler Subcommand routing, create with notes/bad amount/negative amount/too few args, approve with user_id passthrough/unauthorized message, send/paid/void transitions, list/status-filter/bad-status, AR aging/empty aging, service-unavailable degradation

Running tests locally:

# Run all analytics-related tests
pytest tests/test_metric_collector_service.py \
       tests/test_matomo_collector_service.py \
       tests/test_analytics_validation_service.py \
       tests/test_dashboard_audit_service.py \
       tests/test_analytics_command_handler.py \
       tests/test_scheduler_functions.py -v

# Run invoice tests
pytest tests/test_invoice_service.py \
       tests/test_invoice_command_handler.py -v

# Run with coverage
pytest tests/ -v --cov=src --cov-report=term

CI pipeline — The test stage runs pytest tests/ -v --cov=src with markers excluding integration tests (tests marked requires_redis, requires_postgres, requires_slack, etc. are skipped). The lint stage checks formatting with black --check src/ tests/.

Writing new tests — Follow the existing patterns:

  • Create fixtures for revenue_service (MagicMock with record_metric returning True and get_latest returning None) and the service under test.
  • Patch external calls (e.g., _fetch, _fetch_visits_summary, _fetch_matomo_api) with AsyncMock at the service object level.
  • For scheduler tests, use the make_sleep_breaker fixture pattern: a factory that returns a fake asyncio.sleep which raises CancelledError after N calls, breaking out of the infinite scheduler loop predictably.
  • For invoice tests, use the in-memory FakeDB and FakeApprovers doubles (see test_invoice_service.py) to exercise the state machine and approval gate without Postgres. The FakeDB simulates cursor operations for INSERT, UPDATE, SELECT, and the AR aging view.

Troubleshooting

Metric collector reports all errors:

  • Verify THESMITHY_METRICS_URL points to the correct endpoint (https://glassumbrella.io/grit/api/metrics/agent/ — note the /grit/ path prefix, not the grit. subdomain).
  • Check that THESMITHY_METRICS_API_KEY is set in pearl-secrets and matches the server-side agent key.
  • Look for Failed to fetch metrics from TheSmithy in pod logs (kubectl logs -n ai-coworkers deployment/pearl | grep "Failed to fetch").

Matomo auth failing:

  • Check pod logs for Matomo auth FAILED or the :warning: Matomo auth failed Slack alert in the channel configured by PEARL_MATOMO_ALERT_CHANNEL.
  • Verify MATOMO_TOKEN_AUTH is non-empty and valid — the token can be regenerated in the Matomo admin UI under Personal > Security > Auth tokens.
  • The collector retries auth validation on every collection cycle automatically. A restart is not required for recovery — once the token is fixed, the next cycle will succeed and post a :white_check_mark: Matomo auth recovered notice.
  • If MATOMO_TOKEN_AUTH is set to an empty or whitespace-only string, the collector is disabled at startup (check logs for MATOMO_TOKEN_AUTH is set but empty/whitespace).

Validation shows all metrics as missing_stored:

  • This means Pearl has no stored snapshots yet. Run /pearl analytics collect to seed initial baselines, then re-run /pearl analytics validate.

Validation shows all metrics as missing_live:

  • The live API call to TheSmithy failed. Check the Fetch error: line in the validation output and verify TheSmithy endpoint reachability and API key.

Scheduler not running:

  • Confirm the _ENABLED env var is set to true in kubernetes/configmap.yaml (e.g., PEARL_METRIC_COLLECTOR_ENABLED: "true").
  • Check pod logs for scheduler started messages — each scheduler logs on launch.
  • If the interval env var has a non-integer value (e.g., =5m), _interval_seconds() logs a warning and falls back to the default. Search logs for is not an integer; falling back.

Audit posts nothing to #revenue:

  • The audit only posts to the channel when actionable findings exist (at least one failed or warning status). If all checks pass, no message is sent.
  • Verify PEARL_REVENUE_CHANNEL_ID is set to a valid Slack channel ID in the configmap.

Tier 3 Matomo metrics missing but Tier 1 works:

  • Tier 3 failures are logged as warnings (Tier 3 collector <method> failed (non-fatal)) and do not affect Tier 1 collection. Check logs for the specific Matomo API method that failed.
  • The Matomo instance may not have the required plugins enabled (VisitFrequency, Referrers, DevicesDetection).

Invoice commands not available:

  • Verify PEARL_INVOICE_ENABLED is set to "true" in kubernetes/configmap.yaml. The feature ships dark (default "false"). After setting it, apply the configmap and restart the pod:
    kubectl apply -f kubernetes/configmap.yaml
    kubectl rollout restart deployment/pearl -n ai-coworkers
    
  • Check pod logs for Invoice handler initialized (success) or InvoiceCommandHandler unavailable (failure, typically a CoworkersDB connection issue).
  • If the handler initialized but the command is not registered, check for Invoice command disabled (PEARL_INVOICE_ENABLED != true) in logs.

Invoice approval rejected:

  • Only Slack users listed in config/content_approvers.yaml with invoice in their can_approve list may approve invoices. Currently only founders have this permission.
  • The handler returns "🔒 \<user> is not authorized to approve invoices." when the approver check fails.

Invoice state transition errors:

  • The service enforces a strict state machine. Common illegal transitions: sending a draft (must approve first), voiding a paid invoice (terminal state). The error message includes the current status and target status.

Feature Repositories

  • pearl → project_id: the-smithy1/agents/pearl

Code Paths to Explore

  • tests/test_analytics_command_handler.py in pearl
  • src/slack/main.py in pearl
  • src/services/analytics_validation_service.py in pearl
  • src/handlers/analytics_command_handler.py in pearl
  • src/services/invoice_service.py in pearl
  • src/handlers/invoice_command_handler.py in pearl
  • config/content_approvers.yaml in pearl