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
This commit is contained in:
256
server/migrations/001_initial.sql
Normal file
256
server/migrations/001_initial.sql
Normal file
@@ -0,0 +1,256 @@
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user