| company | id, name, type, residency, branding_ref, config_ref, status | Root of everything | type drives the page set. Every other table carries company_id. |
| brand | id, company_id, name, logo_ref | → company | Multi-brand groups (Titan → Tanishq, Taneira) sit here, not as separate tenants |
| entity | id, company_id, brand_id, type, parent_id, name, external_ref, attributes | Self-referencing tree | One polymorphic table for store, city, region, product, app, channel, plan, segment. type plus tenant hierarchy gives meaning. |
| store_profile | entity_id, geo, format, opened_on, cluster, floor_area, manager_id | → entity | Store-specific attributes kept out of the generic entity table |
| source | id, company_id, kind, granularity, auth_state, capabilities, history_start, sla_hours | → company | capabilities declares rating scale, text, reply support, language, location. This is what gates a source from a page. |
| source_record | id, source_id, external_id, payload_raw, captured_at, ingest_run_id, checksum | → source, ingest_run | Immutable. Never edited, never deleted on re-ingest. |
| feedback | id, company_id, source_record_id, entity_id, entity_match_score, occurred_at, rating_raw, rating_scale, rating_norm, language, text_raw, text_translated, reply_text, replied_at, dedupe_group | → source_record, entity, reviewer | The normalised record everything queries. One row per unique piece of feedback after dedupe. |
| reviewer | id, company_id, pseudonym, source_handle_hash, is_repeat, first_seen_at, consent_state | → feedback | Pseudonymised by default. Raw handles hashed. Retention policy per tenant residency. |
| sentiment | feedback_id, label, score, model_version, origin | → feedback | origin is model or rating_derived — the UI must be able to tell them apart |
| theme_mention | feedback_id, theme_id, subtheme_id, confidence, polarity, span_start, span_end, model_version | → feedback, taxonomy | Spans let the UI highlight the phrase that triggered the label |
| taxonomy | id, company_id, parent_id, name, scope, synonyms, threshold, is_universal | Self-referencing | Universal nodes shared; tenant extensions hang off a universal parent |
| competitor_mention | feedback_id, competitor_entity, comparison_dimension, switch_intent, confidence | → feedback | Competitors are a reference list per tenant, not free text |
| insight | id, company_id, pattern_id, headline, severity, confidence, evidence_count, denominator, scope_entity_ids, period, status, first_seen_at, limitations | → evidence_link, action | Recomputed each run; identity is stable so status and history survive |
| evidence_link | insight_id, feedback_id, rank, is_sampled | → insight, feedback | Makes "show me the reviews behind this" a join, not a re-query |
| action | id, company_id, insight_id, title, owner_id, owner_group, entity_scope, severity, sla_days, status, opened_at, closed_at, outcome, baseline_metric, verification_window | → insight, user | Baseline captured at open so verification has something to compare against |
| report | id, company_id, template_id, scope, period, generated_at, data_as_of, snapshot_ref, recipients | → company | data_as_of is distinct from generated_at — a report built on stale data says so |
| ingest_run | id, source_id, started_at, ended_at, status, records_new, records_dup, records_failed, coverage_note, scraper_version | → source | The existing scraper's run-info shape is already close to this — keep it |
| data_quality_issue | id, company_id, kind, severity, affected_count, detected_at, resolved_at, assignee | → source, entity | Unmapped entities, expired auth, missing fields, schema drift |
| user / role | id, company_id, email, role, entity_scope, pii_visible, export_allowed | → company | A person provisioned for two companies has two user rows. Sessions never span them. |