Skip to main content

rekkal v1 data model — proposal for review

Status: APPROVED and shipped. Kept as the design record and the rationale behind the schema — the authoritative DDL is now supabase/migrations/20260725*.sql, applied to dthdxgrrktaavshxjqjr and verified against the hosted database. Where the two ever disagree, the migrations win. Decisions returned on the four open questions, and what changed as a result: T1, T3, T4 and T5 were approved as recommended. §13 records the two changes made during implementation that this document did not originally propose. Authority: decisions.md (captain, 2026-07-25) and the Peec teardown report.md §4, §P2.4, §P2.6. Where this proposal departs from the report, it says so and why. This is the schema every other rekkal feature aggregates over. Migrating it later is the expensive mistake, so it is written concretely — real column names, real SQL, real indexes — rather than as a description of possibilities. Every genuine trade-off I could not settle is in §12, with a recommendation.

1. The one-paragraph summary

One workspace is one tracked brand in one market, and it is the tenancy boundary carried on every fact row from day one. The atom is public.runs: one row is one prompt, sent to one engine, for one scheduled slot date, and it is simultaneously the answer record and the unit the meter counts. Mentions are stored at occurrence grain with UTF-16 character offsets into the stored answer, so the product can highlight the exact sentence. Brand identity is protected by two unique indexes that make the incumbent’s two documented entity-resolution failures structurally impossible. No column anywhere in the schema holds a ratio — every rollup stores sums and counts, so every number the product ever displays can report the n it was computed from, and an absent comparison window is NULL by construction rather than a delta against zero. Runs and the meter have no authenticated write policy at all: they are written only by the scheduled runner through the service-role client. That is necessary but not sufficient on its own — the metering functions are SECURITY DEFINER, PostgREST publishes them as RPC endpoints, and Postgres grants EXECUTE to PUBLIC by default, so keeping the meter off the browser also depends on those grants being revoked (§9).

2. Naming and the tenancy boundary

The report sketched organization → project. This proposal collapses that to a single level, workspace, and makes it the per-client boundary. Why one level. The thing that is catastrophic to add later is a tenancy column on the fact tables — runs, run_mentions, run_citations and the rollups are the tables that grow without bound. The thing that is cheap to add later is a container above the boundary. Decision #3 says the agency layer must be additive, and the additive path is:
If instead workspace were the org and project the client, every fact table would need a second FK today for a level v1 has no use for — and is_workspace_member() would have to become a two-hop check. One boundary, carried everywhere, is the smaller bet. Membership from day one, even though v1 has one seat. workspace_members exists now with exactly one owner row per workspace, and every RLS policy in the schema routes through one helper, public.is_workspace_member(uuid). Adding seats later is an INSERT, not a policy rewrite across 12 tables. This is the single highest-leverage forward-compatibility decision in the proposal and it costs one table.

3. Entities and relations

Twelve workspace-scoped tables, three global reference tables. Every workspace-scoped table carries workspace_id directly — never “reachable by join”. Consistency between the denormalised workspace_id and the parent’s is not left to convention: child tables use a composite foreign key (parent_id, workspace_id) → parent(id, workspace_id), so a row physically cannot claim a workspace its parent does not belong to. What is deliberately absent from v1, with reasons:

4. The atom: public.runs

4.1 What one row means

One row in public.runs is one prompt, sent to one engine, for one scheduled slot date, in one workspace. It carries the answer text exactly as that engine returned it, the execution parameters it actually ran under, and it is the single unit the run meter counts.
Three consequences follow from that sentence, and they are the whole design:
  1. (prompt_id, engine_id, run_date) is unique. Re-running is an upsert, never an insert. This is the idempotency guarantee the brief requires.
  2. run_date is the scheduled slot, not the wall clock. For weekly cadence it is the Monday of the ISO week in the workspace timezone. A run that fails on Monday and is retried on Tuesday reuses the same row and the same slot — which is precisely why retries cannot double-count. Actual execution time lives in started_at / completed_at, and attempt_count records how many tries it took.
  3. Quota unit = row count. COGS = sum(cost_micros). These are deliberately different numbers. A customer is charged one unit for a slot no matter how many times we retry it; the business still sees the true spend. The meter invariant is checkable in one query (§8.4).

