//* 알림 기반 공용 타입 — 규칙 레지스트리(D2) · finding(D7) · 억제 정책(D3) · 공용 설정 키(R7)

export const ALERT_RULE = Symbol('ALERT_RULE'); //* multi-provider 토큰

//* 규칙이 어떤 억제 원칙을 쓸지 선언한다(D3). 원칙 자체는 alert-noise.ts 의 순수 함수.
export interface NoisePolicy {
  ratio?: boolean; //* 원칙 1 — 표본 하한 + 실패율. finding.sample 이 있어야 동작
  persistence?: boolean; //* 원칙 2 — 연속 N회 잡혀야 발송
  severitySplit?: boolean; //* 원칙 3 — warn 은 즉시발송 대신 다이제스트로
  group?: boolean; //* 원칙 4 — 동시 finding 을 메시지 1건으로 묶음
  /**
   * 복구(`✅ [복구]`) 발송을 규칙 단위로 끈다(M9 D8).
   *
   * **1회성 사건의 복구 메시지는 거짓 정보다.** 고위험 관리자 행위처럼 사건이 창을 벗어나면
   * 자동으로 사라지는 규칙은, 다음 회차의 '안 잡힘' 이 '해소됐다' 가 아니라 '시간이 지났다' 다.
   * 그 자리에 복구를 쏘면 조사자가 조치된 것으로 읽는다.
   *
   * **부재는 지금 동작이다** — 선언하지 않은 규칙은 복구를 그대로 보낸다. 지속성 있는 사건
   * (DB 연결·5xx 비율 등)에서 복구는 참이고, 이 칸이 그쪽에 번지면 안 된다.
   */
  suppressRecovery?: boolean;
}

//* 규칙 1건이 만들어내는 경고 1건. 억제 키 단위는 (finding × 목적지)다(D7).
export interface AlertFinding {
  key: string; //* 안정 식별자. 진료 규칙은 경고 문구(w.text) — 기존 키 의미 보존
  level: 'critical' | 'warn';
  text: string; //* 사람이 읽는 본문
  channelHint?: string; //* 벤더 키 — tn_alert_channel 우선 매칭(D7)
  legacyUrls?: string[]; //* 구 설정에서 온 목적지. 채널 매칭 결과와 합집합(D7)
  sample?: { total: number; failed: number }; //* 원칙 1 입력(선택). M1 실사용 0(D10-b)
}

//* 설정 값의 런타임 타입. 'json' 은 원문 문자열로 오간다 — 파싱은 읽는 쪽이 한다(M2 D9).
export type SettingValue = number | boolean | string;

/**
 * 설정 값 하나가 어떤 자료형인지. 화면이 키 이름으로 분기하지 않게 하는 유일한 근거다 —
 * 키를 화면에 박으면 설정이 하나 늘 때 조용히 ms 원값으로 돌아간다(INV4 · 무재배포 조정 M11).
 */
export type SettingShape = 'int' | 'ms' | 'ratio' | 'string[]' | 'object';

/** 값이 객체일 때 그 안의 칸 하나. 화면이 `minSample`·`threshold` 를 하드코딩하지 않게 한다. */
export interface AlertSettingField {
  key: string;
  label: string;
  shape: 'int' | 'ratio';
}

/**
 * KEY/VALUE 편집기가 낼 키 후보 1건. **항목마다 자료형이 다를 수 있다** — `derivation` 은
 * 숫자 3 · `string[]` 1 · 중첩 객체 1 이라 설정 전체의 `valueShape` 하나로는 표현되지 않는다.
 */
export interface AlertSettingKeyOption {
  key: string;
  label: string;
  description: string;
  shape: SettingShape;
  //* shape==='object' 일 때 안의 칸을 메타가 직접 말한다. 산문에만 있으면 편집기가 키 이름을 박는다.
  fields?: AlertSettingField[];
}

