Files
Behavision/docs/Behavision-Architecture.html
Suriyakumarvijayanayagam a1fe0942e2 docs: the architecture overview, as a file
Nine diagrams, one HTML file, no dependencies beyond web fonts that
fall back to system faces offline. The same document is published as
an artifact; this is the copy that ships with the repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-11 17:12:50 +05:30

578 lines
53 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>Behavision Architecture</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root{
--paper:#f1f4f6;--surface:#fff;--surface-2:#e7ecef;--ink:#131b22;--ink-soft:#46545f;--ink-faint:#6d7d88;--rule:#d3dbe0;
--acc:#12707e;--acc-ink:#0b4d57;--acc-bg:#dcedf0;--good:#2f7d55;--warn:#9a6413;--bad:#a8403c;--good-bg:#e2efe8;--warn-bg:#f5ecdc;
--sans:"Archivo","Helvetica Neue",Arial,sans-serif;--serif:"Source Serif 4",Georgia,serif;--mono:"IBM Plex Mono",ui-monospace,Menlo,monospace;
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--paper:#0e141b;--surface:#161f28;--surface-2:#1d2833;--ink:#e6edf2;--ink-soft:#a7b6c1;--ink-faint:#7b8b97;--rule:#2a3742;--acc:#4fc3d6;--acc-ink:#9adfeb;--acc-bg:#13303a;--good:#6cc394;--warn:#d5a55c;--bad:#e0817c;--good-bg:#172c22;--warn-bg:#2e2617}}
:root[data-theme="dark"]{--paper:#0e141b;--surface:#161f28;--surface-2:#1d2833;--ink:#e6edf2;--ink-soft:#a7b6c1;--ink-faint:#7b8b97;--rule:#2a3742;--acc:#4fc3d6;--acc-ink:#9adfeb;--acc-bg:#13303a;--good:#6cc394;--warn:#d5a55c;--bad:#e0817c;--good-bg:#172c22;--warn-bg:#2e2617}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--serif);font-size:1rem;line-height:1.55;-webkit-font-smoothing:antialiased}
h1,h2,h3,.eyebrow,.nav,.legend,.facts,.tag,.metric{font-family:var(--sans)}
h1{font-size:clamp(2.2rem,5vw,3.2rem);line-height:1.02;font-weight:700;letter-spacing:-.025em;margin:0;text-wrap:balance}
h2{font-size:1.5rem;line-height:1.15;font-weight:600;letter-spacing:-.01em;margin:0;text-wrap:balance}
p{margin:0} code{font-family:var(--mono);font-size:.88em;background:var(--surface-2);padding:.06em .35em;border-radius:2px}
a{color:var(--acc)} a:focus-visible{outline:2px solid var(--acc);outline-offset:3px}
.wrap{max-width:74rem;margin:0 auto;padding-inline:20px}
.eyebrow{font-size:.8rem;text-transform:uppercase;letter-spacing:.14em;font-weight:600;color:var(--acc)}
header.mast{background:var(--surface);border-bottom:1px solid var(--rule)}
header.mast .wrap{padding-block:clamp(2.5rem,6vw,4rem) clamp(1.5rem,4vw,2.5rem);display:flex;flex-direction:column;gap:1.1rem}
.brand{display:flex;align-items:center;gap:.7rem;font-family:var(--sans);font-weight:600;letter-spacing:.16em;text-transform:uppercase;font-size:.8rem;color:var(--ink-faint)}
.lens{width:1rem;height:1rem;border-radius:50%;border:2px solid var(--acc)}
.sub{font-size:1.15rem;color:var(--ink-soft);max-width:38rem;line-height:1.45}
.nav{display:flex;flex-wrap:wrap;gap:.35rem .9rem;font-size:.8rem;margin-top:.5rem}
.nav a{text-decoration:none;color:var(--ink-faint)} .nav a:hover{color:var(--ink)} .nav .n{font-family:var(--mono);color:var(--acc);margin-right:.35rem}
/* legend */
.legend{display:flex;flex-wrap:wrap;gap:.6rem 1.6rem;font-size:.8rem;color:var(--ink-soft);align-items:center}
.legend span{display:inline-flex;align-items:center;gap:.45rem}
.legend svg{width:34px;height:20px;display:block}
/* plates */
.plate{padding-block:clamp(2.2rem,5vw,3.5rem);border-bottom:1px solid var(--rule)}
.plate:last-of-type{border-bottom:0}
.head{display:grid;grid-template-columns:3.2rem 1fr;gap:1rem;align-items:baseline;margin-bottom:1.2rem}
.head .n{font-family:var(--mono);font-size:.95rem;color:var(--acc)}
.head p{color:var(--ink-soft);margin-top:.35rem;max-width:42rem}
.fig{background:var(--surface);border:1px solid var(--rule);padding:clamp(.8rem,2.2vw,1.4rem);overflow-x:auto}
.fig svg{display:block;max-width:100%;height:auto;min-width:40rem}
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:.9rem 2rem;margin-top:1.1rem;font-size:.86rem;line-height:1.45}
.facts div{display:grid;grid-template-columns:.7rem 1fr;gap:.6rem}
.facts div::before{content:"";width:.5rem;height:.5rem;border-radius:1px;background:var(--acc);margin-top:.45rem}
.facts b{font-weight:600}
/* svg semantics */
.s{stroke:currentColor;stroke-width:1.5;fill:none}
.sa{stroke:var(--acc);stroke-width:1.75;fill:none}
.sd{stroke:currentColor;stroke-width:1.25;fill:none;stroke-dasharray:4 4;opacity:.75}
.fa{fill:var(--acc)} .fab{fill:var(--acc-bg)} .fs{fill:var(--surface-2)} .fg{fill:var(--good)} .fw{fill:var(--warn)} .fb{fill:var(--bad)} .fgb{fill:var(--good-bg)} .fwb{fill:var(--warn-bg)}
.t{font-family:var(--sans);font-size:12.5px;font-weight:600;fill:currentColor}
.ta{font-family:var(--sans);font-size:12.5px;font-weight:600;fill:var(--acc)}
.m{font-family:var(--mono);font-size:10.5px;fill:currentColor;opacity:.68}
.ma{font-family:var(--mono);font-size:10.5px;fill:var(--acc)}
.l{font-family:var(--sans);font-size:10.5px;fill:currentColor;opacity:.78}
.la{font-family:var(--sans);font-size:10.5px;fill:var(--acc)}
.z{font-family:var(--sans);font-size:11.5px;font-weight:600;letter-spacing:1.5px;fill:currentColor;opacity:.5}
.cap{font-family:var(--serif);font-size:12.5px;fill:currentColor;opacity:.8}
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule)}
.metric{background:var(--surface);padding:1rem 1.05rem 1.1rem;display:flex;flex-direction:column;gap:.2rem}
.metric .v{font-size:1.9rem;font-weight:700;line-height:1;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
.metric .k{font-size:.8rem;color:var(--ink-faint);line-height:1.35}
footer{background:var(--surface);border-top:1px solid var(--rule);color:var(--ink-faint);font-size:.8rem}
footer .wrap{padding-block:1.6rem 2.6rem}
@media (max-width:40rem){.head{grid-template-columns:1fr;gap:.2rem}}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
</style>
<!-- shared glyphs -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker>
<marker id="aa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="fa"/></marker>
<symbol id="g-cam" viewBox="0 0 24 24"><path d="M3 8h4l2-3h6l2 3h4v11H3z" class="s"/><circle cx="12" cy="13" r="3.2" class="s"/></symbol>
<symbol id="g-db" viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="8" ry="3" class="s"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" class="s"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" class="s"/></symbol>
<symbol id="g-pc" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="1" class="s"/><path d="M8 20h8M12 16v4" class="s"/></symbol>
<symbol id="g-phone" viewBox="0 0 24 24"><rect x="7" y="2" width="10" height="20" rx="2" class="s"/><path d="M11 18h2" class="s"/></symbol>
<symbol id="g-lock" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="1" class="s"/><path d="M8 10V7a4 4 0 0 1 8 0v3" class="s"/></symbol>
<symbol id="g-file" viewBox="0 0 24 24"><path d="M6 2h8l5 5v15H6z" class="s"/><path d="M14 2v5h5" class="s"/></symbol>
<symbol id="g-cloud" viewBox="0 0 24 24"><path d="M7 18a4 4 0 0 1-.6-7.95A6 6 0 0 1 18 9a4 4 0 0 1 0 9z" class="s"/></symbol>
<symbol id="g-person" viewBox="0 0 24 24"><circle cx="12" cy="8" r="3.5" class="s"/><path d="M5 20a7 7 0 0 1 14 0" class="s"/></symbol>
<symbol id="g-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" class="s"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4L7 17M17 7l1.4-1.4" class="s"/></symbol>
</defs>
</svg>
<header class="mast">
<div class="wrap">
<div class="brand"><span class="lens" aria-hidden="true"></span> Behavision · Technical Overview</div>
<h1>Behavision Architecture</h1>
<p class="sub">Face recognition for retail. An engine that sees, an agent that delivers, a platform that understands — in nine diagrams.</p>
<nav class="nav" aria-label="Plates">
<a href="#p1"><span class="n">01</span>System</a><a href="#p2"><span class="n">02</span>Shop PC</a><a href="#p3"><span class="n">03</span>Recognition</a><a href="#p4"><span class="n">04</span>Delivery</a><a href="#p5"><span class="n">05</span>Local &amp; master data</a><a href="#p6"><span class="n">06</span>Clients &amp; API</a><a href="#p7"><span class="n">07</span>Onboarding</a><a href="#p8"><span class="n">08</span>Secrets</a><a href="#p9"><span class="n">09</span>Stack &amp; numbers</a>
</nav>
<div class="legend" aria-label="Diagram legend">
<span><svg viewBox="0 0 34 20"><rect x="2" y="3" width="30" height="14" class="s"/></svg>process</span>
<span><svg viewBox="0 0 34 20"><ellipse cx="17" cy="5" rx="11" ry="3" class="s"/><path d="M6 5v10c0 1.7 4.9 3 11 3s11-1.3 11-3V5" class="s"/></svg>store</span>
<span><svg viewBox="0 0 34 20"><rect x="2" y="3" width="30" height="14" class="sa"/></svg>the path in focus</span>
<span><svg viewBox="0 0 34 20"><line x1="2" y1="10" x2="30" y2="10" class="s" marker-end="url(#a)"/></svg>data</span>
<span><svg viewBox="0 0 34 20"><line x1="2" y1="10" x2="30" y2="10" class="sd" marker-end="url(#a)"/></svg>control / pull</span>
<span><svg viewBox="0 0 34 20"><line x1="17" y1="1" x2="17" y2="19" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3" opacity=".5"/></svg>trust boundary</span>
</div>
</div>
</header>
<main class="wrap">
<!-- ============================================================ 01 -->
<section class="plate" id="p1">
<div class="head"><span class="n">01</span><div><h2>The whole system</h2><p>Video stays inside the shop. Only visit records cross the boundary — and every connection across it is made from the inside, outward.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 520" role="img" aria-label="Cameras stream RTSP to a shop PC running the engine, the agent and the shop app. The agent publishes visits over TLS MQTT to Mosquitto in the cloud; an ingest consumer writes them to PostgreSQL; the API serves the head-office console, the mobile app and platform administration. The shop PC pulls camera settings and check jobs from the API. A dashed boundary marks the shop network, with no inbound route.">
<text x="24" y="28" class="z">SHOP NETWORK</text><text x="520" y="28" class="z">LOYALY CLOUD</text><text x="880" y="28" class="z">PEOPLE</text>
<line x1="486" y1="42" x2="486" y2="490" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
<use href="#g-lock" x="474" y="492" width="24" height="24"/>
<text x="486" y="510" text-anchor="middle" class="l" dy="8">outbound only</text>
<!-- cameras -->
<use href="#g-cam" x="30" y="188" width="40" height="40"/>
<use href="#g-cam" x="30" y="236" width="40" height="40"/>
<text x="50" y="292" text-anchor="middle" class="m">RTSP</text>
<!-- shop pc -->
<rect x="120" y="70" width="330" height="400" rx="3" class="s"/>
<use href="#g-pc" x="134" y="82" width="22" height="22"/><text x="164" y="99" class="t">Shop PC</text>
<rect x="150" y="122" width="270" height="78" rx="2" class="sa"/>
<use href="#g-gear" x="162" y="134" width="20" height="20"/>
<text x="190" y="148" class="ta">Recognition engine</text>
<text x="190" y="166" class="m">Python · ONNX Runtime · FAISS</text>
<text x="190" y="182" class="m">detect → track → identify</text>
<use href="#g-db" x="384" y="160" width="26" height="26"/><text x="397" y="200" text-anchor="middle" class="m">SQLite</text>
<rect x="150" y="226" width="270" height="96" rx="2" class="s"/>
<text x="164" y="248" class="t">Agent</text><text x="164" y="266" class="m">Go · supervisor · camera sync</text>
<rect x="164" y="278" width="242" height="32" rx="2" class="fab"/>
<use href="#g-file" x="172" y="284" width="20" height="20"/>
<text x="200" y="299" class="ma">durable spool — one file per event</text>
<rect x="150" y="348" width="270" height="56" rx="2" class="s"/>
<text x="164" y="370" class="t">Shop app</text><text x="164" y="388" class="m">Wails · window + system tray</text>
<text x="285" y="440" text-anchor="middle" class="cap">runs with no internet;</text>
<text x="285" y="456" text-anchor="middle" class="cap">the spool drains when it returns</text>
<line x1="76" y1="212" x2="148" y2="160" class="s" marker-end="url(#a)"/><text x="126" y="214" class="l">video</text>
<line x1="285" y1="202" x2="285" y2="224" class="sa" marker-end="url(#aa)"/><text x="294" y="217" class="la">detections</text>
<line x1="285" y1="324" x2="285" y2="346" class="s" marker-end="url(#a)"/>
<!-- broker -->
<rect x="530" y="108" width="170" height="60" rx="2" class="s"/>
<use href="#g-cloud" x="542" y="118" width="22" height="22"/><text x="572" y="133" class="t">Mosquitto</text><text x="572" y="151" class="m">MQTT · TLS · per-tenant ACL</text>
<!-- server -->
<rect x="530" y="210" width="290" height="120" rx="2" class="s"/>
<text x="544" y="232" class="t">Behavision server</text><text x="544" y="249" class="m">Go · one binary</text>
<rect x="546" y="262" width="120" height="50" rx="2" class="s"/><text x="606" y="284" text-anchor="middle" class="t">ingest</text><text x="606" y="300" text-anchor="middle" class="m">dedupe · reinforce</text>
<rect x="684" y="262" width="120" height="50" rx="2" class="s"/><text x="744" y="284" text-anchor="middle" class="t">API + web</text><text x="744" y="300" text-anchor="middle" class="m">48 routes · SSE</text>
<!-- postgres -->
<use href="#g-db" x="656" y="388" width="40" height="40"/>
<text x="676" y="450" text-anchor="middle" class="ta">PostgreSQL</text>
<text x="676" y="466" text-anchor="middle" class="m">master database</text>
<!-- arrows cloud -->
<path d="M422 294 L505 294 L505 138 L528 138" class="sa" marker-end="url(#aa)"/>
<text x="462" y="284" text-anchor="middle" class="la">visits · QoS 1</text>
<line x1="615" y1="170" x2="606" y2="260" class="s" marker-end="url(#a)"/>
<line x1="606" y1="314" x2="668" y2="386" class="s" marker-end="url(#a)"/>
<line x1="744" y1="314" x2="690" y2="386" class="s" marker-start="url(#a)" marker-end="url(#a)"/>
<path d="M528 300 L470 300 L470 246 L424 246" class="sd" marker-end="url(#a)"/>
<text x="470" y="322" text-anchor="middle" class="l">pull: cameras, checks</text>
<!-- people -->
<rect x="880" y="96" width="196" height="56" rx="2" class="s"/><use href="#g-pc" x="892" y="106" width="22" height="22"/><text x="922" y="121" class="t">Head-office console</text><text x="922" y="138" class="m">owner · manager</text>
<rect x="880" y="182" width="196" height="56" rx="2" class="s"/><use href="#g-phone" x="892" y="192" width="22" height="22"/><text x="922" y="207" class="t">Mobile app</text><text x="922" y="224" class="m">sales staff</text>
<rect x="880" y="268" width="196" height="56" rx="2" class="s"/><use href="#g-person" x="892" y="278" width="22" height="22"/><text x="922" y="293" class="t">Platform admin</text><text x="922" y="310" class="m">creates merchants</text>
<path d="M878 124 L846 124 L846 288 L822 288" class="s" marker-end="url(#a)"/>
<path d="M878 210 L846 210" class="s"/>
<path d="M878 296 L846 296" class="s"/>
<text x="846" y="360" text-anchor="middle" class="l">https · session</text>
</svg>
</div>
<div class="facts">
<div><b>Three tiers</b>, one direction of trust: the shop initiates every connection it has.</div>
<div><b>One server binary</b> carries ingest, the API and the head-office web app.</div>
<div><b>One API</b> for the console, the mobile app and the shop app alike.</div>
<div><b>Offline is a delay, not a loss</b>: visits queue on disk until the broker confirms them.</div>
</div>
</section>
<!-- ============================================================ 02 -->
<section class="plate" id="p2">
<div class="head"><span class="n">02</span><div><h2>Inside the shop PC</h2><p>Three processes on one machine, each in the language its job is best done in, sharing one state root.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 400" role="img" aria-label="On the shop PC: the engine (Python) captures RTSP, runs detection and recognition, and keeps a SQLite gallery with a FAISS index. The agent (Go) supervises the engine, receives detections on a loopback webhook, spools them, and syncs cameras with head office. The shop app (Wails) hosts the window and tray and embeds the agent as a library. All three read and write one state root under ProgramData.">
<!-- engine -->
<rect x="30" y="50" width="340" height="230" rx="3" class="sa"/>
<text x="46" y="76" class="ta">Recognition engine</text><text x="46" y="93" class="m">Python 3.10+ · private venv</text>
<rect x="46" y="112" width="140" height="42" rx="2" class="s"/><text x="116" y="130" text-anchor="middle" class="t">capture thread</text><text x="116" y="146" text-anchor="middle" class="m">per camera · latest frame</text>
<rect x="214" y="112" width="140" height="42" rx="2" class="s"/><text x="284" y="130" text-anchor="middle" class="t">worker thread</text><text x="284" y="146" text-anchor="middle" class="m">per camera · one track = one person</text>
<line x1="188" y1="133" x2="212" y2="133" class="s" marker-end="url(#a)"/>
<rect x="46" y="176" width="308" height="42" rx="2" class="s"/><text x="200" y="194" text-anchor="middle" class="t">models · ONNX Runtime</text><text x="200" y="210" text-anchor="middle" class="m">YuNet · ArcFace r50 · genderage · CoreML / DirectML</text>
<use href="#g-db" x="60" y="232" width="30" height="30"/><text x="104" y="246" class="t">SQLite gallery</text><text x="104" y="262" class="m">identities · embeddings · sightings</text>
<rect x="250" y="232" width="104" height="34" rx="2" class="fab"/><text x="302" y="253" text-anchor="middle" class="ma">FAISS index</text>
<line x1="196" y1="249" x2="248" y2="249" class="sd" marker-end="url(#a)"/><text x="222" y="243" text-anchor="middle" class="l">rebuilt at boot</text>
<text x="200" y="300" text-anchor="middle" class="m">FastAPI on 127.0.0.1:8010 · Basic auth, credential generated on first start</text>
<!-- agent -->
<rect x="430" y="50" width="300" height="230" rx="3" class="s"/>
<text x="446" y="76" class="t">Agent — Go library</text><text x="446" y="93" class="m">agent/pkg · shared by app and headless agent</text>
<rect x="446" y="112" width="130" height="40" rx="2" class="s"/><text x="511" y="130" text-anchor="middle" class="t">supervisor</text><text x="511" y="146" text-anchor="middle" class="m">start · restart · backoff</text>
<rect x="586" y="112" width="130" height="40" rx="2" class="s"/><text x="651" y="130" text-anchor="middle" class="t">bridge</text><text x="651" y="146" text-anchor="middle" class="m">loopback webhook</text>
<rect x="446" y="166" width="130" height="40" rx="2" class="fab"/><text x="511" y="184" text-anchor="middle" class="ma">spool</text><text x="511" y="200" text-anchor="middle" class="m">bounded · acked per event</text>
<rect x="586" y="166" width="130" height="40" rx="2" class="s"/><text x="651" y="184" text-anchor="middle" class="t">pump</text><text x="651" y="200" text-anchor="middle" class="m">MQTT QoS 1 · TLS</text>
<rect x="446" y="220" width="270" height="40" rx="2" class="s"/><text x="581" y="238" text-anchor="middle" class="t">camera reconciler</text><text x="581" y="254" text-anchor="middle" class="m">pulls desired state · runs placement checks</text>
<!-- app -->
<rect x="790" y="50" width="280" height="230" rx="3" class="s"/>
<text x="806" y="76" class="t">Shop app — Wails</text><text x="806" y="93" class="m">Go + React in the system webview · 12 MB</text>
<rect x="806" y="112" width="248" height="40" rx="2" class="s"/><text x="930" y="130" text-anchor="middle" class="t">window</text><text x="930" y="146" text-anchor="middle" class="m">Live · Customers · Cameras</text>
<rect x="806" y="166" width="248" height="40" rx="2" class="s"/><text x="930" y="184" text-anchor="middle" class="t">system tray</text><text x="930" y="200" text-anchor="middle" class="m">green / amber / red · start · stop · quit</text>
<rect x="806" y="220" width="248" height="40" rx="2" class="s"/><text x="930" y="238" text-anchor="middle" class="t">camera relay</text><text x="930" y="254" text-anchor="middle" class="m">loopback · no credential in the page</text>
<!-- links -->
<path d="M372 133 L428 133" class="s" marker-end="url(#a)"/><text x="400" y="126" text-anchor="middle" class="l">events</text>
<path d="M428 186 L372 186" class="sd" marker-end="url(#a)"/><text x="400" y="204" text-anchor="middle" class="l">health · stats</text>
<path d="M732 165 L788 165" class="s" marker-start="url(#a)" marker-end="url(#a)"/><text x="760" y="158" text-anchor="middle" class="l">embeds</text>
<!-- state root -->
<rect x="30" y="316" width="1040" height="60" rx="3" class="fs"/>
<text x="50" y="340" class="t">One state root — ProgramData\Behavision</text>
<text x="50" y="360" class="m">data\behavision.db · data\cameras.json (DPAPI) · data\api_credentials.txt · models\ · runtime\ (the engine's Python) · agent.json · spool\</text>
<path d="M200 282 L200 314" class="sd"/><path d="M580 282 L580 314" class="sd"/><path d="M930 282 L930 314" class="sd"/>
<text x="1050" y="360" text-anchor="end" class="ma">BEHAVISION_DATA_DIR</text>
</svg>
</div>
<div class="facts">
<div><b>Python</b> where the recognition ecosystem is — ONNX, OpenCV, FAISS are first-class.</div>
<div><b>Go</b> for lifecycle and delivery — static binaries, cross-compiled to Windows from anywhere.</div>
<div><b>Wails</b> for the UI — window, tray and supervisor in one process; a service cannot draw a tray icon.</div>
<div><b>Exact search</b>: 100,000 identities in 21.9 ms. Identity is decided once per track, so this is queries per minute, not per frame.</div>
</div>
</section>
<!-- ============================================================ 03 -->
<section class="plate" id="p3">
<div class="head"><span class="n">03</span><div><h2>Recognition: one decision per visit</h2><p>Frames become tracks; tracks accumulate evidence; a track is identified once. A "not sure" outcome is what stops one person becoming three, and a stranger becoming a regular.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 430" role="img" aria-label="Flow: frames from a camera are detected by YuNet, associated into tracks by IoU, scored for quality, aligned and embedded with ArcFace, averaged over at least three views, then compared to the gallery. Similarity at or above 0.42 is a known person; between 0.32 and 0.42 the system waits for a better view; below 0.32 the person is new and enrolled. Known matches at good quality reinforce the gallery.">
<g class="t" text-anchor="middle">
<rect x="20" y="60" width="120" height="66" rx="2" class="s"/><text x="80" y="88">Frames</text>
<rect x="176" y="60" width="130" height="66" rx="2" class="s"/><text x="241" y="88">Detect</text>
<rect x="342" y="60" width="130" height="66" rx="2" class="s"/><text x="407" y="88">Track</text>
<rect x="508" y="60" width="130" height="66" rx="2" class="s"/><text x="573" y="88">Quality gate</text>
<rect x="674" y="60" width="130" height="66" rx="2" class="s"/><text x="739" y="88">Align + embed</text>
<rect x="840" y="60" width="130" height="66" rx="2" class="sa"/><text x="905" y="88" class="ta">Average ≥ 3</text>
</g>
<g class="m" text-anchor="middle">
<text x="80" y="108">15 fps · latest frame</text>
<text x="241" y="108">YuNet · 5 landmarks</text><text x="241" y="121">score ≥ 0.82</text>
<text x="407" y="108">greedy IoU 0.3</text><text x="407" y="121">one track per person</text>
<text x="573" y="108">sharp · size · light · frontal</text><text x="573" y="121">per-camera threshold</text>
<text x="739" y="108">Umeyama → 112×112</text><text x="739" y="121">ArcFace r50 · 512-d</text>
<text x="905" y="108">normalised mean</text><text x="905" y="121">≥ 4 hits</text>
</g>
<g class="s" marker-end="url(#a)"><line x1="142" y1="93" x2="174" y2="93"/><line x1="308" y1="93" x2="340" y2="93"/><line x1="474" y1="93" x2="506" y2="93"/><line x1="640" y1="93" x2="672" y2="93"/><line x1="806" y1="93" x2="838" y2="93"/></g>
<!-- decision -->
<path d="M905 128 L905 176" class="sa" marker-end="url(#aa)"/>
<path d="M905 180 L985 236 L905 292 L825 236 Z" class="sa"/>
<text x="905" y="231" text-anchor="middle" class="ta">cosine vs</text><text x="905" y="246" text-anchor="middle" class="ta">gallery</text>
<!-- outcomes -->
<path d="M825 236 L720 236" class="s" marker-end="url(#a)"/>
<rect x="590" y="206" width="128" height="60" rx="2" class="fgb"/><text x="654" y="230" text-anchor="middle" class="t">known</text><text x="654" y="248" text-anchor="middle" class="m">≥ 0.42 · person.seen</text>
<path d="M905 292 L905 330" class="s" marker-end="url(#a)"/>
<rect x="841" y="334" width="128" height="60" rx="2" class="fwb"/><text x="905" y="358" text-anchor="middle" class="t">not sure</text><text x="905" y="376" text-anchor="middle" class="m">0.32 – 0.42 · retry ≤ 8×</text>
<path d="M985 236 L1090 236" class="s" marker-end="url(#a)" style="display:none"/>
<path d="M985 236 L1020 236 L1020 260" class="s" marker-end="url(#a)"/>
<rect x="956" y="264" width="128" height="60" rx="2" class="fab"/><text x="1020" y="288" text-anchor="middle" class="t">new</text><text x="1020" y="306" text-anchor="middle" class="m">&lt; 0.32 · enrol</text>
<!-- gallery + reinforcement -->
<use href="#g-db" x="380" y="216" width="40" height="40"/>
<text x="400" y="278" text-anchor="middle" class="t">Gallery</text><text x="400" y="294" text-anchor="middle" class="m">SQLite + FAISS · ≤ 5 views per person</text>
<path d="M588 236 L426 236" class="sd" marker-end="url(#a)"/><text x="507" y="228" text-anchor="middle" class="l">reinforce: good quality, not a near-duplicate</text>
<path d="M956 300 L940 300 L940 410 L400 410 L400 262" class="sd" marker-end="url(#a)"/><text x="670" y="403" text-anchor="middle" class="l">enrol as "Visitor N"</text>
<path d="M400 214 L400 140 L905 140" class="sd" stroke-dasharray="2 3"/><text x="650" y="134" text-anchor="middle" class="l">index searched once per track</text>
<!-- retry loop -->
<path d="M841 364 L780 364 L780 93" class="sd" marker-end="url(#a)"/><text x="720" y="380" text-anchor="middle" class="l">wait 0.5 s for a better frame</text>
</svg>
</div>
<div class="facts">
<div><b>Never per frame.</b> Single-frame decisions turned one walk-past into three or four "people"; averaging fixed it.</div>
<div><b>Model-tagged embeddings.</b> Only same-model vectors share an index; swapping encoders can never mix spaces.</div>
<div><b>Quality is per camera, match is shared.</b> Every camera writes into one gallery.</div>
<div><b>Measured:</b> 103 tracks → 7 people, 44 correct re-recognitions, in five minutes on the office camera.</div>
</div>
</section>
<!-- ============================================================ 04 -->
<section class="plate" id="p4">
<div class="head"><span class="n">04</span><div><h2>Delivery: durable before published</h2><p>Nothing is removed from the shop's disk until the broker has confirmed it, and the server drops what it has already seen. That pair is what makes an outage a delay and not a hole.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 380" role="img" aria-label="Swimlanes for shop PC, broker and cloud. A visit event flows from engine to bridge, is appended to the spool and derived an event id, the pump is woken, publishes at QoS 1 over TLS to Mosquitto, the broker acknowledges, only then is the spool file deleted. The ingest consumer deduplicates on the event id, writes to PostgreSQL, and rings a doorbell that wakes live SSE streams to head office. When offline the pump retries with backoff and the spool grows on disk.">
<text x="24" y="26" class="z">SHOP PC</text><text x="560" y="26" class="z">BROKER</text><text x="790" y="26" class="z">CLOUD</text>
<line x1="536" y1="36" x2="536" y2="350" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
<line x1="760" y1="36" x2="760" y2="350" stroke="currentColor" stroke-width="1" opacity=".25"/>
<g class="t" text-anchor="middle">
<rect x="24" y="70" width="110" height="56" rx="2" class="s"/><text x="79" y="94">engine</text>
<rect x="164" y="70" width="110" height="56" rx="2" class="s"/><text x="219" y="94">bridge</text>
<rect x="304" y="70" width="110" height="56" rx="2" class="sa"/><text x="359" y="94" class="ta">spool</text>
<rect x="24" y="200" width="110" height="56" rx="2" class="s"/><text x="79" y="224">waker</text>
<rect x="304" y="200" width="110" height="56" rx="2" class="s"/><text x="359" y="224">pump</text>
<rect x="580" y="130" width="130" height="66" rx="2" class="s"/><text x="645" y="158">Mosquitto</text>
<rect x="790" y="130" width="120" height="66" rx="2" class="s"/><text x="850" y="158">ingest</text>
<rect x="790" y="250" width="120" height="56" rx="2" class="s"/><text x="850" y="274">API · SSE</text>
</g>
<g class="m" text-anchor="middle">
<text x="79" y="113">webhook POST</text>
<text x="219" y="113">event_id = site·cam·id·sec</text>
<text x="359" y="113">append → fsync</text>
<text x="79" y="243">rung AFTER append</text>
<text x="359" y="243">QoS 1 · in order</text>
<text x="645" y="178">TLS 8883 · ACL by tenant</text>
<text x="850" y="178">INSERT … ON CONFLICT</text>
<text x="850" y="293">arrivals feed</text>
</g>
<use href="#g-db" x="1000" y="140" width="40" height="40"/><text x="1020" y="200" text-anchor="middle" class="t">PostgreSQL</text>
<use href="#g-pc" x="1000" y="262" width="36" height="36"/><text x="1020" y="316" text-anchor="middle" class="m">head office</text>
<g class="s" marker-end="url(#a)">
<line x1="136" y1="98" x2="162" y2="98"/><line x1="276" y1="98" x2="302" y2="98"/>
<path d="M219 128 L219 228 L136 228" /><path d="M136 228 L302 228" style="display:none"/>
<line x1="136" y1="228" x2="302" y2="228" />
<path d="M416 228 L470 228 L470 163 L578 163" class="sa" marker-end="url(#aa)"/>
<line x1="712" y1="163" x2="788" y2="163"/>
<line x1="912" y1="163" x2="998" y2="163"/>
<line x1="850" y1="198" x2="850" y2="248"/>
<line x1="912" y1="278" x2="998" y2="278"/>
</g>
<text x="228" y="245" text-anchor="middle" class="l">wake</text>
<text x="470" y="152" text-anchor="middle" class="la">publish</text>
<text x="750" y="156" text-anchor="middle" class="l">deliver</text>
<text x="955" y="156" text-anchor="middle" class="l">write</text>
<text x="862" y="228" class="l">doorbell</text>
<text x="955" y="271" text-anchor="middle" class="l">push</text>
<!-- ack path -->
<path d="M645 198 L645 320 L359 320 L359 258" class="sa" stroke-dasharray="5 4" marker-end="url(#aa)"/>
<text x="500" y="338" text-anchor="middle" class="la">PUBACK → delete the spool file. Never before.</text>
<!-- offline loop -->
<path d="M304 240 L280 240 L280 300 L304 300" class="sd" style="display:none"/>
<rect x="160" y="284" width="126" height="44" rx="2" class="fs"/>
<text x="223" y="302" text-anchor="middle" class="l">offline?</text><text x="223" y="318" text-anchor="middle" class="m">backoff 1 → 30 s · spool grows</text>
<path d="M302 244 L286 300" class="sd" marker-end="url(#a)"/>
<path d="M286 306 L350 260" class="sd" style="display:none"/>
</svg>
</div>
<div class="facts">
<div><b>QoS 1, clean session.</b> QoS 0 could delete an event the wire dropped; QoS 2 buys nothing the derived id doesn't already give.</div>
<div><b>Ordered.</b> A failed publish stops the batch — a customer's visits are a timeline.</div>
<div><b>Bounded and honest.</b> The spool has a cap and reports what it dropped; a corrupt entry is quarantined, never retried forever.</div>
<div><b>Measured:</b> 120 simultaneous visits published, 120 delivered; end to end in ~3 s.</div>
</div>
</section>
<!-- ============================================================ 05 -->
<section class="plate" id="p5">
<div class="head"><span class="n">05</span><div><h2>Local gallery, master database</h2><p>Two stores with two jobs. The shop PC's gallery recognises people in that shop, offline if need be. The platform's database knows the business: customers across shops, history, reports, tenancy.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 360" role="img" aria-label="Left: the shop PC's SQLite gallery with identities, model-tagged embeddings and sightings, plus a camera list. Right: PostgreSQL with clients, sites, users, visitors, visits, embeddings, cameras and sessions, every row carrying a client id. Between them: templates travel up with each visit; camera configuration and check jobs travel down; nothing else crosses.">
<text x="24" y="26" class="z">SHOP PC</text><text x="640" y="26" class="z">PLATFORM</text>
<line x1="540" y1="36" x2="540" y2="330" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 4" opacity=".45"/>
<use href="#g-db" x="60" y="60" width="56" height="56"/>
<text x="140" y="80" class="ta">SQLite gallery</text>
<text x="140" y="98" class="m">the only persistent state on the PC · WAL</text>
<g class="m"><text x="140" y="124">identities Visitor N, label</text><text x="140" y="140">embeddings 512-d · tagged by model</text><text x="140" y="156">sightings identity × camera × time</text></g>
<text x="140" y="184" class="t">FAISS index — rebuilt from SQLite at boot</text>
<text x="140" y="200" class="m">exact inner product · numpy fallback identical</text>
<use href="#g-file" x="60" y="230" width="44" height="44"/>
<text x="140" y="250" class="t">cameras.json</text><text x="140" y="266" class="m">passwords DPAPI-encrypted · machine-bound</text>
<text x="140" y="296" class="t">agent.json</text><text x="140" y="312" class="m">broker login · agent token · sealed at rest</text>
<use href="#g-db" x="590" y="60" width="56" height="56"/>
<text x="670" y="80" class="ta">PostgreSQL</text>
<text x="670" y="98" class="m">every table carries client_id · self-migrating schema · 13 migrations</text>
<g class="m">
<text x="670" y="124">clients slug = MQTT topic prefix</text>
<text x="670" y="140">sites · agents slug · tz · heartbeat · fraction_below_gate</text>
<text x="670" y="156">app_users owner · manager · staff · bcrypt</text>
<text x="670" y="172">visitors number → V-42 · per tenant</text>
<text x="670" y="188">visits seq (feed cursor) · source_event_id (dedupe)</text>
<text x="670" y="204">visitor_embeddings ≤ 5 · reinforced server-side</text>
<text x="670" y="220">site_cameras password sealed AES-GCM, aad = site</text>
<text x="670" y="236">sessions SHA-256 of tokens · revocable</text>
<text x="670" y="252">visit_faces · camera_snapshots · audit_log</text>
</g>
<text x="670" y="290" class="t">Object storage (optional)</text><text x="670" y="306" class="m">presigned PUT from the shop PC · presigned GET for staff · private ACL in the signature</text>
<!-- flows across -->
<path d="M420 120 L660 120" style="display:none"/>
<path d="M380 210 L528 210 L528 190 L556 190" class="sa" marker-end="url(#aa)" style="display:none"/>
<path d="M400 216 L520 216" class="sa" marker-end="url(#aa)"/><text x="460" y="208" text-anchor="middle" class="la">visit + template ↑</text>
<path d="M520 244 L400 244" class="sd" marker-end="url(#a)"/><text x="460" y="262" text-anchor="middle" class="l">cameras · checks ↓</text>
<path d="M400 290 L520 290" class="sd" marker-end="url(#a)" opacity=".5"/><text x="460" y="308" text-anchor="middle" class="l">heartbeat · health ↑</text>
</svg>
</div>
<div class="facts">
<div><b>Video, frames and raw images never cross.</b> A template and a timestamp do.</div>
<div><b>Tenancy is a column and a rule</b>, and the two agree: the tenant comes from the session, never from the request.</div>
<div><b>References are immutable</b> — slugs, camera ids, customer numbers — because other systems store them. Display names are free to change.</div>
<div><b>Feed by <code>seq</code></b>, never by the camera's clock: lossless under bursts and backlogs; cursors are opaque.</div>
</div>
</section>
<!-- ============================================================ 06 -->
<section class="plate" id="p6">
<div class="head"><span class="n">06</span><div><h2>Clients and the API</h2><p>Three kinds of people and one kind of machine, all through one API. Sessions are opaque tokens in a table, so "log that device out, now" actually works.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 400" role="img" aria-label="Head-office console, mobile app and shop app sign in with email and password and receive an opaque session; the tenant and role come from that session. They call the API's visits, visitors, sites, cameras, reports, team and assistant routes. The shop PC's agent uses its own token, issued at enrolment, against the agent routes only. Live camera video reaches head office through the agent's outbound relay.">
<g class="t">
<rect x="24" y="50" width="200" height="60" rx="2" class="s"/><use href="#g-pc" x="36" y="62" width="22" height="22"/><text x="66" y="77">Head-office console</text><text x="66" y="95" class="m">React · embedded in server</text>
<rect x="24" y="130" width="200" height="60" rx="2" class="s"/><use href="#g-phone" x="36" y="142" width="22" height="22"/><text x="66" y="157">Mobile app</text><text x="66" y="175" class="m">arrivals · customers · sales</text>
<rect x="24" y="210" width="200" height="60" rx="2" class="s"/><use href="#g-pc" x="36" y="222" width="22" height="22"/><text x="66" y="237">Shop app</text><text x="66" y="255" class="m">engine on loopback · cloud for the rest</text>
<rect x="24" y="300" width="200" height="60" rx="2" class="sa"/><use href="#g-gear" x="36" y="312" width="22" height="22"/><text x="66" y="327" class="ta">Shop PC agent</text><text x="66" y="345" class="m">token issued once at enrolment</text>
</g>
<rect x="300" y="50" width="220" height="220" rx="2" class="sa"/>
<text x="316" y="76" class="ta">Session</text>
<g class="m"><text x="316" y="100">256-bit opaque token</text><text x="316" y="116">stored as SHA-256 only</text><text x="316" y="132">refresh rotates in place</text><text x="316" y="148">revocable per device, instantly</text></g>
<rect x="316" y="166" width="188" height="88" rx="2" class="fs"/>
<text x="330" y="186" class="t">tenant ← session.client_id</text>
<text x="330" y="206" class="m">staff arrivals · customers · sales</text>
<text x="330" y="222" class="m">manager + cameras · team · erasure</text>
<text x="330" y="238" class="m">owner + mint owners</text>
<rect x="600" y="50" width="476" height="310" rx="2" class="s"/>
<text x="616" y="76" class="t">/api</text>
<g class="m">
<text x="616" y="104">/auth/login · refresh · sessions · register</text>
<text x="616" y="124">/visits · /visits/stream ·································· SSE, cursor</text>
<text x="616" y="144">/visitors · /history · /profile · /image · /purchases</text>
<text x="616" y="164">/sites · /sites/{s}/check · /enrolment-code</text>
<text x="616" y="184">/cameras · /check · /snapshot.jpg · /live ······· SSE relay</text>
<text x="616" y="204">/reports/footfall · /reports/conversion</text>
<text x="616" y="224">/team · /team/members · /team/{id}/password · /invitations</text>
<text x="616" y="244">/assistant ··································· tools, never SQL</text>
<text x="616" y="264">/admin/clients ····························· platform admin only</text>
</g>
<rect x="616" y="284" width="444" height="56" rx="2" class="fab"/>
<text x="630" y="306" class="ma">/agent/* — enrol · cameras · checks · faces · upload-url · live</text>
<text x="630" y="326" class="m">agent token only · a user session is refused</text>
<g class="s" marker-end="url(#a)"><line x1="226" y1="80" x2="298" y2="80"/><line x1="226" y1="160" x2="298" y2="160"/><line x1="226" y1="240" x2="298" y2="240"/><line x1="522" y1="160" x2="598" y2="160"/></g>
<path d="M226 330 L560 330 L560 312 L614 312" class="sa" marker-end="url(#aa)"/>
<text x="262" y="72" class="l">email + password</text>
<text x="1090" y="385" text-anchor="end" class="cap">Ids accept names: /api/visitors/V-42 · ?site=chennai · /api/cameras/cam1 — a uuid still works everywhere.</text>
</svg>
</div>
<div class="facts">
<div><b>Login is boring on purpose.</b> Unknown address and wrong password are byte-identical and cost the same time.</div>
<div><b>Another tenant's data is 404</b>, never 403 — nothing to enumerate.</div>
<div><b>Live video</b> at head office: the agent pushes ~13 fps only while someone watches. 259 KB/s measured.</div>
<div><b>The assistant</b> answers from the same report tools, as the signed-in user; it has no tenant parameter to misuse.</div>
</div>
</section>
<!-- ============================================================ 07 -->
<section class="plate" id="p7">
<div class="head"><span class="n">07</span><div><h2>Onboarding: each tier creates the next</h2><p>No credential ships inside an installer, and nobody creates their own account from nothing.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 300" role="img" aria-label="Sequence: the platform admin creates a merchant and its owner, receiving a one-time password. The owner creates sales staff, receiving a one-time password, or issues an invitation code. Staff sign in on the mobile app. The owner mints a single-use installation code; the shop PC redeems it and receives its broker login and API token. Head office then pushes the shop's cameras to the PC.">
<g class="t" text-anchor="middle">
<use href="#g-person" x="60" y="40" width="40" height="40"/><text x="80" y="100">Platform admin</text>
<use href="#g-person" x="300" y="40" width="40" height="40"/><text x="320" y="100">Merchant owner</text>
<use href="#g-phone" x="540" y="40" width="40" height="40"/><text x="560" y="100">Sales staff</text>
<use href="#g-pc" x="780" y="40" width="40" height="40"/><text x="800" y="100">Shop PC</text>
<use href="#g-cam" x="1000" y="40" width="40" height="40"/><text x="1020" y="100">Cameras</text>
</g>
<g class="s" marker-end="url(#a)">
<line x1="120" y1="140" x2="278" y2="140"/>
<line x1="360" y1="140" x2="518" y2="140"/>
<line x1="360" y1="200" x2="758" y2="200"/>
<line x1="840" y1="200" x2="998" y2="200"/>
</g>
<path d="M840 240 L1000 240" class="sd" marker-end="url(#a)"/>
<g class="m" text-anchor="middle">
<text x="199" y="130">POST /api/admin/clients</text><text x="199" y="158">company + owner, one transaction</text><text x="199" y="172" class="ma" opacity="1">owner password, shown once</text>
<text x="439" y="130">POST /api/team/members</text><text x="439" y="158">or /team/invitations → a code they redeem</text><text x="439" y="172" class="ma" opacity="1">staff password, shown once</text>
<text x="559" y="190">POST /api/sites/{shop}/enrolment-code</text><text x="559" y="218">single use · 7 days · redeemed by the PC:</text><text x="559" y="232" class="ma" opacity="1">broker login + agent token + CA to pin</text>
<text x="919" y="190">head office pushes cameras</text><text x="919" y="230">the PC pulls · adopts local ones up</text><text x="919" y="258">passwords travel only to that site's agent</text>
</g>
<text x="24" y="288" class="cap">Single use is enforced by the UPDATE itself, so two PCs racing on one code cannot both win. A wrong, spent or expired code all read the same.</text>
</svg>
</div>
<div class="facts">
<div><b>Direct or by invitation.</b> A manager can hand over a generated password, or let the salesperson choose their own via a code.</div>
<div><b>Reset signs the lost phone out</b> in the same transaction as the new password.</div>
<div><b>Standalone</b> is a first-class answer on the setup screen: a single-till shop with no head office runs the full product locally.</div>
<div><b>Demo build:</b> cameras ship sealed (AES-256-GCM); the unlock code travels separately from the zip.</div>
</div>
</section>
<!-- ============================================================ 08 -->
<section class="plate" id="p8">
<div class="head"><span class="n">08</span><div><h2>Where every secret lives</h2><p>Biometric data is treated as biometric data. Each credential has one home and one protection, and none of them is ever returned by an API.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 330" role="img" aria-label="Map of secrets: on the shop PC, camera passwords under DPAPI, the engine API credential in a 0600 file, agent token and broker password sealed in agent.json; in the database, site broker passwords and camera passwords under AES-256-GCM with the site as additional data, user passwords under bcrypt cost 12, session tokens as SHA-256; in transit, TLS for MQTT and HTTPS for the API; face templates never leave the shop as images, photos are opt-in, erasure deletes templates and objects, every image read is audited.">
<text x="24" y="26" class="z">SHOP PC</text><text x="400" y="26" class="z">IN TRANSIT</text><text x="640" y="26" class="z">DATABASE</text><text x="900" y="26" class="z">POLICY</text>
<line x1="376" y1="36" x2="376" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
<line x1="616" y1="36" x2="616" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
<line x1="876" y1="36" x2="876" y2="300" stroke="currentColor" stroke-width="1" opacity=".25"/>
<g class="t"><use href="#g-lock" x="24" y="52" width="20" height="20"/><text x="52" y="67">camera passwords</text><text x="52" y="84" class="m">DPAPI · machine-bound · has_password only</text>
<use href="#g-lock" x="24" y="110" width="20" height="20"/><text x="52" y="125">engine API credential</text><text x="52" y="142" class="m">generated on first start · 0600 · read by the agent</text>
<use href="#g-lock" x="24" y="168" width="20" height="20"/><text x="52" y="183">agent token · broker password</text><text x="52" y="200" class="m">sealed in agent.json · earned by enrolment, never shipped</text>
<use href="#g-lock" x="24" y="226" width="20" height="20"/><text x="52" y="241">face templates</text><text x="52" y="258" class="m">SQLite · treated as personal data · erasure deletes outright</text>
</g>
<g class="t"><text x="400" y="67">MQTT</text><text x="400" y="84" class="m">TLS 8883 · pinned issuer · plaintext to any non-loopback host is refused</text>
<text x="400" y="125">API</text><text x="400" y="142" class="m">HTTPS behind Traefik · bearer sessions</text>
<text x="400" y="183">images</text><text x="400" y="200" class="m">presigned URLs, minutes-long · private ACL inside the signature</text>
<text x="400" y="241">shop PC ↔ engine</text><text x="400" y="258" class="m">loopback only · relay token per run, no password in the page</text>
</g>
<g class="t"><text x="640" y="67">broker + camera passwords</text><text x="640" y="84" class="m">AES-256-GCM · aad = owning site · row copies don't decrypt</text>
<text x="640" y="125">user passwords</text><text x="640" y="142" class="m">bcrypt cost 12 · 10 failures / 15 min per account</text>
<text x="640" y="183">session tokens</text><text x="640" y="200" class="m">SHA-256 only · a dump holds no usable session</text>
<text x="640" y="241">audit_log</text><text x="640" y="258" class="m">every face-image hand-out, every code minted, every merchant created</text>
</g>
<g class="t"><text x="900" y="67">video never leaves</text><text x="900" y="84" class="m">recognition runs in the shop</text>
<text x="900" y="125">photos are opt-in</text><text x="900" y="142" class="m">store_faces defaults to off</text>
<text x="900" y="183">tenancy is structural</text><text x="900" y="200" class="m">session decides · 404, never 403</text>
<text x="900" y="241">erasure erases</text><text x="900" y="258" class="m">object first · 502 changes nothing</text>
</g>
</svg>
</div>
</section>
<!-- ============================================================ 09 -->
<section class="plate" id="p9">
<div class="head"><span class="n">09</span><div><h2>The stack, and the numbers behind it</h2><p>Each layer, the choice, and the one reason that decided it.</p></div></div>
<div class="fig">
<svg viewBox="0 0 1100 330" role="img" aria-label="Layer stack: clients (React console, mobile, Wails app); API and web (Go, one binary, opaque sessions); transport (Mosquitto MQTT, QoS 1, TLS); master data (PostgreSQL, self-migrating); shop agent (Go library: spool, pump, supervisor); recognition (Python: YuNet, ArcFace r50 on ONNX Runtime, FAISS, SQLite); cameras (any RTSP). Each with its deciding reason.">
<g class="t">
<rect x="24" y="30" width="1052" height="38" rx="2" class="s"/><text x="40" y="54">Clients</text><text x="200" y="54" class="m">React console (embedded) · mobile app · Wails shop app</text><text x="1060" y="54" text-anchor="end" class="l">one API; UI can never lag its server</text>
<rect x="24" y="74" width="1052" height="38" rx="2" class="s"/><text x="40" y="98">API + web</text><text x="200" y="98" class="m">Go · one binary · opaque sessions in a table</text><text x="1060" y="98" text-anchor="end" class="l">instant per-device revocation; JWTs cannot</text>
<rect x="24" y="118" width="1052" height="38" rx="2" class="s"/><text x="40" y="142">Transport</text><text x="200" y="142" class="m">Mosquitto · MQTT QoS 1 · TLS · per-tenant ACL</text><text x="1060" y="142" text-anchor="end" class="l">built for many outbound clients; ~10 MB</text>
<rect x="24" y="162" width="1052" height="38" rx="2" class="s"/><text x="40" y="186">Master data</text><text x="200" y="186" class="m">PostgreSQL · self-applying migrations · advisory lock · checksums</text><text x="1060" y="186" text-anchor="end" class="l">transactions across tenant + owner; keyset feeds</text>
<rect x="24" y="206" width="1052" height="38" rx="2" class="s"/><text x="40" y="230">Shop agent</text><text x="200" y="230" class="m">Go library · spool · pump · supervisor · reconciler</text><text x="1060" y="230" text-anchor="end" class="l">static binary, cross-compiled; one tested implementation</text>
<rect x="24" y="250" width="1052" height="38" rx="2" class="sa"/><text x="40" y="274" class="ta">Recognition</text><text x="200" y="274" class="m">Python · YuNet · ArcFace r50 (ONNX Runtime) · FAISS IndexFlatIP · SQLite WAL</text><text x="1060" y="274" text-anchor="end" class="la">97.25 IJB-C · exact search · no second process</text>
<rect x="24" y="294" width="1052" height="30" rx="2" class="fs"/><text x="40" y="314">Cameras</text><text x="200" y="314" class="m">any RTSP camera · make picker fills the stream path · placement proved by a 25-second walk-past</text>
</g>
</svg>
</div>
<div class="metrics" style="margin-top:1.1rem">
<div class="metric"><span class="v">103 → 7</span><span class="k">tracks to people, 5 min, office camera — 44 re-recognitions</span></div>
<div class="metric"><span class="v">120 / 120</span><span class="k">simultaneous visits delivered, real broker and database</span></div>
<div class="metric"><span class="v">21.9 ms</span><span class="k">exact search over 100,000 identities</span></div>
<div class="metric"><span class="v">~3 s</span><span class="k">camera to head-office feed</span></div>
<div class="metric"><span class="v">14 fps</span><span class="k">live picture on the shop PC vs a 15 fps camera</span></div>
<div class="metric"><span class="v">10 / 10</span><span class="k">install steps on a clean machine, both cameras connected</span></div>
</div>
</section>
</main>
<footer>
<div class="wrap">Behavision — Loyaly · Technical overview, 11 September 2026 · release 0.4.1 · engine 1.1.0 · schema at migration 13. All figures measured on the running system.</div>
</footer>