> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tryrekkal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Analytics pipeline

# Analytics pipeline

How rekkal turns a monitored prompt into measured brand visibility: the engine adapters, the scheduled runner, mention extraction, metering, and the statistics layer.

The database side is in [supabase.md](./supabase.md); the design record and its rationale is [data-model-proposal.md](./data-model-proposal.md).

## The shape of it

```
Vercel Cron ──► app/api/cron/run-prompts   (CRON_SECRET, service-role only)
  (hourly)           │                      THE ONLY TRIGGER, AND IT SPENDS
                     ▼
              lib/runner/run.ts  runWorkspace()
                     │            recovery + sweep, then dispatch
   ┌─────────────────┼──────────────────────────────┐
   │                 │                              │
slotDateFor      claim_run_slot()              EngineAdapter.run()
(the slot,       (atomic: meter moves          (Perplexity | OpenAI |
 not the clock)   only on a real insert)        Gemini | fixture)
                     │                          4 in flight per engine
                     ▼                              ▼
            extractMentions()               toCitationRows()
            (alias index, UTF-16            (resolve redirects,
             offsets, sentence spans)        never poison the corpus)
                     │                              │
                     └──────────┬───────────────────┘
                                ▼
                   refresh_daily_rollups()  (sums and counts, never ratios)
```

## Engine adapters

One interface, four implementations — `lib/engines/types.ts`:

```ts theme={null}
interface EngineAdapter {
  readonly id: EngineId;
  readonly requiresApiKey: boolean;
  isConfigured(): boolean;
  run(request: EngineRequest): Promise<EngineResult>;
}
```

A fifth engine is a new file, one entry in `lib/engines/index.ts`, and one row in `public.engines`. The runner does not change.

| Engine              | Notes                                                                                                                                    |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `perplexity-sonar`  | Closest API-to-UI parity and the cheapest. The only engine on the free tier.                                                             |
| `openai-web-search` | Responses API + hosted `web_search`. The only engine that attributes citations to spans of the answer.                                   |
| `gemini-grounded`   | Gemini + Google Search grounding. The most expensive by a wide margin — watch its share of the meter.                                    |
| `fixture`           | Replays recorded payloads **through the real parsers**. Seeded `status = 'disabled'` so it can never be dispatched for a real workspace. |

Each adapter returns the answer text **verbatim** — mention offsets are UTF-16 indexes into that exact string, so an adapter must never trim or re-wrap what it received.

### Errors carry a `billed` flag

`EngineError.billed` drives the meter. A failure that never reached the provider refunds the customer's slot via `release_run_slot()`; a provider failure *after* generation stays metered because it still cost money. Getting this backwards either charges customers for our outages or hides real spend.

Only the engine call itself is in that branch. A database failure *after* a successful, paid-for call is ours, not the provider's: it is recorded as `persist_error`, counted separately as `persistFailed`, and never refunded.

Nor is it terminal. Such a run keeps `status = 'succeeded'` and its stored `answer_text`, and carries `extraction_status = 'failed'`; a later pass re-extracts it from the stored text at **zero engine spend** and without touching the meter, reported as `reExtracted`. Setting `status = 'failed'` would make it terminal to `claim_run_slot` and to every rollup, stranding data the provider billed us for.

Recovery is bounded by `runs.extraction_attempt_count`, capped at `MAX_EXTRACTION_ATTEMPTS`. It is **deliberately separate from `attempt_count`**, which counts engine attempts and caps the reclaim loop — conflating them would corrupt a counter that guards spend. The cap matters less for the wasted work than for the starvation: the recovery queue is ordered and capped, so a run that can never be recovered would otherwise hold a slot a recoverable one needs. Past the cap the run stays queryable (`succeeded` + `failed` + `extraction_attempt_count >= 3`) and stays out of **both** denominators, because giving up on extraction is not the same as measuring a zero.