//* 규칙 전용 설정 메타 — 키·기본값·검증범위를 규칙이 직접 들고 온다(D2).
//*  관리자 API(GET /rules)와 화면 폼이 이 메타에서 파생되므로 서비스·DTO 에 규칙 이름이 생기지 않는다.
export interface AlertRuleSetting {
  key: string;
  label: string;
  //* 'json' 은 M2 에서 추가(D9) — min/max 는 적용되지 않는다.
  type: 'int' | 'float' | 'bool' | 'json';
  default: SettingValue;
  min?: number;
  max?: number;
  /**
   * 저장 단위. 화면은 이 값으로만 표시 단위를 정한다 — `ms` 는 초로, `ratio` 는 % 로 낸다.
   * 없으면 저장값을 그대로 낸다. 라벨 문자열 `(ms)`·`(0~1)` 이 지던 역할을 여기가 받는다.
   */
  unit?: 'ms' | 'ratio' | 'count' | 'day' | 'multiplier';
  //* 개발자가 볼 설명. 표면 라벨은 짧게 두고 이 문장이 상세(AlertInfo) 안으로 들어간다.
  description?: string;
  //* type==='json' 일 때 KEY 목록. 열린 집합에서 런타임 파생 키는 컨트롤러가 채운다.
  keyOptions?: AlertSettingKeyOption[];
  //* 'fixed' 면 키를 더하거나 지울 수 없다 — 직접 입력도 막는다(산출 서명이 그 경우다).
  keySet?: 'open' | 'fixed';
  //* 값이 스칼라인가 객체인가. 객체면 `valueFields` 가 그 칸을 말한다.
  valueShape?: 'scalar' | 'object';
  //* valueShape==='object' 일 때 값 객체의 칸. 키가 런타임 파생이어도 값의 모양은 정적이다.
  valueFields?: AlertSettingField[];
}

export interface AlertContext {
  now: number; //* 판정 기준 시각(ms). 규칙은 Date.now() 를 직접 부르지 않는다
  settings: Record<string, SettingValue>; //* 이 규칙의 설정(rule.settings 에서 파생)
  //* 이번 회차의 리스 토큰. 규칙이 상태를 쓸 일이 있으면 반드시 이 값을 실어 조건부로 써야 한다
  //*  (M1 §D1-b). 대부분의 규칙은 상태를 쓰지 않으므로 쓰지 않는다.
  runId: string;
}

export interface AlertRule {
  readonly id: string; //* 억제 키 접두 · 설정 키 접두 · 채널 types 값을 겸한다. /^[a-z0-9-]+$/
  readonly name: string;
  readonly severity: 'critical' | 'warn';
  readonly noise: NoisePolicy;
  readonly settings: ReadonlyArray<AlertRuleSetting>;
  /**
   * 테스트 발송이 쓸 **대표 문면**. 자기 `evaluate` 가 만드는 문장을 대표 수치로 채운 리터럴이다.
   *
   * 선택 필드다 — 없으면 `${name} — 채널 연결 확인` 으로 떨어지고 테스트 배너는 그대로 붙는다.
   * M6("신규 알림 타입 추가가 등록 1건")을 필수 필드로 깨지 않기 위해서다. 다만 **지금 등록된
   * 전 규칙이 갖는다**는 것은 계약 spec 이 레지스트리를 전수해 확인한다.
   *
   * **이것은 재사용이 아니라 복제다.** 규칙 6종의 `text:` 생성 분기는 실측 16자리이고 이 필드는
   * 규칙당 대표 하나다 — `evaluate()` 문면이 바뀌어도 이 값은 낡은 채 계약이 통과한다.
   * 값싼 방어 하나만 건다: 계약이 첫 토큰의 수준 표지(`🔴 [위험]` · `🟠 [주의]`) 일치를 본다.
   */
  readonly sampleText?: string;
  evaluate(ctx: AlertContext): Promise<AlertFinding[]>;
}

//* 규칙 id 규약 — 설정 키 접두와 채널 types 값을 겸하므로 구분자를 허용하지 않는다(D7).
export const RULE_ID_PATTERN = /^[a-z0-9-]+$/;