4.2 Grain, keys, indexes

Country and language are on the prompt, not a run-grain dimension: the same question in three countries is three prompt rows, which is also the correct pricing (three countries genuinely is 3× the runs). They are then copied onto the run at dispatch — along with model_version — because a run is an immutable record of what actually executed. Editing a prompt’s country must not silently rewrite history. answer_text is nullable with answer_archived_at / answer_storage_key alongside it, so the §4.5 archival plan (hot for 30–90 days, then object storage) is a background job rather than a migration. A CHECK forbids a succeeded run with neither an answer nor an archive pointer.

4.3 Answer text and character offsets — the encoding contract

Mention offsets are UTF-16 code-unit offsets into runs.answer_text exactly as the adapter returned it, because the UI slices that string in JavaScript (answerText.slice(sentenceStart, sentenceEnd)). This matters and is easy to get silently wrong: Postgres substring() counts characters, JavaScript counts UTF-16 code units, and the two diverge the moment an answer contains an emoji or any astral-plane character. Fixing the convention now is free; discovering it after 200k mention rows means re-deriving every offset against archived text. The contract is: offsets are produced in TypeScript, consumed in TypeScript, and Postgres only ever stores and compares them. A unit test asserts an emoji-bearing answer round-trips. answer_char_length is stored so offset bounds can be sanity-checked without loading the text.

5. Mentions and entity resolution

5.1 Storage

run_mentions is occurrence grain — one row per textual occurrence, not one per (run, brand). That single choice yields every metric the report confirmed arithmetically in P2.3: Each row stores char_start/char_end (the brand name itself, for highlighting) and sentence_start/sentence_end (the containing sentence, for the quote card), with a CHECK that the sentence span contains the mention span. surface_form records the literal text matched, so “Efrei Paris” is visible in the UI even though it resolved to brand Efrei. unique (run_id, char_start, char_end, brand_id) makes re-extraction idempotent at mention grain, matching the run-grain idempotency. Re-running the extractor over an existing answer converges instead of duplicating.

5.2 How a mention is attributed to a brand