**The extraction marker is written last, never first.** `saveRunSuccess` lands the answer and its sources as `extraction_status = 'pending'`; only once the mentions and citations have actually persisted does `markExtractionComplete` advance it to `alias_only`. Marking first would let a crash in that window leave a succeeded run with zero mentions that the rollups admit as a genuine `0`-of-`n`. A missing number is recoverable; a confidently wrong one is not.

**A run awaiting extraction is held out of the denominator.** `run_count` counts runs that both succeeded *and* were extracted; the rest land in `workspace_daily_runs.awaiting_extraction_run_count`. Admitting an unextracted answer would report every brand as a measured `0` against it, when the truth is that nothing has looked at it yet — the exact fabricated zero the sample-size rules exist to prevent.

**Sources are as durable as the answer, and the source denominator is narrower.** `runs.source_payload` stores the adapter's normalised sources in the same statement as `answer_text`, so recovery replays *both* halves — mentions from the text, citations from the payload — rather than marking a run measured while leaving it permanently source-less. It holds normalised sources only, deliberately never a verbatim HTTP dump or a whole provider response. `refresh_domain_daily_metrics` denominates on `workspace_daily_runs.sourced_run_count`, which counts only extracted runs that carry a payload: `[]` is an answer that genuinely cited nothing, `NULL` is one whose sources were never captured, and the two must not read alike.

Each provider call is also bounded by `ENGINE_CALL_TIMEOUT_MS` (and never runs past the pass deadline), because the budget below is only checked *between* pairs. An abort surfaces as an unbilled `transport` error, so the slot refunds correctly.

`EngineResult.costMicros` is `null` when a provider does not itemise the call. The runner then falls back to `engines.list_cost_micros`, so `runs.cost_micros` is never a silent zero and `sum(cost_micros)` cannot understate spend.

### Two gotchas the adapters exist to contain

* **Gemini returns redirect wrappers, not URLs.** Grounded sources come back as `vertexaisearch.cloud.google.com` links. Store those as-is and every domain in the corpus becomes Google's — and because the corpus is shared across workspaces, one bad write damages every customer's source analytics. Unresolved wrappers are stored as `redirect_unresolved` with `url_id = null`, kept for later re-resolution, and excluded from the source rollups. A database `CHECK` enforces it independently of the code.
* **Gemini's grounding offsets are UTF-8 bytes, not UTF-16 code units.** They coincide on ASCII and diverge on the first accented character, which in a French-market product means immediately. `parseGeminiResponse` converts them.

## Running without provider keys

There are no provider API keys required to develop or test. `pnpm test` is green with an empty environment: the fixture adapter replays recorded payloads from `lib/engines/__fixtures__/` through `parsePerplexityResponse`, `parseOpenAIResponse` and `parseGeminiResponse`, so the production parsing code is exercised rather than mocked around.

The recordings are hand-authored against each provider's documented schema (not captured from live calls) and are deliberately shaped to carry the cases that break parsers: multi-block answers with per-block annotation offsets, redirect-wrapped URIs, accented text, a legacy response shape, and an unparseable URL.

There is also an opt-in integration test against the hosted database, skipped by default:

```sh theme={null}
RUN_SUPABASE_INTEGRATION=1 npx vitest run lib/runner/store-supabase.integration.test.ts
```

It creates a throwaway user and workspace, runs the real pipeline with the fixture adapter (no spend), and deletes the user, which cascades everything it made.

## The runner

`runWorkspace()` resolves the workspace's due prompts and engines for the current slot, executes them, persists answers, extracts mentions and citations, and refreshes the rollups.

**Idempotent per `(prompt, engine, run_date)`.** Re-running a slot performs zero engine calls and moves the meter by zero. That guarantee lives in the database — `claim_run_slot()` returns `exists` without touching `usage_periods` — so it holds under concurrent runners, which a cron retry makes likely rather than theoretical.

A run is contained: one dead engine fails its own runs and the pass continues. Only an unknown workspace throws.