//* 억제 엔트리(2상). 키는 유일성 비교에만 쓰고 절대 split 하지 않는다 — 목적지는 값에 담는다(D7).
export interface SuppressEntry {
  t: number; //* 기록 시각(ms)
  ok: boolean; //* 그 목적지가 실제로 이 경고를 받았는가. false 면 복구를 보내지 않는다
  url: string; //* 복구 발송 목적지. 이관된 구 키는 '' 이며 복구 대상이 아니다
  //* 복구 메시지 본문. 계획서 SuppressEntry 에는 없었으나 url 과 같은 이유로 값에 담아야 한다 —
  //*  키를 절대 파싱하지 않기로 한 이상 복구 본문도 키에서 꺼낼 수 없다(구현 시 확장).
  text: string;
  //* **최초** 발화 시각(ms). 해소 문구의 "N분 만에 해소"가 이 값에서 나온다.
  //*  `t` 로 대신할 수 없다 — `t` 는 마지막 **발송** 시각이라 중복억제 창(`alert_dedupe_ms`)마다
  //*  갱신되고, 그러면 3시간짜리 장애가 "1분 만에 해소"로 나간다. 0 이면 미상(구 엔트리)이며
  //*  그 경우 문구를 생략한다.
  since: number;
}

export type AlertState = Record<string, SuppressEntry>;

//--------------------- 설정 키 (KV: tn_alert_setting) ---------------------//

//* 공용 노브 — 재배포 없이 바뀐다(R7 · M11). 규칙 내부 임계는 범위 밖(D10-b).
export const SETTING_KEY = {
  enabled: 'alert_enabled',
  dedupeMs: 'alert_dedupe_ms',
  pollIntervalMs: 'alert_poll_interval_ms',
  persistenceStreak: 'alert_persistence_streak',
  leaseTtlMs: 'alert_lease_ttl_ms',
  //--- M2 ---//
  digestIntervalMs: 'alert_digest_interval_ms', //* warn 다이제스트 발송 간격(D5)
  snapshotTtlMs: 'alert_snapshot_ttl_ms', //* DB 폴백용 last-known-good 스냅샷 수명(D7)
  scheduleExpect: 'alert_schedule_expect', //* 스케줄러별 기대 주기(ms) JSON(D4)
  fallbackStreak: 'alert_fallback_streak', //* DB 폴백 전용 지속성 임계(D7 R2-F2)
  //--- M3 ---//
  staleGrace: 'alert_stale_grace', //* `dead`·규칙 `stale` 임계 배수(M3 §D2)
  historyRetentionDays: 'alert_history_retention_days', //* 발송 이력 보존일(M3 §D1)
} as const;

/**
 * 공용 노브의 공통 접두 — `getCommon()` 이 키마다 왕복하지 않고 한 번에 읽는 근거다.
 * 런타임 상태는 `alert:` (콜론)라 이 접두에 걸리지 않는다. 새 공용 키는 반드시 `alert_` 로 시작해야
 * 하며, 그렇지 않으면 일괄 조회에서 빠져 조용히 기본값으로 떨어진다.
 */
export const COMMON_SETTING_PREFIX = 'alert_';

//* 규칙 ON/OFF — `alert_rule_enabled:<ruleId>`. 값 '0' 이면 그 규칙을 평가하지 않는다.
export const RULE_ENABLED_PREFIX = 'alert_rule_enabled:';

/**
 * 스누즈 — 카탈로그 밖 KV 다(M3 §D7). `RULE_ENABLED_PREFIX` 와 같은 이유로 `patch()` 를 타지 않는다:
 * 카탈로그는 설정 폼의 소스라 epoch 필드가 폼에 노출되고, 상대시간(분) → 절대시각 변환은
 * 클라이언트 시계를 신뢰하지 않으려면 서버가 해야 한다.
 *
 * `SNOOZE_RULE_PREFIX` 가 `SNOOZE_GLOBAL_KEY + ':'` 라 전역 키는 접두 조회에 걸리지 않는다
 * (`getSettingsByPrefix('alert_snooze:')` 는 `alert_snooze` 를 매칭하지 않는다).
 */
export const SNOOZE_GLOBAL_KEY = 'alert_snooze';
export const SNOOZE_RULE_PREFIX = 'alert_snooze:';

