@camstack/addon-post-analysis 1.2.96 → 1.2.98

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.
@@ -12098,6 +12098,18 @@ var NcOutbox = class {
12098
12098
  maxAttempts;
12099
12099
  drainBatchSize;
12100
12100
  drainInFlight = false;
12101
+ /**
12102
+ * Has {@link load} landed?
12103
+ *
12104
+ * The load moved OFF the boot critical path (see
12105
+ * `NotificationCenter.runBootBackfill`), so for the first seconds of a boot
12106
+ * the centre evaluates and enqueues with an EMPTY `knownIds` — and
12107
+ * `knownIds` is the dedup ledger. Until this flips, {@link enqueue} asks the
12108
+ * store itself instead. False is therefore not "the load failed", it is
12109
+ * "the horizon is not authoritative yet"; a load that FAILED leaves it false
12110
+ * forever, which keeps the per-enqueue guard on, which is exactly right.
12111
+ */
12112
+ horizonLoaded = false;
12101
12113
  constructor(deps) {
12102
12114
  this.store = deps.store;
12103
12115
  this.logger = deps.logger;
@@ -12119,7 +12131,26 @@ var NcOutbox = class {
12119
12131
  columns: [...NC_META_COLUMNS]
12120
12132
  });
12121
12133
  }
12122
- /** Hydrate pending rows + the dedup id horizon. Best-effort. */
12134
+ /**
12135
+ * Hydrate pending rows + the dedup id horizon. Best-effort.
12136
+ *
12137
+ * **Runs OFF the boot critical path** (2026-08-17: 743 rows over the UDS
12138
+ * store client, ~50 s, inside the `initialize()` that publishes the runner's
12139
+ * capability manifest). So it lands against a centre that has already been
12140
+ * evaluating, enqueueing and DELIVERING for as long as the query took — and
12141
+ * the rows it is holding are a snapshot taken before all of that.
12142
+ *
12143
+ * That is why an id this process already knows is SKIPPED rather than
12144
+ * applied. `knownIds` is only ever written by an in-process {@link enqueue}
12145
+ * or by this method, so an id already in it at apply time is one whose live
12146
+ * state (pending, or absent because it went terminal) is strictly newer than
12147
+ * the snapshot. Applying the snapshot over it would put a row that has
12148
+ * already been DELIVERED back into `pending`, and the operator would receive
12149
+ * the same notification twice.
12150
+ *
12151
+ * Nothing is lost by the same token: this method only ADDS to `pending`, so
12152
+ * a row enqueued while the query was in flight is untouched.
12153
+ */
12123
12154
  async load() {
12124
12155
  try {
12125
12156
  const rows = await this.store.query.query({
@@ -12129,19 +12160,30 @@ var NcOutbox = class {
12129
12160
  limit: 1e5
12130
12161
  }
12131
12162
  });
12163
+ let superseded = 0;
12132
12164
  for (const row of rows) {
12165
+ if (this.knownIds.has(row.id)) {
12166
+ superseded += 1;
12167
+ continue;
12168
+ }
12133
12169
  this.knownIds.add(row.id);
12134
12170
  const entry = rowToEntry$1(row.id, row.data);
12135
12171
  if (entry !== null && entry.status === "pending") this.pending.set(entry.id, entry);
12136
12172
  }
12173
+ this.horizonLoaded = true;
12137
12174
  this.logger.info("notification outbox loaded", { meta: {
12138
12175
  pending: this.pending.size,
12139
- known: this.knownIds.size
12176
+ known: this.knownIds.size,
12177
+ superseded
12140
12178
  } });
12141
12179
  } catch (err) {
12142
12180
  this.logger.warn("notification outbox load failed", { meta: { error: String(err) } });
12143
12181
  }
12144
12182
  }
12183
+ /** Is the dedup horizon authoritative? See {@link horizonLoaded}. */
12184
+ hydrated() {
12185
+ return this.horizonLoaded;
12186
+ }
12145
12187
  pendingCount() {
12146
12188
  return this.pending.size;
12147
12189
  }