**A slot a killed runner left behind is reclaimed, not stranded.** A serverless function that hits its wall clock mid-pass leaves rows at `running` with `metered = true`. `claim_run_slot()` returns those as `reclaimed` once they are older than `p_stale_after` (30 minutes) and under `p_max_attempts` (3), and re-executes them **without moving the meter** — the slot was counted at its first claim, so finishing it is free. Past the attempt cap, or once the row is terminal, the answer is `exists` as before, so a completed slot is never re-run.

**A failure that was refunded is retried inside its own slot.** `saveRunFailure` writes `status = 'failed'`, and for a failure the provider *billed* us for that is terminal and stays terminal — paying twice for one slot is the money-losing direction. But a `transport` error (the deadline abort included) or a 429 never reached generation, so `release_run_slot()` refunded it. `claim_run_slot()` hands those back as `retried` when **both** `metered = false` and `error_code in ('transport', 'rate_limited')`, and `loadCompletedPairs` stops reporting them as done so the dispatch loop actually sees them. Without this, one 429 on Monday cost that prompt × engine the whole ISO week — sample size the customer paid for and never received, across the \~167 hourly fires still to come.

`retried` is deliberately not `reclaimed`: the refund took the slot **off** the meter, so putting it back on re-checks the quota and charges it again. Refund (−1) plus retry (+1) is exactly one, so a slot that eventually succeeds is counted once. The cap is the same `p_max_attempts` (3) — three engine calls per slot in total, however they split between the two paths — and at the cap the run stays `failed`, so its slice carries a real `failed_run_count` rather than a hidden gap. The sweep below deliberately does **not** extend this to earlier slots: re-metering charges the period covering the run's own `run_date`, which by then may be closed and already reported.

Both branches are the one path that could hand the same slot to two runners, so the row is locked `FOR UPDATE` before anything is evaluated and each `UPDATE` re-checks its whole predicate under that lock (the reclaim branch also advances `started_at`, its staleness marker). The loser blocks, re-reads the row the winner just advanced, matches nothing, and falls through to `exists`. `attempt_count` is incremented by `mark_run_running()` rather than by a read-modify-write, because that counter is what caps both loops.

**The sweep covers earlier slots, not just this one.** Reclaim only fires for the slot the dispatch loop is walking, and only once the row is stale — so a row left `pending`/`running` when the slot rolls over would be orphaned: metered, answerless, and holding its `(prompt, engine, run_date)` atom so nothing can replace it. `loadIncompleteSlots` resolves those in one query over the last `PAST_SLOT_LOOKBACK` slots and re-dispatches them through the same `reclaimed` path, so the meter moves by zero — the slot was paid for once and the customer is owed the data.

What cannot be re-dispatched — past the lookback, past the engine-attempt cap, or its prompt is gone — is closed out as `status = 'skipped'` with `error_code = 'abandoned'`, counted in `workspace_daily_runs.abandoned_run_count`. `skipped` is deliberately not `failed`: nothing went wrong at the engine. It enters **no** denominator, because there is no answer to measure.

**The meter rule is asymmetric, on purpose.** A run may be *re-dispatched* across a billing-period boundary freely — that costs the meter nothing. It may only be *released* from the meter while its own period is still open: refunding a run in a closed, already-reported period would retroactively decrement a number the customer has been shown, for money that was genuinely spent. That is the same reasoning that keeps a `persist_error` run metered.

**A reported period is immutable once closed, and that governs every branch that can move the meter — not just close-out.** `release_run_slot()` decrements the period covering the run's own `run_date`, so all three of the sweep's meter-moving paths consult one `isSlotInPeriod(slot.runDate, period)` decided once per slot: whether the close-out refunds, whether a raced `retried` claim is released again, and whether an *unbilled engine failure of a re-dispatched slot* refunds. That third one is the easy path to miss — a swept slot executes through the same code as a fresh one, and a hung connection or a deadline abort surfaces as an unbilled `transport` error on a slot that may be two weeks old. When the period is no longer current the refund is withheld and reported as `refundsWithheld`: **the slot stays metered against the period that paid for it.** The provider call came out of that period's budget, and the run stays terminal too — `isRetryableFailure` requires `metered = false` — so no later fire can re-charge a closed period either. The decision is threaded into `executeClaimedRun` as a required argument rather than re-derived at each site, so a fourth path cannot silently omit it.