//* 스누즈 상한 24시간(§D7). 무기한 침묵 경로를 만들지 않는다 — 끄려면 `alert_enabled` 를 쓴다.
export const SNOOZE_MAX_MINUTES = 24 * 60;

//* 런타임 상태 — 설정 화면에 노출하지 않는다.
export const STATE_KEY = {
  lease: 'alert:lease',
  suppress: 'alert:state',
  streak: 'alert:streak',
  digest: 'alert:digest',
  lastRunAt: 'alert:lastRunAt',
  lastSentAt: 'alert:lastSentAt',
  sendFailureCount: 'alert:sendFailureCount',
  droppedLegacyUrlCount: 'alert:droppedLegacyUrlCount',
  leaseHealthy: 'alert:leaseHealthy',
  //--- M2 ---//
  digestSentAt: 'alert:digestSentAt', //* 다이제스트 마지막 발송 시각(D5)
  hbPrefix: 'alert:hb:', //* 스케줄러 하트비트 — 각 스케줄러가 리스 밖에서 쓴다(D4)
  hbSeenPrefix: 'alert:hbSeen:', //* 하트비트 최초 관측 — 첫 배포 오탐 유예(D4)
  //--- 감사 M5 ---//
  hbFailPrefix: 'alert:hbFail:', //* 스케줄러 부분 실패 기록 — 실패가 있을 때만 쓰고 덮지 않는다(감사 M5 D11)
  //--- M3 ---//
  tick: 'alert:tick', //* 런너 생존 + 실측 주기(§D2). `lastRunAt` 과 달리 OFF 회차에도 찍힌다
  ruleStat: 'alert:ruleStat', //* 규칙별 점검·발송 기록(§D3)
  snoozeLog: 'alert:snoozeLog', //* 스누즈 중 경과(§D7)
} as const;

//--------------------- M3 관측 상태 타입 ---------------------//

/**
 * 런너 틱(§D2). `lastRunAt` 이 답하지 못하는 질문 — "꺼진 것인가 죽은 것인가" — 의 유일한 근거다.
 * `gaps` 는 요약값이 아니라 **간격 원본**을 담는다: 최댓값 하나만 접어 두면 배포 지연 한 번이
 * `dead` 임계를 영구히 키워 이후의 런너 죽음을 계속 늦게 감지한다(R3-F1).
 */
export interface TickState {
  at: number; //* 마지막 틱 시각(ms)
  gaps: number[]; //* 최근 간격 최대 5개. 판정은 중앙값을 쓴다
  samples: number; //* 누적 틱 횟수. 3 미만이면 `unknown` 라벨(임계는 gaps 가 정한다)
}

export const TICK_GAP_WINDOW = 5;

/** 규칙별 점검·발송 기록(§D3). 규칙 id 는 키로만 등장하고 이 파일 어디에도 하드코딩되지 않는다. */
export interface RuleStat {
  checkedAt?: number; //* evaluate() 가 **정상 종료**한 마지막 시각. throw 회차에는 갱신하지 않는다
  firedAt?: number; //* finding 이 1건 이상 나온 마지막 시각
  sentAt?: number; //* 그 규칙의 경고가 목적지 1곳 이상에 실제로 나간 마지막 시각
  findings?: number; //* 직전 회차 finding 수
  skip?: 'off' | 'snoozed' | 'unevaluated';
  error?: string; //* evaluate() throw 메시지(앞 200자)
}

/** 발송 종류. 이력 행 1건 = 물리 발송 1회다(§D3 R8-F3). */
export type SendKind =
  | 'fire'
  | 'recover'
  | 'digest'
  | 'test'
  | 'fallback'
  | 'fallback-recover'
  | 'snooze-summary';

/**
 * 발송 이력 1행(§D4). **원문 URL 이 없다** — 웹훅 URL 은 곧 발송 권한이라 행마다 복제하면
 * 백업·덤프로 자격증명이 무기한 퍼진다(R5-F1). 집계는 `channelId`, 표시는 `urlMasked` 다.
 */
