From 46e0f3aeaefce6cd4ace0350104d70ecc4750572 Mon Sep 17 00:00:00 2001
From: "jeon-jonghyuk-taskctl-bot[bot]"
 <282130200+jeon-jonghyuk-taskctl-bot[bot]@users.noreply.github.com>
Date: Fri, 5 Jun 2026 15:38:56 +0900
Subject: [PATCH] [task-2729+2] dev2: normal callback registration
 reconciliation (validator alias)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

근본원인: production envelope 는 registrar(anu_callback_registrar+finalize_hooks)가
real schedule_id 를 'cron_schedule_id', ANU key 를 'anu_key' 필드로 기록하나
validator 는 canonical 'schedule_id'/'owner_key' 만 읽어 필드명 불일치로 4-source FAIL.

fix: validator input mapping 정합 — canonical 부재 시에만 registrar alias
(cron_schedule_id→schedule_id, anu_key→owner_key) 회수. canonical 우선이므로
검증 약화 0 (placeholder/blocked/self-key/!=ANU/path-traversal 검사 그대로 적용).

- utils/normal_callback_registration_validator.py (+11): Source1/Source3 alias
- tests/regression/test_normal_callback_registration_reconciliation_2729p1.py (신규 5건):
  alias PASS, sid↔history 매칭, self-key NON_AUTHORITATIVE 유지, canonical 우선(약화0), canonical dogfood

audit 후 최소 locus 확정: callback_preregistration.py 는 origin/main 부재(다른 lineage)
→ orphan-add 회피 위해 제외. reconciliation 은 validator alias 로 완결(registrar 가 이미 real sid/key 기록).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 ...back_registration_reconciliation_2729p1.py | 359 ++++++++++++++++++
 .../normal_callback_registration_validator.py |  11 +
 2 files changed, 370 insertions(+)
 create mode 100644 tests/regression/test_normal_callback_registration_reconciliation_2729p1.py