**Each engine gets its own bounded pool.** `config.runner.dispatchConcurrency` sets how many calls may be in flight **per engine** — 4 today — and the engines proceed independently of each other. Per-engine rather than one shared pool because the three providers have separate rate limits and separate latencies, so a single pool lets the slowest of them occupy every worker while the other two idle. Concurrency changes how many calls are in flight, not when the pass stops: every worker re-checks the deadline before taking a prompt, and each call is still bounded by `ENGINE_CALL_TIMEOUT_MS` and the caller's abort signal. The cap and the cron interval are one decision, and the arithmetic tying them to the Pro quota is in [deployment.md](./deployment.md#what-the-schedule-actually-carries).

**The pass carries a time budget, and resumes rather than re-walks.** `runWorkspace()` takes an optional `deadline` and stops dispatching cleanly when it passes, reporting `undispatched`. The cron route gives each workspace the whole of what is left of `DISPATCH_BUDGET_MS` rather than a slice of it, and stops when the window is gone; a Pro workspace of 300 grounded calls cannot starve the ones behind it because the next fire starts where this one stopped — workspaces are ordered by `workspaces.last_dispatch_started_at` ascending, nulls first, stamped when dispatch *begins*. Pairs that already reached a terminal status in this slot come back from a single query (`loadCompletedPairs`) instead of one `claim_run_slot` round-trip each, so the next fire spends its budget on work it has not done — progress is monotonic. Anything left mid-flight is picked up through the reclaim path above.

**The budget covers all three phases, and the split is deliberate in every direction.** Extraction recovery and the orphan sweep each run first under their own share (`RECOVERY_BUDGET_SHARE`, `SWEEP_BUDGET_SHARE`), leaving dispatch the clear majority. Neither catch-up phase may consume the whole slice — fresh measurements are the product, and a workspace with a backlog would otherwise never dispatch again. Dispatch must not consume it either, because a stranded run is paid-for data sitting unusable and only those phases can free it. Each share is computed from the time remaining when the phase starts, so one that finishes early hands its unspent budget to the next. Stopping early costs nothing: the rows keep their markers and the next fire re-selects them.

`unrecovered` and `unswept` report the **true outstanding backlog**, not the untouched remainder of one capped batch — a workspace that clears its 50-row slice while 450 wait behind it is still behind, and saying zero there is the exact blindness those numbers exist to remove.

The deadline is checked **before** `markRunRunning`, not after: a pair skipped purely on clock alignment must not burn one of the engine attempts `claim_run_slot` allows, nor start the staleness clock it would then have to wait out.

### Extraction scales with the answer, not the competitor count

Pro tracks an unlimited number of competitors, so `lib/runner/extract.ts` tokenises the answer once and looks up n-grams in a hash map: `O(answer tokens × longest alias)`, **independent of how many aliases exist**. A workspace with 10 competitors and one with 10,000 extract at the same speed. The obvious implementation — loop each alias over the text — would be thousands of times the work.

Stage 1 is deterministic alias matching, always runs, costs nothing. Open-ended LLM extraction (stage 2) is what Position actually requires, because an untracked rival appearing ahead of the customer changes the customer's rank. Until it runs, `runs.extraction_status = 'alias_only'` records that the brand set is knowably incomplete, so no aggregate silently reports a position computed from it.

## Metering and quotas

