docs(bf-5uzm): map cross-package blob conversion boundaries in main.go

Re-verified all four named conversion sites against HEAD 7846408 and found the
task's line numbers had drifted (+90..+110). Recorded the corrected locations:
  E1 fusion.Blob peak -> sigproc.TrackedBlob       :5384 -> :5494
  E2 sigproc.TrackedBlob -> automation.TrackedBlob  :2213 -> :2303  (identity dropped)
  E3 sigproc.TrackedBlob -> explainability.BlobSnap :2116 -> :2206  (identity dropped)
  volume sigproc.TrackedBlob -> volume.BlobPos      :2236 -> :2326  (PersonID field exists, unpopulated)

Enumerated all 9 blob-projection loops in the live 10 Hz fusion loop with source/
target types and copied fields, flagged the two identity-leak boundaries (E2/E3)
as the umbrella goal's fix targets, and noted per-target whether the type even
carries identity fields. Includes a corrected live data-flow diagram: the live
loop runs blobTracker (greedy associator), not tracker.TrackManager/applyIdentity;
resolved identity lives in the ble.IdentityMatcher sidecar and is never written
onto the blob struct.

go vet ./... and go test ./... clean.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-06 15:17:44 -04:00
parent d99f42d3bf
commit 585532f5b2

View file

