@camstack/addon-post-analysis 1.2.305 → 1.2.307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, $n as sceneMonitorCapability, $t as TimelapseRuleInputSchema, A as EMBEDDING_REQUIREMENTS_FAILED, An as isDetectionMacroClass, Ar as array, At as NcTemplateVarDescriptorSchema, B as ListTrackRelationsInputSchema, Bn as memoizePinnedFfmpeg, Br as string, Bt as SCENE_DEFAULT_ANCHOR_THRESHOLD, C as DEFAULT_EVENT_COLOR, Cn as encodeVectorBase64, Cr as hydrateSchema, Ct as NcSnoozeInputSchema, D as DeclaredDevices, Dn as gifDeriveSpeed, Dr as sleep$1, Dt as NcTaxonomySchema, E as DETECTION_PIPELINE_CAP_NAME, En as failureContributionCapability, Et as NcSystemEventKindSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as isSystemDelivery, Fr as number, Ft as RECORDING_SOURCE_CAMSTACK_ADDON, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as plateGalleryCapability, Gt as SCENE_DIVERGED, H as LockStateSchema, Hn as occupancyScope, Hr as union, Ht as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, I as FIRST_LEVEL_MACRO_CLASSES, In as isSystemEventSummaryRule, Ir as object, It as RebuildObjectEmbeddingsRefusalSchema, J as MOTION_CLOSE_AFTER_MS, Jn as readTimelapseGeneratedAt, Jt as SetTrackPurityInputSchema, K as MAX_TRACK_PURITIES, Kn as readDeviceStateFrom, Kt as SCENE_RESET_RECAPTURES, L as FULL_IMAGE_BBOX, Ln as isVectorIndexDimConflict, Lr as partialRecord, Lt as RetrainStatusSchema, M as EVENT_DENSITY_BATCH_MAX, Mn as isKnownTemplateVar, Mr as discriminatedUnion, Mt as OpsLogEntrySchema, N as EVENT_KIND_BY_CAP, Nn as isMediaOwnerType, Nr as literal, O as DetectionSourceSchema, On as gifFromMp4Args, Or as ZodObject, Ot as NcTemplatePreviewInputSchema, P as EVENT_OWNER_TYPES, Pn as isScheduleActive, Pr as looseObject, Pt as RECORDING_EXPORT_MAX_READ_BYTES, Q as NC_AUDIO_CONFIRM_HITS_DEFAULT, Qn as ruleBelongsToAlarm$1, Qt as TRACK_RELATIONS_DEFAULT_LIMIT, R as FailureCounters, Rn as kebabToCamel, Rr as record, Rt as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, S as CoverStateSchema, Sr as defineCustomActions, St as NcScheduleSchema, T as DETECTION_MACRO_CLASSES, Tn as faceGalleryCapability, Tr as nodePin, Tt as NcSnoozeSuppressedSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Ur as unknown, Ut as SCENE_DEFAULT_QUIET_SECONDS, V as ListTrackRelationsResultSchema, Vn as notificationRulesCapability, Vr as tuple, Vt as SCENE_DEFAULT_CHECK_INTERVAL_SEC, W as MAX_BIRTH_DECISION_RECORDS, Wn as pipelineAnalyticsCapability, Wr as EventCategory, Wt as SCENE_DEFAULT_UNCOVERED_POLICY, X as MotionTriggeredBySchema, Xn as resolveLocationMode, Xt as SetTrackRelationInputSchema, Y as MediaFileKindEnum, Yn as resolveClipSearchMinScore, Yt as SetTrackPurityResultSchema, Z as NC_ALARM_SYSTEM_EVENT_KINDS, Zt as TIMELAPSE_DENSE_FLOOR_SEC, _ as BaseDevice, _n as describeSystemEventFamilyProblem, _r as errMsg, _t as NcRuleInputSchema, a as CLIP_EMBEDDING_STEP_ID, an as TrackSideSchema, ar as systemEventFamilyProblems, at as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, b as ClearTrackRelationInputSchema, bn as embeddingRefusalOf, br as createEvent, bt as NcRuleTargetSchema, c as resolveClusterModelPin, cn as alarmPanelCapability, cr as templateCarriesSummary, ct as NC_SNOOZE_MAX_MINUTES, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, dn as audioModeOf, dr as zoneAnalyticsCapability, dt as NC_TAXONOMY, en as TimelapseRulePatchSchema, er as sensorRaisedOf, et as NC_CLIP_POST_ROLL_DEFAULT_SEC, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fn as buildEventKindDescriptor, fr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, ft as NC_TEMPLATE_VARS, g as BIRTH_DECISION_DEFAULT_LIMIT, gn as describeSystemEventDeliveryProblem, gr as DeviceType, gt as NcConditionsSchema, h as ArchivedDebugNoteSchema, hn as deriveRecordingMode, hr as isSourceCap, ht as NcConditionDescriptorSchema, in as TrackRelationSchema, ir as systemEventDeliveryProblems, it as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, jn as isEventOwnerType, jr as boolean, jt as OPS_LOG_DEFAULT_LIMIT, k as EMBEDDING_MODEL_FAILED, kn as isAudioClassName, kr as _enum, kt as NcTemplatePreviewSchema, l as APPEARANCE_BIN_COUNT, ln as assertTimelapseCadences, lr as templateVarsFor, lt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, m as AlarmArmModeSchema, mn as cosineSimilarity$1, mr as evaluateSensorEdge, mt as NcAlarmSettingsPatchSchema, nn as TrackPuritySchema, nr as storageOccupancyCapability, nt as NC_CLIP_RETENTION_MAX_SEC, o as FACE_EMBEDDING_STEP_ID, on as TrackSourceSchema, or as systemEventFilterApplies, ot as NC_DEFAULT_SNOOZE_MINUTES, p as AUDIO_MACRO_LABELS, pn as consumableLowPercentOf, pr as canonicalHash, pt as NcAlarmConfigSchema, q as MAX_TRACK_RELATIONS, qn as readMotionTriggeredBy, qt as SceneMonitorSchema, rn as TrackRelationKindSchema, rr as subKindsOf, rt as NC_CONDITION_CATALOG, s as readClusterModelPinStrict, sn as addonWidgetsSourceCapability, sr as systemEventSummaryScheduleOf, st as NC_SENSOR_TRANSITION_KINDS, t as BUILTIN_CLIP_MODELS, tn as TimelapseRuleSchema, tr as servedTemplateVars, tt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, u as APPEARANCE_BLOCKS, un as audioMetricsCapability, ur as vectorDimFromBase64, ut as NC_TANK_SYSTEM_EVENT_KINDS, v as BirthDecisionRecordSchema, vr as BaseAddon, vt as NcRulePatchSchema, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wn as evictionPolicyOfLocation, wr as isDeviceScopedCap, wt as NcSnoozeSchema, x as ClearTrackRelationResultSchema, xr as customAction, xt as NcRuleUpdateInputSchema, y as COCO_TO_MACRO, yr as CamProfileSchema, yt as NcRuleSchema, z as LabelAttributionSchema, zn as mayWriteToLocation, zr as strictObject, zt as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS } from "../clip-model-registry-EA6x7Vd5.mjs";
1
+ import { $ as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, $n as ruleDeviceCompatCapQueries, $t as TimelapseRuleInputSchema, A as EMBEDDING_REQUIREMENTS_FAILED, An as isDetectionMacroClass, Ar as ZodObject, At as NcTemplateVarDescriptorSchema, B as ListTrackRelationsInputSchema, Bn as memoizePinnedFfmpeg, Br as record, Bt as SCENE_DEFAULT_ANCHOR_THRESHOLD, C as DEFAULT_EVENT_COLOR, Cn as encodeVectorBase64, Cr as customAction, Ct as NcSnoozeInputSchema, D as DeclaredDevices, Dn as gifDeriveSpeed, Dr as nodePin, Dt as NcTaxonomySchema, E as DETECTION_PIPELINE_CAP_NAME, En as failureContributionCapability, Er as isDeviceScopedCap, Et as NcSystemEventKindSchema, F as EXPORT_DENSE_MAX_RANGES, Fn as isSystemDelivery, Fr as literal, Ft as RECORDING_SOURCE_CAMSTACK_ADDON, G as MAX_BIRTH_LATENCY_PROXY_MS, Gn as plateGalleryCapability, Gr as unknown, Gt as SCENE_DIVERGED, H as LockStateSchema, Hn as occupancyScope, Hr as string, Ht as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, I as FIRST_LEVEL_MACRO_CLASSES, In as isSystemEventSummaryRule, Ir as looseObject, It as RebuildObjectEmbeddingsRefusalSchema, J as MOTION_CLOSE_AFTER_MS, Jn as readTimelapseGeneratedAt, Jt as SetTrackPurityInputSchema, K as MAX_TRACK_PURITIES, Kn as readDeviceStateFrom, Kr as EventCategory, Kt as SCENE_RESET_RECAPTURES, L as FULL_IMAGE_BBOX, Ln as isVectorIndexDimConflict, Lr as number, Lt as RetrainStatusSchema, M as EVENT_DENSITY_BATCH_MAX, Mn as isKnownTemplateVar, Mr as array, Mt as OpsLogEntrySchema, N as EVENT_KIND_BY_CAP, Nn as isMediaOwnerType, Nr as boolean, O as DetectionSourceSchema, On as gifFromMp4Args, Ot as NcTemplatePreviewInputSchema, P as EVENT_OWNER_TYPES, Pn as isScheduleActive, Pr as discriminatedUnion, Pt as RECORDING_EXPORT_MAX_READ_BYTES, Q as NC_AUDIO_CONFIRM_HITS_DEFAULT, Qn as ruleBelongsToAlarm$1, Qt as TRACK_RELATIONS_DEFAULT_LIMIT, R as FailureCounters, Rn as kebabToCamel, Rr as object, Rt as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, S as CoverStateSchema, Sr as createEvent, St as NcScheduleSchema, T as DETECTION_MACRO_CLASSES, Tn as faceGalleryCapability, Tr as hydrateSchema, Tt as NcSnoozeSuppressedSchema, U as MAX_ARCHIVED_DEBUG_NOTES, Ur as tuple, Ut as SCENE_DEFAULT_QUIET_SECONDS, V as ListTrackRelationsResultSchema, Vn as notificationRulesCapability, Vr as strictObject, Vt as SCENE_DEFAULT_CHECK_INTERVAL_SEC, W as MAX_BIRTH_DECISION_RECORDS, Wn as pipelineAnalyticsCapability, Wr as union, Wt as SCENE_DEFAULT_UNCOVERED_POLICY, X as MotionTriggeredBySchema, Xn as resolveLocationMode, Xt as SetTrackRelationInputSchema, Y as MediaFileKindEnum, Yn as resolveClipSearchMinScore, Yt as SetTrackPurityResultSchema, Z as NC_ALARM_SYSTEM_EVENT_KINDS, Zt as TIMELAPSE_DENSE_FLOOR_SEC, _ as BaseDevice, _n as describeSystemEventFamilyProblem, _r as isSourceCap, _t as NcRuleInputSchema, a as CLIP_EMBEDDING_STEP_ID, an as TrackSideSchema, ar as subKindsOf, at as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, b as ClearTrackRelationInputSchema, bn as embeddingRefusalOf, br as BaseAddon, bt as NcRuleTargetSchema, c as resolveClusterModelPin, cn as alarmPanelCapability, cr as systemEventFilterApplies, ct as NC_SNOOZE_MAX_MINUTES, d as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, dn as audioModeOf, dr as templateVarsFor, dt as NC_TAXONOMY, en as TimelapseRulePatchSchema, er as ruleKindOf, et as NC_CLIP_POST_ROLL_DEFAULT_SEC, f as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, fn as buildEventKindDescriptor, fr as vectorDimFromBase64, ft as NC_TEMPLATE_VARS, g as BIRTH_DECISION_DEFAULT_LIMIT, gn as describeSystemEventDeliveryProblem, gr as evaluateSensorEdge, gt as NcConditionsSchema, h as ArchivedDebugNoteSchema, hn as deriveRecordingMode, hr as canonicalHash, ht as NcConditionDescriptorSchema, in as TrackRelationSchema, ir as storageOccupancyCapability, it as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, j as EMBEDDING_REQUIREMENTS_UNAVAILABLE, jn as isEventOwnerType, jr as _enum, jt as OPS_LOG_DEFAULT_LIMIT, k as EMBEDDING_MODEL_FAILED, kn as isAudioClassName, kr as sleep$1, kt as NcTemplatePreviewSchema, l as APPEARANCE_BIN_COUNT, ln as assertTimelapseCadences, lr as systemEventSummaryScheduleOf, lt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, m as AlarmArmModeSchema, mn as cosineSimilarity$1, mr as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, mt as NcAlarmSettingsPatchSchema, nn as TrackPuritySchema, nr as sensorRaisedOf, nt as NC_CLIP_RETENTION_MAX_SEC, o as FACE_EMBEDDING_STEP_ID, on as TrackSourceSchema, or as systemEventDeliveryProblems, ot as NC_DEFAULT_SNOOZE_MINUTES, p as AUDIO_MACRO_LABELS, pn as consumableLowPercentOf, pr as zoneAnalyticsCapability, pt as NcAlarmConfigSchema, q as MAX_TRACK_RELATIONS, qn as readMotionTriggeredBy, qt as SceneMonitorSchema, rn as TrackRelationKindSchema, rr as servedTemplateVars, rt as NC_CONDITION_CATALOG, s as readClusterModelPinStrict, sn as addonWidgetsSourceCapability, sr as systemEventFamilyProblems, st as NC_SENSOR_TRANSITION_KINDS, t as BUILTIN_CLIP_MODELS, tn as TimelapseRuleSchema, tr as sceneMonitorCapability, tt as NC_CLIP_PRE_ROLL_DEFAULT_SEC, u as APPEARANCE_BLOCKS, un as audioMetricsCapability, ur as templateCarriesSummary, ut as NC_TANK_SYSTEM_EVENT_KINDS, v as BirthDecisionRecordSchema, vr as DeviceType, vt as NcRulePatchSchema, w as DEFAULT_TIMELAPSE_PREVIEW_TEXT, wn as evictionPolicyOfLocation, wr as defineCustomActions, wt as NcSnoozeSchema, x as ClearTrackRelationResultSchema, xr as CamProfileSchema, xt as NcRuleUpdateInputSchema, y as COCO_TO_MACRO, yr as errMsg, yt as NcRuleSchema, z as LabelAttributionSchema, zn as mayWriteToLocation, zr as partialRecord, zt as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS } from "../clip-model-registry-C9Aw4qkh.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs$1 from "node:fs";
4
4
  import { createWriteStream, promises } from "node:fs";
