@camstack/addon-post-analysis 1.2.72 → 1.2.73

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-C2w3cl2p.js");
5
+ const require_dist = require("../dist-DDv-TKUR.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -3544,6 +3544,17 @@ var NcDeviceDirectory = class {
3544
3544
  size() {
3545
3545
  return this.devices.size;
3546
3546
  }
3547
+ /**
3548
+ * Every identity in the current snapshot.
3549
+ *
3550
+ * Exists for the ONE caller that needs the whole directory rather than one
3551
+ * id: the liveness ledger's boot seed, which has to know what every device
3552
+ * was last known to be before the first event arrives. Off the event path by
3553
+ * construction — it is called once, from `start()`.
3554
+ */
3555
+ all() {
3556
+ return [...this.devices.values()];
3557
+ }
3547
3558
  /** Has a refresh ever succeeded? A cold directory notifies about nothing. */
3548
3559
  isWarm() {
3549
3560
  return this.loadedOnce;
@@ -3947,6 +3958,43 @@ function displayLabel(state) {
3947
3958
  return state.subLabel ?? state.label;
3948
3959
  }
3949
3960
  /**
3961
+ * The tiered columns a row should carry to AGREE with the track, or `undefined`
3962
+ * when the track says nothing yet.
3963
+ *
3964
+ * The projection is total: whichever tiers the track holds are copied, and a
3965
+ * track holding neither yields `undefined` rather than a patch of blanks — the
3966
+ * caller then leaves the row exactly as it was.
3967
+ */
3968
+ function knownTrackLabelPatch(state) {
3969
+ if (state === void 0) return void 0;
3970
+ if (state.label === void 0 && state.subLabel === void 0) return void 0;
3971
+ return {
3972
+ ...state.label !== void 0 ? { label: state.label } : {},
3973
+ ...state.labelScore !== void 0 ? { labelScore: state.labelScore } : {},
3974
+ ...state.labelMeta !== void 0 ? { labelMeta: state.labelMeta } : {},
3975
+ ...state.subLabel !== void 0 ? { subLabel: state.subLabel } : {},
3976
+ ...state.subLabelScore !== void 0 ? { subLabelScore: state.subLabelScore } : {},
3977
+ ...state.subLabelMeta !== void 0 ? { subLabelMeta: state.subLabelMeta } : {}
3978
+ };
3979
+ }
3980
+ /**
3981
+ * Does this row already say what the subject IS?
3982
+ *
3983
+ * The gate behind `DetailDispatcher.hasTrackLabel`: a periodic face-detection
3984
+ * refresh on a track that already has an answer runs geometry-only and drops
3985
+ * the ArcFace embedding. It reads {@link displayLabel} and not the `label`
3986
+ * column, and the difference is the whole point — an identity is a TIER-2
3987
+ * value and lands in `subLabel`, so the column read reported "unlabelled" for
3988
+ * every track ArcFace had already named and kept re-embedding it forever,
3989
+ * while suppressing the face chain on tracks that carried only a tier-1
3990
+ * sub-class (`van`). Exactly backwards, in both directions.
3991
+ */
3992
+ function hasResolvedLabel(state) {
3993
+ if (state === null || state === void 0) return false;
3994
+ const label = displayLabel(state);
3995
+ return label !== void 0 && label.length > 0;
3996
+ }
3997
+ /**
3950
3998
  * The display label of a frame-path tracked detection — the same
3951
3999
  * `subLabel ?? label` question asked of a patch rather than a row. Used by
3952
4000
  * every caller that wants the caption, not the tier (media captions, lifecycle
@@ -3956,6 +4004,36 @@ function patchDisplayLabel(patch) {
3956
4004
  if (patch === void 0) return void 0;
3957
4005
  return patch.subLabel ?? patch.label;
3958
4006
  }
4007
+ /**
4008
+ * WHAT a tier-2 label is about — a recognised PERSON or a read PLATE.
4009
+ *
4010
+ * Derived from the deciding step, never stored a second time: the step that
4011
+ * wrote the value is already on the attribution, and a second column stating
4012
+ * the same thing is a knob that can disagree with the first (D62). Both the
4013
+ * machine step and the operator override of each family are listed, because a
4014
+ * hand-assigned identity is still an identity.
4015
+ *
4016
+ * Callers use it to pick a SENTENCE: "Gianluca è arrivato" and "Veicolo AB123CD"
4017
+ * are different keys in the notification text catalog, and a single
4018
+ * `{{label}}` slot cannot word both.
4019
+ */
4020
+ var FACE_LABEL_STEP_IDS = new Set([
4021
+ "face-recognizer",
4022
+ "face-embedding",
4023
+ "operator:face-gallery"
4024
+ ]);
4025
+ /** Steps whose tier-2 value is a PLATE / vehicle name. See {@link FACE_LABEL_STEP_IDS}. */
4026
+ var PLATE_LABEL_STEP_IDS = new Set(["plate-ocr", "operator:plate-gallery"]);
4027
+ /**
4028
+ * Classify a tier-2 attribution. `undefined` for an unattributed value, a
4029
+ * migrated one, or any step outside the two recognition families (a species, a
4030
+ * sub-class) — the caller then falls back to the generic labelled sentence.
4031
+ */
4032
+ function labelKindOf(meta) {
4033
+ if (meta === void 0) return void 0;
4034
+ if (FACE_LABEL_STEP_IDS.has(meta.stepId)) return "identity";
4035
+ if (PLATE_LABEL_STEP_IDS.has(meta.stepId)) return "plate";
4036
+ }
3959
4037
  /** Whether an attribution came from the 4g migration rather than a real step. */
3960
4038
  function isMigrated(meta) {
3961
4039
  return meta?.stepId === MIGRATION_STEP_ID;
@@ -4011,7 +4089,8 @@ function resolveTieredLabelWrite(state, candidate) {
4011
4089
  const meta = {
4012
4090
  stepId: candidate.stepId,
4013
4091
  ...candidate.modelId !== void 0 ? { modelId: candidate.modelId } : {},
4014
- decidedAt: candidate.decidedAt
4092
+ decidedAt: candidate.decidedAt,
4093
+ ...candidate.identityId !== void 0 ? { identityId: candidate.identityId } : {}
4015
4094
  };
4016
4095
  const incumbent = candidate.tier === 1 ? state.label : state.subLabel;
4017
4096
  const incumbentScore = candidate.tier === 1 ? state.labelScore : state.subLabelScore;
@@ -4233,6 +4312,7 @@ function subjectFromObjectEvent(ev) {
4233
4312
  timestamp: ev.timestamp,
4234
4313
  classNames: [ev.className],
4235
4314
  ...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
4315
+ ...recognitionPatch(ev.subLabelMeta),
4236
4316
  ...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
4237
4317
  zones: ev.zones ?? [],
4238
4318
  ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
@@ -4246,6 +4326,19 @@ function subjectFromObjectEvent(ev) {
4246
4326
  function bboxPatch(bbox) {
4247
4327
  return bbox !== void 0 ? { bbox } : {};
4248
4328
  }
4329
+ /**
4330
+ * The recognition half of a subject, read off the tier-2 attribution: WHICH
4331
+ * gallery row and WHAT KIND of thing. Both absent on an unattributed label, so
4332
+ * every condition that depends on them fails closed for a legacy record — which
4333
+ * is why the id/name matcher keeps its name leg.
4334
+ */
4335
+ function recognitionPatch(meta) {
4336
+ const kind = labelKindOf(meta);
4337
+ return {
4338
+ ...meta?.identityId !== void 0 ? { identityId: meta.identityId } : {},
4339
+ ...kind !== void 0 ? { labelKind: kind } : {}
4340
+ };
4341
+ }
4249
4342
  /** Convert a normalized 0..1 track envelope (min/max) into the subject's
4250
4343
  * top-left+size `BboxRect`. `undefined` in ⇒ `undefined` out (fail closed). */
4251
4344
  function envelopeToBbox(envelope) {
@@ -4410,6 +4503,7 @@ function subjectFromPackageEvent(ev, phase) {
4410
4503
  timestamp: ev.timestamp,
4411
4504
  classNames: [ev.className],
4412
4505
  ...displayLabel(ev) !== void 0 ? { label: displayLabel(ev) } : {},
4506
+ ...recognitionPatch(ev.subLabelMeta),
4413
4507
  ...ev.confidence !== void 0 ? { confidence: ev.confidence } : {},
4414
4508
  zones: ev.zones ?? [],
4415
4509
  ...ev.trackId !== void 0 ? { trackId: ev.trackId } : {},
@@ -4428,6 +4522,7 @@ function subjectFromTrack(track, info) {
4428
4522
  timestamp: track.lastSeen,
4429
4523
  classNames: track.classes && track.classes.length > 0 ? track.classes : [track.className],
4430
4524
  ...displayLabel(track) !== void 0 ? { label: displayLabel(track) } : {},
4525
+ ...recognitionPatch(track.subLabelMeta),
4431
4526
  ...info?.bestConfidence !== void 0 ? { confidence: info.bestConfidence } : {},
4432
4527
  zones: track.zonesVisited,
4433
4528
  trackId: track.trackId,
@@ -4463,7 +4558,7 @@ function presentConditionIds(c) {
4463
4558
  if (c.zonesExclude !== void 0 && c.zonesExclude.length > 0) ids.push("zonesExclude");
4464
4559
  if (c.crossing !== void 0 && c.crossing !== "enter") ids.push("crossing");
4465
4560
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) ids.push("labelEquals");
4466
- if (c.identities !== void 0 && c.identities.length > 0) ids.push("identities");
4561
+ if (c.identities !== void 0) ids.push("identities");
4467
4562
  if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) ids.push("identitiesExclude");
4468
4563
  if (c.minLabelConfidence !== void 0) ids.push("minLabelConfidence");
4469
4564
  if (c.plates !== void 0) ids.push("plates");
@@ -4549,6 +4644,70 @@ function toLowerSet(values) {
4549
4644
  return new Set(values.map((v) => v.trim().toLowerCase()));
4550
4645
  }
4551
4646
  /**
4647
+ * Does the subject's identity appear in `scope`?
4648
+ *
4649
+ * TWO LEGS, both load-bearing, and the reason is the migration rather than
4650
+ * indecision. `scope` holds gallery ids after `NcRuleStore.load` has resolved
4651
+ * them; a name it could not resolve is left as it stands (reported, never
4652
+ * dropped), and a record written before ids reached `LabelAttribution` carries
4653
+ * a name and no id. Matching only the id would silence the first case; matching
4654
+ * only the name would re-introduce the rename bug the id exists to fix.
4655
+ *
4656
+ * The id leg is exact (a uuid has no case); the name leg is the historical
4657
+ * trim + lowercase comparison, unchanged, so a rule that matched yesterday
4658
+ * matches today.
4659
+ */
4660
+ function matchesIdentityScope(subject, scope) {
4661
+ if (subject.identityId !== void 0 && scope.includes(subject.identityId)) return true;
4662
+ const label = subject.label?.trim().toLowerCase();
4663
+ return label !== void 0 && toLowerSet(scope).has(label);
4664
+ }
4665
+ /**
4666
+ * Does this rule's match depend on RECOGNITION — a face identity or a plate
4667
+ * read — rather than on the detection alone?
4668
+ *
4669
+ * Derived from the conditions the rule already carries; nothing stores it. It
4670
+ * is what makes `waitForEnhancement` inert on a rule with nothing to wait for,
4671
+ * and what decides whether a track close owes an "…is Gianluca" follow-up.
4672
+ * `identitiesExclude` is deliberately NOT in the set: a veto is satisfied by
4673
+ * the ABSENCE of a recognition, so waiting for one would delay every
4674
+ * notification the rule exists to send.
4675
+ */
4676
+ function ruleAwaitsRecognition(rule) {
4677
+ return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
4678
+ }
4679
+ /**
4680
+ * Does this rule stop firing on the object event and fire at TRACK CLOSE?
4681
+ *
4682
+ * `waitForEnhancement` AND something to wait for AND an `immediate` delivery.
4683
+ * A `track-end` rule already fires at close, and a rule with no recognition
4684
+ * scope would be delayed for nothing — in both cases the flag is inert, and
4685
+ * that is deliberate: the operator sets one switch and the rule tells them
4686
+ * whether it applies rather than silently changing when a notification arrives.
4687
+ *
4688
+ * The consequence is stated where the flag is declared and repeated in the
4689
+ * editor: a deferred rule inherits TRACK-CLOSE semantics. Its `zones` condition
4690
+ * tests every zone the track visited, and a `crossing` condition can no longer
4691
+ * be satisfied. Nothing is remembered between the two moments — the closed
4692
+ * track IS the evaluation, so a respawn in the middle costs nothing and the
4693
+ * doorbell-mirror failure (D49) has no surface here.
4694
+ */
4695
+ function ruleDefersToTrackEnd(rule) {
4696
+ return rule.waitForEnhancement === true && rule.delivery === "immediate" && ruleAwaitsRecognition(rule);
4697
+ }
4698
+ /**
4699
+ * Does a track close owe this rule an "…and it was Gianluca" SECOND
4700
+ * notification?
4701
+ *
4702
+ * The mirror of {@link ruleDefersToTrackEnd}: an `immediate` recognition rule
4703
+ * that did NOT wait fired seconds before the face was confirmed, so its
4704
+ * notification is true and incomplete. The upgrade is what makes "off" an
4705
+ * honest setting rather than a silent downgrade.
4706
+ */
4707
+ function ruleUpgradesAtTrackEnd(rule) {
4708
+ return rule.waitForEnhancement !== true && rule.delivery === "immediate" && ruleAwaitsRecognition(rule);
4709
+ }
4710
+ /**
4552
4711
  * Expand each selected class id to itself PLUS its taxonomy leaf subs, so a
4553
4712
  * MACRO selection (`vehicle`) matches its sub classNames (`car`/`truck`) — the
4554
4713
  * `EVENT_TAXONOMY` tree is the SINGLE source (mirrors the timeline/filter
@@ -4614,7 +4773,9 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
4614
4773
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
4615
4774
  if (rule.conditions.systemEvent !== void 0) return fail("systemEvent");
4616
4775
  } else {
4617
- if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) return fail("delivery");
4776
+ if ((rule.delivery === "immediate" ? "object-event" : rule.delivery) !== subject.kind) {
4777
+ if (!(subject.kind === "track-end" && ruleDefersToTrackEnd(rule))) return fail("delivery");
4778
+ }
4618
4779
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
4619
4780
  if (rule.conditions.systemEvent !== void 0) return fail("systemEvent");
4620
4781
  if (rule.conditions.audio !== void 0) return fail("audio");
@@ -4684,17 +4845,17 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
4684
4845
  if (c.labelEquals !== void 0 && c.labelEquals.length > 0) {
4685
4846
  if (label === void 0 || !toLowerSet(c.labelEquals).has(label)) return fail("labelEquals");
4686
4847
  }
4687
- if (c.identities !== void 0 && c.identities.length > 0) {
4688
- if (label === void 0 || !toLowerSet(c.identities).has(label)) return fail("identities");
4848
+ if (c.identities !== void 0) {
4849
+ if (!(c.identities.length > 0 ? matchesIdentityScope(subject, c.identities) : subject.labelKind === "identity")) return fail("identities");
4689
4850
  }
4690
4851
  if (c.identitiesExclude !== void 0 && c.identitiesExclude.length > 0) {
4691
- if (label !== void 0 && toLowerSet(c.identitiesExclude).has(label)) return fail("identitiesExclude");
4852
+ if (matchesIdentityScope(subject, c.identitiesExclude)) return fail("identitiesExclude");
4692
4853
  }
4693
4854
  if (c.minLabelConfidence !== void 0) {
4694
4855
  if (subject.labelConfidence === void 0 || subject.labelConfidence < c.minLabelConfidence) return fail("minLabelConfidence");
4695
4856
  }
4696
4857
  if (c.plates !== void 0) {
4697
- if (subject.label === void 0 || !matchesPlate(subject.label, c.plates.values, c.plates.maxDistance)) return fail("plates");
4858
+ if (!(c.plates.values.length > 0 ? subject.label !== void 0 && matchesPlate(subject.label, c.plates.values, c.plates.maxDistance) : subject.labelKind === "plate")) return fail("plates");
4698
4859
  }
4699
4860
  if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) {
4700
4861
  if (subject.sensorKind === void 0 || !toLowerSet(c.sensorKinds).has(subject.sensorKind.trim().toLowerCase())) return fail("sensorKinds");
@@ -4759,6 +4920,27 @@ function keysPerClass(rule, subject) {
4759
4920
  if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
4760
4921
  return rule.throttle.granularity === "per-class";
4761
4922
  }
4923
+ /**
4924
+ * The cooldown scope of ONE system event — subject AND direction.
4925
+ *
4926
+ * The `kind` is here because a cooldown is a budget for VOLUME and must never
4927
+ * be a budget for a DIRECTION. `Eventi di sistema` (60 s, `rule-device`)
4928
+ * covers `device-offline` and `device-online` together, so on 2026-08-13 a
4929
+ * camera that came back 13 s after going down had its recovery suppressed:
4930
+ * the operator kept a "camera offline" as the last word while the camera was
4931
+ * up, and — because the liveness ledger HAD advanced on the suppressed flip —
4932
+ * the next outage was a genuine flip again and arrived as what read like a
4933
+ * duplicate. Same split D62 made one level up between `device-switch:<id>` and
4934
+ * `device:<id>`, for the same reason: two facts must not share a slot and
4935
+ * cancel each other out.
4936
+ *
4937
+ * ONE authority: `cooldownKey` and the boot-time cooldown reseed
4938
+ * (`NotificationCenter.seedCooldowns`) both call this. They used to build the
4939
+ * string separately, which is how a key format drifts across a restart.
4940
+ */
4941
+ function systemEventCooldownScope(systemEvent) {
4942
+ return `s:${systemEvent.subject}:k:${systemEvent.kind}`;
4943
+ }
4762
4944
  /** Stable cooldown key per the rule's throttle scope + class granularity. */
4763
4945
  /**
4764
4946
  * Does the rule's device scope cover this subject?
@@ -4785,8 +4967,9 @@ function matchesDeviceScope(scope, subject) {
4785
4967
  function cooldownKey(rule, subject) {
4786
4968
  const first = subject.classNames[0];
4787
4969
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
4788
- const scopeRef = subject.systemEvent !== void 0 ? `s:${subject.systemEvent.subject}` : `d:${subject.deviceId}`;
4789
- return rule.throttle.scope === "rule" ? `r:${rule.id}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
4970
+ const scopeRef = subject.systemEvent !== void 0 ? systemEventCooldownScope(subject.systemEvent) : `d:${subject.deviceId}`;
4971
+ const kindKey = subject.systemEvent !== void 0 ? `:k:${subject.systemEvent.kind}` : "";
4972
+ return rule.throttle.scope === "rule" ? `r:${rule.id}${kindKey}${classKey}` : `r:${rule.id}:${scopeRef}${classKey}`;
4790
4973
  }
4791
4974
  /** True when the rule fired within its cooldown window before `now`. */
4792
4975
  function isCoolingDown(rule, lastFiredAt, now) {
@@ -4899,8 +5082,11 @@ var en_default = {
4899
5082
  "detection.title": "{{rule}}",
4900
5083
  "detection.body": "{{subject}} on {{camera}}{{inZones}}",
4901
5084
  "detection.body.trackEnd": "{{subject}} on {{camera}}{{inZones}} — visit ended",
5085
+ "detection.body.enhanced": "{{subject}} on {{camera}}{{inZones}} — now recognised",
4902
5086
  "detection.subject": "{{classNoun}}",
4903
5087
  "detection.subject.labelled": "{{classNoun}} ({{label}})",
5088
+ "detection.subject.identity": "{{label}}",
5089
+ "detection.subject.plate": "{{classNoun}} {{label}}",
4904
5090
  "detection.inZones": " in {{zones}}",
4905
5091
  "class.person": {
4906
5092
  "one": "person",
@@ -4980,6 +5166,7 @@ var en_default = {
4980
5166
  "system.camera-disabled.body": "Device {{deviceName}} was switched off",
4981
5167
  "system.camera-enabled.title": "Device switched on",
4982
5168
  "system.camera-enabled.body": "Device {{deviceName}} was switched back on",
5169
+ "test.prefix": "[TEST] {{body}}",
4983
5170
  "timelapse.title": "Timelapse — {{camera}}",
4984
5171
  "timelapse.body.window": "{{rule}} — {{from}}→{{to}}",
4985
5172
  "timelapse.body.tooManyEvents": "too many events for dense sampling: video at a uniform cadence",
@@ -5062,8 +5249,11 @@ var it_default = {
5062
5249
  "detection.title": "{{rule}}",
5063
5250
  "detection.body": "{{subject}} su {{camera}}{{inZones}}",
5064
5251
  "detection.body.trackEnd": "{{subject}} su {{camera}}{{inZones}} — visita terminata",
5252
+ "detection.body.enhanced": "{{subject}} su {{camera}}{{inZones}} — ora riconosciuto",
5065
5253
  "detection.subject": "{{classNoun}}",
5066
5254
  "detection.subject.labelled": "{{classNoun}} ({{label}})",
5255
+ "detection.subject.identity": "{{label}}",
5256
+ "detection.subject.plate": "{{classNoun}} {{label}}",
5067
5257
  "detection.inZones": " in {{zones}}",
5068
5258
  "class.person": {
5069
5259
  "one": "persona",
@@ -5143,6 +5333,7 @@ var it_default = {
5143
5333
  "system.camera-disabled.body": "Il dispositivo {{deviceName}} è stato spento",
5144
5334
  "system.camera-enabled.title": "Dispositivo riacceso",
5145
5335
  "system.camera-enabled.body": "Il dispositivo {{deviceName}} è stato riacceso",
5336
+ "test.prefix": "[TEST] {{body}}",
5146
5337
  "timelapse.title": "Timelapse — {{camera}}",
5147
5338
  "timelapse.body.window": "{{rule}} — {{from}}→{{to}}",
5148
5339
  "timelapse.body.tooManyEvents": "troppi eventi per il campionamento denso: video a cadenza uniforme",
@@ -5417,7 +5608,7 @@ function ncSubjectText(texts, input) {
5417
5608
  vars: { classNoun }
5418
5609
  });
5419
5610
  return texts.text({
5420
- key: "detection.subject.labelled",
5611
+ key: subjectLabelKey(input.labelKind),
5421
5612
  vars: {
5422
5613
  classNoun,
5423
5614
  label: input.label
@@ -5425,6 +5616,16 @@ function ncSubjectText(texts, input) {
5425
5616
  });
5426
5617
  }
5427
5618
  /**
5619
+ * Which labelled-subject key a recognition gets. A kind the catalog does not
5620
+ * name falls back to the generic key rather than printing nothing — the same
5621
+ * direction `ncClassNoun` degrades in, and for the same reason.
5622
+ */
5623
+ function subjectLabelKey(kind) {
5624
+ if (kind === "identity") return "detection.subject.identity";
5625
+ if (kind === "plate") return "detection.subject.plate";
5626
+ return "detection.subject.labelled";
5627
+ }
5628
+ /**
5428
5629
  * ` in gate, drive` — INCLUDING its leading space, and empty when there are no
5429
5630
  * zones.
5430
5631
  *
@@ -5923,13 +6124,18 @@ var NcDispatcher = class {
5923
6124
  ...systemCount,
5924
6125
  frozen: systemEvent?.title ?? entry.payload.ruleName
5925
6126
  });
5926
- const body = this.renderText({
6127
+ const rendered = this.renderText({
5927
6128
  template: entry.payload.template?.body,
5928
6129
  key: bodyKeyFor(entry),
5929
6130
  vars,
5930
6131
  ...systemCount,
5931
6132
  ...systemEvent?.body !== void 0 ? { frozen: systemEvent.body } : {}
5932
6133
  });
6134
+ const body = entry.payload.synthetic === true ? this.renderText({
6135
+ key: "test.prefix",
6136
+ vars: { body: rendered },
6137
+ frozen: rendered
6138
+ }) : rendered;
5933
6139
  const attachments = [...await this.withArtifactUrls(await this.resolveAttachments(entry)), ...frozenArtifactAttachments(entry)];
5934
6140
  const params = pickParams(entry.payload.params);
5935
6141
  const actions = await this.mintActions(entry, target);
@@ -6286,7 +6492,8 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
6286
6492
  ...detection.label !== void 0 ? { label: detection.label } : {}
6287
6493
  }) : systemEvent?.subject ?? ncSubjectText(texts, {
6288
6494
  className: subject.className,
6289
- ...subject.label !== void 0 ? { label: subject.label } : {}
6495
+ ...subject.label !== void 0 ? { label: subject.label } : {},
6496
+ ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {}
6290
6497
  }),
6291
6498
  inZones: ncInZonesText(texts, zoneLabels),
6292
6499
  scope: occupancy?.zone ?? deviceName,
@@ -6318,6 +6525,7 @@ function bodyKeyFor(entry) {
6318
6525
  const subject = entry.payload.subject;
6319
6526
  if (subject.systemEvent !== void 0) return subject.systemEvent.bodyKey ?? "";
6320
6527
  if (subject.occupancy !== void 0) return "occupancy.body";
6528
+ if (entry.payload.enhancement === true) return "detection.body.enhanced";
6321
6529
  return entry.recordKind === "track-end" ? "detection.body.trackEnd" : "detection.body";
6322
6530
  }
6323
6531
  /** The default TITLE key — same shape, same pre-catalog reasoning. */
@@ -7307,6 +7515,73 @@ var NcLivenessLedger = class {
7307
7515
  async load() {
7308
7516
  return (await this.ledger.load()).length;
7309
7517
  }
7518
+ /**
7519
+ * Boot seed from the DEVICE DIRECTORY — the second half of the reseed, and
7520
+ * the fix for the outage this ledger used to swallow.
7521
+ *
7522
+ * ## The hole
7523
+ *
7524
+ * {@link load} recovers what the ledger itself accepted. A device that has
7525
+ * never produced a liveness event since the ledger was created has no row at
7526
+ * all, so its FIRST observation ever seeds silently — and if that first
7527
+ * observation is the camera DYING, the outage is thrown away. Live cost,
7528
+ * 2026-08-13 21:36 CEST: the operator unplugged four cameras and received two
7529
+ * notifications. `device:584` and `device:587` had never spoken; their
7530
+ * unplugging was recorded and never announced. A camera that is quiet until
7531
+ * it dies is a camera whose death is silent, indefinitely.
7532
+ *
7533
+ * ## Why only the ONLINE ones
7534
+ *
7535
+ * The directory is the device manager's own `online` flag (D132 §3: the
7536
+ * authority seeds the mirror at boot and stays the authority). Seeding only
7537
+ * the devices it calls ONLINE is what makes this change one-directional:
7538
+ *
7539
+ * - a row seeded `online` can only ever turn a later `device.offline` into a
7540
+ * notification — a true sentence about a device that just went away;
7541
+ * - it can NEVER turn a `device.online` into one, and that is the only shape
7542
+ * the 2026-08-13 notification flood (D130) ever had.
7543
+ *
7544
+ * A device the directory calls offline — which is also what a cold or
7545
+ * still-settling directory would say — gets no row and keeps today's
7546
+ * behaviour exactly. Failing toward discard, unchanged.
7547
+ *
7548
+ * ## D49
7549
+ *
7550
+ * An EXISTING row is never overwritten. The ledger is the authority for what
7551
+ * it accepted; the directory is a mirror refreshed on a tick and may be
7552
+ * stale. A stale `online` overwriting an accepted `offline` would make the
7553
+ * camera's real recovery a no-flip and lose it. A failed directory read
7554
+ * simply yields an empty list here and seeds nothing.
7555
+ *
7556
+ * ## Staged, not persisted
7557
+ *
7558
+ * The seed is DERIVED from an authority that is read again on every boot, so
7559
+ * losing it costs nothing — the next boot re-derives it. `stage` therefore
7560
+ * advances the mirror without a write, which matters on this hub: the
7561
+ * pipeline-analytics runner respawns several times an hour, and persisting
7562
+ * ~500 re-derivable rows on each respawn would offer thousands of pointless
7563
+ * commits a day to the checkpoint lottery (D96). The first real
7564
+ * {@link observe} flip persists through the ordinary write-behind path.
7565
+ *
7566
+ * Returns how many rows were seeded, so the caller can say it out loud.
7567
+ */
7568
+ seedFromDirectory(identities, now) {
7569
+ let seeded = 0;
7570
+ for (const identity of identities) {
7571
+ if (identity.online !== true) continue;
7572
+ const subject = `device:${identity.deviceId}`;
7573
+ if (this.ledger.has(subject)) continue;
7574
+ this.ledger.stage({
7575
+ subject,
7576
+ state: "online",
7577
+ kind: "device-online",
7578
+ deviceId: identity.deviceId,
7579
+ updatedAt: now
7580
+ });
7581
+ seeded += 1;
7582
+ }
7583
+ return seeded;
7584
+ }
7310
7585
  /** The in-RAM mirror, subject-ordered so two readers agree. */
7311
7586
  snapshot() {
7312
7587
  return this.ledger.snapshot().toSorted((a, b) => a.subject.localeCompare(b.subject));
@@ -7799,7 +8074,7 @@ function probeOccupancyNow(input) {
7799
8074
  * field for them would be a cross-package change for a sentence.
7800
8075
  */
7801
8076
  function occupancyProbeLabel(occupancy, probe, cooldownSec) {
7802
- const classNote = impossibleClassNote(occupancy.className);
8077
+ const classNote = occupancyClassWarning(occupancy.className);
7803
8078
  if (probe.kind === "unavailable") {
7804
8079
  const reason = probe.reason === "no-snapshot" ? "no ZoneAnalytics snapshot for this camera — the condition fails closed until one arrives" : "that zone is not in the camera’s current snapshot — the condition fails closed (deleted zone?)";
7805
8080
  return classNote === null ? reason : `${classNote} · ${reason}`;
@@ -7824,7 +8099,7 @@ function occupancyProbeLabel(occupancy, probe, cooldownSec) {
7824
8099
  * pixel-identical to an empty parking space. `byClass` is MACRO-keyed, so that
7825
8100
  * counter is 0 structurally and no scene can change it.
7826
8101
  */
7827
- function impossibleClassNote(className) {
8102
+ function occupancyClassWarning(className) {
7828
8103
  if (className === void 0 || require_dist.isDetectionMacroClass(className)) return null;
7829
8104
  return `⚠ “${className}” is a sub-type, not a macro class: this counter is always 0 (use ${[...require_dist.DETECTION_MACRO_CLASSES].join(" / ")})`;
7830
8105
  }
@@ -8078,7 +8353,7 @@ var DEFAULT_DRAIN_BATCH = 20;
8078
8353
  var DEDUP_HYDRATE_WINDOW_MS = 168 * 36e5;
8079
8354
  /** Stable dedup id — the durable per-track/per-record fire guarantee. */
8080
8355
  function outboxEntryId(input) {
8081
- const dedupRef = input.trackId ?? input.recordId;
8356
+ const dedupRef = input.dedupRef ?? input.trackId ?? input.recordId;
8082
8357
  return `${input.ruleId}:${dedupRef}:${input.targetId}`;
8083
8358
  }
8084
8359
  var NcOutbox = class {
@@ -8630,7 +8905,130 @@ var NcRuleActionRunner = class {
8630
8905
  }
8631
8906
  };
8632
8907
  //#endregion
8908
+ //#region src/notification-center/rule-status.ts
8909
+ /**
8910
+ * The live occupancy of the scope ONE rule counts.
8911
+ *
8912
+ * `count`/`occupied` come from the SAME probe the dry-run uses
8913
+ * (`probeOccupancyNow`), so the status and the dry-run can never disagree about
8914
+ * a camera. `since` comes from the watcher's CONFIRMED row, which is a
8915
+ * different fact and the one the operator actually asks for: the probe reads a
8916
+ * level, the watcher records when that level was last confirmed to change.
8917
+ */
8918
+ var NcOccupancyRuleStatusSchema = require_dist.object({
8919
+ kind: require_dist.literal("occupancy"),
8920
+ deviceId: require_dist.number().int(),
8921
+ /** Zone NAME when the snapshot knows one, else the zone id, else absent for
8922
+ * whole-frame — the words a card puts after "in". */
8923
+ scope: require_dist.string().optional(),
8924
+ /** The class counted; absent = every class. */
8925
+ className: require_dist.string().optional(),
8926
+ /** The capacity the predicate compares against (`<=C` keys on C+1). */
8927
+ threshold: require_dist.number().int(),
8928
+ sustainSeconds: require_dist.number().int(),
8929
+ /** The count read for the scope. Absent when the probe was unavailable. */
8930
+ count: require_dist.number().int().optional(),
8931
+ /** `count >= threshold` at the probe. Absent when unavailable. */
8932
+ occupied: require_dist.boolean().optional(),
8933
+ /** Frame time of the snapshot the count came from — how FRESH it is, which
8934
+ * is a different question from "now". */
8935
+ observedAt: require_dist.number().int().optional(),
8936
+ /** When the watcher last CONFIRMED a change of this key's level. Absent when
8937
+ * the watcher holds no confirmed row (a cold boot, or a key nothing
8938
+ * watches). */
8939
+ since: require_dist.number().int().optional(),
8940
+ /** Why there is no count: the two fail-closed shapes of the live path. */
8941
+ unavailable: require_dist._enum(["no-snapshot", "zone-absent"]).optional(),
8942
+ /** A scope the counter can never answer (a sub-type class) — stated, because
8943
+ * its reading of 0 is pixel-identical to an empty zone. */
8944
+ warning: require_dist.string().optional()
8945
+ });
8946
+ /** One camera of a timelapse rule — each renders its own video. */
8947
+ var NcTimelapseDeviceStatusSchema = require_dist.object({
8948
+ deviceId: require_dist.number().int(),
8949
+ /** Last successful generation for THIS camera (the per-device map, with the
8950
+ * rule-wide fallback). Absent = never generated. */
8951
+ lastGeneratedAt: require_dist.number().int().optional(),
8952
+ /** The artefact that generation produced, when it is still on the shelf. */
8953
+ lastArtifact: NcArtifactRecordSchema.optional(),
8954
+ /** A render this process is waiting on right now. */
8955
+ rendering: require_dist.object({
8956
+ startedAt: require_dist.number().int(),
8957
+ windowStartMs: require_dist.number().int(),
8958
+ windowEndMs: require_dist.number().int()
8959
+ }).optional()
8960
+ });
8961
+ var NcTimelapseRuleStatusSchema = require_dist.object({
8962
+ kind: require_dist.literal("timelapse"),
8963
+ devices: require_dist.array(NcTimelapseDeviceStatusSchema),
8964
+ /** The most recent window that has already closed — what the next tick would
8965
+ * render for any camera that has not got it yet. Absent = none in the
8966
+ * lookback (a schedule that never closes, or a rule younger than its window). */
8967
+ lastWindow: require_dist.object({
8968
+ startMs: require_dist.number(),
8969
+ endMs: require_dist.number()
8970
+ }).optional(),
8971
+ /** The next OPENING. `endMs` null = the close is beyond the scan horizon. */
8972
+ nextWindow: require_dist.object({
8973
+ startMs: require_dist.number(),
8974
+ endMs: require_dist.number().nullable()
8975
+ }).optional(),
8976
+ /** How long after a close the producer waits for the footage to settle. */
8977
+ settleMs: require_dist.number().int()
8978
+ });
8979
+ var NcRuleStatusSchema = require_dist.discriminatedUnion("kind", [
8980
+ require_dist.object({
8981
+ kind: require_dist.literal("none"),
8982
+ reason: require_dist.string()
8983
+ }),
8984
+ NcOccupancyRuleStatusSchema,
8985
+ NcTimelapseRuleStatusSchema
8986
+ ]);
8987
+ /**
8988
+ * Assemble the occupancy status from the probe and the watcher's confirmed row.
8989
+ *
8990
+ * Pure. The two inputs are deliberately separate rather than one blob: they
8991
+ * answer different questions from different substrates, and merging them at the
8992
+ * source would let a missing snapshot erase a `since` the watcher still knows.
8993
+ */
8994
+ function buildOccupancyStatus(input) {
8995
+ const { condition, probe, threshold } = input;
8996
+ const key = occupancyKey(input.deviceId, condition.zoneId, condition.className, threshold);
8997
+ const row = input.confirmed.find((r) => r.key === key);
8998
+ const scope = probe.kind === "probed" ? probe.zoneName ?? condition.zoneId : condition.zoneId;
8999
+ return {
9000
+ kind: "occupancy",
9001
+ deviceId: input.deviceId,
9002
+ ...scope !== void 0 ? { scope } : {},
9003
+ ...condition.className !== void 0 ? { className: condition.className } : {},
9004
+ threshold,
9005
+ sustainSeconds: condition.sustainSeconds,
9006
+ ...probe.kind === "probed" ? {
9007
+ count: probe.count,
9008
+ occupied: probe.subject.occupancy?.occupied === true,
9009
+ observedAt: probe.observedAt
9010
+ } : { unavailable: probe.reason },
9011
+ ...row !== void 0 ? { since: row.lastChangeAt } : {},
9012
+ ...input.warning !== void 0 ? { warning: input.warning } : {}
9013
+ };
9014
+ }
9015
+ //#endregion
8633
9016
  //#region src/notification-center/rule-store.ts
9017
+ /**
9018
+ * NcRuleStore — durable Notification Center rule set.
9019
+ *
9020
+ * Follows the `stationary-registry.ts` precedent: a declared SQLite
9021
+ * collection (via the central `SettingsStoreClient`) mirrored into an
9022
+ * in-memory cache. The cache serves the hot read path (per-persist rule
9023
+ * matching) with zero I/O; every mutation writes through to the store
9024
+ * FIRST and only then updates the cache (a failed persist never leaves a
9025
+ * phantom in-RAM rule).
9026
+ *
9027
+ * Cross-node note: the collection lives in the hub's centralized
9028
+ * settings-store, so a CRUD served on one node is visible to the
9029
+ * evaluating (post-processing) node after its periodic `load()` refresh —
9030
+ * bounded staleness, never wrongness (D8 reconcile-over-events).
9031
+ */
8634
9032
  var NC_RULES_COLLECTION = "notification-center:rules";
8635
9033
  var NC_RULES_COLUMNS = [
8636
9034
  {
@@ -8672,17 +9070,75 @@ var NC_RULES_INDEXES = [{
8672
9070
  name: "idx_nc_rules_enabled",
8673
9071
  columns: ["enabled"]
8674
9072
  }];
9073
+ /**
9074
+ * Resolve display names to gallery ids inside one rule's identity conditions.
9075
+ *
9076
+ * NON-DESTRUCTIVE and IDEMPOTENT, in that order of importance:
9077
+ *
9078
+ * - a value the map answers for becomes the id;
9079
+ * - a value it does not answer for is LEFT AS IT STANDS, not dropped. It is
9080
+ * either already an id (the second load of a migrated rule, and every load
9081
+ * after that), or a name the gallery no longer holds — and a rule whose
9082
+ * scope silently shrank is exactly the failure this repo keeps paying for.
9083
+ * The engine's name leg keeps such a value matching what it always matched;
9084
+ * the caller reports it once.
9085
+ * - nothing is written back. The migration is a READ projection, so a gallery
9086
+ * that failed to load, or loaded empty, cannot rewrite a rule into one that
9087
+ * matches nobody. The stored rule keeps the operator's words forever.
9088
+ *
9089
+ * Returns the rule unchanged (by identity) when nothing resolved, so `load`
9090
+ * can tell a migrated rule from an untouched one without comparing.
9091
+ */
9092
+ function migrateIdentityNames(rule, idsByName) {
9093
+ const identities = rule.conditions.identities;
9094
+ const excluded = rule.conditions.identitiesExclude;
9095
+ if (identities === void 0 && excluded === void 0) return {
9096
+ rule,
9097
+ unresolved: []
9098
+ };
9099
+ const unresolved = [];
9100
+ const known = new Set(idsByName.values());
9101
+ const resolve = (values) => values.map((value) => {
9102
+ if (known.has(value)) return value;
9103
+ const id = idsByName.get(value.trim().toLowerCase());
9104
+ if (id !== void 0) return id;
9105
+ unresolved.push(value);
9106
+ return value;
9107
+ });
9108
+ const nextIdentities = identities !== void 0 ? resolve(identities) : void 0;
9109
+ const nextExcluded = excluded !== void 0 ? resolve(excluded) : void 0;
9110
+ if (!(nextIdentities !== void 0 && !sameList(identities ?? [], nextIdentities) || nextExcluded !== void 0 && !sameList(excluded ?? [], nextExcluded))) return {
9111
+ rule,
9112
+ unresolved
9113
+ };
9114
+ return {
9115
+ rule: {
9116
+ ...rule,
9117
+ conditions: {
9118
+ ...rule.conditions,
9119
+ ...nextIdentities !== void 0 ? { identities: nextIdentities } : {},
9120
+ ...nextExcluded !== void 0 ? { identitiesExclude: nextExcluded } : {}
9121
+ }
9122
+ },
9123
+ unresolved
9124
+ };
9125
+ }
9126
+ function sameList(a, b) {
9127
+ return a.length === b.length && a.every((v, i) => v === b[i]);
9128
+ }
8675
9129
  var NcRuleStore = class {
8676
9130
  byId = /* @__PURE__ */ new Map();
8677
9131
  store;
8678
9132
  logger;
8679
9133
  now;
8680
9134
  newId;
9135
+ identityIdsByName;
8681
9136
  constructor(deps) {
8682
9137
  this.store = deps.store;
8683
9138
  this.logger = deps.logger;
8684
9139
  this.now = deps.now ?? (() => Date.now());
8685
9140
  this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
9141
+ if (deps.identityIdsByName !== void 0) this.identityIdsByName = deps.identityIdsByName;
8686
9142
  }
8687
9143
  static async declare(store) {
8688
9144
  await store.declareCollection.mutate({
@@ -8703,24 +9159,48 @@ var NcRuleStore = class {
8703
9159
  collection: NC_RULES_COLLECTION,
8704
9160
  filter: { limit: 1e4 }
8705
9161
  });
9162
+ const idsByName = await this.readIdentityIds();
8706
9163
  this.byId.clear();
8707
9164
  let skipped = 0;
9165
+ let migrated = 0;
9166
+ const unresolved = /* @__PURE__ */ new Set();
8708
9167
  for (const row of rows) {
8709
9168
  const parsed = require_dist.NcRuleSchema.safeParse(row.data["rule"]);
8710
9169
  if (!parsed.success) {
8711
9170
  skipped += 1;
8712
9171
  continue;
8713
9172
  }
8714
- this.byId.set(parsed.data.id, parsed.data);
9173
+ const result = migrateIdentityNames(parsed.data, idsByName);
9174
+ if (result.rule !== parsed.data) migrated += 1;
9175
+ for (const name of result.unresolved) unresolved.add(name);
9176
+ this.byId.set(result.rule.id, result.rule);
8715
9177
  }
8716
9178
  this.logger.debug("notification rules loaded", { meta: {
8717
9179
  rules: this.byId.size,
8718
- ...skipped > 0 ? { skippedInvalid: skipped } : {}
9180
+ ...skipped > 0 ? { skippedInvalid: skipped } : {},
9181
+ ...migrated > 0 ? { identitiesResolvedToIds: migrated } : {}
8719
9182
  } });
9183
+ if (unresolved.size > 0) this.logger.info("notification rules name identities the gallery does not know", { meta: { names: [...unresolved].join(", ") } });
8720
9184
  } catch (err) {
8721
9185
  this.logger.warn("notification rules load failed", { meta: { error: String(err) } });
8722
9186
  }
8723
9187
  }
9188
+ /**
9189
+ * The name→id map, lower-cased, or EMPTY when there is none to be had.
9190
+ *
9191
+ * Empty is the safe answer and the only one: `migrateIdentityNames` resolves
9192
+ * nothing from an empty map and leaves every rule as the operator wrote it.
9193
+ * A gallery read that throws must never be able to change what a rule matches.
9194
+ */
9195
+ async readIdentityIds() {
9196
+ if (this.identityIdsByName === void 0) return /* @__PURE__ */ new Map();
9197
+ try {
9198
+ return await this.identityIdsByName();
9199
+ } catch (err) {
9200
+ this.logger.debug("identity gallery read failed — rules left unmigrated", { meta: { error: String(err) } });
9201
+ return /* @__PURE__ */ new Map();
9202
+ }
9203
+ }
8724
9204
  list() {
8725
9205
  return [...this.byId.values()].sort((a, b) => b.updatedAt - a.updatedAt);
8726
9206
  }
@@ -9624,6 +10104,47 @@ function findLastClosedWindow(input) {
9624
10104
  };
9625
10105
  return null;
9626
10106
  }
10107
+ /**
10108
+ * The next window that will OPEN after `nowMs`, and when it closes.
10109
+ *
10110
+ * The forward twin of {@link findLastClosedWindow}, and the answer to the only
10111
+ * question an operator has about a timelapse rule that produced nothing yet:
10112
+ * "when is it going to run?". Standing INSIDE a window reports the NEXT one,
10113
+ * not the current one — the current one's boundaries are already answerable
10114
+ * from the last close, and reporting "next: now" would read as a render that is
10115
+ * owed.
10116
+ *
10117
+ * Derived by scanning `isScheduleActive` for the same reason the backwards scan
10118
+ * is: that function is the authority on what a window means (midnight crossing,
10119
+ * `invert`, IANA timezone), and a second implementation of it is drift this
10120
+ * repo has already paid for.
10121
+ *
10122
+ * @returns `null` for an always-active schedule (it never opens — it never
10123
+ * closed either), and for one with no opening inside the lookahead. A window
10124
+ * whose CLOSE falls beyond the horizon is reported with `endMs: null`: the
10125
+ * opening is the fact the operator asked for, and clamping the close to the
10126
+ * scan floor would report a window that ends when the scan gave up.
10127
+ */
10128
+ function findNextWindowOpen(input) {
10129
+ const step = Math.max(1e3, input.stepMs ?? 6e4);
10130
+ const from = Math.floor(input.nowMs / step) * step;
10131
+ const ceiling = input.nowMs + input.lookaheadMs;
10132
+ const active = (t) => require_dist.isScheduleActive(input.schedule, t);
10133
+ let startMs = null;
10134
+ for (let t = from; t + step <= ceiling; t += step) if (!active(t) && active(t + step)) {
10135
+ startMs = t + step;
10136
+ break;
10137
+ }
10138
+ if (startMs === null) return null;
10139
+ for (let t = startMs; t + step <= ceiling; t += step) if (!active(t + step)) return {
10140
+ startMs,
10141
+ endMs: t + step
10142
+ };
10143
+ return {
10144
+ startMs,
10145
+ endMs: null
10146
+ };
10147
+ }
9627
10148
  /** The canvas behind a tile whose aspect does not fill its cell. */
9628
10149
  var MOSAIC_BACKGROUND = {
9629
10150
  r: 17,
@@ -10255,11 +10776,23 @@ var NcSummaryProducer = class {
10255
10776
  this.tickMs = deps.tickMs ?? 6e4;
10256
10777
  this.texts = deps.texts ?? new NcTextCatalog();
10257
10778
  }
10779
+ /**
10780
+ * Boot: arm the interval, then fire the back-fill tick WITHOUT waiting on it.
10781
+ *
10782
+ * Same contract as the timelapse producer, and for the same reason: a digest
10783
+ * tick renders a mosaic and publishes it, so awaiting it here would hold
10784
+ * `NotificationCenter.start()` → the addon's `initialize()` → the runner's
10785
+ * capability manifest. `start()` resolving means the producer is running,
10786
+ * never that the back-fill has finished. See `timelapse-scheduler.start()`
10787
+ * for the incident this pins.
10788
+ */
10258
10789
  async start() {
10259
- await this.tick();
10260
10790
  this.timer = setInterval(() => {
10261
10791
  this.tick();
10262
10792
  }, this.tickMs);
10793
+ this.tick().catch((err) => {
10794
+ this.deps.logger.warn("summary back-fill tick failed", { meta: { error: String(err) } });
10795
+ });
10263
10796
  }
10264
10797
  stop() {
10265
10798
  if (this.timer !== null) {
@@ -11179,6 +11712,13 @@ var BboxSchema = require_dist.object({
11179
11712
  * all — picking the wrong one is the single most common reason an operator's
11180
11713
  * rule "does not fire", and making it an explicit choice is half the point of
11181
11714
  * the tester.
11715
+ *
11716
+ * It is now EVERY kind, without exception, because the per-rule Test button
11717
+ * (`nc.testRule`) plans its injection from the rule and must be able to test
11718
+ * any rule an operator can author. The two that arrived last are the two whose
11719
+ * rules were previously untestable end-to-end: a SOUND rule fires only on a
11720
+ * confirmed sampling window (`audio-window`, not the single classified sample
11721
+ * `audio-event` stands for), and a SYSTEM rule fires on no record at all.
11182
11722
  */
11183
11723
  var TriggerSchema = require_dist._enum([
11184
11724
  "object-event",
@@ -11186,7 +11726,9 @@ var TriggerSchema = require_dist._enum([
11186
11726
  "device-event",
11187
11727
  "package-event",
11188
11728
  "audio-event",
11189
- "occupancy-event"
11729
+ "audio-window",
11730
+ "occupancy-event",
11731
+ "system-event"
11190
11732
  ]);
11191
11733
  /**
11192
11734
  * What the operator typed. Every field maps onto exactly one field of
@@ -11250,6 +11792,47 @@ var NcTestEventInputSchema = require_dist.object({
11250
11792
  occupied: require_dist.boolean(),
11251
11793
  threshold: require_dist.number().int().min(1)
11252
11794
  }).optional(),
11795
+ /**
11796
+ * AUDIO-WINDOW: the confirmed sampling window.
11797
+ *
11798
+ * Every field of the engine's {@link NcAudioWindowSubject}, because
11799
+ * `matchesAudio` pairs a rule to the window that BELONGS to it — the spec
11800
+ * fields (`samplingSeconds`, `dbThreshold`, `specLabels`) are compared for
11801
+ * equality, so a window built from anything but the rule's own condition
11802
+ * fails closed and the test would report a rule that works as broken.
11803
+ */
11804
+ audioWindow: require_dist.object({
11805
+ hitPercent: require_dist.number().min(0).max(100),
11806
+ samplingSeconds: require_dist.number().int().min(1).max(300),
11807
+ samples: require_dist.number().int().min(0),
11808
+ hits: require_dist.number().int().min(0),
11809
+ dbThreshold: require_dist.number().optional(),
11810
+ peakDbfs: require_dist.number().optional(),
11811
+ specLabels: require_dist.array(require_dist.string().min(1)).optional(),
11812
+ labels: require_dist.array(require_dist.string().min(1)).default([])
11813
+ }).optional(),
11814
+ /**
11815
+ * SYSTEM-EVENT: the normalized infrastructure event.
11816
+ *
11817
+ * The catalog KEYS travel, never the sentence — exactly as the live intake
11818
+ * freezes them (`event-intake.ts`), so a tested system notification is
11819
+ * worded by the same text catalog, in the same language, as the real one. A
11820
+ * tester that froze English prose here would be testing its own prose.
11821
+ */
11822
+ systemEvent: require_dist.object({
11823
+ kind: require_dist.NcSystemEventKindSchema,
11824
+ /** The dedup subject (`device:617`, `node:agent-1`, …). */
11825
+ subject: require_dist.string().min(1),
11826
+ titleKey: require_dist.string().min(1),
11827
+ bodyKey: require_dist.string().min(1),
11828
+ textParams: require_dist.record(require_dist.string(), require_dist.string()).default({}),
11829
+ /** The device the event is ABOUT; absent = an event with no device. */
11830
+ deviceId: require_dist.number().int().optional(),
11831
+ /** `DeviceType` value — what a `deviceTypes` condition is judged against. */
11832
+ deviceType: require_dist.string().min(1).optional(),
11833
+ nodeId: require_dist.string().min(1).optional(),
11834
+ packageName: require_dist.string().min(1).optional()
11835
+ }).optional(),
11253
11836
  /** The instant the event happened. Absent = now. Set it to test a schedule
11254
11837
  * condition without waiting for the hour. */
11255
11838
  timestamp: require_dist.number().int().optional(),
@@ -11337,6 +11920,12 @@ function isSyntheticId(id) {
11337
11920
  function triggerCanCarryStill(kind) {
11338
11921
  return kind === "object-event" || kind === "package-event" || kind === "track-end" || kind === "device-event" || kind === "occupancy-event";
11339
11922
  }
11923
+ /** The detection provenance production stamps on a subject of this kind. */
11924
+ function syntheticSource(kind) {
11925
+ if (kind === "device-event") return "sensor";
11926
+ if (kind === "audio-window") return "audio";
11927
+ return "pipeline";
11928
+ }
11340
11929
  /**
11341
11930
  * Build the synthetic envelope. Pure: same input + same id + same clock ⇒ same
11342
11931
  * event, so the red-green test can assert that this subject and a
@@ -11350,7 +11939,7 @@ function buildSyntheticEvent(input, recordId, now) {
11350
11939
  subject: {
11351
11940
  kind,
11352
11941
  recordId,
11353
- deviceId: input.deviceId,
11942
+ deviceId: kind === "system-event" ? 0 : input.deviceId,
11354
11943
  ...input.sourceDeviceId !== void 0 && input.sourceDeviceId !== input.deviceId ? { sourceDeviceId: input.sourceDeviceId } : {},
11355
11944
  timestamp,
11356
11945
  classNames: input.classNames,
@@ -11358,7 +11947,7 @@ function buildSyntheticEvent(input, recordId, now) {
11358
11947
  ...input.confidence !== void 0 ? { confidence: input.confidence } : {},
11359
11948
  zones: input.zones,
11360
11949
  ...carriesTrackId ? { trackId: recordId } : {},
11361
- source: input.source ?? (kind === "device-event" ? "sensor" : "pipeline"),
11950
+ source: input.source ?? syntheticSource(kind),
11362
11951
  ...input.importance !== void 0 ? { importance: input.importance } : {},
11363
11952
  ...input.dwellSeconds !== void 0 ? { dwellSeconds: input.dwellSeconds } : {},
11364
11953
  ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
@@ -11367,6 +11956,28 @@ function buildSyntheticEvent(input, recordId, now) {
11367
11956
  ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
11368
11957
  ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
11369
11958
  ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
11959
+ ...input.audioWindow !== void 0 ? { audioWindow: {
11960
+ hitPercent: input.audioWindow.hitPercent,
11961
+ samplingSeconds: input.audioWindow.samplingSeconds,
11962
+ samples: input.audioWindow.samples,
11963
+ hits: input.audioWindow.hits,
11964
+ ...input.audioWindow.dbThreshold !== void 0 ? { dbThreshold: input.audioWindow.dbThreshold } : {},
11965
+ ...input.audioWindow.peakDbfs !== void 0 ? { peakDbfs: input.audioWindow.peakDbfs } : {},
11966
+ ...input.audioWindow.specLabels !== void 0 ? { specLabels: input.audioWindow.specLabels } : {},
11967
+ labels: input.audioWindow.labels
11968
+ } } : {},
11969
+ ...input.systemEvent !== void 0 ? { systemEvent: {
11970
+ kind: input.systemEvent.kind,
11971
+ subject: input.systemEvent.subject,
11972
+ titleKey: input.systemEvent.titleKey,
11973
+ bodyKey: input.systemEvent.bodyKey,
11974
+ textParams: input.systemEvent.textParams,
11975
+ ...input.systemEvent.deviceId !== void 0 ? { deviceId: input.systemEvent.deviceId } : {},
11976
+ ...input.systemEvent.deviceType !== void 0 ? { deviceType: input.systemEvent.deviceType } : {},
11977
+ ...input.systemEvent.nodeId !== void 0 ? { nodeId: input.systemEvent.nodeId } : {},
11978
+ ...input.systemEvent.packageName !== void 0 ? { packageName: input.systemEvent.packageName } : {},
11979
+ data: { category: "synthetic.test" }
11980
+ } } : {},
11370
11981
  ...input.occupancy !== void 0 ? { occupancy: {
11371
11982
  ...input.occupancy.zoneId !== void 0 ? { zoneId: input.occupancy.zoneId } : {},
11372
11983
  ...input.occupancy.zoneName !== void 0 ? { zoneName: input.occupancy.zoneName } : {},
@@ -12025,6 +12636,84 @@ function planDenseRanges(input) {
12025
12636
  denseSec
12026
12637
  };
12027
12638
  }
12639
+ function newGroup(event) {
12640
+ const classCounts = /* @__PURE__ */ new Map();
12641
+ if (event.className !== void 0) classCounts.set(event.className, 1);
12642
+ return {
12643
+ ...event.trackId !== void 0 ? { trackId: event.trackId } : {},
12644
+ firstSeen: event.timestamp,
12645
+ lastSeen: event.timestamp,
12646
+ classCounts
12647
+ };
12648
+ }
12649
+ function absorb(group, event) {
12650
+ if (event.timestamp < group.firstSeen) group.firstSeen = event.timestamp;
12651
+ if (event.timestamp > group.lastSeen) group.lastSeen = event.timestamp;
12652
+ if (event.className === void 0) return;
12653
+ group.classCounts.set(event.className, (group.classCounts.get(event.className) ?? 0) + 1);
12654
+ }
12655
+ /** The class most rows agreed on; ties go to whichever appeared first, so the
12656
+ * same window always produces the same sentence. */
12657
+ function dominantClass(group) {
12658
+ let best;
12659
+ let bestCount = 0;
12660
+ for (const [className, count] of group.classCounts) if (count > bestCount) {
12661
+ best = className;
12662
+ bestCount = count;
12663
+ }
12664
+ return best;
12665
+ }
12666
+ function toInterval(group) {
12667
+ const className = dominantClass(group);
12668
+ return {
12669
+ ...group.trackId !== void 0 ? { trackId: group.trackId } : {},
12670
+ firstSeen: group.firstSeen,
12671
+ lastSeen: group.lastSeen,
12672
+ ...className !== void 0 ? { className } : {}
12673
+ };
12674
+ }
12675
+ /**
12676
+ * Fold persisted tracks and object events into one subject-per-entry array.
12677
+ *
12678
+ * @returns intervals sorted by `firstSeen` ascending. A persisted track is
12679
+ * returned unchanged and its own events are discarded: the store knows when
12680
+ * the track really began, the events only know when the emitter chose to
12681
+ * write one, and widening a persisted interval from event timestamps would
12682
+ * move a range boundary for no operator-visible gain.
12683
+ */
12684
+ function mergeDetectionIntervals(input) {
12685
+ const gapMs = Math.max(0, input.tracklessGapMs ?? 1e4);
12686
+ const persistedIds = /* @__PURE__ */ new Set();
12687
+ for (const track of input.tracks) if (track.trackId !== void 0) persistedIds.add(track.trackId);
12688
+ const ordered = input.events.toSorted((a, b) => a.timestamp - b.timestamp);
12689
+ const byTrackId = /* @__PURE__ */ new Map();
12690
+ const trackless = [];
12691
+ const openByClass = /* @__PURE__ */ new Map();
12692
+ for (const event of ordered) {
12693
+ const trackId = event.trackId;
12694
+ if (trackId !== void 0) {
12695
+ if (persistedIds.has(trackId)) continue;
12696
+ const existing = byTrackId.get(trackId);
12697
+ if (existing === void 0) byTrackId.set(trackId, newGroup(event));
12698
+ else absorb(existing, event);
12699
+ continue;
12700
+ }
12701
+ const key = event.className ?? "";
12702
+ const open = openByClass.get(key);
12703
+ if (open !== void 0 && event.timestamp - open.lastSeen <= gapMs) {
12704
+ absorb(open, event);
12705
+ continue;
12706
+ }
12707
+ const group = newGroup(event);
12708
+ trackless.push(group);
12709
+ openByClass.set(key, group);
12710
+ }
12711
+ return [
12712
+ ...input.tracks,
12713
+ ...[...byTrackId.values()].map(toInterval),
12714
+ ...trackless.map(toInterval)
12715
+ ].toSorted((a, b) => a.firstSeen - b.firstSeen);
12716
+ }
12028
12717
  //#endregion
12029
12718
  //#region src/notification-center/timelapse/footage-coverage.ts
12030
12719
  /**
@@ -12077,8 +12766,6 @@ function footageCoverage(ranges, windowStartMs, windowEndMs) {
12077
12766
  complete: windowMs - coveredMs <= COVERAGE_COMPLETE_SLACK_MS
12078
12767
  };
12079
12768
  }
12080
- /** How long a scheduled timelapse's file is kept by the export engine. */
12081
- var TIMELAPSE_EXPORT_LIFE_MS = 7 * 864e5;
12082
12769
  var TimelapseRenderError = class extends Error {
12083
12770
  reason;
12084
12771
  constructor(reason, message) {
@@ -12106,8 +12793,79 @@ function pickProfile(ranges, window) {
12106
12793
  }
12107
12794
  return best;
12108
12795
  }
12109
- /** `HH:MM`, for the export title an operator sees in the exports list. */
12110
- function clockOf$1(atMs) {
12796
+ /**
12797
+ * Is `record` the render this request is about to order?
12798
+ *
12799
+ * The range, the source profile and the BASE cadence must match exactly — those
12800
+ * three decide what the video is. The dense overlay deliberately does NOT: it
12801
+ * is derived from the tracks the window happened to hold, and a retry minutes
12802
+ * later plans it from a slightly longer list. Refusing to adopt over a moved
12803
+ * range boundary would re-order the whole ten hours for a difference nobody can
12804
+ * see, which is precisely the loop this exists to break.
12805
+ */
12806
+ function coversTheSameRender(record, want) {
12807
+ const timelapse = record.options.timelapse;
12808
+ if (timelapse === void 0) return false;
12809
+ return record.profile === want.profile && record.fromMs === want.fromMs && record.toMs === want.toMs && timelapse.everyMs === want.everyMs && timelapse.outputFps === want.outputFps;
12810
+ }
12811
+ /**
12812
+ * Would adopting `record` LOSE the slowdown this render planned?
12813
+ *
12814
+ * The adoption check deliberately ignores the dense RANGES (a retry plans them
12815
+ * from a slightly longer track list, and re-ordering ten hours over a moved
12816
+ * boundary is the loop adoption exists to break). It must NOT ignore whether a
12817
+ * dense overlay exists at all.
12818
+ *
12819
+ * The failure this closes: attempt one reads the detections while the store is
12820
+ * unreachable, plans nothing, and queues a UNIFORM ten-hour export; every retry
12821
+ * then plans ranges correctly, finds that uniform export alive under the same
12822
+ * (profile, range, base cadence, fps) key, adopts it, and delivers a video that
12823
+ * never slows down — for the whole night, no matter how many retries run. The
12824
+ * notification would even caption it "2 persone". A re-order costs ~20 min of
12825
+ * ffmpeg ONCE; adopting the wrong product costs the night.
12826
+ *
12827
+ * Bounded, and that is why it is safe: the verdict is a function of the plan, so
12828
+ * the export ordered after a refusal carries the dense overlay and the NEXT
12829
+ * retry adopts it. Only a plan that changed direction refuses, never a retry.
12830
+ * A candidate DENSER than what we want is adopted — more slowdown than asked is
12831
+ * not a defect, and refusing it would re-order for nothing.
12832
+ */
12833
+ function keepsThePlannedSlowdown(record, want) {
12834
+ if (want === void 0) return true;
12835
+ const dense = record.options.timelapse?.dense;
12836
+ if (dense === void 0 || dense.ranges.length === 0) return false;
12837
+ return dense.everyMs <= want.everyMs;
12838
+ }
12839
+ /** Can this row still become — or already be — a file we can fetch? */
12840
+ function isAdoptable(record, nowMs) {
12841
+ if (record.expiresAt <= nowMs) return false;
12842
+ if (record.state === "ready") return record.fileBytes !== null;
12843
+ return record.state === "queued" || record.state === "rendering";
12844
+ }
12845
+ /**
12846
+ * `HH:MM` in the RULE's timezone, for the export title an operator reads.
12847
+ *
12848
+ * The zone is not decoration. The hub container runs on UTC while the operator
12849
+ * and every one of his schedules run on Europe/Rome, so the host-local
12850
+ * derivation this used to do titled a 21:00→07:00 window `19:00→05:00` — two
12851
+ * hours off, on the one string whose whole job is to say which night the file
12852
+ * is. The window boundaries themselves were always correct: `isScheduleActive`
12853
+ * has resolved minutes through `schedule.timezone` from the start, and only the
12854
+ * label lied.
12855
+ *
12856
+ * An absent or unknown IANA name falls back to the host zone, which is exactly
12857
+ * what `localDayMinute` does for the same input — one behaviour for a bad
12858
+ * zone, not two.
12859
+ */
12860
+ function clockOf$1(atMs, timeZone) {
12861
+ if (timeZone !== void 0) try {
12862
+ return new Intl.DateTimeFormat("en-GB", {
12863
+ timeZone,
12864
+ hour: "2-digit",
12865
+ minute: "2-digit",
12866
+ hourCycle: "h23"
12867
+ }).format(new Date(atMs));
12868
+ } catch {}
12111
12869
  const d = new Date(atMs);
12112
12870
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
12113
12871
  }
@@ -12139,12 +12897,25 @@ var TimelapseRenderer = class {
12139
12897
  });
12140
12898
  throw new TimelapseRenderError("no-footage", `no footage for ${deviceId} in ${window.startMs}..${window.endMs}`);
12141
12899
  }
12142
- const tracks = await this.ports.listTracks({
12900
+ const [tracks, events] = await Promise.all([this.ports.listTracks({
12901
+ deviceId,
12902
+ since: window.startMs,
12903
+ until: window.endMs
12904
+ }).catch((err) => {
12905
+ this.ports.logger.warn("timelapse track read failed — dense plan degraded", {
12906
+ tags,
12907
+ meta: {
12908
+ ruleId: request.ruleId,
12909
+ error: String(err)
12910
+ }
12911
+ });
12912
+ return [];
12913
+ }), this.ports.listDetectionEvents({
12143
12914
  deviceId,
12144
12915
  since: window.startMs,
12145
12916
  until: window.endMs
12146
12917
  }).catch((err) => {
12147
- this.ports.logger.warn("timelapse dense plan unavailable — rendering uniform", {
12918
+ this.ports.logger.warn("timelapse detection-event read failed — dense plan degraded", {
12148
12919
  tags,
12149
12920
  meta: {
12150
12921
  ruleId: request.ruleId,
@@ -12152,13 +12923,17 @@ var TimelapseRenderer = class {
12152
12923
  }
12153
12924
  });
12154
12925
  return [];
12926
+ })]);
12927
+ const subjects = mergeDetectionIntervals({
12928
+ tracks,
12929
+ events
12155
12930
  });
12156
12931
  const detections = countDetections({
12157
- tracks,
12932
+ tracks: subjects,
12158
12933
  ...request.reportClasses !== void 0 ? { reportClasses: request.reportClasses } : {}
12159
12934
  });
12160
12935
  const plan = planDenseRanges({
12161
- tracks,
12936
+ tracks: subjects,
12162
12937
  windowStartMs: window.startMs,
12163
12938
  windowEndMs: window.endMs,
12164
12939
  padSec: 5,
@@ -12191,16 +12966,25 @@ var TimelapseRenderer = class {
12191
12966
  mergedRanges: plan.mergedCount
12192
12967
  }
12193
12968
  });
12194
- const queued = await this.ports.createExport({
12969
+ const adopted = await this.findAdoptable({
12970
+ deviceId,
12971
+ profile: chosen.profile,
12972
+ fromMs: window.startMs,
12973
+ toMs: window.endMs
12974
+ }, {
12975
+ everyMs,
12976
+ outputFps: request.framerate
12977
+ }, dense, tags);
12978
+ const queued = adopted ?? await this.ports.createExport({
12195
12979
  deviceId,
12196
12980
  profile: chosen.profile,
12197
12981
  fromMs: window.startMs,
12198
12982
  toMs: window.endMs,
12199
12983
  options: {
12200
12984
  includeAudio: false,
12201
- maxLifeMs: TIMELAPSE_EXPORT_LIFE_MS,
12985
+ maxLifeMs: 6048e5,
12202
12986
  deleteAfterDownload: false,
12203
- title: `${request.ruleName} ${clockOf$1(window.startMs)}→${clockOf$1(window.endMs)}`,
12987
+ title: `${request.ruleName} ${clockOf$1(window.startMs, request.timezone)}→${clockOf$1(window.endMs, request.timezone)}`,
12204
12988
  timelapse: {
12205
12989
  everyMs,
12206
12990
  outputFps: request.framerate,
@@ -12208,7 +12992,7 @@ var TimelapseRenderer = class {
12208
12992
  }
12209
12993
  }
12210
12994
  });
12211
- this.ports.logger.info("timelapse export queued", {
12995
+ this.ports.logger.info(adopted === null ? "timelapse export queued" : "timelapse export adopted", {
12212
12996
  tags,
12213
12997
  meta: {
12214
12998
  ruleId: request.ruleId,
@@ -12219,7 +13003,11 @@ var TimelapseRenderer = class {
12219
13003
  denseSource: cadence.source,
12220
13004
  denseDwellApplied: cadence.dwellApplied,
12221
13005
  denseRanges: plan.ranges.length,
12222
- coveragePct: chosen.coverage.percent
13006
+ denseTracks: tracks.length,
13007
+ denseEvents: events.length,
13008
+ denseSubjects: subjects.length,
13009
+ coveragePct: chosen.coverage.percent,
13010
+ ...adopted !== null ? { adoptedState: adopted.state } : {}
12223
13011
  }
12224
13012
  });
12225
13013
  const finished = await this.awaitReady(queued, tags);
@@ -12237,14 +13025,79 @@ var TimelapseRenderer = class {
12237
13025
  fileBytes: finished.fileBytes
12238
13026
  };
12239
13027
  }
12240
- /** Poll to a terminal state, under a bound. */
13028
+ /**
13029
+ * The render this request would order, if the engine already owes it.
13030
+ *
13031
+ * Never throws: an adoption lookup that fails costs one duplicate export,
13032
+ * which is strictly better than costing the window. It says so, though — a
13033
+ * branch that silently drops the protection reads as "there was nothing to
13034
+ * adopt".
13035
+ */
13036
+ async findAdoptable(want, cadence, wantDense, tags) {
13037
+ const rows = await this.ports.listExports({ deviceId: want.deviceId }).catch((err) => {
13038
+ this.ports.logger.warn("timelapse could not check for an export already in flight", {
13039
+ tags,
13040
+ meta: { error: String(err) }
13041
+ });
13042
+ return null;
13043
+ });
13044
+ if (rows === null) return null;
13045
+ const now = this.ports.now();
13046
+ const sameRender = rows.filter((r) => isAdoptable(r, now) && coversTheSameRender(r, {
13047
+ ...want,
13048
+ ...cadence
13049
+ }));
13050
+ const usable = sameRender.filter((r) => keepsThePlannedSlowdown(r, wantDense));
13051
+ if (usable.length < sameRender.length) this.ports.logger.info("timelapse refused to adopt an export with no dense overlay", {
13052
+ tags,
13053
+ meta: {
13054
+ candidates: sameRender.length,
13055
+ refused: sameRender.length - usable.length,
13056
+ wantDenseEveryMs: wantDense?.everyMs ?? null,
13057
+ wantDenseRanges: wantDense?.ranges.length ?? 0
13058
+ }
13059
+ });
13060
+ const ready = usable.find((r) => r.state === "ready");
13061
+ if (ready !== void 0) return ready;
13062
+ return usable.toSorted((a, b) => a.createdAt - b.createdAt)[0] ?? null;
13063
+ }
13064
+ /**
13065
+ * Poll to a terminal state, under a bound.
13066
+ *
13067
+ * The bound belongs to the RENDER, not to any single poll of it. A
13068
+ * `getExport` that fails on the transport says nothing at all about the
13069
+ * ffmpeg on the recording node — and treating it as fatal is how a finished
13070
+ * 12.5 MB video was abandoned on 2026-08-14: one `UDS request timed out after
13071
+ * 60000ms` at 05:10:47 threw straight out of the wait for an export that
13072
+ * reached `ready` at 05:23:47. So a failed poll is retried until the
13073
+ * deadline, and only a TERMINAL state the engine actually reported ends the
13074
+ * wait early.
13075
+ */
12241
13076
  async awaitReady(queued, tags) {
12242
13077
  if (queued.state === "ready") return queued;
12243
13078
  const deadline = this.ports.now() + this.timeoutMs;
12244
13079
  let current = queued;
13080
+ let pollFailures = 0;
13081
+ let lastPollError = null;
12245
13082
  while (this.ports.now() < deadline) {
12246
13083
  await this.ports.sleep(this.pollMs);
12247
- current = await this.ports.getExport({ exportId: queued.id });
13084
+ const polled = await this.ports.getExport({ exportId: queued.id }).catch((err) => {
13085
+ pollFailures += 1;
13086
+ lastPollError = String(err);
13087
+ const entry = {
13088
+ tags,
13089
+ meta: {
13090
+ exportId: queued.id,
13091
+ pollFailures,
13092
+ error: lastPollError
13093
+ }
13094
+ };
13095
+ if (pollFailures === 1) this.ports.logger.warn("timelapse export poll failed — the render is still owed", entry);
13096
+ else this.ports.logger.debug("timelapse export poll failed again", entry);
13097
+ return null;
13098
+ });
13099
+ if (polled === null) continue;
13100
+ current = polled;
12248
13101
  if (current.state === "ready") return current;
12249
13102
  if (current.state === "failed" || current.state === "expired" || current.state === "deleted") {
12250
13103
  this.ports.logger.warn("timelapse export did not finish", {
@@ -12263,10 +13116,12 @@ var TimelapseRenderer = class {
12263
13116
  meta: {
12264
13117
  exportId: queued.id,
12265
13118
  timeoutMs: this.timeoutMs,
12266
- lastState: current.state
13119
+ lastState: current.state,
13120
+ pollFailures,
13121
+ ...lastPollError !== null ? { lastPollError } : {}
12267
13122
  }
12268
13123
  });
12269
- throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms`);
13124
+ throw new TimelapseRenderError("export-timeout", `export ${queued.id} timed out after ${this.timeoutMs}ms` + (pollFailures > 0 ? ` (${pollFailures} poll failures, last: ${lastPollError})` : ""));
12270
13125
  }
12271
13126
  /**
12272
13127
  * Mint the public link, or say why there is none.
@@ -12475,6 +13330,25 @@ function buildTimelapseOutboxInputs(input) {
12475
13330
  };
12476
13331
  });
12477
13332
  }
13333
+ //#endregion
13334
+ //#region src/notification-center/timelapse/timelapse-scheduler.ts
13335
+ /**
13336
+ * How long after a window closes before it is rendered.
13337
+ *
13338
+ * A track is persisted when it CLOSES, and a track that was open at 06:00
13339
+ * lands minutes later. Rendering at the stroke of the boundary would miss
13340
+ * exactly the tracks the dense plan is built from — the ones still moving when
13341
+ * the window ended.
13342
+ */
13343
+ var TIMELAPSE_SETTLE_MS = 5 * 6e4;
13344
+ /**
13345
+ * How far back a tick may look for a close.
13346
+ *
13347
+ * Eight days, so a hub that was down over a long weekend still finds last
13348
+ * night. The scan short-circuits at the first close it meets, so a rule with a
13349
+ * nightly window costs about a day's worth of minute steps, not eight.
13350
+ */
13351
+ var TIMELAPSE_LOOKBACK_MS = 8 * 864e5;
12478
13352
  /**
12479
13353
  * The earliest window CLOSE a (rule, device) pair may ever be offered.
12480
13354
  *
@@ -12504,10 +13378,15 @@ function buildTimelapseOutboxInputs(input) {
12504
13378
  function timelapseEligibilityFloor(rule, deviceId) {
12505
13379
  return Math.max(require_dist.readTimelapseGeneratedAt(rule, deviceId), rule.createdAt);
12506
13380
  }
13381
+ /** The in-flight map's key. One render per (rule, camera) pair by construction. */
13382
+ function renderFlightKey(ruleId, deviceId) {
13383
+ return `${ruleId}:${String(deviceId)}`;
13384
+ }
12507
13385
  var TimelapseScheduler = class {
12508
13386
  deps;
12509
13387
  timer = null;
12510
13388
  tickInFlight = false;
13389
+ inFlight = /* @__PURE__ */ new Map();
12511
13390
  settleMs;
12512
13391
  lookbackMs;
12513
13392
  tickMs;
@@ -12520,16 +13399,32 @@ var TimelapseScheduler = class {
12520
13399
  this.texts = deps.texts ?? new NcTextCatalog();
12521
13400
  }
12522
13401
  /**
12523
- * Boot: one tick (the back-fill) and then the interval.
13402
+ * Boot: arm the interval, then fire the back-fill tick WITHOUT waiting on it.
12524
13403
  *
12525
13404
  * The back-fill needs no special path — it IS a tick. A window that closed
12526
13405
  * while the process was down is simply a window whose pair has no stamp.
13406
+ *
13407
+ * `start()` resolving must mean "the producer is running", never "the
13408
+ * back-fill has finished". A back-fill tick renders video: one window can sit
13409
+ * in `awaitReady` for the full `RENDER_TIMEOUT_MS` (30 min), once per (rule,
13410
+ * camera) pair. Awaiting it here held `NotificationCenter.start()`, which held
13411
+ * the addon's `initialize()`, which is what publishes the runner's capability
13412
+ * manifest — so on 2026-08-14 a single 10-hour timelapse window took
13413
+ * `notification-rules`, `videoclips`, `face-gallery`, `plate-gallery`,
13414
+ * `zone-analytics`, `audio-metrics` and `pipeline-analytics` off the cluster
13415
+ * entirely, and the admin UI answered "provider not available".
13416
+ *
13417
+ * `tick()` never throws for a render failure — `produce()` swallows those and
13418
+ * leaves the pair unstamped — so the catch here only covers a store read that
13419
+ * fails, and it must not become an unhandled rejection.
12527
13420
  */
12528
13421
  async start() {
12529
- await this.tick();
12530
13422
  this.timer = setInterval(() => {
12531
13423
  this.tick();
12532
13424
  }, this.tickMs);
13425
+ this.tick().catch((err) => {
13426
+ this.deps.logger.warn("timelapse back-fill tick failed", { meta: { error: String(err) } });
13427
+ });
12533
13428
  }
12534
13429
  stop() {
12535
13430
  if (this.timer !== null) {
@@ -12638,6 +13533,13 @@ var TimelapseScheduler = class {
12638
13533
  */
12639
13534
  async produce(rule, deviceId, window, opts) {
12640
13535
  const tags = { deviceId };
13536
+ const flightKey = renderFlightKey(rule.id, deviceId);
13537
+ this.inFlight.set(flightKey, {
13538
+ ruleId: rule.id,
13539
+ deviceId,
13540
+ window,
13541
+ startedAt: this.deps.now()
13542
+ });
12641
13543
  try {
12642
13544
  const deviceName = await this.resolveDeviceName(deviceId);
12643
13545
  const outcome = await this.deps.render({
@@ -12649,6 +13551,7 @@ var TimelapseScheduler = class {
12649
13551
  framerate: rule.framerate,
12650
13552
  ...rule.denseCadenceSec !== void 0 ? { denseCadenceSec: rule.denseCadenceSec } : {},
12651
13553
  ...rule.minDwellSec !== void 0 ? { minDwellSec: rule.minDwellSec } : {},
13554
+ ...rule.schedule.timezone !== void 0 ? { timezone: rule.schedule.timezone } : {},
12652
13555
  previewText: timelapsePreviewText({
12653
13556
  deviceId,
12654
13557
  ...deviceName !== null ? { deviceName } : {},
@@ -12721,8 +13624,15 @@ var TimelapseScheduler = class {
12721
13624
  window,
12722
13625
  error: err instanceof Error ? err.message : String(err)
12723
13626
  };
13627
+ } finally {
13628
+ this.inFlight.delete(flightKey);
12724
13629
  }
12725
13630
  }
13631
+ /** Renders this process currently owes, newest last. Read by the rule-status
13632
+ * surface; never a decision input for the scheduler itself. */
13633
+ renderInFlight() {
13634
+ return [...this.inFlight.values()];
13635
+ }
12726
13636
  async resolveDeviceName(deviceId) {
12727
13637
  const get = this.deps.getDeviceName;
12728
13638
  if (get === void 0) return null;
@@ -13134,6 +14044,14 @@ var NcZoneOwnershipCache = class {
13134
14044
  /** The one cap whose `trigger` the combined alarm notification joins on. */
13135
14045
  var ALARM_PANEL_CAP = require_dist.alarmPanelCapability.name;
13136
14046
  /**
14047
+ * How far ahead a status may scan for the next window opening.
14048
+ *
14049
+ * Nine days rather than "as far as it takes": a weekly schedule opens within
14050
+ * seven, and a schedule that opens less often than that is one whose next run
14051
+ * is not the fact an operator is looking at a card for.
14052
+ */
14053
+ var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
14054
+ /**
13137
14055
  * The synthetic rule id an alarm ANNOUNCEMENT is enqueued under.
13138
14056
  *
13139
14057
  * The arm announcement is addressed to the panel's own `announceTargets`, not
@@ -13317,6 +14235,44 @@ function historyRecordKind(kind) {
13317
14235
  function contactReadingOf(state) {
13318
14236
  if (state === "open" || state === "closed") return state;
13319
14237
  }
14238
+ /**
14239
+ * The subject kind a persisted record kind maps to.
14240
+ *
14241
+ * `NcOutboxRecordKind` carries two kinds a rule subject never has —
14242
+ * `timelapse` and `ai-summary` are SCHEDULE boundaries, not records. They are
14243
+ * mapped to `device-event`, which is class-agnostic in `cooldownKey`, so their
14244
+ * key is the one the seeder already built for them. A switch rather than a
14245
+ * cast: a kind added to either union has to be answered for here.
14246
+ */
14247
+ function cooldownSubjectKind(kind) {
14248
+ switch (kind) {
14249
+ case "timelapse":
14250
+ case "ai-summary": return "device-event";
14251
+ default: return kind;
14252
+ }
14253
+ }
14254
+ /**
14255
+ * A persisted outbox row, reduced to exactly what `cooldownKey` reads.
14256
+ *
14257
+ * The point is that it reads NOTHING else: the boot-time cooldown reseed calls
14258
+ * the same builder the live path does, so the two can never address different
14259
+ * windows. Only the four fields the key is a function of are reconstructed —
14260
+ * the class (for `per-class` and audio), the subject kind (which decides
14261
+ * whether audio is keyed per class at all), the system event (subject +
14262
+ * direction) and the device.
14263
+ */
14264
+ function cooldownSubjectFromOutbox(entry) {
14265
+ const subject = entry.payload.subject;
14266
+ return {
14267
+ kind: cooldownSubjectKind(entry.recordKind),
14268
+ recordId: entry.recordId,
14269
+ deviceId: entry.deviceId,
14270
+ timestamp: subject.timestamp,
14271
+ classNames: subject.className.length > 0 ? [subject.className] : [],
14272
+ zones: [...subject.zones],
14273
+ ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
14274
+ };
14275
+ }
13320
14276
  function historySystemEventKind(kind) {
13321
14277
  if (kind === "alarm-arming") return "alarm-armed";
13322
14278
  if (kind === "alarm-arm-refused") return "alarm-disarmed";
@@ -13780,7 +14736,8 @@ var NotificationCenter = class NotificationCenter {
13780
14736
  this.now = deps.now ?? (() => Date.now());
13781
14737
  this.rules = new NcRuleStore({
13782
14738
  store: deps.store,
13783
- logger: this.logger.child("rules")
14739
+ logger: this.logger.child("rules"),
14740
+ ...deps.identityIdsByName !== void 0 ? { identityIdsByName: deps.identityIdsByName } : {}
13784
14741
  });
13785
14742
  this.textStore = new NcTextCatalogStore({
13786
14743
  store: deps.store,
@@ -14154,6 +15111,120 @@ var NotificationCenter = class NotificationCenter {
14154
15111
  ...report.droppedBy !== void 0 ? { droppedBy: report.droppedBy } : {}
14155
15112
  };
14156
15113
  }
15114
+ /**
15115
+ * The live status of ONE detection rule — see `rule-status.ts`.
15116
+ *
15117
+ * `none` is a real answer, not a failure: most rule kinds have no live state
15118
+ * a card could show, and returning an empty object would read as a read that
15119
+ * failed. Only occupancy has one today, and it is the kind whose entire
15120
+ * subject is a number nobody can see.
15121
+ */
15122
+ async ruleStatusFor(rule) {
15123
+ const occupancy = rule.conditions.occupancy;
15124
+ if (occupancy === void 0) return {
15125
+ kind: "none",
15126
+ reason: "this rule has no live state to report"
15127
+ };
15128
+ const deviceId = rule.conditions.devices?.[0];
15129
+ const read = this.deps.readOccupancySnapshots;
15130
+ const threshold = occupancyThreshold(occupancy);
15131
+ if (deviceId === void 0 || read === void 0) return buildOccupancyStatus({
15132
+ deviceId: deviceId ?? 0,
15133
+ condition: occupancy,
15134
+ probe: {
15135
+ kind: "unavailable",
15136
+ reason: "no-snapshot"
15137
+ },
15138
+ confirmed: this.occupancyWatcher.snapshotState(),
15139
+ threshold
15140
+ });
15141
+ let snapshot = null;
15142
+ try {
15143
+ snapshot = (await read([deviceId])).get(deviceId) ?? null;
15144
+ } catch (err) {
15145
+ this.logger.warn("rule status could not read the live occupancy snapshot", {
15146
+ tags: { deviceId },
15147
+ meta: {
15148
+ ruleId: rule.id,
15149
+ error: String(err)
15150
+ }
15151
+ });
15152
+ }
15153
+ const probe = probeOccupancyNow({
15154
+ occupancy,
15155
+ deviceId,
15156
+ snapshot,
15157
+ now: this.now()
15158
+ });
15159
+ const warning = occupancyClassWarning(occupancy.className);
15160
+ return buildOccupancyStatus({
15161
+ deviceId,
15162
+ condition: occupancy,
15163
+ probe,
15164
+ confirmed: this.occupancyWatcher.snapshotState(),
15165
+ threshold,
15166
+ ...warning !== null ? { warning } : {}
15167
+ });
15168
+ }
15169
+ /**
15170
+ * The live status of one TIMELAPSE rule.
15171
+ *
15172
+ * Three facts, and each answers a question the rule itself cannot: when each
15173
+ * camera last produced (and the artefact it produced, while the shelf still
15174
+ * holds it), whether a render is in flight RIGHT NOW, and when the next
15175
+ * window opens. The window boundaries are scanned through `isScheduleActive`,
15176
+ * the same authority the producer uses — a second reading of a schedule is
15177
+ * how a status starts promising a run that is not coming.
15178
+ */
15179
+ async timelapseStatusFor(rule, lookup) {
15180
+ const now = this.now();
15181
+ const flights = this.timelapseScheduler?.renderInFlight() ?? [];
15182
+ const lastWindow = findLastClosedWindow({
15183
+ schedule: rule.schedule,
15184
+ nowMs: now,
15185
+ lookbackMs: TIMELAPSE_LOOKBACK_MS
15186
+ });
15187
+ const nextWindow = findNextWindowOpen({
15188
+ schedule: rule.schedule,
15189
+ nowMs: now,
15190
+ lookaheadMs: TIMELAPSE_LOOKAHEAD_MS
15191
+ });
15192
+ const devices = [];
15193
+ for (const deviceId of rule.deviceIds) {
15194
+ const generatedAt = require_dist.readTimelapseGeneratedAt(rule, deviceId);
15195
+ const flight = flights.find((f) => f.ruleId === rule.id && f.deviceId === deviceId);
15196
+ const artifact = lookup === void 0 ? null : await lookup({
15197
+ ruleId: rule.id,
15198
+ deviceId
15199
+ }).catch((err) => {
15200
+ this.logger.warn("rule status could not read the timelapse artifact index", {
15201
+ tags: { deviceId },
15202
+ meta: {
15203
+ ruleId: rule.id,
15204
+ error: String(err)
15205
+ }
15206
+ });
15207
+ return null;
15208
+ });
15209
+ devices.push({
15210
+ deviceId,
15211
+ ...generatedAt > 0 ? { lastGeneratedAt: generatedAt } : {},
15212
+ ...artifact !== null ? { lastArtifact: artifact } : {},
15213
+ ...flight !== void 0 ? { rendering: {
15214
+ startedAt: flight.startedAt,
15215
+ windowStartMs: flight.window.startMs,
15216
+ windowEndMs: flight.window.endMs
15217
+ } } : {}
15218
+ });
15219
+ }
15220
+ return {
15221
+ kind: "timelapse",
15222
+ devices,
15223
+ ...lastWindow !== null ? { lastWindow } : {},
15224
+ ...nextWindow !== null ? { nextWindow } : {},
15225
+ settleMs: TIMELAPSE_SETTLE_MS
15226
+ };
15227
+ }
14157
15228
  /** One live frame, or null. Never throws into an injection. */
14158
15229
  async captureLiveStill(deviceId, deps) {
14159
15230
  const capture = deps.getSnapshot;
@@ -14928,7 +15999,7 @@ var NotificationCenter = class NotificationCenter {
14928
15999
  return report({ droppedBy: "muted" });
14929
16000
  }
14930
16001
  const delivery = deliveryForKind(kind);
14931
- const enabled = this.rules.listEnabled(delivery);
16002
+ const enabled = this.rulesForTrigger(delivery, kind, subject);
14932
16003
  const candidates = controls.onlyRuleId !== void 0 ? enabled.filter((r) => r.id === controls.onlyRuleId) : enabled;
14933
16004
  if (candidates.length === 0) {
14934
16005
  this.logger.debug("no enabled rule for this trigger", {
@@ -14940,6 +16011,7 @@ var NotificationCenter = class NotificationCenter {
14940
16011
  rulesLoaded: this.rules.list().length
14941
16012
  }
14942
16013
  });
16014
+ if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
14943
16015
  return report({ droppedBy: "no-candidate-rule" });
14944
16016
  }
14945
16017
  const now = this.now();
@@ -15040,6 +16112,7 @@ var NotificationCenter = class NotificationCenter {
15040
16112
  at: now
15041
16113
  });
15042
16114
  const inserted = await this.outbox.enqueue(entries);
16115
+ this.noteRecognitionFire(rule, subject, kind, entries, inserted);
15043
16116
  if (inserted > 0 && controls.bypassCooldown !== true) this.lastFiredAt.set(key, now);
15044
16117
  outcomes.push({
15045
16118
  ruleId: rule.id,
@@ -15053,9 +16126,124 @@ var NotificationCenter = class NotificationCenter {
15053
16126
  anyMatched = true;
15054
16127
  }
15055
16128
  if (!anyMatched && rejections.length > 0) this.reportNoMatch(subject, kind, rejections, now);
16129
+ if (kind === "track-end") await this.enqueueRecognitionUpgrades(subject, outcomes);
15056
16130
  return report({ candidates: candidates.length });
15057
16131
  }
15058
16132
  /**
16133
+ * The rules a trigger may fire, which is `listEnabled(delivery)` plus and
16134
+ * minus exactly one thing: `waitForEnhancement`.
16135
+ *
16136
+ * An `immediate` rule that agreed to wait for a name is REMOVED from the
16137
+ * object-event trigger and ADDED to the track close, because the closed track
16138
+ * is the first record that carries the name. Both halves are needed and the
16139
+ * removal is the load-bearing one — leaving it on the object event would fire
16140
+ * the early, nameless notification, and the dedup key (`ruleId:trackId:target`)
16141
+ * would then silently swallow the complete one as a duplicate. The operator
16142
+ * would have set the switch and received exactly what they set it to avoid.
16143
+ *
16144
+ * The drop is REPORTED. A rule vanishing from a trigger reads as a broken rule
16145
+ * from a log, and this repo has paid for that twice.
16146
+ */
16147
+ rulesForTrigger(delivery, kind, subject) {
16148
+ const enabled = this.rules.listEnabled(delivery);
16149
+ if (kind === "object-event") {
16150
+ const deferred = enabled.filter((r) => ruleDefersToTrackEnd(r));
16151
+ if (deferred.length === 0) return enabled;
16152
+ this.logger.debug("rules deferred to track close — waiting for recognition", {
16153
+ tags: { deviceId: subject.deviceId },
16154
+ meta: {
16155
+ rules: deferred.map((r) => r.name).join(", "),
16156
+ eventId: subject.recordId,
16157
+ ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
16158
+ }
16159
+ });
16160
+ return enabled.filter((r) => !ruleDefersToTrackEnd(r));
16161
+ }
16162
+ if (kind === "track-end") return [...enabled, ...this.rules.listEnabled("immediate").filter((r) => ruleDefersToTrackEnd(r))];
16163
+ return enabled;
16164
+ }
16165
+ /**
16166
+ * What an `immediate` recognition rule already said about a track, kept only
16167
+ * until that track closes.
16168
+ *
16169
+ * Keyed `ruleIdtrackId`, holding the targets the first push reached and
16170
+ * the LABEL it froze. In RAM and deliberately not durable: losing it costs an
16171
+ * upgrade, which degrades to "the operator got the first notification and not
16172
+ * the second" — the state of the world before this feature. Making it durable
16173
+ * would trade that for a second write on the enqueue path and a table that
16174
+ * outlives the tracks it describes.
16175
+ */
16176
+ recognitionFires = /* @__PURE__ */ new Map();
16177
+ /** Bound on {@link recognitionFires}. A track that never closes (a crash
16178
+ * between its birth and its close) must not pin a row forever. */
16179
+ static RECOGNITION_FIRE_TTL_MS = 30 * 6e4;
16180
+ recognitionFireKey(ruleId, trackId) {
16181
+ return `${ruleId}${trackId}`;
16182
+ }
16183
+ noteRecognitionFire(rule, subject, kind, entries, inserted) {
16184
+ if (kind !== "object-event" || inserted === 0) return;
16185
+ if (subject.trackId === void 0 || !ruleUpgradesAtTrackEnd(rule)) return;
16186
+ const now = this.now();
16187
+ for (const [key, held] of this.recognitionFires) if (now - held.at > NotificationCenter.RECOGNITION_FIRE_TTL_MS) this.recognitionFires.delete(key);
16188
+ this.recognitionFires.set(this.recognitionFireKey(rule.id, subject.trackId), {
16189
+ targetIds: entries.map((e) => e.targetId),
16190
+ ...subject.label !== void 0 ? { label: subject.label } : {},
16191
+ at: now
16192
+ });
16193
+ }
16194
+ /**
16195
+ * Send the "…and it was Gianluca" follow-up for every rule that fired on this
16196
+ * track WITHOUT a name and has one now.
16197
+ *
16198
+ * Four gates, and each one exists because its absence is a notification the
16199
+ * operator did not ask for:
16200
+ *
16201
+ * - the rule fired for this track at all (`recognitionFires`) — an upgrade
16202
+ * with no original is a notification about a rule that never matched;
16203
+ * - the track ended RECOGNISED (`labelKind`) — a person nobody could name
16204
+ * has nothing to add;
16205
+ * - the name is NEW — plates are frequently read on the first frame (0.0 s
16206
+ * observed), and re-sending the same word is the duplicate this whole
16207
+ * feature exists to avoid;
16208
+ * - one row per (rule, track, target), enforced by the outbox on the
16209
+ * `<trackId>:enhanced` dedup ref, which survives a respawn.
16210
+ *
16211
+ * A rule that ALREADY matched this closed track through the ordinary loop
16212
+ * (`outcomes`) is skipped: it just sent a track-end notification carrying the
16213
+ * name, and an upgrade would be that same sentence twice.
16214
+ */
16215
+ async enqueueRecognitionUpgrades(subject, outcomes) {
16216
+ const trackId = subject.trackId;
16217
+ if (trackId === void 0 || subject.labelKind === void 0) return;
16218
+ const alreadyFired = new Set(outcomes.filter((o) => o.matched).map((o) => o.ruleId));
16219
+ for (const rule of this.rules.listEnabled("immediate")) {
16220
+ if (!ruleUpgradesAtTrackEnd(rule) || alreadyFired.has(rule.id)) continue;
16221
+ const key = this.recognitionFireKey(rule.id, trackId);
16222
+ const held = this.recognitionFires.get(key);
16223
+ if (held === void 0) continue;
16224
+ this.recognitionFires.delete(key);
16225
+ if (held.label === subject.label) continue;
16226
+ const entries = this.buildEntries(rule, subject, "track-end", ["identities"], [], "pipeline", void 0, {
16227
+ dedupRef: `${trackId}:enhanced`,
16228
+ enhancement: true,
16229
+ targetIds: held.targetIds
16230
+ });
16231
+ const inserted = await this.outbox.enqueue(entries);
16232
+ this.logger.info("recognition upgrade — enqueueing a second notification", {
16233
+ tags: { deviceId: subject.deviceId },
16234
+ meta: {
16235
+ ruleId: rule.id,
16236
+ rule: rule.name,
16237
+ trackId,
16238
+ was: held.label ?? "",
16239
+ now: subject.label ?? "",
16240
+ labelKind: subject.labelKind,
16241
+ enqueued: inserted
16242
+ }
16243
+ });
16244
+ }
16245
+ }
16246
+ /**
15059
16247
  * Run this rule's `onTrigger` sequences for this match.
15060
16248
  *
15061
16249
  * Called BEFORE the cooldown gate — see the call site for why. Everything
@@ -15123,7 +16311,7 @@ var NotificationCenter = class NotificationCenter {
15123
16311
  }
15124
16312
  });
15125
16313
  }
15126
- buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown) {
16314
+ buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade) {
15127
16315
  const hasEventMedia = kind === "object-event" || kind === "package-event";
15128
16316
  const isTrackScoped = kind === "object-event" || kind === "track-end";
15129
16317
  const isSystemEvent = kind === "system-event";
@@ -15134,11 +16322,13 @@ var NotificationCenter = class NotificationCenter {
15134
16322
  const renderedZones = notificationZones(rule, subject);
15135
16323
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
15136
16324
  const cropZoneIds = rule.conditions.zones !== void 0 ? rule.conditions.zones.ids : rule.conditions.occupancy?.zoneId !== void 0 ? [rule.conditions.occupancy.zoneId] : void 0;
15137
- return [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))].filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
16325
+ const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
16326
+ return (upgrade !== void 0 ? allTargets.filter((t) => upgrade.targetIds.includes(t.targetId)) : allTargets).filter((target) => !rule.disabledTargetIds.includes(target.targetId)).map((target) => {
15138
16327
  const payload = {
15139
16328
  ruleName: rule.name,
15140
16329
  delivery: rule.delivery,
15141
16330
  ...origin === "synthetic" ? { synthetic: true } : {},
16331
+ ...upgrade !== void 0 ? { enhancement: true } : {},
15142
16332
  priority: rule.priority,
15143
16333
  ...rule.template !== void 0 ? { template: rule.template } : {},
15144
16334
  media: noMedia ? "none" : mediaRule.media.attach,
@@ -15162,6 +16352,7 @@ var NotificationCenter = class NotificationCenter {
15162
16352
  ...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : hasEventMedia ? { eventId: subject.recordId } : {},
15163
16353
  className: subject.classNames[0] ?? subject.sensorKind ?? "event",
15164
16354
  ...subject.label !== void 0 ? { label: subject.label } : {},
16355
+ ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {},
15165
16356
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
15166
16357
  zones: renderedZones,
15167
16358
  timestamp: subject.timestamp,
@@ -15176,6 +16367,7 @@ var NotificationCenter = class NotificationCenter {
15176
16367
  recordKind: kind === "occupancy-event" ? "device-event" : kind,
15177
16368
  recordId: subject.recordId,
15178
16369
  ...isTrackScoped && subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
16370
+ ...upgrade !== void 0 ? { dedupRef: upgrade.dedupRef } : {},
15179
16371
  payload
15180
16372
  };
15181
16373
  });
@@ -15268,13 +16460,26 @@ var NotificationCenter = class NotificationCenter {
15268
16460
  }
15269
16461
  });
15270
16462
  }
15271
- /** Rebuild the cooldown map from persisted outbox rows (restart-proof). */
16463
+ /**
16464
+ * Rebuild the cooldown map from persisted outbox rows (restart-proof).
16465
+ *
16466
+ * The key comes from `cooldownKey` and NOWHERE else. This method used to
16467
+ * build the string itself, and the copy had drifted: it knew the
16468
+ * `r:<id>` / `r:<id>:d:<device>` shapes and neither the `:c:<class>` tail
16469
+ * (`granularity: 'per-class'`, and unconditional for audio) nor — after
16470
+ * 2026-08-14 — the `:k:<kind>` one. Every window it could not address was a
16471
+ * window that did not survive a restart, so a per-class or audio rule
16472
+ * re-fired immediately after every respawn despite the schema promising
16473
+ * otherwise. `notifications.md` had carried it as a known defect since
16474
+ * 2026-08-02 with the instruction "if you touch `cooldownKey`, fix the seeder
16475
+ * in the same commit"; this is that commit.
16476
+ */
15272
16477
  async seedCooldowns() {
15273
16478
  const entries = await this.outbox.queryRecentPersisted(this.now() - 864e5);
15274
16479
  for (const entry of entries) {
15275
16480
  const rule = this.rules.get(entry.ruleId);
15276
16481
  if (rule === null) continue;
15277
- const key = rule.throttle.scope === "rule" ? `r:${rule.id}` : entry.payload.subject.systemEvent !== void 0 ? `r:${rule.id}:s:${entry.payload.subject.systemEvent.subject}` : `r:${rule.id}:d:${entry.deviceId}`;
16482
+ const key = cooldownKey(rule, cooldownSubjectFromOutbox(entry));
15278
16483
  const prev = this.lastFiredAt.get(key) ?? 0;
15279
16484
  if (entry.createdAt > prev) this.lastFiredAt.set(key, entry.createdAt);
15280
16485
  }
@@ -15380,7 +16585,12 @@ var NotificationCenter = class NotificationCenter {
15380
16585
  */
15381
16586
  async hydrateLiveness() {
15382
16587
  const subjects = await this.liveness.load();
15383
- this.logger.info("liveness ledger reseeded", { meta: { subjects } });
16588
+ const seeded = this.liveness.seedFromDirectory(this.deviceDirectory.all(), this.now());
16589
+ this.logger.info("liveness ledger reseeded", { meta: {
16590
+ subjects,
16591
+ seededFromDirectory: seeded,
16592
+ directory: this.deviceDirectory.size()
16593
+ } });
15384
16594
  }
15385
16595
  /**
15386
16596
  * Say that the ledger refused a liveness event, and why.
@@ -15802,6 +17012,307 @@ var NotificationCenter = class NotificationCenter {
15802
17012
  }
15803
17013
  };
15804
17014
  //#endregion
17015
+ //#region src/notification-center/rule-test-plan.ts
17016
+ /**
17017
+ * Which subject kind THIS rule fires on.
17018
+ *
17019
+ * `delivery` alone cannot answer, because two features ride an existing
17020
+ * delivery and are discriminated by a CONDITION — exactly as the engine
17021
+ * discriminates them (`rule-engine.ts`: an audio window fires only for a rule
17022
+ * declaring `conditions.audio`, an occupancy edge only for one declaring
17023
+ * `conditions.occupancy`). A third case is the LEGACY audio-class rule: an
17024
+ * `immediate` rule whose classes are all `audio-*` and which declares no
17025
+ * `audio` condition fires on a classified episode and on nothing else.
17026
+ */
17027
+ function testTriggerForRule(rule) {
17028
+ if (rule.delivery === "system-event") return "system-event";
17029
+ if (rule.delivery === "track-end") return "track-end";
17030
+ if (rule.delivery === "package-event") return "package-event";
17031
+ if (rule.delivery === "device-event") return rule.conditions.occupancy !== void 0 ? "occupancy-event" : "device-event";
17032
+ if (rule.conditions.audio !== void 0) return "audio-window";
17033
+ const classes = rule.conditions.classes ?? [];
17034
+ if (classes.length > 0 && classes.every((c) => c.trim().toLowerCase().startsWith("audio-"))) return "audio-event";
17035
+ return "object-event";
17036
+ }
17037
+ /** Reserved device id for an event with no camera (mirrors the engine's). */
17038
+ var NC_SYSTEM_DEVICE_ID = 0;
17039
+ /** The grid a schedule is authored on — the same one `window-bounds.ts` scans. */
17040
+ var SCHEDULE_SCAN_STEP_MS = 6e4;
17041
+ /** How far back a schedule scan may look for an active minute (one week + a day). */
17042
+ var SCHEDULE_SCAN_LOOKBACK_MS = 8 * 864e5;
17043
+ /**
17044
+ * Nudge a threshold to a value that PASSES it, and that reads like something a
17045
+ * detector would produce. Clamped to 1 so a rule with a floor of 1 still gets a
17046
+ * legal value.
17047
+ */
17048
+ function justAbove(floor) {
17049
+ return Math.min(1, Math.round((floor + .05) * 100) / 100);
17050
+ }
17051
+ /**
17052
+ * A timestamp the rule's SCHEDULE is active at — `now` when it already is.
17053
+ *
17054
+ * Scans BACKWARDS on the schedule's own minute grid through `isScheduleActive`,
17055
+ * the single authority on what a window means (midnight crossing, `invert`,
17056
+ * IANA timezone). Backwards rather than forwards because a test must fire NOW:
17057
+ * a future instant would be a notification about something that has not
17058
+ * happened, and the outbox would deliver it immediately anyway.
17059
+ *
17060
+ * `null` when the schedule has no active minute in the lookback — a rule that
17061
+ * is currently unfireable, which is a note rather than a silent failure.
17062
+ */
17063
+ function scheduleActiveAt(schedule, now) {
17064
+ if (schedule === void 0) return now;
17065
+ if (require_dist.isScheduleActive(schedule, now)) return now;
17066
+ const floor = now - SCHEDULE_SCAN_LOOKBACK_MS;
17067
+ for (let t = now - SCHEDULE_SCAN_STEP_MS; t >= floor; t -= SCHEDULE_SCAN_STEP_MS) if (require_dist.isScheduleActive(schedule, t)) return t;
17068
+ return null;
17069
+ }
17070
+ /**
17071
+ * The bbox that satisfies a `customZones` polygon: the polygon's own bounding
17072
+ * box, clamped to the frame.
17073
+ *
17074
+ * The engine's test is `bboxPolygonOverlap(bbox, points) > 0`, and a polygon's
17075
+ * bounding box always overlaps it (a polygon with area is contained in its own
17076
+ * box). The admin simulator's centred half-frame box does NOT — a polygon drawn
17077
+ * in a corner fails it, and the operator reads a working rule as broken.
17078
+ */
17079
+ function bboxForPolygon(points) {
17080
+ if (points.length === 0) return void 0;
17081
+ const xs = points.map((p) => p.x);
17082
+ const ys = points.map((p) => p.y);
17083
+ const minX = Math.max(0, Math.min(...xs));
17084
+ const minY = Math.max(0, Math.min(...ys));
17085
+ const maxX = Math.min(1, Math.max(...xs));
17086
+ const maxY = Math.min(1, Math.max(...ys));
17087
+ const w = Math.max(.01, maxX - minX);
17088
+ const h = Math.max(.01, maxY - minY);
17089
+ return {
17090
+ x: minX,
17091
+ y: minY,
17092
+ w: Math.min(1 - minX, w),
17093
+ h: Math.min(1 - minY, h)
17094
+ };
17095
+ }
17096
+ /**
17097
+ * The window that answers an audio condition.
17098
+ *
17099
+ * Every spec field is copied from the CONDITION, because `matchesAudio`
17100
+ * compares them for equality — this is the pairing rule that stops a hub with
17101
+ * two sound rules on one camera answering one rule with the other's evidence.
17102
+ * `labels` are normalized and sorted exactly as `audioSpecFromCondition` does,
17103
+ * or the elementwise comparison fails on ordering alone.
17104
+ */
17105
+ function audioWindowFor(condition) {
17106
+ const labels = condition.labels !== void 0 && condition.labels.length > 0 ? [...new Set(condition.labels.map(normalizeAudioLabel))].sort() : void 0;
17107
+ const samples = Math.max(2, condition.samplingSeconds);
17108
+ const hits = Math.max(1, Math.ceil(samples * condition.hitPercent / 100));
17109
+ return {
17110
+ hitPercent: Math.min(100, Math.round(hits / samples * 100)),
17111
+ samplingSeconds: condition.samplingSeconds,
17112
+ samples,
17113
+ hits,
17114
+ ...condition.dbThreshold !== void 0 ? {
17115
+ dbThreshold: condition.dbThreshold,
17116
+ peakDbfs: Math.min(0, condition.dbThreshold + 6)
17117
+ } : {},
17118
+ ...labels !== void 0 ? { specLabels: labels } : {},
17119
+ labels: labels ?? []
17120
+ };
17121
+ }
17122
+ /**
17123
+ * The edge that satisfies an occupancy condition.
17124
+ *
17125
+ * Each op has exactly ONE edge that passes it (see `matchesOccupancy`): the
17126
+ * count arrives AT the threshold from below, falls to zero from it, or falls TO
17127
+ * the threshold from one above.
17128
+ */
17129
+ function occupancyEdgeFor(condition) {
17130
+ const threshold = Math.max(1, Math.round(condition.count));
17131
+ const shared = {
17132
+ ...condition.zoneId !== void 0 ? { zoneId: condition.zoneId } : {},
17133
+ ...condition.className !== void 0 ? { className: condition.className } : {}
17134
+ };
17135
+ if (condition.op === "became-free") return {
17136
+ ...shared,
17137
+ count: 0,
17138
+ previousCount: threshold,
17139
+ occupied: false,
17140
+ threshold
17141
+ };
17142
+ if (condition.op === "<=") {
17143
+ const count = Math.max(0, Math.round(condition.count));
17144
+ return {
17145
+ ...shared,
17146
+ count,
17147
+ previousCount: count + 1,
17148
+ occupied: false,
17149
+ threshold: count + 1
17150
+ };
17151
+ }
17152
+ return {
17153
+ ...shared,
17154
+ count: threshold,
17155
+ previousCount: Math.max(0, threshold - 1),
17156
+ occupied: true,
17157
+ threshold
17158
+ };
17159
+ }
17160
+ /**
17161
+ * The catalog keys a synthetic system event of this kind carries.
17162
+ *
17163
+ * MIRRORS `incomingFromSystemEvent`'s key derivation, and the mirror is pinned
17164
+ * by a spec that asserts every key exists in the shipped English bundle — a key
17165
+ * nothing resolves renders an EMPTY notification, which is the one failure a
17166
+ * tester must not be able to produce. The BODY VARIANTS the intake chooses from
17167
+ * the payload (`.profiled`, `.agent`, `.onNode`) are deliberately not chosen
17168
+ * here: the plain variant is the one every kind has.
17169
+ */
17170
+ function systemEventKeys(kind) {
17171
+ if (kind === "node-online" || kind === "node-offline") return {
17172
+ titleKey: `system.${kind}.title`,
17173
+ bodyKey: `system.${kind}.body.agent`
17174
+ };
17175
+ if (kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-arm-refused") return {
17176
+ titleKey: `system.${kind}.title.${TEST_ALARM_MODE}`,
17177
+ bodyKey: `system.${kind}.body`
17178
+ };
17179
+ return {
17180
+ titleKey: `system.${kind}.title`,
17181
+ bodyKey: `system.${kind}.body`
17182
+ };
17183
+ }
17184
+ /** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
17185
+ var TEST_ALARM_MODE = "away";
17186
+ /** The dedup subject the live intake stamps for this kind — same families. */
17187
+ function systemEventSubject(kind, scope) {
17188
+ if (kind === "device-disabled" || kind === "device-enabled") return `device-switch:${scope.deviceId ?? 0}`;
17189
+ if (kind === "device-online" || kind === "device-offline") return `device:${scope.deviceId ?? 0}`;
17190
+ if (kind === "stream-online" || kind === "stream-offline") return `stream:${scope.deviceId ?? 0}`;
17191
+ if (kind === "node-online" || kind === "node-offline") return `node:${scope.nodeId ?? "hub"}`;
17192
+ if (kind === "addon-update-available" || kind === "server-update-available") return `package:${scope.packageName ?? "@camstack/server"}`;
17193
+ return `alarm:${scope.deviceId ?? 0}`;
17194
+ }
17195
+ /**
17196
+ * The class the subject carries, chosen so it PASSES the rule's class
17197
+ * conditions rather than merely existing.
17198
+ *
17199
+ * A macro selection (`vehicle`) is written verbatim: the engine expands macro →
17200
+ * leaves, so the macro itself is always in its own expansion. The exclude list
17201
+ * is then honoured — a class that is both wanted and vetoed is a rule that can
17202
+ * never fire, and the plan must not paper over it.
17203
+ */
17204
+ function classNamesFor(conditions) {
17205
+ const excluded = new Set((conditions.classesExclude ?? []).map((c) => c.trim().toLowerCase()));
17206
+ const first = (conditions.classes ?? []).filter((c) => !excluded.has(c.trim().toLowerCase()))[0] ?? conditions.classes?.[0];
17207
+ if (first !== void 0) return [first];
17208
+ return excluded.has("person") ? [] : ["person"];
17209
+ }
17210
+ /** The label that satisfies the rule's identity / plate / label matchers. */
17211
+ function labelFor(conditions) {
17212
+ return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
17213
+ }
17214
+ /**
17215
+ * Plan the injection that fires this rule.
17216
+ *
17217
+ * Every condition the engine reads off the SUBJECT is satisfied here; the two
17218
+ * that read the world are reported in {@link NcRuleTestPlan.notes}.
17219
+ */
17220
+ function planRuleTest(input) {
17221
+ const { rule, now } = input;
17222
+ const c = rule.conditions;
17223
+ const trigger = testTriggerForRule(rule);
17224
+ const notes = [];
17225
+ const systemScope = {
17226
+ ...c.systemEvent?.deviceIds?.[0] !== void 0 ? { deviceId: c.systemEvent.deviceIds[0] } : {},
17227
+ ...c.systemEvent?.nodeIds?.[0] !== void 0 ? { nodeId: c.systemEvent.nodeIds[0] } : {},
17228
+ ...c.systemEvent?.packageNames?.[0] !== void 0 ? { packageName: c.systemEvent.packageNames[0] } : {}
17229
+ };
17230
+ const scopedDeviceId = trigger === "system-event" ? systemScope.deviceId ?? input.fallbackDeviceId ?? NC_SYSTEM_DEVICE_ID : c.devices?.[0] ?? input.fallbackDeviceId;
17231
+ if (scopedDeviceId === void 0) notes.push({
17232
+ conditionId: "devices",
17233
+ note: "This rule names no camera and none was supplied, so the event is attributed to no device and carries no picture. Scope the rule to a camera, or pick one, to see the notification it would really send."
17234
+ });
17235
+ const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
17236
+ const scheduledAt = scheduleActiveAt(rule.schedule, now);
17237
+ if (scheduledAt === null) notes.push({
17238
+ conditionId: "schedule",
17239
+ note: "This rule’s weekly schedule has no active minute in the last eight days, so the test fires outside it and the schedule condition will refuse the event — exactly as it refuses every real one."
17240
+ });
17241
+ else if (scheduledAt !== now) notes.push({
17242
+ conditionId: "schedule",
17243
+ note: "The rule is outside its schedule right now, so the event is stamped with the most recent instant inside a window. The notification therefore reports a past time."
17244
+ });
17245
+ if (c.deviceState !== void 0) notes.push({
17246
+ conditionId: "deviceState",
17247
+ note: `This rule only fires while device #${c.deviceState.deviceId} is in one of its chosen states. That gate reads the REAL device and cannot be simulated — put the device in that state first, or the test fails closed exactly as production would.`
17248
+ });
17249
+ if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
17250
+ conditionId: "customZones",
17251
+ note: "The subject is placed over the first drawn polygon so the condition passes. A rule that needs a specific polygon should be checked in the simulator, where the box can be moved."
17252
+ });
17253
+ const zones = c.zones?.ids ?? [];
17254
+ const label = labelFor(c);
17255
+ const bbox = c.customZones !== void 0 && c.customZones.length > 0 ? bboxForPolygon(c.customZones[0]?.points ?? []) : void 0;
17256
+ const audioWindow = trigger === "audio-window" && c.audio !== void 0 ? audioWindowFor(c.audio) : void 0;
17257
+ const classNames = trigger === "audio-window" ? (audioWindow?.labels ?? []).map((l) => `audio-${l}`) : trigger === "occupancy-event" ? [c.occupancy?.className ?? "occupancy"] : trigger === "device-event" || trigger === "system-event" ? [] : classNamesFor(c);
17258
+ const systemEventKind = c.systemEvent?.kinds[0];
17259
+ if (trigger === "system-event" && systemEventKind === void 0) notes.push({
17260
+ conditionId: "systemEvent",
17261
+ note: "This rule names no system event, so nothing can be injected for it — the hub refuses such a rule at save time and the engine matches nothing."
17262
+ });
17263
+ return {
17264
+ trigger,
17265
+ input: {
17266
+ trigger,
17267
+ deviceId,
17268
+ classNames: [...classNames],
17269
+ ...label !== void 0 ? { label } : {},
17270
+ ...c.minConfidence !== void 0 ? { confidence: justAbove(c.minConfidence) } : {},
17271
+ ...c.minLabelConfidence !== void 0 ? { labelConfidence: justAbove(c.minLabelConfidence) } : {},
17272
+ ...c.minImportance !== void 0 ? { importance: justAbove(c.minImportance) } : {},
17273
+ ...c.minDwellSeconds !== void 0 ? { dwellSeconds: Math.round(c.minDwellSeconds) + 1 } : {},
17274
+ zones: [...zones],
17275
+ ...trigger === "object-event" && c.crossing === "exit" ? { crossing: {
17276
+ zoneId: zones[0] ?? "zone",
17277
+ direction: "exit"
17278
+ } } : {},
17279
+ ...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
17280
+ ...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
17281
+ ...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
17282
+ ...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
17283
+ ...bbox !== void 0 ? { bbox } : {},
17284
+ ...trigger === "occupancy-event" && c.occupancy !== void 0 ? { occupancy: occupancyEdgeFor(c.occupancy) } : {},
17285
+ ...audioWindow !== void 0 ? { audioWindow } : {},
17286
+ ...trigger === "system-event" && systemEventKind !== void 0 ? { systemEvent: {
17287
+ kind: systemEventKind,
17288
+ subject: systemEventSubject(systemEventKind, systemScope),
17289
+ ...systemEventKeys(systemEventKind),
17290
+ textParams: {
17291
+ deviceId: String(systemScope.deviceId ?? deviceId),
17292
+ deviceName: `camera ${String(systemScope.deviceId ?? deviceId)}`,
17293
+ nodeId: systemScope.nodeId ?? "hub",
17294
+ packageName: systemScope.packageName ?? "@camstack/server",
17295
+ currentVersion: "0.0.0",
17296
+ latestVersion: "0.0.0",
17297
+ profile: "low",
17298
+ seconds: "30"
17299
+ },
17300
+ ...systemScope.deviceId !== void 0 ? { deviceId: systemScope.deviceId } : {},
17301
+ ...c.systemEvent?.deviceTypes?.[0] !== void 0 ? { deviceType: c.systemEvent.deviceTypes[0] } : {},
17302
+ ...systemScope.nodeId !== void 0 ? { nodeId: systemScope.nodeId } : {},
17303
+ ...systemScope.packageName !== void 0 ? { packageName: systemScope.packageName } : {}
17304
+ } } : {},
17305
+ timestamp: scheduledAt ?? now,
17306
+ onlyRuleId: rule.id,
17307
+ bypassCooldown: true,
17308
+ bypassMute: true,
17309
+ runActions: false,
17310
+ still: "live"
17311
+ },
17312
+ notes
17313
+ };
17314
+ }
17315
+ //#endregion
15805
17316
  //#region src/notification-center/nc-actions.ts
15806
17317
  /**
15807
17318
  * nc-actions — the `pipeline-analytics` rule-bridge action catalog.
@@ -15901,6 +17412,58 @@ var ncActions = require_dist.defineCustomActions({
15901
17412
  auth: "admin",
15902
17413
  caller: "required"
15903
17414
  }),
17415
+ /**
17416
+ * FIRE THIS RULE NOW — the per-rule Test button.
17417
+ *
17418
+ * `nc.injectTestEvent` asks the operator to describe an event; this asks the
17419
+ * RULE. The server plans the synthetic subject from the rule's own conditions
17420
+ * (`rule-test-plan.ts`) and hands it to exactly the same injection, so the
17421
+ * notification travels the whole real path: `evaluateRule` → durable outbox →
17422
+ * the ordinary drain → the ordinary media ladders → the notifier. It arrives
17423
+ * on the phone, which is the entire point.
17424
+ *
17425
+ * It BYPASSES the cooldown and the per-camera mute, deliberately: the
17426
+ * operator pressed a button and must receive the notification, not a report
17427
+ * that a window from ten minutes ago swallowed it. Neither window is
17428
+ * CONSUMED, so the next real fire is unaffected. It never runs the rule's
17429
+ * action sequences — those unlock doors, and "does this notify" is not a
17430
+ * request to open a gate.
17431
+ *
17432
+ * `auth: 'admin'` for the same reason as the injection it wraps: it delivers
17433
+ * to every target the rule addresses, on other people's phones.
17434
+ */
17435
+ "nc.testRule": require_dist.customAction(require_dist.object({
17436
+ ruleId: require_dist.string(),
17437
+ /** The camera to attribute the event to when the rule scopes none. */
17438
+ deviceId: require_dist.number().int().optional()
17439
+ }), NcTestEventResultSchema.extend({
17440
+ /** The subject kind the rule was tested with — the answer to "why was my
17441
+ * rule not a candidate". */
17442
+ trigger: require_dist.string(),
17443
+ /** What the plan could not reproduce (a device-state gate, a polygon). */
17444
+ planNotes: require_dist.array(require_dist.object({
17445
+ conditionId: require_dist.string(),
17446
+ note: require_dist.string()
17447
+ }))
17448
+ }), {
17449
+ kind: "mutation",
17450
+ auth: "admin",
17451
+ caller: "required"
17452
+ }),
17453
+ /**
17454
+ * What this rule is doing RIGHT NOW — a discriminated union per rule kind.
17455
+ *
17456
+ * Answers the question a rule LIST structurally cannot: an occupancy rule's
17457
+ * subject is a live count, and a timelapse rule's output arrives hours after
17458
+ * the window it is about. Everything returned is DERIVED from the watcher's
17459
+ * mirror, the live snapshot and the stores — nothing is a second copy of
17460
+ * state that could disagree with them.
17461
+ *
17462
+ * `caller: 'required'` and not admin-only: a personal rule's owner may read
17463
+ * the status of their own rule, and the handler refuses a rule they cannot
17464
+ * see rather than returning a redacted one.
17465
+ */
17466
+ "nc.getRuleStatus": require_dist.customAction(require_dist.object({ ruleId: require_dist.string() }), NcRuleStatusSchema, { caller: "required" }),
15904
17467
  "nc.listTimelapseRules": require_dist.customAction(require_dist.object({}), require_dist.object({ rules: require_dist.array(NcViewerTimelapseRuleSchema) }), { caller: "required" }),
15905
17468
  /**
15906
17469
  * ONE action for create AND update, unlike the detection-rule pair.
@@ -16234,6 +17797,54 @@ function makeNcActionHandlers(deps) {
16234
17797
  });
16235
17798
  return inject(parsed);
16236
17799
  },
17800
+ "nc.testRule": async (input, caller) => {
17801
+ const c = requireCaller(caller);
17802
+ if (!c.isAdmin) throw new Error("forbidden: admin required to test a rule");
17803
+ const inject = deps.injectTestEvent;
17804
+ if (inject === void 0) throw new Error("this node does not host the notification centre — test on the hub");
17805
+ const rule = assertRuleVisible(input.ruleId, c);
17806
+ const plan = planRuleTest({
17807
+ rule,
17808
+ now: Date.now(),
17809
+ ...input.deviceId !== void 0 ? { fallbackDeviceId: input.deviceId } : {}
17810
+ });
17811
+ deps.logger.info("rule test requested", {
17812
+ tags: { deviceId: plan.input.deviceId },
17813
+ meta: {
17814
+ by: c.userId,
17815
+ ruleId: rule.id,
17816
+ ruleName: rule.name,
17817
+ trigger: plan.trigger,
17818
+ bypassCooldown: true,
17819
+ bypassMute: true,
17820
+ planNotes: plan.notes.map((n) => n.conditionId)
17821
+ }
17822
+ });
17823
+ return {
17824
+ ...await inject(plan.input),
17825
+ trigger: plan.trigger,
17826
+ planNotes: plan.notes.map((n) => ({
17827
+ conditionId: n.conditionId,
17828
+ note: n.note
17829
+ }))
17830
+ };
17831
+ },
17832
+ "nc.getRuleStatus": async (input, caller) => {
17833
+ const c = requireCaller(caller);
17834
+ const status = deps.ruleStatus;
17835
+ if (status === void 0) throw new Error("this node does not host the notification centre — ask the hub");
17836
+ const detection = deps.ruleStore.get(input.ruleId);
17837
+ if (detection !== null) {
17838
+ assertRuleVisible(input.ruleId, c);
17839
+ return status.forRule(detection);
17840
+ }
17841
+ const timelapse = deps.timelapse?.store.get(input.ruleId) ?? null;
17842
+ if (timelapse !== null) {
17843
+ if (!c.isAdmin && timelapse.ownerUserId !== c.userId) throw new Error(`forbidden: timelapse rule not owned: ${input.ruleId}`);
17844
+ return status.forTimelapseRule(timelapse);
17845
+ }
17846
+ throw new Error(`rule not found: ${input.ruleId}`);
17847
+ },
16237
17848
  "nc.listTimelapseRules": async (_input, caller) => {
16238
17849
  const c = requireCaller(caller);
16239
17850
  const tl = requireTimelapse();
@@ -18264,9 +19875,14 @@ async function recomputeTrackImportance(deps, trackId) {
18264
19875
  */
18265
19876
  async function applyTrackLabelCandidate(deps, deviceId, trackId, candidate) {
18266
19877
  let trackWritten = false;
19878
+ /** The patch the TRACK accepted — the ONE decision the events copy. */
19879
+ let accepted;
18267
19880
  try {
18268
19881
  const decision = await deps.trackStore?.applyLabelCandidate(trackId, candidate);
18269
- if (decision?.kind === "write") trackWritten = true;
19882
+ if (decision?.kind === "write") {
19883
+ trackWritten = true;
19884
+ accepted = decision.patch;
19885
+ }
18270
19886
  if (decision?.kind === "refused") {
18271
19887
  const message = decision.reason.kind === "macro-class" ? "label tier collapse refused" : "label write refused";
18272
19888
  deps.logger.info(message, {
@@ -18288,7 +19904,7 @@ async function applyTrackLabelCandidate(deps, deviceId, trackId, candidate) {
18288
19904
  }
18289
19905
  let eventsUpdated = 0;
18290
19906
  try {
18291
- eventsUpdated = await deps.eventStore?.applyLabelCandidateForTrack(trackId, candidate) ?? 0;
19907
+ eventsUpdated = accepted !== void 0 ? await deps.eventStore?.projectTrackLabel(trackId, accepted) ?? 0 : 0;
18292
19908
  } catch (err) {
18293
19909
  deps.logger.warn("event label back-patch failed", {
18294
19910
  tags: { deviceId },
@@ -18661,7 +20277,8 @@ var FaceGalleryProvider = class {
18661
20277
  score: 1,
18662
20278
  stepId: OPERATOR_FACE_STEP_ID,
18663
20279
  decidedAt: Date.now(),
18664
- operatorAuthority: true
20280
+ operatorAuthority: true,
20281
+ identityId
18665
20282
  });
18666
20283
  this.refreshGallery();
18667
20284
  this.safeEmitFaceGalleryChanged({
@@ -20186,6 +21803,12 @@ var FrameProcessor = class {
20186
21803
  /** Optional stationary-object gate (parked-object suppression). Null until
20187
21804
  * the addon wires it via {@link setStationaryGate}. */
20188
21805
  stationaryGate = null;
21806
+ /**
21807
+ * The label the TRACK currently carries, read (never mirrored) from the
21808
+ * active-track cache. See {@link setTrackLabelState} — and the use site in
21809
+ * `toObjectEvent`, which is the reason it exists.
21810
+ */
21811
+ trackLabelState = null;
20189
21812
  constructor(deviceId, trackerConfig = {}, stateConfig = {}, emitterConfig = {}, source = "pipeline") {
20190
21813
  this.deviceId = deviceId;
20191
21814
  this.source = source;
@@ -20202,6 +21825,19 @@ var FrameProcessor = class {
20202
21825
  this.zones = zones;
20203
21826
  }
20204
21827
  /**
21828
+ * Wire the reader that answers "what does this track already say it is".
21829
+ *
21830
+ * A READ, not a mirror the late-label path has to remember to feed: the
21831
+ * active-track cache is in-RAM and already the authority every other surface
21832
+ * resolves against, and a mirror that can be forgotten is how a stamped row
21833
+ * and an unstamped one end up in the same track. Absent (tests, and any
21834
+ * caller that has not wired it) ⇒ the emitter behaves exactly as it did
21835
+ * before, stamping only what the current frame decided.
21836
+ */
21837
+ setTrackLabelState(read) {
21838
+ this.trackLabelState = read;
21839
+ }
21840
+ /**
20205
21841
  * How much of a detection's box must lie inside a zone for the zone to be
20206
21842
  * stamped onto it (0–1 fraction of the box's own area).
20207
21843
  *
@@ -20530,7 +22166,7 @@ var FrameProcessor = class {
20530
22166
  const state = forcedState ?? mapObjectStateToTrackState(objectStates.find((o) => o.trackId === e.detection.trackId)?.state);
20531
22167
  const zones = zonesByTrack.get(e.detection.trackId) ?? [];
20532
22168
  const crossing = crossingOf(e);
20533
- const labelPatch = labelPatchByTrack.get(e.detection.trackId);
22169
+ const labelPatch = labelPatchByTrack.get(e.detection.trackId) ?? knownTrackLabelPatch(this.trackLabelState?.(e.detection.trackId));
20534
22170
  return {
20535
22171
  id: (0, node_crypto.randomUUID)(),
20536
22172
  deviceId: this.deviceId,
@@ -22201,7 +23837,8 @@ var PlateGalleryProvider = class {
22201
23837
  score: 1,
22202
23838
  stepId: OPERATOR_PLATE_STEP_ID,
22203
23839
  decidedAt: Date.now(),
22204
- operatorAuthority: true
23840
+ operatorAuthority: true,
23841
+ identityId: vehicle.id
22205
23842
  });
22206
23843
  this.refreshGallery();
22207
23844
  this.safeEmitPlateGalleryChanged({
@@ -25576,10 +27213,13 @@ var FaceRecognizer = class {
25576
27213
  tier: 2,
25577
27214
  score: work.matchScore ?? 0,
25578
27215
  stepId: FACE_RECOGNIZER_STEP_ID,
25579
- decidedAt: Date.now()
27216
+ decidedAt: Date.now(),
27217
+ identityId: work.assignedIdentityId
25580
27218
  };
27219
+ let accepted;
25581
27220
  try {
25582
- await this.deps.trackStore.applyLabelCandidate(work.trackId, candidate);
27221
+ const decision = await this.deps.trackStore.applyLabelCandidate(work.trackId, candidate);
27222
+ if (decision?.kind === "write") accepted = decision.patch;
25583
27223
  } catch (err) {
25584
27224
  this.deps.logger.warn("track identity label failed", {
25585
27225
  tags: { deviceId: input.deviceId },
@@ -25589,8 +27229,8 @@ var FaceRecognizer = class {
25589
27229
  }
25590
27230
  });
25591
27231
  }
25592
- try {
25593
- await this.deps.eventStore.applyLabelCandidateForTrack(work.trackId, candidate);
27232
+ if (accepted !== void 0) try {
27233
+ await this.deps.eventStore.projectTrackLabel(work.trackId, accepted);
25594
27234
  } catch (err) {
25595
27235
  this.deps.logger.warn("event identity label failed", {
25596
27236
  tags: { deviceId: input.deviceId },
@@ -30791,8 +32431,25 @@ var PlateRecognizer = class {
30791
32431
  * for an implausible read (junk OCR off a distant/oblique plate) — the
30792
32432
  * caller must NOT stamp a label then. */
30793
32433
  resolveLabel(text, score) {
32434
+ return this.resolveLabelWithVehicle(text, score)?.text ?? null;
32435
+ }
32436
+ /**
32437
+ * {@link resolveLabel} plus the GALLERY ROW it named, when a vehicle claimed
32438
+ * the read.
32439
+ *
32440
+ * The id is what a notification rule matches on — a display name is
32441
+ * renameable and a rule authored on the old spelling goes silently dark
32442
+ * (`LabelAttribution.identityId`). An unclaimed but plausible read still
32443
+ * returns its normalized text with NO `vehicleId`: it is a plate, it is just
32444
+ * nobody's plate.
32445
+ */
32446
+ resolveLabelWithVehicle(text, score) {
30794
32447
  if (!isPlausiblePlateRead(text, score)) return null;
30795
- return this.matchVehicle(text, score)?.name ?? normalizePlate(text);
32448
+ const matched = this.matchVehicle(text, score);
32449
+ return matched !== null ? {
32450
+ text: matched.name,
32451
+ vehicleId: matched.vehicleId
32452
+ } : { text: normalizePlate(text) };
30796
32453
  }
30797
32454
  async processFrame(input) {
30798
32455
  const minConfidence = input.minConfidence ?? 0;
@@ -32351,48 +34008,49 @@ var EventStore = class {
32351
34008
  }
32352
34009
  }
32353
34010
  /**
32354
- * Forward-only: offer a label CANDIDATE to every already-emitted object event
32355
- * of a track (roadmap 4g). Returns the number of events actually updated —
32356
- * which is NOT the number of rows, because the write rule is applied PER ROW
32357
- * against that row's own tiers.
34011
+ * Project the TRACK's label decision onto every object event of that track.
34012
+ * Returns how many rows took it.
32358
34013
  *
32359
- * Per row, not once for the track, and that is the point: the events of one
32360
- * track do not all carry the same label. An early row may hold a tier-1
32361
- * sub-class the frame path stamped and a later row a tier-2 identity; a
32362
- * single decision taken on the track and blanket-applied would either
32363
- * overwrite the stronger value or skip the rows that had nothing.
34014
+ * ONE DECISION, EVERY ROW — and that is the correction, not the design it
34015
+ * replaced. This used to re-run `resolveTieredLabelWrite` per row against
34016
+ * that row's own tiers, on the theory that "the events of one track do not
34017
+ * all carry the same label". They do: a track has ONE subject, and every
34018
+ * read surface (the timeline, the event page, a notification body, a rule's
34019
+ * `identities` condition) shows the track's answer. Re-deciding per row meant
34020
+ * a row whose incumbent happened to outscore the track's winner kept a
34021
+ * different word, and the rows drifted apart from the track and from each
34022
+ * other — measured 2026-08-14 as 407 of 437 tracks whose event rows
34023
+ * disagreed with the track they belong to. A notification quoting one row and
34024
+ * a timeline quoting another then told the operator two different names for
34025
+ * the same person.
34026
+ *
34027
+ * The caller passes the patch the TRACK row accepted, so the two can never
34028
+ * disagree by construction. A refused track write projects nothing: nothing
34029
+ * changed, so there is nothing to copy.
32364
34030
  *
32365
34031
  * Used when face recognition, plate matching or a classifier answers after
32366
34032
  * the events were emitted.
32367
34033
  */
32368
- async applyLabelCandidateForTrack(trackId, candidate) {
34034
+ async projectTrackLabel(trackId, patch) {
32369
34035
  const rows = await this.store.query.query({
32370
34036
  collection: OBJECT_EVENTS_COLLECTION,
32371
34037
  filter: { where: { trackId } }
32372
34038
  });
34039
+ const data = tieredLabelColumnData(patch);
32373
34040
  let updated = 0;
32374
- for (const row of rows) {
32375
- const className = row.data["className"];
32376
- if (typeof className !== "string") continue;
32377
- const decision = resolveTieredLabelWrite({
32378
- className,
32379
- ...readTieredLabelColumns(row.data)
32380
- }, candidate);
32381
- if (decision.kind !== "write") continue;
32382
- try {
32383
- await this.store.update.mutate({
32384
- collection: OBJECT_EVENTS_COLLECTION,
32385
- id: row.id,
32386
- data: tieredLabelColumnData(decision.patch)
32387
- });
32388
- updated++;
32389
- } catch (err) {
32390
- this.logger.warn("applyLabelCandidateForTrack update failed", { meta: {
32391
- trackId,
32392
- eventId: row.id,
32393
- error: String(err)
32394
- } });
32395
- }
34041
+ for (const row of rows) try {
34042
+ await this.store.update.mutate({
34043
+ collection: OBJECT_EVENTS_COLLECTION,
34044
+ id: row.id,
34045
+ data
34046
+ });
34047
+ updated++;
34048
+ } catch (err) {
34049
+ this.logger.warn("projectTrackLabel update failed", { meta: {
34050
+ trackId,
34051
+ eventId: row.id,
34052
+ error: String(err)
34053
+ } });
32396
34054
  }
32397
34055
  return updated;
32398
34056
  }
@@ -36807,138 +38465,6 @@ var BindingCache = class {
36807
38465
  }
36808
38466
  };
36809
38467
  //#endregion
36810
- //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
36811
- /**
36812
- * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
36813
- * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
36814
- * coverage authority ("Away armed, 12 cameras" is false if three of them are
36815
- * offline or no longer wrapped), read by the notification centre's device
36816
- * directory (`listDeviceIdentities`).
36817
- *
36818
- * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
36819
- * rule-reload tick. Answering "who still has detection?" with a fleet-wide
36820
- * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
36821
- * default wrappers skipped` log lines on the live hub (524 devices), forever —
36822
- * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
36823
- * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
36824
- * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
36825
- * toggle emits that event carrying the device's id and `capName`
36826
- * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
36827
- * keeps the mirror current without ever touching the fleet again.
36828
- *
36829
- * ── Destructive-direction discipline (D49) ──────────────────────────────────
36830
- *
36831
- * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
36832
- * the destructive answer, and the one an operator reads as "my alarm is not
36833
- * watching that door". A transient `getBindings` blip must never flip a
36834
- * known-covered camera to uncovered:
36835
- * • a failed refresh KEEPS the previous value (a read that throws changes
36836
- * nothing), and
36837
- * • a single read that would flip a known `true → false` is confirmed by a
36838
- * concordant SECOND read before it lands — an empty binding list is not
36839
- * proof the wrapper is gone.
36840
- * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
36841
- * for every id — and unknown never reports a camera as excluded from coverage.
36842
- */
36843
- var DetectionCoverageCache = class {
36844
- api;
36845
- logger;
36846
- /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
36847
- state = /* @__PURE__ */ new Map();
36848
- warm = false;
36849
- seeding = null;
36850
- constructor(deps) {
36851
- this.api = deps.api;
36852
- this.logger = deps.logger;
36853
- }
36854
- /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
36855
- isWarm() {
36856
- return this.warm;
36857
- }
36858
- /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
36859
- has(deviceId) {
36860
- return this.state.has(deviceId);
36861
- }
36862
- /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
36863
- get(deviceId) {
36864
- return this.state.get(deviceId);
36865
- }
36866
- /**
36867
- * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
36868
- * warm cache is a no-op, and concurrent callers coalesce onto one read. A
36869
- * failed seed leaves the mirror COLD so the next tick retries — during that
36870
- * window every id answers unknown, which never reports a camera as excluded.
36871
- */
36872
- async ensureWarm() {
36873
- if (this.warm) return;
36874
- if (this.seeding) return this.seeding;
36875
- this.seeding = this.seed();
36876
- try {
36877
- await this.seeding;
36878
- } finally {
36879
- this.seeding = null;
36880
- }
36881
- }
36882
- async seed() {
36883
- try {
36884
- const rows = await this.api.deviceManager.getAllBindings.query({});
36885
- for (const row of rows) {
36886
- if (this.state.has(row.deviceId)) continue;
36887
- this.state.set(row.deviceId, hasWrappedDetection(row.entries));
36888
- }
36889
- this.warm = true;
36890
- this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
36891
- } catch (err) {
36892
- this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
36893
- }
36894
- }
36895
- /**
36896
- * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
36897
- * (never the fleet). Fire-and-forget from the synchronous event handler.
36898
- */
36899
- onBindingsChanged(event) {
36900
- if (event.capName !== "detection-pipeline") return Promise.resolve();
36901
- return this.refreshDevice(event.deviceId);
36902
- }
36903
- /** Drop a device the fleet no longer has (`device.unregistered`). */
36904
- forget(deviceId) {
36905
- this.state.delete(deviceId);
36906
- }
36907
- /** Return to cold — used on addon shutdown. */
36908
- clearAll() {
36909
- this.state.clear();
36910
- this.warm = false;
36911
- this.seeding = null;
36912
- }
36913
- async refreshDevice(deviceId) {
36914
- try {
36915
- const active = await this.readWrappedDetection(deviceId);
36916
- const prev = this.state.get(deviceId);
36917
- if (!active && prev === true) {
36918
- if (await this.readWrappedDetection(deviceId)) {
36919
- this.state.set(deviceId, true);
36920
- return;
36921
- }
36922
- }
36923
- this.state.set(deviceId, active);
36924
- } catch (err) {
36925
- this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
36926
- tags: { deviceId },
36927
- meta: { error: String(err) }
36928
- });
36929
- }
36930
- }
36931
- async readWrappedDetection(deviceId) {
36932
- return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
36933
- }
36934
- };
36935
- /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
36936
- * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
36937
- * binding is how a deactivated wrapper is represented). */
36938
- function hasWrappedDetection(entries) {
36939
- return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
36940
- }
36941
- //#endregion
36942
38468
  //#region src/pipeline-analytics/runtime/capture-scheduler.ts
36943
38469
  /**
36944
38470
  * Kind-derived default priorities (0 = highest). Explicit `priority` wins.
@@ -37211,6 +38737,138 @@ var CaptureScheduler = class {
37211
38737
  }
37212
38738
  };
37213
38739
  //#endregion
38740
+ //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
38741
+ /**
38742
+ * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
38743
+ * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
38744
+ * coverage authority ("Away armed, 12 cameras" is false if three of them are
38745
+ * offline or no longer wrapped), read by the notification centre's device
38746
+ * directory (`listDeviceIdentities`).
38747
+ *
38748
+ * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
38749
+ * rule-reload tick. Answering "who still has detection?" with a fleet-wide
38750
+ * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
38751
+ * default wrappers skipped` log lines on the live hub (524 devices), forever —
38752
+ * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
38753
+ * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
38754
+ * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
38755
+ * toggle emits that event carrying the device's id and `capName`
38756
+ * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
38757
+ * keeps the mirror current without ever touching the fleet again.
38758
+ *
38759
+ * ── Destructive-direction discipline (D49) ──────────────────────────────────
38760
+ *
38761
+ * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
38762
+ * the destructive answer, and the one an operator reads as "my alarm is not
38763
+ * watching that door". A transient `getBindings` blip must never flip a
38764
+ * known-covered camera to uncovered:
38765
+ * • a failed refresh KEEPS the previous value (a read that throws changes
38766
+ * nothing), and
38767
+ * • a single read that would flip a known `true → false` is confirmed by a
38768
+ * concordant SECOND read before it lands — an empty binding list is not
38769
+ * proof the wrapper is gone.
38770
+ * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
38771
+ * for every id — and unknown never reports a camera as excluded from coverage.
38772
+ */
38773
+ var DetectionCoverageCache = class {
38774
+ api;
38775
+ logger;
38776
+ /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
38777
+ state = /* @__PURE__ */ new Map();
38778
+ warm = false;
38779
+ seeding = null;
38780
+ constructor(deps) {
38781
+ this.api = deps.api;
38782
+ this.logger = deps.logger;
38783
+ }
38784
+ /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
38785
+ isWarm() {
38786
+ return this.warm;
38787
+ }
38788
+ /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
38789
+ has(deviceId) {
38790
+ return this.state.has(deviceId);
38791
+ }
38792
+ /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
38793
+ get(deviceId) {
38794
+ return this.state.get(deviceId);
38795
+ }
38796
+ /**
38797
+ * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
38798
+ * warm cache is a no-op, and concurrent callers coalesce onto one read. A
38799
+ * failed seed leaves the mirror COLD so the next tick retries — during that
38800
+ * window every id answers unknown, which never reports a camera as excluded.
38801
+ */
38802
+ async ensureWarm() {
38803
+ if (this.warm) return;
38804
+ if (this.seeding) return this.seeding;
38805
+ this.seeding = this.seed();
38806
+ try {
38807
+ await this.seeding;
38808
+ } finally {
38809
+ this.seeding = null;
38810
+ }
38811
+ }
38812
+ async seed() {
38813
+ try {
38814
+ const rows = await this.api.deviceManager.getAllBindings.query({});
38815
+ for (const row of rows) {
38816
+ if (this.state.has(row.deviceId)) continue;
38817
+ this.state.set(row.deviceId, hasWrappedDetection(row.entries));
38818
+ }
38819
+ this.warm = true;
38820
+ this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
38821
+ } catch (err) {
38822
+ this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
38823
+ }
38824
+ }
38825
+ /**
38826
+ * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
38827
+ * (never the fleet). Fire-and-forget from the synchronous event handler.
38828
+ */
38829
+ onBindingsChanged(event) {
38830
+ if (event.capName !== "detection-pipeline") return Promise.resolve();
38831
+ return this.refreshDevice(event.deviceId);
38832
+ }
38833
+ /** Drop a device the fleet no longer has (`device.unregistered`). */
38834
+ forget(deviceId) {
38835
+ this.state.delete(deviceId);
38836
+ }
38837
+ /** Return to cold — used on addon shutdown. */
38838
+ clearAll() {
38839
+ this.state.clear();
38840
+ this.warm = false;
38841
+ this.seeding = null;
38842
+ }
38843
+ async refreshDevice(deviceId) {
38844
+ try {
38845
+ const active = await this.readWrappedDetection(deviceId);
38846
+ const prev = this.state.get(deviceId);
38847
+ if (!active && prev === true) {
38848
+ if (await this.readWrappedDetection(deviceId)) {
38849
+ this.state.set(deviceId, true);
38850
+ return;
38851
+ }
38852
+ }
38853
+ this.state.set(deviceId, active);
38854
+ } catch (err) {
38855
+ this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
38856
+ tags: { deviceId },
38857
+ meta: { error: String(err) }
38858
+ });
38859
+ }
38860
+ }
38861
+ async readWrappedDetection(deviceId) {
38862
+ return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
38863
+ }
38864
+ };
38865
+ /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
38866
+ * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
38867
+ * binding is how a deactivated wrapper is represented). */
38868
+ function hasWrappedDetection(entries) {
38869
+ return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
38870
+ }
38871
+ //#endregion
37214
38872
  //#region src/pipeline-analytics/pipeline/capture-crop.ts
37215
38873
  function createCaptureCrop(deps) {
37216
38874
  return async (frameHandle, bbox, frameWidth, frameHeight, padding, maxWidth, deviceId) => {
@@ -42083,6 +43741,17 @@ var REBUILD_FALLBACK_NODE_ID = "hub";
42083
43741
  * count. Two thousand is far above any real night and still a bounded read.
42084
43742
  */
42085
43743
  var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
43744
+ /**
43745
+ * How many object-event rows ONE timelapse window may read.
43746
+ *
43747
+ * Higher than the track limit because these are per-FRAME rows, not per-subject:
43748
+ * one track routinely writes a handful (`entered`, some `moving`, `left`), and
43749
+ * the merge in `detection-intervals.ts` folds them back to one subject. Ten
43750
+ * thousand covers every night this deployment has produced and still bounds the
43751
+ * read; the store returns the NEWEST rows, so an overflow loses the start of the
43752
+ * window rather than a random slice of it.
43753
+ */
43754
+ var TIMELAPSE_EVENT_QUERY_LIMIT = 1e4;
42086
43755
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
42087
43756
  /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
42088
43757
  * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
@@ -43091,7 +44760,24 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43091
44760
  runNow: (input) => center.runSummaryNow(input)
43092
44761
  },
43093
44762
  texts: center.textCatalogEditor,
43094
- ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {}
44763
+ ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
44764
+ ruleStatus: {
44765
+ forRule: (rule) => center.ruleStatusFor(rule),
44766
+ forTimelapseRule: (rule) => center.timelapseStatusFor(rule, async ({ ruleId, deviceId }) => {
44767
+ const index = this.ncArtifactIndex;
44768
+ if (index === null) return null;
44769
+ return (await index.list({
44770
+ kinds: ["timelapse"],
44771
+ ruleId,
44772
+ deviceIds: [deviceId],
44773
+ limit: 1,
44774
+ offset: 0
44775
+ }, {
44776
+ userId: "",
44777
+ isAdmin: true
44778
+ })).artifacts[0] ?? null;
44779
+ })
44780
+ }
43095
44781
  });
43096
44782
  }
43097
44783
  /** Declare typed collections up-front so the first insert doesn't race with
@@ -43564,10 +45250,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43564
45250
  const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
43565
45251
  return buf ? buf.toString("base64") : null;
43566
45252
  },
43567
- hasTrackLabel: (trackId) => {
43568
- const label = this.trackStore?.getActiveByTrack(trackId)?.label;
43569
- return label !== void 0 && label.length > 0;
43570
- }
45253
+ hasTrackLabel: (trackId) => hasResolvedLabel(this.trackStore?.getActiveByTrack(trackId))
43571
45254
  });
43572
45255
  }
43573
45256
  /** Binding cache + the zone-analytics / audio-metrics cap providers. */
@@ -43659,6 +45342,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43659
45342
  addonId: device.addonId
43660
45343
  };
43661
45344
  },
45345
+ identityIdsByName: async () => {
45346
+ const byName = /* @__PURE__ */ new Map();
45347
+ const identities = await stores.identityStore.listIdentities();
45348
+ for (const identity of identities) byName.set(identity.name.trim().toLowerCase(), identity.id);
45349
+ const vehicles = await stores.vehicleStore.listVehicles();
45350
+ for (const vehicle of vehicles) byName.set(vehicle.name.trim().toLowerCase(), vehicle.id);
45351
+ return byName;
45352
+ },
43662
45353
  listDeviceIdentities: async () => {
43663
45354
  const all = await api.deviceManager.listAll.query({ projection: "slim" });
43664
45355
  const coverage = this.detectionCoverage;
@@ -43921,11 +45612,40 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43921
45612
  projection: "slim",
43922
45613
  limit: TIMELAPSE_TRACK_QUERY_LIMIT
43923
45614
  })).map((t) => ({
45615
+ trackId: t.trackId,
43924
45616
  firstSeen: t.firstSeen,
43925
45617
  lastSeen: t.lastSeen,
43926
45618
  className: t.className
43927
45619
  }));
43928
45620
  },
45621
+ /**
45622
+ * The other half of "did anything happen here": the object-event rows.
45623
+ *
45624
+ * The persisted track store is NOT a complete record of the window. Three
45625
+ * gates destroy a track before it is written — the confirmation gate
45626
+ * retracting a suspected false birth, stationary promotion dropping a
45627
+ * parked object, zero-media suppression cascading a thumbnail-less track
45628
+ * away — and on 2026-08-13 they left camera 615 with zero persisted tracks
45629
+ * across a ten-hour night that held a person pass at 23:31. These rows
45630
+ * survive all three.
45631
+ *
45632
+ * FULL projection, not slim: `slimObject` drops `trackId`, and without it
45633
+ * every event of one subject becomes its own detection. It costs nothing
45634
+ * at the store — the row is read whole either way and slim only strips
45635
+ * fields afterwards.
45636
+ */
45637
+ listDetectionEvents: async ({ deviceId, since, until }) => {
45638
+ return (await stores.eventStore.queryObject({
45639
+ deviceId,
45640
+ since,
45641
+ until,
45642
+ limit: TIMELAPSE_EVENT_QUERY_LIMIT
45643
+ })).map((e) => ({
45644
+ timestamp: e.timestamp,
45645
+ className: e.className,
45646
+ ...e.trackId !== void 0 ? { trackId: e.trackId } : {}
45647
+ }));
45648
+ },
43929
45649
  createExport: (input) => api.recordingExport.createExport.mutate({
43930
45650
  deviceId: input.deviceId,
43931
45651
  profile: input.profile,
@@ -43950,6 +45670,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43950
45670
  }
43951
45671
  }),
43952
45672
  getExport: ({ exportId }) => api.recordingExport.getExport.query({ exportId }),
45673
+ listExports: ({ deviceId }) => api.recordingExport.listExports.query({ deviceId }),
43953
45674
  publishArtifact: buildTimelapseArtifactPublisher({
43954
45675
  logger: this.ctx.logger.child("Timelapse"),
43955
45676
  readExportBytes: ({ exportId }) => api.recordingExport.readExportBytes.query({ exportId }),
@@ -45591,8 +47312,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
45591
47312
  });
45592
47313
  }
45593
47314
  }
45594
- const label = d.className === "plate" ? this.plateRecognizer?.resolveLabel(d.label, d.score) ?? null : d.label;
45595
- if (label !== null && label !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, label, d, frame.timestamp);
47315
+ const resolved = d.className === "plate" ? this.plateRecognizer?.resolveLabelWithVehicle(d.label, d.score) ?? null : { text: d.label };
47316
+ if (resolved !== null && resolved.text !== void 0) await this.applyTrackEnrichmentLabel(deviceId, trackId, resolved.text, d, frame.timestamp, resolved.vehicleId);
45596
47317
  }
45597
47318
  } catch (err) {
45598
47319
  this.ctx.logger.warn("detail result route failed", {
@@ -45680,7 +47401,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
45680
47401
  * a runner older than the declaration, because the alternative is guessing
45681
47402
  * and putting a species in the coarse slot.
45682
47403
  */
45683
- async applyTrackEnrichmentLabel(deviceId, trackId, label, detail, decidedAt) {
47404
+ async applyTrackEnrichmentLabel(deviceId, trackId, label, detail, decidedAt, identityId) {
45684
47405
  await applyTrackLabelCandidate({
45685
47406
  ...this.trackStore ? { trackStore: this.trackStore } : {},
45686
47407
  ...this.eventStore ? { eventStore: this.eventStore } : {},
@@ -45691,7 +47412,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
45691
47412
  score: detail.score,
45692
47413
  stepId: detail.stepId,
45693
47414
  ...detail.labelModelId !== void 0 ? { modelId: detail.labelModelId } : {},
45694
- decidedAt
47415
+ decidedAt,
47416
+ ...identityId !== void 0 ? { identityId } : {}
45695
47417
  });
45696
47418
  }
45697
47419
  /** Active CLIP image-embedding model id (== the text encoder's), TTL-cached.
@@ -46761,6 +48483,18 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
46761
48483
  cooldownSec,
46762
48484
  minTrackAgeMs: trk.minTrackAgeMs
46763
48485
  }, source);
48486
+ p.setTrackLabelState((trackId) => {
48487
+ const active = this.trackStore?.getActiveByTrack(trackId);
48488
+ return active !== null && active !== void 0 ? {
48489
+ className: active.className,
48490
+ ...active.label !== void 0 ? { label: active.label } : {},
48491
+ ...active.labelScore !== void 0 ? { labelScore: active.labelScore } : {},
48492
+ ...active.labelMeta !== void 0 ? { labelMeta: active.labelMeta } : {},
48493
+ ...active.subLabel !== void 0 ? { subLabel: active.subLabel } : {},
48494
+ ...active.subLabelScore !== void 0 ? { subLabelScore: active.subLabelScore } : {},
48495
+ ...active.subLabelMeta !== void 0 ? { subLabelMeta: active.subLabelMeta } : {}
48496
+ } : void 0;
48497
+ });
46764
48498
  if (source === "pipeline" && this.stationaryRegistry) {
46765
48499
  const registry = this.stationaryRegistry;
46766
48500
  p.setStationaryGate({ filter: (input) => {