@camstack/addon-post-analysis 1.2.305 → 1.2.306

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_clip_model_registry = require("../clip-model-registry-t2ey3w0B.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-C67UM_q_.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -10652,21 +10652,49 @@ function matchesPlate(label, values, maxDistance) {
10652
10652
  return false;
10653
10653
  }
10654
10654
  /**
10655
+ * Subject kinds whose `classNames` is a real, operator-meaningful detection
10656
+ * class — the ones the `classes` condition can actually scope a rule to.
10657
+ * `device-event` and `system-event` carry NO class live (`classNames: []`,
10658
+ * always — see `subjectFromSensorEvent`); their OUTBOX `className` is a
10659
+ * display placeholder instead (the sensor kind, or the literal `'event'`,
10660
+ * `className: subject.classNames[0] ?? subject.sensorKind ?? 'event'`), which
10661
+ * `cooldownSubjectFromOutbox` would otherwise reconstruct as if it were a real
10662
+ * class. Gating here, not on the reconstructed value, keeps the live path and
10663
+ * the boot reseed addressing the SAME key regardless of what a display label
10664
+ * happens to say (2026-10-01, caught by `system-event-cooldown-direction.spec.ts`
10665
+ * when the default below flipped).
10666
+ */
10667
+ var CLASS_BEARING_SUBJECT_KINDS = new Set([
10668
+ "object-event",
10669
+ "track-end",
10670
+ "package-event",
10671
+ "occupancy-event",
10672
+ "audio-event",
10673
+ "audio-window"
10674
+ ]);
10675
+ /**
10655
10676
  * Should this subject's class be part of the cooldown key?
10656
10677
  *
10657
10678
  * AUDIO is unconditional and predates the setting: a rule opted into several
10658
10679
  * audio classes (dog + scream) must not have a safety-relevant scream
10659
10680
  * swallowed by an unrelated bark's 60 s window.
10660
10681
  *
10661
- * Every other subject gets the same treatment only when the rule ASKS for it
10662
- * (`granularity: 'per-class'`) — cat→dog fires at once, cat→cat still waits.
10663
- * Absent / `shared` reproduces the class-agnostic key byte for byte, because
10664
- * changing how often an operator's existing rules fire is not a side effect
10665
- * anyone asked for.
10682
+ * A `device-event` or `system-event` subject never reads a class — see
10683
+ * {@link CLASS_BEARING_SUBJECT_KINDS} — so the setting has nothing to key on
10684
+ * regardless of `granularity`.
10685
+ *
10686
+ * Every class-bearing subject gets the same treatment unless the rule
10687
+ * explicitly asks for `shared` — cat→dog fires at once, cat→cat still waits.
10688
+ * That is the operator's default since 2026-10-01 (D36's dated note): an
10689
+ * UNSET rule now reads exactly like an explicit `per-class` one, new rules and
10690
+ * existing rules alike. Only a `granularity: 'shared'` actually stored
10691
+ * reproduces the old class-agnostic key byte for byte — the one case where an
10692
+ * operator asked for one window across every class on purpose.
10666
10693
  */
10667
10694
  function keysPerClass(rule, subject) {
10668
10695
  if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
10669
- return rule.throttle.granularity === "per-class";
10696
+ if (!CLASS_BEARING_SUBJECT_KINDS.has(subject.kind)) return false;
10697
+ return rule.throttle.granularity !== "shared";
10670
10698
  }