@@ -10644,21 +10644,49 @@ function matchesPlate(label, values, maxDistance) {
10644
10644
  return false;
10645
10645
  }
10646
10646
  /**
10647
+ * Subject kinds whose `classNames` is a real, operator-meaningful detection
10648
+ * class — the ones the `classes` condition can actually scope a rule to.
10649
+ * `device-event` and `system-event` carry NO class live (`classNames: []`,
10650
+ * always — see `subjectFromSensorEvent`); their OUTBOX `className` is a
10651
+ * display placeholder instead (the sensor kind, or the literal `'event'`,
10652
+ * `className: subject.classNames[0] ?? subject.sensorKind ?? 'event'`), which
10653
+ * `cooldownSubjectFromOutbox` would otherwise reconstruct as if it were a real
10654
+ * class. Gating here, not on the reconstructed value, keeps the live path and
10655
+ * the boot reseed addressing the SAME key regardless of what a display label
10656
+ * happens to say (2026-10-01, caught by `system-event-cooldown-direction.spec.ts`
10657
+ * when the default below flipped).
10658
+ */
10659
+ var CLASS_BEARING_SUBJECT_KINDS = new Set([
10660
+ "object-event",
10661
+ "track-end",
10662
+ "package-event",
10663
+ "occupancy-event",
10664
+ "audio-event",
10665
+ "audio-window"
10666
+ ]);
10667
+ /**
10647
10668
  * Should this subject's class be part of the cooldown key?
10648
10669
  *
10649
10670
  * AUDIO is unconditional and predates the setting: a rule opted into several
10650
10671
  * audio classes (dog + scream) must not have a safety-relevant scream
10651
10672
  * swallowed by an unrelated bark's 60 s window.
10652
10673
  *
10653
- * Every other subject gets the same treatment only when the rule ASKS for it
10654
- * (`granularity: 'per-class'`) — cat→dog fires at once, cat→cat still waits.
10655
- * Absent / `shared` reproduces the class-agnostic key byte for byte, because
10656
- * changing how often an operator's existing rules fire is not a side effect
10657
- * anyone asked for.
10674
+ * A `device-event` or `system-event` subject never reads a class — see
10675
+ * {@link CLASS_BEARING_SUBJECT_KINDS} — so the setting has nothing to key on
10676
+ * regardless of `granularity`.
10677
+ *
10678
+ * Every class-bearing subject gets the same treatment unless the rule
10679
+ * explicitly asks for `shared` — cat→dog fires at once, cat→cat still waits.
10680
+ * That is the operator's default since 2026-10-01 (D36's dated note): an
10681
+ * UNSET rule now reads exactly like an explicit `per-class` one, new rules and
10682
+ * existing rules alike. Only a `granularity: 'shared'` actually stored
10683
+ * reproduces the old class-agnostic key byte for byte — the one case where an
10684
+ * operator asked for one window across every class on purpose.
10658
10685
  */
