@camstack/addon-post-analysis 1.2.106 → 1.2.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-DiU9qiuA.mjs";
1
+ import { $ as deriveRecordingMode, At as array, B as TimelapseRulePatchSchema, Bt as EventCategory, C as NcScheduleSchema, Ct as DeviceType, D as NcSystemEventKindSchema, Dt as nodePin, E as NcSnoozeSuppressedSchema, Et as isDeviceScopedCap, F as SCENE_DEFAULT_UNCOVERED_POLICY, Ft as object, G as alarmPanelCapability, H as TrackSourceSchema, I as SCENE_DIVERGED, It as partialRecord, J as audioModeOf, K as assertTimelapseCadences, L as SceneMonitorSchema, Lt as record, M as RetrainStatusSchema, Mt as discriminatedUnion, Nt as literal, O as NcTaxonomySchema, Ot as sleep, P as SCENE_DEFAULT_ANCHOR_THRESHOLD, Pt as number, Q as defineCustomActions, R as TIMELAPSE_DENSE_FLOOR_SEC, Rt as string, S as NcRuleTargetSchema, St as BaseAddon, T as NcSnoozeSchema, Tt as hydrateSchema, U as VISIT_MERGE_GAP_MS, V as TimelapseRuleSchema, W as addonWidgetsSourceCapability, X as cosineSimilarity$1, Y as buildEventKindDescriptor, Z as customAction, _ as NC_TAXONOMY, _t as systemEventFilterApplies, a as DETECTION_MACRO_CLASSES, at as isScheduleActive, b as NcRulePatchSchema, bt as zoneAnalyticsCapability, c as EVENT_KIND_BY_CAP, d as LabelAttributionSchema, dt as plateGalleryCapability, f as MACRO_LABELS, ft as readDeviceStateFrom, gt as subKindsOf, h as NC_DEFAULT_SNOOZE_MINUTES, ht as sceneMonitorCapability, it as isDetectionMacroClass, j as RECORDING_EXPORT_MAX_READ_BYTES, jt as boolean, k as OpsLogEntrySchema, kt as _enum, l as EVENT_PAD_MS, lt as pickClusterStepModels, m as NC_CONDITION_CATALOG, n as CLUSTER_MODEL_SCOPED_STEPS, nt as faceGalleryCapability, ot as kebabToCamel, p as NC_ALARM_SYSTEM_EVENT_KINDS, pt as readTimelapseGeneratedAt, q as audioMetricsCapability, r as DEFAULT_EVENT_COLOR, s as DeclaredDevices, st as notificationRulesCapability, t as BaseDevice, tt as encodeVectorBase64, u as FULL_IMAGE_BBOX, ut as pipelineAnalyticsCapability, v as NcConditionDescriptorSchema, vt as vectorDimFromBase64, w as NcSnoozeInputSchema, wt as createEvent, x as NcRuleSchema, xt as errMsg, y as NcRuleInputSchema, yt as videoclipsCapability, z as TimelapseRuleInputSchema, zt as unknown } from "../dist-DFNQ5VB_.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -5567,279 +5567,6 @@ function observeLabel(deviceId, spec, sample, now) {
5567
5567
  spec
5568
5568
  };
5569
5569
  }
5570
- /** JPEG quality for the downscaled full frame — matches the crop path. */
5571
- var FULL_FRAME_QUALITY = 80;
5572
- /**
5573
- * Downscale an already-encoded JPEG full frame to FIT WITHIN
5574
- * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
5575
- * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
5576
- * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
5577
- * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
5578
- * at night) is never stored or served — the privacy fix moved to CAPTURE time.
5579
- */
5580
- async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
5581
- return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
5582
- fit: "inside",
5583
- withoutEnlargement: true
5584
- }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
5585
- }
5586
- //#endregion
5587
- //#region src/notification-center/still-shelf.ts
5588
- /**
5589
- * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
5590
- * name owns a frame.
5591
- *
5592
- * ## Why a trigger needs one at all
5593
- *
5594
- * The attachment ladder resolves media by OWNER, and most triggers have one: an
5595
- * object or package event owns its crops, a closed track owns its best shot, a
5596
- * doorbell press owns the marker track the same press projected
5597
- * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
5598
- * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
5599
- * same reason in both cases — the subject is an ABSENCE:
5600
- *
5601
- * - an **audio** match: nothing was boxed, nothing was tracked, and —
5602
- * deliberately — nothing is persisted at all. A confirmed window is a claim
5603
- * about sound that has already stopped, and `event-intake.ts` states why
5604
- * replaying it later would be wrong.
5605
- * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
5606
- * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
5607
- * nobody and the ladder logs `no still could be resolved … owners=[]`.
5608
- *
5609
- * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
5610
- * of the trigger. Not of the sound, not of the object that left — of what the
5611
- * camera can see now that it happened. For a freed parking space that is
5612
- * exactly the answer the operator wants: the space, empty.
5613
- *
5614
- * **One shelf, not two.** The mechanism is identical down to the reuse window,
5615
- * and the only thing that differs between the two triggers is the GATE deciding
5616
- * that a photograph is owed at all — which belongs at the trigger site, where
5617
- * the rules are, and not here. A twin module would be a second derivation of
5618
- * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
5619
- * can say which absence they are about.
5620
- *
5621
- * ## Three properties, and each one is a decision
5622
- *
5623
- * **It is not a record.** The bytes live here, in RAM, under an owner id and a
5624
- * TTL that covers the outbox's whole retry horizon — and nowhere else. The
5625
- * alternative was the doorbell's: materialise a synthetic marker track through
5626
- * `SyntheticTrackMaterializer` and let the notification name it. That would put
5627
- * a durable Track on the camera's timeline for every confirmed window and every
5628
- * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
5629
- * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
5630
- * precisely to bound how many audio markers a camera may emit). A notification
5631
- * must not manufacture timeline history as a side effect of wanting a picture.
5632
- *
5633
- * **The capture STARTS immediately and is never awaited.** The subject is
5634
- * transient — a scream is over before a snapshot round-trip completes, and a
5635
- * freed space is about to be taken by the next car — so the fetch is kicked off
5636
- * at the trigger, before the rule evaluation runs, and the owner id is minted
5637
- * synchronously so the outbox row can name it. The bytes land while the row
5638
- * waits in the queue, and the dispatcher's existing bounded still-wait (or the
5639
- * pause its own footage render already costs) picks them up. A camera that
5640
- * never answers costs the picture and never the notification.
5641
- *
5642
- * **Two triggers seconds apart share ONE capture.** A barking dog confirms
5643
- * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
5644
- * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
5645
- * Without a reuse window this would photograph a camera at whatever rate the
5646
- * sound happens to occur. Each trigger still gets its OWN owner id, so two
5647
- * outbox rows are never mistaken for one subject; they merely point at the same
5648
- * frame, which is the truth — the scene did not change in ten seconds. The
5649
- * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
5650
- * emptied zone ten seconds apart are two claims about one scene.
5651
- *
5652
- * Nothing here is silent: a capture that lands and a camera that refuses each
5653
- * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
5654
- * and 615 not" is the only form that question is ever asked in.
5655
- */
5656
- /**
5657
- * The owner-id namespace. It is what routes a lookup here instead of to the
5658
- * media store, and it is the reason the dispatcher needs no audio or occupancy
5659
- * branch — `getMediaForOwner` answers for all of them under one signature.
5660
- */
5661
- var NC_STILL_SHELF_PREFIX = "nc-still:";
5662
- /** True for an owner id this shelf minted. */
5663
- function isStillShelfId(id) {
5664
- return id.startsWith(NC_STILL_SHELF_PREFIX);
5665
- }
5666
- /**
5667
- * How long the bytes are held.
5668
- *
5669
- * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
5670
- * ten minutes; fifteen covers that with room for a slow drain. Past it the row
5671
- * ships text-only, which is the correct degradation for a photograph of a scene
5672
- * that is a quarter of an hour stale anyway.
5673
- */
5674
- var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
5675
- /** A hanging snapshot cap must not pin a capture slot forever. */
5676
- var SNAPSHOT_TIMEOUT_MS = 8e3;
5677
- /**
5678
- * Hard bound on held captures. Reached only if every camera on the hub triggers
5679
- * one inside a single TTL; the oldest is dropped first, which costs a
5680
- * fifteen-minute-old picture nobody is waiting for.
5681
- */
5682
- var MAX_CAPTURES = 64;
5683
- var NcStillShelf = class {
5684
- deps;
5685
- /** captureId → the photograph. */
5686
- captures = /* @__PURE__ */ new Map();
5687
- /** ownerId → captureId. Several owners may name one capture (the reuse window). */
5688
- owners = /* @__PURE__ */ new Map();
5689
- /** deviceId → its newest capture, for the reuse window. */
5690
- newest = /* @__PURE__ */ new Map();
5691
- constructor(deps) {
5692
- this.deps = deps;
5693
- }
5694
- /**
5695
- * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
5696
- * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
5697
- * path and the outbox row is built from what this returns.
5698
- */
5699
- capture(deviceId, atMs, trigger) {
5700
- const now = this.deps.now();
5701
- this.prune(now);
5702
- const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
5703
- const recent = this.newest.get(deviceId);
5704
- if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
5705
- this.owners.set(ownerId, recent.captureId);
5706
- return ownerId;
5707
- }
5708
- const captureId = randomUUID();
5709
- this.captures.set(captureId, {
5710
- deviceId,
5711
- trigger,
5712
- startedAt: now,
5713
- expiresAt: now + NC_STILL_SHELF_TTL_MS,
5714
- files: []
5715
- });
5716
- this.newest.set(deviceId, {
5717
- captureId,
5718
- at: now
5719
- });
5720
- this.owners.set(ownerId, captureId);
5721
- this.fetch(captureId, deviceId, atMs, trigger);
5722
- return ownerId;
5723
- }
5724
- /**
5725
- * The media an owner id holds, or `undefined` when this shelf never minted
5726
- * it. An EMPTY array is a different answer: the capture exists and has not
5727
- * landed (or never will), which is exactly the case the dispatcher's bounded
5728
- * wait and its `no still could be resolved` line are for.
5729
- */
5730
- get(ownerId) {
5731
- const captureId = this.owners.get(ownerId);
5732
- if (captureId === void 0) return void 0;
5733
- return this.captures.get(captureId)?.files ?? [];
5734
- }
5735
- /** Drop expired captures and the owners that named them. */
5736
- prune(now) {
5737
- for (const [captureId, held] of this.captures) {
5738
- if (held.expiresAt > now) continue;
5739
- this.captures.delete(captureId);
5740
- if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
5741
- }
5742
- while (this.captures.size > MAX_CAPTURES) {
5743
- const oldest = this.captures.keys().next();
5744
- if (oldest.done === true) break;
5745
- const held = this.captures.get(oldest.value);
5746
- this.captures.delete(oldest.value);
5747
- if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
5748
- }
5749
- for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
5750
- }
5751
- /** Drop everything (shutdown). */
5752
- clear() {
5753
- this.captures.clear();
5754
- this.owners.clear();
5755
- this.newest.clear();
5756
- }
5757
- async fetch(captureId, deviceId, atMs, trigger) {
5758
- try {
5759
- const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
5760
- if (shot === null || shot.base64.length === 0) {
5761
- this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
5762
- return;
5763
- }
5764
- const raw = Buffer.from(shot.base64, "base64");
5765
- let data = raw;
5766
- try {
5767
- data = await downscaleFullFrameJpeg(raw, 960, 540);
5768
- } catch {}
5769
- const held = this.captures.get(captureId);
5770
- if (held === void 0) return;
5771
- this.captures.set(captureId, {
5772
- ...held,
5773
- files: stillShelfMedia(data, atMs)
5774
- });
5775
- this.deps.logger.info(`${trigger} still captured`, {
5776
- tags: { deviceId },
5777
- meta: {
5778
- trigger,
5779
- bytes: data.byteLength,
5780
- tookMs: this.deps.now() - held.startedAt
5781
- }
5782
- });
5783
- } catch (err) {
5784
- this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
5785
- }
5786
- }
5787
- /**
5788
- * A branch that drops work says so. This one costs the operator the picture
5789
- * on a notification they DID receive, so it is a warn and it carries the
5790
- * camera — the only key the question is ever asked with.
5791
- */
5792
- reportMiss(deviceId, trigger, reason) {
5793
- this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
5794
- tags: { deviceId },
5795
- meta: {
5796
- trigger,
5797
- reason
5798
- }
5799
- });
5800
- }
5801
- };
5802
- /**
5803
- * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
5804
- *
5805
- * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
5806
- * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
5807
- * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
5808
- * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
5809
- * NOTHING and the notification ships text-only with the ordinary line saying
5810
- * so. Inventing a centre crop would answer a different question from the one
5811
- * the operator asked.
5812
- */
5813
- function stillShelfMedia(data, timestamp) {
5814
- const base64 = data.toString("base64");
5815
- const file = (kind) => ({
5816
- key: `${NC_STILL_SHELF_PREFIX}${kind}`,
5817
- kind,
5818
- base64,
5819
- sizeBytes: data.byteLength,
5820
- timestamp
5821
- });
5822
- return [
5823
- file("keyFrameSmall"),
5824
- file("keyFrame"),
5825
- file("fullFrame")
5826
- ];
5827
- }
5828
- /** Reject if `promise` does not settle within `ms`. */
5829
- function withTimeout$3(promise, ms) {
5830
- return new Promise((resolve, reject) => {
5831
- const timer = setTimeout(() => {
5832
- reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
5833
- }, ms);
5834
- promise.then((value) => {
5835
- clearTimeout(timer);
5836
- resolve(value);
5837
- }, (err) => {
5838
- clearTimeout(timer);
5839
- reject(err instanceof Error ? err : new Error(String(err)));
5840
- });
5841
- });
5842
- }
5843
5570
  //#endregion
5844
5571
  //#region src/shared/llm-vision/prompt-hygiene.ts
5845
5572
  /**
@@ -8900,6 +8627,58 @@ var NcTextCatalog = class {
8900
8627
  }
8901
8628
  };
8902
8629
  //#endregion
8630
+ //#region src/notification-center/trigger-zone-still.ts
8631
+ /**
8632
+ * How far a candidate's instant may sit from the nearest trail point before the
8633
+ * trail stops speaking for it.
8634
+ *
8635
+ * 2 s. The live detection cadence on this install is 100–300 ms and the widest
8636
+ * real gap inside a track is a couple of seconds (a session gap); beyond that
8637
+ * the nearest point is about a different moment, and answering "in the zone"
8638
+ * from it would be a guess. An unanswerable candidate is treated as NOT
8639
+ * qualifying — never as disqualifying the others, and never as a reason to ship
8640
+ * nothing.
8641
+ */
8642
+ var TRAIL_MATCH_MAX_MS = 2e3;
8643
+ /** Overlap of a normalized box with the union of the zones — the maximum over
8644
+ * the polygons, which is what "any of these zones" means for one box. */
8645
+ function zoneOverlap(point, polygons) {
8646
+ let best = 0;
8647
+ for (const polygon of polygons) {
8648
+ if (polygon.length < 3) continue;
8649
+ const overlap = bboxPolygonOverlap(point.bbox, polygon);
8650
+ if (overlap > best) best = overlap;
8651
+ }
8652
+ return best;
8653
+ }
8654
+ /** The trail point closest to `timestamp`, or null when none is close enough. */
8655
+ function trailAt(trail, timestamp) {
8656
+ let best = null;
8657
+ let bestDelta = Number.POSITIVE_INFINITY;
8658
+ for (const point of trail) {
8659
+ const delta = Math.abs(point.timestamp - timestamp);
8660
+ if (delta < bestDelta) {
8661
+ bestDelta = delta;
8662
+ best = point;
8663
+ }
8664
+ }
8665
+ return best !== null && bestDelta <= 2e3 ? best : null;
8666
+ }
8667
+ /**
8668
+ * The candidates that show the subject inside the triggering zone, in the order
8669
+ * they were given. Every candidate comes back unchanged when the question
8670
+ * cannot be asked (no trail, no polygon) or when nobody qualifies.
8671
+ */
8672
+ function selectTriggerZoneCandidates(input) {
8673
+ const { candidates, trail, polygons } = input;
8674
+ if (candidates.length === 0 || trail.length === 0 || polygons.length === 0) return candidates;
8675
+ const qualifying = candidates.filter((candidate) => {
8676
+ const point = trailAt(trail, candidate.timestamp);
8677
+ return point !== null && zoneOverlap(point, polygons) > 0;
8678
+ });
8679
+ return qualifying.length > 0 ? qualifying : candidates;
8680
+ }
8681
+ //#endregion
8903
8682
  //#region src/notification-center/dispatcher.ts
8904
8683
  var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
8905
8684
  /**
@@ -8913,6 +8692,28 @@ var DEFAULT_TARGET_CACHE_TTL_MS = 6e4;
8913
8692
  * text-only rather than holding the alarm any longer.
8914
8693
  */
8915
8694
  var NC_STILL_WAIT_MS = 3e3;
