Super Admin Console — facts API¶
슈퍼 어드민 콘솔의 규칙 엔진이 읽는 사실 계약이다. 판정 규칙 자체는 이 문서 밖이다 — 룰은 순수 함수이고 이 계약은 그 입력만 정한다. 배치 결정의 근거는 ADR-0050.
착지 현황 — 이 문서는 dev 에 있는 것만 적는다¶
정본은 dev 다. 축은 조각별로 하나씩 붙었고 원장에서 나오는 것은 아홉이 전부 찼다 — 아래 표가 곧 하루 facts 응답 전체다. ALPHA-979의 세 제어면 조각까지 배선됐다.
⚠️ 축을 더 붙일 일이 생기면 그 축의 절을 그 조각과 함께 여기 넣고, 붙기 전까지는 응답에 필드를 아예 두지 않는다(빈 배열이 아니다 — §부재를 싣는 규약).
| 조각 | 무엇 | dev |
|---|---|---|
| 조회 창 | 어느 하루를 볼지 고르고 무엇을 봤는지 되돌려준다(meta) |
✅ |
| 런 축 | runs[] |
✅ |
| 계획 결손 슬롯 | 런 행이 없는 슬롯을 런 축에 싣는다 | ✅ |
| 작업 축 | tasks[] |
✅ |
| 데이터셋 축 | datasets[] (작업에서 파생) |
✅ |
| 산출 축 | outputs[] + 기준선(중앙값) |
✅ |
| 경계 축 | boundary |
✅ |
| 체인 축 | chain — 그 날 트리거의 자손을 단계마다 센다 |
✅ |
| ETF 귀결 축 | etfLedger — ETF별 최신 트리거의 최신 설명 실행 상태 |
✅ |
| SFN 제어면 축 | runs[].awsStatus·awsStop + meta.aws |
✅ |
| SQS 제어면 축 | queues[] |
✅ |
엔드포인트¶
GET /api/v1/console/facts[?date=YYYY-MM-DD]
운영자 인증이 필요하다(AdminAuthFilter). 응답은 기존 콘솔 API 와 같은 봉투(result)를 쓰고,
필드명은 UI 타입과 같은 camelCase 다.
{
"result": {
"runs": [
{
"id": "etf-daily:2026-08-03T15:40", // = run_key. 사건 식별자의 대상 축
"lane": "etf-daily", // = pipeline_type
"tradingDate": "2026-08-03", // 비거래일 런은 null (키는 있다)
"ledgerStatus": "SUCCEEDED", // = orchestration_status, 원장 어휘 그대로
"ledgerUpdated": "2026-08-03T07:20:34Z",
"deadline": null, // hard_deadline_at 없으면 null
"awsStatus": "SUCCEEDED", // SFN 원문 상태. 못 봤으면 null
"awsStop": "2026-08-03T07:18:06Z" // SFN 종료 시각. 진행 중·미관측이면 null
}
],
"meta": {
"db": "2026-08-03T07:20:34Z", // DB 시계 — 화면의 "언제 기준인가"
"today": "2026-08-03", // 실제로 본 날
"aws": "2026-08-03T07:20:35Z" // SFN 관측 시각. 조회 실패면 null
}
}
}
엔티티 해소 추이¶
하루 facts의 조회 창은 그대로 하루다. 뉴스 argument 엔티티 해소율의 과거점을 얻기 위해 하루
응답을 여러 번 호출하거나 facts 안에 다른 날짜를 섞지 않고 별도 사실 표면을 둔다.
GET /api/v1/console/trends/entity-resolution[?date=YYYY-MM-DD]
date는 마지막 관측일(KST)이다. 생략하면 API 서버 시계의 KST 오늘 이하를 보고, 형식 오류·미래 날짜는
facts와 같은 400 계약을 쓴다. 응답은 다음 조건을 만족하는 최근 10개 날짜를 오래된 순으로 낸다.
LOAD_ASSERTIONS,plan_status='DUE'- wrapper가 남긴 성공
ops_task_attempt에 분자·분모와 시작·종료 시각이 모두 있는 관측 - 같은 날짜에 여럿이면 원장 생성 시각이 가장 늦은 런, 같은 런이면 시작 시각이 가장 늦은 성공 attempt 하나
실패·시도 증거 없음·계측 없음·배선 전 과거 날짜는 0% 점으로 합성하지 않는다. 그래서 아직 관측이 없으면
points: []다. 과거 데이터를 백필하지 않으며, 미해소 원인 분류도 이 계약에 없다.
{
"result": {
"points": [
{
"date": "2026-08-19",
"totalArguments": 189419,
"resolvedArguments": 123818,
"rate": 0.6537
}
]
}
}
totalArguments: 0은 실제 0건 관측이라 점과 두 계수는 남긴다. 다만 0/0은 비율이 아니므로
그때 rate는 null이다. 서버는 이 값으로 정상·이상이나 색상을 판정하지 않는다.
장중 분석 귀결 추이¶
하루 facts의 장중 체인을 여러 날짜에 걸쳐 비교할 수 있도록 연속 날짜 사실 표면을 둔다.
GET /api/v1/console/trends/intraday-analysis[?maxDate=YYYY-MM-DD&days=30]
maxDate는 마지막 날짜(KST)이며 생략하면 API 서버 시계의 KST 오늘이다. days 기본값은 30,
허용 범위는 1–366이다. 날짜 형식 오류·미래 날짜·범위 밖 일수는 400이다. 응답은 maxDate로
끝나는 정확히 days개의 점을 오래된 순서로 내며, 원장 행이 없는 날짜도 모든 수가 0인 점으로
남긴다. asOf는 조회한 DB 시각이다.
{
"result": {
"asOf": "2026-08-20T04:50:00Z",
"points": [
{
"date": "2026-08-20",
"triggers": 65,
"observations": 61,
"runs": 60,
"activeRuns": 2,
"failedRuns": 1,
"results": 57,
"published": 55
}
]
}
}
- 코호트는 그 날짜의
minute_price_trigger.trigger_kind='FIRE'행이다.REVERT는 설명을 만들지 않는 회수 마커라 제외한다. observations·runs·results·published는 행 수가 아니라 그 단계에 도달한 코호트 관측 수다. 따라서 재실행이 단계를 부풀리거나 결손을 상쇄하지 않고triggers >= observations >= runs >= results >= published가 구조적으로 성립한다.activeRuns·failedRuns는 라우트별 최신 실행을explanation_as_of·started_at·ID 순으로 골라 각각PENDING|RUNNING,FAILED인 관측 수다.CANCELLED는runs에는 들지만 두 부분 상태에는 들지 않는다.results는 전체 재실행 중 결과에 한 번이라도 도달했는지를 센다.published는 그 결과들 중 현재PUBLISHED인 것이 있는 관측 수다. 새 실패/DRAFT 재실행만으로 기존 게시본이 사라지지는 않지만, 운영자가WITHDRAWN으로 전이하면 현재 게시 수는 감소한다. 상태 전이 이력은 이 원장에 없으므로 "과거에 게시된 적 있음"을 뜻하지 않는다.- 서버는 정상·이상, 색상, 감소 원인을 판정하지 않는다.
dev 실데이터 사전 성능 게이트(2026-08-20)는 장중 트리거 678건에서 30일 집계 22.45ms,
임시 파일 0이었다. 트리거 표 순차 스캔은 16 shared-hit blocks/0.35ms이고 하류는 기존 인덱스를
사용했다. 현재 유입률 약 38건/일에서는 선행 인덱스를 추가하지 않는다. 실행계획이 악화되면
window_start 선두 인덱스를 먼저 검토한다.
큐 축¶
Terraform의 1분 파이프라인 원큐와 각 DLQ를 SQS GetQueueAttributes로 관측한다. visible은
원큐 대기, inFlight는 원큐 처리 중, dlq는 DLQ의 visible·in-flight·delayed 합계다. DLQ
메시지가 상태 사이를 이동하는 순간에도 R12가 유실을 놓치지 않게 합계로 센다.
"queues": [{ "name": "news-extraction-realtime", "visible": 7, "inFlight": 3, "dlq": 2 }]
한 큐라도 조회에 실패하면 부분 성공을 전체 관측처럼 내리지 않고 queues: null로 싣는다. 구 API
응답에는 필드 자체가 없을 수 있으므로 UI 검증 경계는 부재도 받는다. 둘 다 R12는 못 돎이고,
성공한 빈 배열만 “전 큐를 봤고 메시지가 없었다”는 실측이다.
런 축¶
그 날의 런 전건이고, 원장 컬럼을 그대로 옮긴다 — 여기서 어휘를 다시 정의하지 않는다
(판정은 클라이언트). id 는 내부 pipeline_run_id 가 아니라 run_key 다: 사건 식별자의
대상 축이고, 뒤에 붙을 작업 축이 이 값으로 매인다(tasks[].runId).
⚠️ 서버 SQL 의 조인은 내부 pipeline_run_id 로 한다 — 와이어에서 잇는 축이 run_key 라는
뜻이지 SQL 조인 키라는 뜻이 아니다.
⚠️ 정렬을 고정한다(run_key, 그다음 내부 id). 안 하면 같은 원장이 조회마다 다른 순서로
나가고, 소비자가 "첫 런"을 집는 순간 판정이 흔들린다. run_key 는 UNIQUE
(uq_ops_pipeline_run_key — "이 슬롯은 한 번만 계획된다")라 그 하나로 전순서가 정해진다.
2차 키는 그래서 잉여이고, 여기서 무엇을 지키지는 않는다 — 그 UNIQUE 가 깨지면 원장 쓰기
쪽이 먼저 죽는다(슬롯 멱등 조회가 단일 행을 전제한다).
⚠️ 값을 못 구한 필드는 키를 두고 null 이다(tradingDate·deadline). 키를 빼면 규칙 층이
"계측 없음"으로 읽어 그 축 규칙을 통째로 못 돎 으로 세운다 — 못 구한 것과 안 재는 것은
다른 사실이다.
런이 0건인 날의 runs: [] 는 사실이다 — "봤는데 없었다". 아직 안 붙은 축의 키 부재와
반대다(그건 "아직 안 본다").
계획 결손 슬롯도 런 축에 실린다¶
계획은 있었는데 런 행이 안 생긴 슬롯(ops_reconciliation_issue PLANNER_MISSING)은
런처럼 생긴 행으로 같은 배열에 들어간다 — 그 슬롯이 사건의 대상이기 때문이다. 런 행이
없으니 ledgerStatus·ledgerUpdated·deadline 은 없는 것이 사실이라 null 이다.
{
"id": "etf-daily:2026-08-03T15:40",
"lane": "etf-daily", // 슬롯 키에서 읽는다 (이슈 테이블에 레인 컬럼이 없다)
"tradingDate": "2026-08-03", // 〃
"ledgerStatus": null,
"planned": true, // ← 계획 슬롯에만 실린다
"noRunRow": true // 〃
}
⚠️ planned·noRunRow 는 실재 런에서 키 자체가 없다. false 로 채우면 "계획된 적 없다"는
단정이 되는데 그걸 답할 계측이 원장에 없다(크론 설정은 DB 밖). 이 응답에서 @JsonInclude 를
필드 단위로 거는 유일한 자리이고, 클래스 단위로 올리면 다른 축의 "집계 없음(null)"까지 같이
지워진다.
⚠️ status='OPEN' 만으로는 부족하다 — NOT EXISTS 를 함께 건다. Reconciler 가 아직 닫지
못한 이슈와 그 사이 생긴 런이 겹치면 같은 run_key 가 두 행으로 나가고, 소비자는 그걸
식별자 충돌로 읽어 그 축 규칙을 통째로 못 돎 으로 세운다.
⚠️ 슬롯 키를 못 읽으면 레인·거래일이 null 이고 경고가 남는다. 잘못 자른 조각을 레인
이름이라고 우기면 화면이 존재하지 않는 레인을 그린다. 응답에는 "레인 미상"으로만 보이므로
그 경고가 Planner 키 형식이 갈렸다는 사실을 남기는 유일한 장치다(키 형식 SSOT 는
ops/planner.py 의 slot_run_key).
두 소스를 합친 뒤 다시 정렬한다 — 각자 자기 안에서만 정렬돼 있어 이어 붙이면 전체 순서가 깨진다.
작업 축¶
그 날의 기대 작업 전건. runId 는 런의 id(=run_key)와 같은 축이다 — 내부
pipeline_run_id 를 쓰면 와이어에서 런 축과 안 이어진다. 창은 런 축과 같은 식을 쓴다.
⚠️ 모르는 값은 null 이다. records_out·unsupported_records·failed_records·
completeness_* 는 원장이 안 준
경우가 있는데, JDBC 의 getLong 은 SQL NULL 을 0 으로 준다 — 접히면 "0건 처리"와 "신호 없음"이
화면에서 같은 칸이 된다. attempts 는 count(*) 라 0 이 실측이다. 업무를 실행하지 않은 중복 재시도
컨테이너(record_source='DUPLICATE_SKIP', ALPHA-1088)는 세지 않는다 — 재시도 소진(R16)이 부풀지 않게.
unsupported_records는 정상 입력이지만 현재 적재 모델이 지원하지 않아 제외한 건수다. 유실이나
INCOMPLETE 판정에 포함하지 않으며, 과거·비계측 작업의 null을 0으로 바꾸지 않는다.
⚠️ 정렬은 파이프라인 순서다(raw → normalize → 그 외). 문자열 순이면 역순이 된다
(feature < normalize < raw).
⚠️ 계약·신선도 컬럼(datasetContractKey·expectedAsOf·actualAsOf·collectedAt·
freshnessStatus·freshnessReason)은 작업 축 와이어에 안 나간다. 그건 데이터셋 축을
파생하는 재료이지 작업 축의 사실이 아니다(dataset_contract 테이블이 없어 ops_expected_task
의 컬럼으로 산다). 그대로 흘리면 소비자가 같은 사실을 두 축에서 읽고, 한쪽만 고칠 때 갈린다.
데이터셋 축¶
여러 행을 하나의 사실로 접는 유일한 축이다. 원장에 이 축의 테이블이 없어 그 날의 작업을
dataset 으로 묶어 세운다. 다른 축은 행과 응답이 1:1 이다 — 런 축도 계획 결손 슬롯을 키에서
파싱해 합성하지만(§계획 결손 슬롯) 슬롯 하나가 행 하나이지 여러 행을 접지는 않는다. 접는
축은 여기뿐이고, 그래서 접는 방향이 곧 계약이다.
"datasets": [
{
"id": "etf_holdings", // = ops_expected_task.dataset (작업 축의 dataset 과 같은 축)
"contract": true, // 이 데이터셋의 작업 중 하나라도 계약이 걸려 있나
"expectedAsOf": "2026-08-03",
"actualAsOf": null,
"collectedAt": "2026-08-03T07:00:00Z",
"unverifiable": "ACTUAL_AS_OF_UNVERIFIED" // null 이면 신선도를 판정할 수 있다
}
]
정렬은 데이터셋 id 다. 안 고정하면 같은 원장이 조회마다 다른 순서로 나가고, 소비자가 "첫
데이터셋"을 집는 순간 판정이 흔들린다(런 축을 run_key 로 고정한 것과 같은 이유).
dataset 이 없거나 빈 문자열인 작업은 이 축에서 뺀다 — 빈 id 가 서면 그걸 위반으로 만드는
규칙이 빈 대상 가드에 걸려 그 규칙 전체를 못 돎으로 세우고, 다른 데이터셋의 판정 불가까지
같이 버려진다. ⚠️ 다만 조용히 빼지 않는다: 스키마에 비공백 제약이 없어 원장에 실제로 들어올
수 있고 그건 writer 결함이다. 요청당 한 줄로 접어 경고를 남기고(행마다 찍으면 결함 하나가
로그량을 요청량 × 작업 수로 증폭한다), 그 작업 자체는 tasks[] 에 그대로 남는다.
etf_nav.actualAsOf는 NAV_COLLECTION_KIS 원장에 기록된 KIS 응답 원본
stck_bsop_date의 최댓값이다. 요청창 종료일·실행일·수집 시각에서 파생하지 않으며, 유효한
응답 거래일 증거가 없으면 기존 nullable 계약대로 null이다.
⚠️ 소비자가 읽는데 이 축이 안 주는 필드가 하나 있다 — windowContract. 규칙 엔진의
신선도 규칙(R08)은 창 계약(as-of 가 아니라 기간으로 신선도를 보는 데이터셋)을 판정에서 빼는데,
그 구분을 이 응답이 안 준다. 채워지면 창 계약 데이터셋이 일반 as-of 계약으로 판정돼
actual < expected 에서 거짓 STALE 이 설 자리다.
다만 화면이 붙는다고 발화하지 않는다 — 원장에 창 계약이 하나도 없기 때문이다. 계약을 다는
카탈로그 항목은 ETF_HOLDINGS_COLLECTION_KRX와 NAV_COLLECTION_KIS이고(ops/catalog.py),
각 계약은 모두 as-of 계약이다(LATEST_KR_TRADING_DAY). Cadence·ExpectedAsOfRule
어느 enum 에도 창 계약을 표현할 값이 없다(ops/contracts.py). 규칙 쪽 술어도 contract 를
먼저 보므로 계약 없는 데이터셋은 이 필드와 무관하게 이미 빠진다. 그래서 이 구멍은
창 계약 성격의 계약이 레지스트리에 들어오는 날 함께 열린다 — 그때 서버가 이 필드를 싣거나
어댑터가 다른 축에서 파생시키면 된다. 화면 착지의 선행 조건은 아니다.
unverifiable — 판정 가능성의 여집합보다 한 겹 넓다¶
판정 코드지 문장이 아니다. 술어는 계약 부재 ∨ 그 날 실행 대상 아님 ∨ actual 근거 부재 ∨
원장이 UNKNOWN 이라고 말함이고, 코드는 이 순서로 정해진다:
| 코드 | 언제 | 출처 |
|---|---|---|
CONTRACT_NOT_APPLIED |
이 데이터셋의 작업 중 계약이 걸린 것이 하나도 없다 | 서버 |
NOT_APPLICABLE |
계약은 있는데 그 계약으로 그 날 DUE 이던 작업이 없다 |
서버 |
원장의 freshness_reason |
원장이 UNKNOWN 이라 말했고 사유를 남겼다 |
원장 그대로 |
FRESHNESS_REASON_MISSING |
원장이 UNKNOWN 이라 말했는데 사유가 비었다 |
서버 |
ACTUAL_AS_OF_MISSING |
UNKNOWN 도 아닌데 actual 근거가 없다(스키마상 안 나오는 자리 — 아래) |
서버 |
null |
판정할 수 있다 — 판정 자체는 클라이언트 소관 | — |
접기는 계약이 걸린 DUE 작업에서만 한다¶
이 축의 사실은 계약의 것이지 데이터셋에 붙은 아무 작업의 것이 아니다. 네 값 중 셋(actual
as-of·수집 시각·신선도)은 스키마가 이미 그렇게 묶어 두므로(ck_ops_expected_task_freshness_applicability)
좁히든 말든 같다.
⚠️ expected_as_of_date 하나만 다르다. Planner 는 그 컬럼을 계약 유무와 무관하게 모든
작업에 쓰고 값이 갈린다 — 계약 작업은 resolve_expected_as_of, 나머지는 slot_date. 안 좁히면
아무 계약도 하지 않은 기대일이 계약 데이터셋의 expectedAsOf 로 나가고 거기에 계약 작업의
사유가 붙는다. 실물 조합이다: etf_holdings 는 계약이 걸린 ETF_HOLDINGS_COLLECTION_KRX 와
계약이 없는 NORMALIZE_ETF 를 함께 갖고(ops/catalog.py), 그 계약은 actual 을 늘 NULL 로 써서
기대일 접기가 정상 경로다.
⚠️ NOT_APPLICABLE 은 오늘 나가지 않는다 — 계약이 걸린 카탈로그 엔트리는
ETF_HOLDINGS_COLLECTION_KRX와 NAV_COLLECTION_KIS 모두 kr_trading_calendar 를 켜지 않아
휴장일에도 DUE 로 선다(플래그를 켠 다섯 엔트리는 계약이 없다). 스키마와 Planner 가 그 조합을 허용하므로
갈래는 두되, "휴장일이 이 코드로 온다"고 읽으면 안 된다.
⚠️ ACTUAL_AS_OF_MISSING 도 스키마상 안 나오는 자리다 — 계약 ∧ DUE 면 상태가 non-null 이고
(ck_ops_expected_task_contract_freshness_status) FRESH·STALE 은 actual 을 요구하며
(ck_ops_expected_task_verified_as_of) UNKNOWN 은 앞 행이 잡는다. 그래도 null 로 떨어뜨리지
않는다 — 원장이 그 제약을 잃는 날 대안은 판정 불가가 조용히 정상으로 서는 것이다.
⚠️ FRESHNESS_REASON_MISSING 은 ACTUAL_AS_OF_MISSING 과 다른 사실이다. UNKNOWN 은 as-of 가
있어도 서므로(ck_ops_expected_task_verified_as_of 는 actual > expected 인 UNKNOWN 을
통과시킨다) 후자를 돌려쓰면 응답이 actual 날짜를 실은 채 "as-of 가 없다"고 말한다 — 판정 불가는
맞지만 사유가 거짓이라 운영자가 없는 결손을 찾으러 간다.
⚠️ 셋째 항을 빼면 안 된다. 스키마는 freshness_status='UNKNOWN' 인데 actual_as_of_date 가 있는
조합을 허용하는데(ck_ops_expected_task_verified_as_of 는 actual > expected 인 UNKNOWN 을
통과시킨다), as-of 유무만 보면 그 데이터셋이 판정 가능으로 서고 값이 낡음 규칙도 통과하면
두 규칙 다 위반 0 이 된다 — 아무도 못 본 데이터셋이 화면에서 정상으로 그려진다. 좁히는 쪽으로
새면 판정도 못 하고 판정 불가로도 안 잡히는 데이터셋이 생기고, 넓히는 쪽 대가는 낡음 규칙과의
겹침뿐이다.
⚠️ 코드→문장 매핑은 UI 소관이고 아직 없다.
한 데이터셋에 작업이 여럿이면¶
as-of 쌍은 한 작업에서 통째로 가져온다. expected 와 actual 을 각자 접으면(max·min)
어느 작업에도 없던 쌍이 만들어져, 둘 다 FRESH 인 작업 두 개가 거짓 STALE 을 낸다
(01/01 과 03/03 → expected 03 · actual 01).
| 무엇 | 어떻게 | 왜 |
|---|---|---|
| 기준 작업 | actual 이 가장 오래된 작업 |
하나만 최신이어도 전체가 최신으로 보이면 낡음이 조용해진다 |
| 동률이면 | 기대일이 늦은 쪽 | actual < expected 가 설 가능성이 큰 쪽 — 낡음을 드러내는 방향 |
| 그래도 동률이면 | 작업 키 | 쌍은 어느 쪽이든 같지만 사유가 이 선택을 따라오므로, 없으면 같은 원장이 조회 순서에 따라 서로 다른 사유를 낸다 |
| 판정 불가 사유 | 기준 작업이 UNKNOWN 이면 거기서, 아니면 나머지 UNKNOWN 중 첫 작업 |
따로 고르면 한 행이 B 의 as-of 와 A 의 사유를 함께 실어 서로 다른 작업이 한 사실로 섞인다 |
collectedAt |
가장 최근 | "언제까지 관측 가능해졌나"라 늦은 쪽이 답이다 |
actual 근거가 아예 없으면 비교할 쌍이 없다 — 그때만 expected 를 따로(최댓값) 접어 표시한다.
⚠️ 빈 사유는 사유가 아니다. ck_ops_expected_task_freshness_pair 는 freshness_reason 에
IS NOT NULL 만 걸어 빈 문자열을 막지 않는다. 그대로 내리면 판정 코드를 truthy 로 보는
소비자가 판정 불가 데이터셋을 정상으로 건너뛴다 — 판정 불가는 유지하고 사유만
FRESHNESS_REASON_MISSING 으로 떨어뜨린다(위 표).
산출 축¶
산출 다섯의 그 날 값과 평소(직전 거래일 중앙값).
"outputs": [
{ "id": "o.pub", "label": "게시 ETF", "today": 16, "base": 32.0, "unit": "종" },
{ "id": "o.trig", "label": "배치 트리거", "today": 0, "base": null, "unit": "종" }
]
| id | 무엇 | 세는 곳 | 계열 | 날짜 축 | 장에 매임 |
|---|---|---|---|---|---|
o.pub |
게시 ETF | explanation_result |
PUBLISHED 만 |
trade_date |
✅ |
o.trig |
배치 트리거 | price_movement_trigger |
전건 | trade_date |
✅ |
o.doc |
뉴스 문서 | document |
NEWS 만 |
available_at 의 KST 날짜 |
— |
o.asr |
assertion | document_assertion |
뉴스+공시 | 〃 | — |
o.evt |
source event | source_event |
뉴스+공시 | 〃 | — |
⚠️ o.evt 는 event_status 축을 안 거른다(ACTIVE·REJECTED). 오늘은 무해하다 —
프로듀서 둘 다 ACTIVE 를 박고 REJECTED 를 쓰는 writer 가 레포에 없다(dev 실측 비-ACTIVE 0건).
REJECTED 적재가 생기면 이 수의 뜻이 조용히 바뀐다.
⚠️ o.asr·o.evt 는 o.doc 과 계열이 다르다. 앞의 둘은 공시도 함께 센다(라벨도 계열을 안
밝힌다). dev 실측(2026-08-11) 기준 공시 비중은 주장 0.07%(63/86,197) · 이벤트 0.16%(63/38,543)라
지금은 판정을 안 뒤집지만, 공시 레인이 커지면 달라진다.
⚠️ 이 수치는 그 시점 dev 원장 조회값이고 저장소만으로는 재현되지 않는다 — 근거로 쓸 때 다시 재라. 셈법을 바꾸는 것은 지표 정의를 바꾸는
일이라 여기서 하지 않고, 현재 셈법을 테스트로 못 박아 조용히 바뀌지 않게 했다.
⚠️ 장에 매임(marketBound)은 "뉴스인가"가 아니라 "장이 서야 나오는가"다. 그 플래그가 정하는
것은 장 안 서는 날 base 를 비우는지 하나다.
🔴 today 와 base 의 nullability 가 다른 것이 이 축의 전부다. today 는 수라 0 이
실측이고, base 는 null 이 "비교할 평소가 없다" 이다. 기준을 0 으로 메우면 소비자가 그
산출을 −100% 로 판정한다.
⚠️ 일관성을 주는 것은 트랜잭션이다. 조회 전체가 REPEATABLE READ 라 나눠 세도 같은 스냅샷을 읽는다 — 산출 다섯이 서로 다른 문장인데도 서로 일관된 것이 그 증거다. 오늘 값과 기준일을 한 문장으로 세는 것은 왕복을 줄이는 선택이고, 격리수준을 낮추면 그때야 값들이 갈린다.
기준 표본 — 순서 셋이 다 계약이다¶
표본은 (런이 있던 날 ∪ 계획 결손일) − 장 안 서는 날 의 최근 10개다(주말 + 평일 휴장). 세 연산의 순서를 바꾸면
각각 다르게 틀린다:
| 순서 | 바꾸면 |
|---|---|
| 계획 결손일을 합집합에 넣는다 | 빼면 Planner 가 통째로 실패한 날의 실측 0 이 표본에서 사라져 중앙값이 올라간다 |
| 장 안 서는 날 제외는 합집합 뒤 한 번 | 한 소스에서만 빼면 다른 소스가 같은 날짜를 되살린다. 합집합 뒤에 한 번 하면 두 소스가 구조적으로 함께 덮인다 |
| 자르기는 제외 뒤 | 먼저 자르면 그 안의 휴장일만큼 표본이 줄고 그만큼의 거래일이 영영 안 들어온다 |
⚠️ "런 0건인 날을 표본에서 빼는 쪽이 과민이라 안전하다"는 거짓이다. 편차 판정은 양방향이라 기준이 올라가면 위쪽 이상이 조용해진다 — 거짓 음성도 같이 만든다. 어느 쪽도 안전하지 않으니 더 그럴듯한 쪽을 고른다: 휴장일에는 Planner 가 정상적으로 돌아 런 행이 생기므로(그래서 휴장 신호도 그때 남는다) 런이 0건인 평일 = 거래일일 확률이 압도적이다.
🔴 남는 불확실 — "장애와 휴장이 겹친 날". 평일 휴장인데 Planner 가 통째로 실패하면 런이 없어 휴장 신호도 없고, 계획 결손일로만 표본에 들어와 거래일로 오인된다. 그 날을 조회하면 장 산출에 기준이 실린다. 그 둘을 가르는 계측이 없어 위 확률 판단이 대가를 감수한 것이다.
⚠️ PLANNER_MISSING 이 주말에 안 열린다고 읽으면 안 된다 — _due_slots 는 레인별 주말 설정을
보고(cand.weekday() >= 5 and not weekend) 뉴스처럼 주말에 도는 레인은 주말 슬롯을 만든다.
실제로 dev 원장에 news:2026-08-08T15:00(토) 결손 슬롯이 있다. 그 날짜는 위 주말 규칙이 뺀다.
장이 안 서는 날 — 물음이 둘로 갈린다¶
⚠️ trading_date 는 거래일 달력이 아니다(Planner 가 슬롯 날짜를 그대로 쓴다). 그래서 "이
날 장이 섰나"를 두 갈래로 답한다:
| 갈래 | 누가 답하나 |
|---|---|
| 주말 | 달력(weekday() >= 5) — 원장은 답해 주지 않는다 |
| 평일 휴장 | 원장의 skip_reason='NON_TRADING_DAY' |
🔴 주말을 원장에 맡기면 샌다. 휴장 신호는 그날 달력에 매인 레인이 실제로 돌았을 때만
생긴다. 뉴스 레인은 주 7일 돌고 그 런에도 trading_date 가 박히므로, 시장 레인이 안 돈 주말은
원장상 평범한 거래일처럼 보인다. dev 실측(2026-08-11): 주말 trading_date 4일 중 2일이 뉴스
런만 있고 skip 행이 0 이었다. 주말 규칙은 data-pipeline 의 ops/trading_calendar.py 와 같은 식이다.
⚠️ 평일 휴장의 제외는 날짜 단위다. 그 신호는 kr_trading_calendar=true 인 작업에만
붙고(가격 레인 셋 + 장중 수급 레인 둘 — 같은 레인 안에서도 작업마다 다르다), 뉴스·공시 레인은
휴장일에도 돈다 — 런 단위로 상관시키면 그 런이 같은 날짜를 되살린다.
조회일이 장 안 서는 날이면(주말이든 휴장이든) o.pub·o.trig 만 base 를 안 준다. 그날
그 둘의 0 은 실측이 맞지만 비교할 평소가 없는 날이라, 기준을 주면 소비자가 −100% 로 판정한다.
⚠️ 이 갈래는 다 지난 날에만 의미가 있다 — 조회일이 KST 오늘이면 그 앞에서 다섯 전부가
이미 빠진다(아래 「미완결일」).
o.doc·o.asr·o.evt 는 그날도 기준을 받는다 — 장과 무관하게 도는 산출이라 비교가 성립한다.
⚠️ 다만 그 기준은 평일 표본이다 — 남은 잔여. 표본은 산출 다섯이 공유하고 장 안 서는 날을
빼므로, 주말에 조회하면 그 셋의 today(주말 실측)가 평일 중앙값과 비교된다. 주말 표본을
따로 두지 않은 이유는 주말 뉴스량에 중심값이 없어서다 — dev 실측(2026-08-11 기준 최근 21일)에서
주말 문서 수가 755 · 2,250 · 62,930 · 231,055(평일은 4,000~6,600)로, 토요일 급증은 백필
아티팩트로 보인다. 그 위에 "평소"를 세우면 기준 자체가 잡음이 된다.
⚠️ 이 수치도 그 시점 dev 조회값이라 저장소만으로 재현되지 않는다 — 근거로 쓸 때 다시 재라.
표본이 아예 없으면 base 는 null 이다 — "평소가 0" 이 아니라 "평소를 모른다"이다.
✅ 미완결일 — 조회일이 KST 오늘이면 다섯 전부 기준이 없다 (ALPHA-946)¶
그 날의 적재 창이 아직 안 지났으면 비교 대상이 아니다. 기준일 후보는 전부 조회일 미만이라
창이 지난 하루의 값인데, 조회일이 KST 오늘이면 그 값은 아직 쌓이는 중이다. dev 배포본
실측(2026-08-11 14:27 KST)에서 산출 다섯 중 넷이 그 이유로 거짓 P1 이었다
(o.trig −100% · o.pub −71% · o.asr −29% · o.evt −59%).
두 억제의 순서: 창이 안 지났으면 다섯 전부 base 가 없다. 장 안 서는 날 규칙(marketBound)은
그 뒤에 남는 판정이라, 다 지난 날에만 갈라 선다.
⚠️ "완결"이 아니라 "창이 지났다"이다 — 이 술어는 완결을 증명하지 않는다. 지난 날의 값도 소급으로 자란다(아래 잔여 1). 주는 것은 정직한 하한이다: 장중 거짓 −100% 는 구조적으로 없애되, 소급 적재분은 여전히 낮게 잰다.
⚠️ 산출별로 가르지 않는다. 적재 창이 산출마다 다르기 때문이다:
| 산출 | 그 날 값이 쌓이는 창 |
|---|---|
o.trig |
시장 SFN 한 번(schedule_expression 기본 cron(40 15 ? * MON-FRI *))이 통째로 넣는 계단 함수. ⚠️ 행에 박힌 detected_at=15:30 은 load_price_triggers.py 가 멱등 키로 쓰는 결정적 이벤트 시각이지 적재 시각이 아니다 — 이 값을 완료 시각으로 읽고 15:30 에 게이트를 풀면 배치가 시작도 안 한 상태를 완결값으로 비교한다 |
o.doc·o.asr·o.evt |
하루 종일 — available_at 이 곧 날짜 버킷이고 1분 레인과 배치가 함께 먹인다 |
o.pub |
날짜 경계를 넘는다 — trade_date 가 적재 시각과 분리돼 있고 writer 가 상주 소비자다 |
다섯 전부에 참인 술어는 "그 날이 다 지났는가" 하나뿐이다. 산출별로 가르려면 산출↔작업 완료 바인딩이 있어야 하는데 원장에 없다.
⚠️ o.trig 이 쓰는 것은 price_movement_trigger 하나다. 1분 레인(minute/price_consumer.py)은
minute_price_trigger 라는 다른 표에 쓴다(ALPHA-709 — 트리거 축은 둘이다). 두 표를 섞어 읽으면
"장중에도 배치 트리거가 쌓인다"는 없는 전제가 선다.
🔴 이 게이트가 안 덮는 잔여 셋:
- 지난 날의 버킷은 양방향으로 흔들린다.
document.available_at의 뜻이 writer 마다 다르고 충돌 처리도 다르다:
| writer | available_at |
ON CONFLICT (source_code, source_document_id) |
과거 버킷에 미치는 영향 |
|---|---|---|---|
steps/assemble_events.py |
published_at |
DO NOTHING |
나중에 돌아도 과거 버킷에 실린다 → 늘린다 |
steps/load_documents.py |
fetched_at 폴백 published_at |
DO NOTHING |
늘린다 — ⚠️ 폴백 여부와 무관하다. 과거 파티션을 재스캔·재실행하면 그때 처음 들어오는 행의 fetched_at 이 이미 지난 날짜다 |
minute/canonical_news.py |
실제 처리 시각 | DO UPDATE … GREATEST(기존, 신규) |
정정이 오면 값을 앞으로 민다 → 과거 버킷을 줄인다 |
⚠️ 버킷을 정하는 것은 writer 의 종류가 아니라 그 행의 available_at 이 어느 KST 날짜인가
다 — 위 표는 각 writer 가 그 값을 어디서 얻는지를 적을 뿐이고, 늦게 도는 것만으로 과거
버킷에 실리지는 않는다(늦게 돌아도 그 시점의 fetched_at 을 쓰면 오늘 버킷이다).
DO NOTHING 인 배치 둘 사이에서는 먼저 넣은 쪽이 값을 정하지만, 1분 레인은 그 뒤에도
값을 전진시킬 수 있다. 게다가 뉴스 day-close 는 00:10 에 돌며 어제 창을 처리한다(#669).
o.pub 도 같은 부류다 — dev 실측(행 기준) 08-10 → 30 당일 + 4 익일 ·
08-03 → 15 + 15(절반이 익일). ⇒ 자정 직후 어제를 조회하면 base 는 서는데 today 는 아직
움직일 수 있다.
2. o.trig 은 시장 SFN 이 끝난 뒤엔 과잉 억제 — 그때부터 자정까지는 사실상 확정인데 판정을
안 한다. 푸는 조건은 위와 같다(산출↔작업 완료 바인딩).
3. 🔴 그 날의 진짜 결손이 자정까지 조용해진다. 산출이 통째로 0 인 장애를 R13 이 당일에 잡던
경로가 사라지는데, 그걸 대신 잡을 규칙이 지금 없다 — 완전성 축이 대부분 UNKNOWN 이라
작업이 FULFILLED 로 끝나면 R05~R07 이 조용하다(ALPHA-728). 거짓 P1 다섯 중 넷을 없애는 값으로
그 지연을 받았다. 완전성 축이 서면 그쪽이 당일 검출을 맡는다.
⚠️ 위 실측은 그 시점 dev 원장 조회값이라 저장소만으로 재현되지 않는다 — 근거로 쓸 때 다시 재라.
경계 축¶
게시 상태와 테넌트 발번이 어긋난 건수. 배열이 아니라 객체 하나다 — 다른 축은 "그 날의 행들"이지만 이건 "지금 어긋난 것이 몇 건인가"라 수 셋으로 끝난다.
"boundary": {
"publishedWithoutDelivery": 0, // 게시했는데 발번이 없다
"deliveryNowNonpublished": 1, // 발번했는데 지금 비게시다 (무효화 통지도 안 갔다)
"deliveryRows": 114 // 발번 전체 — "발번이 돌고는 있나"
}
🔴 이 축만 날짜 창을 안 탄다 — 기준 시각이 meta.today 가 아니라 meta.db 다. 나머지 축은
"그 날 무슨 일이 있었나"이고 이건 누적이다: 어제 어긋난 것이 오늘 저절로 낫지 않는다. 날짜로
자르면 어제 난 사고가 오늘 조회에서 나은 것처럼 보인다.
⚠️ 그래서 과거 날짜를 조회해도 이 세 수는 "지금" 값이다. 소비자가 이 축의 판정을 그 날의
사건으로 귀속하면 오늘 난 불일치가 과거 날짜의 사건처럼 표시된다 — 이 축을 쓰는 판정은
meta.db 를 시각으로 삼거나, 과거 날짜 조회에서 아예 빼야 한다.
⚠️ deliveryRows 는 "발번이 돌고는 있나"를 답한다. 앞의 둘이 0 일 때 그것이 정합인지
발번이 아직 하나도 없음인지 이 값이 가른다 — 없으면 "아무 문제 없음"과 "아직 아무것도 안
나감"이 같은 칸이 된다.
⚠️ 비율의 분모로 쓸 수는 없다: 앞의 둘은 단위가 서로 다르고(결과 건수 vs 테넌트별 발번 건수)
deliveryRows 에는 INVALIDATION 도 들어간다.
🔴 정상 무효화를 위반으로 세면 안 된다¶
운영자 무효화(JdbcAnalysisWriteRepository.invalidate)는 결과를 WITHDRAWN 으로 전이하고 그
NEW 를 받은 테넌트에 INVALIDATION 을 발번하되 원래 NEW 행은 남긴다. 상태만 보면 정상
무효화한 분석이 전부 "발번했는데 현재 비게시"로 남아 그 수가 영구히 단조 증가한다.
실제 위반은 "비게시인데 무효화 통지도 안 갔다" 이고, 상관은 결과만이 아니라 테넌트까지다 —
무효화는 그 NEW 를 받은 테넌트에만 나가므로(invalidate 의 EXISTS 제한), 받고도 통지 못
받은 테넌트가 있으면 그건 진짜 위반이다. 결과 단위로만 상관시키면 그 경우가 0건으로 사라진다.
🔴 테넌트가 하나도 없으면 미발번은 위반이 아니다. _fanout_new(analysis-engine
eventstore.py)는 tenant 전건에 발번하므로 0명이면 0행을 넣고, 호출부는 그걸
fanout_tenants: 0 인 정상 성공으로 기록한다. 안 막으면 새 환경 부트스트랩에서 게시본 전건이
위반으로 선다 — 정상 무효화를 위반으로 세면 안 되는 것과 같은 부류다.
⏭ 남는 것: 게시 뒤에 테넌트가 생기면 그 과거 게시본은 발번이 백필되지 않아 계속 잡힌다.
가르려면 tenant.created_at 과 결과 시각을 상관시켜야 하는데 그게 계약인지 정해진 바 없다.
⚠️ 무효화 통지의 cursor 순서는 안 본다. INVALIDATION 을 넣는 경로가 invalidate 하나뿐
이고, 그 문장이 그 결과의 NEW 를 이미 받은 테넌트에만(EXISTS 제한) MAX(cursor)+1 로 넣어
언제나 NEW 보다 뒤다. 순서 술어를 더하면 어떤 행도 못 거르는 가드가 하나 는다.
⚠️ 이 표의 writer 가 하나라는 뜻은 아니다 — NEW 는 analysis-engine 이, INVALIDATION 은
super-admin-api 가 넣는다. 순서를 지키는 것은 writer 수가 아니라 무효화 쪽 문장의 EXISTS 다.
⚠️ delivery_type 을 따로 거르지 않는다. 발번은 2형상이고(NEW·INVALIDATION —
CORRECTION 은 ADR-0044 로 폐지), ck_tenant_delivery_payload
가 explanation_result_id 를 NEW 에만 허용해 INVALIDATION 은 위 두 조건에 애초에 안 걸린다.
유형 술어를 더하면 어떤 행도 못 거르는 가드가 늘고 테스트로 겨눌 수도 없다 — 같은 이유로
V202608011200 이 자기참조 금지 제약을 "죽은 제약"이라며 지웠다. 3형상이 돌아오면 두 조건에
유형 술어가 같이 필요하다.
ETF 귀결 축¶
조회일에 발화한 배치(price_movement_trigger)와 장중 FIRE(minute_price_trigger)를 합쳐
ETF마다 가장 늦은 트리거 하나를 고른다. 그 트리거에 실행이 여러 번 있으면
explanation_as_of가 가장 늦은 실행의 run_status를 그대로 싣는다.
"etfLedger": { "rows": [
{ "etf": "etf-1", "name": "첫 ETF", "triggered": true,
"outcome": "FAILED", "error": null }
] }
실행이 아직 없으면 outcome: null이다. 진행 중 상태와 실행 부재를 실패로 만들지 않으며 R15는
정확히 FAILED만 센다. 원장에 오류 문자열이 없어 error는 null이고, 설명을 꾸며내지 않는다.
구 API 응답에는 축 자체가 없을 수 있으므로 UI 경계는 etfLedger 부재를 허용하며 그때 R15는
못 돎이다. 서버가 축을 보낸 빈 rows는 “조회일에 트리거 ETF가 0종”이라는 실측이다.
체인 축¶
설명 생산 흐름에서 그 날 발화한 트리거가 단계마다 몇 건 남았나. 배열 둘을 가진 객체다.
"chain": {
"feeds": [
{ "id": "feed.batch", "label": "배치 트리거", "v": 20, "unit": "ETF", "src": "price_movement_trigger" },
{ "id": "feed.intraday", "label": "장중 트리거", "v": 65, "unit": "건", "src": "minute_price_trigger" }
],
"stages": [
{ "id": "c.obs", "label": "관측", "batch": 20, "intraday": 0, "src": "etf_contribution_observation" },
{ "id": "c.route", "label": "라우트", "batch": 20, "intraday": 0, "src": "explanation_route" },
{ "id": "c.run", "label": "런", "batch": 16, "intraday": 0, "src": "explanation_run" },
{ "id": "c.res", "label": "결과", "batch": 16, "intraday": 0, "src": "explanation_result" },
{ "id": "c.pub", "label": "게시", "batch": 16, "intraday": 0, "src": "publication_status=PUBLISHED" }
]
}
흐름은 Cloud 게시에서 끝난다 — 발번(tenant_delivery)도 온프렘 수신도 안 싣는다. 그 뒤는
전달 경계의 물음이고(ADR-0026) 화면도 거기서 잘라 그린다. ⚠️ 한 번 c.dlv 를 실었다가 뺐다:
R10 은 실린 단계를 전부 비교해 "게시 → 전달" P0 를 낼 수 있는데 화면에 그 단계 노드가 없어
사건 딥링크가 조용히 아무 데도 안 닿았다. 게시했는데 발번이 없는 것은
boundary.publishedWithoutDelivery 가 답한다 — 답이 둘이면 한쪽만 낡는다.
🔴 두 목록의 순서가 계약이다. 소비자는 feeds 를 각 갈래의 첫 점으로 삼아 stages 를
순서대로 인접 비교하고, 감소를 손실로 읽는다(R10). 원장에는 단계 간 선후가 없어 순서를
복원할 방법이 없다 — 재배치는 어떤 값 단언도 안 깨뜨린 채 판정만 뒤섞는다.
그리고 feeds[0]=배치 · feeds[1]=장중을 위치로 읽는다(id 로 찾지 않는다). 서버는 늘 둘을
내지만 소비자는 셋째를 거부하지 않는다 — 그 대가와 이유는 아래 §옵셔널 절에 있다.
날짜 축은 트리거 하나뿐이다¶
단계마다 자기 테이블을 자기 날짜 컬럼으로 따로 세면 서로 다른 무리를 센 수가 나란히 서고,
인접 비교가 없는 손실을 만든다 — 15:40 배치가 만든 관측을 자정 넘겨 게시하면 날짜별 집계는
관측 20 · 게시 0 을 낸다. 그래서 그 날의 트리거만 날짜로 고르고 나머지 단계는 전부 그
자손을 따라간다(LEFT JOIN). 하류 행의 날짜는 안 본다.
갈래는 관측이 가른다 — etf_contribution_observation 이 두 트리거 FK 중 정확히 하나를 갖고
(ck_etf_contribution_one_trigger), 그 아래 단계에는 배치/장중을 가르는 컬럼이 없다.
⚠️ 분봉 트리거는 FIRE 만 센다. REVERT 는 노출 회수 마커라 설명을 만들지 않는다
(ALPHA-799 — 무효화만 한다). 지금 원장에 0행이라 종류를 안 걸러도 값이 같지만, 그 writer 가
착지하는 날 피드만 조용히 부풀어 없던 손실이 P0 로 선다.
⚠️ 여기 "큐로도 안 나간다"고 적었다가 정정했다 — ExposureReverted 의 destination 은 발화와
같은 큐다(data_pipeline/minute/jobs.py). 안 세는 근거는 설명을 안 만든다는 것 하나다.
🔴 단계는 행 수가 아니라 도달한 코호트 구성원 수다¶
count(DISTINCT obs_id) FILTER (WHERE <그 단계> IS NOT NULL) — 그래서 각 단계는 앞 단계의
부분집합이고, 단조성이 구조적으로 보장된다. 남는 감소는 전부 "여기서 멈춘 구성원 수"다.
행을 세면 다중도가 손실로도 은폐로도 읽힌다. 리뷰가 실 스키마에서 둘 다 실증했다:
| 형상 | 행을 세면 | 도달로 세면 |
|---|---|---|
| 재실행(경로에 UNIQUE 없음). 엔진은 그 경로에 이미 게시본이 있으면 새 결과를 DRAFT 로 넣는다 | 결과 2 · 게시 1 → "결과 → 게시" P0 (설계된 감소인데) | 1 · 1 → 조용 |
| 경로 20 중 10 은 런 없음 + 나머지 10 이 두 번씩 | 런 20 → 경로→런이 조용 → "ETF 10종이 설명을 못 받았다"가 화면 어디에도 없다 | 런 10 → 그 자리에서 P0 |
⚠️ 그래도 못 가르는 것: DRAFT 는 사유를 안 남긴다 — 아직 처리 중인지, 게시 대상이
아닌지(publishable=false), 운영자가 내렸는지(WITHDRAWN)가 같은 모양이다. 그래서
결과 → 게시 의 감소에는 설계된 것이 섞인다.
🔴 천장 — 진행 중인 하루의 감소는 손실과 같은 모양이다¶
이 축은 "지금까지 몇 건이 도착했나"이지 "몇 건이 끝내 사라졌나"가 아니다. 아직 처리 중인 트리거는 하류 단계에 없으므로 감소로 보이고, R10 은 그걸 P0 손실로 읽는다 — 장중에 과민하다.
가리려면 "이 트리거는 처리될 기회를 가졌나"를 답하는 사실이 필요한데 원장에 없다. 값을 접지 않는 쪽을 택했다: 접으면 진짜 손실도 같이 사라지고, 그건 이 콘솔이 없애려는 칸 혼동이다.
0 은 실측이고 축 부재는 아니다¶
수에 null 자리가 없다 — 코호트를 정해 놓고 세므로 "못 셌다"가 없다. 단계가 0 이면 아무도
그 단계에 도달 못 했다는 실측이다.
⚠️ 0 자체가 위반은 아니다. R10 은 인접한 두 값의 감소를 보므로, 앞 단계도 0 이면 아무
위반도 안 선다(그 갈래는 애초에 아무것도 안 흘렀다). 여기 "0 이면 손실"이라고 적었다가
정정했다 — 이 응답이 하지 않는 판정이다.
🔴 이 축만 옵셔널이다 — 없으면 그 규칙만 못 돎¶
다른 축과 달리 chain 은 나중에 붙었다. 검증 경계는 축이 통째로 없는 응답을 거부하지
않는다: 안 싣는 배포본이 실재하고(API 롤백 · UI 선배포), 거부하면 체인 카드 하나 때문에
Run·Dataset·Delivery·Trend·Incidents 까지 전 화면이 "응답이 계약과 다릅니다" 한 장이 된다.
규칙 엔진은 축 부재를 표현할 수단(canRun)을 이미 갖고 있으므로 그쪽으로 흘려보낸다.
축이 있으면 전 필드를 검사하고, 갈래가 모자라면 거부한다(feeds[0]·feeds[1] 을 위치로
읽으므로 한 갈래만 오면 그 갈래의 첫 비교점이 사라진다). 갈래가 넘치는 것은 거부하지
않는다 — 전진 배포를 멈추지 않기 위해서다. ⚠️ 대가 둘: 셋째 갈래가 생기면 R10 이 그
갈래를 한 번도 안 보고, 화면은 그 칸을 장중 색으로 칠한다(ChainStrip 이 첫 칸만 배치로
가른다). 축이 늘 때 규칙과 화면을 같이 고치는 것 말고 이걸 잡는 자동 가드는 없다.
SFN 제어면 축¶
원장 상태와 독립인 Step Functions DescribeExecution 관측을 runs[].awsStatus·awsStop에 싣고,
관측 시각을 meta.aws에 싣는다. 실행 ARN은 sfn_execution_arn을 우선하고 Planner가 계산한
expected_execution_arn을 조회 locator로만 보조 사용한다. 후자의 존재는 실행 증거가 아니다.
부재 형상은 둘이다. 키가 없으면 이 축을 안 싣던 API 배포본이고, 키가 있으면서 null이면
조회했지만 제어면을 못 봤다. UI 검증 경계는 롤백·선배포 스큐를 위해 이 세 필드를 옵셔널로
받되, 값이 오면 상태 문자열과 ISO 시각을 검사한다. 첫 AWS 조회가 실패하면 부분 성공으로
접지 않고 축 전체를 비운다. ExecutionDoesNotExist는 관측 성공·해당 실행 없음이라 그 런만
상태가 null이다.
R03은 상태가 다르더라도 ledgerUpdated < awsStop이면 Reconciler가 종료 뒤 아직 원장을 훑지
않은 정상 지연으로 보고 위반에서 뺀다. 두 시각이 같거나 원장이 더 늦게 갱신됐는데도 상태가
다를 때만 투영 불일치다.
조회 창¶
창은 하루다. date 를 생략하면 서버가 고르고, 무엇을 골랐는지 meta.today 로
되돌려준다 — 화면이 요청한 날짜를 그대로 그리면 서버가 다른 날을 골랐을 때 거짓말이 된다.
어느 날을 고르나¶
- 런이 있던 마지막 날
- 계획만 있고 런 행이 없던 마지막 날(
ops_reconciliation_issuePLANNER_MISSING) - 둘 중 뒤쪽. 둘 다 없으면(원장이 빔) DB 시계의 KST 오늘
⚠️ 2번을 빼면 안 된다. 런이 하루도 안 뜬 날이 조회 후보에서 사라지는데, 그날이 바로 콘솔이 열려야 하는 날이다.
런이 어느 날에 속하나¶
COALESCE(r.trading_date, (r.created_at AT TIME ZONE 'Asia/Seoul')::date)
⚠️ trading_date 로 단순화하지 마라 — 비거래일 런은 그 컬럼이 NULL 이라 통째로 새어 나간다.
이 식을 쓰는 자리가 앞으로 여럿이 된다(조회 창 · 작업 조인 · 최신 날짜). 한 자리라도 다르게
쓰면 그 런이 창에는 들어오는데 최신 날짜에는 안 잡혀 기본 화면에서 사라진다.
⚠️ 날짜 캐스트는 존을 항상 명시한다. 생략하면 세션 TimeZone(=JVM 기본값)을 타서 로컬(KST) 에서는 맞고 운영(UTC)에서만 하루가 밀린다 — 통합 테스트가 못 보는 자리가 된다.
요청 날짜 검사 — 셋 다 400¶
| 입력 | 왜 |
|---|---|
형식 오류(2026-8-3) |
아래 계층에서 터지면 500 으로 위장돼 운영자가 원인을 못 찾는다 |
확장 연도(+999999999-12-31) |
SourceService 와 같은 규약 |
| 미래 날짜 | 아직 오지 않은 날의 사실은 실측 0 이 아니라 "아직" 인데, 이 응답에 그 둘을 가르는 자리가 없다 |
⚠️ 미래의 상한은 KST 오늘이지 원장의 최신 거래일이 아니다. 최신 거래일로 자르면 계획이 통째로
안 돈 날(런 0건 + PLANNER_MISSING)을 못 열게 되는데, 그날이 콘솔이 열려야 하는 날이다 —
게이트가 사고를 숨기는 방향으로 서면 안 된다.
원장에서 오는 미래도 같은 이유로 막는다: 미래 거래일 런이나 미래 슬롯 키가 하나라도 있으면 기본 조회가 오지 않은 날로 뛴다. 그래서 후보 조회에 상한을 KST 오늘로 건다.
부재를 싣는 규약¶
이 응답의 핵심은 값이 아니라 부재의 종류다. 셋을 끝까지 가른다:
| 뜻 | 어떻게 |
|---|---|
| 실측 0 | 0 |
| 집계 없음 · 관측 불가 | null |
| 계측 없음 | 키 자체가 없다 |
[]·0·"" 으로 메우지 않는다 — 화면이 "괜찮다"로 그린다. 표시 문자열도 만들지 않는다
(포맷은 UI 소관 — SourceReportResponse 가 이미 지키는 규약).
⚠️ 빈 배열과 키 부재는 다른 뜻이다. runs: [] 는 "봤는데 없었다"는 사실이고, 키 부재는
"아직 안 본다"이다. 전자를 후자로 접으면 규칙 층이 못 돎 대신 "평가됨 · 위반 0" 을 세워
계측 공백이 정상으로 뒤집힌다.
⚠️ 지금 이 응답에 키가 없는 축은 없다 — 일곱이 다 찼다. 이 규약은 축을 더 붙일 때 쓴다: 붙기 전까지 그 필드를 아예 두지 않는다.
⚠️ 클래스 단위 @JsonInclude(NON_NULL) 을 걸지 않는 이유가 이것이다 — 위에 걸면 "집계
없음(null)"이 조용히 "계측 없음(필드 부재)"으로 바뀌어, 콘솔이 없애려는 칸 혼동을 서버가 다시
만든다. 필요한 필드에만 붙인다.
성능에 대해 지금 하지 않는 것¶
날짜 후보 조회에 하한(lookback)을 두지 않는다. 한 번 뒀다가 되돌렸다 — 90일 창은 파이프라인이
그보다 오래 멈췄다 재개한 날 원장이 아는 마지막 날을 창 밖으로 밀어낸다 — run_latest 도
계획 결손일도 안 잡혀 기본 조회가 KST 오늘로 떨어지고, 화면은 사고 난 그날 대신 빈 오늘을 연다.
안 해도 될 성능 대비로 계약을 깎은 것이었다.
규모가 그 대비를 요구하지 않는다: ops_pipeline_run 은 레인 4개 × 하루 1~3슬롯이라 연 수천 행이고,
날짜 조회는 그중 DISTINCT 날짜(운영 일수)만 낸다. 느려지면 인덱스가 먼저다.