10659
10686
  function keysPerClass(rule, subject) {
10660
10687
  if (subject.kind === "audio-event" || subject.kind === "audio-window") return true;
10661
- return rule.throttle.granularity === "per-class";
10688
+ if (!CLASS_BEARING_SUBJECT_KINDS.has(subject.kind)) return false;
10689
+ return rule.throttle.granularity !== "shared";
10662
10690
  }
10663
10691
  /**
10664
10692
  * The cooldown scope of ONE system event — subject AND direction.
@@ -10743,6 +10771,42 @@ function isCoolingDown(rule, lastFiredAt, now) {
10743
10771
  return now - lastFiredAt < rule.throttle.cooldownSec * 1e3;
10744
10772
  }
10745
10773
  /**
10774
+ * The media policy EVERY sensor rule attaches: one whole-scene photograph,
10775
+ * never a selector. `attach: 'keyFrame'` is the existing "clean full scene
10776
+ * frame" ladder (`attachmentKindPreference`) — the closest existing concept
10777
+ * to a plain snapshot, as opposed to `'best'`/`'best-matching'`, which chase a
10778
+ * SUBJECT crop a sensor subject (a doorbell press, a contact change) has
10779
+ * none of to chase. No zone crop, no gif, no clip, no profile — a sensor
10780
+ * subject has no zone condition to crop to and no reason to animate.
10781
+ */
10782
+ var NC_SENSOR_MEDIA_POLICY = { attach: "keyFrame" };
10783
+ /**
10784
+ * The media policy actually used to build a delivery, for a rule of this
10785
+ * KIND. A sensor rule's STORED `media` is never read at evaluation — the
10786
+ * editor offers no media control for one any more, and `media.attach` /
10787
+ * `.gif` / `.clip` / `.profile` on an existing sensor rule (authored before
10788
+ * this ruling, from the viewer or the API) are IGNORED here, never refused at
10789
+ * load. See {@link mediaPolicyMatchesSensorDefault} for the sampled-log gate.
10790
+ */
10791
+ function effectiveMediaPolicy(rule, kind) {
10792
+ return kind === "sensor" ? NC_SENSOR_MEDIA_POLICY : rule.media;
10793
+ }
10794
+ /**
10795
+ * True when a stored media policy already reads as the sensor default — the
10796
+ * gate the boot/evaluation path uses to decide whether an existing sensor
10797
+ * rule's stored choice is being silently overridden (and so is worth a
10798
+ * sampled log line) or already matches and there is nothing to say.
10799
+ */
10800
+ function mediaPolicyMatchesSensorDefault(media) {
10801
+ 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;
10802
+ }
10803
+ /** `effectiveMediaPolicy`, resolving the kind itself — the call-site shortcut
10804
+ * every production caller uses; `effectiveMediaPolicy` stays the unit under
10805
+ * test because `ruleKindOf` needs no fixture of its own there. */
10806
+ function effectiveMediaPolicyForRule(rule) {
10807
+ return effectiveMediaPolicy(rule, ruleKindOf(rule));
10808
+ }
10809
+ /**
10746
10810
  * Ordered media-kind preference for one attachment, per policy and owner.
10747
10811
  * `best` favours the tight subject crop; `keyFrame` the clean scene frame
10748
10812
  * (small variant first — the degrade engine enforces per-backend byte
@@ -14324,11 +14388,11 @@ function logRefusal$3(check, ctx) {
14324
14388
  try {
14325
14389
  check();
14326
14390
  } catch (err) {
14327
- if (err instanceof NcDevicesExcludeRefusal) warnRefusal$2(ctx.logger, err, ctx);
14391
+ if (err instanceof NcDevicesExcludeRefusal) warnRefusal$3(ctx.logger, err, ctx);
14328
14392
  throw err;
14329
14393
  }
14330
14394
  }
14331
- function warnRefusal$2(logger, err, ctx) {
14395
+ function warnRefusal$3(logger, err, ctx) {
14332
14396
  logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
14333
14397
  ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14334
14398
  by: ctx.by,
@@ -14351,6 +14415,184 @@ function guardPatchDevicesExclude(existing, patch, ctx) {
14351
14415
  logRefusal$3(() => assertDevicesExcludeDelivery(merged), ctx);
14352
14416
  }
14353
14417
  //#endregion
14418
+ //#region src/notification-center/device-compat-guard.ts
14419
+ /**
14420
+ * A NEW or UPDATED rule's `conditions.devices` (the "Only these" INCLUDE list)
14421
+ * names only devices that can actually produce this rule's KIND of subject —
14422
+ * operator ruling, 2026-10-01: "the DEVICE picker of a rule must offer only
14423
+ * devices compatible with the rule type … the server should also refuse a
14424
+ * save whose deviceIds include an incompatible device".
14425
+ *
14426
+ * THE authority, same arrangement as `device-scope-guard.ts` beside it: the
14427
+ * cap-provider route (`notificationRules.createRule/updateRule`, index.ts)
14428
+ * and the rule bridge (`nc.createRule/updateRule`, nc-actions.ts) both call
14429
+ * `guardRuleDeviceCompat` / `guardPatchDeviceCompat`. Which devices a kind
14430
+ * may scope is `ruleDeviceCompatCapQueries` (`@camstack/types`) — the SAME
14431
+ * table the admin's Devices tab narrows by, so a save can never refuse a
14432
+ * device its own picker just offered. A `sensor` rule's set includes every
14433
+ * camera: a camera-scoped sensor rule is legitimate (D630 §6).
14434
+ *
14435
+ * ASYNC, unlike its sibling guards: it has to ask `deviceManager` which
14436
+ * devices carry the caps. `listDeviceIdsByCaps` is therefore OPTIONAL on the
14437
+ * context — absent on an older wiring, if the cross-addon call throws, or if
14438
+ * it answers EMPTY (the registry not yet warm at boot: `getAllBindings`
14439
+ * answers `[]` with only a warning, which is not a measurement — D315/D393),
14440
+ * the guard FAILS OPEN (never refuses) rather than blocking every save on a
14441
+ * read it cannot make (D49). It is never SILENT about it: an unjudged scoped
14442
+ * save logs a sampled WARN naming the rule and the reason, so a dropped wiring
14443
+ * is visible in production rather than a guard that quietly stopped existing.
14444
+ *
14445
+ * ON UPDATE only the ids the patch ADDS are judged. A device already stored on
14446
+ * the rule is never re-judged: the answer is not stable (switching detection
14447
+ * off on a camera removes its `detection-pipeline` binding, D62; an agent
14448
+ * camera's native caps are missing during the handshake), and re-judging
14449
+ * would make every rule naming that camera un-editable until the operator
14450
+ * removed it — losing a scope a later switch-on would not give back.
14451
+ *
14452
+ * NEVER called at LOAD. `NcRuleSchema.parse` and the store's own read path are
14453
+ * untouched — a rule saved before this ruling, or saved while the capability
14454
+ * read was unavailable, keeps loading and keeps firing exactly as it does
14455
+ * today; only a NEW save attempt is judged.
14456
+ *
14457
+ * `devicesExclude` is NOT checked: this guard judges what the rule actually
14458
+ * MATCHES ON (the include scope), not what it widens away from, and an
14459
+ * operator excluding an incompatible device from an "all except" rule is not
14460
+ * the mistake this exists to catch.
14461
+ */
14462
+ /** How long one rule's "accepted unchecked" WARN stays quiet after it fired. */
14463
+ var UNCHECKED_WARN_WINDOW_MS = 10 * 6e4;
14464
+ /** Bound on remembered rule keys — cleared whole when exceeded. */
14465
+ var UNCHECKED_WARN_MEMO_MAX = 256;
14466
+ /**
14467
+ * Samples the "accepted unchecked" WARN per rule: an editor that autosaves, or
14468
+ * a script, must not turn one unreachable read into a log flood, but the first
14469
+ * save of every rule in a window is always reported.
14470
+ */
14471
+ var NcDeviceCompatWarnSampler = class {
14472
+ lastWarnedAt = /* @__PURE__ */ new Map();
14473
+ shouldWarn(key, now) {
14474
+ const last = this.lastWarnedAt.get(key);
14475
+ if (last !== void 0 && now - last < UNCHECKED_WARN_WINDOW_MS) return false;
14476
+ if (this.lastWarnedAt.size >= UNCHECKED_WARN_MEMO_MAX) this.lastWarnedAt.clear();
14477
+ this.lastWarnedAt.set(key, now);
14478
+ return true;
14479
+ }
14480
+ };
14481
+ var NcDeviceCompatRefusal = class extends Error {
14482
+ kind;
14483
+ incompatibleIds;
14484
+ constructor(kind, incompatibleIds) {
14485
+ super(`rule refused: device(s) ${incompatibleIds.join(", ")} cannot produce a '${kind}' rule's subject`);
14486
+ this.name = "NcDeviceCompatRefusal";
14487
+ this.kind = kind;
14488
+ this.incompatibleIds = incompatibleIds;
14489
+ }
14490
+ };
14491
+ /** The union of every compat query's answer, or why there is none. */
14492
+ async function readCompatibleIds(queries, listDeviceIdsByCaps) {
14493
+ try {
14494
+ const answers = await Promise.all(queries.map((capNames) => listDeviceIdsByCaps(capNames)));
14495
+ return {
14496
+ ok: true,
14497
+ ids: new Set(answers.flat())
14498
+ };
14499
+ } catch (err) {
14500
+ return {
14501
+ ok: false,
14502
+ error: String(err)
14503
+ };
14504
+ }
14505
+ }
14506
+ /**
14507
+ * The check. `judged` is the ids to judge — the whole include list on a
14508
+ * create, only the ADDED ids on an update (see the file header).
14509
+ *
14510
+ * Resolves `not-judged` when the kind has no capability filter
14511
+ * (`ruleDeviceCompatCapQueries` is `null`) or there is nothing to judge —
14512
+ * "every device" is never refused here. Resolves `unchecked` (fail open) when
14513
+ * the read is not wired, throws, or answers nothing at all. Throws
14514
+ * {@link NcDeviceCompatRefusal} naming every incompatible id, never only the
14515
+ * first — an operator fixing the list needs the whole picture in one pass.
14516
+ */
14517
+ async function assertRuleDeviceCompat(rule, listDeviceIdsByCaps, judged = rule.conditions.devices ?? []) {
14518
+ const kind = ruleKindOf(rule);
14519
+ const queries = ruleDeviceCompatCapQueries(kind);
14520
+ if (queries === null || judged.length === 0) return { outcome: "not-judged" };
14521
+ if (listDeviceIdsByCaps === void 0) return {
14522
+ outcome: "unchecked",
14523
+ reason: "unwired",
14524
+ ids: judged
14525
+ };
14526
+ const read = await readCompatibleIds(queries, listDeviceIdsByCaps);
14527
+ if (!read.ok) return {
14528
+ outcome: "unchecked",
14529
+ reason: "read-failed",
14530
+ ids: judged,
14531
+ error: read.error
14532
+ };
14533
+ if (read.ids.size === 0) return {
14534
+ outcome: "unchecked",
14535
+ reason: "answered-empty",
14536
+ ids: judged
14537
+ };
14538
+ const incompatible = judged.filter((id) => !read.ids.has(id));
14539
+ if (incompatible.length > 0) throw new NcDeviceCompatRefusal(kind, incompatible);
14540
+ return { outcome: "compatible" };
14541
+ }
14542
+ /**
14543
+ * Run the check and report what it could not do: a refusal is dropped work, so
14544
+ * it is logged before it is rethrown; an unchecked acceptance is a guard that
14545
+ * did not run, so it is logged (sampled) too.
14546
+ */
14547
+ async function runAndReport(rule, judged, ctx) {
14548
+ let verdict;
14549
+ try {
14550
+ verdict = await assertRuleDeviceCompat(rule, ctx.listDeviceIdsByCaps, judged);
14551
+ } catch (err) {
14552
+ if (err instanceof NcDeviceCompatRefusal) warnRefusal$2(ctx.logger, err, ctx);
14553
+ throw err;
14554
+ }
14555
+ if (verdict.outcome === "unchecked") warnUnchecked(ruleKindOf(rule), verdict, ctx);
14556
+ }
14557
+ function warnRefusal$2(logger, err, ctx) {
14558
+ logger.warn("nc rule refused: device(s) incompatible with the rule kind", { meta: {
14559
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14560
+ by: ctx.by,
14561
+ kind: err.kind,
14562
+ incompatibleIds: [...err.incompatibleIds]
14563
+ } });
14564
+ }
14565
+ function warnUnchecked(kind, verdict, ctx) {
14566
+ const now = (ctx.now ?? Date.now)();
14567
+ const key = `${ctx.ruleId ?? `new:${ctx.by}`}:${verdict.reason}`;
14568
+ if (ctx.sampler !== void 0 && !ctx.sampler.shouldWarn(key, now)) return;
14569
+ ctx.logger.warn("nc rule saved WITHOUT the device-compatibility check — accepted unchecked", { meta: {
14570
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
14571
+ by: ctx.by,
14572
+ kind,
14573
+ reason: verdict.reason,
14574
+ uncheckedIds: [...verdict.ids],
14575
+ ...verdict.error !== void 0 ? { error: verdict.error } : {}
14576
+ } });
14577
+ }
14578
+ /** The create path: the whole include list is judged. */
14579
+ async function guardRuleDeviceCompat(rule, ctx) {
14580
+ await runAndReport(rule, rule.conditions.devices ?? [], ctx);
14581
+ }
14582
+ /**
14583
+ * The update path: the MERGED rule decides the kind, but only the ids the
14584
+ * patch ADDS to `conditions.devices` are judged — a stored id is never
14585
+ * re-judged (see the file header). A patch that adds nothing never pays for a
14586
+ * capability read.
14587
+ */
14588
+ async function guardPatchDeviceCompat(existing, patch, ctx) {
14589
+ if (existing === null) return;
14590
+ const merged = mergedFamilySubject(existing, patch);
14591
+ if (merged === null) return;
14592
+ const stored = new Set(existing.conditions.devices ?? []);
14593
+ await runAndReport(merged, (merged.conditions.devices ?? []).filter((id) => !stored.has(id)), ctx);
14594
+ }
14595
+ //#endregion
14354
14596
  //#region src/notification-center/rule-summary-guard.ts
