콘텐츠로 이동

ADR-0050: 콘솔 규칙 엔진의 사실 공급 — 엔드포인트는 사실만 주고 평가는 클라이언트가 한다

  • 상태: 승인됨
  • 날짜: 2026-08-08

맥락

슈퍼 어드민 콘솔의 규칙 엔진(super-admin-ui/src/rules/)은 19개 규칙 + 인과 간선으로 운영 사실을 읽어 위반·사건을 만든다. 그 입력이 지금 앱 번들 안의 정적 facts-snapshot.json 이다 — 화면은 실판정처럼 그리는데 값은 2026-08-03 에 뜬 스냅샷이다(ALPHA-738).

이걸 실 응답으로 바꾸려면 두 가지를 정해야 한다: 평가를 어디서 하는가, 그리고 DB 에 없는 사실(AWS 제어면)을 어떻게 싣는가.

rules/README.md 는 이 선택을 열어 뒀었다 — "API 가 사실을 주기 시작하면 이 모듈을 그대로 서버(또는 BFF)로 옮기거나 스냅샷 import 를 fetch 로 바꾼다".

결정

1. 엔드포인트는 Facts 를 주고, 평가는 클라이언트가 계속한다

GET /api/v1/console/facts 의 응답은 사실이고 Violation[] 이 아니다.

  • evaluate(f, now) 는 순수 함수이고, 실시간 축은 이미 클라이언트가 평가한다 — 화면이 evaluate({ ...facts, minute: 응답 }) 을 돌린다. 서버로 옮기면 같은 판정이 두 곳에 생긴다.
  • 규칙은 IA 라운드마다 바뀐다(9라운드 동안 16 → 19개). 서버에 두면 규칙 문구 한 줄에 배포가 붙는다.
  • 엔진과 테스트는 손대지 않아도 된다 — 순수 함수라 입력만 바뀐다.

2. AWS 제어면은 같은 응답에, 자기 부재 사유와 함께 싣는다

R03(제어면·원장 불일치)·R11·R12(큐)가 읽는 사실은 DB 에 없다 — Step Functions DescribeExecution 과 SQS GetQueueAttributes 가 필요하다. 이걸 별도 엔드포인트로 가르지 않고 같은 응답에 넣되, 실패해도 DB 축을 막지 않는다. SFN은 runs[].awsStatus·awsStop과 meta.aws로 붙었고, 조회 실패는 meta.aws: null + 런별 AWS 값 null로 축 전체를 비운다. SQS도 queues[]로 붙었고, 한 큐라도 조회에 실패하면 queues: null로 축 전체를 비운다(ALPHA-979 조각 3).

[] 로 보내지 않는 것이 핵심이다 — [] 는 "큐가 0개"라는 거짓말이고, 규칙이 그걸 통과시켜 평가됨 · 위반 0("봤고 괜찮다")이 된다. null 이면 canRun 이 false 라 못 돎 으로 남는다.

대안

규칙 엔진을 서버(또는 BFF)로 옮긴다. 판정이 한 곳에 모이고 응답이 작아진다. 그러나 실시간 축이 이미 클라이언트에서 평가되고 있어 옮겨도 판정이 두 곳으로 갈리며, 규칙 변경마다 배포가 붙는다. 규칙이 아직 IA 와 함께 흔들리는 단계라 그 비용이 크다.

AWS 축을 별도 엔드포인트로 뺀다. 실패 격리가 자연스러워 보이지만, 그러면 "AWS 를 못 봤다"가 타임아웃·404 로만 나타나고 그걸 못 돎 으로 옮기는 일이 화면 몫이 된다. 부분 실패를 축 단위 부재로 표현하려면 같은 응답이어야 한다. ("화면이 두 응답을 합치지 않게"는 근거가 못 된다 — 실시간 축이 이미 별도 응답이라 어차피 합친다.)

응답을 자를 때 runs 를 자른다. 응답 크기를 줄이는 가장 직관적인 축이지만 안 된다. c.runId === p.targetId 인과 간선 3개가 전부 런 규칙(R04)을 부모로 갖는다 — runs 를 자르면 부모가 사라져 자식 위반들이 고아가 되고, 위반 수는 그대로인데 사건 수와 P0 수가 부푼다. 자른다면 tasks 쪽이다(자식이 줄 뿐 병합은 정상).

결과

  • 계약은 docs/contracts/console-facts-api.md.
  • R15까지 ETF별 최신 트리거 실행 상태로 선다. 남은 계측 공백(R11·R16)은 화면에 못 돎 으로 서야 한다 — 평가됨 · 위반 0 과 같은 칸에 그리면 계측 공백이 정상으로 보인다.
  • ⚠️ R10 은 그 뒤 배선됐다(ALPHA-979 조각 1, 2026-08-13). 체인이 읽는 표가 전부 클라우드 DB 에 있어(price_movement_trigger · minute_price_trigger · etf_contribution_observation · explanation_route · explanation_run · explanation_result) super-admin-api 가 SQL 로 조립한다 — 없던 것은 소스가 아니라 집계였다. 이 줄의 "소스 자체가 없다"에서 R10 은 빠진다. 축의 형상(피드 2 + 단계 5)과 그 천장은 계약의 §체인 축이 정본이다.
  • R03도 그 뒤 배선됐다(ALPHA-979 조각 2, 2026-08-13). SFN 종료 뒤 Reconciler가 아직 원장을 훑지 않은 ledger_updated < aws_stop은 정상 지연이라 위반에서 제외한다.
  • R12도 그 뒤 배선됐다(ALPHA-979 조각 3, 2026-08-13). SQS 조회가 일부라도 실패하면 부분 관측을 내리지 않고 축 전체를 null로 둔다.
  • R15도 그 뒤 배선됐다(ALPHA-978, 2026-08-13). 조회일의 배치·장중 발화를 합쳐 ETF별 최신 트리거 하나와 그 트리거의 최신 explanation_run.run_status를 낸다.
  • R08도 그 뒤 배선됐다(ALPHA-978). KIS NAV 응답 원본의 최신 stck_bsop_date를 actual_as_of_date로 기록한다. KRX holdings는 독립 증거가 없어 계속 NULL이며, NAV 증거까지 없거나 손상된 응답에서는 R08이 정직하게 못 돎을 유지한다.
  • 런북은 29/29 미등록이 된다 — 레지스트리 계약들의 runbook_uri 가 None 이고, 스냅샷의 8개 항목은 손으로 쓴 목이다.
  • 클라이언트 평가를 택했으므로 화면 12곳의 동기 사실 접근을 비동기로 바꿔야 한다(하나는 모듈 import 시점에 읽어 fetch 로는 만족되지 않는다). "import 를 fetch 로 바꾸면 끝"이 아니다.
  • 정적 스냅샷이 가리고 있던 결함들이 배선 시 함께 드러난다 — 계약 문서 "배선 시 함께 해야 하는 것".