export interface HistoryEntry {
  t: number;
  kind: SendKind;
  ok: boolean;
  ruleId: string | null; //* 여러 규칙을 묶은 다이제스트·테스트는 null
  channelId: number | null; //* 레거시 URL·중복 URL 은 null(오귀속 대신 미귀속, R6-F5)
  urlMasked: string;
  status?: number;
  error?: string;
  text: string; //* 앞 200자
}

/** 스누즈 설정값. 감사 흔적을 값에 담는다 — 누가 언제 걸었는지가 값 자체에 남는다. */
export interface SnoozeEntry {
  until: number; //* 절대 시각(ms). 0/과거 = 스누즈 없음
  by: number | null; //* 건 관리자 admId
  at: number; //* 건 시각
}

/**
 * 스누즈 중 경과 1건(§D7). 평가는 계속 돌므로 전달이 막힌 구간의 사실이 여기 남는다.
 * `channelHint`·`legacyUrls` 는 표시용이 아니라 **해제 시 `destinationsFor()` 재전개 입력**이다(R3-F3).
 */
export interface SnoozeLogEntry {
  firstAt: number;
  lastAt: number;
  level: 'critical' | 'warn';
  text: string;
  channelHint?: string;
  legacyUrls?: string[];
  resolvedAt?: number; //* 스누즈 중 해소됨. 평가된 회차에만 찍는다(R4-F3)
  deliveredUrls?: string[]; //* 해제 요약이 **실제로 도착한** 목적지. 부분 실패 재시도의 근거
}

export type SnoozeLog = Record<string, SnoozeLogEntry>;

const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;

/**
 * 감시 대상 스케줄러와 기대 주기(ms) — **감시 목록의 사실 소스**다(D4).
 * 여기 없는 이름의 하트비트는 무시하고, 여기 있는데 하트비트가 늦으면 경고한다.
 *
 * 대상 15종은 `apps/api/dockerfile` 의 `SCHEDULER_TYPE` case 문에서 왔다 — 배포되는 것이 그것뿐이다.
 * `samsung-s2-report` 는 case 문에 없어 제외한다(PRD Q4 를 이 근거로 닫았다).
 *
 * 값의 근거는 각 파일 헤더 주석의 "동작주기"이며 **EventBridge 실제 설정과 대조하지 못했다(Q8)**.
 * 주석이 "확인필요"인 둘(`delete-analysis`·`delete-fc`)은 하루로 둔다 — 기대 주기를 길게 잡으면
 * 미탐 방향이라 배포 직후 오탐 폭주를 만들지 않는다. 전부 재배포 없이 화면에서 고친다(UI11).
 */
export const DEFAULT_SCHEDULE_EXPECT: Readonly<Record<string, number>> = {
  'alimtalk-status': 5 * MINUTE,
  'analysis-stall': 10 * MINUTE,
  'analysis-status': 5 * MINUTE,
  'delete-analysis': DAY, //* 주석 "확인필요"
  'delete-fc': DAY, //* 주석 "확인필요"
  'delete-invoice-files': DAY,
  'delete-treat': DAY,
  'disable-subscription': DAY,
  'expired-money': DAY,
  'fax-status': 1 * MINUTE,
  //* 하루 2회(9시·18시)라 최대 간격은 18시→9시의 15시간이다. 평균이 아니라 최대를 쓴다.
  'regular-agency-subscription': 15 * HOUR,
  'regular-subscription': 15 * HOUR,
  'sales-statistics': DAY,
  'settlement-agency': 31 * DAY, //* 매월 1일 — 최장 달 기준
  'settlement-client': 31 * DAY,
};

/**
 * 스케줄러 15종의 설명. **서버가 낸다** — 화면에 적으면 스케줄러가 늘 때 설명이 갈린다.
 * 키 집합은 `DEFAULT_SCHEDULE_EXPECT` 와 같아야 하며 계약 spec 이 그 일치를 본다.
 */
