5 Commits

Author SHA1 Message Date
ecc8bbba6f The app on a laptop reported an engine that was never meant to be there
Signing in on a second Mac showed "engine not reachable at
http://127.0.0.1:8010" and 0 of 0 cameras, on an account whose shops were
running and recognising people the whole time. Nothing was broken: Live() and
Cameras() read only the engine on loopback, so the app answered as though the
person had never signed in - and camera sync goes through the engine, which is
why the count was zero rather than stale.

Having no engine is a normal state. A shop PC watches cameras; an owner's
laptop, a manager's machine and a second till being set up do not, and all
three are signed in to the same estate. Both methods now fall back to head
office when loopback fails and somebody is signed in. Loopback is still tried
first: a real shop PC must never be shown a minute-old summary when the engine
two milliseconds away has the live one.

Decisions worth keeping:

- Viewing is on the snapshot, not inferred per screen. Three surfaces read it,
  and a screen that computed it separately is how the shops screen once came
  out labelled Working, in green, above "2 of 3 cameras not connecting".
- fraction_below_gate takes the WORST shop, never an average. 0.10 against
  0.73 averages to 0.42 and hides the only shop anyone needs to visit.
- A remote camera is flagged, and Edit, Remove and Check placement are
  withheld. They talk to a camera on a LAN this computer cannot reach, and a
  button that cannot work is worse than one that is absent.
- connected is three states. null is "no shop computer has reported yet" and
  reads as waiting; false is "Not connecting". A bare false sends somebody to
  check cabling on a camera nobody has tried to reach.
- Snapshots are fetched in Go as data: URIs and cached by snapshot_at. A
  webview <img> resolves a relative src against wails:// and cannot send the
  bearer - the problem VisitorImage already solved - and this screen polls
  every 8 seconds at ~90 KB a camera.
- With no engine AND no session, the engine error is still the answer. The
  person is most likely setting this PC up.

The picture is the last snapshot and the banner says so: there is no live
video from here, because the engine's MJPEG stream is on the shop PC's
loopback behind a router with no inbound route. The LiveHub relay head office
uses is the answer to that and is a further step for this client.

Verified against production: five arrivals and two cameras parsed from the
real API. viewing_test.go covers the fallback, the worst-shop rule, the
withheld credentials and that an unchanged snapshot is fetched once across
two polls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 16:33:32 +05:30
97a8ecc03a Setup told a Mac with Python 3.12 on it to go and install Python
Found by running behavision-setup against a clean state directory the
way a second machine will, which had never been done. It failed at the
first step:

    Setup did not finish: no Python 3.10 or newer was found
      Found, but too old: python3 3.9

on a machine that has 3.12. The search was `python3` then `python`, and
on macOS `/usr/bin/python3` is ALWAYS the Command Line Tools build -
3.9 on current macOS, below the 3.10 floor. Anything newer installs as
`python3.12`, under Homebrew, as a framework, or somewhere a GUI
application's minimal PATH never sees.

So it now tries versioned names newest-first, then the plain ones, then
the four directories macOS actually uses - and absolute candidates are
stat'd rather than passed to LookPath, which only searches PATH. It
found /Users/tenext/.local/opt/python3.12/bin/python3.12, which is
exactly the interpreter it had been ignoring.

With that, the whole install completes on a Mac for the first time:
venv, engine and dependencies, models, agent.json, and "Engine starts
and answers - verified". EXIT=0, a 298 MB runtime.

Also the last thing it prints, which is the first thing an operator
acts on. It said "Start Behavision from the Start menu" and "it appears
in the system tray; right-click there to stop it". On macOS there is no
Start menu and, deliberately, no tray at all - so the finishing message
was describing a machine the user was not sitting at, on the one step
where setup had otherwise succeeded. It now says to right-click the app
the first time because the build is not notarised, and that closing the
window stops recognition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:44:25 +05:30
50d122e5f0 An unused constant was a Stop() that could hang forever
Ran staticcheck across all three Go modules for the first time. server
(23k lines) and desktop came back clean. agent had seven findings, and
one of them was not tidiness.

`stopGrace = 10 * time.Second` was declared and wired to nothing.
Stop() cancels the context, cmd.Cancel kills the process tree, and then
Stop() blocks on cmd.Wait() - which, with no WaitDelay set, waits not
just for the process but for every writer of its stdout pipe to close.
One grandchild still holding that pipe hangs Wait, hangs Stop, and on the
desktop app that is the tray's Quit never returning. The constant named
the intent and nothing read it. cmd.WaitDelay = stopGrace is the line
that was missing.

The rest were real but small: an unused field in the live relay, an
unused sleep helper in the pump, and "net/url" imported twice under two
names - both genuinely used, in two functions doing the same job for the
same reason, so they are unified rather than one deleted. My first pass
deleted the wrong one on a bad grep and the build caught it immediately.

Three findings are suppressed rather than fixed, with the reason stated:

- Two "error strings should not end with punctuation". Both are
  multi-line messages a shop operator reads at a counter, not errors
  anything wraps. ST1005 exists because wrapped errors concatenate
  mid-sentence; stripping the full stops would run three sentences
  together to satisfy a rule that does not apply.
- A deliberately nil context in a pump test - the point of the test is
  that an unconnected client does not panic. It already carried
  //nolint:staticcheck, which is golangci-lint's directive and
  staticcheck ignores, which is why it kept being reported.

Also tidied agent/go.mod, which had paho and x/sys marked indirect while
being imported directly.

All three modules clean, all suites pass: 21 Go packages, 226 engine
tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:14:09 +05:30
dd3331ee9d Commit the technical dossier, marked as the snapshot it is
511 lines extracted from the code at release 0.4.1 / schema 013, and
accurate for that point. The repository is nine releases and a schema
past it.

A stale document that states its own version reads as current to anyone
skimming, which is the same failure this project keeps catching
elsewhere: wrong in a way nobody can detect. So the top now lists what
it predates by name - the motion gate, Gallery.health, tenantOnly, the
password endpoint, customers and merge, the admin drill-down, sales and
dashboard, migration 014, the macOS build - and points at API.md and
CLAUDE.md, which are kept current.

No credential values in it; the matches for password/secret/token are
environment variable NAMES and package paths describing where secrets
live, which is what a dossier should say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:06:19 +05:30
68a50d10b1 Record the desktop work: the tray, macOS, and the release
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 15:01:55 +05:30
19 changed files with 1237 additions and 61 deletions

139
CLAUDE.md
View File

