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:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

View 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;

View File

@@ -0,0 +1,94 @@
-- Migration 002: who is allowed to ask, and what a fresh PC is told.
--
-- 001 built the data. Nothing could read it: the only writer was the MQTT
-- consumer, authenticated by the broker. This adds the request/response half —
-- staff logging in from the desktop app, and a newly installed store PC
-- collecting its own broker credentials.
BEGIN;
-- ============================================================ sessions =====
-- Opaque tokens in a table, not JWTs.
--
-- A JWT cannot be revoked without a blocklist, which is a session table with
-- extra steps and worse failure modes. This system holds biometric data on
-- shop-floor PCs that get lost, resold and shared between staff, so "log that
-- device out, now" has to actually work. At this volume the lookup is one
-- indexed read.
--
-- Only the SHA-256 of each token is stored. A database dump then contains no
-- usable session — and SHA-256 rather than bcrypt because the token is 256
-- bits from crypto/rand, so there is no dictionary to slow an attacker down
-- through, only a per-request cost to pay.
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
-- Denormalised from app_users so the auth lookup is one row, and so a
-- session cannot outlive a user being moved between clients.
client_id uuid REFERENCES clients(id) ON DELETE CASCADE,
access_hash bytea NOT NULL UNIQUE,
refresh_hash bytea NOT NULL UNIQUE,
access_expires_at timestamptz NOT NULL,
refresh_expires_at timestamptz NOT NULL,
revoked_at timestamptz,
-- Enough to tell one device from another in a session list. Not an
-- identifier, and deliberately not an IP address: a shop's IP tells us
-- where a customer's staff live, which we have no reason to keep.
device text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz
);
CREATE INDEX sessions_user_idx ON sessions (user_id) WHERE revoked_at IS NULL;
CREATE INDEX sessions_expiry_idx ON sessions (refresh_expires_at);
-- ============================================================ enrolment ====
-- A one-shot token that turns an anonymous install into a known site.
--
-- The installer ships with no credentials at all, so a leaked build hands out
-- nothing. The operator types this code once; the server answers with the
-- broker credentials for exactly one site and marks the token spent.
CREATE TABLE site_enrolment_tokens (
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,
token_hash bytea NOT NULL UNIQUE,
label text NOT NULL DEFAULT '',
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX site_enrolment_site_idx ON site_enrolment_tokens (site_id);
-- The broker password this site publishes with, encrypted with the server's
-- own key (AES-GCM, BEHAVISION_SECRET_KEY).
--
-- It has to be recoverable, not hashed: enrolment HANDS IT OUT. Encrypting it
-- means a stolen database dump is not a set of live broker logins, which a
-- plaintext column would be. Mosquitto keeps its own hashed copy in its passwd
-- file; this is the second half of a pair that must be provisioned together.
ALTER TABLE agents ADD COLUMN mqtt_password_enc bytea;
-- ============================================================ health =======
-- Reported by the heartbeat, kept on the agent row because they describe the
-- site right now, not a history worth querying.
--
-- fraction_below_gate is the one that matters: the share of faces this site's
-- cameras saw that fell under the enrolment gate. A footfall number from a
-- badly placed camera is wrong in a way nobody can see from the number itself,
-- so the figure has to travel with its own confidence. Measured on the Office1
-- camera it was 0.727 — 73% of visitors seen and discarded, while the report
-- said what looked like a quiet week.
ALTER TABLE agents ADD COLUMN fraction_below_gate real;
ALTER TABLE agents ADD COLUMN cameras_total integer NOT NULL DEFAULT 0;
ALTER TABLE agents ADD COLUMN cameras_up integer NOT NULL DEFAULT 0;
ALTER TABLE agents ADD COLUMN spool_queued integer NOT NULL DEFAULT 0;
-- Events a site lost because it was offline long enough to overflow its own
-- queue. Cumulative and never reset: a footfall figure with known-lost events
-- behind it must say so.
ALTER TABLE agents ADD COLUMN spool_dropped bigint NOT NULL DEFAULT 0;
COMMIT;

View File

@@ -0,0 +1,42 @@
-- Migration 003: face images, and the credential a shop PC uses to upload one.
--
-- Until now the system stored no images anywhere, which was a deliberate
-- privacy position rather than a missing feature. Turning images on changes
-- that position, so the schema makes the new obligations explicit rather than
-- leaving them to whoever writes the next query:
--
-- * an image is an object KEY, never a URL - a stored URL is permanent and
-- unrevocable, and these are pictures of customers' faces
-- * erasure has to delete the object, so the keys must stay findable
-- * a site uploads through a short-lived presigned URL and never holds
-- bucket credentials
BEGIN;
-- The credential a store PC uses for HTTPS calls it makes on its own behalf -
-- today, asking for an upload URL.
--
-- Separate from the broker password because they authenticate different
-- things: the broker password says "this site may publish events", this says
-- "this site may ask the API for something". Reusing one secret for both means
-- rotating either one breaks the other.
--
-- Hashed, not encrypted: unlike the broker password this is never handed back
-- out. It is shown once at enrolment and the agent keeps it.
ALTER TABLE agents ADD COLUMN api_token_hash bytea;
CREATE UNIQUE INDEX agents_api_token_idx
ON agents (api_token_hash) WHERE api_token_hash IS NOT NULL;
-- When the image for this visit was erased, and by which request. Kept as a
-- record rather than just blanking image_key: "we deleted it" is the thing an
-- auditor asks to see, and an empty column cannot tell you whether an image was
-- deleted or never captured.
ALTER TABLE visits ADD COLUMN image_deleted_at timestamptz;
-- Finding every object belonging to one person is the erasure path's first
-- step, and without an index it is a full scan of every visit the client has
-- ever recorded.
CREATE INDEX visits_image_key_idx ON visits (visitor_id)
WHERE image_key <> '' AND image_deleted_at IS NULL;
COMMIT;

View File

@@ -0,0 +1,33 @@
BEGIN;
-- A monotonic, server-assigned position for every visit, so a live feed can be
-- paged without losing anyone.
--
-- The arrivals feed originally ordered by (occurred_at, id). That is wrong in a
-- way that only appears under the exact condition the feed exists for: several
-- people walking through one door together share an occurred_at to the
-- microsecond, so the tiebreaker was a RANDOM uuid. A visit committed after the
-- reader had moved its cursor, but carrying a lower uuid, sorted behind the
-- cursor and was never delivered - a silent footfall undercount, exactly the
-- class of bug this system is otherwise careful about. Measured live: four
-- simultaneous visits, two delivered.
--
-- occurred_at cannot fix it either. It is the CAMERA's clock, and a site that
-- was offline for a day floods in with yesterday's timestamps; a reader whose
-- cursor is already past them would skip the entire backlog.
--
-- So the feed is ordered by when the SERVER learned of a visit, not by when it
-- happened. Each row still carries occurred_at for display; seq is only ever a
-- position. That is what makes a reconnecting site's backlog get delivered
-- rather than hidden behind a timestamp the reader has passed.
ALTER TABLE visits ADD COLUMN IF NOT EXISTS seq bigserial;
-- The feed always filters by tenant and orders by seq, so this is the index it
-- runs on. Without it every poll is a scan of the whole table.
CREATE INDEX IF NOT EXISTS visits_client_seq_idx ON visits (client_id, seq);
CREATE INDEX IF NOT EXISTS visits_site_seq_idx ON visits (site_id, seq);
-- Not UNIQUE by accident: bigserial already guarantees it, and the constraint
-- would make a future partitioning change harder for no gain.
COMMIT;

View File

@@ -0,0 +1,76 @@
BEGIN;
-- Cameras, owned by head office rather than by the PC they run on.
--
-- Until now a camera existed only in `cameras.json` on one shop's disk, added
-- through the desktop app by somebody standing in that shop. That is fine for
-- the shop and impossible for the tenant: an owner onboarding a new store, or
-- fixing a camera in a branch they are not standing in, had no way to do it.
--
-- The shop PC stays the thing that CONNECTS to the camera - it is on the same
-- LAN, and nothing else can be - so this table is desired state that the agent
-- pulls and applies. The engine's own store remains the running config; these
-- two are reconciled, not merged.
CREATE TABLE site_cameras (
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,
-- The id the ENGINE knows this camera by, and the one that lands in
-- visits.camera_id. Stable for the life of the camera: renaming it would
-- orphan every visit already recorded against the old name.
camera_id text NOT NULL,
label text NOT NULL DEFAULT '',
-- Connection, split into parts rather than stored as one URL. The engine
-- builds the RTSP URL itself with percent-encoded credentials, because a
-- password containing '@' in a hand-assembled URL is the exact bug the
-- original project shipped.
host text NOT NULL DEFAULT '',
port integer NOT NULL DEFAULT 554,
path text NOT NULL DEFAULT '/',
username text NOT NULL DEFAULT '',
-- Encrypted, never hashed: the agent has to be able to USE it. Same
-- treatment as a site's broker password, and for the same reason.
--
-- This is a real widening of what the server holds. An RTSP credential is
-- a live path into the camera itself, and until now it lived only on the
-- shop PC under DPAPI. Putting it here is the price of onboarding a camera
-- from head office, and it is sealed with the site id as additional data so
-- a row copied between sites will not decrypt.
password_enc bytea,
max_width integer NOT NULL DEFAULT 1280,
tuning jsonb NOT NULL DEFAULT '{}'::jsonb,
enabled boolean NOT NULL DEFAULT true,
-- Bumped on every edit. The agent compares it to what it last applied, so
-- a reconcile is cheap when nothing has changed - which is almost always.
revision bigint NOT NULL DEFAULT 1,
-- Observed state, written by the agent. Kept beside desired state on
-- purpose: "this camera is configured" and "this camera is working" are
-- the two halves of the only question anyone asks about a camera, and
-- splitting them across tables makes the screen that answers it a join
-- nobody remembers to write.
connected boolean,
last_seen_at timestamptz,
-- The most recent frame, as an object key. NOT a URL, for the same reason
-- face images are keys: a stored URL is permanent and unrevocable.
snapshot_key text NOT NULL DEFAULT '',
snapshot_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- A tombstone, not a delete. The agent ADOPTS cameras it finds configured
-- locally, so a hard delete would be undone on the next sync by the very
-- camera the operator just removed.
deleted_at timestamptz,
UNIQUE (site_id, camera_id)
);
CREATE INDEX site_cameras_site_idx ON site_cameras (site_id) WHERE deleted_at IS NULL;
CREATE INDEX site_cameras_client_idx ON site_cameras (client_id) WHERE deleted_at IS NULL;
COMMIT;

View File

@@ -0,0 +1,42 @@
BEGIN;
-- Proving a camera works, from an office somewhere else.
--
-- The engine already knows how to answer both questions - `probe_source` says
-- whether a stream can be opened and hands back a frame, `CommissionRun` says
-- whether a person walking past produces a view worth enrolling - and both
-- already phrase their answers for an installer. Neither was reachable from
-- head office, so onboarding a camera meant typing an address and hoping.
--
-- A check is therefore a JOB the shop PC picks up on its next sync, not a call
-- head office makes: a PC behind a router has no inbound route, and the
-- placement check takes 25 seconds of somebody walking about, which is far
-- longer than an HTTP request should live.
ALTER TABLE site_cameras
-- What has been asked for, and when. NULL means nothing is pending.
ADD COLUMN IF NOT EXISTS check_kind text,
ADD COLUMN IF NOT EXISTS check_requested_at timestamptz,
ADD COLUMN IF NOT EXISTS check_seconds integer NOT NULL DEFAULT 25,
-- Claimed by the agent, so a request is not run twice by a PC that synced
-- while the first attempt was still going.
ADD COLUMN IF NOT EXISTS check_started_at timestamptz,
ADD COLUMN IF NOT EXISTS check_finished_at timestamptz,
-- The engine's own answer, stored whole rather than unpacked into columns.
--
-- Deliberate: `verdict`, `headline` and `advice` are written for the person
-- standing next to the camera, and re-wording them in the server and again
-- in the browser is how three descriptions of one failure drift apart. The
-- engine says it once; everything above passes it through.
ADD COLUMN IF NOT EXISTS check_result jsonb,
-- A frame captured during the check. Separate from snapshot_key: that one
-- is the routine picture refreshed every minute, this one is the evidence
-- for a specific check and must not be overwritten by the next refresh.
ADD COLUMN IF NOT EXISTS check_image_key text NOT NULL DEFAULT '';
-- Partial index: the agent asks "is anything pending for my site" on every
-- sync, and almost always the answer is no.
CREATE INDEX IF NOT EXISTS site_cameras_pending_check_idx
ON site_cameras (site_id)
WHERE check_requested_at IS NOT NULL AND check_finished_at IS NULL;
COMMIT;

View File

@@ -0,0 +1,46 @@
BEGIN;
-- One address, one account.
--
-- 001 made the address unique PER CLIENT, so two companies could each have a
-- user called alice@example.com and a platform admin could share an address
-- with a tenant user. The intent was reasonable; it is not implementable. Sign
-- in takes an email and a password and nothing else - no company field, no
-- subdomain - so `UserByEmail` looks up `WHERE lower(email) = $1` and takes the
-- first row Postgres happens to return.
--
-- Measured on a real database with one address held by a platform admin and a
-- tenant owner: the first sign-in succeeded as the admin, `TouchUserLogin`
-- rewrote that row, which moved it to the end of the heap, and every later
-- sign-in with the SAME password returned "Email or password is incorrect."
-- because the other account's hash was now first. The account was not locked,
-- disabled, or wrong - it had simply stopped being the row the query found.
-- Nothing in a log would explain that to anyone.
--
-- So: global uniqueness. Somebody who genuinely needs an account in two
-- companies needs two addresses, which is the ordinary answer everywhere else
-- and is honest about what the sign-in form can express.
-- Fail loudly and name the addresses rather than leaving a half-applied schema
-- for the operator to work out from a constraint violation.
DO $$
DECLARE dupes text;
BEGIN
SELECT string_agg(e, ', ') INTO dupes FROM (
SELECT lower(email) AS e FROM app_users GROUP BY 1 HAVING count(*) > 1
) d;
IF dupes IS NOT NULL THEN
RAISE EXCEPTION
'these addresses have an account in more than one company: %. '
'Give each account its own address before applying this migration.',
dupes;
END IF;
END $$;
DROP INDEX IF EXISTS app_users_email_idx;
-- Same NAME as before on purpose: the API turns a violation of this index into
-- "That email address already has an account", by matching the name.
CREATE UNIQUE INDEX app_users_email_idx ON app_users (lower(email));
COMMIT;