export const SCHEDULE_EXPECT_DESC: Readonly<Record<string, string>> = {
  'alimtalk-status': '알림톡 발송 결과를 조회해 상태를 갱신한다',
  'analysis-stall': '진행이 멈춘 분석 건을 찾아 정리한다',
  'analysis-status': '분석 요청의 진행 상태를 갱신한다',
  'delete-analysis': '보존 기간이 지난 분석 데이터를 지운다',
  'delete-fc': '보존 기간이 지난 FC 데이터를 지운다',
  'delete-invoice-files': '보존 기간이 지난 청구 파일을 지운다',
  'delete-treat': '보존 기간이 지난 진료 데이터를 지운다',
  'disable-subscription': '만료된 구독을 중지 상태로 내린다',
  'expired-money': '기한이 지난 머니를 소멸 처리한다',
  'fax-status': '팩스 발송 결과를 조회해 상태를 갱신한다',
  'regular-agency-subscription': '기업 정기결제를 실행한다 (하루 2회 · 9시와 18시)',
  'regular-subscription': '일반 정기결제를 실행한다 (하루 2회 · 9시와 18시)',
  'sales-statistics': '매출 통계를 집계한다',
  'settlement-agency': '기업 정산을 마감한다 (매월 1일)',
  'settlement-client': '고객 정산을 마감한다 (매월 1일)',
};

/**
 * `alert_schedule_expect` 편집기가 낼 KEY 목록. 값은 전부 ms 라 화면이 초로 낸다.
 * **열린 집합**이다 — 스케줄러가 늘 수 있으므로 직접 입력을 함께 낸다.
 */
export const SCHEDULE_EXPECT_KEY_OPTIONS: AlertSettingKeyOption[] =
  Object.keys(DEFAULT_SCHEDULE_EXPECT).map((name) => ({
    key: name,
    label: name,
    description: SCHEDULE_EXPECT_DESC[name] ?? '설명 없음 — 감시 목록에만 있는 이름입니다',
    shape: 'ms' as const,
  }));

