@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.
@@ -12091,6 +12091,18 @@ var NcOutbox = class {
12091
12091
  maxAttempts;
12092
12092
  drainBatchSize;
12093
12093
  drainInFlight = false;
12094
+ /**
12095
+ * Has {@link load} landed?
12096
+ *
12097
+ * The load moved OFF the boot critical path (see
12098
+ * `NotificationCenter.runBootBackfill`), so for the first seconds of a boot
12099
+ * the centre evaluates and enqueues with an EMPTY `knownIds` — and
12100
+ * `knownIds` is the dedup ledger. Until this flips, {@link enqueue} asks the
12101
+ * store itself instead. False is therefore not "the load failed", it is
12102
+ * "the horizon is not authoritative yet"; a load that FAILED leaves it false
12103
+ * forever, which keeps the per-enqueue guard on, which is exactly right.
12104
+ */
12105
+ horizonLoaded = false;
12094
12106
  constructor(deps) {
12095
12107
  this.store = deps.store;
12096
12108
  this.logger = deps.logger;
@@ -12112,7 +12124,26 @@ var NcOutbox = class {
12112
12124
  columns: [...NC_META_COLUMNS]
12113
12125
  });
12114
12126
  }
12115
- /** Hydrate pending rows + the dedup id horizon. Best-effort. */
12127
+ /**
12128
+ * Hydrate pending rows + the dedup id horizon. Best-effort.
12129
+ *
12130
+ * **Runs OFF the boot critical path** (2026-08-17: 743 rows over the UDS
12131
+ * store client, ~50 s, inside the `initialize()` that publishes the runner's
12132
+ * capability manifest). So it lands against a centre that has already been
12133
+ * evaluating, enqueueing and DELIVERING for as long as the query took — and
12134
+ * the rows it is holding are a snapshot taken before all of that.
12135
+ *
12136
+ * That is why an id this process already knows is SKIPPED rather than
12137
+ * applied. `knownIds` is only ever written by an in-process {@link enqueue}
12138
+ * or by this method, so an id already in it at apply time is one whose live
12139
+ * state (pending, or absent because it went terminal) is strictly newer than
12140
+ * the snapshot. Applying the snapshot over it would put a row that has
12141
+ * already been DELIVERED back into `pending`, and the operator would receive
12142
+ * the same notification twice.
12143
+ *
12144
+ * Nothing is lost by the same token: this method only ADDS to `pending`, so
12145
+ * a row enqueued while the query was in flight is untouched.
12146
+ */
12116
12147
  async load() {
12117
12148
  try {
12118
12149
  const rows = await this.store.query.query({
@@ -12122,19 +12153,30 @@ var NcOutbox = class {
12122
12153
  limit: 1e5
12123
12154
  }
12124
12155
  });
12156
+ let superseded = 0;
12125
12157
  for (const row of rows) {
12158
+ if (this.knownIds.has(row.id)) {
12159
+ superseded += 1;
12160
+ continue;
12161
+ }
12126
12162
  this.knownIds.add(row.id);
12127
12163
  const entry = rowToEntry$1(row.id, row.data);
12128
12164
  if (entry !== null && entry.status === "pending") this.pending.set(entry.id, entry);
12129
12165
  }
12166
+ this.horizonLoaded = true;
12130
12167
  this.logger.info("notification outbox loaded", { meta: {
12131
12168
  pending: this.pending.size,
12132
- known: this.knownIds.size
12169
+ known: this.knownIds.size,
12170
+ superseded
12133
12171
  } });
12134
12172
  } catch (err) {
12135
12173
  this.logger.warn("notification outbox load failed", { meta: { error: String(err) } });
12136
12174
  }
12137
12175
  }
12176
+ /** Is the dedup horizon authoritative? See {@link horizonLoaded}. */
12177
+ hydrated() {
12178
+ return this.horizonLoaded;
12179
+ }
12138
12180
  pendingCount() {
12139
12181
  return this.pending.size;
12140
12182
  }