@@ -12156,6 +12198,18 @@ var NcOutbox = class {
12156
12198
  for (const input of inputs) {
12157
12199
  const id = outboxEntryId(input);
12158
12200
  if (this.knownIds.has(id)) continue;
12201
+ if (!this.horizonLoaded && await this.rowExists(id)) {
12202
+ this.knownIds.add(id);
12203
+ this.logger.info("outbox enqueue deduped against the store — the horizon is still cold", {
12204
+ tags: { deviceId: input.deviceId },
12205
+ meta: {
12206
+ id,
12207
+ ruleId: input.ruleId,
12208
+ targetId: input.targetId
12209
+ }
12210
+ });
12211
+ continue;
12212
+ }
12159
12213
  const entry = {
12160
12214
  id,
12161
12215
  ruleId: input.ruleId,
@@ -12428,6 +12482,35 @@ var NcOutbox = class {
12428
12482
  this.pending.set(entry.id, retry);
12429
12483
  await this.mutate(retry);
12430
12484
  }
12485
+ /**
12486
+ * Does a row already exist under this dedup id?
12487
+ *
12488
+ * Asked ONLY while {@link horizonLoaded} is false. {@link persist} writes
12489
+ * with `set` — an UPSERT keyed by the dedup id — so an enqueue that ran
12490
+ * before the horizon landed would rewrite a row that had already been
12491
+ * DELIVERED back to `pending`, and the drain would send it again. That is
12492
+ * the D-3 `maxPerTrack: 1` guarantee, and it cannot be allowed to depend on
12493
+ * whether a 743-row query had finished.
12494
+ *
12495
+ * A read that FAILS answers `false`, i.e. the intent is enqueued. Duplicate
12496
+ * over lost: a duplicate notification is an annoyance the operator can see,
12497
+ * and a dropped one is a notification nobody ever knows was owed.
12498
+ */
12499
+ async rowExists(id) {
12500
+ try {
12501
+ const row = await this.store.get.query({
12502
+ collection: NC_OUTBOX_COLLECTION,
12503
+ key: id
12504
+ });
12505
+ return row !== null && row !== void 0;
12506
+ } catch (err) {
12507
+ this.logger.debug("outbox cold-horizon dedup read failed", { meta: {
12508
+ id,
12509
+ error: String(err)
12510
+ } });
12511
+ return false;
12512
+ }
12513
+ }
12431
12514
  async persist(entry) {
12432
12515
  await this.store.set.mutate({
12433
12516
  collection: NC_OUTBOX_COLLECTION,
@@ -14206,7 +14289,11 @@ function mosaicTextPlan(captions) {
14206
14289
  };
14207
14290
  }
14208
14291
  /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
14209
- * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
14292
+ * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
14293
+ *
14294
+ * `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
14295
+ * distributes into. `cols` describes the WIDEST row, and is no longer a cell
14296
+ * count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
14210
14297
  function mosaicGrid(n) {
14211
14298
  const count = Math.max(1, Math.floor(n));
14212
14299
  const cols = Math.ceil(Math.sqrt(count));
@@ -14216,31 +14303,84 @@ function mosaicGrid(n) {
14216
14303
  };
14217
14304
  }
14218
14305
  /**
14306
+ * How many tiles each ROW carries, top to bottom — and the reason the mosaic
14307
+ * can no longer have a hole in it.
14308
+ *
14309
+ * The layout used to be a `cols × rows` GRID, and a grid holds more cells than
14310
+ * tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
14311
+ * 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
14312
+ * cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
14313
+ * three-tile digest was a quarter black. Measured on the shipped renderer:
14314
+ * 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
14315
+ * elementi non dovrebbe renderizzare buchi neri"*.
14316
+ *
14317
+ * Counting ROWS instead removes the failure by construction rather than by
14318
+ * patching it. Every row spans the full width and divides it among the tiles it
14319
+ * actually has, so a row is full whatever the remainder and there is no such
14320
+ * thing as a spare cell. The shapes fall out of it, and they are the ones asked
14321
+ * for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
14322
+ *
14323
+ * **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
14324
+ * so row 0 is the head of the ranking; putting the remainder at the top would
14325
+ * make the WORST tile the big one.
14326
+ *
14327
+ * Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
14328
+ * also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
14329
+ * 16:9 picture centred in a double-height cell with bars over and under it.
14330
+ */
14331
+ function mosaicRowCounts(n) {
14332
+ const count = Math.max(1, Math.floor(n));
14333
+ const { rows } = mosaicGrid(count);
14334
+ const base = Math.floor(count / rows);
14335
+ const remainder = count % rows;
14336
+ const counts = [];
14337
+ for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
14338
+ return counts;
14339
+ }
14340
+ /**
14219
14341
  * Where every tile lands. Pure — no bytes, no sharp, no clock.
14220
14342
  *
14221
14343
  * Exported because the rectangles are DATA the artefact should carry: a viewer
14222
14344
  * that wants a tappable mosaic reads these rather than re-deriving a layout
14223
14345
  * that could drift from the one actually rendered.
14346
+ *
14347
+ * The rectangles COVER the picture area exactly — that is the property the
14348
+ * spec asserts, and it is the one the old grid broke.
14224
14349
  */
14225
14350
  function mosaicLayout(input) {
14226
- const { cols, rows } = mosaicGrid(input.tileCount);
14227
- const maxWidth = input.maxWidth ?? 1920;
14228
- const tileWidth = Math.max(160, Math.floor(maxWidth / cols));
14351
+ const rowCounts = mosaicRowCounts(input.tileCount);
14352
+ const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
14353
+ const firstRowCount = rowCounts[0] ?? 1;
14354
+ const tileWidth = Math.floor(width / firstRowCount);
14229
14355
  const tileHeight = Math.round(tileWidth * TILE_ASPECT);
14230
14356
  const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
14231
14357
  const tiles = [];
14232
- for (let index = 0; index < Math.max(1, input.tileCount); index += 1) tiles.push({
14233
- index,
14234
- x: index % cols * tileWidth,
14235
- y: titleHeight + Math.floor(index / cols) * tileHeight,
14236
- w: tileWidth,
14237
- h: tileHeight
14238
- });
14358
+ let y = titleHeight;
14359
+ let index = 0;
14360
+ for (const count of rowCounts) {
14361
+ const nominalWidth = Math.floor(width / count);
14362
+ const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
14363
+ let x = 0;
14364
+ for (let inRow = 0; inRow < count; inRow += 1) {
14365
+ const w = inRow === count - 1 ? width - x : nominalWidth;
14366
+ tiles.push({
14367
+ index,
14368
+ x,
14369
+ y,
14370
+ w,
14371
+ h: rowHeight
14372
+ });
14373
+ x += w;
14374
+ index += 1;
14375
+ }
14376
+ y += rowHeight;
14377
+ }
14239
14378
  return {
14240
- width: cols * tileWidth,
14241
- height: titleHeight + rows * tileHeight,
14242
- cols,
14243
- rows,
14379
+ width,
14380
+ height: y,
14381
+ cols: Math.max(...rowCounts),
14382
+ rows: rowCounts.length,
14383
+ rowCounts,
14244
14384
  tileWidth,
14245
14385
  tileHeight,
14246
14386
  titleHeight,
@@ -14304,36 +14444,20 @@ function titleSvg(width, height, caption) {
14304
14444
  *
14305
14445
  * A tile sharp cannot read is DROPPED and counted, never composited as a blank
14306
14446
  * cell — a black square in a digest reads as "the camera saw nothing", which is
14307
- * the opposite of what happened. The grid is recomputed from the survivors so
14308
- * the mosaic has no hole.
14447
+ * the opposite of what happened. The layout is computed from the SURVIVORS, and
14448
+ * because {@link mosaicRowCounts} counts rows rather than grid cells there is no
14449
+ * remainder to leave empty either.
14309
14450
  *
14310
- * @throws only if sharp cannot produce the final canvas at all; the caller
14311
- * treats that as "no mosaic" and still delivers the counts.
14451
+ * @throws when every tile was dropped, and when sharp cannot produce the final
14452
+ * canvas at all; the caller treats both as "no mosaic" and still
14453
+ * delivers the counts.
14312
14454
  */
14313
14455
  async function renderMosaic(request, logger) {
14314
14456
  const plan = mosaicTextPlan(request.captions);
14315
- const decoded = [];
14316
- const probeLayout = mosaicLayout({
14317
- tileCount: Math.max(1, request.tiles.length),
14318
- ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
14319
- });
14457
+ const usable = [];
14320
14458
  for (const tile of request.tiles) try {
14321
- const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14322
- const fitted = (0, sharp.default)(tile.jpeg).resize({
14323
- width: probeLayout.tileWidth,
14324
- height: probeLayout.tileHeight,
14325
- fit: "contain",
14326
- background: MOSAIC_BACKGROUND
14327
- });
14328
- const resized = await (label.length === 0 ? fitted : fitted.composite([{
14329
- input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
14330
- top: 0,
14331
- left: 0
14332
- }])).jpeg({ quality: 82 }).toBuffer();
14333
- decoded.push({
14334
- tile,
14335
- resized
14336
- });
14459
+ await (0, sharp.default)(tile.jpeg).metadata();
14460
+ usable.push(tile);
14337
14461
  } catch (err) {
14338
14462
  logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
14339
14463
  tags: { deviceId: tile.deviceId },
@@ -14343,23 +14467,34 @@ async function renderMosaic(request, logger) {
14343
14467
  }
14344
14468
  });
14345
14469
  }
14346
- const droppedTiles = request.tiles.length - decoded.length;
14470
+ const droppedTiles = request.tiles.length - usable.length;
14471
+ if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
14347
14472
  const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
14348
14473
  const layout = mosaicLayout({
14349
- tileCount: Math.max(1, decoded.length),
14474
+ tileCount: usable.length,
14350
14475
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
14351
14476
  withTitle: caption.length > 0
14352
14477
  });
14353
- const composites = await Promise.all(decoded.map(async ({ resized }, index) => ({
14354
- input: layout.tileWidth === probeLayout.tileWidth && layout.tileHeight === probeLayout.tileHeight ? resized : await (0, sharp.default)(resized).resize({
14355
- width: layout.tileWidth,
14356
- height: layout.tileHeight,
14478
+ const composites = await Promise.all(usable.map(async (tile, index) => {
14479
+ const rect = layout.tiles[index];
14480
+ if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
14481
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14482
+ const fitted = (0, sharp.default)(tile.jpeg).resize({
14483
+ width: rect.w,
14484
+ height: rect.h,
14357
14485
  fit: "contain",
14358
14486
  background: MOSAIC_BACKGROUND
14359
- }).jpeg({ quality: 82 }).toBuffer(),
14360
- top: layout.tiles[index]?.y ?? 0,
14361
- left: layout.tiles[index]?.x ?? 0
14362
- })));
14487
+ });
14488
+ return {
14489
+ input: await (label.length === 0 ? fitted : fitted.composite([{
14490
+ input: tileLabelSvg(rect.w, rect.h, label),
14491
+ top: 0,
14492
+ left: 0
14493
+ }])).jpeg({ quality: 82 }).toBuffer(),
14494
+ top: rect.y,
14495
+ left: rect.x
14496
+ };
14497
+ }));
14363
14498
  if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
14364
14499
  input: titleSvg(layout.width, layout.titleHeight, caption),
14365
14500
  top: 0,
@@ -14575,6 +14710,36 @@ function buildSummaryOutboxInputs(input) {
14575
14710
  * the quota degrades honestly: the best cameras get their one tile, the rest
14576
14711
  * are counted as dropped and the count rides the log line — never silently.
14577
14712
  *
14713
+ * ## Only tracks the pipeline PROMOTED
14714
+ *
14715
+ * A digest is retrospective. The operator's own framing, verbatim: *"ideale
14716
+ * sarebbe considerare solo tracce che vengono alla fine promosse a tracce, dato
14717
+ * che il summary non è atteso di essere realtime"* — the window is closed
14718
+ * before this function is called, so the selection may wait for the pipeline's
14719
+ * verdict instead of guessing at it.
14720
+ *
14721
+ * The verdict is `SortTracker`'s own promotion threshold: a candidate is not
14722
+ * emitted as a track until it has been matched on `minHits` frames
14723
+ * (`isConfirmedForEmit`, per-device setting, *"minimum consecutive detection
14724
+ * hits before a track is promoted to output"*). The gate here re-applies that
14725
+ * same threshold to what the CLOSED row actually left behind — the count of
14726
+ * recorded sightings on its trajectory — and the port that builds a candidate
14727
+ * is the only thing that knows the device's `minHits`.
14728
+ *
14729
+ * This is not a movement heuristic and deliberately so: `totalDistance` and
14730
+ * `state` were both measured against live traffic and both hide real passages.
14731
+ * What they cannot see is the shape this gate is for — a row whose whole life
14732
+ * is one or two frames. Measured on device 617 (2026-08-19 04:12): two rows,
14733
+ * `closedReason` NULL so the parked pass is blind to them, dwell 53 ms,
14734
+ * `totalDistance` 0, two recorded sightings each. And `minImportance` cannot
14735
+ * cover it either, structurally: the static-track gate that demotes a frozen
14736
+ * subject needs two centroids to measure anything
14737
+ * (`computeStaticTrackMetrics`), so a single-sighting row is exactly the shape
14738
+ * it never scores down — 33 of the live hub's 34 such rows sit at or above 0.2.
14739
+ *
14740
+ * There is no rule field to turn it off. A digest of things that were never
14741
+ * tracks has no audience, and this repo has shipped knobs nobody read.
14742
+ *
14578
14743
  * ## Zones are scoped PER CAMERA
14579
14744
  *
14580
14745
  * `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
@@ -14598,15 +14763,20 @@ function dwellSeconds(candidate) {
14598
14763
  * branch that discards work silently reads as "it never happened".
14599
14764
  */
14600
14765
  function filterSummaryCandidates(candidates, filters, zoneOwner) {
14601
- const moving = filters?.includeStationary === true ? candidates : candidates.filter((candidate) => candidate.stationary !== true);
14602
- const droppedStationary = candidates.length - moving.length;
14766
+ const promoted = candidates.filter((candidate) => candidate.confirmed !== false);
14767
+ const droppedUnconfirmed = candidates.length - promoted.length;
14768
+ const unknownConfirmation = promoted.filter((candidate) => candidate.confirmed === void 0).length;
14769
+ const moving = filters?.includeStationary === true ? promoted : promoted.filter((candidate) => candidate.stationary !== true);
14770
+ const droppedStationary = promoted.length - moving.length;
14603
14771
  if (filters === void 0) return {
14604
14772
  kept: [...moving],
14605
14773
  droppedByClass: 0,
14606
14774
  droppedByZone: 0,
14607
14775
  droppedByImportance: 0,
14608
14776
  droppedByDwell: 0,
14609
- droppedStationary
14777
+ droppedStationary,
14778
+ droppedUnconfirmed,
14779
+ unknownConfirmation
14610
14780
  };
14611
14781
  const include = filters.classes;
14612
14782
  const exclude = new Set(filters.classesExclude ?? []);
@@ -14652,7 +14822,9 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
14652
14822
  droppedByZone,
14653
14823
  droppedByImportance,
14654
14824
  droppedByDwell,
14655
- droppedStationary
14825
+ droppedStationary,
14826
+ droppedUnconfirmed,
14827
+ unknownConfirmation
14656
14828
  };
14657
14829
  }
14658
14830
  /**
@@ -15028,7 +15200,9 @@ var NcSummaryProducer = class {
15028
15200
  droppedByZone: filtered.droppedByZone,
15029
15201
  droppedByImportance: filtered.droppedByImportance,
15030
15202
  droppedByDwell: filtered.droppedByDwell,
15031
- droppedStationary: filtered.droppedStationary
15203
+ droppedStationary: filtered.droppedStationary,
15204
+ droppedUnconfirmed: filtered.droppedUnconfirmed,
15205
+ unknownConfirmation: filtered.unknownConfirmation
15032
15206
  } });
15033
15207
  if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
15034
15208
  return {
@@ -15092,6 +15266,8 @@ var NcSummaryProducer = class {
15092
15266
  scanned: candidates.length,
15093
15267
  matched: filtered.kept.length,
15094
15268
  droppedStationary: filtered.droppedStationary,
15269
+ droppedUnconfirmed: filtered.droppedUnconfirmed,
15270
+ unknownConfirmation: filtered.unknownConfirmation,
15095
15271
  droppedByClass: filtered.droppedByClass,
15096
15272
  tiles: loaded.length,
15097
15273
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
@@ -18860,6 +19036,18 @@ var NotificationCenter = class NotificationCenter {
18860
19036
  drainTicks = 0;
18861
19037
  evaluationActive = false;
18862
19038
  /**
19039
+ * The armed boot back-fill — see {@link runBootBackfill}. Held so
19040
+ * {@link whenHydrated} can be awaited by an operator surface or a test that
19041
+ * needs the durable state, and so nothing re-arms it.
19042
+ */
19043
+ hydration = null;
19044
+ /**
19045
+ * The instant `start()` opened the intake. Everything persisted from here on
19046
+ * is evaluated live by THIS process, which is what bounds the boot reconcile
19047
+ * at its late end — see {@link reconcile}.
19048
+ */
19049
+ intakeOpenedAt = 0;
19050
+ /**
18863
19051
  * The tap-through buttons for ONE delivery, each with its own single-use
18864
19052
  * token.
18865
19053
  *
@@ -19361,49 +19549,190 @@ var NotificationCenter = class NotificationCenter {
19361
19549
  /**
19362
19550
  * Load rules (every node — the cap provider serves CRUD from any node).
19363
19551
  * When `evaluation` is true (the designated post-processing node ONLY),
19364
- * also hydrate the outbox, seed cooldowns, run the crash-gap reconcile
19365
- * and start the drain + rule-reload timers.
19552
+ * open the intake and arm the drain + rule-reload timers.
19553
+ *
19554
+ * ## `start()` resolving means "the centre is EVALUATING"
19555
+ *
19556
+ * It does NOT mean the durable back-fill has finished. That contract was
19557
+ * written on 2026-08-14 for the two producers (a timelapse render inside
19558
+ * `initialize()` took eight capabilities off the cluster for two render
19559
+ * timeouts) and it applies to the centre itself for the same reason: this
19560
+ * method is awaited by `buildNotificationCenter`, which is awaited by
19561
+ * `pipeline-analytics.onInitialize()`, and `addon-runner` publishes a
19562
+ * child's capability manifest only AFTER `initialize()` returns.
19563
+ *
19564
+ * Measured on the live hub 2026-08-17 at ZERO transport contention, the
19565
+ * `buildNotificationCenter` boot step took **270 s** — all of it durable
19566
+ * back-fill: `hydrateLiveness` (775 subjects), `outbox.load` (743 rows),
19567
+ * and two N+1 per-device refresh loops (`readDeviceStates` and
19568
+ * `readDeviceZoneIds` issue ONE RPC per watched device, serially). For
19569
+ * those 270 s the cluster had no provider for any capability this addon
19570
+ * declares, and the intake below had not been opened either — so nothing
19571
+ * arriving in that window was late, it was never evaluated at all.
19572
+ *
19573
+ * ## What stays here, and why
19574
+ *
19575
+ * Only reads a notification cannot be CORRECT without, and each is one
19576
+ * store query:
19577
+ *
19578
+ * - the TEXT catalog — a notification built before it goes out in the
19579
+ * wrong language;
19580
+ * - the RULES — without them there is nothing to evaluate;
19581
+ * - the SNOOZE windows and the per-camera MUTES — both SUPPRESS, so a cold
19582
+ * one fails OPEN: the operator gets pushes they explicitly silenced;
19583
+ * - the timelapse + summary rule sets, which the producers armed below read.
19584
+ *
19585
+ * Everything else is a mirror the 30 s reload tick re-reads anyway, or a
19586
+ * boot-once hydration whose cold behaviour is already defined and safe —
19587
+ * see {@link runBootBackfill}, which names the direction for each one.
19366
19588
  */
19367
19589
  async start(opts) {
19368
- await this.reloadTexts();
19369
- await this.rules.load();
19370
- await this.snoozes.load();
19371
- await this.deviceMutes.load();
19372
- await this.timelapseRules.load();
19373
- await this.summaryRules.load();
19590
+ const t0 = Date.now();
19591
+ let phaseT = t0;
19592
+ const timed = async (phase, run) => {
19593
+ await run();
19594
+ const now = Date.now();
19595
+ this.logger.info("notification start phase", { meta: {
19596
+ phase,
19597
+ ms: now - phaseT,
19598
+ sinceStartMs: now - t0
19599
+ } });
19600
+ phaseT = now;
19601
+ };
19602
+ await timed("texts", () => this.reloadTexts());
19603
+ await timed("rules", () => this.rules.load());
19604
+ await timed("snoozes", () => this.snoozes.load());
19605
+ await timed("deviceMutes", () => this.deviceMutes.load());
19606
+ await timed("timelapseRules", () => this.timelapseRules.load());
19607
+ await timed("summaryRules", () => this.summaryRules.load());
19374
19608
  this.refreshOccupancyWatch();
19375
19609
  if (!opts.evaluation) return;
19376
- await this.deviceStates.refresh();
19377
- await this.deviceDirectory.refresh();
19378
- await this.hydrateLiveness();
19379
- await this.zoneOwners.refresh();
19380
19610
  this.sceneStates.refresh();
19611
+ this.intakeOpenedAt = this.now();
19381
19612
  this.evaluationActive = true;
19382
- await this.outbox.load();
19383
- await this.seedCooldowns();
19384
- await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
19385
- await this.hydrateOccupancy();
19386
- await this.reconcile();
19387
19613
  this.drainTimer = setInterval(() => {
19388
19614
  this.drainTick();
19389
19615
  }, this.deps.drainIntervalMs ?? DEFAULT_DRAIN_INTERVAL_MS);
19390
19616
  this.reloadTimer = setInterval(() => {
19391
19617
  this.reloadRules();
19392
19618
  }, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
19393
- if (this.timelapseScheduler !== null) await this.timelapseScheduler.start().catch((err) => {
19619
+ const timelapseScheduler = this.timelapseScheduler;
19620
+ if (timelapseScheduler !== null) await timed("timelapseProducer", () => timelapseScheduler.start()).catch((err) => {
19394
19621
  this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
19395
19622
  });
19396
- if (this.summaryProducer !== null) await this.summaryProducer.start().catch((err) => {
19623
+ const summaryProducer = this.summaryProducer;
19624
+ if (summaryProducer !== null) await timed("summaryProducer", () => summaryProducer.start()).catch((err) => {
19397
19625
  this.logger.warn("summary producer did not start", { meta: { error: String(err) } });
19398
19626
  });
19627
+ this.armBootBackfill();
19399
19628
  this.logger.info("notification center started", { meta: {
19629
+ ms: Date.now() - t0,
19400
19630
  rules: this.rules.list().length,
19401
- pendingOutbox: this.outbox.pendingCount(),
19402
19631
  timelapseRules: this.timelapseRules.list().length,
19403
19632
  timelapseProducer: this.timelapseScheduler !== null,
19404
19633
  summaryRules: this.summaryRules.list().length,
19405
19634
  summaryProducer: this.summaryProducer !== null,
19406
- summaryAi: this.summaryAi !== null
19635
+ summaryAi: this.summaryAi !== null,
19636
+ backfill: "armed"
19637
+ } });
19638
+ }
19639
+ /**
19640
+ * The durable back-fill, finished.
19641
+ *
19642
+ * Resolves immediately when nothing was armed (a non-evaluation node, or a
19643
+ * centre that was never started). Never rejects — every phase owns its own
19644
+ * failure. Exists so a caller that genuinely needs the hydrated state — a
19645
+ * test asserting the reseed, an operator surface reporting readiness — can
19646
+ * ask for it instead of sleeping.
19647
+ */
19648
+ whenHydrated() {
19649
+ return this.hydration ?? Promise.resolve();
19650
+ }
19651
+ /**
19652
+ * ONE drain pass, awaited — the interval's own tick, called directly.
19653
+ *
19654
+ * The same code path the timer drives, deliberately: a caller that flushes
19655
+ * the queue through a parallel implementation is testing the parallel
19656
+ * implementation.
19657
+ */
19658
+ async drainNow() {
19659
+ await this.drainTick();
19660
+ }
19661
+ armBootBackfill() {
19662
+ if (this.hydration !== null) return;
19663
+ this.hydration = this.runBootBackfill().catch((err) => {
19664
+ this.logger.warn("notification back-fill could not be armed", { meta: { error: String(err) } });
19665
+ });
19666
+ }
19667
+ /**
19668
+ * The boot back-fill: everything `start()` used to await.
19669
+ *
19670
+ * Ordered by what a cold mirror COSTS, most expensive first — this is a
19671
+ * sequence, not a `Promise.all`, because these reads share one UDS store
19672
+ * client and racing them is how a boot becomes a queue again.
19673
+ *
19674
+ * 1. the device directory + the LIVENESS ledger. Cold costs a FLOOD (D130:
19675
+ * one "camera online" per camera on the installation, 2026-08-13), so it
19676
+ * goes first. The ledger's own contract holds in the window: an empty
19677
+ * mirror SEEDS silently rather than notifying (D49, fail toward
19678
+ * discard), and device liveness is additionally held for 5 minutes by
19679
+ * `DeviceLivenessHoldoff` before it can notify at all — which is far
19680
+ * longer than this phase.
19681
+ * 2. the OCCUPANCY state. Cold costs a re-announced edge (the cold-baseline
19682
+ * bug, `occupancy-cold-seed.spec.ts`).
19683
+ * 3. the OUTBOX horizon, then the COOLDOWN seed. Both cost a REPEAT while
19684
+ * cold — a dedup id nobody remembers, a cooldown nobody recovered — and
19685
+ * the horizon goes first because it is what makes every phase after it
19686
+ * (the reconcile above all) idempotent. The outbox's own cold window is
19687
+ * made safe structurally — see `NcOutbox.load` / `rowExists`.
19688
+ * 4. the ZONE ownership. Cold costs a WIDER verdict (a zone-scoped rule
19689
+ * evaluates camera-wide), so it precedes the gates that fail closed.
19690
+ * 5. the DEVICE STATES. Cold costs silence — `undefined` never matches — so
19691
+ * it is the safest of the lot and goes after the N+1 above it.
19692
+ * 6. the RECONCILE, which replays records persisted across the crash gap.
19693
+ * After the outbox on purpose: the replay is idempotent only through the
19694
+ * dedup horizon.
19695
+ * 7. the retention prune. Pure hygiene, nothing reads its result.
19696
+ *
19697
+ * Every phase reports its own ms. The 2026-08-17 boot could not say which of
19698
+ * these owned the 270 s because none of them said anything, and the addon's
19699
+ * own per-step timing stopped at `buildNotificationCenter`.
19700
+ */
19701
+ async runBootBackfill() {
19702
+ const t0 = Date.now();
19703
+ const phase = async (name, run) => {
19704
+ if (!this.evaluationActive) return false;
19705
+ const at = Date.now();
19706
+ try {
19707
+ await run();
19708
+ } catch (err) {
19709
+ this.logger.warn("notification back-fill phase failed", { meta: {
19710
+ phase: name,
19711
+ error: String(err)
19712
+ } });
19713
+ }
19714
+ this.logger.info("notification back-fill phase", { meta: {
19715
+ phase: name,
19716
+ ms: Date.now() - at,
19717
+ sinceStartMs: Date.now() - t0
19718
+ } });
19719
+ return true;
19720
+ };
19721
+ if (!await phase("deviceDirectory", () => this.deviceDirectory.refresh())) return;
19722
+ if (!await phase("liveness", () => this.hydrateLiveness())) return;
19723
+ if (!await phase("occupancy", () => this.hydrateOccupancy())) return;
19724
+ if (!await phase("outbox", () => this.outbox.load())) return;
19725
+ if (!await phase("cooldowns", () => this.seedCooldowns())) return;
19726
+ if (!await phase("zoneOwners", () => this.zoneOwners.refresh())) return;
19727
+ if (!await phase("deviceStates", () => this.deviceStates.refresh())) return;
19728
+ if (!await phase("reconcile", () => this.reconcile())) return;
19729
+ if (!await phase("prune", async () => {
19730
+ await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
19731
+ })) return;
19732
+ this.logger.info("notification center back-fill complete", { meta: {
19733
+ ms: Date.now() - t0,
19734
+ pendingOutbox: this.outbox.pendingCount(),
19735
+ dedupHorizon: this.outbox.hydrated()
19407
19736
  } });
19408
19737
  }
19409
19738
  async stop() {
@@ -19421,6 +19750,7 @@ var NotificationCenter = class NotificationCenter {
19421
19750
  this.addonUpdates.dispose();
19422
19751
  this.livenessHoldoff.dispose();
19423
19752
  this.evaluationActive = false;
19753
+ this.hydration = null;
19424
19754
  }
19425
19755
  /**
19426
19756
  * Consume ONE event. THE entry point of the notification path — every
@@ -21603,14 +21933,31 @@ var NotificationCenter = class NotificationCenter {
21603
21933
  });
21604
21934
  }
21605
21935
  }
21606
- /** Boot crash-gap reconcile — see the module docstring. */
21936
+ /**
21937
+ * Boot crash-gap reconcile — see the module docstring.
21938
+ *
21939
+ * **Bounded at BOTH ends.** `since` closes the gap the previous process left;
21940
+ * {@link intakeOpenedAt} closes the one this one would otherwise open. The
21941
+ * reconcile used to run before the intake existed, so "a record newer than
21942
+ * the watermark" and "a record this process has not evaluated" were the same
21943
+ * set. They are not any more: the reconcile is a back-fill phase now, and
21944
+ * every record persisted since `start()` opened the intake has ALREADY been
21945
+ * evaluated in-process — the exact sentence `drainTick` uses to justify
21946
+ * advancing the watermark to `now`.
21947
+ *
21948
+ * Replaying one of those is not merely redundant. The outbox dedup id makes
21949
+ * the DELIVERY idempotent, but a rule's `onTrigger` sequence is actuated
21950
+ * before the cooldown gate and before any dedup (see `runRuleActions`), so a
21951
+ * replay would open the gate, arm the alarm or sound the siren a second time.
21952
+ */
21607
21953
  async reconcile() {
21608
21954
  const now = this.now();
21609
21955
  const watermark = await this.outbox.getWatermark();
21610
21956
  const windowStart = now - (this.deps.reconcileWindowMs ?? DEFAULT_RECONCILE_WINDOW_MS);
21611
21957
  const since = Math.max(windowStart, (watermark ?? 0) - RECONCILE_OVERLAP_MS);
21958
+ const until = this.intakeOpenedAt;
21612
21959
  try {
21613
- const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
21960
+ const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].filter((e) => e.timestamp < until).sort((a, b) => a.timestamp - b.timestamp);
21614
21961
  for (const event of ordered) {
21615
21962
  const phase = packagePhaseOf(event);
21616
21963
  this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
@@ -22251,6 +22598,16 @@ function planRuleTest(input) {
22251
22598
  * never be re-owned through a patch.
22252
22599
  * - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
22253
22600
  * VISIBLE to him (his own personal rule OR a global/admin rule).
22601
+ * - the TIMELAPSE and DIGEST families (`nc.*TimelapseRule*`,
22602
+ * `nc.*SummaryRule*`) repeat that model exactly over their own stores. The
22603
+ * digest half was `auth: 'admin'` in v1 and was relaxed once the operator
22604
+ * closed the cost question; `nc.listSummaryRules` reports the current answer
22605
+ * as `canAuthor` so a client talking to an older hub degrades to a read-only
22606
+ * list instead of discovering the refusal as a 403 on a filled-in form.
22607
+ * - the three TEST actions (`nc.injectTestEvent`, `nc.testRule`,
22608
+ * `nc.testTimelapseRule`, `nc.testSummaryRule`) stay `auth: 'admin'`
22609
+ * regardless of who owns the rule: they DELIVER, to every target the rule
22610
+ * addresses, on other people's phones.
22254
22611
  *
22255
22612
  * Target-ownership limits (personal-rule delivery target list + the opt-out
22256
22613
  * target) are enforced against the notifiers target catalog; ADMINS bypass the
@@ -22469,7 +22826,25 @@ var ncActions = require_dist.defineCustomActions({
22469
22826
  auth: "admin",
22470
22827
  caller: "required"
22471
22828
  }),
22472
- "nc.listSummaryRules": require_dist.customAction(require_dist.object({}), require_dist.object({ rules: require_dist.array(NcViewerSummaryRuleSchema) }), { caller: "required" }),
22829
+ "nc.listSummaryRules": require_dist.customAction(require_dist.object({}), require_dist.object({
22830
+ rules: require_dist.array(NcViewerSummaryRuleSchema),
22831
+ /**
22832
+ * DOES THIS HUB LET THE CALLER AUTHOR ONE? The capability probe, and the
22833
+ * only honest way a client can know.
22834
+ *
22835
+ * `nc.upsertSummaryRule` was `auth: 'admin'` until the operator closed
22836
+ * that decision, and a viewer talking to a hub that still refuses has to
22837
+ * find out BEFORE it offers a "New summary rule" button — otherwise the
22838
+ * only feedback is a 403 taken in front of the operator on a form they
22839
+ * had already filled in. ADDITIVE and therefore `.optional()` on every
22840
+ * client mirror: absent means an older hub, which the viewer reads as "no
22841
+ * authoring" and degrades to a read-only list with a note.
22842
+ *
22843
+ * Derived from the same predicate the handlers enforce — never a constant
22844
+ * `true` — so a hub that re-tightened the auth could not say otherwise.
22845
+ */
22846
+ canAuthor: require_dist.boolean()
22847
+ }), { caller: "required" }),
22473
22848
  /**
22474
22849
  * ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
22475
22850
  * rule is authored as a whole (cameras + window + filters + targets) and the
@@ -22478,10 +22853,18 @@ var ncActions = require_dist.defineCustomActions({
22478
22853
  *
22479
22854
  * `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
22480
22855
  *
22481
- * `auth: 'admin'` in v1 (operator decision): a digest spans cameras and, once
22482
- * P2 lands, consumes the local vision model. A personal rule that fans vision
22483
- * calls across other people's cameras is a decision to take after the real
22484
- * cost has been watched for a few weeks, not before.
22856
+ * `caller: 'required'`, NOT `auth: 'admin'`. It was admin-only in v1 because
22857
+ * a digest spans cameras and consumes the local vision model, and that cost
22858
+ * had not been watched yet. The operator closed the decision, so this now
22859
+ * carries exactly the auth the two rule families beside it carry: ownership
22860
+ * is enforced in the handler, `ownerUserId` is STAMPED from the resolved
22861
+ * caller, a personal rule may deliver only to targets that caller OWNS, and an
22862
+ * admin still authors GLOBAL rules. No new ownership model was invented for
22863
+ * it — `NcSummaryStore` has had `ownerUserId` / `listForOwner` since it was
22864
+ * written and they were simply not reachable.
22865
+ *
22866
+ * `nc.testSummaryRule` stays admin: that one DELIVERS, on other people's
22867
+ * phones.
22485
22868
  */
22486
22869
  "nc.upsertSummaryRule": require_dist.customAction(require_dist.object({
22487
22870
  ruleId: require_dist.string().optional(),
@@ -22489,12 +22872,10 @@ var ncActions = require_dist.defineCustomActions({
22489
22872
  patch: NcSummaryRulePatchSchema.optional()
22490
22873
  }), require_dist.object({ rule: NcSummaryRuleSchema }), {
22491
22874
  kind: "mutation",
22492
- auth: "admin",
22493
22875
  caller: "required"
22494
22876
  }),
22495
22877
  "nc.deleteSummaryRule": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
22496
22878
  kind: "mutation",
22497
- auth: "admin",
22498
22879
  caller: "required"
22499
22880
  }),
22500
22881
  /**
@@ -22588,6 +22969,17 @@ var ncActions = require_dist.defineCustomActions({
22588
22969
  * a surface that distinguishes them reports what other people own. */
22589
22970
  "nc.getArtifact": require_dist.customAction(require_dist.object({ artifactId: require_dist.string().min(1) }), require_dist.object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
22590
22971
  });
22972
+ /**
22973
+ * Does THIS build let a non-admin author a digest rule?
22974
+ *
22975
+ * A named constant rather than a literal `true` at the call site, so the one
22976
+ * fact `nc.listSummaryRules.canAuthor` reports and the auth the upsert actually
22977
+ * carries sit beside each other and are changed together. If the operator ever
22978
+ * re-tightens the action, this goes to `false` in the same edit and the viewer
22979
+ * degrades on the next list — instead of the app discovering it by taking a 403
22980
+ * on a form the operator had already filled in.
22981
+ */
22982
+ var SUMMARY_AUTHORING_OPEN = true;
22591
22983
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
22592
22984
  function requireCaller$1(caller) {
22593
22985
  if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
@@ -22648,6 +23040,22 @@ function makeNcActionHandlers(deps) {
22648
23040
  if (editor === void 0) throw new Error("this node does not host the notification text catalog — use the hub");
22649
23041
  return editor;
22650
23042
  };
23043
+ /**
23044
+ * A digest rule the caller may EDIT. Same shape and same admin bypass as
23045
+ * {@link assertTimelapseEditable} — deliberately, because a second ownership
23046
+ * model for the third rule family is a second place to get it wrong.
23047
+ *
23048
+ * A GLOBAL rule (no `ownerUserId`, what an admin authors) is VISIBLE to
23049
+ * everyone and editable by admins only: `listForOwner` returns it, so a user
23050
+ * sees it in the list, and `readOnly` says so.
23051
+ */
23052
+ const assertSummaryEditable = (ruleId, caller, sum) => {
23053
+ const rule = sum.store.get(ruleId);
23054
+ if (rule === null) throw new Error(`forbidden: summary rule not found: ${ruleId}`);
23055
+ if (caller.isAdmin) return rule;
23056
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: summary rule not owned: ${ruleId}`);
23057
+ return rule;
23058
+ };
22651
23059
  /** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
22652
23060
  const assertTimelapseEditable = (ruleId, caller, tl) => {
22653
23061
  const rule = tl.store.get(ruleId);
@@ -22918,21 +23326,23 @@ function makeNcActionHandlers(deps) {
22918
23326
  "nc.listSummaryRules": async (_input, caller) => {
22919
23327
  const c = requireCaller$1(caller);
22920
23328
  const sum = requireSummary();
22921
- return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
22922
- ...r,
22923
- readOnly: !c.isAdmin
22924
- })) };
23329
+ return {
23330
+ rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
23331
+ ...r,
23332
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
23333
+ })),
23334
+ canAuthor: SUMMARY_AUTHORING_OPEN
23335
+ };
22925
23336
  },
22926
23337
  "nc.upsertSummaryRule": async (input, caller) => {
22927
23338
  const c = requireCaller$1(caller);
22928
- if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
22929
23339
  const sum = requireSummary();
22930
23340
  if (input.ruleId === void 0) {
22931
23341
  if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
22932
23342
  const parsed = NcSummaryRuleInputSchema.parse(input.rule);
22933
23343
  assertSummaryBudget(parsed);
22934
23344
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
22935
- const rule = await sum.store.create(parsed, c.userId);
23345
+ const rule = await sum.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
22936
23346
  deps.logger.info("summary rule created", { meta: {
22937
23347
  ruleId: rule.id,
22938
23348
  owner: c.userId,
@@ -22940,8 +23350,7 @@ function makeNcActionHandlers(deps) {
22940
23350
  } });
22941
23351
  return { rule };
22942
23352
  }
22943
- const existing = sum.store.get(input.ruleId);
22944
- if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
23353
+ const existing = assertSummaryEditable(input.ruleId, c, sum);
22945
23354
  if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
22946
23355
  const patch = NcSummaryRulePatchSchema.parse(input.patch);
22947
23356
  assertSummaryBudget({
@@ -22958,8 +23367,9 @@ function makeNcActionHandlers(deps) {
22958
23367
  },
22959
23368
  "nc.deleteSummaryRule": async (input, caller) => {
22960
23369
  const c = requireCaller$1(caller);
22961
- if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
22962
- await requireSummary().store.delete(input.ruleId);
23370
+ const sum = requireSummary();
23371
+ assertSummaryEditable(input.ruleId, c, sum);
23372
+ await sum.store.delete(input.ruleId);
22963
23373
  deps.logger.info("summary rule deleted", { meta: {
22964
23374
  ruleId: input.ruleId,
22965
23375
  owner: c.userId
@@ -44276,7 +44686,9 @@ var TrackStore = class {
44276
44686
  */
44277
44687
  rowToTrack(id, data, projection) {
44278
44688
  const slim = projection === "slim";
44279
- const positions = slim ? [] : data["positions"] ?? [];
44689
+ const storedPositions = data["positions"] ?? [];
44690
+ const observations = storedPositions.length;
44691
+ const positions = slim ? [] : storedPositions;
44280
44692
  const snapshots = slim ? [] : data["snapshots"] ?? [];
44281
44693
  const zones = data["zonesVisited"] ?? [];
44282
44694
  const classes = data["classes"];
@@ -44333,6 +44745,7 @@ var TrackStore = class {
44333
44745
  ...typeof hasFace === "boolean" ? { hasFace } : {},
44334
44746
  ...typeof hasRider === "boolean" ? { hasRider } : {},
44335
44747
  ...closedReason !== void 0 ? { closedReason } : {},
44748
+ observations,
44336
44749
  ...envelope !== null ? { envelope } : {}
44337
44750
  };
44338
44751
  }
@@ -51721,7 +52134,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
51721
52134
  return out;
51722
52135
  };
51723
52136
  await step("declareCollections", () => this.declareCollections(api));
51724
- await step("declareAlarmPanel", () => this.declareAlarmPanel(api));
52137
+ this.armAlarmPanelDeclaration(api);
51725
52138
  const logger = this.ctx.logger;
51726
52139
  const storage = await step("resolveMediaStorage", () => this.resolveMediaStorage(logger));
51727
52140
  const stores = await step("buildStores", () => this.buildStores(api, logger, storage));
@@ -51864,8 +52277,33 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
51864
52277
  * name over the operator's rename. What is left is the declaration and the
51865
52278
  * ports.
51866
52279
  */
52280
+ /**
52281
+ * Arm the declaration as BACKGROUND work and return immediately.
52282
+ *
52283
+ * `initialize()` is on the critical path of the whole capability graph — the
52284
+ * runner publishes an addon's manifest only after it returns — so an await
52285
+ * that can outlive a boot is an outage with a fixed blast radius
52286
+ * (docs/architecture/addon-lifecycle.md). This one could not merely outlive a
52287
+ * boot, it could not possibly succeed during one: the first port call is
52288
+ * `integrations.getByAddonId`, a hub-CORE namespace served by `$core-caps`,
52289
+ * which `main.ts` registers only AFTER `app.init()` returns, i.e. after the
52290
+ * whole addon boot. Core caps are excluded from the boot-window retry, so the
52291
+ * call sat in the 60 s UDS timeout — measured at 61 s of DETERMINISTIC delay
52292
+ * on every boot of this addon (D167).
52293
+ *
52294
+ * And the wait bought nothing. The retry ladder is the real path: on the live
52295
+ * boot of 2026-08-19 the panel was declared at t0+204 s with `attempts=1`,
52296
+ * i.e. on the ladder's FIRST tick, after the doomed boot attempt had already
52297
+ * been paid for.
52298
+ */
52299
+ armAlarmPanelDeclaration(api) {
52300
+ this.declareAlarmPanel(api).catch((err) => {
52301
+ this.ctx.logger.warn("alarm panel declaration could not be armed — the Alarm tab stays empty until this addon restarts", { meta: { error: require_dist.errMsg(err) } });
52302
+ });
52303
+ }
51867
52304
  async declareAlarmPanel(api) {
51868
52305
  if (await this.attemptAlarmPanelDeclaration(api)) return;
52306
+ if (this.shuttingDown) return;
51869
52307
  this.alarmPanelConvergence = startAlarmPanelConvergence({
51870
52308
  attempt: () => this.attemptAlarmPanelDeclaration(api),
51871
52309
  logger: this.ctx.logger.child("alarm")
@@ -52642,13 +53080,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52642
53080
  buildSummaryPorts(stores) {
52643
53081
  return {
52644
53082
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
52645
- return (await stores.trackStore.queryHistorical({
53083
+ const tracks = await stores.trackStore.queryHistorical({
52646
53084
  deviceId,
52647
53085
  since: sinceMs,
52648
53086
  until: untilMs,
52649
53087
  projection: "slim",
52650
53088
  limit
52651
- })).map((t) => ({
53089
+ });
53090
+ const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
53091
+ return tracks.map((t) => ({
52652
53092
  trackId: t.trackId,
52653
53093
  deviceId: t.deviceId,
52654
53094
  className: t.className,
@@ -52657,7 +53097,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52657
53097
  ...t.importance !== void 0 ? { importance: t.importance } : {},
52658
53098
  zones: [...t.zonesVisited],
52659
53099
  ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
52660
- ...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
53100
+ ...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
53101
+ ...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
52661
53102
  }));
52662
53103
  },
52663
53104
  listMediaInfo: async (trackId) => {