-- Behavision server schema, migration 001. -- -- Two rules run through all of it: -- -- 1. `client_id` is on every table, even where a join could derive it. That is -- what makes row-level security possible later, and it means a cross-tenant -- leak requires a deliberately wrong WHERE clause rather than a forgotten -- join condition. -- -- 2. Face embeddings are biometric personal data under GDPR and India's DPDP. -- Template inversion is an established attack — published results -- reconstruct a recognisable face from an ArcFace embedding — so the -- "it's just numbers" defence does not hold. They live in their own table, -- are hard-deleted rather than soft-deleted, and every read of them is -- expected to be tenant-scoped. BEGIN; CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pgcrypto; -- ============================================================ tenancy ====== CREATE TABLE clients ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), slug text NOT NULL UNIQUE, -- used in MQTT topics name text NOT NULL, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT clients_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$') ); COMMENT ON COLUMN clients.slug IS 'Appears in MQTT topics as bv/./... and is enforced ' 'by the broker ACL, so it must stay stable and URL/topic safe.'; CREATE TABLE sites ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, slug text NOT NULL, name text NOT NULL, timezone text NOT NULL DEFAULT 'UTC', -- footfall is reported in local time address text NOT NULL DEFAULT '', active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (client_id, slug), CONSTRAINT sites_slug_format CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$') ); -- One row per store PC. Exists so that "this site reported nothing" can be -- distinguished from "this site is switched off" — without it, an agent that -- has been unplugged for a week looks identical to a shop with no customers, -- which is a silent hole in the customer's own report. CREATE TABLE agents ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE, mqtt_username text NOT NULL UNIQUE, -- '.' agent_version text NOT NULL DEFAULT '', engine_version text NOT NULL DEFAULT '', -- Which encoder actually loaded. Embeddings from different models are -- numerically incompatible, so this decides whether a site's vectors can -- be compared with anything else at all. recognition_model text NOT NULL DEFAULT '', last_heartbeat_at timestamptz, last_event_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (site_id) ); CREATE INDEX agents_heartbeat_idx ON agents (last_heartbeat_at); -- ============================================================ people ======= -- A person, scoped to ONE client. -- -- Deliberately not global. Linking the same face across unrelated clients -- would build a cross-company biometric tracking network: legally -- indefensible in every jurisdiction that matters, and commercially dead on -- arrival — no retailer accepts their customer data enriching a competitor's. -- Within one client, sites DO share, which is the feature: a customer -- recognised at the Chennai store is the same record at the Bangalore store. CREATE TABLE visitors ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, label text NOT NULL DEFAULT '', -- 'Visitor 12' until staff name them first_seen_at timestamptz NOT NULL DEFAULT now(), last_seen_at timestamptz, visit_count integer NOT NULL DEFAULT 0, -- Erasure request received. The embeddings are removed outright (see -- below); this row is kept only long enough to stop the same face being -- re-enrolled, and is purged by retention. deleted_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX visitors_client_idx ON visitors (client_id) WHERE deleted_at IS NULL; CREATE INDEX visitors_last_seen_idx ON visitors (client_id, last_seen_at DESC); -- Biometric templates. The most sensitive table in the system. -- -- ON DELETE CASCADE from visitors is doing real work: a GDPR/DPDP erasure -- request must actually destroy the template, not flag it. There is no -- soft-delete column here on purpose. CREATE TABLE visitor_embeddings ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), visitor_id uuid NOT NULL REFERENCES visitors(id) ON DELETE CASCADE, client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, -- Same rule as the edge: vectors from different encoders occupy different -- spaces and must never be compared. Every query filters on this. model text NOT NULL, embedding vector(512) NOT NULL, quality real NOT NULL DEFAULT 0, source_site_id uuid REFERENCES sites(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX visitor_embeddings_lookup_idx ON visitor_embeddings (client_id, model); CREATE INDEX visitor_embeddings_visitor_idx ON visitor_embeddings (visitor_id); -- No ANN (HNSW/IVFFlat) index yet, deliberately. Every search here MUST be -- filtered by client_id, and pgvector's ANN indexes under-return when combined -- with a selective filter — it walks the graph globally and then discards -- other tenants' neighbours, so a client with few vectors can silently get -- zero results. Exact search is microseconds at this scale (measured on the -- edge: 100k vectors, 21.9 ms). Add an index when measurement says to, not -- before. -- What staff collect on the mobile form. Separate from `visitors` because it -- is ordinary PII with a different lifecycle and a different access rule: -- plenty of people should read a customer's name who should never touch a -- biometric template. CREATE TABLE visitor_profiles ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), visitor_id uuid NOT NULL REFERENCES visitors(id) ON DELETE CASCADE, client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, full_name text NOT NULL DEFAULT '', phone text NOT NULL DEFAULT '', email text NOT NULL DEFAULT '', gender text NOT NULL DEFAULT '', date_of_birth date, notes text NOT NULL DEFAULT '', collected_by uuid, -- app_users.id, set by the API collected_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (visitor_id) ); CREATE INDEX visitor_profiles_phone_idx ON visitor_profiles (client_id, phone) WHERE phone <> ''; -- Consent is a record, not a flag: "when, how, and for what" is what an -- auditor asks for, and a boolean cannot answer it. Revocation is a second -- timestamp rather than a delete, so the withdrawal itself stays provable. CREATE TABLE consents ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), visitor_id uuid NOT NULL REFERENCES visitors(id) ON DELETE CASCADE, client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, scope text NOT NULL, -- 'biometric' | 'marketing' | ... method text NOT NULL, -- 'in_store_form' | 'signage' | ... granted_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz, collected_by uuid, evidence jsonb NOT NULL DEFAULT '{}'::jsonb ); CREATE INDEX consents_visitor_idx ON consents (visitor_id, scope); -- ============================================================ activity ===== CREATE TABLE visits ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE, visitor_id uuid REFERENCES visitors(id) ON DELETE CASCADE, -- The agent's own event id. MQTT delivery is at-least-once by design, so -- the consumer MUST be idempotent: a redelivered event after a reconnect -- would otherwise double a store's footfall, which is the one number the -- customer is paying for. source_event_id text NOT NULL, occurred_at timestamptz NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), camera_id text NOT NULL DEFAULT '', is_new_visitor boolean NOT NULL DEFAULT false, similarity real, quality real, -- gender/age/emotion. jsonb because the estimators change and their -- outputs differ (integer age vs bucketed range). attributes jsonb NOT NULL DEFAULT '{}'::jsonb, -- The object key, NOT a URL. A stored public URL is permanent and -- unrevocable; a key is presigned on read, expires, and can be deleted. image_key text NOT NULL DEFAULT '', UNIQUE (client_id, source_event_id) ); CREATE INDEX visits_site_time_idx ON visits (site_id, occurred_at DESC); CREATE INDEX visits_client_time_idx ON visits (client_id, occurred_at DESC); CREATE INDEX visits_visitor_idx ON visits (visitor_id, occurred_at DESC); -- Purchases are decoupled from how they were entered. Manual entry on the -- mobile form today, a POS integration later, same table — `source` is the -- only thing that differs, so adding POS is not a migration. CREATE TABLE purchases ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE, visitor_id uuid REFERENCES visitors(id) ON DELETE SET NULL, visit_id uuid REFERENCES visits(id) ON DELETE SET NULL, -- numeric, never float: money in a float loses cents, and this feeds the -- conversion report the customer judges the product by. amount numeric(14,2) NOT NULL DEFAULT 0, currency char(3) NOT NULL DEFAULT 'INR', items jsonb NOT NULL DEFAULT '[]'::jsonb, source text NOT NULL DEFAULT 'manual', -- 'manual' | 'pos' | 'import' external_ref text NOT NULL DEFAULT '', recorded_by uuid, occurred_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX purchases_site_time_idx ON purchases (site_id, occurred_at DESC); CREATE INDEX purchases_visitor_idx ON purchases (visitor_id); -- ============================================================ access ======= CREATE TABLE app_users ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), client_id uuid REFERENCES clients(id) ON DELETE CASCADE, -- NULL = platform admin email text NOT NULL, password_hash text NOT NULL, full_name text NOT NULL DEFAULT '', role text NOT NULL DEFAULT 'staff', -- 'owner'|'manager'|'staff'|'admin' active boolean NOT NULL DEFAULT true, last_login_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT app_users_role_known CHECK (role IN ('owner', 'manager', 'staff', 'admin')) ); -- Case-insensitive uniqueness, per client. Two clients may each have a -- user with the same address; one client may not have two. coalesce() is -- needed because a NULL client_id (platform admin) does not compare equal -- to itself in a unique index, which would let duplicates through. CREATE UNIQUE INDEX app_users_email_idx ON app_users (coalesce(client_id, '00000000-0000-0000-0000-000000000000'::uuid), lower(email)); -- Every read of a biometric template and every export is worth a row here. -- If a client ever asks "who looked at my customers", an audit trail is the -- only answer that is not a guess. CREATE TABLE audit_log ( id bigserial PRIMARY KEY, client_id uuid REFERENCES clients(id) ON DELETE SET NULL, actor_id uuid, actor_kind text NOT NULL DEFAULT 'user', -- 'user' | 'agent' | 'system' action text NOT NULL, entity text NOT NULL DEFAULT '', entity_id text NOT NULL DEFAULT '', detail jsonb NOT NULL DEFAULT '{}'::jsonb, at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX audit_log_client_time_idx ON audit_log (client_id, at DESC); COMMIT;