@@ -12149,6 +12191,18 @@ var NcOutbox = class {
12149
12191
  for (const input of inputs) {
12150
12192
  const id = outboxEntryId(input);
12151
12193
  if (this.knownIds.has(id)) continue;
12194
+ if (!this.horizonLoaded && await this.rowExists(id)) {
12195
+ this.knownIds.add(id);
12196
+ this.logger.info("outbox enqueue deduped against the store — the horizon is still cold", {
12197
+ tags: { deviceId: input.deviceId },
12198
+ meta: {
12199
+ id,
12200
+ ruleId: input.ruleId,
12201
+ targetId: input.targetId
12202
+ }
12203
+ });
12204
+ continue;
12205
+ }
12152
12206
  const entry = {
12153
12207
  id,
12154
12208
  ruleId: input.ruleId,
@@ -12421,6 +12475,35 @@ var NcOutbox = class {
12421
12475
  this.pending.set(entry.id, retry);
12422
12476
  await this.mutate(retry);
12423
12477
  }
12478
+ /**
12479
+ * Does a row already exist under this dedup id?
12480
+ *
12481
+ * Asked ONLY while {@link horizonLoaded} is false. {@link persist} writes
12482
+ * with `set` — an UPSERT keyed by the dedup id — so an enqueue that ran
12483
+ * before the horizon landed would rewrite a row that had already been
12484
+ * DELIVERED back to `pending`, and the drain would send it again. That is
12485
+ * the D-3 `maxPerTrack: 1` guarantee, and it cannot be allowed to depend on
12486
+ * whether a 743-row query had finished.
12487
+ *
12488
+ * A read that FAILS answers `false`, i.e. the intent is enqueued. Duplicate
12489
+ * over lost: a duplicate notification is an annoyance the operator can see,
12490
+ * and a dropped one is a notification nobody ever knows was owed.
12491
+ */
12492
+ async rowExists(id) {
12493
+ try {
12494
+ const row = await this.store.get.query({
12495
+ collection: NC_OUTBOX_COLLECTION,
12496
+ key: id
12497
+ });
12498
+ return row !== null && row !== void 0;
12499
+ } catch (err) {
12500
+ this.logger.debug("outbox cold-horizon dedup read failed", { meta: {
12501
+ id,
12502
+ error: String(err)
12503
+ } });
12504
+ return false;
12505
+ }
12506
+ }
12424
12507
  async persist(entry) {
12425
12508
  await this.store.set.mutate({
12426
12509
  collection: NC_OUTBOX_COLLECTION,
@@ -14199,7 +14282,11 @@ function mosaicTextPlan(captions) {
14199
14282
  };
14200
14283
  }
14201
14284
  /** Grid shape for `n` tiles: as square as possible, rows never exceeding cols
14202
- * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip. */
14285
+ * by more than one — a 3×3 for nine, a 3×2 for five, never a 1×9 strip.
14286
+ *
14287
+ * `rows` is the load-bearing half — it is how many rows {@link mosaicRowCounts}
14288
+ * distributes into. `cols` describes the WIDEST row, and is no longer a cell
14289
+ * count: see {@link mosaicRowCounts} for why a `cols × rows` grid was the bug. */
14203
14290
  function mosaicGrid(n) {
14204
14291
  const count = Math.max(1, Math.floor(n));
14205
14292
  const cols = Math.ceil(Math.sqrt(count));
@@ -14209,31 +14296,84 @@ function mosaicGrid(n) {
14209
14296
  };
14210
14297
  }
14211
14298
  /**
14299
+ * How many tiles each ROW carries, top to bottom — and the reason the mosaic
14300
+ * can no longer have a hole in it.
14301
+ *
14302
+ * The layout used to be a `cols × rows` GRID, and a grid holds more cells than
14303
+ * tiles for every count that is not a perfect fit: 3 into a 2×2, 5 into a 3×2,
14304
+ * 7 and 8 into a 3×3. The composite only ever wrote the tiles, so the spare
14305
+ * cells stayed the canvas — `MOSAIC_BACKGROUND`, rgb 17 — and the operator's
14306
+ * three-tile digest was a quarter black. Measured on the shipped renderer:
14307
+ * 24.8% of a three-tile mosaic was background. His words: *"se ci sono 3
14308
+ * elementi non dovrebbe renderizzare buchi neri"*.
14309
+ *
14310
+ * Counting ROWS instead removes the failure by construction rather than by
14311
+ * patching it. Every row spans the full width and divides it among the tiles it
14312
+ * actually has, so a row is full whatever the remainder and there is no such
14313
+ * thing as a spare cell. The shapes fall out of it, and they are the ones asked
14314
+ * for: 1 ⇒ full frame, 2 ⇒ side by side, 3 ⇒ one big over two small, 4 ⇒ 2×2.
14315
+ *
14316
+ * **The fewer-tiled row comes FIRST.** Tiles arrive in the selection's order,
14317
+ * so row 0 is the head of the ranking; putting the remainder at the top would
14318
+ * make the WORST tile the big one.
14319
+ *
14320
+ * Every cell keeps {@link TILE_ASPECT}, so rows differ in height — which is
14321
+ * also what stops the letterbox: a full-width hero tile is 16:9 exactly, not a
14322
+ * 16:9 picture centred in a double-height cell with bars over and under it.
14323
+ */
14324
+ function mosaicRowCounts(n) {
14325
+ const count = Math.max(1, Math.floor(n));
14326
+ const { rows } = mosaicGrid(count);
14327
+ const base = Math.floor(count / rows);
14328
+ const remainder = count % rows;
14329
+ const counts = [];
14330
+ for (let row = 0; row < rows; row += 1) counts.push(row < rows - remainder ? base : base + 1);
14331
+ return counts;
14332
+ }
14333
+ /**
14212
14334
  * Where every tile lands. Pure — no bytes, no sharp, no clock.
14213
14335
  *
14214
14336
  * Exported because the rectangles are DATA the artefact should carry: a viewer
14215
14337
  * that wants a tappable mosaic reads these rather than re-deriving a layout
14216
14338
  * that could drift from the one actually rendered.
14339
+ *
14340
+ * The rectangles COVER the picture area exactly — that is the property the
14341
+ * spec asserts, and it is the one the old grid broke.
14217
14342
  */
14218
14343
  function mosaicLayout(input) {
14219
- const { cols, rows } = mosaicGrid(input.tileCount);
14220
- const maxWidth = input.maxWidth ?? 1920;
14221
- const tileWidth = Math.max(160, Math.floor(maxWidth / cols));
14344
+ const rowCounts = mosaicRowCounts(input.tileCount);
14345
+ const width = Math.max(1, Math.floor(input.maxWidth ?? 1920));
14346
+ const firstRowCount = rowCounts[0] ?? 1;
14347
+ const tileWidth = Math.floor(width / firstRowCount);
14222
14348
  const tileHeight = Math.round(tileWidth * TILE_ASPECT);
14223
14349
  const titleHeight = input.withTitle === true ? Math.round(tileHeight * TITLE_BAND_FRACTION) : 0;
14224
14350
  const tiles = [];
14225
- for (let index = 0; index < Math.max(1, input.tileCount); index += 1) tiles.push({
14226
- index,
14227
- x: index % cols * tileWidth,
14228
- y: titleHeight + Math.floor(index / cols) * tileHeight,
14229
- w: tileWidth,
14230
- h: tileHeight
14231
- });
14351
+ let y = titleHeight;
14352
+ let index = 0;
14353
+ for (const count of rowCounts) {
14354
+ const nominalWidth = Math.floor(width / count);
14355
+ const rowHeight = Math.round(nominalWidth * TILE_ASPECT);
14356
+ let x = 0;
14357
+ for (let inRow = 0; inRow < count; inRow += 1) {
14358
+ const w = inRow === count - 1 ? width - x : nominalWidth;
14359
+ tiles.push({
14360
+ index,
14361
+ x,
14362
+ y,
14363
+ w,
14364
+ h: rowHeight
14365
+ });
14366
+ x += w;
14367
+ index += 1;
14368
+ }
14369
+ y += rowHeight;
14370
+ }
14232
14371
  return {
14233
- width: cols * tileWidth,
14234
- height: titleHeight + rows * tileHeight,
14235
- cols,
14236
- rows,
14372
+ width,
14373
+ height: y,
14374
+ cols: Math.max(...rowCounts),
14375
+ rows: rowCounts.length,
14376
+ rowCounts,
14237
14377
  tileWidth,
14238
14378
  tileHeight,
14239
14379
  titleHeight,
@@ -14297,36 +14437,20 @@ function titleSvg(width, height, caption) {
14297
14437
  *
14298
14438
  * A tile sharp cannot read is DROPPED and counted, never composited as a blank
14299
14439
  * cell — a black square in a digest reads as "the camera saw nothing", which is
14300
- * the opposite of what happened. The grid is recomputed from the survivors so
14301
- * the mosaic has no hole.
14440
+ * the opposite of what happened. The layout is computed from the SURVIVORS, and
14441
+ * because {@link mosaicRowCounts} counts rows rather than grid cells there is no
14442
+ * remainder to leave empty either.
14302
14443
  *
14303
- * @throws only if sharp cannot produce the final canvas at all; the caller
14304
- * treats that as "no mosaic" and still delivers the counts.
14444
+ * @throws when every tile was dropped, and when sharp cannot produce the final
14445
+ * canvas at all; the caller treats both as "no mosaic" and still
14446
+ * delivers the counts.
14305
14447
  */
14306
14448
  async function renderMosaic(request, logger) {
14307
14449
  const plan = mosaicTextPlan(request.captions);
14308
- const decoded = [];
14309
- const probeLayout = mosaicLayout({
14310
- tileCount: Math.max(1, request.tiles.length),
14311
- ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {}
14312
- });
14450
+ const usable = [];
14313
14451
  for (const tile of request.tiles) try {
14314
- const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14315
- const fitted = sharp(tile.jpeg).resize({
14316
- width: probeLayout.tileWidth,
14317
- height: probeLayout.tileHeight,
14318
- fit: "contain",
14319
- background: MOSAIC_BACKGROUND
14320
- });
14321
- const resized = await (label.length === 0 ? fitted : fitted.composite([{
14322
- input: tileLabelSvg(probeLayout.tileWidth, probeLayout.tileHeight, label),
14323
- top: 0,
14324
- left: 0
14325
- }])).jpeg({ quality: 82 }).toBuffer();
14326
- decoded.push({
14327
- tile,
14328
- resized
14329
- });
14452
+ await sharp(tile.jpeg).metadata();
14453
+ usable.push(tile);
14330
14454
  } catch (err) {
14331
14455
  logger.warn("summary mosaic: a tile could not be decoded and was dropped", {
14332
14456
  tags: { deviceId: tile.deviceId },
@@ -14336,23 +14460,34 @@ async function renderMosaic(request, logger) {
14336
14460
  }
14337
14461
  });
14338
14462
  }
14339
- const droppedTiles = request.tiles.length - decoded.length;
14463
+ const droppedTiles = request.tiles.length - usable.length;
14464
+ if (usable.length === 0) throw new Error(`summary mosaic: no tile could be decoded (${request.tiles.length} offered)`);
14340
14465
  const caption = plan.titleBand ? request.caption?.trim() ?? "" : "";
14341
14466
  const layout = mosaicLayout({
14342
- tileCount: Math.max(1, decoded.length),
14467
+ tileCount: usable.length,
14343
14468
  ...request.maxWidth !== void 0 ? { maxWidth: request.maxWidth } : {},
14344
14469
  withTitle: caption.length > 0
14345
14470
  });
14346
- const composites = await Promise.all(decoded.map(async ({ resized }, index) => ({
14347
- input: layout.tileWidth === probeLayout.tileWidth && layout.tileHeight === probeLayout.tileHeight ? resized : await sharp(resized).resize({
14348
- width: layout.tileWidth,
14349
- height: layout.tileHeight,
14471
+ const composites = await Promise.all(usable.map(async (tile, index) => {
14472
+ const rect = layout.tiles[index];
14473
+ if (rect === void 0) throw new Error("summary mosaic: layout lost a tile");
14474
+ const label = plan.tileLabels ? tile.label?.trim() ?? "" : "";
14475
+ const fitted = sharp(tile.jpeg).resize({
14476
+ width: rect.w,
14477
+ height: rect.h,
14350
14478
  fit: "contain",
14351
14479
  background: MOSAIC_BACKGROUND
14352
- }).jpeg({ quality: 82 }).toBuffer(),
14353
- top: layout.tiles[index]?.y ?? 0,
14354
- left: layout.tiles[index]?.x ?? 0
14355
- })));
14480
+ });
14481
+ return {
14482
+ input: await (label.length === 0 ? fitted : fitted.composite([{
14483
+ input: tileLabelSvg(rect.w, rect.h, label),
14484
+ top: 0,
14485
+ left: 0
14486
+ }])).jpeg({ quality: 82 }).toBuffer(),
14487
+ top: rect.y,
14488
+ left: rect.x
14489
+ };
14490
+ }));
14356
14491
  if (caption.length > 0 && layout.titleHeight > 0) composites.unshift({
14357
14492
  input: titleSvg(layout.width, layout.titleHeight, caption),
14358
14493
  top: 0,
@@ -14568,6 +14703,36 @@ function buildSummaryOutboxInputs(input) {
14568
14703
  * the quota degrades honestly: the best cameras get their one tile, the rest
14569
14704
  * are counted as dropped and the count rides the log line — never silently.
14570
14705
  *
14706
+ * ## Only tracks the pipeline PROMOTED
14707
+ *
14708
+ * A digest is retrospective. The operator's own framing, verbatim: *"ideale
14709
+ * sarebbe considerare solo tracce che vengono alla fine promosse a tracce, dato
14710
+ * che il summary non è atteso di essere realtime"* — the window is closed
14711
+ * before this function is called, so the selection may wait for the pipeline's
14712
+ * verdict instead of guessing at it.
14713
+ *
14714
+ * The verdict is `SortTracker`'s own promotion threshold: a candidate is not
14715
+ * emitted as a track until it has been matched on `minHits` frames
14716
+ * (`isConfirmedForEmit`, per-device setting, *"minimum consecutive detection
14717
+ * hits before a track is promoted to output"*). The gate here re-applies that
14718
+ * same threshold to what the CLOSED row actually left behind — the count of
14719
+ * recorded sightings on its trajectory — and the port that builds a candidate
14720
+ * is the only thing that knows the device's `minHits`.
14721
+ *
14722
+ * This is not a movement heuristic and deliberately so: `totalDistance` and
14723
+ * `state` were both measured against live traffic and both hide real passages.
14724
+ * What they cannot see is the shape this gate is for — a row whose whole life
14725
+ * is one or two frames. Measured on device 617 (2026-08-19 04:12): two rows,
14726
+ * `closedReason` NULL so the parked pass is blind to them, dwell 53 ms,
14727
+ * `totalDistance` 0, two recorded sightings each. And `minImportance` cannot
14728
+ * cover it either, structurally: the static-track gate that demotes a frozen
14729
+ * subject needs two centroids to measure anything
14730
+ * (`computeStaticTrackMetrics`), so a single-sighting row is exactly the shape
14731
+ * it never scores down — 33 of the live hub's 34 such rows sit at or above 0.2.
14732
+ *
14733
+ * There is no rule field to turn it off. A digest of things that were never
14734
+ * tracks has no audience, and this repo has shipped knobs nobody read.
14735
+ *
14571
14736
  * ## Zones are scoped PER CAMERA
14572
14737
  *
14573
14738
  * `filters.zones` is a flat id list on a rule that spans cameras, and zone ids
@@ -14591,15 +14756,20 @@ function dwellSeconds(candidate) {
14591
14756
  * branch that discards work silently reads as "it never happened".
14592
14757
  */
14593
14758
  function filterSummaryCandidates(candidates, filters, zoneOwner) {
14594
- const moving = filters?.includeStationary === true ? candidates : candidates.filter((candidate) => candidate.stationary !== true);
14595
- const droppedStationary = candidates.length - moving.length;
14759
+ const promoted = candidates.filter((candidate) => candidate.confirmed !== false);
14760
+ const droppedUnconfirmed = candidates.length - promoted.length;
14761
+ const unknownConfirmation = promoted.filter((candidate) => candidate.confirmed === void 0).length;
14762
+ const moving = filters?.includeStationary === true ? promoted : promoted.filter((candidate) => candidate.stationary !== true);
14763
+ const droppedStationary = promoted.length - moving.length;
14596
14764
  if (filters === void 0) return {
14597
14765
  kept: [...moving],
14598
14766
  droppedByClass: 0,
14599
14767
  droppedByZone: 0,
14600
14768
  droppedByImportance: 0,
14601
14769
  droppedByDwell: 0,
14602
- droppedStationary
14770
+ droppedStationary,
14771
+ droppedUnconfirmed,
14772
+ unknownConfirmation
14603
14773
  };
14604
14774
  const include = filters.classes;
14605
14775
  const exclude = new Set(filters.classesExclude ?? []);
@@ -14645,7 +14815,9 @@ function filterSummaryCandidates(candidates, filters, zoneOwner) {
14645
14815
  droppedByZone,
14646
14816
  droppedByImportance,
14647
14817
  droppedByDwell,
14648
- droppedStationary
14818
+ droppedStationary,
14819
+ droppedUnconfirmed,
14820
+ unknownConfirmation
14649
14821
  };
14650
14822
  }
14651
14823
  /**
@@ -15021,7 +15193,9 @@ var NcSummaryProducer = class {
15021
15193
  droppedByZone: filtered.droppedByZone,
15022
15194
  droppedByImportance: filtered.droppedByImportance,
15023
15195
  droppedByDwell: filtered.droppedByDwell,
15024
- droppedStationary: filtered.droppedStationary
15196
+ droppedStationary: filtered.droppedStationary,
15197
+ droppedUnconfirmed: filtered.droppedUnconfirmed,
15198
+ unknownConfirmation: filtered.unknownConfirmation
15025
15199
  } });
15026
15200
  if (opts.stamp) await this.deps.store.markGenerated(rule.id, window.endMs);
15027
15201
  return {
@@ -15085,6 +15259,8 @@ var NcSummaryProducer = class {
15085
15259
  scanned: candidates.length,
15086
15260
  matched: filtered.kept.length,
15087
15261
  droppedStationary: filtered.droppedStationary,
15262
+ droppedUnconfirmed: filtered.droppedUnconfirmed,
15263
+ unknownConfirmation: filtered.unknownConfirmation,
15088
15264
  droppedByClass: filtered.droppedByClass,
15089
15265
  tiles: loaded.length,
15090
15266
  cameras: new Set(loaded.map((t) => t.candidate.deviceId)).size,
@@ -18838,6 +19014,18 @@ var NotificationCenter = class NotificationCenter {
18838
19014
  drainTicks = 0;
18839
19015
  evaluationActive = false;
18840
19016
  /**
19017
+ * The armed boot back-fill — see {@link runBootBackfill}. Held so
19018
+ * {@link whenHydrated} can be awaited by an operator surface or a test that
19019
+ * needs the durable state, and so nothing re-arms it.
19020
+ */
19021
+ hydration = null;
19022
+ /**
19023
+ * The instant `start()` opened the intake. Everything persisted from here on
19024
+ * is evaluated live by THIS process, which is what bounds the boot reconcile
19025
+ * at its late end — see {@link reconcile}.
19026
+ */
19027
+ intakeOpenedAt = 0;
19028
+ /**
18841
19029
  * The tap-through buttons for ONE delivery, each with its own single-use
18842
19030
  * token.
18843
19031
  *
@@ -19339,49 +19527,190 @@ var NotificationCenter = class NotificationCenter {
19339
19527
  /**
19340
19528
  * Load rules (every node — the cap provider serves CRUD from any node).
19341
19529
  * When `evaluation` is true (the designated post-processing node ONLY),
19342
- * also hydrate the outbox, seed cooldowns, run the crash-gap reconcile
19343
- * and start the drain + rule-reload timers.
19530
+ * open the intake and arm the drain + rule-reload timers.
19531
+ *
19532
+ * ## `start()` resolving means "the centre is EVALUATING"
19533
+ *
19534
+ * It does NOT mean the durable back-fill has finished. That contract was
19535
+ * written on 2026-08-14 for the two producers (a timelapse render inside
19536
+ * `initialize()` took eight capabilities off the cluster for two render
19537
+ * timeouts) and it applies to the centre itself for the same reason: this
19538
+ * method is awaited by `buildNotificationCenter`, which is awaited by
19539
+ * `pipeline-analytics.onInitialize()`, and `addon-runner` publishes a
19540
+ * child's capability manifest only AFTER `initialize()` returns.
19541
+ *
19542
+ * Measured on the live hub 2026-08-17 at ZERO transport contention, the
19543
+ * `buildNotificationCenter` boot step took **270 s** — all of it durable
19544
+ * back-fill: `hydrateLiveness` (775 subjects), `outbox.load` (743 rows),
19545
+ * and two N+1 per-device refresh loops (`readDeviceStates` and
19546
+ * `readDeviceZoneIds` issue ONE RPC per watched device, serially). For
19547
+ * those 270 s the cluster had no provider for any capability this addon
19548
+ * declares, and the intake below had not been opened either — so nothing
19549
+ * arriving in that window was late, it was never evaluated at all.
19550
+ *
19551
+ * ## What stays here, and why
19552
+ *
19553
+ * Only reads a notification cannot be CORRECT without, and each is one
19554
+ * store query:
19555
+ *
19556
+ * - the TEXT catalog — a notification built before it goes out in the
19557
+ * wrong language;
19558
+ * - the RULES — without them there is nothing to evaluate;
19559
+ * - the SNOOZE windows and the per-camera MUTES — both SUPPRESS, so a cold
19560
+ * one fails OPEN: the operator gets pushes they explicitly silenced;
19561
+ * - the timelapse + summary rule sets, which the producers armed below read.
19562
+ *
19563
+ * Everything else is a mirror the 30 s reload tick re-reads anyway, or a
19564
+ * boot-once hydration whose cold behaviour is already defined and safe —
19565
+ * see {@link runBootBackfill}, which names the direction for each one.
19344
19566
  */
19345
19567
  async start(opts) {
19346
- await this.reloadTexts();
19347
- await this.rules.load();
19348
- await this.snoozes.load();
19349
- await this.deviceMutes.load();
19350
- await this.timelapseRules.load();
19351
- await this.summaryRules.load();
19568
+ const t0 = Date.now();
19569
+ let phaseT = t0;
19570
+ const timed = async (phase, run) => {
19571
+ await run();
19572
+ const now = Date.now();
19573
+ this.logger.info("notification start phase", { meta: {
19574
+ phase,
19575
+ ms: now - phaseT,
19576
+ sinceStartMs: now - t0
19577
+ } });
19578
+ phaseT = now;
19579
+ };
19580
+ await timed("texts", () => this.reloadTexts());
19581
+ await timed("rules", () => this.rules.load());
19582
+ await timed("snoozes", () => this.snoozes.load());
19583
+ await timed("deviceMutes", () => this.deviceMutes.load());
19584
+ await timed("timelapseRules", () => this.timelapseRules.load());
19585
+ await timed("summaryRules", () => this.summaryRules.load());
19352
19586
  this.refreshOccupancyWatch();
19353
19587
  if (!opts.evaluation) return;
19354
- await this.deviceStates.refresh();
19355
- await this.deviceDirectory.refresh();
19356
- await this.hydrateLiveness();
19357
- await this.zoneOwners.refresh();
19358
19588
  this.sceneStates.refresh();
19589
+ this.intakeOpenedAt = this.now();
19359
19590
  this.evaluationActive = true;
19360
- await this.outbox.load();
19361
- await this.seedCooldowns();
19362
- await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
19363
- await this.hydrateOccupancy();
19364
- await this.reconcile();
19365
19591
  this.drainTimer = setInterval(() => {
19366
19592
  this.drainTick();
19367
19593
  }, this.deps.drainIntervalMs ?? DEFAULT_DRAIN_INTERVAL_MS);
19368
19594
  this.reloadTimer = setInterval(() => {
19369
19595
  this.reloadRules();
19370
19596
  }, this.deps.ruleReloadIntervalMs ?? DEFAULT_RULE_RELOAD_INTERVAL_MS);
19371
- if (this.timelapseScheduler !== null) await this.timelapseScheduler.start().catch((err) => {
19597
+ const timelapseScheduler = this.timelapseScheduler;
19598
+ if (timelapseScheduler !== null) await timed("timelapseProducer", () => timelapseScheduler.start()).catch((err) => {
19372
19599
  this.logger.warn("timelapse producer did not start", { meta: { error: String(err) } });
19373
19600
  });
19374
- if (this.summaryProducer !== null) await this.summaryProducer.start().catch((err) => {
19601
+ const summaryProducer = this.summaryProducer;
19602
+ if (summaryProducer !== null) await timed("summaryProducer", () => summaryProducer.start()).catch((err) => {
19375
19603
  this.logger.warn("summary producer did not start", { meta: { error: String(err) } });
19376
19604
  });
19605
+ this.armBootBackfill();
19377
19606
  this.logger.info("notification center started", { meta: {
19607
+ ms: Date.now() - t0,
19378
19608
  rules: this.rules.list().length,
19379
- pendingOutbox: this.outbox.pendingCount(),
19380
19609
  timelapseRules: this.timelapseRules.list().length,
19381
19610
  timelapseProducer: this.timelapseScheduler !== null,
19382
19611
  summaryRules: this.summaryRules.list().length,
19383
19612
  summaryProducer: this.summaryProducer !== null,
19384
- summaryAi: this.summaryAi !== null
19613
+ summaryAi: this.summaryAi !== null,
19614
+ backfill: "armed"
19615
+ } });
19616
+ }
19617
+ /**
19618
+ * The durable back-fill, finished.
19619
+ *
19620
+ * Resolves immediately when nothing was armed (a non-evaluation node, or a
19621
+ * centre that was never started). Never rejects — every phase owns its own
19622
+ * failure. Exists so a caller that genuinely needs the hydrated state — a
19623
+ * test asserting the reseed, an operator surface reporting readiness — can
19624
+ * ask for it instead of sleeping.
19625
+ */
19626
+ whenHydrated() {
19627
+ return this.hydration ?? Promise.resolve();
19628
+ }
19629
+ /**
19630
+ * ONE drain pass, awaited — the interval's own tick, called directly.
19631
+ *
19632
+ * The same code path the timer drives, deliberately: a caller that flushes
19633
+ * the queue through a parallel implementation is testing the parallel
19634
+ * implementation.
19635
+ */
19636
+ async drainNow() {
19637
+ await this.drainTick();
19638
+ }
19639
+ armBootBackfill() {
19640
+ if (this.hydration !== null) return;
19641
+ this.hydration = this.runBootBackfill().catch((err) => {
19642
+ this.logger.warn("notification back-fill could not be armed", { meta: { error: String(err) } });
19643
+ });
19644
+ }
19645
+ /**
19646
+ * The boot back-fill: everything `start()` used to await.
19647
+ *
19648
+ * Ordered by what a cold mirror COSTS, most expensive first — this is a
19649
+ * sequence, not a `Promise.all`, because these reads share one UDS store
19650
+ * client and racing them is how a boot becomes a queue again.
19651
+ *
19652
+ * 1. the device directory + the LIVENESS ledger. Cold costs a FLOOD (D130:
19653
+ * one "camera online" per camera on the installation, 2026-08-13), so it
19654
+ * goes first. The ledger's own contract holds in the window: an empty
19655
+ * mirror SEEDS silently rather than notifying (D49, fail toward
19656
+ * discard), and device liveness is additionally held for 5 minutes by
19657
+ * `DeviceLivenessHoldoff` before it can notify at all — which is far
19658
+ * longer than this phase.
19659
+ * 2. the OCCUPANCY state. Cold costs a re-announced edge (the cold-baseline
19660
+ * bug, `occupancy-cold-seed.spec.ts`).
19661
+ * 3. the OUTBOX horizon, then the COOLDOWN seed. Both cost a REPEAT while
19662
+ * cold — a dedup id nobody remembers, a cooldown nobody recovered — and
19663
+ * the horizon goes first because it is what makes every phase after it
19664
+ * (the reconcile above all) idempotent. The outbox's own cold window is
19665
+ * made safe structurally — see `NcOutbox.load` / `rowExists`.
19666
+ * 4. the ZONE ownership. Cold costs a WIDER verdict (a zone-scoped rule
19667
+ * evaluates camera-wide), so it precedes the gates that fail closed.
19668
+ * 5. the DEVICE STATES. Cold costs silence — `undefined` never matches — so
19669
+ * it is the safest of the lot and goes after the N+1 above it.
19670
+ * 6. the RECONCILE, which replays records persisted across the crash gap.
19671
+ * After the outbox on purpose: the replay is idempotent only through the
19672
+ * dedup horizon.
19673
+ * 7. the retention prune. Pure hygiene, nothing reads its result.
19674
+ *
19675
+ * Every phase reports its own ms. The 2026-08-17 boot could not say which of
19676
+ * these owned the 270 s because none of them said anything, and the addon's
19677
+ * own per-step timing stopped at `buildNotificationCenter`.
19678
+ */
19679
+ async runBootBackfill() {
19680
+ const t0 = Date.now();
19681
+ const phase = async (name, run) => {
19682
+ if (!this.evaluationActive) return false;
19683
+ const at = Date.now();
19684
+ try {
19685
+ await run();
19686
+ } catch (err) {
19687
+ this.logger.warn("notification back-fill phase failed", { meta: {
19688
+ phase: name,
19689
+ error: String(err)
19690
+ } });
19691
+ }
19692
+ this.logger.info("notification back-fill phase", { meta: {
19693
+ phase: name,
19694
+ ms: Date.now() - at,
19695
+ sinceStartMs: Date.now() - t0
19696
+ } });
19697
+ return true;
19698
+ };
19699
+ if (!await phase("deviceDirectory", () => this.deviceDirectory.refresh())) return;
19700
+ if (!await phase("liveness", () => this.hydrateLiveness())) return;
19701
+ if (!await phase("occupancy", () => this.hydrateOccupancy())) return;
19702
+ if (!await phase("outbox", () => this.outbox.load())) return;
19703
+ if (!await phase("cooldowns", () => this.seedCooldowns())) return;
19704
+ if (!await phase("zoneOwners", () => this.zoneOwners.refresh())) return;
19705
+ if (!await phase("deviceStates", () => this.deviceStates.refresh())) return;
19706
+ if (!await phase("reconcile", () => this.reconcile())) return;
19707
+ if (!await phase("prune", async () => {
19708
+ await this.outbox.pruneBefore(this.now() - OUTBOX_RETENTION_MS);
19709
+ })) return;
19710
+ this.logger.info("notification center back-fill complete", { meta: {
19711
+ ms: Date.now() - t0,
19712
+ pendingOutbox: this.outbox.pendingCount(),
19713
+ dedupHorizon: this.outbox.hydrated()
19385
19714
  } });
19386
19715
  }
19387
19716
  async stop() {
@@ -19399,6 +19728,7 @@ var NotificationCenter = class NotificationCenter {
19399
19728
  this.addonUpdates.dispose();
19400
19729
  this.livenessHoldoff.dispose();
19401
19730
  this.evaluationActive = false;
19731
+ this.hydration = null;
19402
19732
  }
19403
19733
  /**
19404
19734
  * Consume ONE event. THE entry point of the notification path — every
@@ -21581,14 +21911,31 @@ var NotificationCenter = class NotificationCenter {
21581
21911
  });
21582
21912
  }
21583
21913
  }
21584
- /** Boot crash-gap reconcile — see the module docstring. */
21914
+ /**
21915
+ * Boot crash-gap reconcile — see the module docstring.
21916
+ *
21917
+ * **Bounded at BOTH ends.** `since` closes the gap the previous process left;
21918
+ * {@link intakeOpenedAt} closes the one this one would otherwise open. The
21919
+ * reconcile used to run before the intake existed, so "a record newer than
21920
+ * the watermark" and "a record this process has not evaluated" were the same
21921
+ * set. They are not any more: the reconcile is a back-fill phase now, and
21922
+ * every record persisted since `start()` opened the intake has ALREADY been
21923
+ * evaluated in-process — the exact sentence `drainTick` uses to justify
21924
+ * advancing the watermark to `now`.
21925
+ *
21926
+ * Replaying one of those is not merely redundant. The outbox dedup id makes
21927
+ * the DELIVERY idempotent, but a rule's `onTrigger` sequence is actuated
21928
+ * before the cooldown gate and before any dedup (see `runRuleActions`), so a
21929
+ * replay would open the gate, arm the alarm or sound the siren a second time.
21930
+ */
21585
21931
  async reconcile() {
21586
21932
  const now = this.now();
21587
21933
  const watermark = await this.outbox.getWatermark();
21588
21934
  const windowStart = now - (this.deps.reconcileWindowMs ?? DEFAULT_RECONCILE_WINDOW_MS);
21589
21935
  const since = Math.max(windowStart, (watermark ?? 0) - RECONCILE_OVERLAP_MS);
21936
+ const until = this.intakeOpenedAt;
21590
21937
  try {
21591
- const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].sort((a, b) => a.timestamp - b.timestamp);
21938
+ const ordered = [...await this.deps.listObjectEventsSince(since, RECONCILE_SCAN_LIMIT)].filter((e) => e.timestamp < until).sort((a, b) => a.timestamp - b.timestamp);
21592
21939
  for (const event of ordered) {
21593
21940
  const phase = packagePhaseOf(event);
21594
21941
  this.consumeEvent(asReconcile(phase !== null ? incomingFromPackageEvent(event, phase) : incomingFromObjectEvent(event)));
@@ -22229,6 +22576,16 @@ function planRuleTest(input) {
22229
22576
  * never be re-owned through a patch.
22230
22577
  * - `nc.setRuleTargetEnabled` — a user opting HIS OWN target out of a rule
22231
22578
  * VISIBLE to him (his own personal rule OR a global/admin rule).
22579
+ * - the TIMELAPSE and DIGEST families (`nc.*TimelapseRule*`,
22580
+ * `nc.*SummaryRule*`) repeat that model exactly over their own stores. The
22581
+ * digest half was `auth: 'admin'` in v1 and was relaxed once the operator
22582
+ * closed the cost question; `nc.listSummaryRules` reports the current answer
22583
+ * as `canAuthor` so a client talking to an older hub degrades to a read-only
22584
+ * list instead of discovering the refusal as a 403 on a filled-in form.
22585
+ * - the three TEST actions (`nc.injectTestEvent`, `nc.testRule`,
22586
+ * `nc.testTimelapseRule`, `nc.testSummaryRule`) stay `auth: 'admin'`
22587
+ * regardless of who owns the rule: they DELIVER, to every target the rule
22588
+ * addresses, on other people's phones.
22232
22589
  *
22233
22590
  * Target-ownership limits (personal-rule delivery target list + the opt-out
22234
22591
  * target) are enforced against the notifiers target catalog; ADMINS bypass the
@@ -22447,7 +22804,25 @@ var ncActions = defineCustomActions({
22447
22804
  auth: "admin",
22448
22805
  caller: "required"
22449
22806
  }),
22450
- "nc.listSummaryRules": customAction(object({}), object({ rules: array(NcViewerSummaryRuleSchema) }), { caller: "required" }),
22807
+ "nc.listSummaryRules": customAction(object({}), object({
22808
+ rules: array(NcViewerSummaryRuleSchema),
22809
+ /**
22810
+ * DOES THIS HUB LET THE CALLER AUTHOR ONE? The capability probe, and the
22811
+ * only honest way a client can know.
22812
+ *
22813
+ * `nc.upsertSummaryRule` was `auth: 'admin'` until the operator closed
22814
+ * that decision, and a viewer talking to a hub that still refuses has to
22815
+ * find out BEFORE it offers a "New summary rule" button — otherwise the
22816
+ * only feedback is a 403 taken in front of the operator on a form they
22817
+ * had already filled in. ADDITIVE and therefore `.optional()` on every
22818
+ * client mirror: absent means an older hub, which the viewer reads as "no
22819
+ * authoring" and degrades to a read-only list with a note.
22820
+ *
22821
+ * Derived from the same predicate the handlers enforce — never a constant
22822
+ * `true` — so a hub that re-tightened the auth could not say otherwise.
22823
+ */
22824
+ canAuthor: boolean()
22825
+ }), { caller: "required" }),
22451
22826
  /**
22452
22827
  * ONE action for create AND update, like `nc.upsertTimelapseRule`: a digest
22453
22828
  * rule is authored as a whole (cameras + window + filters + targets) and the
@@ -22456,10 +22831,18 @@ var ncActions = defineCustomActions({
22456
22831
  *
22457
22832
  * `ruleId` absent ⇒ create from `rule`; present ⇒ patch.
22458
22833
  *
22459
- * `auth: 'admin'` in v1 (operator decision): a digest spans cameras and, once
22460
- * P2 lands, consumes the local vision model. A personal rule that fans vision
22461
- * calls across other people's cameras is a decision to take after the real
22462
- * cost has been watched for a few weeks, not before.
22834
+ * `caller: 'required'`, NOT `auth: 'admin'`. It was admin-only in v1 because
22835
+ * a digest spans cameras and consumes the local vision model, and that cost
22836
+ * had not been watched yet. The operator closed the decision, so this now
22837
+ * carries exactly the auth the two rule families beside it carry: ownership
22838
+ * is enforced in the handler, `ownerUserId` is STAMPED from the resolved
22839
+ * caller, a personal rule may deliver only to targets that caller OWNS, and an
22840
+ * admin still authors GLOBAL rules. No new ownership model was invented for
22841
+ * it — `NcSummaryStore` has had `ownerUserId` / `listForOwner` since it was
22842
+ * written and they were simply not reachable.
22843
+ *
22844
+ * `nc.testSummaryRule` stays admin: that one DELIVERS, on other people's
22845
+ * phones.
22463
22846
  */
22464
22847
  "nc.upsertSummaryRule": customAction(object({
22465
22848
  ruleId: string().optional(),
@@ -22467,12 +22850,10 @@ var ncActions = defineCustomActions({
22467
22850
  patch: NcSummaryRulePatchSchema.optional()
22468
22851
  }), object({ rule: NcSummaryRuleSchema }), {
22469
22852
  kind: "mutation",
22470
- auth: "admin",
22471
22853
  caller: "required"
22472
22854
  }),
22473
22855
  "nc.deleteSummaryRule": customAction(object({ ruleId: string() }), object({ success: literal(true) }), {
22474
22856
  kind: "mutation",
22475
- auth: "admin",
22476
22857
  caller: "required"
22477
22858
  }),
22478
22859
  /**
@@ -22566,6 +22947,17 @@ var ncActions = defineCustomActions({
22566
22947
  * a surface that distinguishes them reports what other people own. */
22567
22948
  "nc.getArtifact": customAction(object({ artifactId: string().min(1) }), object({ artifact: NcArtifactRecordSchema.nullable() }), { caller: "required" })
22568
22949
  });
22950
+ /**
22951
+ * Does THIS build let a non-admin author a digest rule?
22952
+ *
22953
+ * A named constant rather than a literal `true` at the call site, so the one
22954
+ * fact `nc.listSummaryRules.canAuthor` reports and the auth the upsert actually
22955
+ * carries sit beside each other and are changed together. If the operator ever
22956
+ * re-tightens the action, this goes to `false` in the same edit and the viewer
22957
+ * degrades on the next list — instead of the app discovering it by taking a 403
22958
+ * on a form the operator had already filled in.
22959
+ */
22960
+ var SUMMARY_AUTHORING_OPEN = true;
22569
22961
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
22570
22962
  function requireCaller$1(caller) {
22571
22963
  if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
@@ -22626,6 +23018,22 @@ function makeNcActionHandlers(deps) {
22626
23018
  if (editor === void 0) throw new Error("this node does not host the notification text catalog — use the hub");
22627
23019
  return editor;
22628
23020
  };
23021
+ /**
23022
+ * A digest rule the caller may EDIT. Same shape and same admin bypass as
23023
+ * {@link assertTimelapseEditable} — deliberately, because a second ownership
23024
+ * model for the third rule family is a second place to get it wrong.
23025
+ *
23026
+ * A GLOBAL rule (no `ownerUserId`, what an admin authors) is VISIBLE to
23027
+ * everyone and editable by admins only: `listForOwner` returns it, so a user
23028
+ * sees it in the list, and `readOnly` says so.
23029
+ */
23030
+ const assertSummaryEditable = (ruleId, caller, sum) => {
23031
+ const rule = sum.store.get(ruleId);
23032
+ if (rule === null) throw new Error(`forbidden: summary rule not found: ${ruleId}`);
23033
+ if (caller.isAdmin) return rule;
23034
+ if (rule.ownerUserId !== caller.userId) throw new Error(`forbidden: summary rule not owned: ${ruleId}`);
23035
+ return rule;
23036
+ };
22629
23037
  /** A timelapse rule the caller may EDIT. Same admin bypass as `NcRule`. */
22630
23038
  const assertTimelapseEditable = (ruleId, caller, tl) => {
22631
23039
  const rule = tl.store.get(ruleId);
@@ -22896,21 +23304,23 @@ function makeNcActionHandlers(deps) {
22896
23304
  "nc.listSummaryRules": async (_input, caller) => {
22897
23305
  const c = requireCaller$1(caller);
22898
23306
  const sum = requireSummary();
22899
- return { rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
22900
- ...r,
22901
- readOnly: !c.isAdmin
22902
- })) };
23307
+ return {
23308
+ rules: (c.isAdmin ? sum.store.list() : sum.store.listForOwner(c.userId)).map((r) => ({
23309
+ ...r,
23310
+ readOnly: !c.isAdmin && r.ownerUserId !== c.userId
23311
+ })),
23312
+ canAuthor: SUMMARY_AUTHORING_OPEN
23313
+ };
22903
23314
  },
22904
23315
  "nc.upsertSummaryRule": async (input, caller) => {
22905
23316
  const c = requireCaller$1(caller);
22906
- if (!c.isAdmin) throw new Error("forbidden: admin required to author a summary rule");
22907
23317
  const sum = requireSummary();
22908
23318
  if (input.ruleId === void 0) {
22909
23319
  if (input.rule === void 0) throw new Error("upsertSummaryRule: rule or ruleId required");
22910
23320
  const parsed = NcSummaryRuleInputSchema.parse(input.rule);
22911
23321
  assertSummaryBudget(parsed);
22912
23322
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
22913
- const rule = await sum.store.create(parsed, c.userId);
23323
+ const rule = await sum.store.create(parsed, c.userId, c.isAdmin ? void 0 : c.userId);
22914
23324
  deps.logger.info("summary rule created", { meta: {
22915
23325
  ruleId: rule.id,
22916
23326
  owner: c.userId,
@@ -22918,8 +23328,7 @@ function makeNcActionHandlers(deps) {
22918
23328
  } });
22919
23329
  return { rule };
22920
23330
  }
22921
- const existing = sum.store.get(input.ruleId);
22922
- if (existing === null) throw new Error(`forbidden: summary rule not found: ${input.ruleId}`);
23331
+ const existing = assertSummaryEditable(input.ruleId, c, sum);
22923
23332
  if (input.patch === void 0) throw new Error("upsertSummaryRule: patch required");
22924
23333
  const patch = NcSummaryRulePatchSchema.parse(input.patch);
22925
23334
  assertSummaryBudget({
@@ -22936,8 +23345,9 @@ function makeNcActionHandlers(deps) {
22936
23345
  },
22937
23346
  "nc.deleteSummaryRule": async (input, caller) => {
22938
23347
  const c = requireCaller$1(caller);
22939
- if (!c.isAdmin) throw new Error("forbidden: admin required to delete a summary rule");
22940
- await requireSummary().store.delete(input.ruleId);
23348
+ const sum = requireSummary();
23349
+ assertSummaryEditable(input.ruleId, c, sum);
23350
+ await sum.store.delete(input.ruleId);
22941
23351
  deps.logger.info("summary rule deleted", { meta: {
22942
23352
  ruleId: input.ruleId,
22943
23353
  owner: c.userId
@@ -44216,7 +44626,9 @@ var TrackStore = class {
44216
44626
  */
44217
44627
  rowToTrack(id, data, projection) {
44218
44628
  const slim = projection === "slim";
44219
- const positions = slim ? [] : data["positions"] ?? [];
44629
+ const storedPositions = data["positions"] ?? [];
44630
+ const observations = storedPositions.length;
44631
+ const positions = slim ? [] : storedPositions;
44220
44632
  const snapshots = slim ? [] : data["snapshots"] ?? [];
44221
44633
  const zones = data["zonesVisited"] ?? [];
44222
44634
  const classes = data["classes"];
@@ -44273,6 +44685,7 @@ var TrackStore = class {
44273
44685
  ...typeof hasFace === "boolean" ? { hasFace } : {},
44274
44686
  ...typeof hasRider === "boolean" ? { hasRider } : {},
44275
44687
  ...closedReason !== void 0 ? { closedReason } : {},
44688
+ observations,
44276
44689
  ...envelope !== null ? { envelope } : {}
44277
44690
  };
44278
44691
  }
@@ -51661,7 +52074,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
51661
52074
  return out;
51662
52075
  };
51663
52076
  await step("declareCollections", () => this.declareCollections(api));
51664
- await step("declareAlarmPanel", () => this.declareAlarmPanel(api));
52077
+ this.armAlarmPanelDeclaration(api);
51665
52078
  const logger = this.ctx.logger;
51666
52079
  const storage = await step("resolveMediaStorage", () => this.resolveMediaStorage(logger));
51667
52080
  const stores = await step("buildStores", () => this.buildStores(api, logger, storage));
@@ -51804,8 +52217,33 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
51804
52217
  * name over the operator's rename. What is left is the declaration and the
51805
52218
  * ports.
51806
52219
  */
52220
+ /**
52221
+ * Arm the declaration as BACKGROUND work and return immediately.
52222
+ *
52223
+ * `initialize()` is on the critical path of the whole capability graph — the
52224
+ * runner publishes an addon's manifest only after it returns — so an await
52225
+ * that can outlive a boot is an outage with a fixed blast radius
52226
+ * (docs/architecture/addon-lifecycle.md). This one could not merely outlive a
52227
+ * boot, it could not possibly succeed during one: the first port call is
52228
+ * `integrations.getByAddonId`, a hub-CORE namespace served by `$core-caps`,
52229
+ * which `main.ts` registers only AFTER `app.init()` returns, i.e. after the
52230
+ * whole addon boot. Core caps are excluded from the boot-window retry, so the
52231
+ * call sat in the 60 s UDS timeout — measured at 61 s of DETERMINISTIC delay
52232
+ * on every boot of this addon (D167).
52233
+ *
52234
+ * And the wait bought nothing. The retry ladder is the real path: on the live
52235
+ * boot of 2026-08-19 the panel was declared at t0+204 s with `attempts=1`,
52236
+ * i.e. on the ladder's FIRST tick, after the doomed boot attempt had already
52237
+ * been paid for.
52238
+ */
52239
+ armAlarmPanelDeclaration(api) {
52240
+ this.declareAlarmPanel(api).catch((err) => {
52241
+ this.ctx.logger.warn("alarm panel declaration could not be armed — the Alarm tab stays empty until this addon restarts", { meta: { error: errMsg(err) } });
52242
+ });
52243
+ }
51807
52244
  async declareAlarmPanel(api) {
51808
52245
  if (await this.attemptAlarmPanelDeclaration(api)) return;
52246
+ if (this.shuttingDown) return;
51809
52247
  this.alarmPanelConvergence = startAlarmPanelConvergence({
51810
52248
  attempt: () => this.attemptAlarmPanelDeclaration(api),
51811
52249
  logger: this.ctx.logger.child("alarm")
@@ -52582,13 +53020,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52582
53020
  buildSummaryPorts(stores) {
52583
53021
  return {
52584
53022
  listTracks: async ({ deviceId, sinceMs, untilMs, limit }) => {
52585
- return (await stores.trackStore.queryHistorical({
53023
+ const tracks = await stores.trackStore.queryHistorical({
52586
53024
  deviceId,
52587
53025
  since: sinceMs,
52588
53026
  until: untilMs,
52589
53027
  projection: "slim",
52590
53028
  limit
52591
- })).map((t) => ({
53029
+ });
53030
+ const { minHits } = await this.resolveDeviceDetectionSensitivitySettings(deviceId);
53031
+ return tracks.map((t) => ({
52592
53032
  trackId: t.trackId,
52593
53033
  deviceId: t.deviceId,
52594
53034
  className: t.className,
@@ -52597,7 +53037,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52597
53037
  ...t.importance !== void 0 ? { importance: t.importance } : {},
52598
53038
  zones: [...t.zonesVisited],
52599
53039
  ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
52600
- ...t.closedReason === "stationary-promotion" ? { stationary: true } : {}
53040
+ ...t.closedReason === "stationary-promotion" ? { stationary: true } : {},
53041
+ ...t.observations !== void 0 ? { confirmed: t.observations >= minHits } : {}
52601
53042
  }));
52602
53043
  },
52603
53044
  listMediaInfo: async (trackId) => {