@@ -2568,6 +2568,93 @@ raw string that is two literal characters and Postgres requires exactly one —
it would have failed the **whole** customer search at runtime, on a query no
in-memory test executes.
## The desktop app on two platforms, and three bugs found by launching it
All three were reported or found by *starting the app the way a person
starts one*, and all three had survived every test.
### "Open dashboard" in the tray did nothing reliable
`runtime.Show` was wrong three times over and the first is why it failed
rather than merely misbehaved. Wails implements `Show()` as a bare
`mainWindow.Show()` while `WindowShow()` wraps the identical work in
`runtime.LockOSThread`. Win32 window operations must run on the thread owning
the window's message pump, and the tray handler runs on the **systray's**
goroutine, which never is.
Two more, each sufficient alone: showing is not un-minimising (hidden and
minimised are different states), and Windows refuses the foreground to a
process that does not already hold it — so the window returned *behind*
whatever was being looked at. A tray click is by definition a moment when the
app is not in front, so that is every time, not an edge case. The
always-on-top flip is the ordinary way to ask, and it is why this runs in a
goroutine: a menu loop that sleeps is a tray that ignores the next click.
`OnSecondInstanceLaunch` had the same shape and is hit far more often —
double-clicking the desktop icon while the app is already running.
### The engine inherited whatever directory launched the app
Nothing ever set `cmd.Dir`, so the child took the parent's — and an app
started by double-clicking its bundle is handed `/`. On macOS the symptom was
`python: No module named behavision` forever, because the dev engine runs as
`-m behavision`, which resolves against the working directory.
**The same app launched from a terminal inside the repo worked perfectly**,
which is the shape of a bug that survives every test a developer runs.
`Config.EngineDir` (empty = install root) is set by both launchers, which had
identical code and the identical omission.
### macOS is a supported DEVELOPER target, not a product
Indian retail counters are Windows. A Mac product means an Apple Developer
account, notarisation, a second installer, and DPAPI having no macOS
equivalent — a permanent second platform for customers who do not have Macs.
What it *is* worth is demoing on the machine this is written on.
It cost one missing framework and then two crashes:
```
link Undefined symbols: _OBJC_CLASS_$_UTType
Wails' darwin frontend references it and does not link
UniformTypeIdentifiers. Fails at the LINK step after compiling
everything, so it reads like a broken toolchain.
systray.Run SIGTRAP in cgo — nativeLoop takes the macOS main
run loop and Wails already has it
RunWithExternalLoop "NSWindow should only be instantiated on the main
thread!" — still builds AppKit objects, and
OnStartup is not the main thread
```
So **there is no tray on macOS**, and the consequence is handled rather than
left: with no tray there is no way back from a hidden window and no way to
quit, so on macOS closing the window quits and stops the engine. Same rule the
tray's Quit follows — never leave it watching with no visible control.
**The window could not be maximised**, and that was an omission with a precise
consequence. Wails computes `zoomable` *inside* `if frontendOptions.Mac !=
nil`; the variable defaults to 0, and the native side then does
`if (!zoomable && resizable) [zoomButton setEnabled: NO]`. There was a
`Windows` options block and no `Mac` one — so the platform that was configured
behaved and the platform that was not looked broken.
### A macOS release costs nothing extra, and the reason is worth keeping
`release.sh` has never used PyInstaller. The Windows package is a **source
install**: a pure-Python wheel plus `behavision-setup`, which builds a venv on
the target machine, done that way because PyInstaller cannot cross-compile.
macOS therefore needs nothing new — same wheel, same setup tool, a natively
built `.app` instead of the `.exe`. `MAC=1 ./release.sh` opts in.
Not notarised, and that is stated in the notes rather than discovered: macOS
*blocks* an unsigned download rather than warning like SmartScreen, so a first
launch needs right-click → Open.
Verified by extracting the published zip to a clean directory: signature
intact through the round trip, the app runs, and it reports *"engine not
installed yet; run behavision-setup, then Start"* — the correct fresh-machine
state rather than a crash.
## Setting up on a new machine
1. Copy the `Behavision` folder **including `.env`** (gitignored, holds
@@ -3220,3 +3307,55 @@ Against real Postgres, on the demo tenant:
- HTML, PDF, GIF and empty bodies are all refused as face images: the check is
on the magic bytes, never the `Content-Type` header, because this endpoint
stores what it is handed and serves it back to a browser.
## Viewer mode: the app on a computer that is not watching anything
Signing in on a second Mac showed `engine not reachable at
http://127.0.0.1:8010` and **0 of 0 cameras**, on an account whose shops were
running and recognising people the whole time. Nothing was broken. `App.Live()`
and `App.Cameras()` read **only** `a.local`, so the app answered as though the
person had never signed in — and camera sync goes *through* the engine, which
is why the count was zero rather than merely stale.
That is the wrong model of what this application is. A shop PC watches
cameras; an owner's laptop, a manager's machine, a second till being set up do
not, and all three are signed in to the same estate. **Having no engine is a
normal state, not a failure**, and the app now says what it can see from where
it is standing instead of reporting the absence of something it does not need.
Both methods try loopback first and fall back to head office when it fails and
somebody is signed in. The order matters: a real shop PC must never be shown
head office's minute-old summary when the engine two milliseconds away has the
live one.
- **`Viewing` is on the snapshot, not inferred in the browser.** Three surfaces
read it — the banner, the camera tally, the getting-started panel — and a
screen that computed it separately is how the shops screen once came out
labelled **Working**, in green, directly above *"2 of 3 cameras not
connecting"*. One fact, one place, the same rule as the tray being a client
of `EngineStatus()`.
- **`fraction_below_gate` is the WORST shop, never an average.** 0.10 against
0.73 averages to 0.42 and hides the only shop anyone needs to visit. Same
rule the heartbeat already follows with `worst_site`.
- **A remote camera is flagged `remote: true`, and the screen withholds Edit,
Remove and Check placement.** Those talk to a camera on a LAN this computer
cannot reach, and an Edit button that cannot work is worse than one that is
absent. The tenant response structurally cannot carry `host`, `username` or
`has_password`, so nothing here can invent them either — a test asserts that.
- **`connected` is three states.** `null` is "no shop computer has reported on
this yet" and reads as *waiting*; `false` is *"Not connecting"*. A bare false
sends somebody to check cabling on a camera nobody has tried to reach.
- **The picture is the last snapshot, and it says so.** There is no live video
here: the engine's MJPEG stream is on the shop PC's loopback behind a router
with no inbound route. Head office's `LiveHub` relay is the answer to that
and is a further step for this client; the banner does not imply otherwise.
- **Snapshots are fetched in Go and passed as `data:` URIs, cached by
`snapshot_at`.** A webview `<img>` resolves a relative src against `wails://`
and cannot send the session's bearer — the same problem `VisitorImage`
already solved — and this screen polls every 8 seconds at ~90 KB a camera, so
re-fetching an unchanged frame is megabytes an hour to redraw the same
picture. Keyed on the server's `snapshot_at`, because a new timestamp is the
only thing that means a new photograph.
- **With no engine AND nobody signed in, the engine error is still the answer.**
There is nothing else to show and the person is most likely setting this PC
up; naming head office there points them at a step they have not reached.

View File

@@ -93,6 +93,12 @@ func run() error {
}
if running := behavisionRunning(); running != "" {
// lint:ignore ST1005 — this is not a wrapped error, it is the whole
// message an operator reads at a shop counter. ST1005 forbids
// trailing punctuation because errors get concatenated mid-sentence;
// nothing wraps this one, and stripping the full stops would make
// three sentences run together.
//lint:ignore ST1005 operator-facing prose, never wrapped
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
"re-claims the shop while the open app still holds the old credentials.", running)
@@ -179,8 +185,19 @@ func run() error {
}
fmt.Println()
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
fmt.Println(" It appears in the system tray; right-click there to stop it.")
// The last thing setup says is the first thing the operator does, so it
// has to describe THEIR machine. On macOS there is no Start menu and,
// deliberately, no tray at all - telling somebody to right-click a tray
// icon that does not exist is how software loses their trust on the step
// where it was otherwise finished.
if runtime.GOOS == "windows" {
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
fmt.Println(" It appears in the system tray; right-click there to stop it.")
} else {
fmt.Println(" Done. Open Behavision.app - right-click it and choose Open the")
fmt.Println(" first time, because this build is not notarised.")
fmt.Println(" There is no tray on macOS: closing the window stops recognition.")
}
fmt.Println()
return nil
}
@@ -241,13 +258,53 @@ func findPython() (string, string, error) {
if runtime.GOOS == "windows" {
cands = append(cands, cand{"py", []string{"-3"}})
}
// Versioned names FIRST, newest first, and this is not belt-and-braces on
// macOS - it is the only thing that works. `/usr/bin/python3` there is
// always the Command Line Tools build, 3.9 on current macOS, which is
// below the 3.10 floor. Anything newer installs as `python3.12` or into a
// directory that is not on a GUI application's PATH. Searching only
// `python3` therefore told a Mac with Python 3.12 sitting on it to go and
// install Python - measured on this machine, which has 3.12 under
// ~/.local/opt and reported "Found, but too old: python3 3.9".
versions := []string{"3.14", "3.13", "3.12", "3.11", "3.10"}
for _, v := range versions {
cands = append(cands, cand{"python" + v, nil})
}
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
// And the places a Mac puts an interpreter that LookPath will not find,
// because a double-clicked app inherits a minimal PATH rather than the
// one a shell profile builds.
if runtime.GOOS != "windows" {
home, _ := os.UserHomeDir()
for _, v := range versions {
for _, dir := range []string{
"/opt/homebrew/bin",
"/usr/local/bin",
"/Library/Frameworks/Python.framework/Versions/" + v + "/bin",
filepath.Join(home, ".local", "opt", "python"+v, "bin"),
} {
cands = append(cands, cand{filepath.Join(dir, "python"+v), nil})
}
}
}
var tried []string
for _, c := range cands {
exe, err := exec.LookPath(c.exe)
if err != nil {
continue
exe := c.exe
if filepath.IsAbs(exe) {
// An absolute candidate is a guess about where an interpreter
// might be; most will not exist, and that is not an error.
if fi, err := os.Stat(exe); err != nil || fi.IsDir() {
continue
}
} else {
found, err := exec.LookPath(exe)
if err != nil {
continue
}
exe = found
}
args := append(append([]string{}, c.args...), "-c",
"import sys;print('%d.%d'%sys.version_info[:2])")

View File

@@ -3,9 +3,12 @@ module github.com/loyaly/behavision-agent
go 1.22
require (
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
github.com/eclipse/paho.mqtt.golang v1.4.3
golang.org/x/sys v0.20.0
)
require (
github.com/gorilla/websocket v1.5.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.20.0 // indirect
)

View File

@@ -81,6 +81,7 @@ usage: %s <command>
// agent.json - which is the state that screen was built to end.
func cmdClaim(args []string) error {
if len(args) == 0 {
//lint:ignore ST1005 usage text read by a person, never wrapped
return fmt.Errorf("usage: behavision-agent claim <installation code>\n" +
"Ask whoever manages your shops for one - they can create it from\n" +
"the Behavision platform, under the shop.")

View File

@@ -36,7 +36,6 @@ type Live struct {
FPS float64
Width int
Quality int
pollDelay time.Duration
}
// Defaults, measured against the office camera rather than guessed.

View File

@@ -244,6 +244,13 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
}
return kill()
}
// Cancel sends the kill; WaitDelay bounds how long Wait() then waits for
// the output pipes to close. Without it Wait() blocks until every writer
// is gone - and Stop() blocks on Wait() - so one grandchild still holding
// the engine's stdout hangs Stop FOREVER, which on the desktop app means
// the tray's Quit never returns. stopGrace was declared for exactly this
// and never wired to anything; staticcheck found it as an unused const.
cmd.WaitDelay = stopGrace
prepare(cmd)
if err := cmd.Start(); err != nil {
return fmt.Errorf("engine failed to start: %w", err)

View File

@@ -18,7 +18,6 @@ import (
"log"
"net"
"net/url"
neturl "net/url"
"os"
"strings"
"time"
@@ -225,7 +224,7 @@ func checkTransport(raw string) error {
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
// "[" and refuses a perfectly good loopback address.
u, err := neturl.Parse(raw)
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
}

View File

@@ -92,7 +92,11 @@ func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
// The pump calls this on every tick; a nil-client panic would take the
// whole agent down instead of backing off.
c := &Client{}
if err := c.Publish(nil, "t", []byte("{}")); err == nil { //nolint:staticcheck
// The nil context is the POINT: the pump must not panic on a client that
// never connected. //nolint is golangci-lint's directive and staticcheck
// ignores it, which is why this kept being reported.
//lint:ignore SA1012 passing nil is what is under test
if err := c.Publish(nil, "t", []byte("{}")); err == nil {
t.Fatal("publish on an unconnected client reported success")
}
if c.Connected() {

View File

@@ -205,14 +205,3 @@ func (p *Pump) logf(format string, args ...any) {
p.Log.Printf(format, args...)
}
}
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}

View File

@@ -616,10 +616,43 @@ func (a *App) StopEngine() EngineStatus {
// ---------------------------------------------------------------- cameras --
// Cameras lists this PC's cameras, or the company's if this PC has none of
// its own.
//
// The distinction is load-bearing and the UI is told which it got. A camera
// from the local engine is one THIS machine can reach, edit and stream. One
// from head office is a camera at a shop somewhere else: it has a snapshot
// and a connection state, and it cannot be edited from here because the shop
// PC on that LAN is the only thing that can reach it. Offering an Edit button
// that could not work would be worse than not showing the camera at all.
func (a *App) Cameras() ([]map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.local.Cameras(ctx)
cams, err := a.local.Cameras(ctx)
if err == nil {
return cams, nil
}
if !a.cloud.LoggedIn() {
return nil, err
}
remote, rerr := a.cloud.RemoteCameras(ctx)
if rerr != nil {
return nil, err // the local failure is the one worth reporting
}
out := make([]map[string]any, 0, len(remote))
for _, c := range remote {
out = append(out, map[string]any{
"id": c.ID, "camera_id": c.CameraID, "label": c.Label,
"site": c.Site, "enabled": c.Enabled,
"connected": c.Connected, "last_seen_at": c.LastSeenAt,
"snapshot": c.Snapshot, "snapshot_at": c.SnapshotAt,
// What the screen keys off to hide Edit, Test and Check: this
// camera is on a network this PC cannot reach.
"remote": true,
})
}
return out, nil
}
// DiscoverCameras lists the cameras on this PC's network, so the add-camera
@@ -691,20 +724,93 @@ func (a *App) StreamURL(cameraID string) string {
type LiveSnapshot struct {
Stats map[string]any `json:"stats"`
Events []map[string]any `json:"events"`
// Viewing is true when none of this came from an engine on THIS PC. The
// screen must say so: the numbers are the company's, not this machine's,
// and a laptop in a hotel showing "2 cameras live" without that word
// would be claiming to be watching a shop it cannot see.
Viewing bool `json:"viewing"`
}
// Live is what the shop PC sees, and falls back to what HEAD OFFICE sees.
//
// A PC with no engine is not necessarily broken - it is somebody signed in on
// a laptop away from the shop, which is the ordinary way an owner looks at
// their estate. Until now that produced "engine not reachable at
// 127.0.0.1:8010", an accurate sentence and a useless one when the reader was
// never expecting an engine on that machine.
//
// The local engine always wins when it is there: it is this shop's own
// ground truth and it is live rather than a heartbeat old.
func (a *App) Live() (LiveSnapshot, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
stats, err := a.local.Stats(ctx)
if err == nil {
events, eerr := a.local.Events(ctx, 40)
if eerr == nil {
return LiveSnapshot{Stats: stats, Events: events}, nil
}
}
// No engine here. If nobody is signed in either, the honest answer is
// still the local error - there is nothing else to show and the person
// is most likely setting this PC up.
if !a.cloud.LoggedIn() {
return LiveSnapshot{}, err
}
return a.liveFromCloud(ctx)
}
// liveFromCloud builds the same shape the Live screen already renders, out of
// the estate's own feed, so the view needs no second code path.
func (a *App) liveFromCloud(ctx context.Context) (LiveSnapshot, error) {
sites, err := a.cloud.Sites(ctx)
if err != nil {
return LiveSnapshot{}, err
}
events, err := a.local.Events(ctx, 40)
arrivals, err := a.cloud.Arrivals(ctx, 40)
if err != nil {
return LiveSnapshot{}, err
}
return LiveSnapshot{Stats: stats, Events: events}, nil
// The counters are summed across the estate, and fraction_below_gate
// takes the WORST site rather than an average - one badly placed camera
// is a hole in the numbers, and averaging it against three good ones
// hides the only site anyone needs to visit. Same rule the heartbeat
// already follows.
var up, total int
worst := 0.0
people := map[string]struct{}{}
for _, s := range sites {
up, total = up+s.CamerasUp, total+s.CamerasTotal
if s.FractionBelowGate > worst {
worst = s.FractionBelowGate
}
}
events := make([]map[string]any, 0, len(arrivals))
for _, v := range arrivals {
if v.VisitorID != "" {
people[v.VisitorID] = struct{}{}
}
events = append(events, map[string]any{
"type": map[bool]string{true: "person.new", false: "person.seen"}[v.IsNew],
"ts": v.OccurredAt, "camera_id": v.CameraID,
"data": map[string]any{
"label": v.Label, "ref": v.Ref, "site": v.Site,
"similarity": v.Similarity, "attributes": v.Attributes,
},
})
}
return LiveSnapshot{
Viewing: true,
Events: events,
Stats: map[string]any{
"cameras": []map[string]any{},
"gallery": map[string]any{"identities": len(people), "sightings": len(arrivals)},
"cameras_up": up, "cameras_total": total,
"fraction_below_gate": worst,
},
}, nil
}
// ---------------------------------------------------------------- reports --

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
<script type="module" crossorigin src="./assets/index-Be_Iv2Nz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-lhDNZRcC.css">
<script type="module" crossorigin src="./assets/index-vAtlvw9l.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-xWw5ie4A.css">
</head>
<body>
<div id="root"></div>

View File

@@ -634,3 +634,17 @@ tr.click { cursor: pointer; } tr.click:hover td { background: var(--s2); }
.starter .note { padding: var(--sp-3) var(--sp-4); font-size: 12px; }
.btn.ghost { background: none; border-color: transparent; color: var(--ink-3); }
.btn.ghost:hover { color: var(--ink); }
/* Viewer mode: this PC has no engine, so the screens show the company's own
data from head office. Informational, not an error - it is the ordinary
state of a laptop away from a shop, and styling it red would train people
to ignore the red that means something. */
.viewing {
display: flex; gap: 10px; align-items: flex-start;
padding: 12px 14px; margin-bottom: 14px;
border: 1px solid var(--line); border-radius: 10px;
background: color-mix(in srgb, var(--accent) 7%, transparent);
color: var(--ink-2); font-size: 13px; line-height: 1.5;
}
.viewing b { color: var(--ink); font-weight: 600; }
.viewing svg { flex: none; margin-top: 2px; color: var(--accent); }

View File

@@ -19,7 +19,14 @@ export default function Cameras() {
const [editing, setEditing] = useState(null)
const [check, setCheck] = useState(null)
const cams = data ?? []
const streams = useStreamURLs(cams)
// Every camera is remote or none is: this list comes from the engine on
// loopback, and when that is unreachable the whole list comes from head
// office instead. A remote camera is on a network this computer cannot
// reach, so the picture is the shop PC's last snapshot and the buttons that
// would talk to the camera are not offered - one that cannot work is worse
// than one that is absent.
const remote = cams.some(c => c.remote)
const streams = useStreamURLs(remote ? [] : cams)
async function remove(cam) {
if (!confirm(`Remove ${cam.id}? Recognition from it stops immediately.`)) return
@@ -31,13 +38,21 @@ export default function Cameras() {
<header className="pagehead">
<div>
<h2>Cameras</h2>
<p>Add a camera, then prove it can see faces with a walk-past. Only then is it working.</p>
<p>{remote
? 'The cameras across your shops, as the shop computers last reported them.'
: 'Add a camera, then prove it can see faces with a walk-past. Only then is it working.'}</p>
</div>
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
{!remote && <button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
<Icon.Plus size={15} />Add camera
</button>
</button>}
</header>
{remote && <div className="viewing">
<b>Viewing your shops from here.</b> These cameras are wired to the shop
computers, so they are set up and checked there. The picture is each
camera's most recent frame, not live video.
</div>}
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{cams.length === 0
@@ -64,40 +79,57 @@ export default function Cameras() {
}
function CameraCard({ cam, stream, onEdit, onCheck, onRemove }) {
const conn = cam.connected === undefined ? { tone: 'idle', label: 'Engine stopped' }
// Three states, not two, and the third is why `connected` is a pointer on
// the wire: null means no shop computer has reported on this camera yet,
// which reads as waiting rather than as a fault to go and investigate.
const conn = cam.connected === undefined || cam.connected === null
? (cam.remote ? { tone: 'idle', label: 'Waiting for the shop computer' }
: { tone: 'idle', label: 'Engine stopped' })
: cam.connected ? { tone: 'ok', label: 'Connected' } : { tone: 'bad', label: 'Not connecting' }
// The last placement verdict, so "proven" survives closing the sheet. Only
// `good` is a pass: marginal means half the visitors are silently discarded.
const { data: last } = usePolled(() => api.placementResult(cam.id), 15000, [cam.id])
// Never asked for a remote camera: that answer lives on the shop computer,
// and polling loopback for it here only produces an error every 15 seconds.
const { data: last } = usePolled(
() => cam.remote ? Promise.resolve(null) : api.placementResult(cam.id), 15000, [cam.id])
const proof = !last || last.running || !last.verdict || last.verdict === 'starting'
? { tone: 'miss', label: 'Not yet proven', text: 'Walk past it once and Behavision will tell you if the placement works.' }
: last.verdict === 'good'
? { tone: 'seen', label: 'Proven', text: last.headline || 'Faces recognised on a walk-past.' }
: { tone: 'miss', label: 'Not proven', text: last.headline || 'Move the camera and check again.' }
const shot = cam.snapshot?.available ? cam.snapshot.url : ''
return (
<article className="camcard">
<div className="camview">
{stream
? <img src={stream} alt={cam.id} />
{stream || shot
? <img src={stream || shot} alt={cam.id} />
: <div className="placeholder"><Icon.NoCamera size={34} /></div>}
<span className={`pill ${conn.tone === 'idle' ? '' : conn.tone} over`}><i className={`dot ${conn.tone}`} />{conn.label}</span>
</div>
<div className="cambody">
<div className="camtitle">
<div>
<h3>{cam.id}</h3>
<span className="mono note">{cam.host || cam.url}{cam.path ? ` · ${cam.path}` : ''}</span>
<h3>{cam.label || cam.camera_id || cam.id}</h3>
<span className="mono note">{cam.remote
? cam.site || cam.site_slug || ''
: `${cam.host || cam.url}${cam.path ? ` · ${cam.path}` : ''}`}</span>
</div>
<div className="camactions">
{!cam.remote && <div className="camactions">
<button className="btn sm" onClick={onEdit}>Edit</button>
<button className="btn sm danger" onClick={onRemove}>Remove</button>
</div>
</div>
<div className="camproof">
<span className={`tag ${proof.tone}`}>{proof.label}</span>
<span className="note">{proof.text}</span>
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
</div>}
</div>
{cam.remote
? <div className="camproof">
<span className="note">{cam.snapshot?.available
? 'Last picture from the shop computer.'
: cam.snapshot?.reason || 'No picture yet from the shop computer.'}</span>
</div>
: <div className="camproof">
<span className={`tag ${proof.tone}`}>{proof.label}</span>
<span className="note">{proof.text}</span>
<button className={`btn sm ${proof.tone === 'seen' ? '' : 'primary'}`} onClick={onCheck}><Icon.Play size={13} />{proof.tone === 'seen' ? 'Check again' : 'Check placement'}</button>
</div>}
</div>
</article>
)

View File

@@ -23,15 +23,22 @@ export default function Live({ onNavigate }) {
const cameras = data?.stats?.cameras ?? []
const gallery = data?.stats?.gallery ?? {}
const events = data?.events ?? []
// No engine on THIS PC, so everything below came from head office. It has to
// be said rather than implied: a laptop in a hotel showing "2 cameras live"
// without this line is claiming to watch a shop it cannot see.
const viewing = data?.viewing === true
// fraction_below_gate is the number that decides a site: what share of the
// faces this camera saw were too poor to enrol. Surfaced rather than buried,
// because a high value looks exactly like "a quiet day".
const worst = cameras.reduce((acc, c) => {
const f = c?.pipeline?.best_quality?.fraction_below_gate
return typeof f === 'number' && f > acc ? f : acc
}, 0)
const up = cameras.filter(c => c.connected).length
const worst = viewing
? (data?.stats?.fraction_below_gate ?? 0)
: cameras.reduce((acc, c) => {
const f = c?.pipeline?.best_quality?.fraction_below_gate
return typeof f === 'number' && f > acc ? f : acc
}, 0)
// Viewing: the server already summed these across the estate.
const up = viewing ? (data?.stats?.cameras_up ?? 0) : cameras.filter(c => c.connected).length
const arrivals = events.filter(e => e.type === 'person.new' || e.type === 'person.seen')
const freshest = useFreshest(arrivals[0])
@@ -40,14 +47,29 @@ export default function Live({ onNavigate }) {
<div className="page">
<header>
<h2>Live</h2>
<p>Who is in the shop, and whether it is reaching head office.</p>
<p>{viewing
? 'Your shops, as head office sees them.'
: 'Who is in the shop, and whether it is reaching head office.'}</p>
</header>
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
{viewing && (
<div className="viewing">
<Icon.Cloud size={15} />
<span><b>Viewing your shops from here.</b> This computer is not watching
any cameras itself — everything below is what your shop PCs reported.
To recognise people on this machine, it has to be on the same network
as a camera.</span>
</div>
)}
<PipelineStrip pipe={pipe} cameras={cameras} up={up} />
<GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />
{/* Getting Started walks somebody through setting up a camera on THIS
PC — not what a viewer is doing, and not something they could finish
from here. */}
{!viewing && <GettingStarted cameras={cameras} arrivals={arrivals} onNavigate={onNavigate} />}
<div className="panel arrivals-panel">
<div className="panelhead">

View File

@@ -39,6 +39,19 @@ type Client struct {
// single-use refresh token.
refreshMu sync.Mutex
onRefresh func(Session)
// Camera snapshots already fetched, keyed by camera id. The Cameras screen
// polls every 8 seconds and a snapshot is ~90 KB, so re-fetching one that
// has not changed would put megabytes an hour on the wire to redraw the
// same picture - the same trap the web app's useAuthedImage avoids by
// keying on the url rather than the object around it.
shotMu sync.Mutex
shots map[string]cachedShot
}
type cachedShot struct {
at string // the server's snapshot_at; a new one is a new picture
uri string
}
type User struct {
@@ -629,3 +642,107 @@ func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
"items": items, "source": "manual", "notes": notes,
}, nil)
}
// ---------------------------------------------------------------- viewing --
//
// A PC with no engine of its own is not broken, it is a VIEWER: somebody
// signed in on a laptop away from the shop. Everything below reads head
// office so those screens have something true to show instead of "engine not
// reachable", which is an accurate sentence and a useless one when the reader
// was never expecting an engine on that machine.
// Arrival is one visit as the estate's feed reports it, across every shop -
// not just this PC's. `GET /api/visits`.
type Arrival struct {
VisitID string `json:"visit_id"`
VisitRef string `json:"visit_ref"`
OccurredAt string `json:"occurred_at"`
Site string `json:"site"`
SiteSlug string `json:"site_slug"`
CameraID string `json:"camera_id"`
VisitorID string `json:"visitor_id"`
Ref string `json:"ref"`
Label string `json:"label"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity"`
Attributes map[string]any `json:"attributes"`
Image Photo `json:"image"`
}
// RemoteCamera is a camera as HEAD OFFICE knows it. Deliberately not the same
// type the local engine returns: this one can never be edited from here (the
// shop PC on that LAN is the only thing that can reach it) and it carries a
// snapshot rather than a stream.
type RemoteCamera struct {
ID string `json:"id"`
CameraID string `json:"camera_id"`
Label string `json:"label"`
Site string `json:"site"`
SiteSlug string `json:"site_slug"`
Enabled bool `json:"enabled"`
Connected *bool `json:"connected"`
LastSeenAt string `json:"last_seen_at"`
Snapshot Photo `json:"snapshot"`
SnapshotAt string `json:"snapshot_at"`
}
// Arrivals reads the estate's recent visits, newest last.
func (c *Client) Arrivals(ctx context.Context, limit int) ([]Arrival, error) {
var out struct {
Arrivals []Arrival `json:"arrivals"`
}
if err := c.send(ctx, http.MethodGet,
fmt.Sprintf("/api/visits?limit=%d", limit), nil, &out); err != nil {
return nil, err
}
return out.Arrivals, nil
}
// RemoteCameras lists every camera head office knows about for this company.
func (c *Client) RemoteCameras(ctx context.Context) ([]RemoteCamera, error) {
var out []RemoteCamera
if err := c.send(ctx, http.MethodGet, "/api/cameras", nil, &out); err != nil {
return nil, err
}
for i := range out {
out[i].Snapshot = c.resolveShot(ctx, out[i].ID, out[i].SnapshotAt, out[i].Snapshot)
}
return out, nil
}
// resolveShot turns a camera snapshot into something the window can render.
//
// Same problem VisitorImage has and the same answer: a deployment with no
// object storage serves the picture from the API itself, so the url is
// relative and needs this session's bearer. A webview <img> can supply
// neither - it resolves a relative src against wails:// and cannot set a
// header - so the bytes are fetched here and passed as a data: URI.
//
// A failure is an absence with a reason, never an error. Whether the camera is
// CONNECTED is the answer this screen exists to give; the photograph is
// decoration, and blanking the card because a picture would not load would
// hide the part that matters.
func (c *Client) resolveShot(ctx context.Context, camID, at string, p Photo) Photo {
if !p.Available || !p.Auth || p.URL == "" {
return p
}
c.shotMu.Lock()
hit, ok := c.shots[camID]
c.shotMu.Unlock()
if ok && hit.at == at && at != "" {
p.URL, p.Auth = hit.uri, false
return p
}
uri, err := c.fetchImage(ctx, p.URL)
if err != nil {
return Photo{Reason: "That camera's picture could not be loaded."}
}
c.shotMu.Lock()
if c.shots == nil {
c.shots = map[string]cachedShot{}
}
c.shots[camID] = cachedShot{at: at, uri: uri}
c.shotMu.Unlock()
p.URL, p.Auth = uri, false
return p
}

166
desktop/viewing_test.go Normal file
View File

@@ -0,0 +1,166 @@
package main
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/loyaly/behavision-desktop/internal/cloud"
"github.com/loyaly/behavision-desktop/internal/local"
)
// Viewer mode: what the app shows on a computer that is signed in and is not
// itself watching any cameras.
//
// This is the friend's-Mac case, and before it existed the app was honest and
// useless: Live() and Cameras() read ONLY the engine on 127.0.0.1, so a laptop
// with no engine got "engine not reachable at http://127.0.0.1:8010" and
// "0 of 0 cameras" - on an account whose shops were running and recognising
// people the whole time. Signing in is what the person did; the app answered
// as if they had not.
//
// The engine here is a port nothing listens on, which is precisely what a PC
// with no engine is.
const noEngine = "http://127.0.0.1:1" // reserved, refuses immediately
func viewerApp(t *testing.T, srv *httptest.Server) *App {
t.Helper()
c := cloud.New(srv.URL)
c.SetSession(cloud.Session{Token: "test-token"})
return &App{
ctx: context.Background(),
cloud: c,
local: local.New(noEngine, "", ""),
}
}
func TestLiveFallsBackToHeadOfficeWhenThereIsNoEngine(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/sites":
// Two shops. One is fine, one is the Office1 case.
w.Write([]byte(`[
{"slug":"a","name":"A","cameras_up":2,"cameras_total":2,"fraction_below_gate":0.10},
{"slug":"b","name":"B","cameras_up":1,"cameras_total":3,"fraction_below_gate":0.73}
]`))
case strings.HasPrefix(r.URL.Path, "/api/visits"):
w.Write([]byte(`{"arrivals":[
{"visit_id":"v1","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2","is_new_visitor":true},
{"visit_id":"v2","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2"},
{"visit_id":"v3","camera_id":"entrance"}
]}`))
default:
t.Errorf("unexpected request %s", r.URL.Path)
}
}))
defer srv.Close()
snap, err := viewerApp(t, srv).Live()
if err != nil {
t.Fatalf("Live: %v", err)
}
if !snap.Viewing {
t.Fatal("the snapshot did not say it was a view of somewhere else")
}
if got := snap.Stats["cameras_up"]; got != 3 {
t.Errorf("cameras_up = %v, want 3 summed across both shops", got)
}
if got := snap.Stats["cameras_total"]; got != 5 {
t.Errorf("cameras_total = %v, want 5", got)
}
// The WORST site, never an average. Averaging 0.10 against 0.73 reports
// 0.42 and hides the only shop anyone needs to go and fix - the same rule
// the heartbeat already follows with worst_site.
if got := snap.Stats["fraction_below_gate"]; got != 0.73 {
t.Errorf("fraction_below_gate = %v, want the worst shop's 0.73", got)
}
// Three arrivals, two of them the same person, one unidentified. A visit
// with no visitor_id is real footfall and an unknown person, so it counts
// as a sighting and not as somebody known.
g := snap.Stats["gallery"].(map[string]any)
if g["identities"] != 1 || g["sightings"] != 3 {
t.Errorf("gallery = %v, want 1 identity over 3 sightings", g)
}
if len(snap.Events) != 3 {
t.Fatalf("got %d events, want 3", len(snap.Events))
}
if snap.Events[0]["type"] != "person.new" || snap.Events[1]["type"] != "person.seen" {
t.Errorf("arrival types wrong: %v", snap.Events)
}
}
// Nobody signed in: the local failure is the honest answer. There is nothing
// else to show, and the person is most likely setting this PC up - telling
// them about head office would be telling them about something they have not
// got to yet.
func TestLiveWithNoEngineAndNoSessionReportsTheEngine(t *testing.T) {
a := &App{ctx: context.Background(), cloud: cloud.New("https://example.invalid"),
local: local.New(noEngine, "", "")}
if _, err := a.Live(); err == nil {
t.Fatal("want the engine error, got nil")
}
}
// A remote camera is flagged, because the screen has to withhold every button
// that would talk to a camera on a network this computer cannot reach. An Edit
// button that cannot work is worse than one that is absent.
func TestRemoteCamerasAreFlaggedAndCarryNoCredentials(t *testing.T) {
jpeg := base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xD9})
var shots int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/camera-snapshots/") {
shots++
w.Header().Set("Content-Type", "image/jpeg")
b, _ := base64.StdEncoding.DecodeString(jpeg)
w.Write(b)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`[
{"id":"c1","camera_id":"cam2","label":"Open office","site":"Coimbatore",
"connected":true,"snapshot_at":"2026-09-30T10:00:00Z",
"snapshot":{"available":true,"url":"/api/camera-snapshots/c1.jpg","auth":true}}
]`))
}))
defer srv.Close()
a := viewerApp(t, srv)
cams, err := a.Cameras()
if err != nil {
t.Fatalf("Cameras: %v", err)
}
if len(cams) != 1 {
t.Fatalf("got %d cameras, want 1", len(cams))
}
if cams[0]["remote"] != true {
t.Error("the camera was not flagged remote")
}
// The RTSP details are a live path into the camera itself and the server
// does not send them to a tenant at all. Nothing here may invent them.
for _, k := range []string{"host", "port", "path", "username", "password"} {
if _, ok := cams[0][k]; ok {
t.Errorf("a remote camera carried %q", k)
}
}
// The picture has to be fetched here: a webview <img> resolves a relative
// src against wails:// and cannot send the session's bearer.
shot := cams[0]["snapshot"].(cloud.Photo)
if !strings.HasPrefix(shot.URL, "data:image/jpeg;base64,") || shot.Auth {
t.Errorf("snapshot url = %q auth=%v, want an inline data URI", shot.URL, shot.Auth)
}
// And fetched ONCE. This screen polls every 8 seconds and a real snapshot
// is ~90 KB, so re-fetching an unchanged picture is megabytes an hour to
// redraw the same frame.
if _, err := a.Cameras(); err != nil {
t.Fatalf("second poll: %v", err)
}
if shots != 1 {
t.Errorf("fetched the same snapshot %d times across two polls", shots)
}
}

521
docs/TECHNICAL-DOSSIER.md Normal file
View File

@@ -0,0 +1,521 @@
# Behavision — Technical Dossier
Everything below is extracted from the repository as of release 0.4.1 (engine 1.1.0, schema at migration 013). File paths, function names, thresholds, topics, ports and table definitions are the real ones. Where something lives outside the repository (the production host's proxy and container configuration) it is stated as such rather than invented.
> **Snapshot, not a live document.** Written against release 0.4.1 / schema
> 013, and the repository is past that. It predates at least: the engine's
> motion gate and one-thread detector (CPU 214% → 16%), `Gallery.health` and
> the stalled-camera state, the `tenantOnly` guard, `POST /api/auth/password`,
> `POST /api/customers` and the visitor merge, the admin console drill-down,
> `GET /api/sales` and `/api/dashboard/summary`, migration 014, and the macOS
> desktop build. Everything it *does* describe was extracted from the code and
> was true then; nothing here was invented. For the current surface read
> `API.md`, which is kept up to date, and `CLAUDE.md` for the decisions.
Companion documents: `API.md` (every route with request/response shapes), `docs/openapi.yaml` (generated), `docs/Behavision-Architecture.html` (diagrams).
---
## 1. System architecture
### 1.1 Repository layout
```
behavision/ Recognition engine (Python 3.10+)
__main__.py CLI: run | enroll | setup-models | calibrate | paths
api.py FastAPI on 127.0.0.1:8010 — dashboard, cameras, stream, stats
capture.py VideoSource: RTSP capture thread, latest-frame slot, probe_source
detection.py FaceDetector (YuNet), Detection
geometry.py umeyama, align_face, iou, clip_box
recognition.py ArcFaceEncoder (ONNX Runtime), face_quality
tracking.py Track, IouTracker
engine.py Engine, CameraWorker, PipelineStats — the per-camera pipeline
attributes.py AttributeEstimator (gender/age/emotion), aggregate
commission.py CommissionRun — placement check verdicts
gallery/store.py IdentityStore — SQLite (identities, embeddings, sightings)
gallery/index.py VectorIndex — FAISS IndexIDMap2(IndexFlatIP) / numpy fallback
gallery/service.py Gallery — resolve, enroll, reinforce, merge, duplicates
events.py EventBus + LogSink / WebhookSink / EmailSink
cameras.py CameraStore (cameras.json), protect/unprotect (DPAPI)
config.py pydantic Config, ${ENV} expansion, RTSP URL building
paths.py install_root / state_root / config_path resolution
static/dashboard.html Engine's own dashboard (no build step)
config/default.yaml Engine config (thresholds, cameras via ${ENV})
tests/ Engine tests (204), dependency-light: no camera, no models
agent/ Shop-PC agent (Go 1.22, module github.com/loyaly/behavision-agent)
main.go Headless agent binary
cmd/behavision-setup/ Installer: venv, wheel, models, config, smoke test, demo bundle
cmd/behavision-demo-pack/ Seals a camera list (AES-256-GCM) — build machine only
pkg/spool/ Durable queue: one file per event, bounded, ack by delete
pkg/mqtt/ paho adapter (client.go) + Pump + Waker (pump.go)
pkg/bridge/ Loopback webhook the engine posts to; derives event_id; queues
pkg/engine/ Supervisor (start/stop/restart/backoff), Health, ChildEnv
pkg/cameras/ Syncer: pull desired cameras, adopt local, run checks
pkg/enrol/ Redeem an installation code
pkg/config/ agent.json with DPAPI-protected secrets
pkg/paths/ StateRoot / InstallRoot — mirrors behavision/paths.py
pkg/demo/ Sealed bundle: NewCode, Seal, Open
desktop/ Shop-PC app (Wails v2.9.2, Go + React)
main.go wails.Run, HideWindowOnClose, tray start/stop
app.go Methods bound to the frontend; owns Supervisor, Bridge, Pump, Syncer
tray.go / icons.go fyne.io/systray; ICO rendered at runtime on Windows
stream_proxy.go Loopback relay for camera MJPEG (credential never in the page)
internal/local/ Client for the engine on 127.0.0.1:8010
internal/cloud/ Client for the platform API (sessions, refresh, images)
frontend/ React + Vite; src/bridge.js calls window.go.main.App.*
server/ Platform (Go, module github.com/loyaly/behavision-server)
cmd/behavision-server/main.go One binary: migrate → store → hub → ingest → API → web
Dockerfile Two-stage; static binary on alpine; EXPOSE 8080
migrations/001..013_*.sql go:embed'ed; applied at boot under an advisory lock
internal/api/ HTTP handlers, middleware, Hub (SSE doorbell), LiveHub (relay)
internal/store/ PostgreSQL access (pgx) — every query tenant-scoped
internal/ingest/ MQTT consumer: topic → site → visit/heartbeat → store
internal/auth/ Passwords (bcrypt 12), tokens, codes, Principal + role checks
internal/secret/ secret.Box — AES-256-GCM with AAD
internal/blob/ S3-compatible object storage (presign, private ACL check)
internal/assistant/ Claude tool loop; tools.go has no LLM import
internal/contract/ The MQTT wire contract: Visit, Heartbeat, ParseTopic
internal/migrate/ Migration runner (checksums, numeric order, baseline)
internal/provision/ CLI: provision key|client|site|user|token
internal/web/ go:embed of the built React console (web/dist → here)
web/ Head-office console (React + Vite); outDir → server/internal/web/dist
shared/cameraMakes.js Camera make → RTSP path table, imported by web AND desktop
installer/ build.ps1 (PyInstaller path), behavision.iss, INSTALL.txt, LAN launcher
run-local.sh Whole platform locally: Postgres + Mosquitto in Docker, server as binary
```
### 1.2 Processes and where they run
| Process | Language | Runs on | Listens | Talks to |
|---|---|---|---|---|
| Recognition engine | Python | shop PC | `127.0.0.1:8010` (Basic auth, generated) | cameras (RTSP), agent webhook (loopback) |
| Agent (inside the desktop app, or headless) | Go | shop PC | loopback webhook, port 0 | engine API, Mosquitto (TLS 8883), platform API (HTTPS) |
| Shop app | Go + webview | shop PC | loopback relay, port 0 | engine API, platform API |
| Mosquitto | C | cloud | `8883` TLS (agents), `1883` internal (server) | — |
| behavision-server | Go | cloud | `8080` (behind proxy) | PostgreSQL, Mosquitto (subscriber), object storage (optional), Anthropic API (optional) |
| PostgreSQL + pgvector | C | cloud | `5432` internal | — |
| Head-office console | React | browser | — | platform API |
| Mobile app | — | phone | — | platform API |
### 1.3 Network, domains, TLS
| Endpoint | Purpose | TLS |
|---|---|---|
| `https://platform.loyaly.ai` | Head-office console + API (`/api/*`) | Terminated at the reverse proxy (Traefik); the server listens plain HTTP on `LISTEN_ADDR` (default `:8080`) |
| `https://mcp.loyaly.ai/api/*` | Same API, the hostname the shop app defaults to (`BEHAVISION_CLOUD`) | Proxy |
| `tls://mcp.loyaly.ai:8883` | MQTT for agents (`AGENT_MQTT_URL` default) | Mosquitto's own listener; certificate must carry `DNS:mcp.loyaly.ai`; agents pin the issuing CA (`AGENT_CA_FILE` delivered at enrolment) |
| `tcp://behavision-mqtt:1883` | Server ↔ Mosquitto, internal network only (`MQTT_URL` default) | Plaintext on a private network |
Trust rules enforced in code:
- `X-Forwarded-For` is trusted for the login throttle **only because** nothing reaches the server port except through the proxy (`api/throttle.go`).
- The agent refuses `tcp://` to any non-loopback host unless `BEHAVISION_ALLOW_PLAINTEXT_MQTT=1` (`agent/pkg/mqtt/client.go`).
- No inbound route to a shop PC is ever required: agent → broker, agent → API, app → API are all outbound.
### 1.4 Server configuration (environment)
| Variable | Default | Purpose |
|---|---|---|
| `DATABASE_URL` | required | PostgreSQL DSN |
| `LISTEN_ADDR` | `:8080` | HTTP listener (behind proxy) |
| `MQTT_URL` / `MQTT_USERNAME` / `MQTT_PASSWORD` | `tcp://behavision-mqtt:1883` | Server's subscriber credential |
| `AGENT_MQTT_URL` | `tls://mcp.loyaly.ai:8883` | Broker URL handed to a PC at enrolment |
| `AGENT_CA_FILE` | — | CA PEM handed to a PC at enrolment (pinned) |
| `AGENT_MODELS_FILE` | — | Model manifest handed at enrolment |
| `BEHAVISION_SECRET_KEY` | — | 32-byte key for `secret.Box`; enrolment and camera passwords need it |
| `DO_SPACES_*` (`ENDPOINT`, `REGION`, `BUCKET`, `ACCESS_KEY`, `SECRET_KEY`, `PREFIX`) | prefix `behavision/v2` | Optional object storage; absent = images stored in Postgres |
| `ANTHROPIC_API_KEY` / `ANTHROPIC_WORKSPACE_ID` / `BEHAVISION_ASSISTANT_MODEL` | model `claude-sonnet-5` | Assistant; absent = `501 assistant_off` |
| `BEHAVISION_SKIP_MIGRATE` | — | Escape hatch; default applies migrations at boot |
### 1.5 Container / deployment shape
The repository ships `server/Dockerfile` (golang:1.25-alpine build → alpine:3.20 runtime, static binary, `EXPOSE 8080`, non-root user) and `run-local.sh`, which stands the whole platform up locally: `bv-pg` (pgvector/pgvector:pg16, port 55432), `bv-mqtt` (eclipse-mosquitto:2, port 51883, `passwd` + `acl` mounted), and the server as a local binary on 8088 with the console embedded.
Production host configuration (proxy routes, compose/unit files, certificate issuance) is **outside the repository**. What the code requires of it: a proxy terminating TLS for `platform.loyaly.ai` and forwarding to `LISTEN_ADDR`; Mosquitto with a TLS listener on 8883 whose certificate names `mcp.loyaly.ai`, a `passwd` file the `provision site` command adds to, and an ACL of the form `pattern write bv/%u/#`; PostgreSQL with the `vector` extension.
---
## 2. Recognition engine internals
### 2.1 Pipeline, function by function
```
RTSP ──▶ capture.VideoSource.run() thread per camera; cv2.VideoCapture(CAP_FFMPEG)
│ OPENCV_FFMPEG_CAPTURE_OPTIONS = rtsp_transport;tcp | stimeout;5000000 |
│ fflags;nobuffer | flags;low_delay | max_delay;200000
│ downscale to max_width (1280) with INTER_AREA
│ latest frame + timestamp in a lock-protected slot
▼
engine.CameraWorker.run() thread per camera; takes source.latest_since(ts)
│
├─ detection.FaceDetector.detect(frame) cv2.FaceDetectorYN (YuNet 2023mar)
│ score_threshold 0.82 · nms 0.3 · min_face_px 48 · max_faces 20
│ → Detection(box, kps[5], score)
│
├─ recognition.face_quality(frame, box, kps) weighted: sharpness .35 · size .25 · brightness .15 · frontality .25
│
├─ tracking.IouTracker.update(dets, ts) greedy IoU association, iou_threshold 0.3, max_misses 25
│ → active Track[], ended Track[] one Track == one person on camera
│
├─ for each active track, _should_identify(): hits ≥ 4 · quality ≥ min_quality_to_encode (0.35)
│ ≤ max_id_attempts (8) · spaced id_retry_interval_seconds (0.5)
│
├─ _identify(track, frame):
│ geometry.align_face(frame, kps) Umeyama similarity transform → 112×112 BGR chip
│ recognition.ArcFaceEncoder.encode() BGR→RGB, (x−127.5)/127.5, NCHW float32, L2-normalised 512-d
│ track.emb_sum += e; when emb_count ≥ min_embeddings_for_id (3): mean → normalise
│ gallery.Gallery.resolve(mean, quality, rcfg) → Resolution(kind, identity, similarity)
│
├─ Resolution.kind:
│ known sim ≥ match_threshold (0.42) → person.seen (+ reinforce if 0.32 ≤ sim < 0.55, q ≥ gate, < 5 stored)
│ ambiguous 0.32 ≤ sim < 0.42 → wait; retry on a later frame
│ new sim < enroll_threshold (0.32) → Gallery.enroll → "Visitor N" · person.new
│ skipped quality < min_enroll_quality (0.65) → counted as rejected_quality
│
├─ attributes.AttributeEstimator.estimate() genderage.onnx on a loose 1.5× crop; FER+ on the chip;
│ medianed over the track (attributes.aggregate)
│
├─ _finish_track(ended) PipelineStats.record(outcome) — exactly once per track
│
└─ _remember_tracks(active) boxes + labels for the live picture (no encode)
Live picture: CameraWorker.latest_jpeg_since(ts) — freshest CAPTURED frame + last boxes, encoded on demand.
Events: events.EventBus → LogSink · WebhookSink (→ agent bridge) · EmailSink
```
### 2.2 Models (`recognition.MODEL_CANDIDATES`, first loadable wins)
| Order | File | Role |
|---|---|---|
| 1–2 | `adaface_ir101.onnx`, `adaface_ir50.onnx` | wired, optional |
| **3** | **`w600k_r50.onnx`** (166 MB) | **in use** — IJB-C 97.25; same-person p05 0.719 on the office camera |
| 4 | `arcface_int8.onnx` | optional |
| 5 | `w600k_mbf.onnx` (13 MB) | always loads; MobileFaceNet fallback (95.02) |
| 6 | `arcface.onnx` (r100, 249 MB) | optional |
| — | `face_detection_yunet_2023mar.onnx` | detector |
| — | `genderage.onnx` (InsightFace buffalo_l) | attributes |
Every stored embedding is tagged with the model name; `IdentityStore.all_embeddings(model)` loads only same-model vectors into the index.
### 2.3 Local gallery
`gallery/store.py` — SQLite (WAL), single source of truth:
```
identities(id, label, kind auto|named, created_at, sighting_count)
embeddings(id, identity_id, model, vector BLOB, quality, created_at)
sightings(id, identity_id, camera_id, similarity, at)
```
`gallery/index.py` — `VectorIndex` over FAISS `IndexIDMap2(IndexFlatIP)` (exact inner product = cosine on L2-normalised vectors), rebuilt from SQLite at boot, −1 ids filtered, identical numpy fallback. Measured: 1k → 0.27 ms, 10k → 2.24 ms, 100k → 21.9 ms.
`gallery/service.py` — `Gallery.resolve` (three zones), `enroll`, `reinforce_identity` (refuses a view whose nearest neighbour is another identity), `merge_identities` (one transaction; human name outranks "Visitor N"; `sighting_count` recomputed; trimmed to 5 by quality), `duplicate_candidates` (k-NN across identities, O(n·k)).
### 2.4 What leaves the engine
`WebhookSink` POSTs each `person.seen` / `person.new` to the agent's loopback bridge with `identity_id`, `label`, `similarity`, `quality`, attributes, and optionally `image_path` (only when `app.store_faces: true`). The bridge fetches the identity's **best** stored embedding once per identity via `GET /api/identities/{id}/embedding`. `person.missed`, `camera.up/down` are diagnostics and never become visits.
---
## 3. Backend internals
### 3.1 Request path
```
proxy (TLS) ─▶ net/http mux (Go 1.22 patterns, method + path)
│
├─ s.authed(h) Bearer token → SHA-256 → sessions row → Principal{UserID, ClientID, Role}
│ token_expired vs unauthorized distinguished; last_used_at touched
├─ s.adminOnly(h) Principal.Role == "admin" AND ClientID == "" (both) → else 404
├─ s.agentAuthed(h) Agent token (hashed) → AgentPrincipal{ClientID, Client slug, SiteID, AgentID}
└─ no wrapper login, refresh, invitation preview, register, enrol
│
▼
handlers_*.go decode (unknown fields rejected) → validate → Store call → writeJSON
│ every Store call receives p.ClientID from the session, never the body
▼
store/*.go (pgx) SQL with client_id in every WHERE / INSERT
```
Login throttle (`api/throttle.go`): per-account 10 failures / 15 min and per-IP 60, in memory, pruned on read; success clears both. Unknown address is verified against `auth.DummyHash` so timing matches a wrong password.
### 3.2 Handler areas → store methods
| Area (file) | Routes | Store surface |
|---|---|---|
| `handlers_auth.go`, `handlers_sessions.go` | login, refresh, logout, me, sessions list/revoke | `UserByEmail`, `CreateSession`, `SessionByAccessHash`, `RotateSession`, `RevokeSession(s)` |
| `handlers_team.go` | team, members, password reset, invitations, register | `Team`, `UpdateTeamMember`, `CreateMember`, `ResetMemberPassword`, `CreateInvitation`, `RedeemInvitation`, `OwnerCount` |
| `handlers_admin.go` | admin/clients | `ListClients`, `CreateClientWithOwner` (one transaction) |
| `handlers_arrivals.go`, `hub.go` | visits, visits/stream | `Arrivals` (keyset by `seq`), `Hub.Notify` doorbell → SSE |
| `handlers_people.go` | visitors, history, profile, purchases, erasure | `SearchVisitors`, `VisitorHistory`, `SaveProfile`, `RecordPurchase`, `ForgetVisitor` |
| `handlers_images.go`, `handlers_faces.go` | visitor image, face bytes | `VisitorImageKey`, `FaceImage`; `imageFor(key)` decides presigned vs `auth:true` |
| `handlers_cameras.go`, `handlers_snapshots.go` | cameras CRUD, snapshot | `Cameras`, `CreateCamera`, `UpdateCamera`, `DeleteCamera` (tombstone), `Snapshot` |
| `handlers_checks.go` | camera check, site check | `RequestCheck`, `ClaimChecks`, `ReleaseStaleChecks`, `RecordCheck`, `SiteCheck` |
| `handlers_live.go`, `live.go` | cameras/{id}/live, agent live | `LiveHub` — one-slot buffer per viewer, on-demand upload |
| `handlers_reports.go` | footfall, conversion | `Footfall`, `Conversion` — unique vs visits, first-ever "new", single currency |
| `handlers_enrolment.go`, `handlers_agent.go` | enrol, agent cameras/checks/faces/upload-url | `RedeemEnrolment` (single-use via UPDATE), `AgentCameras`, `AgentReport`, `PutFace`, `UploadTarget` |
| `handlers_assistant.go` | assistant | `assistant.Client.Ask` with the Principal passed at the call site |
### 3.3 Server-side recognition (`store/store.go`, `RecordVisit`)
```
similarity = 1 - (embedding <=> $1::vector) -- pgvector cosine distance
ORDER BY embedding <=> $1::vector LIMIT 1 -- within client_id, same model
sim ≥ 0.42 → known visitor; reinforce if 0.32 ≤ sim < 0.55 AND quality ≥ floor AND < 5 stored
sim < 0.42 → new visitor: clients.visitor_seq += 1 RETURNING (row-locks the client), label "Visitor N"
INSERT visits ... ON CONFLICT (client_id, source_event_id) DO NOTHING -- idempotent
```
### 3.4 Single-process composition (`cmd/behavision-server/main.go`)
```
migrate.Apply(embedded FS) → store.Open → hub := api.NewHub()
ingest.Consumer{Store, Notify: hub.Notify} ← paho client, SetOrderMatters(true), subscribed bv/+/+
api.New(Store, Hub, LiveHub, Blob?, Assistant?) → web.Handler (embedded dist; /api/ keeps JSON 404)
http.Server{ReadTimeout, IdleTimeout, WriteTimeout: 0} -- zero: SSE streams must outlive any write deadline
```
---
## 4. MQTT architecture
### 4.1 Identity and topics
Broker username = `<client-slug>.<site-slug>` (e.g. `tenext-retail.chennai`). ACL: `pattern write bv/%u/#` — a site physically cannot publish under another site's prefix.
```
bv/<client>.<site>/visit Visit payload QoS 1 spooled, acked per event
bv/<client>.<site>/heartbeat Heartbeat payload QoS 1 never spooled — only meaningful now
bv/<client>.<site>/status reserved
bv/<client>.<site>/cmd/... reserved (server → site)
```
`contract.ParseTopic` → `Topic{Username, Client, Site, Kind, Rest}`; `Kind ∉ {visit, heartbeat, status, cmd}` is dropped as permanent.
### 4.2 Payloads (`server/internal/contract/contract.go`)
```json
// visit
{ "event_id": "tenext-retail.chennai|cam2|7|1757580000", // <site>|<camera>|<identity>|<unix second> — derived, never random
"occurred_at": "2026-09-11T05:20:00Z", "camera_id": "cam2",
"is_new": false, "similarity": 0.61, "quality": 0.70,
"local_visitor_id": 7, "embedding": [512 floats], "model": "w600k_r50",
"image_key": "behavision/v2/tenext-retail/chennai/2026/09/11/…jpg", // or "db:<uuid>", or absent
"attributes": { "gender": "Male", "age": 32, "emotion": "neutral" } }
// heartbeat
{ "sent_at": "…", "agent_version": "0.4.1", "engine_version": "1.1.0", "recognition_model": "w600k_r50",
"cameras": { "cam1": true, "cam2": true }, "queued": 0, "dropped": 0, "fraction_below_gate": 0.47 }
```
`Visit.Validate()` (permanent errors): `event_id` required ≤128; `occurred_at` required and not >24 h in the future; embedding must be exactly 512 and carry `model`.
### 4.3 Delivery semantics
| Stage | Component | Guarantee |
|---|---|---|
| Engine → agent | `WebhookSink` → `bridge.Bridge.Handle` | loopback HTTP; `event_id` derived from `<site>|<camera>|<identity>|<second>` (sighting cooldown is 30 s, so one person/camera cannot share a second) |
| Append | `spool.Spool.Append` | one file per event, fsync, bounded (`SpoolMax`, default 50,000); on overflow drops oldest and counts `Dropped()`; corrupt entry quarantined, not retried |
| Wake | `mqtt.Waker.Wake` **after** the append | a wake before durability is a drain that finds nothing |
| Publish | `mqtt.Pump.Run` → `Client.Publish` | QoS 1, `CleanSession(true)`, publish bounded by a timeout as well as context (half-open TCP otherwise stalls forever); **a failed publish stops the batch** (ordering per visitor) |
| Ack | `Spool.Ack(seq)` on PUBACK | per event, never per batch; file deleted only now |
| Consume | `ingest.Consumer.Handle` | paho `SetOrderMatters(true)`; permanent error → `drop()` + log (message is acked, never redelivered); transient error → returned → redelivered |
| Write | `store.RecordVisit` | `ON CONFLICT (client_id, source_event_id) DO NOTHING` — duplicates from at-least-once delivery are absorbed |
| Notify | `hub.Notify(clientID)` | only on a genuine insert; SSE streams re-query from their own cursor |
Backoff: reconnect 1 → 30 s exponential; supervisor backoff resets only after a run that stayed up 60 s. `describeStall` distinguishes *broker refused the credential* (TCP opens, connect never completes) from *broker unreachable*.
### 4.4 Failure paths
| Failure | Behaviour |
|---|---|
| Internet down | spool grows on disk; heartbeats stop; head office shows site offline after 3 missed beats; on reconnect the backlog drains in order |
| Broker rejects credential | pump logs "reachable but not accepted — re-link this PC"; spool retained |
| Server down, broker up | broker holds nothing (clean session); agent's PUBACKs still arrive from the broker, so events are acked at the broker — the server's own subscription reconnects and Mosquitto delivers what it queued for the persistent server session |
| Duplicate delivery | absorbed by `source_event_id` uniqueness |
| Malformed event | dropped with a log line naming the site and reason; never blocks the queue |
| Site clock wrong | `occurred_at` > 24 h ahead rejected as permanent; feed ordering uses server `seq`, so a wrong clock cannot hide a visit |
---
## 5. Database schema (PostgreSQL + pgvector, migrations 001–013)
### 5.1 Tables
```
clients id PK · slug UQ (immutable, = MQTT prefix) · name · active · visitor_seq bigint
sites id PK · client_id FK · slug (immutable, per client) · name · timezone · address · active
agents id PK · client_id FK · site_id FK · mqtt_username UQ · mqtt_password_enc bytea (sealed)
api_token_hash bytea · agent_version · engine_version · recognition_model
last_heartbeat_at · last_event_at · fraction_below_gate · cameras_total/up · spool_queued/dropped
app_users id PK · client_id FK (NULL = platform admin) · email (lower(email) UQ globally, 007)
password_hash (bcrypt 12) · full_name · role owner|manager|staff|admin · active · last_login_at
sessions id PK · user_id FK · client_id FK · access_hash UQ · refresh_hash UQ (SHA-256)
access_expires_at · refresh_expires_at · revoked_at · device · last_used_at
invitations id PK · client_id FK · email · full_name · role · code_hash UQ · invited_by FK
expires_at · used_at · used_by FK · revoked_at
site_enrolment_tokens id PK · client_id FK · site_id FK · token_hash UQ · label · expires_at · used_at · created_by
visitors id PK · client_id FK · number bigint (per-client, immutable → "V-42") · label
first_seen_at · last_seen_at · visit_count · deleted_at
visitor_embeddings id PK · visitor_id FK · client_id FK · model · embedding vector(512) · quality · source_site_id FK
visitor_profiles id PK · visitor_id FK · client_id FK · full_name · phone · email · gender · date_of_birth · notes
consents id PK · visitor_id FK · client_id FK · scope · method · granted_at · revoked_at · evidence jsonb
visits id PK · client_id FK · site_id FK · visitor_id FK (nullable) · source_event_id (UQ per client)
occurred_at · received_at · camera_id text · is_new_visitor · similarity · quality
attributes jsonb · image_key · image_deleted_at · seq bigserial (feed cursor)
purchases id PK · client_id FK · site_id FK · visitor_id FK · visit_id FK · amount numeric(14,2)
currency char(3) · items jsonb · source · external_ref · recorded_by · occurred_at
site_cameras id PK · client_id FK · site_id FK · camera_id text (immutable, UQ per site) · label
host · port · path · username · password_enc bytea (sealed, aad = site_id) · max_width
tuning jsonb · enabled · revision · connected (nullable) · last_seen_at · snapshot_key
snapshot_at · deleted_at (tombstone) · check_kind · check_* (requested/started/finished/result/image_key)
camera_snapshots camera_id PK FK · client_id FK · site_id FK · image bytea · bytes · captured_at
visit_faces id PK · client_id FK · site_id FK · image bytea · bytes · captured_at (one survives per visitor)
audit_log id bigserial PK · client_id FK (SET NULL) · actor_id · actor_kind · action · entity · entity_id · detail jsonb · at
schema_migrations version · checksum · applied_at · baselined
```
### 5.2 Relationships
```
clients ─┬─< sites ─┬─< agents
│ ├─< site_cameras ──< camera_snapshots (1:1, PK = camera_id)
│ ├─< site_enrolment_tokens
│ ├─< visits
│ ├─< purchases
│ └─< visit_faces
├─< app_users ─┬─< sessions
│ └─< invitations (invited_by, used_by)
├─< visitors ─┬─< visitor_embeddings
│ ├─< visitor_profiles
│ ├─< consents
│ ├─< visits
│ └─< purchases
└─< audit_log (SET NULL)
visits ──< purchases (visit_id, SET NULL)
```
Every FK onto `clients` is `ON DELETE CASCADE` except `audit_log` (`SET NULL`). Every tenant-owned table carries `client_id` directly, so no query needs a join to enforce tenancy.
### 5.3 Invariants enforced in the database
- `007` — `lower(email)` globally unique; the migration refuses to apply while duplicates exist and names them.
- `012` — `visitors.number` per-client sequence from `clients.visitor_seq` (`UPDATE … RETURNING`, row-locked); unique on `(client_id, number)`.
- `013` — triggers refuse changes to `clients.slug`, `sites.slug`, `site_cameras.camera_id`, `visitors.number` (`BEFORE UPDATE OF … WHEN OLD IS DISTINCT FROM NEW`). Display names are deliberately not frozen.
- `004` — `visits.seq bigserial`; the arrivals cursor is `v1:<seq>` base64, opaque to clients.
- `sites_slug_format` — `^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`.
---
## 6. API specification
Full request/response shapes: `API.md`. Machine-readable: `docs/openapi.yaml`.
### 6.1 Authentication and session lifecycle
```
POST /api/auth/login {email,password,device}
→ {access_token (12 h), refresh_token (30 d), expires_at, user{id,email,full_name,role,client_id,client_name}}
tokens: 256-bit random; only SHA-256 stored; bcrypt cost 12 verify; DummyHash for unknown addresses
POST /api/auth/refresh {refresh_token,device}
→ same shape; BOTH rotate; old refresh invalid immediately; client must serialise and persist before use
401 {error:"token_expired"} → refresh once and retry
401 {error:"bad_credentials"} / {error:"unauthorized"} → sign in
GET/DELETE /api/auth/sessions[/{id}] · POST /api/auth/sessions/revoke-others
```
### 6.2 Route families and least role
| Family | Routes | Least role |
|---|---|---|
| Auth | login, refresh, logout, me, sessions | none / authed |
| Joining | `GET /api/auth/invitation?code=`, `POST /api/auth/register` | none |
| Team | `GET /api/team` | authed (tenant users) |
| | `POST /api/team/members`, `POST /api/team/{id}/password`, `PATCH /api/team/{id}`, `/api/team/invitations*` | manager |
| Arrivals | `GET /api/visits`, `GET /api/visits/stream` (SSE) | authed |
| Customers | `GET /api/visitors`, `/history`, `/image`, `GET /api/faces/{id}` | authed |
| | `PUT /api/visitors/{id}/profile`, `POST /api/purchases` | staff |
| | `DELETE /api/visitors/{id}` (erasure) | manager |
| Shops & cameras | `GET /api/sites`, `/check`, `GET /api/cameras`, `/snapshot.jpg`, `/live` (SSE) | authed |
| | `POST /api/sites/{site}/cameras`, `PATCH`/`DELETE /api/cameras/{id}`, `POST /api/cameras/{id}/check`, `POST /api/sites/{site}/enrolment-code` | manager |
| Reports | `GET /api/reports/footfall`, `/conversion` | authed |
| Assistant | `POST /api/assistant` | authed |
| Admin | `GET`/`POST /api/admin/clients` | platform admin (role admin AND no client) |
| Agent | `POST /api/agent/enrol` (none), then `/api/agent/{cameras, checks, faces, upload-url, live, cameras/{c}/snapshot, cameras/{c}/live}` | agent token |
Identifiers: any `{id}` or `site` accepts a uuid **or** the human reference (`V-42`, `chennai`, `cam1`). Unknown reference in a path → 404; in a query filter → 400. Another tenant's data → 404, never 403.
### 6.3 Assistant tools (`internal/assistant/tools.go`)
`list_sites`, `site_health`, `footfall`, `conversion`, `find_customer`, `customer_history`, `check_camera` (manager+; refuses staff in the tool, not the prompt). No tool takes a tenant id; the Principal is bound at the call site. Business tools only — never `execute_sql`. Loop bounded at 8 iterations; text produced alongside a tool call is discarded; failing tools return results, not errors.
---
## 7. Deployment topology
```
INTERNET
│
┌───────────────┼───────────────────┐
│ HTTPS 443 │ │ TLS 8883
┌────────▼─────────┐ │ ┌────────▼─────────┐
│ Traefik │ │ │ Mosquitto │
│ platform.loyaly │ │ │ mcp.loyaly.ai │
│ mcp.loyaly.ai │ │ │ passwd + ACL │
│ TLS termination │ │ │ pattern write │
└────────┬─────────┘ │ │ bv/%u/# │
│ :8080 plain │ └────────┬─────────┘
┌────────▼───────────────────────┐ │ :1883 internal
│ behavision-server (one binary) │◄───────────┘ subscribe bv/+/+
│ ├ migrate (boot) │
│ ├ ingest consumer → Hub │
│ ├ API (48 routes) → SSE │
│ ├ LiveHub (camera relay) │
│ ├ web (embedded React) │
│ └ assistant (optional) │
└────────┬───────────────┬───────┘
│ │ presigned PUT/GET (optional)
┌────────▼─────────┐ ┌──▼──────────────────┐
│ PostgreSQL 16 │ │ Object storage │
│ + pgvector │ │ (S3-compatible) │
│ 17 tables │ │ private ACL │
└──────────────────┘ └─────────────────────┘
════════════════════════ trust boundary: no inbound route ════════════════════════
SHOP NETWORK (one per shop, behind NAT)
┌──────────────────────────────────────────────────────────┐
│ Cameras ──RTSP/TCP──▶ Engine :8010 ──webhook──▶ Agent │
│ │ SQLite │ spool │
│ │ FAISS │ │
│ Shop app ◄─── relay ───────┘ │
│ │
│ outbound only: agent ──TLS 8883──▶ Mosquitto │
│ agent ──HTTPS────▶ /api/agent/* │
│ app ──HTTPS────▶ /api/* │
└──────────────────────────────────────────────────────────┘
```
**Failure domains.** A shop PC failing affects one shop; its footfall queues locally and nothing else notices except the heartbeat. Mosquitto failing stops delivery for all shops but loses nothing (every event is on a shop's disk). The server failing stops the console and API; Mosquitto retains the server's subscription backlog. PostgreSQL is the single stateful component in the cloud tier.
**Data residency.** Video never leaves the shop. Face templates leave the shop only as 512-float vectors inside visit events, over TLS, to the tenant's own prefix. Photographs leave only when `store_faces` is enabled, via presigned upload to a private object, or into Postgres when no bucket is configured. Every image read at head office is audited.
**Encrypted paths.** Camera credentials: DPAPI on the shop PC, AES-256-GCM (aad = site) in Postgres, plaintext only inside the agent's process and on the LAN RTSP connection to the camera. Broker password: sealed in `agent.json`, sealed in `agents.mqtt_password_enc`, hashed in Mosquitto's `passwd`. Sessions: SHA-256 at rest. User passwords: bcrypt 12.
---
## 8. Measured
| Metric | Value | Source |
|---|---|---|
| Identity stability | 103 tracks → 7 people, 44 re-recognitions, 5 min | engine `/api/stats`, office cam2 |
| Same-person similarity | p05 0.719 (frontal webcam, 18,528 pairs) | `calibrate` |
| Gallery search | 0.27 / 2.24 / 21.9 ms at 1k / 10k / 100k | `VectorIndex` benchmark |
| Delivery under burst | 120 of 120 simultaneous visits | live broker + Postgres |
| Camera → head office | ~3 s | end-to-end run |
| Head-office live view | 13 fps, 259 KB/s, 0 duplicates | relay measurement |
| Shop-PC live picture | 14.0 pictures/s vs 15 fps camera; engine CPU 90% → 62% | before/after, cam2 sub-stream |
| Clean-machine install | 10/10 steps, both cameras connected | fresh container |
| Server test suite | < 10 s (bcrypt cost lowered for tests only) | `go test ./...` |