What rekkal measures
The product side of the repo: which engines rekkal queries, what surface those queries represent, how often they run, and the quotas that bound them. The plan numbers here are the ones encoded inconfig.ts, which is the source of truth — this file explains them.
Engines
v1 measures exactly three engines:config.engines in config.ts is the single source of truth. The features grid, the coverage marquee, the FAQ, the OG image, and the SEO metadata all render from it, so adding or dropping an engine is one edit there — config.test.ts fails if any component under components/marketing/ or the OG image hardcodes an engine name instead, and the engine count in the coverage heading is derived too. The prose lists that cannot import it (this file, the README, and llms*.txt) still have to be changed in the same pass.
The per-plan engine bullets on the pricing cards are the deliberate exception: each plan renders its own engines list, and the free plan covers Perplexity only (see Quotas). A plan naming fewer engines than the three above is correct, not drift.
Measurement surface
rekkal queries each provider’s own API with grounding turned on, and reports what those APIs return. That is the whole measurement surface: there is no browser automation and no scraping of a consumer chat UI. An answer from a provider’s API can differ from what the same provider’s consumer app returns for the same question — different retrieval, different personalisation, different model routing. So rekkal’s numbers are a consistent measure of the grounded API surface, not a transcript of what any individual user sees in the app. The site says this in the same words on the pricing section and in the FAQ; keep those in step with this file.Cadence
Both plans refresh weekly. This is a design decision, not a limitation to apologise for: a week of runs is enough sample to say whether something actually moved, where a daily figure mostly reports sampling noise. Consequently, every displayed metric carries sample-size awareness — a confidence band on the value, suppression of deltas the sample does not support, and an explicit “no comparison” state when there is no prior period, rather than a delta computed against nothing.Quotas
rekkal pays for every engine query; customers supply no API keys. Quotas are therefore enforced in code, not implied by fair-use wording, because margin is directly exposed to vendor pricing.config.limits in config.ts is the enforced ceiling, and lib/runner/limits.ts is the only place that reads it. config.plans.*.features is marketing copy: when the two disagree, config.limits is the truth and the copy is the bug.
The bullets that quote a limit are rendered from it rather than retyped, so they cannot disagree in the first place: the engine list through formatEngineNames, the tracked-competitor allowance through formatTrackedCompetitors, which turns Pro’s null into “Unlimited” with no sentinel number. config.test.ts fails if a plan’s bullet stops following config.limits. The remaining bullets are hand-written prose and can still drift.
runsPerMonth is derived, not chosen: prompts × engines × 5, because a calendar month holds at most five weekly slots. Pro’s unlimited competitors is a packaging promise, so trackedCompetitors is deliberately null — genuinely unlimited, not unset. Auto-discovered brands stay suggested and never count against it; the only ceiling is config.limits.maxTrackedBrandsPerWorkspace, a stability guard on the discovery write path rather than a pricing lever.
The last row is a capability, not a quantity, and it is enforced like one: config.limits.<plan>.competitorRanking is a boolean, read through includesCompetitorRanking in lib/runner/limits.ts like every other limit. Free still tracks 3 competitors, and those brands still constrain the runner, discovery and the sources view — what free does not get is the ranking and share-of-voice view built from them. The two limits are deliberately independent; a change that made one follow the other would take tracking away from every free workspace.
The gate is applied where the data is read, not where it is drawn. lib/analytics/source-supabase.ts resolves the workspace owner’s plan before it issues the brand reads, and a plan without the view never selects the competitor brands or their brand_daily_metrics rows at all; buildDashboardAnalytics then has nothing to compute the four ranking surfaces from and returns { entitled: false, requiredPlan: "pro" } in their place. That costs an extra round trip — entitlement decides what may be read, so it cannot be resolved in parallel with the reads it gates — and it is the point: hiding CompetitorTable while the query still ran would not be a gate. Only the reads the gate narrows pay for it; an entitled workspace issues the unfiltered brand_daily_metrics read alongside brands. Share of voice travels with the ranking rather than with visibility because its denominator is the tracked competitor set.
Entitlement is the owner’s, and a caller who is not the owner raises rather than being gated. subscriptions is readable only by its own user (auth.uid() = user_id), so a non-owner reader sees no row — which is indistinguishable, from the row alone, from a workspace that genuinely has no paid plan. The two are told apart by the caller’s own id (client.auth.getClaims()) against workspaces.owner_user_id: the owner with no row is a free workspace and resolves to free exactly as before, while a caller who is not the owner throws. Today workspace_members holds one owner row per workspace, so that path is unreachable and the raise costs nothing; workspace_members exists so that seats can be added by INSERT, and the first extra seat on a Pro workspace would otherwise be shown the plan gate silently — a paying customer’s colleague told they are on the free plan, with nothing in the logs. Serving seats needs an owner-scoped plan lookup this read layer does not have (a security definer function shipped with its revoke, or the plan denormalised onto workspaces), and the raise is what forces that decision instead of shipping the wrong answer.
The gated state is its own screen (CompetitorRankingGate in components/dashboard/plan-gate.tsx), never the empty state. “Nothing has been measured yet” and “your plan does not include this view” have different causes and different remedies, and this product’s whole contract is that absence renders its specific cause — so the gate names the plan, its price, and says plainly that the tracked competitors keep working. data-slot="plan-gated" distinguishes it from the empty states’ data-slot="no-value" in tests. REKKAL_DASHBOARD_FIXTURE=1 with ?scenario=gated renders it against a fully populated workspace, which is the state that proves the gate does not read as missing data.
The shape trades freshness for breadth: more prompts tighten the aggregate visibility figure, while the weekly cadence keeps the run count — and the bill — bounded. Gemini’s Google Search grounding is the most expensive of the three engines per query and is the one to watch when the run count changes.
How the runner enforces all of this — over-limit prompts skipped rather than deleted, and the limit snapshotted into usage_periods.run_limit — is in analytics-pipeline.md.