Files
Behavision/server/internal/store/api_images.go
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

153 lines
5.2 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/api"
)
// SetAgentAPIToken stores the hash of a store PC's HTTPS credential.
//
// Hashed, not encrypted, unlike the broker password: this one is never handed
// back out. It is shown once at enrolment and the agent keeps it, so a database
// dump contains nothing usable.
func (s *Store) SetAgentAPIToken(ctx context.Context, agentID string, hash []byte) error {
_, err := s.pool.Exec(ctx,
`UPDATE agents SET api_token_hash = $2 WHERE id = $1::uuid`, agentID, hash)
return err
}
func (s *Store) AgentByToken(ctx context.Context, hash []byte) (api.AgentPrincipal, error) {
var ap api.AgentPrincipal
err := s.pool.QueryRow(ctx, `
SELECT a.id::text, a.client_id::text, a.site_id::text, a.mqtt_username,
c.slug, si.slug
FROM agents a
JOIN sites si ON si.id = a.site_id AND si.active
JOIN clients c ON c.id = a.client_id AND c.active
WHERE a.api_token_hash = $1`, hash).
Scan(&ap.AgentID, &ap.ClientID, &ap.SiteID, &ap.Slug, &ap.Client, &ap.Site)
if errors.Is(err, pgx.ErrNoRows) {
return api.AgentPrincipal{}, errors.New("no such agent")
}
return ap, err
}
// VisitorImageKey is the most recent surviving photo of one person.
//
// image_deleted_at is checked, not just image_key: a key that has been erased
// is still in the row as the record that it WAS erased, and handing it to the
// presigner would produce a link to an object that is gone - or, worse, to one
// that was re-created under the same name.
func (s *Store) VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error) {
var key string
err := s.pool.QueryRow(ctx, `
SELECT image_key FROM visits
WHERE client_id = $1 AND visitor_id = $2::uuid
AND image_key <> '' AND image_deleted_at IS NULL
ORDER BY occurred_at DESC
LIMIT 1`, clientID, visitorID).Scan(&key)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
return key, err
}
// VisitorImageKeys is every object belonging to one person - the first step of
// an erasure request.
func (s *Store) VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT image_key FROM visits
WHERE client_id = $1 AND visitor_id = $2::uuid
AND image_key <> '' AND image_deleted_at IS NULL`, clientID, visitorID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// ForgetVisitor is the database half of erasure.
//
// What goes and what stays is a deliberate line:
//
// - the biometric template is DELETED outright, not flagged. Template
// inversion reconstructs a recognisable face from an ArcFace embedding, so
// a soft-deleted vector is a retained photograph by another name.
// - the profile goes: a name, a phone number and a date of birth are exactly
// what the request is about.
// - visits STAY, with the person unlinked. They are the shop's own footfall
// history, and silently changing last quarter's numbers because one
// customer exercised a right is both wrong and detectable.
// - the visitors row stays with deleted_at set, so the same face cannot be
// re-enrolled as a brand new person the next time they walk in.
func (s *Store) ForgetVisitor(ctx context.Context, clientID, visitorID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
var exists bool
err = tx.QueryRow(ctx,
`SELECT true FROM visitors WHERE id = $1::uuid AND client_id = $2`,
visitorID, clientID).Scan(&exists)
if errors.Is(err, pgx.ErrNoRows) {
return errors.New("no such visitor")
}
if err != nil {
return err
}
if _, err := tx.Exec(ctx, `
DELETE FROM visitor_embeddings
WHERE visitor_id = $1::uuid AND client_id = $2`,
visitorID, clientID); err != nil {
return fmt.Errorf("delete templates: %w", err)
}
if _, err := tx.Exec(ctx, `
DELETE FROM visitor_profiles
WHERE visitor_id = $1::uuid AND client_id = $2`,
visitorID, clientID); err != nil {
return fmt.Errorf("delete profile: %w", err)
}
// The consent record itself survives as a revocation. Deleting it would
// destroy the proof of what we were permitted to do and when, which is the
// thing an auditor actually asks for.
if _, err := tx.Exec(ctx, `
UPDATE consents SET revoked_at = COALESCE(revoked_at, now())
WHERE visitor_id = $1::uuid AND client_id = $2`,
visitorID, clientID); err != nil {
return fmt.Errorf("revoke consents: %w", err)
}
// The objects are already gone from storage by the time this runs; this
// records that, and stops anything presigning a dead key.
if _, err := tx.Exec(ctx, `
UPDATE visits SET image_deleted_at = now()
WHERE client_id = $1 AND visitor_id = $2::uuid
AND image_key <> '' AND image_deleted_at IS NULL`,
clientID, visitorID); err != nil {
return fmt.Errorf("mark images deleted: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE visitors
SET deleted_at = now(), label = 'Erased'
WHERE id = $1::uuid AND client_id = $2`,
visitorID, clientID); err != nil {
return fmt.Errorf("mark visitor erased: %w", err)
}
return tx.Commit(ctx)
}