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
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
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
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.
| `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.
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)
`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.
│ 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.
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
`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 |
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.
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.
`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.
**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.
| Server test suite | < 10 s (bcrypt cost lowered for tests only) | `go test ./...` |
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.