`config.limits` is the single source of truth for what a plan may consume; `lib/runner/limits.ts` is the only place that reads it. The per-plan numbers are in [measurement.md](./measurement.md#quotas) — this section is only how the runner enforces them.

`config.limits.maxTrackedBrandsPerWorkspace` (5,000) is a **stability guard, not a pricing limit** — it bounds the auto-discovery write path so one malformed extraction cannot grow the table without bound. Hitting it is an incident, not an upsell.

It is enforced **in the database**, by the `brands_enforce_ceiling` trigger ([`20260725121400_brand_ceiling.sql`](../supabase/migrations/20260725121400_brand_ceiling.sql)), because `brands` carries an unrestricted INSERT policy for members and a TypeScript-only guard is not a guard at all. The trigger raises SQLSTATE `RK001`, which `createBrand` maps to the same refusal `canAddBrand` returns from its pre-check — so a caller sees one outcome type whether the ceiling was caught before the write or by the write. Refusal is a result, never an exception: auto-discovery must be able to stop at a ceiling without failing the extraction around it.

The plan `canAddBrand` is evaluated against is **not a parameter** of `createBrand` — it is resolved inside, from the owner's Stripe subscription, by the same helper `loadWorkspace` uses, so entitlement has one source of truth and no caller can pick its own branch. If it cannot be resolved the answer is `plan_unresolved`, never `free`: a fallback would be conservative but still a confident claim about a plan nothing knows. `workspaceId` cannot be derived — it is the target of the write — so the caller must have verified membership before calling, which the TSDoc states as a precondition; this module writes through the service-role client and bypasses RLS.

If the alias index cannot be read at all, the pass **degrades rather than dies**: dispatch and extraction recovery are skipped (an empty index would certify a measured zero for every brand an answer actually named), orphan close-out still runs because it needs no index and is what frees already-metered slots, and the reason is reported in `RunSummary.aliasIndexError`.

Over-limit prompts are *skipped*, never deleted: a workspace that downgrades keeps its 100 prompts and runs the first 10.

The limit in force is snapshotted into `usage_periods.run_limit` when a period opens, and only ever rises — so a mid-period downgrade cannot retroactively put a customer over quota, and changing pricing next quarter does not rewrite past enforcement.

## Sample size

`lib/stats.ts` is deliberately the most heavily tested module in the repo, because the product's positioning *is* the statistics.

* `Measure` carries `successes`, `n`, and `value: number | null`. TypeScript will not let a caller render a number it does not have.
* `Comparison` has no numeric fallback: an empty prior window yields `previous: null` and `delta: null`, so "no comparison" is the only renderable state. There is no `?? 0` in the read path, and there must never be one.
* Intervals are **Clopper–Pearson** (exact binomial), not the normal approximation, which collapses to zero width at 0/n — precisely the case that must not render as a confident "0%".
* `combine()` sums components rather than averaging ratios, which is why the rollups store sums and counts.

## Scheduling

`app/api/cron/run-prompts` is a `GET` gated on `CRON_SECRET`, which **fails closed**: with no secret configured nothing is authorised, because an unauthenticated runner lets anyone spend the engine budget. It is excluded from the session proxy in `proxy.ts` for the same reason webhooks are — it carries no session cookie.

The runner writes through the service-role client, and the tables it writes have no authenticated write policy. That alone does not keep the meter off the browser: the metering RPCs are `SECURITY DEFINER` and PostgREST publishes them as `/rest/v1/rpc/<name>`, so the guarantee also depends on their EXECUTE grants being revoked — see [supabase.md](./supabase.md) and `20260725120600_function_grants.sql`.

**One trigger runs the whole pass.** Reclaim, extraction recovery and orphan close-out are phases of the same fire rather than a separate endpoint, so all three are exercised on every fire and there is no second schedule that can drift out of step with this one.

**The trigger fires hourly; the measurement is still weekly.** `run_date` is the ISO week, so a prompt × engine is measured exactly once per week however often the cron fires — the extra fires drain what is left of the week's quota instead of asking one fire to carry a whole Pro workspace. The interval is also where total dispatch capacity comes from, since a fire's window is not divided across workspaces. The schedule is declared in `vercel.json` rather than in the Vercel dashboard; the interval, the per-engine concurrency, the rotation, and the arithmetic tying them together are in [deployment.md](./deployment.md).

Since the pipeline is idempotent per slot, firing more often than the cadence is safe and costs nothing: it just returns `alreadyRun`. A pass with nothing to do — no active prompts, or no engine whose key is set — claims nothing and writes nothing at all, not even a usage period, so the schedule can be live before the first workspace exists.