diff --git a/tests/regression/test_normal_callback_registration_reconciliation_2729p1.py b/tests/regression/test_normal_callback_registration_reconciliation_2729p1.py
new file mode 100644
index 00000000..8abed905
--- /dev/null
+++ b/tests/regression/test_normal_callback_registration_reconciliation_2729p1.py
@@ -0,0 +1,359 @@
+# -*- coding: utf-8 -*-
+"""task-2729+2 — normal callback registration reconciliation 회귀 테스트.
+
+근본 원인 (audit): production 의 ANU normal callback envelope 는 registrar
+(utils/anu_callback_registrar + dispatch/finalize_hooks) 가 real schedule_id 를
+``cron_schedule_id`` 필드로, ANU key 를 ``anu_key`` 필드로 기록한다. 그러나
+validator(utils/normal_callback_registration_validator) 는 canonical
+``schedule_id`` / ``owner_key`` 필드명으로만 읽어 필드명 불일치로 FAIL 했다.
+
+fix: validator 가 canonical 부재 시에만 registrar alias(cron_schedule_id /
+anu_key) 를 회수하도록 input mapping 을 정합(canonical 우선 — 검증 약화 0).
+
+테스트 케이스 (5개, validator alias 정합 검증):
+  1. test_validator_pass_via_registrar_aliases — alias 만으로 4-source PASS
+  2. test_sid_history_match_and_mismatch       — sid ↔ schedule_history 매칭
+  3. test_self_key_non_authoritative_preserved — alias 경로 self-key 차단 유지
+  4. test_canonical_precedence_no_weakening     — canonical 우선(검증 약화 0)
+  5. test_existing_canonical_dogfood_pass       — 기존 canonical 동작 무손상
+"""
+from __future__ import annotations
+
+import json
+import pathlib
+import sys
+
+# --------------------------------------------------------------------------- #
+# validator import
+# --------------------------------------------------------------------------- #
+
+_REPO = pathlib.Path(__file__).resolve().parents[2]  # repo root
+if str(_REPO) not in sys.path:
+    sys.path.insert(0, str(_REPO))
+
+from utils.normal_callback_registration_validator import (  # noqa: E402
+    ANU_KEY,
+    FAIL,
+    NON_AUTHORITATIVE,
+    PASS,
+    validate_callback_registration,
+)
+
+# --------------------------------------------------------------------------- #
+# Helpers
+# --------------------------------------------------------------------------- #
+
+
+def _write_envelope(path: pathlib.Path, data: dict) -> str:
+    """tmp 경로에 envelope JSON 작성 후 경로 문자열 반환."""
+    path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
+    return str(path)
+
+
+def _write_schedule_history(history_dir: pathlib.Path, sid: str, status: str = "ok") -> None:
+    """schedule_history/<sid>.log 작성."""
+    log = history_dir / f"{sid}.log"
+    log.write_text(
+        json.dumps({"status": status, "schedule_id": sid}) + "\n",
+        encoding="utf-8",
+    )
+
+
+def _write_inbound(inbound_dir: pathlib.Path, task_id: str) -> pathlib.Path:
+    """inbound/<task_id>-normal-completion.json 작성."""
+    f = inbound_dir / f"{task_id}-normal-completion.json"
+    f.write_text(json.dumps({"task_id": task_id, "status": "ok"}), encoding="utf-8")
+    return f
+
+
+# --------------------------------------------------------------------------- #
+# 1. test_validator_pass_via_registrar_aliases
+# --------------------------------------------------------------------------- #
+
+def test_validator_pass_via_registrar_aliases(tmp_path):
+    """registrar 스타일 envelope (cron_schedule_id + anu_key alias) + history + inbound
+    → validator가 alias로 Source 1,3을 회수하여 verdict==PASS.
+    """
+    task_id = "task-reg-2729"
+    cron_sid = "SID-REG-1"
+
+    # tmp dirs
+    history_dir = tmp_path / "schedule_history"
+    history_dir.mkdir()
+    inbound_dir = tmp_path / "inbound"
+    inbound_dir.mkdir()
+
+    # envelope: schedule_id 없음, owner_key 없음, alias만 존재
+    envelope_data = {
+        "task_id": task_id,
+        "cron_schedule_id": cron_sid,
+        "anu_key": ANU_KEY,
+        "schedule_type": "cron_once",
+    }
+    env_path = _write_envelope(tmp_path / f"{task_id}.envelope.json", envelope_data)
+
+    # Source 2: schedule_history
+    _write_schedule_history(history_dir, cron_sid, status="ok")
+
+    # Source 4: inbound
+    _write_inbound(inbound_dir, task_id)
+
+    result = validate_callback_registration(
+        task_id=task_id,
+        envelope_path=env_path,
+        schedule_history_dir=str(history_dir),
+        inbound_search_dirs=[str(inbound_dir)],
+    )
+
+    assert result.verdict == PASS, (
+        f"Expected PASS via alias, got {result.verdict}. "
+        f"sources={result.sources_checked}, reasons={result.reasons}"
+    )
+    # 모든 sources PASS
+    for src, v in result.sources_checked.items():
+        assert v == PASS, f"source '{src}' not PASS: {v}. reasons={result.reasons}"
+
+
+# --------------------------------------------------------------------------- #
+# 3. test_sid_history_match_and_mismatch
+# --------------------------------------------------------------------------- #
+
+def test_sid_history_match_and_mismatch(tmp_path):
+    """
+    (a) cron_schedule_id alias + matching history → schedule_history source PASS, verdict PASS
+    (b) cron_schedule_id alias + missing history → schedule_history source FAIL, verdict FAIL
+    """
+    # --- (a) match ---
+    task_a = "task-match-2729"
+    sid_a = "SID-MATCH"
+
+    history_dir = tmp_path / "schedule_history"
+    history_dir.mkdir()
+    inbound_dir = tmp_path / "inbound"
+    inbound_dir.mkdir()
+
+    _write_schedule_history(history_dir, sid_a, status="ok")
+    _write_inbound(inbound_dir, task_a)
+
+    env_a = _write_envelope(
+        tmp_path / f"{task_a}.envelope.json",
+        {
+            "task_id": task_a,
+            "cron_schedule_id": sid_a,
+            "anu_key": ANU_KEY,
+            "schedule_type": "cron_once",
+        },
+    )
+
+    result_a = validate_callback_registration(
+        task_id=task_a,
+        envelope_path=env_a,
+        schedule_history_dir=str(history_dir),
+        inbound_search_dirs=[str(inbound_dir)],
+    )
+    assert result_a.sources_checked.get("schedule_history") == PASS, (
+        f"(a) schedule_history should PASS, got: {result_a.sources_checked}. "
+        f"reasons={result_a.reasons}"
+    )
+    assert result_a.verdict == PASS, \
+        f"(a) verdict should PASS, got {result_a.verdict}"
+
+    # --- (b) mismatch (history file missing) ---
+    task_b = "task-nohist-2729"
+    sid_b = "SID-NOHIST"
+    # history_dir 에 SID-NOHIST.log 없음
+    _write_inbound(inbound_dir, task_b)
+
+    env_b = _write_envelope(
+        tmp_path / f"{task_b}.envelope.json",
+        {
+            "task_id": task_b,
+            "cron_schedule_id": sid_b,
+            "anu_key": ANU_KEY,
+            "schedule_type": "cron_once",
+        },
+    )
+
+    result_b = validate_callback_registration(
+        task_id=task_b,
+        envelope_path=env_b,
+        schedule_history_dir=str(history_dir),
+        inbound_search_dirs=[str(inbound_dir)],
+    )
+    assert result_b.sources_checked.get("schedule_history") == FAIL, (
+        f"(b) schedule_history should FAIL (no log), got: {result_b.sources_checked}"
+    )
+    assert result_b.verdict == FAIL, \
+        f"(b) verdict should FAIL, got {result_b.verdict}"
+
+
+# --------------------------------------------------------------------------- #
+# 4. test_self_key_non_authoritative_preserved
+# --------------------------------------------------------------------------- #
+
+def test_self_key_non_authoritative_preserved(tmp_path):
+    """executor self-key를 anu_key alias 필드에 기록 + owner_key 없음
+    → alias 경로로 회수해도 self-key 차단 → NON_AUTHORITATIVE.
+    """
+    task_id = "task-selfkey-2729"
+    self_key = "deadbeef00000000"  # ANU_KEY 아님
+    sid = "SID-SELFKEY"
+
+    history_dir = tmp_path / "schedule_history"
+    history_dir.mkdir()
+    inbound_dir = tmp_path / "inbound"
+    inbound_dir.mkdir()
+
+    _write_schedule_history(history_dir, sid, status="ok")
+    _write_inbound(inbound_dir, task_id)
+
+    # anu_key 필드에 self-key 기록, canonical owner_key 없음
+    env_path = _write_envelope(
+        tmp_path / f"{task_id}.envelope.json",
+        {
+            "task_id": task_id,
+            "cron_schedule_id": sid,
+            "anu_key": self_key,
+            "schedule_type": "cron_once",
+        },
+    )
+
+    result = validate_callback_registration(
+        task_id=task_id,
+        envelope_path=env_path,
+        executor_key=self_key,
+        schedule_history_dir=str(history_dir),
+        inbound_search_dirs=[str(inbound_dir)],
+    )
+
+    assert result.verdict == NON_AUTHORITATIVE, (
+        f"Expected NON_AUTHORITATIVE (self-key alias blocked), got {result.verdict}. "
+        f"sources={result.sources_checked}, reasons={result.reasons}"
+    )
+    assert result.sources_checked.get("owner_key") == NON_AUTHORITATIVE, (
+        f"owner_key source should be NON_AUTHORITATIVE, got: {result.sources_checked}"
+    )
+
+
+# --------------------------------------------------------------------------- #
+# 5. test_canonical_precedence_no_weakening
+# --------------------------------------------------------------------------- #
+
+def test_canonical_precedence_no_weakening(tmp_path):
+    """canonical 필드가 존재할 때 alias로 fallthrough하지 않음 (검증 약화 0 증명).
+
+    (a) canonical schedule_id='pending'(placeholder) + cron_schedule_id='SID-VALID'
+        → canonical placeholder 채택, alias 무시 → FAIL
+
+    (b) canonical owner_key=wrong_key + anu_key=ANU_KEY
+        → canonical 우선이므로 owner_key FAIL, alias 무시 → FAIL
+    """
+    history_dir = tmp_path / "schedule_history"
+    history_dir.mkdir()
+    inbound_dir = tmp_path / "inbound"
+    inbound_dir.mkdir()
+
+    # --- (a) canonical placeholder schedule_id ---
+    task_a = "task-canon-sched-2729"
+    sid_valid = "SID-VALID"
+    _write_schedule_history(history_dir, sid_valid, status="ok")
+    _write_inbound(inbound_dir, task_a)
+
+    env_a = _write_envelope(
+        tmp_path / f"{task_a}.envelope.json",
+        {
+            "task_id": task_a,
+            "schedule_id": "pending",          # canonical placeholder
+            "cron_schedule_id": sid_valid,     # alias (무시되어야 함)
+            "owner_key": ANU_KEY,
+            "schedule_type": "cron_once",
+        },
+    )
+
+    result_a = validate_callback_registration(
+        task_id=task_a,
+        envelope_path=env_a,
+        schedule_history_dir=str(history_dir),
+        inbound_search_dirs=[str(inbound_dir)],
+    )
+    assert result_a.sources_checked.get("schedule_id") == FAIL, (
+        "(a) canonical placeholder 'pending' should cause schedule_id FAIL, "
+        f"got: {result_a.sources_checked}. reasons={result_a.reasons}"
+    )
+    assert result_a.verdict == FAIL, \
+        f"(a) verdict should FAIL, got {result_a.verdict}"
+
+    # --- (b) canonical wrong owner_key ---
+    task_b = "task-canon-owner-2729"
+    sid_b = "SID-CANON-B"
+    _write_schedule_history(history_dir, sid_b, status="ok")
+    _write_inbound(inbound_dir, task_b)
+
+    env_b = _write_envelope(
+        tmp_path / f"{task_b}.envelope.json",
+        {
+            "task_id": task_b,
+            "schedule_id": sid_b,
+            "owner_key": "deadbeef00000000",  # canonical wrong key
+            "anu_key": ANU_KEY,               # alias (무시되어야 함)
+            "schedule_type": "cron_once",
+        },
+    )
+
+    result_b = validate_callback_registration(
+        task_id=task_b,
+        envelope_path=env_b,
+        schedule_history_dir=str(history_dir),
+        inbound_search_dirs=[str(inbound_dir)],
+    )
+    assert result_b.sources_checked.get("owner_key") == FAIL, (
+        "(b) canonical wrong owner_key should FAIL, "
+        f"got: {result_b.sources_checked}. reasons={result_b.reasons}"
+    )
+    assert result_b.verdict == FAIL, \
+        f"(b) verdict should FAIL, got {result_b.verdict}"
+
+
+# --------------------------------------------------------------------------- #
+# 6. test_existing_canonical_dogfood_pass
+# --------------------------------------------------------------------------- #
+
+def test_existing_canonical_dogfood_pass(tmp_path):
+    """canonical 필드만 사용 (기존 정상 동작 무손상).
+
+    schedule_id + owner_key 모두 canonical → 4-source AND PASS → verdict PASS.
+    """
+    task_id = "task-dogfood-2729"
+    sid = "SID-CANON"
+
+    history_dir = tmp_path / "schedule_history"
+    history_dir.mkdir()
+    inbound_dir = tmp_path / "inbound"
+    inbound_dir.mkdir()
+
+    _write_schedule_history(history_dir, sid, status="ok")
+    _write_inbound(inbound_dir, task_id)
+
+    env_path = _write_envelope(
+        tmp_path / f"{task_id}.envelope.json",
+        {
+            "task_id": task_id,
+            "schedule_id": sid,
+            "owner_key": ANU_KEY,
+            "schedule_type": "cron_once",
+        },
+    )
+
+    result = validate_callback_registration(
+        task_id=task_id,
+        envelope_path=env_path,
+        schedule_history_dir=str(history_dir),
+        inbound_search_dirs=[str(inbound_dir)],
+    )
+
+    assert result.verdict == PASS, (
+        f"canonical dogfood: expected PASS, got {result.verdict}. "
+        f"sources={result.sources_checked}, reasons={result.reasons}"
+    )
+    for src, v in result.sources_checked.items():
+        assert v == PASS, f"source '{src}' not PASS: {v}"
diff --git a/utils/normal_callback_registration_validator.py b/utils/normal_callback_registration_validator.py
index 51246e67..c1e30318 100644
--- a/utils/normal_callback_registration_validator.py
+++ b/utils/normal_callback_registration_validator.py
@@ -126,6 +126,12 @@ def _check_schedule_id(envelope: dict) -> tuple[str, Optional[str], List[str]]:
 
     schedule_id_raw = envelope.get("schedule_id")
     schedule_id = _normalize(schedule_id_raw)