14355
14597
  /**
14356
14598
  * The hub's half of D647: `systemEventDelivery: { mode: 'summary' }` belongs to
@@ -17195,6 +17437,8 @@ function requireAdmin(caller) {
17195
17437
  return c;
17196
17438
  }
17197
17439
  function makeNcActionHandlers(deps) {
17440
+ /** One sampler per handler set: an unchecked save warns once per rule per window. */
17441
+ const deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
17198
17442
  /** The alarm half, or a refusal by name — never a made-up "disarmed". */
17199
17443
  const requireAlarm = (action) => {
17200
17444
  if (deps.alarm === void 0) throw new Error(`${action}: the alarm is not wired on this node`);
@@ -17352,6 +17596,12 @@ function makeNcActionHandlers(deps) {
17352
17596
  logger: deps.logger,
17353
17597
  by: c.userId
17354
17598
  });
17599
+ await guardRuleDeviceCompat(parsed, {
17600
+ logger: deps.logger,
17601
+ by: c.userId,
17602
+ listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
17603
+ sampler: deviceCompatWarnSampler
17604
+ });
17355
17605
  guardRuleSystemEventDelivery(parsed, {
17356
17606
  logger: deps.logger,
17357
17607
  by: c.userId
@@ -17394,6 +17644,13 @@ function makeNcActionHandlers(deps) {
17394
17644
  by: c.userId,
17395
17645
  ruleId: input.ruleId
17396
17646
  });
17647
+ await guardPatchDeviceCompat(existing, patch, {
17648
+ logger: deps.logger,
17649
+ by: c.userId,
17650
+ ruleId: input.ruleId,
17651
+ listDeviceIdsByCaps: deps.listDeviceIdsByCaps,
17652
+ sampler: deviceCompatWarnSampler
17653
+ });
17397
17654
  guardPatchSystemEventDelivery(existing, patch, clear, {
17398
17655
  logger: deps.logger,
17399
17656
  by: c.userId,
@@ -17895,9 +18152,9 @@ function makeNcActionHandlers(deps) {
17895
18152
  * reproduce.
17896
18153
  */
17897
18154
  var NC_CLIP_DISPATCH_MARGIN_SEC = 10;
17898
- /** Does this rule ever cut footage from the broker's clip ring? */
17899
- function wantsFootage(rule) {
17900
- return rule.media.gif === true || rule.media.clip === true;
18155
+ /** Does this media policy ever cut footage from the broker's clip ring? */
18156
+ function wantsFootage(media) {
18157
+ return media.gif === true || media.clip === true;
17901
18158
  }
17902
18159
  /** The ask ONE rule makes, or `null` when it needs no retained footage. */
17903
18160
  function askSecondsFor(rule) {
@@ -17906,9 +18163,10 @@ function askSecondsFor(rule) {
17906
18163
  seconds: NC_CLIP_RETENTION_MAX_SEC,
17907
18164
  reason: "occupancy"
17908
18165
  };
17909
- if (!wantsFootage(rule)) return null;
17910
- const pre = rule.media.clipPreRollSec ?? NC_CLIP_PRE_ROLL_DEFAULT_SEC;
17911
- const post = rule.media.clipPostRollSec ?? NC_CLIP_POST_ROLL_DEFAULT_SEC;
18166
+ const media = effectiveMediaPolicyForRule(rule);
18167
+ if (!wantsFootage(media)) return null;
18168
+ const pre = media.clipPreRollSec ?? NC_CLIP_PRE_ROLL_DEFAULT_SEC;
18169
+ const post = media.clipPostRollSec ?? NC_CLIP_POST_ROLL_DEFAULT_SEC;
17912
18170
  return {
17913
18171
  seconds: Math.min(pre + post + NC_CLIP_DISPATCH_MARGIN_SEC, NC_CLIP_RETENTION_MAX_SEC),
17914
18172
  reason: "window"
@@ -17927,12 +18185,13 @@ function deriveClipRetentionAsks(rules) {
17927
18185
  for (const rule of rules) {
17928
18186
  const ask = askSecondsFor(rule);
17929
18187
  if (ask === null) continue;
18188
+ const profile = effectiveMediaPolicyForRule(rule).profile;
17930
18189
  const base = {
17931
18190
  seconds: ask.seconds,
17932
18191
  reason: ask.reason,
17933
18192
  ruleId: rule.id,
17934
18193
  ruleName: rule.name,
17935
- ...rule.media.profile !== void 0 ? { profile: rule.media.profile } : {}
18194
+ ...profile !== void 0 ? { profile } : {}
17936
18195
  };
17937
18196
  const devices = rule.conditions.devices;
17938
18197
  if (devices === void 0 || devices.length === 0) {
@@ -19789,6 +20048,114 @@ function ncGroupSubjectText(texts, input) {
19789
20048
  return texts.join([...names, ...others]);
19790
20049
  }
19791
20050
  //#endregion
20051
+ //#region src/notification-center/outbox-key.ts
20052
+ /**
20053
+ * The record kinds whose `recordId` is a per-table SQLite rowid (D474), and so
20054
+ * may be reused after a delete. `device-event` is NOT one: sensor events are
20055
+ * TEXT-keyed with a producer-minted id (`sensor-event-store.ts`).
20056
+ */
20057
+ var ROWID_RECORD_KINDS = new Set([
20058
+ "object-event",
20059
+ "package-event",
20060
+ "audio-event"
20061
+ ]);
20062
+ /** Is this kind's record id a reusable rowid? See {@link ROWID_RECORD_KINDS}. */
20063
+ function isRowidRecordKind(kind) {
20064
+ return ROWID_RECORD_KINDS.has(kind);
20065
+ }
20066
+ /** The record identity an outbox key input speaks for. */
20067
+ function outboxRecordIdentity(input) {
20068
+ return {
20069
+ recordId: input.recordId,
20070
+ deviceId: input.deviceId,
20071
+ recordAt: input.payload.subject.timestamp
20072
+ };
20073
+ }
20074
+ /**
20075
+ * The record GENERATION ref — `<recordId>@<deviceId>:<recordAt>`. Exported for
20076
+ * the specs and for a caller that must name a rowid record outside the outbox
20077
+ * (none today); a key is always built through {@link outboxEntryId}.
20078
+ */
20079
+ function recordGenerationRef(record) {
20080
+ return `${record.recordId}@${String(record.deviceId)}:${String(record.recordAt)}`;
20081
+ }
20082
+ /**
20083
+ * The middle segment of the dedup id:
20084
+ *
20085
+ * 1. an explicit `dedupRef` (upgrade / burst revision);
20086
+ * 2. the track id — `maxPerTrack: 1` (a track id is random, never reused);
20087
+ * 3. for a rowid record, its generation ref;
20088
+ * 4. the record id as is (synthetic ids already carry device + instant).
20089
+ */
20090
+ function outboxDedupRef(input) {
20091
+ if (input.dedupRef !== void 0) return input.dedupRef;
20092
+ if (input.trackId !== void 0) return input.trackId;
20093
+ if (isRowidRecordKind(input.recordKind)) return recordGenerationRef(outboxRecordIdentity(input));
20094
+ return input.recordId;
20095
+ }
20096
+ /** The outbox row id — the durable per-track / per-record fire guarantee. */
20097
+ function outboxEntryId(input) {
20098
+ return `${input.ruleId}:${outboxDedupRef(input)}:${input.targetId}`;
20099
+ }
20100
+ /**
20101
+ * The id this input had BEFORE D694 — but only when it differs from today's,
20102
+ * i.e. for a rowid record keyed on its record id. `null` otherwise: there is no
20103
+ * older spelling to look for.
20104
+ */
20105
+ function legacyOutboxEntryId(input) {
20106
+ if (input.dedupRef !== void 0 || input.trackId !== void 0) return null;
20107
+ if (!isRowidRecordKind(input.recordKind)) return null;
20108
+ return `${input.ruleId}:${input.recordId}:${input.targetId}`;
20109
+ }
20110
+ /**
20111
+ * A STORED row's id as it would be built today, when the row was written under
20112
+ * the pre-D694 spelling; `null` when the stored id is already current (or was
20113
+ * built from an explicit dedup ref, which a row does not record and which never
20114
+ * involved the record id).
20115
+ */
20116
+ function translatedLegacyEntryId(storedId, row) {
20117
+ const legacy = legacyOutboxEntryId(row);
20118
+ if (legacy === null || legacy !== storedId) return null;
20119
+ return outboxEntryId(row);
20120
+ }
20121
+ /**
20122
+ * Does a STORED row's payload really carry its record timestamp?
20123
+ *
20124
+ * The type says it always does, but a stored payload is checked only as an
20125
+ * object on the way back from the store (`rowToEntry`), so a malformed or very
20126
+ * old row can lack `subject`. Every read of {@link outboxRecordIdentity} on a
20127
+ * STORED row is preceded by this check. Takes the payload as `unknown` on
20128
+ * purpose: the static type cannot answer the question.
20129
+ */
20130
+ function hasRecordTimestamp(payload) {
20131
+ if (typeof payload !== "object" || payload === null || !("subject" in payload)) return false;
20132
+ const subject = payload.subject;
20133
+ return typeof subject === "object" && subject !== null && "timestamp" in subject && typeof subject.timestamp === "number";
20134
+ }
20135
+ /** Do two rows speak for the same RECORD? Id, camera and record timestamp. */
20136
+ function isSameOutboxRecord(a, b) {
20137
+ return a.recordId === b.recordId && a.deviceId === b.deviceId && a.recordAt === b.recordAt;
20138
+ }
20139
+ /**
20140
+ * The scope of the event a payload's `eventId` names.
20141
+ *
20142
+ * Usually the row's own subject. A COMBINED alarm row is the exception: its
20143
+ * subject is the alarm transition (its own instant), while its `eventId` is the
20144
+ * triggering rule's event — so the generation is the alarm SOURCE's camera and
20145
+ * timestamp (`NcRuleFireRef`), never the transition's.
20146
+ */
20147
+ function eventMediaScopeOf(subject) {
20148
+ const source = subject.systemEvent?.alarmSource;
20149
+ if (source !== void 0) return {
20150
+ deviceId: source.deviceId,
20151
+ recordAt: source.timestamp
20152
+ };
20153
+ return {
20154
+ deviceId: subject.deviceId,
20155
+ recordAt: subject.timestamp
20156
+ };
20157
+ }
20158
+ //#endregion
19792
20159
  //#region src/notification-center/footage-source.ts
19793
20160
  /**
19794
20161
  * Decide where this window must be cut from. Pure and total.
@@ -21754,7 +22121,7 @@ var NcDispatcher = class {
21754
22121
  });
21755
22122
  if (policy === "keyFrame" || frame === "full" || frame === "boxed") owners.reverse();
21756
22123
  for (const owner of owners) try {
21757
- const files = await this.deps.getMediaForOwner(owner.kind, owner.id);
22124
+ const files = owner.kind === "object" ? await this.deps.getMediaForOwner(owner.kind, owner.id, eventMediaScopeOf(subject)) : await this.deps.getMediaForOwner(owner.kind, owner.id);
21758
22125
  if (files.length === 0) continue;
21759
22126
  const preference = frame !== void 0 ? framePreference(frame, owner.kind) : policy === "best-matching" ? bestMatchingKindPreference(signal, owner.kind) : attachmentKindPreference(policy, owner.kind);
21760
22127
  const ordered = [];
@@ -24039,11 +24406,6 @@ var DEFAULT_MAX_ATTEMPTS = 8;
24039
24406
  var DEFAULT_DRAIN_BATCH = 20;
24040
24407
  /** Dedup ids remembered at boot — bounded to recent rows (dedup horizon). */
24041
24408
  var DEDUP_HYDRATE_WINDOW_MS = 168 * 36e5;
24042
- /** Stable dedup id — the durable per-track/per-record fire guarantee. */
24043
- function outboxEntryId(input) {
24044
- const dedupRef = input.dedupRef ?? input.trackId ?? input.recordId;
24045
- return `${input.ruleId}:${dedupRef}:${input.targetId}`;
24046
- }
24047
24409
  var NcOutbox = class {
24048
24410
  pending = /* @__PURE__ */ new Map();
24049
24411
  /** Every id ever enqueued within the dedup horizon (incl. sent/dead). */
@@ -24120,20 +24482,27 @@ var NcOutbox = class {
24120
24482
  }
24121
24483
  });
24122
24484
  let superseded = 0;
24485
+ let translated = 0;
24123
24486
  for (const row of rows) {
24124
- if (this.knownIds.has(row.id)) {
24487
+ const entry = rowToEntry$1(row.id, row.data);
24488
+ const current = entry === null ? null : this.translateLegacy(row.id, entry);
24489
+ if (this.knownIds.has(row.id) || current !== null && this.knownIds.has(current)) {
24125
24490
  superseded += 1;
24126
24491
  continue;
24127
24492
  }
24128
24493
  this.knownIds.add(row.id);
24129
- const entry = rowToEntry$1(row.id, row.data);
24494
+ if (current !== null) {
24495
+ this.knownIds.add(current);
24496
+ translated += 1;
24497
+ }
24130
24498
  if (entry !== null && entry.status === "pending") this.pending.set(entry.id, entry);
24131
24499
  }
24132
24500
  this.horizonLoaded = true;
24133
24501
  this.logger.info("notification outbox loaded", { meta: {
24134
24502
  pending: this.pending.size,
24135
24503
  known: this.knownIds.size,
24136
- superseded
24504
+ superseded,
24505
+ translated
24137
24506
  } });
24138
24507
  } catch (err) {
24139
24508
  this.logger.warn("notification outbox load failed", { meta: { error: String(err) } });
@@ -24157,7 +24526,7 @@ var NcOutbox = class {
24157
24526
  for (const input of inputs) {
24158
24527
  const id = outboxEntryId(input);
24159
24528
  if (this.knownIds.has(id)) continue;
24160
- if (!this.horizonLoaded && await this.rowExists(id)) {
24529
+ if (!this.horizonLoaded && await this.alreadyStored(id, input)) {
24161
24530
  this.knownIds.add(id);
24162
24531
  this.logger.info("outbox enqueue deduped against the store — the horizon is still cold", {
24163
24532
  tags: { deviceId: input.deviceId },
@@ -24374,7 +24743,7 @@ var NcOutbox = class {
24374
24743
  */
24375
24744
  async supersede(input) {
24376
24745
  const wanted = new Set(input.targetIds);
24377
- const victims = [...this.pending.values()].filter((e) => e.ruleId === input.ruleId && e.recordId === input.recordId && wanted.has(e.targetId));
24746
+ const victims = [...this.pending.values()].filter((e) => e.ruleId === input.ruleId && wanted.has(e.targetId) && hasRecordTimestamp(e.payload) && isSameOutboxRecord(outboxRecordIdentity(e), input));
24378
24747
  let dropped = 0;
24379
24748
  for (const entry of victims) try {
24380
24749
  await this.store.delete.mutate({
@@ -24490,6 +24859,81 @@ var NcOutbox = class {
24490
24859
  await this.mutate(retry);
24491
24860
  }
24492
24861
  /**
24862
+ * Cold-horizon dedup: is this intent already a row in the store?
24863
+ *
24864
+ * Today's id first. Then, for a rowid record, the pre-D694 spelling — which
24865
+ * is accepted ONLY when the row found speaks for the SAME record (camera and
24866
+ * record timestamp too). A legacy row of a different record that reused the
24867
+ * rowid is exactly the collision D694 removes, and must not swallow this one.
24868
+ */
24869
+ async alreadyStored(id, input) {
24870
+ if (await this.rowExists(id)) return true;
24871
+ const legacy = legacyOutboxEntryId(input);
24872
+ if (legacy === null) return false;
24873
+ const row = await this.readRow(legacy);
24874
+ if (row === null) return false;
24875
+ if (!hasRecordTimestamp(row.payload)) {
24876
+ this.logger.warn("outbox: a pre-D694 row carries no record timestamp — not used for dedup", {
24877
+ tags: { deviceId: row.deviceId },
24878
+ meta: {
24879
+ id,
24880
+ legacyId: legacy,
24881
+ ruleId: input.ruleId
24882
+ }
24883
+ });
24884
+ return false;
24885
+ }
24886
+ if (isSameOutboxRecord(outboxRecordIdentity(row), outboxRecordIdentity(input))) return true;
24887
+ this.logger.info("outbox: a pre-D694 row shares this rowid but is a different record — not a duplicate", {
24888
+ tags: { deviceId: input.deviceId },
24889
+ meta: {
24890
+ id,
24891
+ legacyId: legacy,
24892
+ legacyDeviceId: row.deviceId,
24893
+ ruleId: input.ruleId
24894
+ }
24895
+ });
24896
+ return false;
24897
+ }
24898
+ /**
24899
+ * The id a stored pre-D694 row would have today, or `null`.
24900
+ *
24901
+ * Guarded per row: a stored payload is validated only as an object
24902
+ * (`rowToEntry`), and a row with no `subject.timestamp` would otherwise throw
24903
+ * inside {@link load} and leave the WHOLE dedup horizon cold. Such a row is
24904
+ * still loaded under its stored id; it just gets no translated twin.
24905
+ */
24906
+ translateLegacy(storedId, entry) {
24907
+ if (legacyOutboxEntryId(entry) !== storedId) return null;
24908
+ if (!hasRecordTimestamp(entry.payload)) {
24909
+ this.logger.warn("outbox: a pre-D694 row carries no record timestamp — loaded without translation", {
24910
+ tags: { deviceId: entry.deviceId },
24911
+ meta: {
24912
+ id: storedId,
24913
+ ruleId: entry.ruleId
24914
+ }
24915
+ });
24916
+ return null;
24917
+ }
24918
+ return translatedLegacyEntryId(storedId, entry);
24919
+ }
24920
+ /** One stored row by id, or `null` (absent, unreadable, or a failed read). */
24921
+ async readRow(id) {
24922
+ try {
24923
+ const row = await this.store.get.query({
24924
+ collection: NC_OUTBOX_COLLECTION,
24925
+ key: id
24926
+ });
24927
+ return isStoredRow(row) ? rowToEntry$1(id, row) : null;
24928
+ } catch (err) {
24929
+ this.logger.debug("outbox cold-horizon dedup read failed", { meta: {
24930
+ id,
24931
+ error: String(err)
24932
+ } });
24933
+ return null;
24934
+ }
24935
+ }
24936
+ /**
24493
24937
  * Does a row already exist under this dedup id?
24494
24938
  *
24495
24939
  * Asked ONLY while {@link horizonLoaded} is false. {@link persist} writes
@@ -24583,6 +25027,10 @@ var OUTBOX_RECORD_KINDS = new Set([
24583
25027
  function isOutboxRecordKind(x) {
24584
25028
  return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
24585
25029
  }
25030
+ /** `settingsStore.get` answers `unknown` — a guard, never a cast. */
25031
+ function isStoredRow(x) {
25032
+ return typeof x === "object" && x !== null && !Array.isArray(x);
25033
+ }
24586
25034
  /** A stored row back to an entry — a guard, never a cast. Exported for the
24587
25035
  * specs that read the store a centre wrote to. */
24588
25036
  function rowToEntry$1(id, data) {
@@ -31546,9 +31994,15 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
31546
31994
  * A rule's media policy, as the fields a row freezes — ONE derivation, used by
31547
31995
  * every row a rule addresses AND by the alarm's own announcement row when it is
31548
31996
  * the COMBINED siren (the triggering rule's pictures on the alarm's words).
31997
+ *
31998
+ * `effectiveMediaPolicyForRule` is what actually resolves it: a SENSOR rule's
31999
+ * stored `media` is ignored here, in favour of the one-snapshot default
32000
+ * (operator ruling, 2026-10-01) — the rule still LOADS with whatever it has
32001
+ * stored, and `buildEntries` is where the sampled log fires when that stored
32002
+ * choice is about to be overridden.
31549
32003
  */
31550
32004
  function mediaPolicyFields(mediaRule, cropZoneIds) {
31551
- const media = mediaRule.media;
32005
+ const media = effectiveMediaPolicyForRule(mediaRule);
31552
32006
  return {
31553
32007
  media: media.attach,
31554
32008
  ...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
@@ -31842,13 +32296,14 @@ function cooldownSubjectKind(kind) {
31842
32296
  * the same builder the live path does, so the two can never address different
31843
32297
  * windows. Only the four fields the key is a function of are reconstructed —
31844
32298
  * the class (for `per-class` and audio), the subject kind (which decides
31845
- * whether audio is keyed per class at all), the system event (subject +
32299
+ * whether the class is keyed at all — audio always, occupancy and detections
32300
+ * unless `shared`), the system event (subject +
31846
32301
  * direction) and the device.
31847
32302
  */
31848
32303
  function cooldownSubjectFromOutbox(entry) {
31849
32304
  const subject = entry.payload.subject;
31850
32305
  return {
31851
- kind: cooldownSubjectKind(entry.recordKind),
32306
+ kind: entry.recordKind === "device-event" && subject.occupancy !== void 0 ? "occupancy-event" : cooldownSubjectKind(entry.recordKind),
31852
32307
  recordId: entry.recordId,
31853
32308
  deviceId: entry.deviceId,
31854
32309
  timestamp: subject.timestamp,
@@ -31927,6 +32382,9 @@ function renderAudioScope(devices, excluded) {
31927
32382
  if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
31928
32383
  return devices.filter((id) => !excluded.includes(id)).join("+");
31929
32384
  }
32385
+ /** {@link NotificationCenter.reportedSensorMediaIgnored} cap, same shape as
32386
+ * the dispatcher's own annotated-fallback memo. */
32387
+ var NC_SENSOR_MEDIA_IGNORED_MEMO_MAX = 5e3;
31930
32388
  var NotificationCenter = class NotificationCenter {
31931
32389
  logger;
31932
32390
  rules;
@@ -31934,6 +32392,11 @@ var NotificationCenter = class NotificationCenter {
31934
32392
  dispatcher;
31935
32393
  deps;
31936
32394
  now;
32395
+ /** Rule ids whose stored (non-default) media policy has already been
32396
+ * reported as ignored at evaluation — ONE line per rule, not per fire. */
32397
+ reportedSensorMediaIgnored = /* @__PURE__ */ new Set();
32398
+ /** Samples the device-compat guard's "accepted unchecked" WARN per rule. */
32399
+ deviceCompatWarnSampler = new NcDeviceCompatWarnSampler();
31937
32400
  /**
31938
32401
  * Every word this centre can print, in the hub's language.
31939
32402
  *
@@ -32934,7 +33397,7 @@ var NotificationCenter = class NotificationCenter {
32934
33397
  ...deps.dispatcher,
32935
33398
  logger: this.logger.child("dispatch"),
32936
33399
  texts: this.texts,
32937
- getMediaForOwner: async (ownerKind, ownerId) => {
33400
+ getMediaForOwner: async (ownerKind, ownerId, eventScope) => {
32938
33401
  const held = isSyntheticId(ownerId) ? this.syntheticMedia.get(ownerId) : void 0;
32939
33402
  if (held !== void 0) return held.files;
32940
33403
  const shelved = isStillShelfId(ownerId) ? this.stills?.get(ownerId) : void 0;
@@ -32943,7 +33406,7 @@ var NotificationCenter = class NotificationCenter {
32943
33406
  if (linked !== void 0) return linked;
32944
33407
  const departed = ownerKind === "track" && this.departureFallbacks.has(ownerId);
32945
33408
  const owner = departed ? await this.resolveDepartedTrack(ownerId) : ownerId;
32946
- const files = await deps.dispatcher.getMediaForOwner(ownerKind, owner);
33409
+ const files = await deps.dispatcher.getMediaForOwner(ownerKind, owner, eventScope);
32947
33410
  if (files.length > 0 || !departed) return files;
32948
33411
  return this.departureFallbackMedia(ownerId) ?? files;
32949
33412
  },
@@ -33714,7 +34177,7 @@ var NotificationCenter = class NotificationCenter {
33714
34177
  const shown = this.unmutedCameras(cameraIds);
33715
34178
  const involved = [sensorId, ...rowShown];
33716
34179
  if (!this.rules.listEnabled("device-event").some((rule) => {
33717
- if (rule.media.attach === "none") return false;
34180
+ if (effectiveMediaPolicyForRule(rule).attach === "none") return false;
33718
34181
  if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
33719
34182
  const excluded = rule.conditions.devicesExclude;
33720
34183
  if (excluded !== void 0 && deviceScopeExcludes(excluded, {
@@ -34011,7 +34474,7 @@ var NotificationCenter = class NotificationCenter {
34011
34474
  ruleWantsOccupancyStill(edge) {
34012
34475
  const subject = occupancySubjectFromEdge(edge);
34013
34476
  for (const rule of this.rules.listEnabled("device-event")) {
34014
- if (rule.media.attach === "none") continue;
34477
+ if (effectiveMediaPolicyForRule(rule).attach === "none") continue;
34015
34478
  const occ = rule.conditions.occupancy;
34016
34479
  if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
34017
34480
  const devices = rule.conditions.devices;
@@ -34278,6 +34741,12 @@ var NotificationCenter = class NotificationCenter {
34278
34741
  logger: this.logger,
34279
34742
  by: caller.userId
34280
34743
  });
34744
+ await guardRuleDeviceCompat(rule, {
34745
+ logger: this.logger,
34746
+ by: caller.userId,
34747
+ listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
34748
+ sampler: this.deviceCompatWarnSampler
34749
+ });
34281
34750
  guardRuleSystemEventDelivery(rule, {
34282
34751
  logger: this.logger,
34283
34752
  by: caller.userId
@@ -34315,6 +34784,13 @@ var NotificationCenter = class NotificationCenter {
34315
34784
  by: caller.userId,
34316
34785
  ruleId
34317
34786
  });
34787
+ await guardPatchDeviceCompat(existing, patch, {
34788
+ logger: this.logger,
34789
+ by: caller.userId,
34790
+ ruleId,
34791
+ listDeviceIdsByCaps: this.deps.listDeviceIdsByCaps,
34792
+ sampler: this.deviceCompatWarnSampler
34793
+ });
34318
34794
  guardPatchSystemEventDelivery(existing, patch, clear ?? [], {
34319
34795
  logger: this.logger,
34320
34796
  by: caller.userId,
@@ -35077,6 +35553,8 @@ var NotificationCenter = class NotificationCenter {
35077
35553
  const dropped = await this.outbox.supersede({
35078
35554
  ruleId: source.ruleId,
35079
35555
  recordId: source.recordId,
35556
+ deviceId: source.deviceId,
35557
+ recordAt: source.timestamp,
35080
35558
  targetIds: [...combined]
35081
35559
  });
35082
35560
  this.logger.info("alarm notification combined with the rule that triggered it", {
@@ -35362,7 +35840,7 @@ var NotificationCenter = class NotificationCenter {
35362
35840
  buttons: unresolvable.join(", ")
35363
35841
  }
35364
35842
  });
35365
- if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && rule.media.attach !== "none") {
35843
+ if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && subject.linkedStills === void 0 && effectiveMediaPolicyForRule(rule).attach !== "none") {
35366
35844
  noOwnerReported = true;
35367
35845
  const linked = subject.linkedCameraIds;
35368
35846
  const meta = {
@@ -35740,6 +36218,24 @@ var NotificationCenter = class NotificationCenter {
35740
36218
  }
35741
36219
  });
35742
36220
  }
36221
+ /**
36222
+ * ONE line per rule id, the first time a SENSOR rule's stored media policy
36223
+ * is about to be overridden by {@link effectiveMediaPolicyForRule}'s
36224
+ * one-snapshot default (operator ruling, 2026-10-01). Never refuses the
36225
+ * rule — it still loads and still fires — this only says the stored choice
36226
+ * is not the one being used.
36227
+ */
36228
+ warnSensorMediaIgnoredOnce(rule) {
36229
+ if (mediaPolicyMatchesSensorDefault(rule.media)) return;
36230
+ if (this.reportedSensorMediaIgnored.has(rule.id)) return;
36231
+ if (this.reportedSensorMediaIgnored.size >= NC_SENSOR_MEDIA_IGNORED_MEMO_MAX) this.reportedSensorMediaIgnored.clear();
36232
+ this.reportedSensorMediaIgnored.add(rule.id);
36233
+ this.logger.info("sensor rule carries a media choice — ignored at evaluation, one snapshot is always attached", { meta: {
36234
+ ruleId: rule.id,
36235
+ rule: rule.name,
36236
+ storedMedia: rule.media
36237
+ } });
36238
+ }
35743
36239
  buildEntries(rule, subject, kind, matchedOn, userTargets = [], origin = "pipeline", cooldown, upgrade, group) {
35744
36240
  const hasEventMedia = kind === "object-event" || kind === "package-event";
35745
36241
  const isTrackScoped = kind === "object-event" || kind === "track-end";
@@ -35747,6 +36243,7 @@ var NotificationCenter = class NotificationCenter {
35747
36243
  const alarmSource = subject.systemEvent?.alarmSource;
35748
36244
  const mediaRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) ?? rule : rule;
35749
36245
  const noMedia = isSystemEvent && alarmSource === void 0;
36246
+ if (ruleKindOf(mediaRule) === "sensor") this.warnSensorMediaIgnoredOnce(mediaRule);
35750
36247
  const direct = new Set(rule.targets.map((t) => t.targetId));
35751
36248
  const renderedZones = notificationZones(rule, subject);
35752
36249
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
@@ -95727,6 +96224,30 @@ async function encodeClipQuery(encode, text, modelId, warn) {
95727
96224
  }
95728
96225
  }
95729
96226
  //#endregion
96227
+ //#region src/pipeline-analytics/nc-owner-media-reader.ts
96228
+ /** Keep only the rows written for THIS generation of the event id. */
96229
+ function ofGeneration(files, scope) {
96230
+ return files.filter((f) => f.timestamp === scope.recordAt);
96231
+ }
96232
+ function makeNcOwnerMediaReader(deps) {
96233
+ return async (ownerKind, ownerId, eventScope) => {
96234
+ if (ownerKind === "track") return deps.media.listByOwner("track", await deps.resolveTrackOwnerId(ownerId));
96235
+ if (eventScope === void 0) return deps.media.listByOwner("object", ownerId);
96236
+ const onCamera = await deps.media.listByOwner("object", ownerId, eventScope.deviceId);
96237
+ const kept = ofGeneration(onCamera, eventScope);
96238
+ if (kept.length < onCamera.length) deps.logger.warn("event media of another record on a reused event id — not used", {
96239
+ tags: { deviceId: eventScope.deviceId },
96240
+ meta: {
96241
+ ownerId,
96242
+ recordAt: eventScope.recordAt,
96243
+ refused: onCamera.length - kept.length,
96244
+ kept: kept.length
96245
+ }
96246
+ });
96247
+ return kept;
96248
+ };
96249
+ }
96250
+ //#endregion
95730
96251
  //#region src/pipeline-analytics/store/settings-raw-mirror.ts
95731
96252
  /** How long a mirrored entry is served before a refresh is started behind the
95732
96253
  * next reader. See the module doc for why this matches the per-kind caches'
@@ -98899,6 +99420,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
98899
99420
  listCallerTargetIds: async (userId) => {
98900
99421
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
98901
99422
  },
99423
+ listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
98902
99424
  dropRuleMosaics: async (ruleId) => {
98903
99425
  const mediaStore = this.mediaStore;
98904
99426
  if (mediaStore === null) return;
@@ -99815,6 +100337,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
99815
100337
  addonId: device.addonId
99816
100338
  };
99817
100339
  },
100340
+ listDeviceIdsByCaps: (capNames) => api.deviceManager.listDeviceIdsByCaps.query({ capNames: [...capNames] }),
99818
100341
  isDeviceDeleted: async (deviceId) => await api.deviceManager.getDevice.query({ deviceId }) === null,
99819
100342
  readNodeDirectory: () => api.nodes.directory.query(),
99820
100343
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
@@ -100049,7 +100572,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
100049
100572
  }
100050
100573
  });
100051
100574
  },
100052
- getMediaForOwner: async (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, await resolveMediaOwnerId(ownerKind, ownerId, (id) => this.trackAliasStore?.resolve(id) ?? Promise.resolve(null), (failure) => this.aliasResolveFailureLogger?.(failure), "getMediaForOwner")),
100575
+ getMediaForOwner: makeNcOwnerMediaReader({
100576
+ media: stores.mediaStore,
100577
+ resolveTrackOwnerId: (trackId) => resolveMediaOwnerId("track", trackId, (id) => this.trackAliasStore?.resolve(id) ?? Promise.resolve(null), (failure) => this.aliasResolveFailureLogger?.(failure), "getMediaForOwner"),
100578
+ logger: this.ctx.logger.child("nc-media")
100579
+ }),
100053
100580
  getSubjectTrail: async (deviceId, trackId) => {
100054
100581
  const dims = this.lastFrameDimsByDevice.get(deviceId);
100055
100582
  if (dims === void 0 || dims.w <= 0 || dims.h <= 0) return [];