@ -0,0 +1,333 @@
# Cross-Package Blob Conversion Boundaries in main.go (bf-5uzm)
> **Scope:** The cross-package conversion boundaries in `mothership/cmd/mothership/main.go`
> where a tracked blob is converted from one type to another. These are the identity-leak
> sites — the actual goal of the umbrella effort (parent findings: `notes/bf-3ldj-findings.md`
> §5 Tier 1 + §6 data flow, `notes/bf-4bhd.md` Pattern 4).
>
> **All file:line references below were re-verified against HEAD `7846408` on 2026-07-06.**
> The four sites named in the task have **drifted** since the reference notes were written;
> the corrected (current) line numbers are authoritative and are recorded in §1.
>
> Paths: the reference notes use the module-relative path `cmd/mothership/main.go`. The
> repo-relative path (used throughout this doc) is `mothership/cmd/mothership/main.go`
> same file.
---
## 0. TL;DR
| Task site (claimed) | Verified site (HEAD `7846408`) | Boundary | Target carries identity? | Verdict |
|---|---|---|---|---|
| `main.go:5384` (E1) | **`main.go:5494`** | `fusion.Blob` peak → `sigproc.TrackedBlob` | Target **has** 6 identity fields | Left zero — **by design** (peak source is pre-identity) |
| `main.go:2213` (E2) | **`main.go:2303`** | `sigproc.TrackedBlob``automation.TrackedBlob` | Target has **none** | ❌ **LEAK — identity dropped**, no compensation |
| `main.go:2116` (E3) | **`main.go:2206`** | `sigproc.TrackedBlob``explainability.BlobSnapshot` | Target has **none** | ⚠ **LEAK — dropped at the snapshot**, but a separate `identityMap` side-channel (`main.go:2216`) partially compensates |
| `main.go:2236` (volume) | **`main.go:2326`** | `sigproc.TrackedBlob``volume.BlobPos` | Target **has** `PersonID` | ⚠ **Dropped** — field exists but conversion leaves it `""` |
The two boundaries to fix for the umbrella goal are **E2 (automation, `:2303`)** and
**E3 (explainability, `:2206`)** — exactly as the reference notes flag, only the line
numbers have moved (+90 to +110 lines).
**One material correction to the reference notes** (see §3): the live 10 Hz loop does **not**
run `tracker.TrackManager`/`applyIdentity`. It runs a simple peak-associator (`blobTracker`,
`main.go:5433`) that emits `sigproc.TrackedBlob` with **identity fields permanently zero**.
Resolved identity lives in a sidecar — `ble.IdentityMatcher`, queried by `blob.ID` — and is
never written back onto the blob struct. So "identity dropped at the conversion" really means
"the conversion is the place identity *could* be attached from the sidecar, and isn't."
---
## 1. The four named conversion boundaries (verified against HEAD)
All four live in two places: **E1** is in `(*blobTracker).track` (`main.go:5447`); **E2, E3,
volume** are inline stages of the live 10 Hz fusion loop. For the latter three the loop source
is `blobs`, declared at `main.go:2057`:
```go
blobs := blobTracker.track(result) // []sigproc.TrackedBlob (main.go:2057)
```
So the **source type for E2 / E3 / volume is `sigproc.TrackedBlob`** (alias of
`signal.TrackedBlob`; import at `main.go:63`: `sigproc "github.com/spaxel/mothership/internal/signal"`).
### 1.1 E1 — `main.go:5494``fusion.Blob` peak → `sigproc.TrackedBlob`
- **Enclosing func:** `(*blobTracker).track` (`main.go:5447`).
- **Source type:** `fusion.Blob``{X, Y, Z, Confidence}` (`internal/fusion/fusion.go:36`).
**No identity fields** (a fusion peak is pre-identity by construction).
- **Target type:** `sigproc.TrackedBlob` (`internal/signal/processor.go:587`) — **has** 6
identity fields (`PersonID`, `PersonLabel`, `PersonColor`, `IdentityConfidence`,
`IdentitySource`, `Posture`).
- **Literal (`main.go:5494`):**
```go
b := sigproc.TrackedBlob{
ID: id, // from greedy nearest-neighbour association, NOT from the peak
X: pk.X,
Y: pk.Y,
Z: pk.Z,
Weight: pk.Confidence,
}
// then, if a previous position exists for this id (main.go:5501-5505):
// b.VX = (pk.X - pb.X) / dt ; b.VY = ... ; b.VZ = ...
```
- **Copied:** `ID` (tracker-assigned), `X/Y/Z`, `Weight` (← peak `Confidence`); `VX/VY/VZ`
derived from the previous frame's position of the same ID.
- **Identity dropped:** all 6 identity fields left at Go zero values.
- **Why this is "by design", not a leak:** the source is a pre-identity peak, and `blobTracker`
is a pure position-associator — it has no access to BLE matches. Identity is attached
*later* (in the sidecar; see §3). Tier 3 in `bf-3ldj` §5.
### 1.2 E2 — `main.go:2303``sigproc.TrackedBlob``automation.TrackedBlob` ❌ LEAK
- **Enclosing stage:** "Stage 6: Trigger evaluation" (`main.go:2298`), inside the
`if automationEngine != nil {` block.
- **Source type:** `sigproc.TrackedBlob` (`b`, range var over `blobs`).
- **Target type:** `automation.TrackedBlob` (`internal/automation/engine.go:1337`):
```go
type TrackedBlob struct {
ID int
X, Y, Z float64
VX, VY, VZ float64
Confidence float64
}
```
**The target type carries NO identity fields at all** (ID + pos + vel + Confidence only).
- **Literal (`main.go:2303`):**
```go
autoBlobs[i] = automation.TrackedBlob{
ID: b.ID,
X: b.X, Y: b.Y, Z: b.Z,
VX: b.VX, VY: b.VY, VZ: b.VZ,
Confidence: b.Weight,
}
```
- **Copied:** `ID`, `X/Y/Z`, `VX/VY/VZ`, `Confidence` (← `b.Weight`).
- **Identity dropped:** all of it — and the target struct has nowhere to put it even if the
source had it.
- **No side-channel compensation:** `automationEngine.Evaluate(autoBlobs, zoneForBlob)`
(`main.go:2314`) is passed only a `zoneForBlob` resolver (returns zone name), **not** a
person resolver. The engine does have a `personProvider` (wired at `main.go:3147`,
`automationEngine.SetPersonProvider(&automationPersonAdapter{registry: bleRegistry})`),
but it resolves `PersonName`/`PersonColor` **given a `PersonID`** (`engine.go:952`) — and
no path supplies a `PersonID` for a blob, because `automation.TrackedBlob` has no such field
and `Evaluate` receives only position. **Person-aware automations ("when Alice enters…") are
therefore structurally blocked at this boundary.** This is the highest-value fix.
### 1.3 E3 — `main.go:2206``sigproc.TrackedBlob``explainability.BlobSnapshot` ⚠ LEAK (partly compensated)
- **Enclosing stage:** "Stage 2" explainability block (`main.go:2160`,
`if explainabilityHandler != nil {`).
- **Source type:** `sigproc.TrackedBlob` (`blob`, range var over `blobs`).
- **Target type:** `explainability.BlobSnapshot` (`internal/explainability/handler.go:95`):
```go
type BlobSnapshot struct {
ID int
X, Y, Z float64
Confidence float64
Weight float64 // Peak height in the grid
}
```
**The target type carries NO identity fields.**
- **Literal (`main.go:2206`):**
```go
blobSnapshots = append(blobSnapshots, explainability.BlobSnapshot{
ID: blob.ID,
X: blob.X, Y: blob.Y, Z: blob.Z,
Confidence: blob.Weight,
})
```
- **Copied:** `ID`, `X/Y/Z`, `Confidence` (← `blob.Weight`). (`BlobSnapshot.Weight` — peak
height — is left `0`; no grid snapshot is available in this path, see `main.go:2235`.)
- **Identity dropped:** person/color/source — none carried by the snapshot.
- **Side-channel compensation (important):** immediately after the snapshot loop, a separate
`identityMap` is built from the sidecar and passed alongside the snapshots
(`main.go:2215-2236`):
```go
identityMap := make(map[int]*explainability.BLEMatch)
for _, blob := range blobs {
match := identityMatcher.GetMatch(blob.ID)
if match != nil && match.PersonID != "" { ... identityMap[blob.ID] = &explainability.BLEMatch{...} }
}
explainabilityHandler.UpdateBlobs(blobSnapshots, linkStates, gridSnapshot, identityMap)
```
So the "Why?" overlay **does** receive identity — via the `identityMap` argument keyed by
`blob.ID`, not via the `BlobSnapshot`. The snapshot struct itself remains identity-free; if
`BlobSnapshot` is ever consumed in isolation (without `identityMap`), the person name is
lost. The clean fix is to put identity on `BlobSnapshot` and drop the parallel map.
### 1.4 volume — `main.go:2326``sigproc.TrackedBlob``volume.BlobPos` ⚠ Dropped
- **Enclosing stage:** "Stage 6: Trigger evaluation" (`main.go:2322`),
`if volumeTriggersHandler != nil {`.
- **Source type:** `sigproc.TrackedBlob` (`blob`, range var over `blobs`).
- **Target type:** `volume.BlobPos` (`internal/volume/shape.go:1080`):
```go
type BlobPos struct {
ID int
PersonID string // ← identity field EXISTS on the target
X, Y, Z float64
}
```
**The target type DOES carry an identity field (`PersonID`).**
- **Literal (`main.go:2326`):**
```go
volumeBlobs[i] = volume.BlobPos{
ID: blob.ID,
X: blob.X, Y: blob.Y, Z: blob.Z,
}
```
- **Copied:** `ID`, `X/Y/Z` only.
- **Identity dropped:** `PersonID` — the field is **present** on `volume.BlobPos` but the
conversion **does not populate it** (left `""`). Velocity is also not copied (not carried by
`BlobPos`). Because the field already exists, this is the cheapest boundary to repair: a
single `PersonID: <from sidecar>` line would unblock person-filtered volume triggers
(`condition_params.person`).
---
## 2. Complete enumeration of blob-conversion loops in main.go
Acceptance criterion: *"Every main.go blob-conversion loop enumerated with source type,
target type, copied fields."* Beyond the four named boundaries, the live loop projects the
tracked blob into several other shapes. Full list (HEAD `7846408`):
| # | main.go:line | Source | Target type | Fields copied | Identity? |
|---|---|---|---|---|---|
| 1 | `:2087` | `sigproc.TrackedBlob` (`blob`) | `map[string]interface{}` (detection-event JSON detail) | `x,y,z,vx,vy,vz,confidence,posture` | person passed **separately** as `personID` arg to `eventsHandler.LogEvent` (`:2100`) — not in the map |
| 2 | `:2113` | `sigproc.TrackedBlob` (`b`) | anon struct `{ID,X,Y,Z,Weight}``identityMatcher.UpdateBlobs` | `ID,X,Y,Z,Weight` | n/a — matcher is the identity **producer**, not consumer; velocity dropped |
| 3 | **`:2206` (E3)** | `sigproc.TrackedBlob` | `explainability.BlobSnapshot` | `ID,X,Y,Z,Confidence` | ⚠ dropped (compensated via `identityMap` side-channel, `:2216`) |
| 4 | `:2271` | `sigproc.TrackedBlob` + sidecar | `analytics.TrackUpdate` | `ID,X,Y,Z,VX,VY,VZ,PersonID` | ✅ **PersonID populated from sidecar** (`identityMatcher.GetMatch`, `:2267`) — the one consumer that does it right |
| 5 | `:2288` | `sigproc.TrackedBlob` (`blob`) | anon struct `{ID,X,Y,Z,VX,VY,VZ,Posture}``fallDetector.Update` | `ID,X,Y,Z,VX,VY,VZ` | `Posture` field **exists on the anon struct but is not set** (`:2293`); no identity |
| 6 | **`:2303` (E2)** | `sigproc.TrackedBlob` | `automation.TrackedBlob` | `ID,X/Y/Z,VX/VY/VZ,Confidence` | ❌ dropped; target has no identity field |
| 7 | **`:2326` (volume)** | `sigproc.TrackedBlob` | `volume.BlobPos` | `ID,X,Y,Z` | ⚠ `PersonID` field exists on target, left `""` |
| 8 | `:2153` | `sigproc.TrackedBlob` (`blob`) | `localization.Vec3` (ground-truth collector position) | `X,Y,Z` | n/a — pure position vector, no `ID` |
| 9 | **`:5494` (E1)** | `fusion.Blob` peak (`pk`) | `sigproc.TrackedBlob` | `ID(assoc),X,Y,Z,Weight(+derived VX/VY/VZ)` | by-design zero (pre-identity peak) |
Notes on the table:
- **Rows 3, 6, 7, 9** are the four boundaries named in the task.
- **Row 4 (`analytics.TrackUpdate`)** is the reference implementation for how identity *should*
be threaded: pull `PersonID` from `identityMatcher.GetMatch(blob.ID)` at the conversion site.
The E2/volume fixes should mirror this pattern.
- **Row 5 (fall-detect anon struct)** has its own latent bug — the `Posture` field is declared
but never set — tracked separately under falldetect (see `bf-3ldj` §3 [G1]
`falldetect/detector.go:277` for the in-package `BlobSnapshot`).
---
## 3. Material correction to the reference data-flow diagram
`bf-3ldj-findings.md` §6 draws the live path as:
```
fusion.Blob peak → sigproc.TrackedBlob [E1] → tracker.Blob [A1 tracker.go:162]
→ TrackManager.UpdateWithIdentity [A3] ✅ IDENTITY ATTACHED → GetAllBlobs [B1/B2] → consumers
```
**That is not the live path in HEAD `7846408`.** Verified facts:
1. **`tracker.Tracker` / `tracker.TrackManager` is not wired into `main.go` at all** — zero
references in `cmd/mothership/main.go` (non-test). The `applyIdentity` / `clearIdentity`
identity machinery in `internal/tracker/identity.go:164-185` therefore never runs in the
live loop. (grep for `tracker\.\|TrackManager` in main.go returns nothing.)
2. The live loop uses `blobTracker` (`main.go:5433`, a hand-rolled greedy nearest-neighbour
associator) whose `track()` returns `[]sigproc.TrackedBlob` (`main.go:5447`).
3. **`sigproc.TrackedBlob`'s 6 identity fields are never written in any live path** — grep
across `internal/` and `cmd/` (non-test) for `\.PersonLabel =` / `\.PersonID =` /
`\.PersonColor =` / `\.IdentityConfidence =` / `\.IdentitySource =` finds **zero** writes
to a `sigproc.TrackedBlob`. The only identity-field writes in the tree are on
`tracker.Blob` inside `internal/tracker/identity.go` (the unused-in-live-path 3D tracker).
4. **Resolved identity lives in a sidecar:** `ble.IdentityMatcher` (`main.go:799`), queried by
`blob.ID` via `identityMatcher.GetMatch(blob.ID)` (e.g. `main.go:2077, 2138, 2218, 2267,
2381`). It is never written back onto the blob struct.
**Corrected live data flow:**
```
fusion.Engine.Fuse ──fusion.Blob peaks──▶ (NO identity)
[fusion.go:260] │
blobTracker.track ──sigproc.TrackedBlob──▶ (6 identity fields exist, ALL ZERO)
[E1 main.go:5494] │ ← identity never written onto the struct
pm.SetTrackedBlobs(blobs) ──blobs: []sigproc.TrackedBlob──▶ every consumer reads ZERO identity
[main.go:2057-2058] │
│ resolved identity held SEPARATELY in:
│ ble.IdentityMatcher (main.go:799)
│ queried per-blob by ID via GetMatch()
┌──────────────┬───────────────┬───────────────────┬────────────────┬──────────────────┐
▼ ▼ ▼ ▼ ▼ ▼
analytics explainability automation volume identityMatcher fall-detect
[:2271] [:2206 E3] [:2303 E2] [:2326] [:2113] [:2288]
✅ pulls ⚠ snapshot ❌ target has ⚠ PersonID (sidecar: the (anon struct;
PersonID drops person NO identity field exists identity SOURCE) Posture field
from sidecar BUT identity field; no but left "" — not a leak, also not set)
→ correct Map side- side-channel — cheap to this is where
channel ( compensation fix identity is made)
:2216) partly
compensates
```
**Implication for the fix:** because identity is in the sidecar (not on the blob), every
identity-aware conversion boundary must call `identityMatcher.GetMatch(blob.ID)` at the
conversion site — exactly as `analytics.TrackUpdate` already does (`main.go:2267-2279`).
Merely adding fields to the target structs is insufficient; the population step is the actual
fix. The two flagged boundaries:
- **E2 (`:2303`, automation):** add identity fields to `automation.TrackedBlob`
(`engine.go:1337`) **and** populate them from the sidecar in the loop. Without this,
`automationEngine.personProvider` can never fire (it needs a `PersonID` nothing supplies).
- **E3 (`:2206`, explainability):** add identity fields to `explainability.BlobSnapshot`
(`handler.go:95`), populate from the sidecar, and fold the parallel `identityMap`
(`main.go:2216`) into the snapshot so the "Why?" overlay has one identity source.
- **volume (`:2326`):** the `PersonID` field already exists — just populate it from the
sidecar (one line) to enable person-filtered volume triggers.
---
## 4. Per-boundary: does the target type carry identity fields?
Acceptance criterion explicit checklist:
| Boundary | main.go (HEAD) | Target type | Target carries identity fields? |
|---|---|---|---|
| E1 | `:5494` | `sigproc.TrackedBlob` (`signal/processor.go:587`) | **Yes** — 6 fields (`PersonID`, `PersonLabel`, `PersonColor`, `IdentityConfidence`, `IdentitySource`, `Posture`); left zero (pre-identity peak source) |
| E2 | `:2303` | `automation.TrackedBlob` (`automation/engine.go:1337`) | **No**`ID, X/Y/Z, VX/VY/VZ, Confidence` only |
| E3 | `:2206` | `explainability.BlobSnapshot` (`explainability/handler.go:95`) | **No**`ID, X/Y/Z, Confidence, Weight` only |
| volume | `:2326` | `volume.BlobPos` (`volume/shape.go:1080`) | **Yes**`PersonID` (single field); present but unpopulated |
---
## 5. Line-number reconciliation (task claim vs HEAD)
All four task-claimed lines have drifted; the code at the old lines is now unrelated
non-conversion code. Re-locate any future drift with:
`grep -n "sigproc.TrackedBlob{\|automation.TrackedBlob{\|explainability.BlobSnapshot{\|volume.BlobPos{" mothership/cmd/mothership/main.go`
| Boundary | Task claim | Verified (HEAD `7846408`, 2026-07-06) | Delta |
|---|---|---|---|
| E1 | `main.go:5384` | **`main.go:5494`** | +110 |
| E2 | `main.go:2213` | **`main.go:2303`** | +90 |
| E3 | `main.go:2116` | **`main.go:2206`** | +90 |
| volume | `main.go:2236` | **`main.go:2326`** | +90 |
Reference-note line numbers that are still accurate (verified): `fusion.Blob`
`fusion/fusion.go:36`; `sigproc.TrackedBlob` `signal/processor.go:587`;
`automation.TrackedBlob` `automation/engine.go:1337`; `explainability.BlobSnapshot`
`explainability/handler.go:95`; `volume.BlobPos` `volume/shape.go:1080`.
---
## 6. Acceptance criteria status
- [x] **Every main.go blob-conversion loop enumerated with source type, target type, copied
fields** — §2 table (9 loops; the 4 named boundaries + 5 additional projections).
- [x] **The two identity-dropping boundaries (`:2303` automation, `:2206` explainability)
explicitly flagged as the leak to fix** — §0, §1.2, §1.3, §3. (Line numbers updated
from the task's `:2213`/`:2116` to the verified `:2303`/`:2206`.)
- [x] **Whether the target type carries identity fields is noted for each boundary** — §4
checklist (E1 yes/zero, E2 none, E3 none, volume yes/unpopulated).
- [x] **All line numbers verified against current HEAD** — §1, §5 (HEAD `7846408`,
2026-07-06).
- [x] **Findings written to `notes/bf-<id>-conversions.md`** — this file
(`notes/bf-5uzm-conversions.md`).