10671
10699
  /**
10672
10700
  * The cooldown scope of ONE system event — subject AND direction.
@@ -10751,6 +10779,42 @@ function isCoolingDown(rule, lastFiredAt, now) {
10751
10779
  return now - lastFiredAt < rule.throttle.cooldownSec * 1e3;
10752
10780
  }
10753
10781
  /**
10782
+ * The media policy EVERY sensor rule attaches: one whole-scene photograph,
10783
+ * never a selector. `attach: 'keyFrame'` is the existing "clean full scene
10784
+ * frame" ladder (`attachmentKindPreference`) — the closest existing concept
10785
+ * to a plain snapshot, as opposed to `'best'`/`'best-matching'`, which chase a
10786
+ * SUBJECT crop a sensor subject (a doorbell press, a contact change) has
10787
+ * none of to chase. No zone crop, no gif, no clip, no profile — a sensor
10788
+ * subject has no zone condition to crop to and no reason to animate.
10789
+ */
10790
+ var NC_SENSOR_MEDIA_POLICY = { attach: "keyFrame" };
10791
+ /**
10792
+ * The media policy actually used to build a delivery, for a rule of this
10793
+ * KIND. A sensor rule's STORED `media` is never read at evaluation — the
10794
+ * editor offers no media control for one any more, and `media.attach` /
10795
+ * `.gif` / `.clip` / `.profile` on an existing sensor rule (authored before
10796
+ * this ruling, from the viewer or the API) are IGNORED here, never refused at
10797
+ * load. See {@link mediaPolicyMatchesSensorDefault} for the sampled-log gate.
10798
+ */
10799
+ function effectiveMediaPolicy(rule, kind) {
10800
+ return kind === "sensor" ? NC_SENSOR_MEDIA_POLICY : rule.media;
10801
+ }
10802
+ /**
10803
+ * True when a stored media policy already reads as the sensor default — the
10804
+ * gate the boot/evaluation path uses to decide whether an existing sensor
10805
+ * rule's stored choice is being silently overridden (and so is worth a
10806
+ * sampled log line) or already matches and there is nothing to say.
10807
+ */
10808
+ function mediaPolicyMatchesSensorDefault(media) {
10809
+ return media.attach === NC_SENSOR_MEDIA_POLICY.attach && media.frame === void 0 && media.zoneCrop !== true && media.gif !== true && media.clip !== true && media.profile === void 0;
10810
+ }
10811
+ /** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut
10812
+ * every production caller uses; `effectiveMediaPolicy` stays the unit under
10813
+ * test because `ruleKindOf` needs no fixture of its own there. */
10814
+ function effectiveMediaPolicyForRule(rule) {
10815
+ return effectiveMediaPolicy(rule, require_clip_model_registry.ruleKindOf(rule));
10816
+ }
10817
+ /**
10754
10818
  * Ordered media-kind preference for one attachment, per policy and owner.
10755
10819
  * `best` favours the tight subject crop; `keyFrame` the clean scene frame
10756
10820
  * (small variant first — the degrade engine enforces per-backend byte
@@ -14332,11 +14396,11 @@ function logRefusal$3(check, ctx) {
14332
14396
  try {
14333
14397
  check();
14334
14398
  } catch (err) {
14335
- if (err instanceof NcDevicesExcludeRefusal) warnRefusal$2(ctx.logger, err, ctx);
14399
+ if (err instanceof NcDevicesExcludeRefusal) warnRefusal$3(ctx.logger, err, ctx);
14336
14400
  throw err;
14337
14401
  }
14338
14402
  }
14339
- function warnRefusal$2(logger, err, ctx) {
14403
+ function warnRefusal$3(logger, err, ctx) {
14340
14404
  logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
14341
14405
  ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14342
14406
  by: ctx.by,
@@ -14359,6 +14423,184 @@ function guardPatchDevicesExclude(existing, patch, ctx) {
14359
14423
  logRefusal$3(() => assertDevicesExcludeDelivery(merged), ctx);
14360
14424
  }
14361
14425
  //#endregion
14426
+ //#region src/notification-center/device-compat-guard.ts
14427
+ /**
14428
+ * A NEW or UPDATED rule's `conditions.devices` (the "Only these" INCLUDE list)
14429
+ * names only devices that can actually produce this rule's KIND of subject —
14430
+ * operator ruling, 2026-10-01: "the DEVICE picker of a rule must offer only
14431
+ * devices compatible with the rule type … the server should also refuse a
14432
+ * save whose deviceIds include an incompatible device".
14433
+ *
14434
+ * THE authority, same arrangement as `device-scope-guard.ts` beside it: the
14435
+ * cap-provider route (`notificationRules.createRule/updateRule`, index.ts)
14436
+ * and the rule bridge (`nc.createRule/updateRule`, nc-actions.ts) both call
14437
+ * `guardRuleDeviceCompat` / `guardPatchDeviceCompat`. Which devices a kind
14438
+ * may scope is `ruleDeviceCompatCapQueries` (`@camstack/types`) — the SAME
14439
+ * table the admin's Devices tab narrows by, so a save can never refuse a
14440
+ * device its own picker just offered. A `sensor` rule's set includes every
14441
+ * camera: a camera-scoped sensor rule is legitimate (D630 §6).
14442
+ *
14443
+ * ASYNC, unlike its sibling guards: it has to ask `deviceManager` which
14444
+ * devices carry the caps. `listDeviceIdsByCaps` is therefore OPTIONAL on the
14445
+ * context — absent on an older wiring, if the cross-addon call throws, or if
14446
+ * it answers EMPTY (the registry not yet warm at boot: `getAllBindings`
14447
+ * answers `[]` with only a warning, which is not a measurement — D315/D393),
14448
+ * the guard FAILS OPEN (never refuses) rather than blocking every save on a
14449
+ * read it cannot make (D49). It is never SILENT about it: an unjudged scoped
14450
+ * save logs a sampled WARN naming the rule and the reason, so a dropped wiring
14451
+ * is visible in production rather than a guard that quietly stopped existing.
14452
+ *
14453
+ * ON UPDATE only the ids the patch ADDS are judged. A device already stored on
14454
+ * the rule is never re-judged: the answer is not stable (switching detection
14455
+ * off on a camera removes its `detection-pipeline` binding, D62; an agent
14456
+ * camera's native caps are missing during the handshake), and re-judging
14457
+ * would make every rule naming that camera un-editable until the operator
14458
+ * removed it — losing a scope a later switch-on would not give back.
14459
+ *
14460
+ * NEVER called at LOAD. `NcRuleSchema.parse` and the store's own read path are
14461
+ * untouched — a rule saved before this ruling, or saved while the capability
14462
+ * read was unavailable, keeps loading and keeps firing exactly as it does
14463
+ * today; only a NEW save attempt is judged.
14464
+ *
14465
+ * `devicesExclude` is NOT checked: this guard judges what the rule actually
14466
+ * MATCHES ON (the include scope), not what it widens away from, and an
14467
+ * operator excluding an incompatible device from an "all except" rule is not
14468
+ * the mistake this exists to catch.
14469
+ */
14470
+ /** How long one rule's "accepted unchecked" WARN stays quiet after it fired. */
14471
+ var UNCHECKED_WARN_WINDOW_MS = 10 * 6e4;
14472
+ /** Bound on remembered rule keys — cleared whole when exceeded. */
14473
+ var UNCHECKED_WARN_MEMO_MAX = 256;
14474
+ /**
14475
+ * Samples the "accepted unchecked" WARN per rule: an editor that autosaves, or
14476
+ * a script, must not turn one unreachable read into a log flood, but the first
14477
+ * save of every rule in a window is always reported.
14478
+ */
14479
+ var NcDeviceCompatWarnSampler = class {
14480
+ lastWarnedAt = /* @__PURE__ */ new Map();
14481
+ shouldWarn(key, now) {
14482
+ const last = this.lastWarnedAt.get(key);
14483
+ if (last !== void 0 && now - last < UNCHECKED_WARN_WINDOW_MS) return false;
14484
+ if (this.lastWarnedAt.size >= UNCHECKED_WARN_MEMO_MAX) this.lastWarnedAt.clear();
14485
+ this.lastWarnedAt.set(key, now);
14486
+ return true;
14487
+ }
14488
+ };
14489
+ var NcDeviceCompatRefusal = class extends Error {
14490
+ kind;
14491
+ incompatibleIds;
14492
+ constructor(kind, incompatibleIds) {
14493
+ super(`rule refused: device(s) ${incompatibleIds.join(", ")} cannot produce a '${kind}' rule's subject`);
14494
+ this.name = "NcDeviceCompatRefusal";
14495
+ this.kind = kind;
14496
+ this.incompatibleIds = incompatibleIds;
14497
+ }
14498
+ };
14499
+ /** The union of every compat query's answer, or why there is none. */
14500
+ async function readCompatibleIds(queries, listDeviceIdsByCaps) {
14501
+ try {
14502
+ const answers = await Promise.all(queries.map((capNames) => listDeviceIdsByCaps(capNames)));
14503
+ return {
14504
+ ok: true,
14505
+ ids: new Set(answers.flat())
14506
+ };
14507
+ } catch (err) {
14508
+ return {
14509
+ ok: false,
14510
+ error: String(err)
14511
+ };
14512
+ }
14513
+ }
14514
+ /**
14515
+ * The check. `judged` is the ids to judge — the whole include list on a
14516
+ * create, only the ADDED ids on an update (see the file header).
14517
+ *
14518
+ * Resolves `not-judged` when the kind has no capability filter
14519
+ * (`ruleDeviceCompatCapQueries` is `null`) or there is nothing to judge —
14520
+ * "every device" is never refused here. Resolves `unchecked` (fail open) when
14521
+ * the read is not wired, throws, or answers nothing at all. Throws
14522
+ * {@link NcDeviceCompatRefusal} naming every incompatible id, never only the
14523
+ * first — an operator fixing the list needs the whole picture in one pass.
14524
+ */
14525
+ async function assertRuleDeviceCompat(rule, listDeviceIdsByCaps, judged = rule.conditions.devices ?? []) {
14526
+ const kind = require_clip_model_registry.ruleKindOf(rule);
14527
+ const queries = require_clip_model_registry.ruleDeviceCompatCapQueries(kind);
14528
+ if (queries === null || judged.length === 0) return { outcome: "not-judged" };
14529
+ if (listDeviceIdsByCaps === void 0) return {
14530
+ outcome: "unchecked",
14531
+ reason: "unwired",
14532
+ ids: judged
14533
+ };
14534
+ const read = await readCompatibleIds(queries, listDeviceIdsByCaps);
14535
+ if (!read.ok) return {
14536
+ outcome: "unchecked",
14537
+ reason: "read-failed",
14538
+ ids: judged,
14539
+ error: read.error
14540
+ };
14541
+ if (read.ids.size === 0) return {
14542
+ outcome: "unchecked",
14543
+ reason: "answered-empty",
14544
+ ids: judged
14545
+ };
14546
+ const incompatible = judged.filter((id) => !read.ids.has(id));
14547
+ if (incompatible.length > 0) throw new NcDeviceCompatRefusal(kind, incompatible);
14548
+ return { outcome: "compatible" };
14549
+ }
14550
+ /**
14551
+ * Run the check and report what it could not do: a refusal is dropped work, so
14552
+ * it is logged before it is rethrown; an unchecked acceptance is a guard that
14553
+ * did not run, so it is logged (sampled) too.
14554
+ */
14555
+ async function runAndReport(rule, judged, ctx) {
14556
+ let verdict;
14557
+ try {
14558
+ verdict = await assertRuleDeviceCompat(rule, ctx.listDeviceIdsByCaps, judged);
14559
+ } catch (err) {
14560
+ if (err instanceof NcDeviceCompatRefusal) warnRefusal$2(ctx.logger, err, ctx);
14561
+ throw err;
14562
+ }
14563
+ if (verdict.outcome === "unchecked") warnUnchecked(require_clip_model_registry.ruleKindOf(rule), verdict, ctx);
14564
+ }
14565
+ function warnRefusal$2(logger, err, ctx) {
14566
+ logger.warn("nc rule refused: device(s) incompatible with the rule kind", { meta: {
14567
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14568
+ by: ctx.by,
14569
+ kind: err.kind,
14570
+ incompatibleIds: [...err.incompatibleIds]
14571
+ } });
14572
+ }
14573
+ function warnUnchecked(kind, verdict, ctx) {
14574
+ const now = (ctx.now ?? Date.now)();
14575
+ const key = `${ctx.ruleId ?? `new:${ctx.by}`}:${verdict.reason}`;
14576
+ if (ctx.sampler !== void 0 && !ctx.sampler.shouldWarn(key, now)) return;
14577
+ ctx.logger.warn("nc rule saved WITHOUT the device-compatibility check — accepted unchecked", { meta: {
14578
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14579
+ by: ctx.by,
14580
+ kind,
14581
+ reason: verdict.reason,
14582
+ uncheckedIds: [...verdict.ids],
14583
+ ...verdict.error !== void 0 ? { error: verdict.error } : {}
14584
+ } });
14585
+ }
14586
+ /** The create path: the whole include list is judged. */
14587
+ async function guardRuleDeviceCompat(rule, ctx) {
14588
+ await runAndReport(rule, rule.conditions.devices ?? [], ctx);
14589
+ }
14590
+ /**
14591
+ * The update path: the MERGED rule decides the kind, but only the ids the
14592
+ * patch ADDS to `conditions.devices` are judged — a stored id is never
14593
+ * re-judged (see the file header). A patch that adds nothing never pays for a
14594
+ * capability read.
14595
+ */
14596
+ async function guardPatchDeviceCompat(existing, patch, ctx) {
14597
+ if (existing === null) return;
14598
+ const merged = mergedFamilySubject(existing, patch);
14599
+ if (merged === null) return;
14600
+ const stored = new Set(existing.conditions.devices ?? []);
14601
+ await runAndReport(merged, (merged.conditions.devices ?? []).filter((id) => !stored.has(id)), ctx);
14602
+ }
14603
+ //#endregion
14362
14604
  //#region src/notification-center/rule-summary-guard.ts