export const COMMON_SETTINGS: ReadonlyArray<AlertRuleSetting> = [
  {
    key: SETTING_KEY.enabled,
    label: '알림 전체 사용',
    type: 'bool',
    default: true,
    description:
      '끄면 이 인스턴스의 알림이 한 건도 나가지 않습니다. 평가는 계속 돌고 전달만 멈춥니다.',
  },
  {
    key: SETTING_KEY.dedupeMs,
    label: '중복 억제창',
    type: 'int',
    default: 30 * 60 * 1000, //* 기존 treat-alert.service.ts:6 의 DEDUPE_MS 승계
    min: 60_000,
    max: 24 * 60 * 60 * 1000,
    unit: 'ms',
    description:
      '같은 경고를 이 시간 안에 다시 보내지 않습니다. 짧게 잡으면 같은 장애가 여러 번 울립니다.',
  },
  {
    key: SETTING_KEY.pollIntervalMs,
    label: '폴링 주기',
    type: 'int',
    default: 5 * 60 * 1000, //* analysis-status 스케줄러 주기(UI20)
    min: 60_000,
    max: 60 * 60 * 1000,
    unit: 'ms',
    description: '알림 판정을 이 간격으로 돌립니다. 통보 지연의 하한이 이 값입니다.',
  },
  {
    key: SETTING_KEY.persistenceStreak,
    label: '지속성 임계',
    type: 'int',
    default: 1, //* 1 = 지속성 억제 없음(기존 동작)
    min: 1,
    max: 10,
    unit: 'count',
    description:
      '연속 N회 잡혀야 보냅니다. 1이면 지속성 억제가 없습니다 — 올리면 통보가 그만큼 늦어집니다.',
  },
  {
    key: SETTING_KEY.leaseTtlMs,
    label: '실행 리스 수명',
    type: 'int',
    default: 15 * 60 * 1000, //* 폴링 주기를 크게 상회해야 정상 실행을 탈취하지 않는다(D1-b)
    min: 60_000,
    max: 60 * 60 * 1000,
    unit: 'ms',
    description:
      '여러 인스턴스가 동시에 돌지 않게 잡는 자물쇠의 수명입니다. 폴링 주기보다 충분히 길어야 합니다.',
  },
  {
    key: SETTING_KEY.digestIntervalMs,
    label: '주의 등급 요약 간격',
    type: 'int',
    default: 24 * 60 * 60 * 1000, //* M9 — 무장애 24시간 수신량 ≤ 요약 1건
    min: 60 * 60 * 1000,
    max: 7 * 24 * 60 * 60 * 1000,
    unit: 'ms',
    description: '주의 등급 경고를 즉시 보내지 않고 이 간격으로 묶어 한 통으로 보냅니다.',
  },
  {
    key: SETTING_KEY.snapshotTtlMs,
    label: 'DB 장애 대비 스냅샷 수명',
    type: 'int',
    default: 24 * 60 * 60 * 1000, //* 만료 후에는 폴백이 무발송이다(D7 fail-closed)
    min: 60 * 60 * 1000,
    max: 7 * 24 * 60 * 60 * 1000,
    unit: 'ms',
    description:
      'DB 가 죽었을 때 쓸 마지막 정상 설정의 수명입니다. 만료되면 그 구간에는 발송하지 않습니다.',
  },
  {
    key: SETTING_KEY.scheduleExpect,
    label: '스케줄러 기대 주기',
    type: 'json',
    default: JSON.stringify(DEFAULT_SCHEDULE_EXPECT),
    description:
      '감시할 스케줄러와 각자의 기대 주기입니다. 이 목록에 없는 이름의 하트비트는 무시하고, 있는데 늦으면 경고합니다.',
    keyOptions: SCHEDULE_EXPECT_KEY_OPTIONS,
    keySet: 'open',
    valueShape: 'scalar',
  },
  {
    key: SETTING_KEY.fallbackStreak,
    label: 'DB 장애 판정 지속성 임계',
    type: 'int',
    //* `persistenceStreak` 을 재사용하면 안 된다. 그 값의 기본은 UI9(통보 지연 ≤ 주기+1분)를
    //*  지키려고 1 로 고정돼 있고(§D8 R3-F1), 1 이면 `streak < snapshot.streak` 이 첫 회차부터
    //*  거짓이라 **폴백의 지속성 게이트가 기본값에서 통째로 no-op** 이 된다. 그러면 §D7 R2-F2 가
    //*  "커넥션 풀 순단 1회를 오탐·플래핑으로 만들지 않는다"며 도입한 장치가 실제로는 없는 것과 같다.
    //*  DB 프로브는 3초 타임아웃 1회짜리라 순단에 특히 민감하므로 전용 노브로 분리한다.
    default: 2,
    min: 1,
    max: 10,
    unit: 'count',
    description:
      'DB 연결 실패가 연속 N회 잡혀야 보냅니다. 커넥션 순단 1회를 장애로 부르지 않기 위한 값입니다.',
  },
  {
    key: SETTING_KEY.staleGrace,
    label: '생존 판정 유예 배수',
    type: 'float',
    //* `schedule-heartbeat_grace` 와 같은 형태·같은 기본값. 임계 = 기대주기 × 이 값이다.
    //*  오탐(정상 런너를 `dead` 로) 방어를 여기가 지고, 미탐 방어는 실측 중앙값이 진다(§D2).
    default: 2.0,
    min: 1,
    max: 10,
    unit: 'multiplier',
    description:
      '죽음 판정 임계 = 기대 주기 x 이 값입니다. 올리면 오탐이 줄고 죽음을 늦게 알아챕니다.',
  },
  {
    key: SETTING_KEY.historyRetentionDays,
    label: '발송 이력 보존일',
    type: 'int',
    //* 원장은 추가 전용이라 지우는 주체가 없으면 무한히 는다(§D1). 화면 창은 최근 200건이고
    //*  채널 창은 최근 20건이므로, 보존일은 "사후 조사에 필요한 기간"만 정하면 된다.
    //*  하한 7 — 그보다 짧으면 주말에 난 장애를 월요일에 조사할 수 없다.
    default: 90,
    min: 7,
    max: 3650,
    unit: 'day',
    description: '발송 이력을 며칠 남길지. 짧게 잡으면 지난 장애를 사후에 조사할 수 없습니다.',
  },
];