+    # task-2729+2 reconciliation: registrar(anu_callback_registrar)는 real
+    # schedule_id 를 'cron_schedule_id' 필드로 기록한다. canonical 'schedule_id'
+    # 가 부재/공백일 때만 alias 로 회수한다(canonical 우선 — 검증 약화 0:
+    # placeholder/blocked/path-traversal/history status=ok 검사는 회수값에 그대로 적용).
+    if not schedule_id:
+        schedule_id = _normalize(envelope.get("cron_schedule_id"))
     schedule_type = _normalize(envelope.get("schedule_type"))
 
     # blocked schedule_type → 즉시 FAIL (sid 와 무관)
@@ -222,6 +228,11 @@ def _check_owner_key(
 
     # Gemini PR #155 medium: hex key case-insensitive 비교 (대문자 혼합 입력 방어).
     owner_key = _normalize(envelope.get("owner_key")).lower()
+    # task-2729+2 reconciliation: registrar 는 ANU key 를 'anu_key' 필드로 기록.
+    # canonical 'owner_key' 가 부재/공백일 때만 alias 로 회수(canonical 우선 —
+    # 검증 약화 0: self-key→NON_AUTHORITATIVE, !=ANU→FAIL 정책 그대로 적용).
+    if not owner_key:
+        owner_key = _normalize(envelope.get("anu_key")).lower()
     evidence["owner_key_present"] = bool(owner_key)
 
     if not owner_key:
-- 
2.43.0