8695
+ /**
8696
+ * How far a still's OWN instant may sit from the trigger and still count as
8697
+ * being ABOUT the trigger, for a `delivery: 'immediate'` rule.
8698
+ *
8699
+ * Derived from {@link TRAIL_MATCH_MAX_MS} rather than repeated: that constant
8700
+ * already answers "can the subject's trail speak for this instant", and the two
8701
+ * gates run back to back on the same candidate list (moment, then place). If
8702
+ * they disagreed, a frame could be judged in-zone for a moment the age gate had
8703
+ * already ruled a different one.
8704
+ *
8705
+ * The value is measured, not chosen: on the 54 live fires of 'Persona su Uscio'
8706
+ * a full-scene frame within ±1 s of the trigger existed for 52 (96%) and within
8707
+ * ±2 s for 53 (98%), while the frame that produced the operator's report was
8708
+ * +4901 ms. Widening this to 5 s would re-admit exactly that frame.
8709
+ *
8710
+ * It is NOT the ladder's top rung that has to be in band — that rung is the
8711
+ * native keyFrame, whose capture misses and retries (36 misses over 105 track
8712
+ * starts on device 615 in 6 h), so it is in band only 46% of the time. Holding
8713
+ * out for the KIND the rule asked for would wait for the late frame. The gate
8714
+ * is the instant; the ladder then runs among the frames that are about it.
8715
+ */
8716
+ var NC_TRIGGER_STILL_MAX_AGE_MS = TRAIL_MATCH_MAX_MS;
8916
8717
  var NcDispatcher = class {
8917
8718
  deps;
8918
8719
  targetCache = null;
@@ -9495,9 +9296,10 @@ var NcDispatcher = class {
9495
9296
  const out = [];
9496
9297
  const zoneIdsWanted = entry.payload.mediaZoneIds;
9497
9298
  const stillOverride = zoneIdsWanted !== void 0 && zoneIdsWanted.length > 0 ? "keyFrame" : void 0;
9498
- const firstLook = await this.resolveAttachment(entry, stillOverride);
9499
- const footage = await this.renderFootage(entry);
9500
- const still = firstLook !== null || entry.payload.media === "none" ? firstLook : footage.attempted ? await this.resolveAttachment(entry, stillOverride) ?? await this.waitForStill(entry, stillOverride) : await this.waitForStill(entry, stillOverride);
9299
+ const firstLook = await this.resolveAttachment(entry, stillOverride, "any");
9300
+ const footagePending = this.renderFootage(entry);
9301
+ const still = firstLook !== null || entry.payload.media === "none" ? firstLook : await this.waitForStill(entry, stillOverride);
9302
+ const footage = await footagePending;
9501
9303
  if (still !== null) {
9502
9304
  const zoneIds = zoneIdsWanted;
9503
9305
  if (zoneIds !== void 0 && zoneIds.length > 0) {
@@ -9603,20 +9405,27 @@ var NcDispatcher = class {
9603
9405
  /**
9604
9406
  * Poll the still ladder for {@link NC_STILL_WAIT_MS}, then give up.
9605
9407
  *
9606
- * Only reached when the rule asked for NO footage: with a render in the
9607
- * dispatch there is a 7–8 s wait to look again after, and this would add
9608
- * latency to a notification that is already late. Bounded and then abandoned
9609
- * a text-only notification about a person at the door beats a punctual one
9610
- * nobody received.
9408
+ * Runs for EVERY rule now, footage or not, and concurrently with the render:
9409
+ * the still's deadline is its own. Track media lands 1.5–5.5 s after the
9410
+ * trigger, so a notification built at the instant of the trigger routinely
9411
+ * resolves nothing and this is what recovers it.
9412
+ *
9413
+ * Each attempt asks for a frame from the trigger instant and nothing else, so
9414
+ * a poll that has not found one keeps waiting rather than settling for a
9415
+ * later frame that happens to be ranked higher. The give-up pass is the one
9416
+ * place a later frame is accepted, and it says so.
9417
+ *
9418
+ * Bounded and then abandoned — a text-only notification about a person at the
9419
+ * door beats a punctual one nobody received.
9611
9420
  */
9612
9421
  async waitForStill(entry, policyOverride) {
9613
9422
  const subject = entry.payload.subject;
9614
- if (this.now() - subject.timestamp > 3e4) return null;
9423
+ if (this.now() - subject.timestamp > 3e4) return this.resolveAttachment(entry, policyOverride, "any");
9615
9424
  if (subject.eventId === void 0 && subject.trackId === void 0) return null;
9616
9425
  const attempts = Math.floor(NC_STILL_WAIT_MS / 500);
9617
9426
  for (let i = 0; i < attempts; i += 1) {
9618
9427
  await this.sleep(500);
9619
- const still = await this.resolveAttachment(entry, policyOverride);
9428
+ const still = await this.resolveAttachment(entry, policyOverride, "trigger-only");
9620
9429
  if (still !== null) {
9621
9430
  this.deps.logger.info("the still landed during the bounded wait", {
9622
9431
  tags: { deviceId: entry.payload.subject.deviceId },
@@ -9630,7 +9439,7 @@ var NcDispatcher = class {
9630
9439
  return still;
9631
9440
  }
9632
9441
  }
9633
- return null;
9442
+ return this.resolveAttachment(entry, policyOverride, "any");
9634
9443
  }
9635
9444
  /** Crop a JPEG to the padded bbox of the given zones (normalized polygons →
9636
9445
  * pixel rect via sharp metadata). Null on any failure — caller falls back
@@ -9721,7 +9530,86 @@ var NcDispatcher = class {
9721
9530
  bytes.set(composed.bytes);
9722
9531
  return bytes;
9723
9532
  }
9724
- async resolveAttachment(entry, policyOverride) {
9533
+ /**
9534
+ * Drop the candidate stills that show the subject OUTSIDE the zone that fired
9535
+ * the rule. A filter, never a re-rank: the ladder still decides which kind of
9536
+ * picture the rule gets, among the frames that are about the right thing.
9537
+ *
9538
+ * Every unanswerable case returns the list untouched — no frozen zones, no
9539
+ * track to trail, no dep wired, no polygon resolved, an empty trail, or a
9540
+ * throw. And when nothing qualifies, the full list comes back: a real picture
9541
+ * beats no picture (the same rule D212 settled for the best shot).
9542
+ */
9543
+ async narrowToTriggerZone(entry, ordered) {
9544
+ const zoneIds = entry.payload.triggerZoneIds;
9545
+ const trackId = entry.payload.subject.trackId;
9546
+ const getTrail = this.deps.getSubjectTrail;
9547
+ const deviceId = entry.payload.subject.deviceId;
9548
+ if (zoneIds === void 0 || zoneIds.length === 0 || trackId === void 0 || getTrail === void 0 || ordered.length < 2) return ordered;
9549
+ try {
9550
+ const [polygons, trail] = await Promise.all([this.deps.getZonePolygons?.(deviceId, zoneIds) ?? Promise.resolve([]), getTrail(deviceId, trackId)]);
9551
+ const kept = selectTriggerZoneCandidates({
9552
+ candidates: ordered,
9553
+ trail,
9554
+ polygons,
9555
+ triggerAt: entry.payload.subject.timestamp
9556
+ });
9557
+ if (kept.length === ordered.length) return kept;
9558
+ this.deps.logger.info("still narrowed to the zone that fired the rule", {
9559
+ tags: { deviceId },
9560
+ meta: {
9561
+ ruleId: entry.ruleId,
9562
+ trackId,
9563
+ zoneIds: zoneIds.join(","),
9564
+ was: ordered[0]?.kind ?? null,
9565
+ wasOffsetMs: ordered[0] !== void 0 ? ordered[0].timestamp - entry.payload.subject.timestamp : null,
9566
+ now: kept[0]?.kind ?? null,
9567
+ nowOffsetMs: kept[0] !== void 0 ? kept[0].timestamp - entry.payload.subject.timestamp : null,
9568
+ dropped: ordered.length - kept.length
9569
+ }
9570
+ });
9571
+ return kept;
9572
+ } catch (err) {
9573
+ this.deps.logger.debug("trigger-zone still narrowing failed — shipping the plain ladder", {
9574
+ tags: { deviceId },
9575
+ meta: {
9576
+ ruleId: entry.ruleId,
9577
+ error: String(err)
9578
+ }
9579
+ });
9580
+ return ordered;
9581
+ }
9582
+ }
9583
+ /**
9584
+ * Keep only the stills that are ABOUT the trigger instant.
9585
+ *
9586
+ * Applies to `delivery: 'immediate'` and nothing else: a track-end rule is
9587
+ * legitimately about the whole track, and a system event has no instant of
9588
+ * its own to be near. Under `any` the list is never emptied — the best
9589
+ * out-of-band frame ships instead, and the degrade is REPORTED, because
9590
+ * "the picture is from nine seconds later" is the difference between a
9591
+ * notification that is late and one that is about something else.
9592
+ */
9593
+ narrowToTriggerInstant(entry, ordered, freshness) {
9594
+ if (entry.payload.delivery !== "immediate" || ordered.length === 0) return ordered;
9595
+ const triggerAt = entry.payload.subject.timestamp;
9596
+ const inBand = ordered.filter((file) => Math.abs(file.timestamp - triggerAt) <= NC_TRIGGER_STILL_MAX_AGE_MS);
9597
+ if (inBand.length > 0 || freshness === "trigger-only") return inBand;
9598
+ const chosen = ordered[0];
9599
+ if (chosen !== void 0) this.deps.logger.info("no still from the trigger instant — shipping a later frame", {
9600
+ tags: { deviceId: entry.payload.subject.deviceId },
9601
+ meta: {
9602
+ ruleId: entry.ruleId,
9603
+ recordId: entry.recordId,
9604
+ kind: chosen.kind,
9605
+ offsetMs: chosen.timestamp - triggerAt,
9606
+ boundMs: NC_TRIGGER_STILL_MAX_AGE_MS,
9607
+ waitedMs: NC_STILL_WAIT_MS
9608
+ }
9609
+ });
9610
+ return ordered;
9611
+ }
9612
+ async resolveAttachment(entry, policyOverride, freshness = "any") {
9725
9613
  const policy = policyOverride ?? entry.payload.media;
9726
9614
  if (policy === "none") return null;
9727
9615
  const frame = policyOverride === void 0 ? entry.payload.mediaFrame : void 0;
@@ -9741,9 +9629,12 @@ var NcDispatcher = class {
9741
9629
  const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
9742
9630
  if (files.length === 0) continue;
9743
9631
  const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
9744
- for (const kind of preference) {
9745
- const file = files.find((f) => f.kind === kind);
9746
- if (file === void 0) continue;
9632
+ const ordered = [];
9633
+ for (const kind of preference) for (const file of files) if (file.kind === kind) ordered.push(file);
9634
+ const inBand = this.narrowToTriggerInstant(entry, ordered, freshness);
9635
+ if (inBand.length === 0) continue;
9636
+ const shortlist = await this.narrowToTriggerZone(entry, inBand);
9637
+ for (const file of shortlist) {
9747
9638
  const raw = Buffer.from(file.base64, "base64");
9748
9639
  if (raw.byteLength === 0) continue;
9749
9640
  const bytes = new Uint8Array(raw.byteLength);
@@ -11092,268 +10983,6 @@ function rowToRecord(id, data) {
11092
10983
  sealed: data["sealed"] === true
11093
10984
  };
11094
10985
  }
11095
- //#endregion
11096
- //#region src/notification-center/liveness-ledger.ts
11097
- /**
11098
- * @durable class=ledger owner=notification-center
11099
- * write="a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"
11100
- * retention="none, deliberately — one row per device plus one per node, bounded by the installation. NOT pruned against the device directory: a prune is work destroyed on a fallible read (D49/D130)."
11101
- */
11102
- var NC_LIVENESS_COLLECTION = "notification-center:liveness";
11103
- var NC_LIVENESS_COLUMNS = [
11104
- (
11105
- /** The subject the intake minted (`device:617`, `node:agent-1`) — one row
11106
- * each, so the two families can never collide on a key. */
11107
- {
11108
- name: "subject",
11109
- type: "TEXT",
11110
- primaryKey: true,
11111
- notNull: true
11112
- }),
11113
- (
11114
- /** `online` | `offline` — the STATE, not the kind, so a legacy `camera-*`
11115
- * row and a `device-*` event describing the same fact compare equal. */
11116
- {
11117
- name: "state",
11118
- type: "TEXT",
11119
- notNull: true
11120
- }),
11121
- (
11122
- /** The kind that last wrote the row. Telemetry: it makes the table readable
11123
- * when someone asks "why did 617 not notify". */
11124
- {
11125
- name: "kind",
11126
- type: "TEXT",
11127
- notNull: true
11128
- }),
11129
- (
11130
- /** Absent on a node row. Indexed — every question about a device is asked
11131
- * per-device. */
11132
- {
11133
- name: "deviceId",
11134
- type: "INTEGER"
11135
- }),
11136
- {
11137
- name: "updatedAt",
11138
- type: "INTEGER",
11139
- notNull: true
11140
- }
11141
- ];
11142
- var NC_LIVENESS_INDEXES = [{
11143
- name: "idx_nc_liveness_device",
11144
- columns: ["deviceId"]
11145
- }];
11146
- /**
11147
- * The kinds the ledger governs — see the module docblock for why the stream
11148
- * and switch families are absent.
11149
- */
11150
- var NC_LEDGERED_KINDS = new Set([
11151
- "device-online",
11152
- "device-offline",
11153
- "node-online",
11154
- "node-offline",
11155
- "camera-online",
11156
- "camera-offline"
11157
- ]);
11158
- /**
11159
- * Reduce a system-event subject to a ledger observation, or `null` when the
11160
- * kind is not one the ledger governs.
11161
- *
11162
- * The ONE place that decides which kinds are gated: a caller that branched on
11163
- * a `kind.startsWith('device-')` prefix would silently pull the operator's
11164
- * disable switch into a liveness ledger, which is the mistake the docblock
11165
- * spells out.
11166
- */
11167
- function ledgeredLiveness(system) {
11168
- if (!NC_LEDGERED_KINDS.has(system.kind)) return null;
11169
- const state = system.kind.endsWith("-offline") ? "offline" : "online";
11170
- return {
11171
- subject: system.subject,
11172
- kind: system.kind,
11173
- state,
11174
- ...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {}
11175
- };
11176
- }
11177
- /**
11178
- * The ledger's mechanics, declared once. Everything policy-shaped is right
11179
- * here in this object: what the KEY is, what the persisted row looks like, and
11180
- * — the only decision the primitive cannot make — WHAT COUNTS AS THE SAME
11181
- * FACT. For liveness the fact is the STATE, so a legacy `camera-online` row
11182
- * and a `device-online` event describing it compare equal, and a repeat never
11183
- * moves `updatedAt`.
11184
- *
11185
- * The reseed is uncapped in practice: the row set is bounded by the
11186
- * installation — one row per device plus one per node — so the primitive's
11187
- * default ceiling still bounds a pathological read. Deliberately NOT pruned
11188
- * against the device directory: a prune is work DESTROYED on a fallible read
11189
- * (D49), and a directory that answered empty once would wipe the ledger and
11190
- * re-arm the very flood this file exists to prevent. A removed device leaves
11191
- * exactly one row behind, and a re-added one re-seeds correctly.
11192
- */
11193
- var NC_LIVENESS_SPEC = {
11194
- collection: NC_LIVENESS_COLLECTION,
11195
- columns: NC_LIVENESS_COLUMNS,
11196
- indexes: NC_LIVENESS_INDEXES,
11197
- writeMode: "write-behind",
11198
- keyOf: (row) => row.subject,
11199
- toValue: rowToValue$1,
11200
- fromRecord: recordToRow$2,
11201
- equalFact: (held, incoming) => held.state === incoming.state,
11202
- deviceIdOf: (row) => row.deviceId
11203
- };
11204
- var NcLivenessLedger = class {
11205
- ledger;
11206
- constructor(deps) {
11207
- this.ledger = new DurableLedger({
11208
- spec: NC_LIVENESS_SPEC,
11209
- store: deps.store,
11210
- logger: deps.logger
11211
- });
11212
- }
11213
- static declare(store) {
11214
- return DurableLedger.declare(store, NC_LIVENESS_SPEC);
11215
- }
11216
- /**
11217
- * Boot reseed — the whole point of the file. Returns the row count so the
11218
- * caller can say out loud how much memory it recovered; "loaded 0" after a
11219
- * container recreate is the one line that would have explained tonight.
11220
- *
11221
- * A failed load KEEPS whatever is already mirrored (the primitive's contract
11222
- * rule 2) rather than clearing it: dropping the memory is what caused the
11223
- * flood, so no error path may do it on purpose.
11224
- */
11225
- async load() {
11226
- return (await this.ledger.load()).length;
11227
- }
11228
- /**
11229
- * Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
11230
- * the fix for the outage this ledger used to swallow.
11231
- *
11232
- * ## The hole
11233
- *
11234
- * {@link load} recovers what the ledger itself accepted. A device that has
11235
- * never produced a liveness event since the ledger was created has no row at
11236
- * all, so its FIRST observation ever seeds silently — and if that first
11237
- * observation is the camera DYING, the outage is thrown away. Live cost,
11238
- * 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
11239
- * notifications. `device:584` and `device:587` had never spoken; their
11240
- * unplugging was recorded and never announced. A camera that is quiet until
11241
- * it dies is a camera whose death is silent, indefinitely.
11242
- *
11243
- * ## Why only the ONLINE ones
11244
- *
11245
- * The directory is the device manager's own `online` flag (D132 §3: the
11246
- * authority seeds the mirror at boot and stays the authority). Seeding only
11247
- * the devices it calls ONLINE is what makes this change one-directional:
11248
- *
11249
- * - a row seeded `online` can only ever turn a later `device.offline` into a
11250
- * notification — a true sentence about a device that just went away;
11251
- * - it can NEVER turn a `device.online` into one, and that is the only shape
11252
- * the 2026-08-13 notification flood (D130) ever had.
11253
- *
11254
- * A device the directory calls offline — which is also what a cold or
11255
- * still-settling directory would say — gets no row and keeps today's
11256
- * behaviour exactly. Failing toward discard, unchanged.
11257
- *
11258
- * ## D49
11259
- *
11260
- * An EXISTING row is never overwritten. The ledger is the authority for what
11261
- * it accepted; the directory is a mirror refreshed on a tick and may be
11262
- * stale. A stale `online` overwriting an accepted `offline` would make the
11263
- * camera's real recovery a no-flip and lose it. A failed directory read
11264
- * simply yields an empty list here and seeds nothing.
11265
- *
11266
- * ## Staged, not persisted
11267
- *
11268
- * The seed is DERIVED from an authority that is read again on every boot, so
11269
- * losing it costs nothing — the next boot re-derives it. `stage` therefore
11270
- * advances the mirror without a write, which matters on this hub: the
11271
- * pipeline-analytics runner respawns several times an hour, and persisting
11272
- * ~500 re-derivable rows on each respawn would offer thousands of pointless
11273
- * commits a day to the checkpoint lottery (D96). The first real
11274
- * {@link observe} flip persists through the ordinary write-behind path.
11275
- *
11276
- * Returns how many rows were seeded, so the caller can say it out loud.
11277
- */
11278
- seedFromDirectory(identities, now) {
11279
- let seeded = 0;
11280
- for (const identity of identities) {
11281
- if (identity.online !== true) continue;
11282
- const subject = `device:${identity.deviceId}`;
11283
- if (this.ledger.has(subject)) continue;
11284
- this.ledger.stage({
11285
- subject,
11286
- state: "online",
11287
- kind: "device-online",
11288
- deviceId: identity.deviceId,
11289
- updatedAt: now
11290
- });
11291
- seeded += 1;
11292
- }
11293
- return seeded;
11294
- }
11295
- /** The in-RAM mirror, subject-ordered so two readers agree. */
11296
- snapshot() {
11297
- return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
11298
- }
11299
- /** The state the ledger currently accepts for a subject, if any. */
11300
- stateOf(subject) {
11301
- return this.ledger.get(subject)?.state;
11302
- }
11303
- /**
11304
- * Judge ONE observation and advance the ledger.
11305
- *
11306
- * Synchronous on purpose: the verdict is a function of the in-RAM mirror
11307
- * alone, so an event can never be gated on an I/O that might fail (D49). The
11308
- * durable write is kicked off behind it and its failure changes no verdict.
11309
- *
11310
- * `no-flip` leaves the row untouched — including its `updatedAt`, which
11311
- * therefore means "when this subject last CHANGED", not "when it last
11312
- * spoke". That is the timestamp anyone reading the table wants.
11313
- */
11314
- observe(observation, now) {
11315
- return this.ledger.observe({
11316
- subject: observation.subject,
11317
- state: observation.state,
11318
- kind: observation.kind,
11319
- ...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
11320
- updatedAt: now
11321
- });
11322
- }
11323
- };
11324
- /** The persisted column map (the `subject` PK is passed separately). */
11325
- function rowToValue$1(row) {
11326
- return {
11327
- state: row.state,
11328
- kind: row.kind,
11329
- ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
11330
- updatedAt: row.updatedAt
11331
- };
11332
- }
11333
- /**
11334
- * Structurally validate one persisted record. A row whose state is not one of
11335
- * the two known values is `null` — skipped, so the subject re-seeds cold
11336
- * (silent) rather than hydrating a state nothing can ever equal, which would
11337
- * make every future event a phantom flip.
11338
- */
11339
- function recordToRow$2(subject, data) {
11340
- if (subject.length === 0) return null;
11341
- const state = data["state"];
11342
- if (state !== "online" && state !== "offline") return null;
11343
- const kind = data["kind"];
11344
- if (typeof kind !== "string" || kind.length === 0) return null;
11345
- const updatedAt = Number(data["updatedAt"]);
11346
- if (!Number.isFinite(updatedAt)) return null;
11347
- const rawDeviceId = data["deviceId"];
11348
- const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
11349
- return {
11350
- subject,
11351
- state,
11352
- kind,
11353
- ...deviceId !== void 0 ? { deviceId } : {},
11354
- updatedAt
11355
- };
11356
- }
11357
10986
  function isDeviceLivenessKind(kind) {
11358
10987
  return kind === "device-online" || kind === "device-offline" || kind === "camera-online" || kind === "camera-offline";
11359
10988
  }
@@ -11416,6 +11045,268 @@ var DeviceLivenessHoldoff = class {
11416
11045
  }
11417
11046
  };
11418
11047
  //#endregion
11048
+ //#region src/notification-center/liveness-ledger.ts
11049
+ /**
11050
+ * @durable class=ledger owner=notification-center
11051
+ * write="a liveness state TRANSITION the centre accepted (seed or flip); a no-flip writes nothing"
11052
+ * retention="none, deliberately — one row per device plus one per node, bounded by the installation. NOT pruned against the device directory: a prune is work destroyed on a fallible read (D49/D130)."
11053
+ */
11054
+ var NC_LIVENESS_COLLECTION = "notification-center:liveness";
11055
+ var NC_LIVENESS_COLUMNS = [
11056
+ (
11057
+ /** The subject the intake minted (`device:617`, `node:agent-1`) — one row
11058
+ * each, so the two families can never collide on a key. */
11059
+ {
11060
+ name: "subject",
11061
+ type: "TEXT",
11062
+ primaryKey: true,
11063
+ notNull: true
11064
+ }),
11065
+ (
11066
+ /** `online` | `offline` — the STATE, not the kind, so a legacy `camera-*`
11067
+ * row and a `device-*` event describing the same fact compare equal. */
11068
+ {
11069
+ name: "state",
11070
+ type: "TEXT",
11071
+ notNull: true
11072
+ }),
11073
+ (
11074
+ /** The kind that last wrote the row. Telemetry: it makes the table readable
11075
+ * when someone asks "why did 617 not notify". */
11076
+ {
11077
+ name: "kind",
11078
+ type: "TEXT",
11079
+ notNull: true
11080
+ }),
11081
+ (
11082
+ /** Absent on a node row. Indexed — every question about a device is asked
11083
+ * per-device. */
11084
+ {
11085
+ name: "deviceId",
11086
+ type: "INTEGER"
11087
+ }),
11088
+ {
11089
+ name: "updatedAt",
11090
+ type: "INTEGER",
11091
+ notNull: true
11092
+ }
11093
+ ];
11094
+ var NC_LIVENESS_INDEXES = [{
11095
+ name: "idx_nc_liveness_device",
11096
+ columns: ["deviceId"]
11097
+ }];
11098
+ /**
11099
+ * The kinds the ledger governs — see the module docblock for why the stream
11100
+ * and switch families are absent.
11101
+ */
11102
+ var NC_LEDGERED_KINDS = new Set([
11103
+ "device-online",
11104
+ "device-offline",
11105
+ "node-online",
11106
+ "node-offline",
11107
+ "camera-online",
11108
+ "camera-offline"
11109
+ ]);
11110
+ /**
11111
+ * Reduce a system-event subject to a ledger observation, or `null` when the
11112
+ * kind is not one the ledger governs.
11113
+ *
11114
+ * The ONE place that decides which kinds are gated: a caller that branched on
11115
+ * a `kind.startsWith('device-')` prefix would silently pull the operator's
11116
+ * disable switch into a liveness ledger, which is the mistake the docblock
11117
+ * spells out.
11118
+ */
11119
+ function ledgeredLiveness(system) {
11120
+ if (!NC_LEDGERED_KINDS.has(system.kind)) return null;
11121
+ const state = system.kind.endsWith("-offline") ? "offline" : "online";
11122
+ return {
11123
+ subject: system.subject,
11124
+ kind: system.kind,
11125
+ state,
11126
+ ...system.deviceId !== void 0 ? { deviceId: system.deviceId } : {}
11127
+ };
11128
+ }
11129
+ /**
11130
+ * The ledger's mechanics, declared once. Everything policy-shaped is right
11131
+ * here in this object: what the KEY is, what the persisted row looks like, and
11132
+ * — the only decision the primitive cannot make — WHAT COUNTS AS THE SAME
11133
+ * FACT. For liveness the fact is the STATE, so a legacy `camera-online` row
11134
+ * and a `device-online` event describing it compare equal, and a repeat never
11135
+ * moves `updatedAt`.
11136
+ *
11137
+ * The reseed is uncapped in practice: the row set is bounded by the
11138
+ * installation — one row per device plus one per node — so the primitive's
11139
+ * default ceiling still bounds a pathological read. Deliberately NOT pruned
11140
+ * against the device directory: a prune is work DESTROYED on a fallible read
11141
+ * (D49), and a directory that answered empty once would wipe the ledger and
11142
+ * re-arm the very flood this file exists to prevent. A removed device leaves
11143
+ * exactly one row behind, and a re-added one re-seeds correctly.
11144
+ */
11145
+ var NC_LIVENESS_SPEC = {
11146
+ collection: NC_LIVENESS_COLLECTION,
11147
+ columns: NC_LIVENESS_COLUMNS,
11148
+ indexes: NC_LIVENESS_INDEXES,
11149
+ writeMode: "write-behind",
11150
+ keyOf: (row) => row.subject,
11151
+ toValue: rowToValue$1,
11152
+ fromRecord: recordToRow$2,
11153
+ equalFact: (held, incoming) => held.state === incoming.state,
11154
+ deviceIdOf: (row) => row.deviceId
11155
+ };
11156
+ var NcLivenessLedger = class {
11157
+ ledger;
11158
+ constructor(deps) {
11159
+ this.ledger = new DurableLedger({
11160
+ spec: NC_LIVENESS_SPEC,
11161
+ store: deps.store,
11162
+ logger: deps.logger
11163
+ });
11164
+ }
11165
+ static declare(store) {
11166
+ return DurableLedger.declare(store, NC_LIVENESS_SPEC);
11167
+ }
11168
+ /**
11169
+ * Boot reseed — the whole point of the file. Returns the row count so the
11170
+ * caller can say out loud how much memory it recovered; "loaded 0" after a
11171
+ * container recreate is the one line that would have explained tonight.
11172
+ *
11173
+ * A failed load KEEPS whatever is already mirrored (the primitive's contract
11174
+ * rule 2) rather than clearing it: dropping the memory is what caused the
11175
+ * flood, so no error path may do it on purpose.
11176
+ */
11177
+ async load() {
11178
+ return (await this.ledger.load()).length;
11179
+ }
11180
+ /**
11181
+ * Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
11182
+ * the fix for the outage this ledger used to swallow.
11183
+ *
11184
+ * ## The hole
11185
+ *
11186
+ * {@link load} recovers what the ledger itself accepted. A device that has
11187
+ * never produced a liveness event since the ledger was created has no row at
11188
+ * all, so its FIRST observation ever seeds silently — and if that first
11189
+ * observation is the camera DYING, the outage is thrown away. Live cost,
11190
+ * 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
11191
+ * notifications. `device:584` and `device:587` had never spoken; their
11192
+ * unplugging was recorded and never announced. A camera that is quiet until
11193
+ * it dies is a camera whose death is silent, indefinitely.
11194
+ *
11195
+ * ## Why only the ONLINE ones
11196
+ *
11197
+ * The directory is the device manager's own `online` flag (D132 §3: the
11198
+ * authority seeds the mirror at boot and stays the authority). Seeding only
11199
+ * the devices it calls ONLINE is what makes this change one-directional:
11200
+ *
11201
+ * - a row seeded `online` can only ever turn a later `device.offline` into a
11202
+ * notification — a true sentence about a device that just went away;
11203
+ * - it can NEVER turn a `device.online` into one, and that is the only shape
11204
+ * the 2026-08-13 notification flood (D130) ever had.
11205
+ *
11206
+ * A device the directory calls offline — which is also what a cold or
11207
+ * still-settling directory would say — gets no row and keeps today's
11208
+ * behaviour exactly. Failing toward discard, unchanged.
11209
+ *
11210
+ * ## D49
11211
+ *
11212
+ * An EXISTING row is never overwritten. The ledger is the authority for what
11213
+ * it accepted; the directory is a mirror refreshed on a tick and may be
11214
+ * stale. A stale `online` overwriting an accepted `offline` would make the
11215
+ * camera's real recovery a no-flip and lose it. A failed directory read
11216
+ * simply yields an empty list here and seeds nothing.
11217
+ *
11218
+ * ## Staged, not persisted
11219
+ *
11220
+ * The seed is DERIVED from an authority that is read again on every boot, so
11221
+ * losing it costs nothing — the next boot re-derives it. `stage` therefore
11222
+ * advances the mirror without a write, which matters on this hub: the
11223
+ * pipeline-analytics runner respawns several times an hour, and persisting
11224
+ * ~500 re-derivable rows on each respawn would offer thousands of pointless
11225
+ * commits a day to the checkpoint lottery (D96). The first real
11226
+ * {@link observe} flip persists through the ordinary write-behind path.
11227
+ *
11228
+ * Returns how many rows were seeded, so the caller can say it out loud.
11229
+ */
11230
+ seedFromDirectory(identities, now) {
11231
+ let seeded = 0;
11232
+ for (const identity of identities) {
11233
+ if (identity.online !== true) continue;
11234
+ const subject = `device:${identity.deviceId}`;
11235
+ if (this.ledger.has(subject)) continue;
11236
+ this.ledger.stage({
11237
+ subject,
11238
+ state: "online",
11239
+ kind: "device-online",
11240
+ deviceId: identity.deviceId,
11241
+ updatedAt: now
11242
+ });
11243
+ seeded += 1;
11244
+ }
11245
+ return seeded;
11246
+ }
11247
+ /** The in-RAM mirror, subject-ordered so two readers agree. */
11248
+ snapshot() {
11249
+ return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
11250
+ }
11251
+ /** The state the ledger currently accepts for a subject, if any. */
11252
+ stateOf(subject) {
11253
+ return this.ledger.get(subject)?.state;
11254
+ }
11255
+ /**
11256
+ * Judge ONE observation and advance the ledger.
11257
+ *
11258
+ * Synchronous on purpose: the verdict is a function of the in-RAM mirror
11259
+ * alone, so an event can never be gated on an I/O that might fail (D49). The
11260
+ * durable write is kicked off behind it and its failure changes no verdict.
11261
+ *
11262
+ * `no-flip` leaves the row untouched — including its `updatedAt`, which
11263
+ * therefore means "when this subject last CHANGED", not "when it last
11264
+ * spoke". That is the timestamp anyone reading the table wants.
11265
+ */
11266
+ observe(observation, now) {
11267
+ return this.ledger.observe({
11268
+ subject: observation.subject,
11269
+ state: observation.state,
11270
+ kind: observation.kind,
11271
+ ...observation.deviceId !== void 0 ? { deviceId: observation.deviceId } : {},
11272
+ updatedAt: now
11273
+ });
11274
+ }
11275
+ };
11276
+ /** The persisted column map (the `subject` PK is passed separately). */
11277
+ function rowToValue$1(row) {
11278
+ return {
11279
+ state: row.state,
11280
+ kind: row.kind,
11281
+ ...row.deviceId !== void 0 ? { deviceId: row.deviceId } : {},
11282
+ updatedAt: row.updatedAt
11283
+ };
11284
+ }
11285
+ /**
11286
+ * Structurally validate one persisted record. A row whose state is not one of
11287
+ * the two known values is `null` — skipped, so the subject re-seeds cold
11288
+ * (silent) rather than hydrating a state nothing can ever equal, which would
11289
+ * make every future event a phantom flip.
11290
+ */
11291
+ function recordToRow$2(subject, data) {
11292
+ if (subject.length === 0) return null;
11293
+ const state = data["state"];
11294
+ if (state !== "online" && state !== "offline") return null;
11295
+ const kind = data["kind"];
11296
+ if (typeof kind !== "string" || kind.length === 0) return null;
11297
+ const updatedAt = Number(data["updatedAt"]);
11298
+ if (!Number.isFinite(updatedAt)) return null;
11299
+ const rawDeviceId = data["deviceId"];
11300
+ const deviceId = typeof rawDeviceId === "number" && Number.isFinite(rawDeviceId) ? rawDeviceId : void 0;
11301
+ return {
11302
+ subject,
11303
+ state,
11304
+ kind,
11305
+ ...deviceId !== void 0 ? { deviceId } : {},
11306
+ updatedAt
11307
+ };
11308
+ }
11309
+ //#endregion
11419
11310
  //#region src/notification-center/occupancy-watcher.ts
11420
11311
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
11421
11312
  var FRAME_SCOPE = "@frame";
@@ -13782,6 +13673,279 @@ var NcSnoozeStore = class {
13782
13673
  return parsed.success ? parsed.data : null;
13783
13674
  }
13784
13675
  };
13676
+ /** JPEG quality for the downscaled full frame — matches the crop path. */
13677
+ var FULL_FRAME_QUALITY = 80;
13678
+ /**
13679
+ * Downscale an already-encoded JPEG full frame to FIT WITHIN
13680
+ * {@link FULL_FRAME_MAX_WIDTH}×{@link FULL_FRAME_MAX_HEIGHT}, preserving aspect
13681
+ * ratio (`fit: 'inside'`) and never enlarging a source already smaller than the
13682
+ * box. Re-encodes as JPEG. Used before persisting a synthetic sensor/control
13683
+ * track's whole-scene snapshot so a raw native-resolution frame (a 4K bedroom
13684
+ * at night) is never stored or served — the privacy fix moved to CAPTURE time.
13685
+ */
13686
+ async function downscaleFullFrameJpeg(jpeg, maxWidth = 640, maxHeight = 360) {
13687
+ return sharp(Buffer.from(jpeg)).resize(maxWidth, maxHeight, {
13688
+ fit: "inside",
13689
+ withoutEnlargement: true
13690
+ }).jpeg({ quality: FULL_FRAME_QUALITY }).toBuffer();
13691
+ }
13692
+ //#endregion
13693
+ //#region src/notification-center/still-shelf.ts
13694
+ /**
13695
+ * The still shelf — the PHOTOGRAPH a notification carries when nothing it can
13696
+ * name owns a frame.
13697
+ *
13698
+ * ## Why a trigger needs one at all
13699
+ *
13700
+ * The attachment ladder resolves media by OWNER, and most triggers have one: an
13701
+ * object or package event owns its crops, a closed track owns its best shot, a
13702
+ * doorbell press owns the marker track the same press projected
13703
+ * (`sensor-marker-projector.ts`), an occupancy edge names one of the objects it
13704
+ * counted (`chooseOccupancyMediaOwner`). Two triggers own nothing, and for the
13705
+ * same reason in both cases — the subject is an ABSENCE:
13706
+ *
13707
+ * - an **audio** match: nothing was boxed, nothing was tracked, and —
13708
+ * deliberately — nothing is persisted at all. A confirmed window is a claim
13709
+ * about sound that has already stopped, and `event-intake.ts` states why
13710
+ * replaying it later would be wrong.
13711
+ * - an **occupancy** edge whose scope is EMPTY — "posto libero". The vehicle
13712
+ * whose departure IS the news has left, so `chooseOccupancyMediaOwner` names
13713
+ * nobody and the ladder logs `no still could be resolved … owners=[]`.
13714
+ *
13715
+ * So the only honest picture is a PHOTOGRAPH of the camera taken at the moment
13716
+ * of the trigger. Not of the sound, not of the object that left — of what the
13717
+ * camera can see now that it happened. For a freed parking space that is
13718
+ * exactly the answer the operator wants: the space, empty.
13719
+ *
13720
+ * **One shelf, not two.** The mechanism is identical down to the reuse window,
13721
+ * and the only thing that differs between the two triggers is the GATE deciding
13722
+ * that a photograph is owed at all — which belongs at the trigger site, where
13723
+ * the rules are, and not here. A twin module would be a second derivation of
13724
+ * one thing. The trigger rides along as {@link NcStillTrigger} only so the logs
13725
+ * can say which absence they are about.
13726
+ *
13727
+ * ## Three properties, and each one is a decision
13728
+ *
13729
+ * **It is not a record.** The bytes live here, in RAM, under an owner id and a
13730
+ * TTL that covers the outbox's whole retry horizon — and nowhere else. The
13731
+ * alternative was the doorbell's: materialise a synthetic marker track through
13732
+ * `SyntheticTrackMaterializer` and let the notification name it. That would put
13733
+ * a durable Track on the camera's timeline for every confirmed window and every
13734
+ * emptied zone, feeding the digest's `listTracks`, retention, and the audio-
13735
+ * marker feature's own operator ceilings (`audio-marker-projector.ts` exists
13736
+ * precisely to bound how many audio markers a camera may emit). A notification
13737
+ * must not manufacture timeline history as a side effect of wanting a picture.
13738
+ *
13739
+ * **The capture STARTS immediately and is never awaited.** The subject is
13740
+ * transient — a scream is over before a snapshot round-trip completes, and a
13741
+ * freed space is about to be taken by the next car — so the fetch is kicked off
13742
+ * at the trigger, before the rule evaluation runs, and the owner id is minted
13743
+ * synchronously so the outbox row can name it. The bytes land while the row
13744
+ * waits in the queue, and the dispatcher's existing bounded still-wait (or the
13745
+ * pause its own footage render already costs) picks them up. A camera that
13746
+ * never answers costs the picture and never the notification.
13747
+ *
13748
+ * **Two triggers seconds apart share ONE capture.** A barking dog confirms
13749
+ * repeatedly and a label-mode rule has no re-arm timer at all (D157) — the
13750
+ * rule's cooldown is its only brake, and the cooldown is applied AFTER this.
13751
+ * Without a reuse window this would photograph a camera at whatever rate the
13752
+ * sound happens to occur. Each trigger still gets its OWN owner id, so two
13753
+ * outbox rows are never mistaken for one subject; they merely point at the same
13754
+ * frame, which is the truth — the scene did not change in ten seconds. The
13755
+ * window is per CAMERA and trigger-agnostic for the same reason: a sound and an
13756
+ * emptied zone ten seconds apart are two claims about one scene.
13757
+ *
13758
+ * Nothing here is silent: a capture that lands and a camera that refuses each
13759
+ * emit one line carrying `tags: { deviceId }`, because "why did 617 get a photo
13760
+ * and 615 not" is the only form that question is ever asked in.
13761
+ */
13762
+ /**
13763
+ * The owner-id namespace. It is what routes a lookup here instead of to the
13764
+ * media store, and it is the reason the dispatcher needs no audio or occupancy
13765
+ * branch — `getMediaForOwner` answers for all of them under one signature.
13766
+ */
13767
+ var NC_STILL_SHELF_PREFIX = "nc-still:";
13768
+ /** True for an owner id this shelf minted. */
13769
+ function isStillShelfId(id) {
13770
+ return id.startsWith(NC_STILL_SHELF_PREFIX);
13771
+ }
13772
+ /**
13773
+ * How long the bytes are held.
13774
+ *
13775
+ * The outbox retries 8 times with a 5 s → 300 s backoff, which tops out around
13776
+ * ten minutes; fifteen covers that with room for a slow drain. Past it the row
13777
+ * ships text-only, which is the correct degradation for a photograph of a scene
13778
+ * that is a quarter of an hour stale anyway.
13779
+ */
13780
+ var NC_STILL_SHELF_TTL_MS = 15 * 6e4;
13781
+ /** A hanging snapshot cap must not pin a capture slot forever. */
13782
+ var SNAPSHOT_TIMEOUT_MS = 8e3;
13783
+ /**
13784
+ * Hard bound on held captures. Reached only if every camera on the hub triggers
13785
+ * one inside a single TTL; the oldest is dropped first, which costs a
13786
+ * fifteen-minute-old picture nobody is waiting for.
13787
+ */
13788
+ var MAX_CAPTURES = 64;
13789
+ var NcStillShelf = class {
13790
+ deps;
13791
+ /** captureId → the photograph. */
13792
+ captures = /* @__PURE__ */ new Map();
13793
+ /** ownerId → captureId. Several owners may name one capture (the reuse window). */
13794
+ owners = /* @__PURE__ */ new Map();
13795
+ /** deviceId → its newest capture, for the reuse window. */
13796
+ newest = /* @__PURE__ */ new Map();
13797
+ constructor(deps) {
13798
+ this.deps = deps;
13799
+ }
13800
+ /**
13801
+ * Photograph `deviceId` for a trigger at `atMs`, and return the owner id the
13802
+ * subject should name. SYNCHRONOUS by contract: the caller is on the trigger
13803
+ * path and the outbox row is built from what this returns.
13804
+ */
13805
+ capture(deviceId, atMs, trigger) {
13806
+ const now = this.deps.now();
13807
+ this.prune(now);
13808
+ const ownerId = `${NC_STILL_SHELF_PREFIX}${randomUUID()}`;
13809
+ const recent = this.newest.get(deviceId);
13810
+ if (recent !== void 0 && now - recent.at < 1e4 && this.captures.has(recent.captureId)) {
13811
+ this.owners.set(ownerId, recent.captureId);
13812
+ return ownerId;
13813
+ }
13814
+ const captureId = randomUUID();
13815
+ this.captures.set(captureId, {
13816
+ deviceId,
13817
+ trigger,
13818
+ startedAt: now,
13819
+ expiresAt: now + NC_STILL_SHELF_TTL_MS,
13820
+ files: []
13821
+ });
13822
+ this.newest.set(deviceId, {
13823
+ captureId,
13824
+ at: now
13825
+ });
13826
+ this.owners.set(ownerId, captureId);
13827
+ this.fetch(captureId, deviceId, atMs, trigger);
13828
+ return ownerId;
13829
+ }
13830
+ /**
13831
+ * The media an owner id holds, or `undefined` when this shelf never minted
13832
+ * it. An EMPTY array is a different answer: the capture exists and has not
13833
+ * landed (or never will), which is exactly the case the dispatcher's bounded
13834
+ * wait and its `no still could be resolved` line are for.
13835
+ */
13836
+ get(ownerId) {
13837
+ const captureId = this.owners.get(ownerId);
13838
+ if (captureId === void 0) return void 0;
13839
+ return this.captures.get(captureId)?.files ?? [];
13840
+ }
13841
+ /** Drop expired captures and the owners that named them. */
13842
+ prune(now) {
13843
+ for (const [captureId, held] of this.captures) {
13844
+ if (held.expiresAt > now) continue;
13845
+ this.captures.delete(captureId);
13846
+ if (this.newest.get(held.deviceId)?.captureId === captureId) this.newest.delete(held.deviceId);
13847
+ }
13848
+ while (this.captures.size > MAX_CAPTURES) {
13849
+ const oldest = this.captures.keys().next();
13850
+ if (oldest.done === true) break;
13851
+ const held = this.captures.get(oldest.value);
13852
+ this.captures.delete(oldest.value);
13853
+ if (held !== void 0 && this.newest.get(held.deviceId)?.captureId === oldest.value) this.newest.delete(held.deviceId);
13854
+ }
13855
+ for (const [ownerId, captureId] of this.owners) if (!this.captures.has(captureId)) this.owners.delete(ownerId);
13856
+ }
13857
+ /** Drop everything (shutdown). */
13858
+ clear() {
13859
+ this.captures.clear();
13860
+ this.owners.clear();
13861
+ this.newest.clear();
13862
+ }
13863
+ async fetch(captureId, deviceId, atMs, trigger) {
13864
+ try {
13865
+ const shot = await withTimeout$3(this.deps.getSnapshot(deviceId), SNAPSHOT_TIMEOUT_MS);
13866
+ if (shot === null || shot.base64.length === 0) {
13867
+ this.reportMiss(deviceId, trigger, "the camera returned no snapshot");
13868
+ return;
13869
+ }
13870
+ const raw = Buffer.from(shot.base64, "base64");
13871
+ let data = raw;
13872
+ try {
13873
+ data = await downscaleFullFrameJpeg(raw, 960, 540);
13874
+ } catch {}
13875
+ const held = this.captures.get(captureId);
13876
+ if (held === void 0) return;
13877
+ this.captures.set(captureId, {
13878
+ ...held,
13879
+ files: stillShelfMedia(data, atMs)
13880
+ });
13881
+ this.deps.logger.info(`${trigger} still captured`, {
13882
+ tags: { deviceId },
13883
+ meta: {
13884
+ trigger,
13885
+ bytes: data.byteLength,
13886
+ tookMs: this.deps.now() - held.startedAt
13887
+ }
13888
+ });
13889
+ } catch (err) {
13890
+ this.reportMiss(deviceId, trigger, err instanceof Error ? err.message : String(err));
13891
+ }
13892
+ }
13893
+ /**
13894
+ * A branch that drops work says so. This one costs the operator the picture
13895
+ * on a notification they DID receive, so it is a warn and it carries the
13896
+ * camera — the only key the question is ever asked with.
13897
+ */
13898
+ reportMiss(deviceId, trigger, reason) {
13899
+ this.deps.logger.warn(`the camera did not answer the ${trigger} still — this notification ships text-only`, {
13900
+ tags: { deviceId },
13901
+ meta: {
13902
+ trigger,
13903
+ reason
13904
+ }
13905
+ });
13906
+ }
13907
+ };
13908
+ /**
13909
+ * The photograph, in the three kinds the CLEAN-SCENE ladders ask for.
13910
+ *
13911
+ * `keyFrameSmall` leads because it is the first rung of both `attach: 'best'`
13912
+ * and `attach: 'keyFrame'` on a track owner; `keyFrame` and `fullFrame` answer
13913
+ * `frame: 'full'` and `frame: 'boxed'`'s honest degrade. There is deliberately
13914
+ * no `crop` / `thumbnail`: nothing was boxed, so `frame: 'cropped'` resolves
13915
+ * NOTHING and the notification ships text-only with the ordinary line saying
13916
+ * so. Inventing a centre crop would answer a different question from the one
13917
+ * the operator asked.
13918
+ */
13919
+ function stillShelfMedia(data, timestamp) {
13920
+ const base64 = data.toString("base64");
13921
+ const file = (kind) => ({
13922
+ key: `${NC_STILL_SHELF_PREFIX}${kind}`,
13923
+ kind,
13924
+ base64,
13925
+ sizeBytes: data.byteLength,
13926
+ timestamp
13927
+ });
13928
+ return [
13929
+ file("keyFrameSmall"),
13930
+ file("keyFrame"),
13931
+ file("fullFrame")
13932
+ ];
13933
+ }
13934
+ /** Reject if `promise` does not settle within `ms`. */
13935
+ function withTimeout$3(promise, ms) {
13936
+ return new Promise((resolve, reject) => {
13937
+ const timer = setTimeout(() => {
13938
+ reject(/* @__PURE__ */ new Error(`snapshot cap timed out after ${String(ms)}ms`));
13939
+ }, ms);
13940
+ promise.then((value) => {
13941
+ clearTimeout(timer);
13942
+ resolve(value);
13943
+ }, (err) => {
13944
+ clearTimeout(timer);
13945
+ reject(err instanceof Error ? err : new Error(String(err)));
13946
+ });
13947
+ });
13948
+ }
13785
13949
  //#endregion
13786
13950
  //#region src/notification-center/summary/summary-ai.ts
13787
13951
  /**
@@ -21841,6 +22005,7 @@ var NotificationCenter = class NotificationCenter {
21841
22005
  ...rule.template !== void 0 ? { template: rule.template } : {},
21842
22006
  media: noMedia ? "none" : mediaRule.media.attach,
21843
22007
  ...!noMedia && mediaRule.media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
22008
+ ...!noMedia && cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
21844
22009
  ...confirm !== void 0 ? {
21845
22010
  confirm,
21846
22011
  ...cooldown !== void 0 ? { cooldown } : {}
@@ -25917,37 +26082,61 @@ function eligibleIdentities(gallery, probeModelId, probeDim, minIdentitySamples)
25917
26082
  return eligible;
25918
26083
  }
25919
26084
  /**
25920
- * Match a probe embedding against the gallery. Only samples with the same
25921
- * `modelId` AND the same dimension are compared (model-version safety). Returns
25922
- * the best identity when its score threshold and it beats the best OTHER
25923
- * identity by ≥ margin; otherwise null.
26085
+ * THE scoring authority. Every identity with at least one sample comparable to
26086
+ * the probe (same `modelId` AND same dimension model-version safety), scored
26087
+ * against its best such sample, sorted best-first.
26088
+ *
26089
+ * Extracted from `matchEmbedding`, which is now a thin decision on top of it,
26090
+ * so that "what would the pipeline score this face" has exactly ONE
26091
+ * implementation. `face-rescore.ts` — the operator's recompute button — reads
26092
+ * the same rows the matcher decides from; a second scoring pass written for the
26093
+ * UI would be free to drift, and a UI that disagrees with the pipeline is worse
26094
+ * than no UI.
25924
26095
  */
25925
- function matchEmbedding(probe, gallery, opts) {
26096
+ function rankIdentities(probe, gallery, minIdentitySamples) {
25926
26097
  const probeVec = new Float32Array(probe.embedding);
25927
- const eligible = eligibleIdentities(gallery, probe.modelId, probe.embedding.length, opts.minIdentitySamples ?? 1);
25928
26098
  const bestByIdentity = /* @__PURE__ */ new Map();
26099
+ const countByIdentity = /* @__PURE__ */ new Map();
25929
26100
  for (const s of gallery) {
25930
26101
  if (s.modelId !== probe.modelId) continue;
25931
26102
  if (s.embedding.length !== probe.embedding.length) continue;
25932
- if (!eligible.has(s.identityId)) continue;
25933
26103
  const score = cosineSimilarity$1(probeVec, new Float32Array(s.embedding));
25934
26104
  const prev = bestByIdentity.get(s.identityId);
25935
26105
  if (prev === void 0 || score > prev) bestByIdentity.set(s.identityId, score);
26106
+ countByIdentity.set(s.identityId, (countByIdentity.get(s.identityId) ?? 0) + 1);
25936
26107
  }
25937
- if (bestByIdentity.size === 0) return null;
25938
- let bestId = null;
25939
- let bestScore = -Infinity;
25940
- let secondScore = -Infinity;
25941
- for (const [id, score] of bestByIdentity) if (score > bestScore) {
25942
- secondScore = bestScore;
25943
- bestScore = score;
25944
- bestId = id;
25945
- } else if (score > secondScore) secondScore = score;
25946
- if (bestId === null || bestScore < opts.threshold) return null;
25947
- if (secondScore > -Infinity && bestScore - secondScore < opts.margin) return null;
26108
+ const rows = [];
26109
+ for (const [identityId, score] of bestByIdentity) {
26110
+ const sampleCount = countByIdentity.get(identityId) ?? 0;
26111
+ rows.push({
26112
+ identityId,
26113
+ score,
26114
+ sampleCount,
26115
+ eligible: sampleCount >= minIdentitySamples
26116
+ });
26117
+ }
26118
+ rows.sort((a, b) => b.score - a.score);
26119
+ return rows;
26120
+ }
26121
+ /**
26122
+ * Match a probe embedding against the gallery. Only samples with the same
26123
+ * `modelId` AND the same dimension are compared (model-version safety). Returns
26124
+ * the best identity when its score ≥ threshold and it beats the best OTHER
26125
+ * identity by ≥ margin; otherwise null.
26126
+ *
26127
+ * The scores come from {@link rankIdentities}; this function is only the
26128
+ * decision (eligibility filter, threshold, ambiguity guard).
26129
+ */
26130
+ function matchEmbedding(probe, gallery, opts) {
26131
+ const ranked = rankIdentities(probe, gallery, opts.minIdentitySamples ?? 1).filter((r) => r.eligible);
26132
+ const best = ranked[0];
26133
+ if (best === void 0) return null;
26134
+ if (best.score < opts.threshold) return null;
26135
+ const second = ranked[1];
26136
+ if (second !== void 0 && best.score - second.score < opts.margin) return null;
25948
26137
  return {
25949
- identityId: bestId,
25950
- score: bestScore
26138
+ identityId: best.identityId,
26139
+ score: best.score
25951
26140
  };
25952
26141
  }
25953
26142
  /**
@@ -26086,17 +26275,6 @@ function updateTrackAggregate(prev, match, opts) {
26086
26275
  /** Attribution of a face identity NAMED BY THE OPERATOR in the gallery. */
26087
26276
  var OPERATOR_FACE_STEP_ID = "operator:face-gallery";
26088
26277
  /**
26089
- * Model id assumed for a face row that recorded none.
26090
- *
26091
- * Every row written before 2026-08-20 predates `Face.embeddingModelId`, and
26092
- * every one of them was embedded with `arcface-r100` — it was the only face
26093
- * model the step had ever defaulted to. So this is a statement about history,
26094
- * NOT a default for new work: a face that names its model is stamped with the
26095
- * model it names, because a sample that lies about its feature space defeats
26096
- * the only guard `face-matcher` has.
26097
- */
26098
- var LEGACY_MODEL_ID = "arcface-r100";
26099
- /**
26100
26278
  * How many face crops `listRecentFaces` resolves at once.
26101
26279
  *
26102
26280
  * Each crop costs a store round-trip plus a disk read, so the page is I/O-bound
@@ -26338,7 +26516,7 @@ var FaceGalleryProvider = class {
26338
26516
  }
26339
26517
  const currentFace = await this.faceStore.get(faceId);
26340
26518
  if (!currentFace) throw new Error(`FaceGalleryProvider.assignFace: face disappeared after pre-cleanup: ${faceId}`);
26341
- const enrolledModelId = currentFace.embeddingModelId ?? LEGACY_MODEL_ID;
26519
+ const enrolledModelId = currentFace.embeddingModelId ?? "arcface-r100";
26342
26520
  const verdict = enrollmentPromiscuity(currentFace.embedding, enrolledModelId, identityId, await this.identityStore.loadGallery());
26343
26521
  if (verdict !== null) {
26344
26522
  this.logger.warn("assignFace refused: embedding too close to a FOREIGN identity sample", {
@@ -26511,6 +26689,314 @@ var FaceGalleryProvider = class {
26511
26689
  }
26512
26690
  };
26513
26691
  //#endregion
26692
+ //#region src/pipeline-analytics/face-rescore.ts
26693
+ /**
26694
+ * face-rescore — score ONE face against the gallery AS IT IS RIGHT NOW.
26695
+ *
26696
+ * ## Why this exists: the suggestion on a track is stale by construction
26697
+ *
26698
+ * `Face.suggestedIdentityId` / `suggestedMatchScore` are not computed when you
26699
+ * look at a track. They are computed DURING live detection, frame by frame
26700
+ * (`face-recognizer.ts` — the suggestion band), and the per-track peak is
26701
+ * persisted when the track CLOSES. They are a photograph of what the gallery
26702
+ * said at that instant.
26703
+ *
26704
+ * So every enrolment after that instant is invisible to them. The operator's
26705
+ * real workflow is the exact case this breaks: assign ten faces to build a
26706
+ * person up, then open an older unassigned track and ask "does it match NOW?"
26707
+ * — and read a number that was decided before any of those ten existed. There
26708
+ * is no background job that refreshes it and there should not be one (the
26709
+ * gallery changes on every assign; re-scoring every buffered face each time is
26710
+ * work nobody asked for). The honest fix is to make the question askable on
26711
+ * demand. That is this module, and that is the button.
26712
+ *
26713
+ * ## What it does NOT do
26714
+ *
26715
+ * It does not run inference. The face's embedding is already persisted, and
26716
+ * matching is pure arithmetic over vectors — so a recompute is a read plus a
26717
+ * few thousand dot products, not a pipeline pass. Re-embedding is a SEPARATE,
26718
+ * explicitly-requested action (see the action's `reembed` flag): a button
26719
+ * labelled "recompute" must never silently spend GPU.
26720
+ *
26721
+ * ## The model gate is not an optimisation, it is the difference between a
26722
+ * number and noise
26723
+ *
26724
+ * A cosine between vectors from two different face models is a well-formed
26725
+ * float with no meaning. `face-matcher` has always refused to compare across
26726
+ * `modelId`, which means a face embedded before a model flip scores against
26727
+ * NOTHING — and the trap is that "nothing" renders identically to "no match".
26728
+ * This module names that state (`model-mismatch`) so the UI can say "this face
26729
+ * is in an old feature space" instead of showing 0% and letting the operator
26730
+ * conclude the person is not there.
26731
+ */
26732
+ /** The empty report every "cannot score" path returns, so shape never varies. */
26733
+ function emptyReport(verdict, input, comparable) {
26734
+ return {
26735
+ verdict,
26736
+ comparable,
26737
+ probeModelId: input.probe.modelId,
26738
+ clusterModelId: input.clusterModelId,
26739
+ params: input.params,
26740
+ identities: []
26741
+ };
26742
+ }
26743
+ /**
26744
+ * Decide the verdict from the ELIGIBLE ranking, using exactly the gates
26745
+ * `matchEmbedding` uses — it is called here rather than re-implemented, so the
26746
+ * "would auto-assign" claim is the matcher's own answer and cannot drift.
26747
+ */
26748
+ function decide(eligible, input) {
26749
+ const best = eligible[0];
26750
+ if (best === void 0) return {
26751
+ verdict: "no-eligible-identity",
26752
+ winnerId: null
26753
+ };
26754
+ const match = matchEmbedding(input.probe, input.gallery, {
26755
+ threshold: input.params.threshold,
26756
+ margin: input.params.margin,
26757
+ minIdentitySamples: input.params.minIdentitySamples
26758
+ });
26759
+ if (match !== null) return {
26760
+ verdict: "auto-assignable",
26761
+ winnerId: match.identityId
26762
+ };
26763
+ if (best.score >= input.params.threshold) return {
26764
+ verdict: "ambiguous",
26765
+ winnerId: null
26766
+ };
26767
+ if (best.score >= input.params.suggestionMinCosine) return {
26768
+ verdict: "suggestion",
26769
+ winnerId: null
26770
+ };
26771
+ return {
26772
+ verdict: "below-threshold",
26773
+ winnerId: null
26774
+ };
26775
+ }
26776
+ /**
26777
+ * Score one probe against the gallery as it stands, and say what the pipeline
26778
+ * would do with it. Pure: every input is passed in, nothing is read or written.
26779
+ */
26780
+ function buildFaceRescoreReport(input) {
26781
+ if (input.probe.modelId !== input.clusterModelId) return emptyReport("model-mismatch", input, false);
26782
+ const ranked = rankIdentities(input.probe, input.gallery, input.params.minIdentitySamples);
26783
+ if (ranked.length === 0) return emptyReport("empty-gallery", input, true);
26784
+ const eligible = ranked.filter((r) => r.eligible);
26785
+ const { verdict, winnerId } = decide(eligible, input);
26786
+ const identities = ranked.map((r) => ({
26787
+ identityId: r.identityId,
26788
+ name: input.identityNames.get(r.identityId) ?? r.identityId,
26789
+ score: r.score,
26790
+ sampleCount: r.sampleCount,
26791
+ eligible: r.eligible,
26792
+ meetsThreshold: r.score >= input.params.threshold,
26793
+ wouldAutoAssign: r.identityId === winnerId
26794
+ }));
26795
+ const [best, second] = eligible;
26796
+ return {
26797
+ verdict,
26798
+ comparable: true,
26799
+ probeModelId: input.probe.modelId,
26800
+ clusterModelId: input.clusterModelId,
26801
+ params: input.params,
26802
+ identities,
26803
+ ...best !== void 0 && second !== void 0 ? { runnerUpGap: best.score - second.score } : {}
26804
+ };
26805
+ }
26806
+ //#endregion
26807
+ //#region src/pipeline-analytics/face-rescore-actions.ts
26808
+ /**
26809
+ * face-rescore-actions — "score this face against the gallery AS IT IS NOW".
26810
+ *
26811
+ * ## Why an `addons.custom` action and not a `faceGallery` cap method
26812
+ *
26813
+ * Same reason `photo.*`, `embedding.*` and `debug.*` ride this bridge: a cap
26814
+ * method's wire schema lives in `@camstack/types`, which travels inside the
26815
+ * `@camstack/server` closure, so a new one is not callable until a server train
26816
+ * ships — however current the addon is. Here the schema lives in the addon's
26817
+ * own dist and the hub forwards an opaque envelope, so `camstack deploy` is the
26818
+ * whole delivery.
26819
+ *
26820
+ * ## Why re-embedding is an explicit flag and not a fallback
26821
+ *
26822
+ * `face.rescoreTrack` with `reembed: false` (the default) reads a persisted
26823
+ * vector and does arithmetic — microseconds, no GPU, no node hop. With
26824
+ * `reembed: true` it runs the `face-embedding` step on a cluster node. Those
26825
+ * are different enough in cost and in consequence (a re-embed WRITES the face
26826
+ * row) that a button labelled "recompute" must not choose between them on the
26827
+ * operator's behalf. The report says whether a re-embed is possible
26828
+ * (`reembedAvailable`); the operator asks for it.
26829
+ *
26830
+ * See `face-rescore.ts` for why the number already on the track is stale by
26831
+ * construction — that is the whole reason this action exists.
26832
+ */
26833
+ var FaceRescoreVerdictSchema = _enum([
26834
+ "auto-assignable",
26835
+ "ambiguous",
26836
+ "suggestion",
26837
+ "below-threshold",
26838
+ "no-eligible-identity",
26839
+ "empty-gallery",
26840
+ "model-mismatch"
26841
+ ]);
26842
+ var FaceRescoreParamsSchema = object({
26843
+ threshold: number(),
26844
+ margin: number(),
26845
+ minIdentitySamples: number().int(),
26846
+ /** DERIVED from `threshold`, never stored (D222) — see `suggestionBandFactor`. */
26847
+ suggestionMinCosine: number(),
26848
+ /** The fraction of `threshold` that `suggestionMinCosine` is, so the report
26849
+ * states the RULE and not only its current result. */
26850
+ suggestionBandFactor: number()
26851
+ });
26852
+ var FaceRescoreIdentityRowSchema = object({
26853
+ identityId: string(),
26854
+ name: string(),
26855
+ /** Raw cosine. The UI formats; the wire stays in the matcher's units. */
26856
+ score: number(),
26857
+ sampleCount: number().int(),
26858
+ eligible: boolean(),
26859
+ meetsThreshold: boolean(),
26860
+ wouldAutoAssign: boolean()
26861
+ });
26862
+ /**
26863
+ * Why a re-embed did not happen, in the addon's vocabulary.
26864
+ *
26865
+ * `not-a-template` is the one that matters and the one that is easy to get
26866
+ * wrong: a face's stored crop is the 112×112 ArcFace template ONLY when the
26867
+ * detection carried landmarks. When it does not, the crop is a padded bbox cut
26868
+ * — and feeding THAT to `face-embedding` as a whole frame produces a vector
26869
+ * that is plausible and useless, exactly the trap `face-reembed-pass.ts`
26870
+ * documents from the other direction. Refusing is the honest answer.
26871
+ */
26872
+ var FaceReembedRefusalSchema = _enum([
26873
+ "not-requested",
26874
+ "no-pixels",
26875
+ "not-a-template",
26876
+ "no-capable-node",
26877
+ "failed",
26878
+ "not-persisted"
26879
+ ]);
26880
+ var FaceReembedReportSchema = object({
26881
+ attempted: boolean(),
26882
+ /** True only when a NEW vector was embedded AND read back off the face row. */
26883
+ succeeded: boolean(),
26884
+ reason: FaceReembedRefusalSchema.optional(),
26885
+ detail: string().optional(),
26886
+ /** The model the re-embed pinned — the cluster row's, never a substitute. */
26887
+ modelId: string().optional()
26888
+ });
26889
+ var faceRescoreActions = defineCustomActions({
26890
+ /**
26891
+ * Read-only by default. `admin` because the report names every enrolled
26892
+ * person and how well an arbitrary face matches them — the gallery's shape,
26893
+ * which is not a viewer-level fact.
26894
+ */
26895
+ "face.rescoreTrack": customAction(object({
26896
+ deviceId: number().int(),
26897
+ trackId: string().min(1),
26898
+ /**
26899
+ * Run `face-embedding` over the face's stored aligned template first, so a
26900
+ * face left in a superseded feature space becomes comparable again. Costs a
26901
+ * node round-trip and WRITES the face row. Never implicit.
26902
+ */
26903
+ reembed: boolean().default(false)
26904
+ }), object({
26905
+ faceId: string(),
26906
+ deviceId: number().int(),
26907
+ trackId: string(),
26908
+ /** The face's current assignment, so the UI can mark "already this person". */
26909
+ assigned: boolean(),
26910
+ assignedIdentityId: string().optional(),
26911
+ /** What the track has been SHOWING until now — persisted at detection time.
26912
+ * Returned so the UI can put the stale number beside the fresh one. */
26913
+ storedSuggestedIdentityId: string().optional(),
26914
+ storedSuggestedMatchScore: number().optional(),
26915
+ verdict: FaceRescoreVerdictSchema,
26916
+ comparable: boolean(),
26917
+ probeModelId: string(),
26918
+ clusterModelId: string(),
26919
+ params: FaceRescoreParamsSchema,
26920
+ identities: array(FaceRescoreIdentityRowSchema).readonly(),
26921
+ runnerUpGap: number().optional(),
26922
+ /** Whether a `reembed: true` retry could actually do anything. Lets the UI
26923
+ * offer the escape hatch only when it exists. */
26924
+ reembedAvailable: boolean(),
26925
+ reembed: FaceReembedReportSchema
26926
+ }), {
26927
+ kind: "mutation",
26928
+ auth: "admin"
26929
+ }) });
26930
+ //#endregion
26931
+ //#region src/pipeline-analytics/pipeline/closed-row-media-gate.ts
26932
+ var DEFAULT_LOG_MIN_GAP_MS = 6e4;
26933
+ var DEFAULT_MAX_TRACKED = 512;
26934
+ var WRITE = {
26935
+ refuse: false,
26936
+ log: false,
26937
+ closedAgeMs: 0,
26938
+ suppressed: 0
26939
+ };
26940
+ var ClosedRowMediaGate = class {
26941
+ logMinGapMs;
26942
+ maxTracked;
26943
+ /** trackId → sampling state, insertion-ordered so the oldest evicts first. */
26944
+ sampling = /* @__PURE__ */ new Map();
26945
+ constructor(options = {}) {
26946
+ this.logMinGapMs = options.logMinGapMs ?? DEFAULT_LOG_MIN_GAP_MS;
26947
+ this.maxTracked = options.maxTracked ?? DEFAULT_MAX_TRACKED;
26948
+ }
26949
+ /**
26950
+ * @param rowClosedAt when the store closed this track's row, or `undefined`
26951
+ * when it holds no such record — which always means WRITE.
26952
+ */
26953
+ evaluate(trackId, rowClosedAt, nowMs) {
26954
+ if (rowClosedAt === void 0) return WRITE;
26955
+ const closedAgeMs = Math.max(0, nowMs - rowClosedAt);
26956
+ const existing = this.sampling.get(trackId);
26957
+ if (existing === void 0) {
26958
+ this.remember(trackId, {
26959
+ lastLoggedAt: nowMs,
26960
+ suppressed: 0
26961
+ });
26962
+ return {
26963
+ refuse: true,
26964
+ log: true,
26965
+ closedAgeMs,
26966
+ suppressed: 0
26967
+ };
26968
+ }
26969
+ if (nowMs - existing.lastLoggedAt < this.logMinGapMs) {
26970
+ existing.suppressed += 1;
26971
+ return {
26972
+ refuse: true,
26973
+ log: false,
26974
+ closedAgeMs,
26975
+ suppressed: existing.suppressed
26976
+ };
26977
+ }
26978
+ const suppressed = existing.suppressed;
26979
+ existing.lastLoggedAt = nowMs;
26980
+ existing.suppressed = 0;
26981
+ return {
26982
+ refuse: true,
26983
+ log: true,
26984
+ closedAgeMs,
26985
+ suppressed
26986
+ };
26987
+ }
26988
+ /** Drop all sampling state (addon dispose). */
26989
+ clear() {
26990
+ this.sampling.clear();
26991
+ }
26992
+ remember(trackId, state) {
26993
+ this.sampling.set(trackId, state);
26994
+ if (this.sampling.size <= this.maxTracked) return;
26995
+ const oldest = this.sampling.keys().next();
26996
+ if (!oldest.done) this.sampling.delete(oldest.value);
26997
+ }
26998
+ };
26999
+ //#endregion
26514
27000
  //#region src/pipeline-analytics/pipeline/edge-clear.ts
26515
27001
  /**
26516
27002
  * edge-clear — pure geometry for "is the subject fully in frame?" best-frame
@@ -35295,6 +35781,210 @@ async function resolveDefaultEventMedia(deps) {
35295
35781
  return pickTrackFallbackMedia(await deps.listTrackMedia(trackId)) ?? null;
35296
35782
  }
35297
35783
  //#endregion
35784
+ //#region src/pipeline-analytics/face-settings.ts
35785
+ /**
35786
+ * Per-device face-recognition settings. Cascade: a per-device override on top
35787
+ * of the global default, resolved per field (an invalid/missing value falls
35788
+ * back to its default — parse never throws). Mirrors `audio-detection-settings`.
35789
+ */
35790
+ var FaceSettingsSchema = object({
35791
+ /**
35792
+ * GLOBAL master switch only — a temporary kill for the whole face-recognition
35793
+ * post-processor. On by default; the recognizer runs whenever the per-device
35794
+ * detection pipeline produces face embeddings (the pipeline steps ARE the
35795
+ * per-camera control). Not a per-device override — see
35796
+ * `resolveGlobalFaceEnabled` / `getDeviceSettingsContribution` (the field is
35797
+ * stripped from the per-device schema).
35798
+ */
35799
+ enabled: boolean().default(true),
35800
+ /**
35801
+ * Cosine similarity (on L2-normalized arcface vectors) required to match.
35802
+ *
35803
+ * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
35804
+ * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
35805
+ * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
35806
+ * left almost nothing recognisable. That retune lived only as a RUNTIME
35807
+ * override until 2026-08-19; it is the schema default now, because a default
35808
+ * nobody runs is a decision nobody made.
35809
+ *
35810
+ * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
35811
+ * quality, where most of what reaches ArcFace is not a correctly aligned
35812
+ * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
35813
+ * aligned, 18% marginal, 62% misaligned or containing no detectable face).
35814
+ * On correctly aligned crops the SAME models separate at a far lower
35815
+ * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
35816
+ * 0.175, best accuracy 98.3% at threshold 0.28.
35817
+ *
35818
+ * Recalibration plan, in this order:
35819
+ * 1. land the landmark-precision gate (the alignment fix),
35820
+ * 2. re-enrol the gallery from crops produced AFTER it,
35821
+ * 3. re-measure genuine/impostor over a week of post-fix traffic,
35822
+ * 4. expect to lower this toward ~0.30, and `margin` with it.
35823
+ * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
35824
+ * scores ~0.9 against everything, so the degenerate population sits ABOVE
35825
+ * any threshold you could pick — the knob cannot reach it.
35826
+ */
35827
+ similarityThreshold: number().min(0).max(1).default(.55),
35828
+ /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
35829
+ * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
35830
+ * same story and the same recalibration plan as {@link similarityThreshold}. */
35831
+ margin: number().min(0).max(1).default(.12),
35832
+ /** Minimum face-detection confidence for a face to be considered. */
35833
+ minFaceConfidence: number().min(0).max(1).default(.5),
35834
+ /**
35835
+ * Minimum face bbox size (px, shorter side, NATIVE scale when the runner
35836
+ * measured it, else detection-frame space) for a face to be DETECTED and
35837
+ * COLLECTED into the recent-faces buffer. Below this the face is dropped
35838
+ * BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
35839
+ * NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
35840
+ */
35841
+ minFacePx: number().min(0).default(30),
35842
+ /**
35843
+ * Minimum face short side (px, NATIVE scale when the runner measured it, else
35844
+ * detection-frame space) for a collected face to be eligible for AUTO-MATCH
35845
+ * (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
35846
+ * between `minFacePx` and this floor are still detected, cropped, and stored
35847
+ * in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
35848
+ * an identity. ArcFace embeddings below ~48px are unreliable and drove the
35849
+ * observed false positives (2026-07-23 face-quality batch). A face below this
35850
+ * floor keeps `recognizedIdentityId` UNSET (fail-safe).
35851
+ */
35852
+ recognitionMinFacePx: number().min(0).default(48),
35853
+ /**
35854
+ * Minimum enrolled-sample count an identity must have before it can be an
35855
+ * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
35856
+ * enrolment attracted 81% of matches); identities below this are excluded from
35857
+ * automatic matching until more samples are enrolled (#26.3).
35858
+ */
35859
+ minIdentitySamples: number().int().min(1).default(2),
35860
+ /** Frames an identity must be confirmed before a track is assigned. Floor of
35861
+ * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
35862
+ * such a value falls back to the default). */
35863
+ confirmFrames: number().int().min(1).default(3),
35864
+ /**
35865
+ * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35866
+ * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35867
+ * the gate.
35868
+ *
35869
+ * This exists because a degenerate crop does not score LOW, it scores HIGH:
35870
+ * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35871
+ * faces exceeded cosine 0.90 and the worst 40% of the face population had
35872
+ * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35873
+ * similarity threshold can reach that — it sits above every threshold — so the
35874
+ * only defence is to refuse the embedding before it is stored or compared.
35875
+ *
35876
+ * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
35877
+ * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
35878
+ * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
35879
+ * 5.25) regardless of size, grey level or blur, while information-free crops
35880
+ * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
35881
+ * 93.2% of degenerate ones.
35882
+ *
35883
+ * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
35884
+ * magnitude has never been observable here — `result-assembler` discarded it
35885
+ * one line after computing it. The field and its logging ship first; set a
35886
+ * real bound once a week of live `face: embedding magnitude` lines says where
35887
+ * this cluster's populations actually sit. Enabling a gate against a number
35888
+ * nobody has measured in production is how recognition silently stops.
35889
+ */
35890
+ maxEmbeddingMagnitude: number().min(0).default(0),
35891
+ /** Max buffered faces kept per device. */
35892
+ bufferMaxPerDevice: number().int().min(0).default(50)
35893
+ });
35894
+ var FACE_DEFAULTS = FaceSettingsSchema.parse({});
35895
+ function resolveFaceSettings(raw) {
35896
+ const pick = (key) => {
35897
+ const parsed = FaceSettingsSchema.shape[key].safeParse(raw[key]);
35898
+ return parsed.success ? parsed.data : FACE_DEFAULTS[key];
35899
+ };
35900
+ return {
35901
+ enabled: pick("enabled"),
35902
+ similarityThreshold: pick("similarityThreshold"),
35903
+ margin: pick("margin"),
35904
+ minFaceConfidence: pick("minFaceConfidence"),
35905
+ minFacePx: pick("minFacePx"),
35906
+ recognitionMinFacePx: pick("recognitionMinFacePx"),
35907
+ minIdentitySamples: pick("minIdentitySamples"),
35908
+ maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
35909
+ confirmFrames: pick("confirmFrames"),
35910
+ bufferMaxPerDevice: pick("bufferMaxPerDevice")
35911
+ };
35912
+ }
35913
+ /**
35914
+ * The suggestion band opens at 80% of the assignment threshold.
35915
+ *
35916
+ * ## Why derived and not a knob
35917
+ *
35918
+ * `suggestionMinCosine` used to be its own operator-overridable field. Two
35919
+ * independent numbers described ONE band, so they could contradict each other
35920
+ * — and on the live hub they did. The 2026-08-19 retune lowered
35921
+ * `similarityThreshold` to 0.45 and left the suggestion floor at its 0.50
35922
+ * default, which INVERTS the band: `[0.50, 0.45)` is empty, so from that moment
35923
+ * the recognizer could not produce a single suggestion. Nothing errored,
35924
+ * nothing logged, and the form showed two plausible numbers side by side. That
35925
+ * is the D62 shape — a second authority over a fact that already had one — and
35926
+ * the fix is the same: delete the second authority rather than police it.
35927
+ *
35928
+ * Derived, the band cannot invert for ANY threshold, which is the property
35929
+ * `face-settings.spec.ts` pins over the whole domain rather than at samples.
35930
+ *
35931
+ * ## Why 0.8
35932
+ *
35933
+ * The operator's number, and it matches what the band was for: a near-miss is
35934
+ * a probe that scored within a fifth of the bar. At the schema default (0.55)
35935
+ * it reproduces a 0.44 floor — close to the 0.50 the band shipped with in
35936
+ * 2026-07 — and it TRACKS every future retune, which is the whole point: the
35937
+ * recalibration plan on {@link FaceSettingsSchema.shape.similarityThreshold}
35938
+ * expects the threshold to fall toward ~0.30 once alignment lands, and a fixed
35939
+ * floor would have to be remembered and moved by hand each time.
35940
+ */
35941
+ var SUGGESTION_BAND_FACTOR = .8;
35942
+ /** Decimal places the derived floor is rounded to. `0.8 * 0.45` is
35943
+ * `0.36000000000000004` in IEEE754, and an operator reading "36.000000000004%"
35944
+ * in a report has been told the number is untrustworthy. */
35945
+ var DERIVED_PRECISION = 1e4;
35946
+ /**
35947
+ * The cosine floor of the suggestion band for a given assignment threshold.
35948
+ *
35949
+ * INVARIANT, total over `[0, 1]`: the result is never ABOVE the threshold, and
35950
+ * is strictly below it for every threshold > 0 — so the band
35951
+ * `[floor, threshold)` can never be empty. Rounding is applied only when it
35952
+ * preserves that (for a threshold small enough that rounding would reach it,
35953
+ * the exact product is returned instead).
35954
+ */
35955
+ function deriveSuggestionMinCosine(similarityThreshold) {
35956
+ const exact = similarityThreshold * SUGGESTION_BAND_FACTOR;
35957
+ const rounded = Math.round(exact * DERIVED_PRECISION) / DERIVED_PRECISION;
35958
+ return rounded < similarityThreshold ? rounded : exact;
35959
+ }
35960
+ /**
35961
+ * The settings key that used to hold the suggestion floor. Named once, here,
35962
+ * because the ONLY code allowed to mention it now is the migration that removes
35963
+ * it from a store.
35964
+ */
35965
+ var STORED_SUGGESTION_MIN_COSINE_KEY = "suggestionMinCosine";
35966
+ /**
35967
+ * Plan the removal of a persisted suggestion floor from one settings blob.
35968
+ *
35969
+ * `null` = nothing to do, which is the steady state after the first pass and
35970
+ * for every store that never had one. Absence of the key IS the migration
35971
+ * marker — no flag, no version row, and re-running is a no-op.
35972
+ *
35973
+ * The patch uses `undefined`, never `null`: the settings merge preserves an
35974
+ * explicit `null` as a STORED value, which would leave exactly the
35975
+ * accepted-but-ignored residue this exists to remove, while `undefined`
35976
+ * survives the merge as a key with no value and is dropped by the JSON
35977
+ * serialisation the blob round-trips through.
35978
+ */
35979
+ function planStoredSuggestionPurge(raw) {
35980
+ const was = raw[STORED_SUGGESTION_MIN_COSINE_KEY];
35981
+ if (was === void 0) return null;
35982
+ return {
35983
+ was,
35984
+ patch: { [STORED_SUGGESTION_MIN_COSINE_KEY]: void 0 }
35985
+ };
35986
+ }
35987
+ //#endregion
35298
35988
  //#region src/pipeline-analytics/pipeline/embedding-magnitude-gate.ts
35299
35989
  /**
35300
35990
  * `null` = accept. A verdict = refuse, carrying both numbers so the caller can
@@ -35542,7 +36232,7 @@ var FaceRecognizer = class {
35542
36232
  embedding: c.embedding,
35543
36233
  modelId: c.embeddingModelId
35544
36234
  }, this.gallery, {
35545
- threshold: settings.suggestionMinCosine,
36235
+ threshold: deriveSuggestionMinCosine(settings.similarityThreshold),
35546
36236
  margin: settings.margin,
35547
36237
  minIdentitySamples: settings.minIdentitySamples
35548
36238
  });
@@ -35835,148 +36525,6 @@ var FaceRecognizer = class {
35835
36525
  }
35836
36526
  };
35837
36527
  //#endregion
35838
- //#region src/pipeline-analytics/face-settings.ts
35839
- /**
35840
- * Per-device face-recognition settings. Cascade: a per-device override on top
35841
- * of the global default, resolved per field (an invalid/missing value falls
35842
- * back to its default — parse never throws). Mirrors `audio-detection-settings`.
35843
- */
35844
- var FaceSettingsSchema = object({
35845
- /**
35846
- * GLOBAL master switch only — a temporary kill for the whole face-recognition
35847
- * post-processor. On by default; the recognizer runs whenever the per-device
35848
- * detection pipeline produces face embeddings (the pipeline steps ARE the
35849
- * per-camera control). Not a per-device override — see
35850
- * `resolveGlobalFaceEnabled` / `getDeviceSettingsContribution` (the field is
35851
- * stripped from the per-device schema).
35852
- */
35853
- enabled: boolean().default(true),
35854
- /**
35855
- * Cosine similarity (on L2-normalized arcface vectors) required to match.
35856
- *
35857
- * History: 0.55→0.62 on 2026-07-23, then back to 0.55 on 2026-08-18 once the
35858
- * dead `assignUniquePerFrame` margin and the poisoned gallery were fixed —
35859
- * with both repaired, genuine probes scored median 0.38 / p90 0.51, so 0.62
35860
- * left almost nothing recognisable. That retune lived only as a RUNTIME
35861
- * override until 2026-08-19; it is the schema default now, because a default
35862
- * nobody runs is a decision nobody made.
35863
- *
35864
- * ⚠ NOT the end state. 0.55 is the least-bad value for the CURRENT crop
35865
- * quality, where most of what reaches ArcFace is not a correctly aligned
35866
- * face (measured 2026-08-19 over 60 live crops pulled from the hub: 20%
35867
- * aligned, 18% marginal, 62% misaligned or containing no detectable face).
35868
- * On correctly aligned crops the SAME models separate at a far lower
35869
- * operating point — 300 LFW pairs gave genuine median 0.605 / impostor p90
35870
- * 0.175, best accuracy 98.3% at threshold 0.28.
35871
- *
35872
- * Recalibration plan, in this order:
35873
- * 1. land the landmark-precision gate (the alignment fix),
35874
- * 2. re-enrol the gallery from crops produced AFTER it,
35875
- * 3. re-measure genuine/impostor over a week of post-fix traffic,
35876
- * 4. expect to lower this toward ~0.30, and `margin` with it.
35877
- * Lowering it BEFORE step 1 would be actively harmful: a misaligned crop
35878
- * scores ~0.9 against everything, so the degenerate population sits ABOVE
35879
- * any threshold you could pick — the knob cannot reach it.
35880
- */
35881
- similarityThreshold: number().min(0).max(1).default(.55),
35882
- /** Reject ambiguous matches: require best − secondBest ≥ margin. 0.10→0.15
35883
- * (2026-07-23), then →0.12 (2026-08-18) alongside the threshold retune —
35884
- * same story and the same recalibration plan as {@link similarityThreshold}. */
35885
- margin: number().min(0).max(1).default(.12),
35886
- /** Minimum face-detection confidence for a face to be considered. */
35887
- minFaceConfidence: number().min(0).max(1).default(.5),
35888
- /**
35889
- * Minimum face bbox size (px, shorter side, NATIVE scale when the runner
35890
- * measured it, else detection-frame space) for a face to be DETECTED and
35891
- * COLLECTED into the recent-faces buffer. Below this the face is dropped
35892
- * BEFORE ingest/enrolment (#26.1). This is the DETECTION/collection floor —
35893
- * NOT the auto-assignment floor (see {@link recognitionMinFacePx}).
35894
- */
35895
- minFacePx: number().min(0).default(30),
35896
- /**
35897
- * Minimum face short side (px, NATIVE scale when the runner measured it, else
35898
- * detection-frame space) for a collected face to be eligible for AUTO-MATCH
35899
- * (identity assignment). Separate from — and ≥ — {@link minFacePx}: faces
35900
- * between `minFacePx` and this floor are still detected, cropped, and stored
35901
- * in the buffer (available for MANUAL assignment), but are NEVER auto-assigned
35902
- * an identity. ArcFace embeddings below ~48px are unreliable and drove the
35903
- * observed false positives (2026-07-23 face-quality batch). A face below this
35904
- * floor keeps `recognizedIdentityId` UNSET (fail-safe).
35905
- */
35906
- recognitionMinFacePx: number().min(0).default(48),
35907
- /**
35908
- * Lower cosine bound of the SUGGESTION band (2026-07-24). A face whose best
35909
- * gallery match MISSES auto-assignment but is still plausible surfaces as a
35910
- * SUGGESTION (persisted `suggestedIdentityId`/`suggestedMatchScore`, never an
35911
- * assignment) when EITHER: its match cosine is in [`suggestionMinCosine`,
35912
- * `similarityThreshold`) AND its face clears the recognition size floor; OR its
35913
- * cosine is ≥ `similarityThreshold` but the face is below the recognition floor
35914
- * (blocked ONLY by size). Below this cosine nothing is suggested. Operator-
35915
- * overridable per field, like the other face thresholds.
35916
- */
35917
- suggestionMinCosine: number().min(0).max(1).default(.5),
35918
- /**
35919
- * Minimum enrolled-sample count an identity must have before it can be an
35920
- * AUTO-MATCH target. A single-sample identity is an unreliable sink (one noisy
35921
- * enrolment attracted 81% of matches); identities below this are excluded from
35922
- * automatic matching until more samples are enrolled (#26.3).
35923
- */
35924
- minIdentitySamples: number().int().min(1).default(2),
35925
- /** Frames an identity must be confirmed before a track is assigned. Floor of
35926
- * 1 (0 confirmations would assign on a single noisy frame — nonsensical;
35927
- * such a value falls back to the default). */
35928
- confirmFrames: number().int().min(1).default(3),
35929
- /**
35930
- * Upper bound on the RAW ArcFace feature magnitude a face may have and still
35931
- * be embedded into the buffer / matched / enrolled. `0` (the default) DISABLES
35932
- * the gate.
35933
- *
35934
- * This exists because a degenerate crop does not score LOW, it scores HIGH:
35935
- * measured on this cluster 2026-08-18/19, 28.8% of all pairs of DIFFERENT
35936
- * faces exceeded cosine 0.90 and the worst 40% of the face population had
35937
- * collapsed onto effectively one vector (mean pairwise cosine 0.976). No
35938
- * similarity threshold can reach that — it sits above every threshold — so the
35939
- * only defence is to refuse the embedding before it is stored or compared.
35940
- *
35941
- * Direction: for this model the DEGENERATE crops carry the LARGER norm (the
35942
- * inverse of the MagFace convention), hence a maximum. Over 140 LFW faces
35943
- * through this exact model and preprocessing, usable crops sat at ≈4.4 (p95
35944
- * 5.25) regardless of size, grey level or blur, while information-free crops
35945
- * jumped to 10–16; a bound of 6.0 kept 97.6% of usable crops and rejected
35946
- * 93.2% of degenerate ones.
35947
- *
35948
- * Default 0 ON PURPOSE. Those numbers are LFW's, not this cluster's, and the
35949
- * magnitude has never been observable here — `result-assembler` discarded it
35950
- * one line after computing it. The field and its logging ship first; set a
35951
- * real bound once a week of live `face: embedding magnitude` lines says where
35952
- * this cluster's populations actually sit. Enabling a gate against a number
35953
- * nobody has measured in production is how recognition silently stops.
35954
- */
35955
- maxEmbeddingMagnitude: number().min(0).default(0),
35956
- /** Max buffered faces kept per device. */
35957
- bufferMaxPerDevice: number().int().min(0).default(50)
35958
- });
35959
- var FACE_DEFAULTS = FaceSettingsSchema.parse({});
35960
- function resolveFaceSettings(raw) {
35961
- const pick = (key) => {
35962
- const parsed = FaceSettingsSchema.shape[key].safeParse(raw[key]);
35963
- return parsed.success ? parsed.data : FACE_DEFAULTS[key];
35964
- };
35965
- return {
35966
- enabled: pick("enabled"),
35967
- similarityThreshold: pick("similarityThreshold"),
35968
- margin: pick("margin"),
35969
- minFaceConfidence: pick("minFaceConfidence"),
35970
- minFacePx: pick("minFacePx"),
35971
- recognitionMinFacePx: pick("recognitionMinFacePx"),
35972
- suggestionMinCosine: pick("suggestionMinCosine"),
35973
- minIdentitySamples: pick("minIdentitySamples"),
35974
- maxEmbeddingMagnitude: pick("maxEmbeddingMagnitude"),
35975
- confirmFrames: pick("confirmFrames"),
35976
- bufferMaxPerDevice: pick("bufferMaxPerDevice")
35977
- };
35978
- }
35979
- //#endregion
35980
36528
  //#region src/pipeline-analytics/location-aware-media-storage.ts
35981
36529
  /**
35982
36530
  * Location-aware blob storage for event media (entity-routing spec, Phase 3).
@@ -46163,7 +46711,8 @@ function parseCloseReason(value) {
46163
46711
  }
46164
46712
  var DEFAULT_CONFIG = {
46165
46713
  ttlMs: 3e4,
46166
- maxPositionHistory: 300
46714
+ maxPositionHistory: 300,
46715
+ closedRowMemory: 4096
46167
46716
  };
46168
46717
  /** `queryRecent` page-size defaults (mirrors the cap input's bounds). */
46169
46718
  var RECENT_DEFAULT_LIMIT = 200;
@@ -46480,6 +47029,20 @@ function cloneTrack(t) {
46480
47029
  }
46481
47030
  var TrackStore = class {
46482
47031
  active = /* @__PURE__ */ new Map();
47032
+ /**
47033
+ * trackId → the instant THIS store closed (or deleted) the track's row.
47034
+ *
47035
+ * The `active` map alone cannot answer "is this row still open?": a sighting
47036
+ * for an already-closed id re-creates a fresh active entry (`upsert`), which
47037
+ * is exactly how a person track closed 4.4 minutes earlier received a dog's
47038
+ * `thumbnail`, `keyFrame` and `lastFrame` on 2026-08-21. This is POSITIVE
47039
+ * knowledge of a close the store performed — an id it has never heard of, or
47040
+ * one evicted by the bound below, reads as WRITABLE (D49): losing a live
47041
+ * track's media is worse than tolerating a late write.
47042
+ *
47043
+ * Insertion-ordered and bounded by `closedRowMemory` (oldest evicted first).
47044
+ */
47045
+ closedRows = /* @__PURE__ */ new Map();
46483
47046
  config;
46484
47047
  logger;
46485
47048
  store;
@@ -46501,6 +47064,25 @@ var TrackStore = class {
46501
47064
  indexes: [...TRACKS_INDEXES]
46502
47065
  });
46503
47066
  }
47067
+ /**
47068
+ * When this store CLOSED the track's row (TTL expiry, early close, or the
47069
+ * cascade delete), or `undefined` when it holds no such record.
47070
+ *
47071
+ * `undefined` is not "still open" — it is "no evidence of a close", and every
47072
+ * caller must treat it as permission to write (D49). Read by the frame path's
47073
+ * best-media gate; in-memory and infallible by construction.
47074
+ */
47075
+ rowClosedAt(trackId) {
47076
+ return this.closedRows.get(trackId);
47077
+ }
47078
+ /** Remember a close. Bounded, oldest-first — an evicted entry fails OPEN. */
47079
+ noteRowClosed(trackId, atMs) {
47080
+ this.closedRows.delete(trackId);
47081
+ this.closedRows.set(trackId, atMs);
47082
+ if (this.closedRows.size <= this.config.closedRowMemory) return;
47083
+ const oldest = this.closedRows.keys().next();
47084
+ if (!oldest.done) this.closedRows.delete(oldest.value);
47085
+ }
46504
47086
  /** Create or update the track record for a sighting in this frame. */
46505
47087
  upsert(params) {
46506
47088
  const existing = this.active.get(params.trackId);
@@ -46699,6 +47281,7 @@ var TrackStore = class {
46699
47281
  });
46700
47282
  }
46701
47283
  this.active.delete(trackId);
47284
+ this.noteRowClosed(trackId, nowMs);
46702
47285
  expired.push(record);
46703
47286
  }
46704
47287
  return expired;
@@ -47066,6 +47649,7 @@ var TrackStore = class {
47066
47649
  }
47067
47650
  clearAll() {
47068
47651
  this.active.clear();
47652
+ this.closedRows.clear();
47069
47653
  }
47070
47654
  /**
47071
47655
  * Drop a single active track WITHOUT persisting it as a historical row. Used
@@ -47113,6 +47697,7 @@ var TrackStore = class {
47113
47697
  const record = cloneTrack(t);
47114
47698
  await this.persistCompleted(record);
47115
47699
  this.active.delete(trackId);
47700
+ this.noteRowClosed(trackId, Date.now());
47116
47701
  return true;
47117
47702
  }
47118
47703
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
@@ -47123,6 +47708,7 @@ var TrackStore = class {
47123
47708
  key: trackId
47124
47709
  });
47125
47710
  this.active.delete(trackId);
47711
+ this.noteRowClosed(trackId, Date.now());
47126
47712
  }
47127
47713
  /**
47128
47714
  * A page of persisted track ids for a device whose `lastSeen < cutoffMs`
@@ -52452,13 +53038,38 @@ function retagRetentionSection(sections) {
52452
53038
  } : s);
52453
53039
  }
52454
53040
  /**
53041
+ * Key of the read-only readout that replaced the `suggestionMinCosine` knob
53042
+ * (D222).
53043
+ *
53044
+ * It is `readonlyField: true`, so the form renders it display-only with no
53045
+ * mutation handler and it can never enter a settings patch — it carries a
53046
+ * derived value and must not become the second stored authority it replaced.
53047
+ * Its value is injected at hydrate time by the addon's `getGlobalSettings`
53048
+ * override, the same mechanism `detection-pipeline` uses for its live
53049
+ * model-substitution readout.
53050
+ */
53051
+ var SUGGESTION_THRESHOLD_READOUT_KEY = "suggestionThresholdReadout";
53052
+ /** Percent, at most one decimal, without a trailing `.0`. Cosines here are
53053
+ * two-decimal operator values; `36.0%` reads like false precision. */
53054
+ function asPercent(value) {
53055
+ return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
53056
+ }
53057
+ /**
53058
+ * The sentence beside the similarity threshold: what is being suggested from,
53059
+ * and the rule it comes from. Both, always — a number that moves whenever
53060
+ * another number moves reads as a bug unless the rule is on screen with it.
53061
+ */
53062
+ function formatSuggestionThresholdReadout(similarityThreshold) {
53063
+ return `${asPercent(deriveSuggestionMinCosine(similarityThreshold))} — ${asPercent(SUGGESTION_BAND_FACTOR)} of the ${asPercent(similarityThreshold)} match threshold`;
53064
+ }
53065
+ /**
52455
53066
  * Fields that live ONLY on the global settings page and must never surface in a
52456
53067
  * per-device contribution. The face-recognition `enabled` switch is the GLOBAL
52457
53068
  * master kill for the whole subsystem — per-camera face production is governed
52458
53069
  * by each device's detection-pipeline steps, so a per-device toggle would be
52459
53070
  * misleading. Keyed by section id → set of field keys to drop.
52460
53071
  */
52461
- var GLOBAL_ONLY_FIELDS = { "face-recognition": new Set(["enabled"]) };
53072
+ var GLOBAL_ONLY_FIELDS = { "face-recognition": new Set(["enabled", SUGGESTION_THRESHOLD_READOUT_KEY]) };
52462
53073
  /** Whole sections that are cluster-wide (global page only) and must NOT appear
52463
53074
  * in a per-device contribution. */
52464
53075
  var GLOBAL_ONLY_SECTIONS = new Set(["cluster-post-processing"]);
@@ -52940,7 +53551,7 @@ function buildGlobalSettingsSchema() {
52940
53551
  type: "number",
52941
53552
  key: "similarityThreshold",
52942
53553
  label: "Similarity threshold",
52943
- description: "Cosine similarity (0–1) required to match a face to a known identity. Higher = stricter.",
53554
+ description: "Cosine similarity (0–1) required to match a face to a known identity. Higher = stricter. This is the ONLY face-match bar: the suggestion band below it is derived from this value (80% of it), so it moves with every change you make here and can never end up above it.",
52944
53555
  min: 0,
52945
53556
  max: 1,
52946
53557
  step: .05,
@@ -52987,14 +53598,12 @@ function buildGlobalSettingsSchema() {
52987
53598
  unit: "px"
52988
53599
  },
52989
53600
  {
52990
- type: "number",
52991
- key: "suggestionMinCosine",
52992
- label: "Suggestion threshold",
52993
- description: "Lower cosine bound (0–1) of the SUGGESTION band. A plausible match that misses auto-assignment — cosine between this value and the similarity threshold with a large-enough face, or above the similarity threshold but below the recognition size floor — is surfaced as a one-tap SUGGESTION instead of being assigned. The face stays unassigned. Below this cosine nothing is suggested.",
52994
- min: 0,
52995
- max: 1,
52996
- step: .05,
52997
- default: FACE_DEFAULTS.suggestionMinCosine
53601
+ type: "text",
53602
+ key: SUGGESTION_THRESHOLD_READOUT_KEY,
53603
+ label: "Suggestion threshold (derived)",
53604
+ description: "Read-only. A plausible match that misses auto-assignment — a cosine between this value and the similarity threshold with a large-enough face, or above the similarity threshold but below the recognition size floor — is surfaced as a one-tap SUGGESTION instead of being assigned; the face stays unassigned. This floor is always 80% of the similarity threshold above, so lowering the threshold widens what is offered instead of silently closing the band.",
53605
+ readonlyField: true,
53606
+ default: ""
52998
53607
  },
52999
53608
  {
53000
53609
  type: "number",
@@ -54477,7 +55086,8 @@ var customActions = {
54477
55086
  ...debugActions,
54478
55087
  ...orphanAuditActions,
54479
55088
  ...viewerSettingsActions,
54480
- ...photoEnrollActions
55089
+ ...photoEnrollActions,
55090
+ ...faceRescoreActions
54481
55091
  };
54482
55092
  /**
54483
55093
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -54666,7 +55276,7 @@ var MOTIONLESS_MAX_PX = 8;
54666
55276
  var PHANTOM_CELL_PX = 32;
54667
55277
  /** How long a cell remembers its closes. */
54668
55278
  var PHANTOM_CELL_WINDOW_MS = 360 * 6e4;
54669
- var PipelineAnalyticsAddon = class extends BaseAddon {
55279
+ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
54670
55280
  /** Recent SHORT+MOTIONLESS track closes per `<device>:<class>:<cell>` —
54671
55281
  * see {@link noteShortMotionlessTrack}. Measurement only; each entry is
54672
55282
  * filtered against the 6-hour window on write, so it stays bounded. */
@@ -55198,6 +55808,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55198
55808
  * queued captures via the onClose hook) and `closeDevice` finally clears
55199
55809
  * per-track state on device removal (the two documented leaks). */
55200
55810
  residents = new TrackResidentState({ onClose: (trackId) => this.captureScheduler.cancelForTrack(trackId) });
55811
+ /** Defence in depth for the 2026-08-21 `b50e09f7` incident: a row the track
55812
+ * store has already CLOSED never earns another media target, whatever the
55813
+ * tracker keeps handing back. Reads the store's in-RAM close record only —
55814
+ * see `pipeline/closed-row-media-gate.ts` for why it can only fail OPEN. */
55815
+ closedRowMediaGate = new ClosedRowMediaGate();
55201
55816
  /** Windowed per-device aggregation of the "media capture" diagnostic — the
55202
55817
  * per-capture line is debug; a 60s per-counter SUM lands at info (~1
55203
55818
  * line/min/device instead of one per §5 cadence tick, see media-capture-log.ts). */
@@ -55535,13 +56150,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
55535
56150
  ...debugActions,
55536
56151
  ...orphanAuditActions,
55537
56152
  ...viewerSettingsActions,
55538
- ...photoEnrollActions
56153
+ ...photoEnrollActions,
56154
+ ...faceRescoreActions
55539
56155
  },
55540
56156
  actionHandlers: {
55541
56157
  ...ncHandlers,
55542
56158
  ...this.buildEmbeddingActionHandlers(),
55543
56159
  ...this.buildDebugActionHandlers(),
55544
56160
  ...this.buildPhotoEnrollActionHandlers(),
56161
+ ...this.buildFaceRescoreActionHandlers(),
55545
56162
  ...this.viewerSettingsSnapshots ? makeViewerSettingsActionHandlers(this.viewerSettingsSnapshots) : {}
55546
56163
  }
55547
56164
  } : {}
@@ -56413,6 +57030,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
56413
57030
  });
56414
57031
  },
56415
57032
  getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
57033
+ getSubjectTrail: async (deviceId, trackId) => {
57034
+ const dims = this.lastFrameDimsByDevice.get(deviceId);
57035
+ if (dims === void 0 || dims.w <= 0 || dims.h <= 0) return [];
57036
+ const track = stores.trackStore.getActiveByTrack(trackId) ?? await stores.trackStore.getPersistedByTrackId(trackId);
57037
+ if (track === null || track === void 0) return [];
57038
+ return track.positions.map((p) => ({
57039
+ timestamp: p.timestamp,
57040
+ bbox: {
57041
+ x: p.bbox.x / dims.w,
57042
+ y: p.bbox.y / dims.h,
57043
+ w: p.bbox.w / dims.w,
57044
+ h: p.bbox.h / dims.h
57045
+ }
57046
+ }));
57047
+ },
56416
57048
  getDeviceName,
56417
57049
  buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
56418
57050
  },
@@ -59090,6 +59722,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
59090
59722
  const targets = [];
59091
59723
  for (const t of tracked) {
59092
59724
  if (t.matchedThisFrame === false) continue;
59725
+ const verdict = this.closedRowMediaGate.evaluate(t.trackId, this.trackStore.rowClosedAt(t.trackId), Date.now());
59726
+ if (verdict.refuse) {
59727
+ if (verdict.log) this.ctx.logger.warn("track media refused: row already closed", {
59728
+ tags: { deviceId },
59729
+ meta: {
59730
+ trackId: t.trackId,
59731
+ className: t.className,
59732
+ closedAgeMs: verdict.closedAgeMs,
59733
+ suppressed: verdict.suppressed
59734
+ }
59735
+ });
59736
+ continue;
59737
+ }
59093
59738
  const lastSnap = this.trackStore.lastSnapshotAt(t.trackId);
59094
59739
  const dueSnapshot = media.saveThumbnails && evaluatePeriodicSnapshot({
59095
59740
  lastSnapshotAt: lastSnap,
@@ -59719,6 +60364,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
59719
60364
  try {
59720
60365
  const deviceIds = await this.retentionDeviceIds();
59721
60366
  await this.migrateRetiredRetentionSettings(deviceIds);
60367
+ await this.purgeStoredSuggestionThreshold(deviceIds);
59722
60368
  await this.runTrackRetentionSweep(deviceIds, now);
59723
60369
  const eventStore = this.eventStore;
59724
60370
  const sensorEventStore = this.sensorEventStore;
@@ -59838,6 +60484,96 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
59838
60484
  }
59839
60485
  }
59840
60486
  }
60487
+ /** Whether the GLOBAL store has been checked for a stored suggestion floor
60488
+ * this process, and which devices have. One announcement, not a heartbeat. */
60489
+ storedSuggestionCheckedGlobal = false;
60490
+ storedSuggestionCheckedDevices = /* @__PURE__ */ new Set();
60491
+ /**
60492
+ * One-time: announce the suggestion floor D222 retired, then unset it —
60493
+ * globally and per device.
60494
+ *
60495
+ * Removing the field from the schema already makes a stored value inert
60496
+ * (`resolveFaceSettings` reads declared fields only). That is not enough. A
60497
+ * number sitting in the blob is an authority waiting to be re-adopted by the
60498
+ * next reader who greps for it, and it is what the operator last typed — so
60499
+ * it must leave the store AND be said out loud, because the value in force
60500
+ * changes: this hub carried `suggestionMinCosine: 0.33` against a 0.45
60501
+ * threshold, and the band is 0.36 from now on. Silence here would be the
60502
+ * same defect one layer up — an operator discovering three weeks later that
60503
+ * their number stopped applying.
60504
+ *
60505
+ * Absence of the key IS the marker: no flag, no version row, re-running is a
60506
+ * no-op. The write goes through `updateGlobalSettings` / `writeDeviceStore`
60507
+ * (never a raw store write), and the patch unsets with `undefined` — an
60508
+ * explicit `null` would survive the merge as exactly the residue being
60509
+ * removed.
60510
+ */
60511
+ async purgeStoredSuggestionThreshold(deviceIds) {
60512
+ const settings = this.ctxIfReady?.settings;
60513
+ if (!settings) return;
60514
+ if (!this.storedSuggestionCheckedGlobal) {
60515
+ this.storedSuggestionCheckedGlobal = true;
60516
+ try {
60517
+ const plan = planStoredSuggestionPurge(await this.resolveGlobalStore());
60518
+ if (plan !== null) {
60519
+ const global = resolveFaceSettings(await this.resolveGlobalStore());
60520
+ this.announceRetiredSuggestionThreshold({
60521
+ scope: "global",
60522
+ was: plan.was,
60523
+ threshold: global.similarityThreshold
60524
+ });
60525
+ await this.updateGlobalSettings(plan.patch);
60526
+ }
60527
+ } catch (err) {
60528
+ this.storedSuggestionCheckedGlobal = false;
60529
+ this.ctx.logger.warn("face suggestion threshold purge failed — will retry", { meta: {
60530
+ scope: "global",
60531
+ error: String(err)
60532
+ } });
60533
+ }
60534
+ }
60535
+ for (const deviceId of deviceIds) {
60536
+ if (this.storedSuggestionCheckedDevices.has(deviceId)) continue;
60537
+ this.storedSuggestionCheckedDevices.add(deviceId);
60538
+ try {
60539
+ const plan = planStoredSuggestionPurge(await settings.readDeviceStore(deviceId));
60540
+ if (plan === null) continue;
60541
+ const effective = await this.resolveDeviceFaceSettings(deviceId);
60542
+ this.announceRetiredSuggestionThreshold({
60543
+ scope: "device",
60544
+ was: plan.was,
60545
+ threshold: effective.similarityThreshold,
60546
+ deviceId
60547
+ });
60548
+ await settings.writeDeviceStore(deviceId, plan.patch);
60549
+ } catch (err) {
60550
+ this.storedSuggestionCheckedDevices.delete(deviceId);
60551
+ this.ctx.logger.warn("face suggestion threshold purge failed — will retry", {
60552
+ tags: { deviceId },
60553
+ meta: {
60554
+ scope: "device",
60555
+ error: String(err)
60556
+ }
60557
+ });
60558
+ }
60559
+ }
60560
+ }
60561
+ /** The one line the operator can read to see what their number became. Both
60562
+ * numbers, and the rule between them, or it does not explain anything. */
60563
+ announceRetiredSuggestionThreshold(found) {
60564
+ this.ctx.logger.warn("face suggestion threshold RETIRED — it is derived from the match threshold now", {
60565
+ ...found.deviceId !== void 0 ? { tags: { deviceId: found.deviceId } } : {},
60566
+ meta: {
60567
+ scope: found.scope,
60568
+ setting: STORED_SUGGESTION_MIN_COSINE_KEY,
60569
+ wasSetTo: found.was,
60570
+ similarityThreshold: found.threshold,
60571
+ bandFactor: SUGGESTION_BAND_FACTOR,
60572
+ nowInForce: deriveSuggestionMinCosine(found.threshold),
60573
+ why: "two knobs over one band could disagree, and did — a 0.45 threshold against a 0.50 floor made the suggestion band [0.50, 0.45) empty, so nothing could be suggested at all (D222)."
60574
+ }
60575
+ });
60576
+ }
59841
60577
  /**
59842
60578
  * Hold the unassigned face + plate buffers to their per-device capacity.
59843
60579
  *
@@ -61344,6 +62080,205 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
61344
62080
  byIdentity: [...progress.byIdentity]
61345
62081
  };
61346
62082
  }
62083
+ /**
62084
+ * Side of the canonical ArcFace aligned template, in pixels
62085
+ * (`ARCFACE_TEMPLATE_112` / `face-align.ts` on the runner). A stored face crop
62086
+ * of exactly this size IS the template the embedder was fed; any other size
62087
+ * is a padded bbox cut that only looks like one.
62088
+ */
62089
+ static ARCFACE_TEMPLATE_PX = 112;
62090
+ /**
62091
+ * Re-embed ONE buffered face from its stored crop, into the cluster's current
62092
+ * `face-embedding` space, and persist the result on the face row.
62093
+ *
62094
+ * Persisting is not optional. `assignFace` enrols `face.embedding` verbatim,
62095
+ * so a rescore that computed a fresh vector, showed the operator a convincing
62096
+ * percentage and then threw the vector away would enrol the STALE one the
62097
+ * moment they acted on it — the gallery would gain a sample in a space the
62098
+ * matcher cannot reach, which is precisely the failure the whole re-embed
62099
+ * pass exists to undo.
62100
+ *
62101
+ * The 112×112 check is the load-bearing guard. A face's crop is the ArcFace
62102
+ * template only when the detection carried landmarks; otherwise it is a
62103
+ * padded bbox cut, and `face-embedding` over THAT as a whole frame returns a
62104
+ * vector that passes every gate and means nothing (`face-reembed-pass.ts`
62105
+ * documents the mirror-image trap). Refusing beats answering.
62106
+ */
62107
+ async reembedBufferedFace(face, clusterModelId) {
62108
+ const api = this.ctx.api;
62109
+ const faceStore = this.faceStore;
62110
+ const mediaStore = this.mediaStore;
62111
+ const refuse = (reason, detail) => {
62112
+ this.ctx.logger.warn("face rescore: re-embed refused", {
62113
+ tags: { deviceId: face.deviceId },
62114
+ meta: {
62115
+ faceId: face.id,
62116
+ trackId: face.trackId,
62117
+ reason,
62118
+ detail,
62119
+ clusterModelId
62120
+ }
62121
+ });
62122
+ return {
62123
+ report: {
62124
+ attempted: true,
62125
+ succeeded: false,
62126
+ reason,
62127
+ detail,
62128
+ modelId: clusterModelId
62129
+ },
62130
+ embedding: null
62131
+ };
62132
+ };
62133
+ if (!api || !faceStore || !mediaStore) return refuse("failed", "the addon API or the face/media stores are not ready");
62134
+ if (face.mediaKey === void 0 || face.mediaKey === "") return refuse("no-pixels", "this face kept no crop — there is nothing to re-embed from");
62135
+ const file = await mediaStore.getByKey(face.mediaKey);
62136
+ if (file === null) return refuse("no-pixels", `the crop blob ${face.mediaKey} is gone`);
62137
+ const raster = await decodeJpegToRgb(file.base64);
62138
+ const templatePx = PipelineAnalyticsAddon.ARCFACE_TEMPLATE_PX;
62139
+ if (raster.width !== templatePx || raster.height !== templatePx) return refuse("not-a-template", `the stored crop is ${raster.width}×${raster.height}, not the ${templatePx}² aligned template — this face was captured without landmarks, so re-embedding it would produce a plausible and meaningless vector`);
62140
+ const outcome = await new FaceReembedEmbedder({
62141
+ runStatelessStep: (nodeId, stepInput) => this.runRebuildStepOn(nodeId, stepInput),
62142
+ logger: { warn: (m, e) => this.ctx.logger.warn(m, e) }
62143
+ }, await this.resolveRebuildNodes(api, void 0), clusterModelId).embed({
62144
+ sampleId: face.id,
62145
+ identityId: face.recognizedIdentityId ?? "",
62146
+ identityName: `track ${face.trackId}`,
62147
+ modelId: face.embeddingModelId ?? "arcface-r100",
62148
+ dim: face.embedding.length,
62149
+ mediaKey: face.mediaKey
62150
+ }, Buffer.from(file.base64, "base64"));
62151
+ if (outcome.kind === "no-capable-node") return refuse("no-capable-node", outcome.reason);
62152
+ if (outcome.kind === "failed") return refuse("failed", outcome.reason);
62153
+ await faceStore.update(face.id, {
62154
+ embedding: outcome.embedding,
62155
+ embeddingModelId: clusterModelId
62156
+ });
62157
+ if ((await faceStore.get(face.id))?.embeddingModelId !== clusterModelId) return refuse("not-persisted", "the re-embedded vector did not land on the face row");
62158
+ this.ctx.logger.info("face rescore: re-embedded into the cluster model space", {
62159
+ tags: { deviceId: face.deviceId },
62160
+ meta: {
62161
+ faceId: face.id,
62162
+ trackId: face.trackId,
62163
+ from: face.embeddingModelId ?? "arcface-r100",
62164
+ to: clusterModelId,
62165
+ dim: outcome.dim
62166
+ }
62167
+ });
62168
+ return {
62169
+ report: {
62170
+ attempted: true,
62171
+ succeeded: true,
62172
+ modelId: clusterModelId
62173
+ },
62174
+ embedding: outcome.embedding
62175
+ };
62176
+ }
62177
+ /**
62178
+ * The matcher parameters in force for one device, as the report echoes them.
62179
+ *
62180
+ * The suggestion floor is DERIVED here and nowhere else in this class
62181
+ * (D222) — and it is reported WITH `suggestionBandFactor`, because a floor
62182
+ * that moves whenever the threshold moves reads as a bug unless the rule is
62183
+ * on screen beside it. There is no stored `suggestionMinCosine` to report:
62184
+ * the one the live hub carried (0.33) decided nothing from the moment this
62185
+ * shipped, and the retention pass removes it.
62186
+ */
62187
+ faceRescoreParams(settings) {
62188
+ return {
62189
+ threshold: settings.similarityThreshold,
62190
+ margin: settings.margin,
62191
+ minIdentitySamples: settings.minIdentitySamples,
62192
+ suggestionMinCosine: deriveSuggestionMinCosine(settings.similarityThreshold),
62193
+ suggestionBandFactor: SUGGESTION_BAND_FACTOR
62194
+ };
62195
+ }
62196
+ /**
62197
+ * `face.rescoreTrack` — the operator's "what does the gallery say NOW?".
62198
+ *
62199
+ * See `face-rescore.ts` for why the number already on the track cannot answer
62200
+ * that: it was computed during live detection and persisted at track close,
62201
+ * so every enrolment made since is invisible to it.
62202
+ */
62203
+ async rescoreFaceForTrack(input) {
62204
+ const api = this.ctx.api;
62205
+ if (!api) throw new Error("face.rescoreTrack: the addon API is not available");
62206
+ const faceStore = this.faceStore;
62207
+ if (!faceStore) throw new Error("face.rescoreTrack: the face store is not ready");
62208
+ const identityStore = this.identityStore;
62209
+ if (!identityStore) throw new Error("face.rescoreTrack: the identity store is not ready");
62210
+ const faceId = `face-${input.trackId}`;
62211
+ const face = await faceStore.get(faceId);
62212
+ if (!face) throw new Error(`face.rescoreTrack: no buffered face for track ${input.trackId}`);
62213
+ if (face.deviceId !== input.deviceId) throw new Error(`face.rescoreTrack: face ${faceId} belongs to device ${face.deviceId}, not ${input.deviceId}`);
62214
+ const clusterModelId = await resolveClusterModelPin(api, FACE_EMBEDDING_STEP_ID, { warn: (m, e) => this.ctx.logger.warn(m, e) });
62215
+ if (clusterModelId === null) throw new Error("face.rescoreTrack: the cluster face-embedding model could not be resolved");
62216
+ const settings = await this.resolveDeviceFaceSettings(input.deviceId);
62217
+ const params = this.faceRescoreParams(settings);
62218
+ let probeEmbedding = face.embedding;
62219
+ let probeModelId = face.embeddingModelId ?? "arcface-r100";
62220
+ let reembed = {
62221
+ attempted: false,
62222
+ succeeded: false,
62223
+ reason: "not-requested"
62224
+ };
62225
+ if (input.reembed) {
62226
+ const outcome = await this.reembedBufferedFace(face, clusterModelId);
62227
+ reembed = outcome.report;
62228
+ if (outcome.embedding !== null) {
62229
+ probeEmbedding = outcome.embedding;
62230
+ probeModelId = clusterModelId;
62231
+ }
62232
+ }
62233
+ const [gallery, identities] = await Promise.all([identityStore.loadGallery(), identityStore.listIdentities()]);
62234
+ const report = buildFaceRescoreReport({
62235
+ probe: {
62236
+ embedding: probeEmbedding,
62237
+ modelId: probeModelId
62238
+ },
62239
+ gallery,
62240
+ identityNames: new Map(identities.map((i) => [i.id, i.name])),
62241
+ clusterModelId,
62242
+ params
62243
+ });
62244
+ this.ctx.logger.info("face rescore", {
62245
+ tags: { deviceId: input.deviceId },
62246
+ meta: {
62247
+ trackId: input.trackId,
62248
+ faceId,
62249
+ verdict: report.verdict,
62250
+ probeModelId,
62251
+ clusterModelId,
62252
+ gallerySamples: gallery.length,
62253
+ scored: report.identities.length,
62254
+ best: report.identities[0]?.name ?? null,
62255
+ bestScore: report.identities[0]?.score ?? null,
62256
+ storedSuggestion: face.suggestedMatchScore ?? null,
62257
+ reembedded: reembed.succeeded
62258
+ }
62259
+ });
62260
+ return {
62261
+ faceId,
62262
+ deviceId: face.deviceId,
62263
+ trackId: face.trackId,
62264
+ assigned: face.assigned,
62265
+ ...face.recognizedIdentityId != null ? { assignedIdentityId: face.recognizedIdentityId } : {},
62266
+ ...face.suggestedIdentityId != null ? { storedSuggestedIdentityId: face.suggestedIdentityId } : {},
62267
+ ...face.suggestedMatchScore != null ? { storedSuggestedMatchScore: face.suggestedMatchScore } : {},
62268
+ verdict: report.verdict,
62269
+ comparable: report.comparable,
62270
+ probeModelId: report.probeModelId,
62271
+ clusterModelId: report.clusterModelId,
62272
+ params: report.params,
62273
+ identities: report.identities,
62274
+ ...report.runnerUpGap !== void 0 ? { runnerUpGap: report.runnerUpGap } : {},
62275
+ reembedAvailable: probeModelId !== clusterModelId && face.mediaKey !== void 0 && face.mediaKey !== "",
62276
+ reembed
62277
+ };
62278
+ }
62279
+ buildFaceRescoreActionHandlers() {
62280
+ return { "face.rescoreTrack": (input) => this.rescoreFaceForTrack(input) };
62281
+ }
61347
62282
  buildPhotoEnrollActionHandlers() {
61348
62283
  return {
61349
62284
  "photo.analyze": async (input) => {
@@ -62040,6 +62975,30 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
62040
62975
  globalSettingsSchema() {
62041
62976
  return this.schema(buildGlobalSettingsSchema());
62042
62977
  }
62978
+ /**
62979
+ * The only reason this is overridden: the face suggestion floor is DERIVED
62980
+ * (D222), so the form has to be able to SHOW it without storing it.
62981
+ *
62982
+ * The schema is a static declaration and cannot interpolate a live value, so
62983
+ * the readout's value is injected into the hydrate blob here — the same
62984
+ * mechanism `detection-pipeline` uses for its model-substitution readout.
62985
+ * It is a `readonlyField`, so it renders display-only, never enters a patch,
62986
+ * and never reaches the store: the operator sees what their threshold
62987
+ * implies and has nothing to contradict it with. Everything else about this
62988
+ * call is `BaseAddon`'s.
62989
+ */
62990
+ async getGlobalSettings(overlay, cap, nodeId) {
62991
+ const stored = await this.resolveGlobalStore(nodeId, cap);
62992
+ const merged = overlay ? {
62993
+ ...stored,
62994
+ ...overlay
62995
+ } : stored;
62996
+ const readout = formatSuggestionThresholdReadout(resolveFaceSettings(merged).similarityThreshold);
62997
+ return super.getGlobalSettings({
62998
+ ...merged,
62999
+ [SUGGESTION_THRESHOLD_READOUT_KEY]: readout
63000
+ }, cap, nodeId);
63001
+ }
62043
63002
  async getDeviceSettingsContribution(input) {
62044
63003
  if (!await this.isCameraDevice(input.deviceId)) return null;
62045
63004
  const schema = this.globalSettingsSchema();