14363
14605
  /**
14364
14606
  * The hub's half of D647: `systemEventDelivery: { mode: 'summary' }` belongs to
@@ -17218,6 +17460,8 @@ function requireAdmin(caller) {
17218
17460
  return c;
17219
17461
  }
17220
17462
  function makeNcActionHandlers(deps) {
17463
+ /** One sampler per handler set: an unchecked save warns once per rule per window. */
17464
+ const deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
17221
17465
  /** The alarm half, or a refusal by name — never a made-up "disarmed". */
17222
17466
  const requireAlarm = (action) => {
17223
17467
  if (deps.alarm === void 0) throw new Error(`${action}: the alarm is not wired on this node`);
@@ -17375,6 +17619,12 @@ function makeNcActionHandlers(deps) {
17375
17619
  logger: deps.logger,
17376
17620
  by: c.userId
17377
17621
  });
17622
+ await guardRuleDeviceCompat(parsed, {
17623
+ logger: deps.logger,
17624
+ by: c.userId,
17625
+ listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
17626
+ sampler: deviceCompatWarnSampler
17627
+ });
17378
17628
  guardRuleSystemEventDelivery(parsed, {
17379
17629
  logger: deps.logger,
17380
17630
  by: c.userId
@@ -17417,6 +17667,13 @@ function makeNcActionHandlers(deps) {
17417
17667
  by: c.userId,
17418
17668
  ruleId: input.ruleId
17419
17669
  });
17670
+ await guardPatchDeviceCompat(existing, patch, {
17671
+ logger: deps.logger,
17672
+ by: c.userId,
17673
+ ruleId: input.ruleId,
17674
+ listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
17675
+ sampler: deviceCompatWarnSampler
17676
+ });
17420
17677
  guardPatchSystemEventDelivery(existing, patch, clear, {
17421
17678
  logger: deps.logger,
17422
17679
  by: c.userId,
@@ -17918,9 +18175,9 @@ function makeNcActionHandlers(deps) {
17918
18175
  * reproduce.
17919
18176
  */
17920
18177
  var NC_CLIP_DISPATCH_MARGIN_SEC = 10;
17921
- /** Does this rule ever cut footage from the broker's clip ring? */
17922
- function wantsFootage(rule) {
17923
- return rule.media.gif === true || rule.media.clip === true;
18178
+ /** Does this media policy ever cut footage from the broker's clip ring? */
18179
+ function wantsFootage(media) {
18180
+ return media.gif === true || media.clip === true;
17924
18181
  }
17925
18182
  /** The ask ONE rule makes, or `null` when it needs no retained footage. */
17926
18183
  function askSecondsFor(rule) {
@@ -17929,9 +18186,10 @@ function askSecondsFor(rule) {
17929
18186
  seconds: require_clip_model_registry.NC_CLIP_RETENTION_MAX_SEC,
17930
18187
  reason: "occupancy"
17931
18188
  };
17932
- if (!wantsFootage(rule)) return null;
17933
- const pre = rule.media.clipPreRollSec ?? require_clip_model_registry.NC_CLIP_PRE_ROLL_DEFAULT_SEC;
17934
- const post = rule.media.clipPostRollSec ?? require_clip_model_registry.NC_CLIP_POST_ROLL_DEFAULT_SEC;
18189
+ const media = effectiveMediaPolicyForRule(rule);
18190
+ if (!wantsFootage(media)) return null;
18191
+ const pre = media.clipPreRollSec ?? require_clip_model_registry.NC_CLIP_PRE_ROLL_DEFAULT_SEC;
18192
+ const post = media.clipPostRollSec ?? require_clip_model_registry.NC_CLIP_POST_ROLL_DEFAULT_SEC;
17935
18193
  return {
17936
18194
  seconds: Math.min(pre + post + NC_CLIP_DISPATCH_MARGIN_SEC, require_clip_model_registry.NC_CLIP_RETENTION_MAX_SEC),
17937
18195
  reason: "window"
@@ -17950,12 +18208,13 @@ function deriveClipRetentionAsks(rules) {
17950
18208
  for (const rule of rules) {
17951
18209
  const ask = askSecondsFor(rule);
17952
18210
  if (ask === null) continue;
18211
+ const profile = effectiveMediaPolicyForRule(rule).profile;
17953
18212
  const base = {
17954
18213
  seconds: ask.seconds,
17955
18214
  reason: ask.reason,
17956
18215
  ruleId: rule.id,
17957
18216
  ruleName: rule.name,
17958
- ...rule.media.profile !== void 0 ? { profile: rule.media.profile } : {}
18217
+ ...profile !== void 0 ? { profile } : {}
17959
18218
  };
17960
18219
  const devices = rule.conditions.devices;
17961
18220
  if (devices === void 0 || devices.length === 0) {
@@ -31569,9 +31828,15 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
31569
31828
  * A rule's media policy, as the fields a row freezes — ONE derivation, used by
31570
31829
  * every row a rule addresses AND by the alarm's own announcement row when it is
31571
31830
  * the COMBINED siren (the triggering rule's pictures on the alarm's words).
31831
+ *
31832
+ * `effectiveMediaPolicyForRule` is what actually resolves it: a SENSOR rule's
31833
+ * stored `media` is ignored here, in favour of the one-snapshot default
31834
+ * (operator ruling, 2026-10-01) — the rule still LOADS with whatever it has
31835
+ * stored, and `buildEntries` is where the sampled log fires when that stored
31836
+ * choice is about to be overridden.
31572
31837
  */
31573
31838
  function mediaPolicyFields(mediaRule, cropZoneIds) {
31574
- const media = mediaRule.media;
31839
+ const media = effectiveMediaPolicyForRule(mediaRule);
31575
31840
  return {
31576
31841
  media: media.attach,
31577
31842
  ...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
@@ -31865,13 +32130,14 @@ function cooldownSubjectKind(kind) {
31865
32130
  * the same builder the live path does, so the two can never address different
31866
32131
  * windows. Only the four fields the key is a function of are reconstructed —
31867
32132
  * the class (for `per-class` and audio), the subject kind (which decides
31868
- * whether audio is keyed per class at all), the system event (subject +
32133
+ * whether the class is keyed at all — audio always, occupancy and detections
32134
+ * unless `shared`), the system event (subject +
31869
32135
  * direction) and the device.
31870
32136
  */
31871
32137
  function cooldownSubjectFromOutbox(entry) {
31872
32138
  const subject = entry.payload.subject;
31873
32139
  return {
31874
- kind: cooldownSubjectKind(entry.recordKind),
32140
+ kind: entry.recordKind === "device-event" && subject.occupancy !== void 0 ? "occupancy-event" : cooldownSubjectKind(entry.recordKind),
31875
32141
  recordId: entry.recordId,
31876
32142
  deviceId: entry.deviceId,
31877
32143
  timestamp: subject.timestamp,
@@ -31950,6 +32216,9 @@ function renderAudioScope(devices, excluded) {
31950
32216
  if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
31951
32217
  return devices.filter((id) => !excluded.includes(id)).join("+");
31952
32218
  }
32219
+ /** {@link NotificationCenter.reportedSensorMediaIgnored} cap, same shape as
32220
+ * the dispatcher's own annotated-fallback memo. */
32221
+ var NC_SENSOR_MEDIA_IGNORED_MEMO_MAX = 5e3;
31953
32222
  var NotificationCenter = class NotificationCenter {
31954
32223
  logger;
31955
32224
  rules;
@@ -31957,6 +32226,11 @@ var NotificationCenter = class NotificationCenter {
31957
32226
  dispatcher;
31958
32227
  deps;
31959
32228
  now;
32229
+ /** Rule ids whose stored (non-default) media policy has already been
32230
+ * reported as ignored at evaluation — ONE line per rule, not per fire. */
32231
+ reportedSensorMediaIgnored = /* @__PURE__ */ new Set();
32232
+ /** Samples the device-compat guard's "accepted unchecked" WARN per rule. */
32233
+ deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
31960
32234
  /**
31961
32235
  * Every word this centre can print, in the hub's language.
31962
32236
  *
@@ -33737,7 +34011,7 @@ var NotificationCenter = class NotificationCenter {
33737
34011
  const shown = this.unmutedCameras(cameraIds);
33738
34012
  const involved = [sensorId, ...rowShown];
33739
34013
  if (!this.rules.listEnabled("device-event").some((rule) => {
33740
- if (rule.media.attach === "none") return false;
34014
+ if (effectiveMediaPolicyForRule(rule).attach === "none") return false;
33741
34015
  if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
33742
34016
  const excluded = rule.conditions.devicesExclude;
33743
34017
  if (excluded !== void 0 && deviceScopeExcludes(excluded, {
@@ -34034,7 +34308,7 @@ var NotificationCenter = class NotificationCenter {
34034
34308
  ruleWantsOccupancyStill(edge) {
34035
34309
  const subject = occupancySubjectFromEdge(edge);
34036
34310
  for (const rule of this.rules.listEnabled("device-event")) {
34037
- if (rule.media.attach === "none") continue;
34311
+ if (effectiveMediaPolicyForRule(rule).attach === "none") continue;
34038
34312
  const occ = rule.conditions.occupancy;
34039
34313
  if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
34040
34314
  const devices = rule.conditions.devices;
@@ -34301,6 +34575,12 @@ var NotificationCenter = class NotificationCenter {
34301
34575
  logger: this.logger,
34302
34576
  by: caller.userId
34303
34577
  });
34578
+ await guardRuleDeviceCompat(rule, {
34579
+ logger: this.logger,
34580
+ by: caller.userId,
34581
+ listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
34582
+ sampler: this.deviceCompatWarnSampler
34583
+ });
34304
34584
  guardRuleSystemEventDelivery(rule, {
34305
34585
  logger: this.logger,
34306
34586
  by: caller.userId
@@ -34338,6 +34618,13 @@ var NotificationCenter = class NotificationCenter {
34338
34618
  by: caller.userId,
34339
34619
  ruleId
34340
34620
  });
34621
+ await guardPatchDeviceCompat(existing, patch, {
34622
+ logger: this.logger,
34623
+ by: caller.userId,
34624
+ ruleId,
34625
+ listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
34626
+ sampler: this.deviceCompatWarnSampler
34627
+ });
34341
34628
  guardPatchSystemEventDelivery(existing, patch, clear ?? [], {
34342
34629
  logger: this.logger,
34343
34630
  by: caller.userId,
@@ -35385,7 +35672,7 @@ var NotificationCenter = class NotificationCenter {
35385
35672
  buttons: unresolvable.join(", ")
35386
35673
  }
35387
35674
  });
35388
- if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && rule.media.attach !== "none") {
35675
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && effectiveMediaPolicyForRule(rule).attach !== "none") {
35389
35676
  noOwnerReported = true;
35390
35677
  const linked = subject.linkedCameraIds;
35391
35678
  const meta = {
@@ -35763,6 +36050,24 @@ var NotificationCenter = class NotificationCenter {
35763
36050
  }
35764
36051
  });
35765
36052
  }
36053
+ /**
36054
+ * ONE line per rule id, the first time a SENSOR rule's stored media policy
36055
+ * is about to be overridden by {@link effectiveMediaPolicyForRule}'s
36056
+ * one-snapshot default (operator ruling, 2026-10-01). Never refuses the
36057
+ * rule — it still loads and still fires — this only says the stored choice
36058
+ * is not the one being used.
36059
+ */
36060
+ warnSensorMediaIgnoredOnce(rule) {
36061
+ if (mediaPolicyMatchesSensorDefault(rule.media)) return;
36062
+ if (this.reportedSensorMediaIgnored.has(rule.id)) return;
36063
+ if (this.reportedSensorMediaIgnored.size >= NC_SENSOR_MEDIA_IGNORED_MEMO_MAX) this.reportedSensorMediaIgnored.clear();
36064
+ this.reportedSensorMediaIgnored.add(rule.id);
36065
+ this.logger.info("sensor rule carries a media choice — ignored at evaluation, one snapshot is always attached", { meta: {
36066
+ ruleId: rule.id,
36067
+ rule: rule.name,
36068
+ storedMedia: rule.media
36069
+ } });
36070
+ }
35766
36071
  buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade, group) {
35767
36072
  const hasEventMedia = kind === "object-event" || kind === "package-event";
35768
36073
  const isTrackScoped = kind === "object-event" || kind === "track-end";
@@ -35770,6 +36075,7 @@ var NotificationCenter = class NotificationCenter {
35770
36075
  const alarmSource = subject.systemEvent?.alarmSource;
35771
36076
  const mediaRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) ?? rule : rule;
35772
36077
  const noMedia = isSystemEvent && alarmSource === void 0;
36078
+ if (require_clip_model_registry.ruleKindOf(mediaRule) === "sensor") this.warnSensorMediaIgnoredOnce(mediaRule);
35773
36079
  const direct = new Set(rule.targets.map((t) => t.targetId));
35774
36080
  const renderedZones = notificationZones(rule, subject);
35775
36081
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
@@ -98994,6 +99300,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
98994
99300
  listCallerTargetIds: async (userId) => {
98995
99301
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
98996
99302
  },
99303
+ listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
98997
99304
  dropRuleMosaics: async (ruleId) => {
98998
99305
  const mediaStore = this.mediaStore;
98999
99306
  if (mediaStore === null) return;
@@ -99910,6 +100217,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
99910
100217
  addonId: device.addonId
99911
100218
  };
99912
100219
  },
100220
+ listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
99913
100221
  isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
99914
100222
  readNodeDirectory: () => api.nodes.directory.query(),
99915
100223
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),