Files
Behavision/server/migrations/001_initial.sql
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 11:14:18 +05:30

257 lines
12 KiB
PL/PgSQL

-- 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/<client.slug>.<site.slug>/... 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, -- '<client.slug>.<site.slug>'
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;