Two stages, deliberately separable so the pipeline runs with no provider keys at all: Stage 1 — deterministic alias match (always runs, free, unit-testable). Every brand has a set of aliases in brand_aliases. Each alias is normalised by public.rekkal_normalize() — lowercase, accent-folded, whitespace-collapsed — and matched with word-boundary awareness against the answer. Produces exact offsets, match_method = 'alias', match_confidence = 1.00, and a matched_alias_id so every mention is traceable to the rule that created it. Accent folding is not optional in this market: Télécom Paris and Telecom Paris are the same school and P2.6’s ranking bug was about exactly that school. Stage 2 — open-ended LLM extraction (optional, requires a key). Report §4.3 is unambiguous: Position cannot be computed by matching a list of tracked names, because an untracked brand appearing ahead of you changes your position. That requires NER over every answer to find all brands in mention order. Stage 2 writes mentions with match_method = 'llm' for brands not in the tracked set, creating brands rows with discovery_source = 'auto', tracking_state = 'suggested'. The honesty hook. runs.extraction_status records which stage actually ran ('alias_only' | 'llm'). An aggregate computed over alias_only runs has a knowably incomplete brand set, so Position over it is not trustworthy — and the schema says so rather than hiding it. This is the same failure the incumbent ships silently (P2.6: visibility rank says #2, position says #5.4, nothing reconciles them). Recording the extraction mode is what lets the read layer refuse to render Position until Stage 2 has run.

5.3 Avoiding the incumbent’s two documented resolution failures

Report P2.6 records two precision failures. Both are prevented by DDL, not by application care. Failure 1 — “Efrei” and “Efrei Paris” as two brands sharing efrei.fr.
A domain identifies at most one brand per workspace. The second candidate cannot become a brand; it becomes an alias of the first. Reinforced at the alias level:
One normalised alias resolves to exactly one brand, so the mention resolver is deterministic rather than first-match-wins. This is what makes Stage 1 reproducible: the same answer text always produces the same mentions. Failure 2 — “ECE” resolved to ece-ehlers.de, a German company, not the French school ece.fr. The root cause is that an auto-discovered brand’s domain is a guess that gets promoted to fact. So the schema refuses the promotion:
  • unverified — an LLM or heuristic guessed it. Never used for attribution and never joined to domains/Sources. It is a display hint on the suggestions screen and nothing else.
  • corroborated — the domain has actually appeared as a cited source in runs that mention the brand. domain_evidence_run_count records how many. Evidence, not assertion.
  • confirmed — a human said yes.
  • rejected — a human said no; excluded from the unique index so the correct brand can claim the domain.
ece-ehlers.de never co-occurs with mentions of the French school, so it never corroborates, so it never affects a number. The wrong answer stays inert instead of poisoning the Sources table. Failure 3, which the report identifies as a correctness bug (P2.6 / decision #7) — ranking against a set that excludes the customer’s biggest rival. Untracked brands still get brands rows and still get run_mentions rows, so the comparison “is any brand out-mentioning us?” is a query over data we already hold, not a feature that needs new acquisition:

5.4 Backfill

Report §4.3: editing a brand’s name, aliases, or regex must trigger a backfill across historical runs, which requires the raw answer text. This is why answer_archived_at exists rather than a hard delete — the archive is retrievable, and the alias-edit path re-extracts. The unique (run_id, char_start, char_end, brand_id) index makes that re-extraction safe to run repeatedly.

6. Sources and citations

run_citations carries the tenancy boundary; domains and urls are a global reference corpus shared across workspaces (see trade-off T2). A URL and its title are not customer data; which run cited it is, and that lives on run_citations. The Gemini redirect gotcha (report §4.4), handled in DDL. Gemini returns source URIs as vertexaisearch redirect wrappers. Storing them unresolved makes every domain in the database Google’s. So:
An unresolved wrapper is stored as redirect_unresolved with url_id = NULL. It is preserved for later re-resolution and it is visibly missing from the Sources rollup rather than silently misattributed. Resolution failure becomes a countable metric instead of a corrupt corpus. domain_daily_metrics stores both denominators P2.3 distinguishes:
  • retrieval_count — total citations of the domain
  • retrieved_run_countdistinct runs that retrieved it
Citation rate is retrieval_count / retrieved_run_count (P2.3 confirmed values 0.4 → 2.9, consistent with the per-retrieving-chat denominator; dividing by retrieval count is the deprecated metric). Storing both means the correct one is always computable and the wrong one is never the only option.

7. Sample size as a first-class property

This is the captain’s positioning (decisions #6, #7) and the report’s strongest finding (P2.4: every per-prompt percentage in the incumbent is quantised to ninths, and a displayed “11%” has a 95% CI of roughly 0.3%–48%). Four mechanisms, all structural.

7.1 No column in this schema holds a ratio

Every rollup stores sums and counts:
Because the components are stored, every ratio is recomputable at any aggregation level — by engine, by topic, by week, by all-engines-combined — and n comes along for free at each one. Report §4.1 and Peec’s own API docs describe the trap this avoids: “ratio metrics must be recombined from their underlying sums, not averaged.” A visibility numeric column would make that arithmetic impossible to get right and it will not exist. The naming convention is the enforcement: every rollup column ends in _count or _sum. Anything else is a review failure.

7.2 Every aggregate can report its n, because n is the same column every time

run_count is the denominator for every brand metric, at every grain. The read contract in TypeScript is a single generic that makes n structurally impossible to drop:
value: number | null is the point: TypeScript will not let a caller render a number it does not have. Intervals are computed in lib/stats.ts, in TypeScript, unit-tested — not in SQL. For a product whose positioning is the statistics, the statistics must be the most tested code in the repo, and Postgres has no incomplete beta function. Clopper–Pearson, not the normal approximation, because the normal approximation gives a zero-width interval at 0/9 — exactly the case P2.4 shows the incumbent rendering as a confident “0%”. Clopper–Pearson reproduces the report’s published figures exactly (1/9 → 0.28%–48.2%; 0/9 → 0%–33.6%), is conservative rather than optimistic, and conservative is the correct direction of error for this product. See T4.

7.3 An absent comparison period is NULL by construction, never zero

P2.5 is a shipped bug in the incumbent: with one day of history the KPI strip renders Visibility 27.1% +27.1% · Position #5.4 +5.4, because the prior period is empty and the comparison is current − 0. The Position delta renders as green-flavoured improvement while meaning “we had no data yesterday”. Three layers prevent it:
  1. brand_daily_metrics has check (run_count > 0). A row exists only where runs actually happened. There is no such thing as a zero-n row, so “no data” is row absence, which SQL already models as NULL.
  2. Every ratio divides by nullif(denominator, 0). Division by an empty window yields NULL, not 0, without any application-level care.
  3. The comparison type has no numeric fallback:
delta: number | null with previous: Measure | null means “no comparison” is the only thing the UI can render. There is deliberately no ?? 0 anywhere in the read path, and that is a lint-able invariant.

7.4 Distinguishing “measured zero” from “not measured”

These are different facts and the schema separates them:
  • Measured zerobrand_daily_metrics row exists, run_count = 12, mention_run_count = 0. Visibility is genuinely 0% with n = 12. The rollup zero-fills every tracked brand for every (engine, date) that had runs, precisely so this case has a row.
  • Not measured — no row. n is 0. Nothing is rendered but an explanation.
Untracked/auto-discovered brands are not zero-filled (that would be one row per suggestion per engine per day for no benefit); they get rows only where they were mentioned. Presence data for discovery, true zeros for the tracked set.

7.5 Where the threshold lives

workspaces.min_sample_n integer not null default 30 — per workspace, so it is auditable and adjustable, and so the product has a stated threshold rather than the inconsistent internal one P2.4 caught the incumbent applying (Ironhack rendered Sentiment 57 / Position #2.0 on essentially no mentions, while ETNA and Supinfo rendered blank — same product, same page, two different rules). Default 30: at weekly cadence, 10 prompts × 3 engines = 30 runs per slot, so a Pro workspace clears the bar in its first week at the aggregate level while individual prompts (3 runs/week) correctly stay unreportable until they accumulate. The number is a workspace column, not a constant, so changing it is configuration rather than a deploy.

8. Run metering

8.1 Where the meter lives

public.usage_periods — one row per workspace per billing period — and it is only ever moved by public.claim_run_slot(). Nothing else in the codebase writes runs_used.
Counting runs directly would also work at v1 scale, but a materialised period row buys three things a count(*) cannot: a place to lock so concurrent dispatchers serialise (§8.2), a record that survives the answer-archival plan in report §4.5, and an auditable snapshot of the limit that was actually in force — so raising Pro’s quota next quarter does not retroactively rewrite last quarter’s enforcement. run_limit is snapshotted from config.ts when the period opens, and on upgrade it moves only upward (greatest(existing, new)), so a mid-period plan change never retroactively puts a customer over quota.

8.2 The atomic claim

One SECURITY DEFINER function, one transaction:
  1. Does a run already exist for this (prompt, engine, run_date)? → return it, created = false, meter untouched. This is the idempotency guarantee.
  2. SELECT … FOR UPDATE the workspace’s open period. Concurrent dispatchers serialise on that row — per workspace, so there is no global contention.
  3. runs_used >= run_limit → return reason = 'quota_exhausted', insert nothing.
  4. INSERT … ON CONFLICT DO NOTHING. Increment runs_used only if the insert actually created a row.
There is no window in which a run exists without having been metered, or is metered twice. Quota is consumed at dispatch, not on success, because a dispatched run is a committed intent to spend.

8.3 Not charging customers for our own failures

runs.metered boolean not null default true, plus:
Sets metered = false and decrements, guarded by where metered so it is idempotent. Called only when a run failed before the provider billed us (connection refused, 429 with no completion, our own crash). The row survives for audit; it just stops counting. A provider 500 after generation still counts, because it still cost money.

8.4 The invariant, and how to verify it on the hosted DB

Per data/learnings.md (2026-07-23), a green deploy does not prove the hosted DB has the migration. Phase B verifies schema objects by querying dthdxgrrktaavshxjqjr directly, and this invariant query is part of that check.

8.5 Quota values

Decision #5 replaces the current config.ts quotas (Free 25 prompts/weekly; Pro €99 for 500 prompts/daily/all engines — roughly 10–30× what €99 can fund). The proposed machine-readable block:
runsPerMonth is derived, not chosen: prompts × engines × 5, because a calendar month contains at most five weekly slots. Free = 10 × 1 × 5 = 50 (decision #5’s “~43 runs/month” is the expected value; 50 is the ceiling that stops a five-Monday month failing). Pro = 100 × 3 × 5 = 1500 (decision #5’s “~1,300 runs/month” expected). The number is written out literally rather than computed so the enforced limit is greppable and auditable. Competitor-brand limits are not settled by the decisions doc — see trade-off T6.

9. RLS posture

Every table has RLS enabled. Every workspace-scoped policy is using (public.is_workspace_member(workspace_id)) — one helper, so a future seats/roles change is one function rather than twelve policies.
SECURITY DEFINER is required: without it the helper would query workspace_members under that table’s own RLS policy, which calls the helper — infinite recursion. search_path = '' with fully-qualified references guards against search-path hijacking, matching the existing handle_new_user pattern.

How a scheduled job writes rows the user can only read

The runner uses lib/supabase/admin.ts — the existing service-role client, which bypasses RLS. This is the pattern already established in this repo for the Stripe webhook, extended rather than reinvented. Entry point is a Route Handler at app/api/cron/run-prompts/route.ts invoked by Vercel Cron and gated on CRON_SECRET; the handler is server-only and the service-role key never enters a module with "use client". The security property this buys, and its limit: the six bolded tables have no INSERT, UPDATE, or DELETE policy for authenticated — not a restrictive one, none at all. Postgres denies by default. So a user with a valid session token, calling PostgREST directly with a hand-crafted request against those tables, cannot fabricate a run, forge a mention, or move the meter. There is no policy to exploit, which is a stronger statement than “the API doesn’t expose it”. But absent write policies are necessary, not sufficient — and this proposal originally claimed otherwise. The functions that move the meter (claim_run_slot, ensure_usage_period, release_run_slot) and refresh the rollups are SECURITY DEFINER, so they execute with their owner’s privileges and route straight around the RLS on those tables. Postgres grants EXECUTE to PUBLIC on every newly created function, Supabase does not revoke it, and PostgREST exposes everything in schema public as /rest/v1/rpc/<name>. As first shipped, a browser holding only the publishable anon key could therefore call ensure_usage_period(..., p_run_limit => 2147483647) and lift its own quota without bound, release_run_slot() to unmeter a run, or claim_run_slot() to move the meter directly. 20260725120600_function_grants.sql revokes those grants. public.is_workspace_member(uuid) keeps its authenticated grant, deliberately and by name: every workspace-scoped policy calls it and a policy is evaluated as the calling role, so revoking it would fail every policy closed; it is safe because it is read-only, takes only a workspace id, and returns a boolean the caller could already derive from workspace_members under its own RLS. The guarantee holds only while those grants stay revoked, so it is not a property to restate unconditionally. Any new SECURITY DEFINER function must ship with its REVOKE in the same migration; supabase/migrations/function-grants.test.ts fails the suite if one does not. Users retain full write access to what they legitimately curate — brands, aliases, topics, prompts — because that is their input to the pipeline, not its output.

10. Migrations

Six files, following the existing YYYYMMDDHHMMSS_name.sql convention. Full SQL below; this is what Phase B would apply verbatim to dthdxgrrktaavshxjqjr.
Validation status, stated plainly: this SQL has been reviewed by hand but not yet executed against a Postgres engine — there is no local Postgres on this machine and the Docker daemon (OrbStack) is down, and Phase A is explicitly design-only. Two defects were already caught and fixed in review (a schema-qualified ON CONFLICT target, and the unaccent immutability caveat below). Phase B runs the whole set against a throwaway database before touching the hosted project, then verifies the hosted objects by querying them. Review the shape here; treat exact syntax as pending verification.

20260725120000_workspaces.sql

20260725120100_brands.sql

20260725120200_prompts.sql

20260725120300_runs.sql

20260725120400_metering.sql

20260725120500_rollups.sql


11. TypeScript surface

The engine adapter contract — one interface, four implementations

Testing with no provider keys. There are no provider API keys on this machine, so the three real adapters are unit-tested against recorded fixtures: each adapter’s response-parsing is a pure function over a captured provider payload (lib/engines/__fixtures__/<engine>/*.json), tested directly — including the Gemini redirect-wrapper case and a provider-error case. The fixture adapter implements the same interface over the same recordings, so the runner, extractor, meter and rollups are exercised end-to-end with zero spend. pnpm test passes with an empty .env.

The runner

Slot resolution (slotDateFor(cadence, now, timezone)) is TypeScript and unit-tested, including the DST boundary and the five-Monday month.

12. Open trade-offs

These are the trade-offs as originally proposed. All of them have since been decided — the decision table at the top of this document records what was returned and what changed as a result. Each has a recommendation. T1, T2 and T6 are the ones I would most want the captain to look at.

T1 — One tenancy level (workspace) or two (organization → project)?

  • A (recommended): one level. workspace_id on every fact table; organizations added above later, touching only the workspaces table. Simpler RLS (one hop), fewer joins, and the expensive column is already in place.
  • B: two levels now, matching report §4.1. Agency billing needs no migration at all — but every fact table carries a second FK for a level v1 cannot use, and is_workspace_member() becomes a two-hop check on the hot path.
Why A: the irreversible decision is the column on the fact tables, and A already makes it. B pays a real cost today to avoid a cheap ALTER TABLE workspaces later.

T2 — Are domains / urls global or per-workspace?

  • A (recommended): global reference corpus, readable by any authenticated user; run_citations carries the boundary. Report P2.9 shows the incumbent’s source corpus is global and identifies it as “a large, compounding cost advantage that grows with customer count”. rekkal starts with none of it; making it global on day one is how it starts accruing.
  • B: per-workspace urls. Zero cross-tenant inference possible, but the same URL is stored and re-crawled per customer and the corpus advantage never exists.
Why A, and the honest caveat: the theoretical leak is that a user who could enumerate urls might infer someone tracks a niche URL. No per-workspace metadata lives on the global rows, the app exposes no enumeration endpoint, and the inference reveals nothing about whom. If the captain judges even that unacceptable, B is a one-line change now (add workspace_id) and a painful one later.

T3 — Materialise per-run brand ranks?

  • A (recommended): derive. Position is rank() over (partition by run_id order by min(char_start)), computed once per day in refresh_brand_daily_metrics.
  • B: a run_brand_ranks table. Faster per-run reads, but a second copy of a derived fact that must be kept in sync with re-extraction and alias backfills.
Why A: at v1 scale (~1,500 runs/workspace/month) the window function is cheap, and B adds a consistency liability with no read the product currently needs.

T4 — Clopper–Pearson or Wilson intervals?

  • A (recommended): Clopper–Pearson (exact). Reproduces the report’s published figures exactly (1/9 → 0.28%–48.2%), never gives a zero-width interval at 0/9 — the precise case P2.4 catches the incumbent rendering as a confident “0%”.
  • B: Wilson score. Closed form, computable in SQL, narrower (1/9 → 2.0%–43.5%).
Why A: Clopper–Pearson is conservative, and for a product whose position is statistical honesty, conservative is the correct direction of error. It costs ~60 lines of unit-tested TypeScript for the incomplete beta function.

T5 — A workspace_engines table?

  • A (recommended): no table. Enabled engines are a function of the plan in config.ts. Which engine a historical run used is on runs.engine_id.
  • B: a table, allowing a Pro customer to disable Gemini (the expensive one) to stretch quota.
Why A: B is a real product idea but nothing in the decisions doc asks for it, and it is one purely additive table whenever it is wanted.

T6 — Competitor-brand limits are not settled (captain input wanted)

Decision #5 sets Free = 1 brand / 10 prompts / 1 engine and Pro = ~100 prompts / 3 engines. “1 brand” is the customer’s own brand; how many competitors each tier may track is unstated, and the product is inert with zero competitors. My recommendation, to be overridden freely: Free = 3 tracked competitors, Pro = 15. Competitors cost nothing per run (mentions are extracted from answers that were already paid for), so the limit is a packaging choice, not a COGS one. Auto-discovered brands should not count against the limit — they are tracking_state = 'suggested' and are the mechanism that fixes P2.6.

T7 — Per-run LLM extraction is a COGS line the decisions doc does not price

Report §4.3 makes open-ended NER mandatory for Position, and §4.5 #2 flags it as the second-worst-scaling cost: it is per run, not per prompt. Decision #5 prices Pro at ~1,300 runs/month ≈ €20–30 COGS, which covers the answer engines only. A Haiku-class extraction pass at ~1.5k input tokens/run adds roughly 2M tokens ≈ €2/month/customer — small, but not zero, and it is a fourth provider dependency. Not a blocker; flagged so the margin number is complete. The schema already handles the no-key case: extraction_status = 'alias_only' records that Position is not yet trustworthy rather than rendering it anyway.

T8 — config.ts marketing copy vs. enforced limits

Decision #5 explicitly replaces the current config.ts quotas. This task needs the machine-readable limits block (§8.5) to enforce anything. The plans.*.features strings are marketing copy (“500 monitored prompts”, “Daily refresh”, “including Claude & Copilot”) and are now wrong on every line. Recommendation: add limits in this task (the code needs it); leave features prose to the pricing/marketing task, since config.features.preLaunch is true and the copy is not publicly indexed. Flagged rather than silently changed, because the two blocks disagreeing in main is exactly the kind of thing that ships wrong.

13. Amendments made during implementation

Changes the original proposal did not contain. A and B both follow from the T6 decision that Pro tracks an unlimited number of competitors — a limit this document had assumed would be a small number. C came later, from the throughput the schedule actually needs.

A. workspace_daily_runs, a fifteenth table

The proposed rollup zero-filled every tracked brand for every (engine, date) that had runs, so that “0 mentions out of n=12” had a row and was distinguishable from “not measured”. With an unlimited competitor set that multiplies out: 5,000 competitors × 3 engines × 365 days is millions of empty rows per workspace per year. workspace_daily_runs(workspace_id, engine_id, bucket_date, run_count, attempted_run_count, failed_run_count) holds the sample size once per slice, because run_count is a property of the slice and not of a brand. The zero-fill is then bounded to brands that have ever been mentioned in the workspace (brands.first_seen_run_date is not null) plus the self brand, and a brand with no row is still reportable as “0 out of n” against this table. Two things fall out of it that were not the motivation but are worth having: all three rollups now derive run_count from one place, so every surface agrees on n by construction; and attempted_run_count / failed_run_count make a slice whose n was depressed by engine failures visible, which is a sample-size fact the product should be able to state.

B. The defensive brand ceiling: 5,000 per workspace

config.limits.maxTrackedBrandsPerWorkspace = 5000, applied to every brand row regardless of plan or tracking_state. This is not a pricing limit and is deliberately far above anything a real customer would reach — the largest tracked set in the competitive teardown was 8 brands with 11 suggestions. It exists because auto-discovery writes brand rows from model output, so one malformed extraction emitting thousands of spurious names would otherwise grow the table without bound. Hitting it is an incident to investigate, not an upsell. The related instruction — that extraction degrade sanely with a large competitor set — is handled in lib/runner/extract.ts: matching tokenises the answer once and looks up n-grams in a hash map, which is O(answer tokens × longest alias) and independent of alias count. A test asserts a workspace with 20,000 aliases extracts in the same time as one with 8. A per-alias loop, which is the obvious implementation, would have been 20,000× the work.

C. The runner’s trigger: hourly, one endpoint, per-engine concurrency, workspace rotation (2026-07-25)

§9 above describes a single Route Handler at app/api/cron/run-prompts/route.ts and left the schedule implicit. Both were settled during implementation, and the shape that shipped is worth recording because it is not what a first reading of this document would assume. The proposal’s sequential dispatch on a weekly cron does not deliver the Pro plan: a 240s window at one call at a time clears ~24 grounded calls against the 300 a full Pro workspace needs per slot, so it falls further behind every week. What shipped instead is bounded concurrency of 4 in flight per engine (per engine, not one shared pool, because the providers have separate rate limits) on an hourly cron. Hourly is the trigger, not the measurement: run_date still buckets by ISO week, so a prompt × engine is still measured exactly once per week, and the extra fires drain the slot progressively. Two intermediate shapes were implemented and then removed, and both are worth recording so they are not re-proposed. A two-endpoint split — a frequent /api/cron/maintain for the zero-spend phases — was dropped because the one dispatch pass already runs reclaim, extraction recovery and orphan close-out, so the second endpoint bought only recovery latency and cost a schedule that could drift. Dividing the dispatch window evenly across workspaces was dropped because a divided window drains nobody: at ten workspaces each slice is ~24s. The window now goes to one workspace at a time in full, and one new column, workspaces.last_dispatch_started_at (20260725121100_dispatch_rotation.sql), rotates which workspace leads each fire — data-derived rather than a stored cursor, so adding or removing a workspace cannot skip one. Rotation and interval fix different things, which is the part most likely to be lost: rotation is allocation, the hourly interval is capacity. Total dispatch is window × fires, so rotating full slices on a daily cron gives ten workspaces the same ~101 calls a week that the even split did, and equally fails to clear a Pro slot. The live documentation for all of this is analytics-pipeline.md and deployment.md, which carry the interval, the concurrency, the rotation, and the arithmetic tying them to the quota — including the ceiling this design has and the fan-out that lifts it.

14. What this proposal deliberately does not include

Out of scope per the brief, and not designed for here: dashboard UI, the free no-signup audit, recommendations/Actions, the MCP server, sentiment, source page-content crawling, agent/crawler analytics, and any change to config.features.preLaunch (which stays true). The rollup tables are included, against a narrow reading of the brief, because “store sums and counts, never ratios” is the decision that is expensive to reverse and it belongs in the same review as the atom. The captain confirmed this. This file is deliberately not listed in llms.txt and not folded into llms-full.txt. Those index the durable product docs; this is a design record. docs/supabase.md carries the shipped schema and is the file the index points at.

15. Definition of done for Phase B, and how it was verified

Every item verified by querying the hosted database, never by trusting a command’s exit code — a green deploy does not prove the hosted DB has the migration. The test counts below are the Phase B snapshot, not a running total: the review rounds that followed added tests and migrations without invalidating any row here. Run pnpm test for the current count, and see supabase.md for which migrations are applied to the hosted project today. One defect was found this way that no unit test could have caught: PostgREST unifies the column set across a batch insert, so a column present in one row but omitted from another is sent as an explicit NULL and the column default never applies. Production code is unaffected because it builds uniform rows, but it is a live-database behaviour worth knowing before the next batch insert is written.