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>
19 KiB
Cross-Package Blob Conversion Boundaries in main.go (bf-5uzm)
Scope: The cross-package conversion boundaries in
mothership/cmd/mothership/main.gowhere 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.mdPattern 4).All file:line references below were re-verified against HEAD
7846408on 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) ismothership/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:
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):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(← peakConfidence);VX/VY/VZderived 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
blobTrackeris a pure position-associator — it has no access to BLE matches. Identity is attached later (in the sidecar; see §3). Tier 3 inbf-3ldj§5.
1.2 E2 — main.go:2303 — sigproc.TrackedBlob → automation.TrackedBlob ❌ LEAK
- Enclosing stage: "Stage 6: Trigger evaluation" (
main.go:2298), inside theif automationEngine != nil {block. - Source type:
sigproc.TrackedBlob(b, range var overblobs). - Target type:
automation.TrackedBlob(internal/automation/engine.go:1337):
The target type carries NO identity fields at all (ID + pos + vel + Confidence only).type TrackedBlob struct { ID int X, Y, Z float64 VX, VY, VZ float64 Confidence float64 } - Literal (
main.go:2303):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 azoneForBlobresolver (returns zone name), not a person resolver. The engine does have apersonProvider(wired atmain.go:3147,automationEngine.SetPersonProvider(&automationPersonAdapter{registry: bleRegistry})), but it resolvesPersonName/PersonColorgiven aPersonID(engine.go:952) — and no path supplies aPersonIDfor a blob, becauseautomation.TrackedBlobhas no such field andEvaluatereceives 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 overblobs). - Target type:
explainability.BlobSnapshot(internal/explainability/handler.go:95):
The target type carries NO identity fields.type BlobSnapshot struct { ID int X, Y, Z float64 Confidence float64 Weight float64 // Peak height in the grid } - Literal (
main.go:2206):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 left0; no grid snapshot is available in this path, seemain.go:2235.) - Identity dropped: person/color/source — none carried by the snapshot.
- Side-channel compensation (important): immediately after the snapshot loop, a separate
identityMapis built from the sidecar and passed alongside the snapshots (main.go:2215-2236):
So the "Why?" overlay does receive identity — via theidentityMap := 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)identityMapargument keyed byblob.ID, not via theBlobSnapshot. The snapshot struct itself remains identity-free; ifBlobSnapshotis ever consumed in isolation (withoutidentityMap), the person name is lost. The clean fix is to put identity onBlobSnapshotand 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 overblobs). - Target type:
volume.BlobPos(internal/volume/shape.go:1080):
The target type DOES carry an identity field (type BlobPos struct { ID int PersonID string // ← identity field EXISTS on the target X, Y, Z float64 }PersonID). - Literal (
main.go:2326):volumeBlobs[i] = volume.BlobPos{ ID: blob.ID, X: blob.X, Y: blob.Y, Z: blob.Z, } - Copied:
ID,X/Y/Zonly. - Identity dropped:
PersonID— the field is present onvolume.BlobPosbut the conversion does not populate it (left""). Velocity is also not copied (not carried byBlobPos). Because the field already exists, this is the cheapest boundary to repair: a singlePersonID: <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: pullPersonIDfromidentityMatcher.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
Posturefield is declared but never set — tracked separately under falldetect (seebf-3ldj§3 [G1]falldetect/detector.go:277for the in-packageBlobSnapshot).
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:
tracker.Tracker/tracker.TrackManageris not wired intomain.goat all — zero references incmd/mothership/main.go(non-test). TheapplyIdentity/clearIdentityidentity machinery ininternal/tracker/identity.go:164-185therefore never runs in the live loop. (grep fortracker\.\|TrackManagerin main.go returns nothing.)- The live loop uses
blobTracker(main.go:5433, a hand-rolled greedy nearest-neighbour associator) whosetrack()returns[]sigproc.TrackedBlob(main.go:5447). sigproc.TrackedBlob's 6 identity fields are never written in any live path — grep acrossinternal/andcmd/(non-test) for\.PersonLabel =/\.PersonID =/\.PersonColor =/\.IdentityConfidence =/\.IdentitySource =finds zero writes to asigproc.TrackedBlob. The only identity-field writes in the tree are ontracker.Blobinsideinternal/tracker/identity.go(the unused-in-live-path 3D tracker).- Resolved identity lives in a sidecar:
ble.IdentityMatcher(main.go:799), queried byblob.IDviaidentityMatcher.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 toautomation.TrackedBlob(engine.go:1337) and populate them from the sidecar in the loop. Without this,automationEngine.personProvidercan never fire (it needs aPersonIDnothing supplies). - E3 (
:2206, explainability): add identity fields toexplainability.BlobSnapshot(handler.go:95), populate from the sidecar, and fold the parallelidentityMap(main.go:2216) into the snapshot so the "Why?" overlay has one identity source. - volume (
:2326): thePersonIDfield 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
- 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).
- The two identity-dropping boundaries (
:2303automation,:2206explainability) explicitly flagged as the leak to fix — §0, §1.2, §1.3, §3. (Line numbers updated from the task's:2213/:2116to the verified:2303/:2206.) - Whether the target type carries identity fields is noted for each boundary — §4 checklist (E1 yes/zero, E2 none, E3 none, volume yes/unpopulated).
- All line numbers verified against current HEAD — §1, §5 (HEAD
7846408, 2026-07-06). - Findings written to
notes/bf-<id>-conversions.md— this file (notes/bf-5uzm-conversions.md).