tests/gateway/test_telegram_physique_checkin.py-2014-        adapter._send_message_with_thread_fallback = AsyncMock()
--
tests/gateway/test_telegram_physique_checkin.py-2028-
tests/gateway/test_telegram_physique_checkin.py-2029-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2030-    async def test_source_review_callback_requires_the_exact_owner_topic_and_approves_one_candidate(self, monkeypatch):
tests/gateway/test_telegram_physique_checkin.py-2031-        # Given: a source-review card and a queue candidate in the private profile scope.
tests/gateway/test_telegram_physique_checkin.py:2032:        adapter = self._adapter({"physique_checkin": {"enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1"}})
tests/gateway/test_telegram_physique_checkin.py-2033-        candidate = SimpleNamespace(candidate_id="a" * 64, source=SimpleNamespace(value="naver"), title="감량기 탄수화물 배분")
tests/gateway/test_telegram_physique_checkin.py-2034-        queue = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2035-        queue.candidate_by_prefix.return_value = candidate
tests/gateway/test_telegram_physique_checkin.py-2036-        queue.approve.return_value = SimpleNamespace(approved_count=1)
--
tests/gateway/test_telegram_physique_checkin.py-2085-        query.answer.assert_not_awaited()
tests/gateway/test_telegram_physique_checkin.py-2086-
tests/gateway/test_telegram_physique_checkin.py-2087-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2088-    async def test_enabled_pc1_text_is_consumed_before_generic_aggregation(self, monkeypatch):
tests/gateway/test_telegram_physique_checkin.py:2089:        adapter = self._adapter({"physique_checkin": {"enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1"}})
tests/gateway/test_telegram_physique_checkin.py-2090-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2091-        fake.handle_text.return_value = WizardReply(True, True, "", None, None)
tests/gateway/test_telegram_physique_checkin.py:2092:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2093-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2094-        message = SimpleNamespace(
tests/gateway/test_telegram_physique_checkin.py-2095-            text="70.2", chat=SimpleNamespace(id="chat-1", type="supergroup", is_forum=True),
tests/gateway/test_telegram_physique_checkin.py-2096-            from_user=SimpleNamespace(id="owner-1"), message_thread_id="topic-1", is_topic_message=True,
--
tests/gateway/test_telegram_physique_checkin.py-2101-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2102-
tests/gateway/test_telegram_physique_checkin.py-2103-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2104-    async def test_invalid_anonymous_natural_language_wizard_text_uses_model_without_generic_ingress(self):
tests/gateway/test_telegram_physique_checkin.py:2105:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2106-            "enabled": True,
tests/gateway/test_telegram_physique_checkin.py-2107-            "owner_id": "owner-1",
tests/gateway/test_telegram_physique_checkin.py-2108-            "chat_id": "chat-1",
tests/gateway/test_telegram_physique_checkin.py-2109-            "topic_id": "topic-1",
--
tests/gateway/test_telegram_physique_checkin.py-2116-            "오늘 체중을 못 쟀구나.",
tests/gateway/test_telegram_physique_checkin.py-2117-            WizardPrompt("오늘 체중을 못 쟀구나. 나중에 숫자로 보내면 이어갈게."),
tests/gateway/test_telegram_physique_checkin.py-2118-            None,
tests/gateway/test_telegram_physique_checkin.py-2119-        )
tests/gateway/test_telegram_physique_checkin.py:2120:        fake.active_checkin_snapshot.return_value = {
tests/gateway/test_telegram_physique_checkin.py-2121-            "step": "bodyweight", "flow": "morning", "kst_day": "2026-07-18", "answers": {},
tests/gateway/test_telegram_physique_checkin.py-2122-        }
tests/gateway/test_telegram_physique_checkin.py-2123-        fake.apply_model_action.return_value = WizardReply(True, True, "", WizardPrompt("① 기상 직후 체중(kg)을 숫자로 보내줘. 예: 70.2"), None)
tests/gateway/test_telegram_physique_checkin.py:2124:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2125-        adapter._interpret_physique_active_turn = AsyncMock(return_value=("stay", None, "괜찮아. 지금은 체중 없이 진행할지 먼저 정해보자."))
tests/gateway/test_telegram_physique_checkin.py-2126-        adapter._render_physique_text_prompt = AsyncMock()
tests/gateway/test_telegram_physique_checkin.py-2127-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2128-        message = SimpleNamespace(
--
tests/gateway/test_telegram_physique_checkin.py-2144-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2145-
tests/gateway/test_telegram_physique_checkin.py-2146-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2147-    async def test_active_natural_language_turn_uses_model_action_instead_of_static_invalid_template(self):
tests/gateway/test_telegram_physique_checkin.py:2148:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2149-            "enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1",
tests/gateway/test_telegram_physique_checkin.py-2150-            "allow_anonymous_sender_chat": True,
tests/gateway/test_telegram_physique_checkin.py-2151-        }})
tests/gateway/test_telegram_physique_checkin.py-2152-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2153-        fake.handle_text.return_value = WizardReply(True, False, "입력을 확인하고 다시 시도해줘.", WizardPrompt("STATIC"), None)
tests/gateway/test_telegram_physique_checkin.py:2154:        fake.active_checkin_snapshot.return_value = {
tests/gateway/test_telegram_physique_checkin.py-2155-            "step": "bodyweight", "flow": "morning", "kst_day": "2026-07-18", "answers": {},
tests/gateway/test_telegram_physique_checkin.py-2156-        }
tests/gateway/test_telegram_physique_checkin.py-2157-        fake.apply_model_action.return_value = WizardReply(True, True, "", WizardPrompt("② 수면 시간을 숫자로 보내줘."), None)
tests/gateway/test_telegram_physique_checkin.py:2158:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2159-        adapter._interpret_physique_active_turn = AsyncMock(return_value=("skip", None, "좋아, 오늘 체중은 결측으로 남기고 수면부터 이어가자."))
tests/gateway/test_telegram_physique_checkin.py-2160-        adapter._render_physique_text_prompt = AsyncMock()
tests/gateway/test_telegram_physique_checkin.py-2161-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2162-        message = SimpleNamespace(
--
tests/gateway/test_telegram_physique_checkin.py-2167-
tests/gateway/test_telegram_physique_checkin.py-2168-        await adapter._handle_text_message(SimpleNamespace(message=message, effective_message=message, update_id=1), None)
tests/gateway/test_telegram_physique_checkin.py-2169-
tests/gateway/test_telegram_physique_checkin.py-2170-        adapter._interpret_physique_active_turn.assert_awaited_once_with(
tests/gateway/test_telegram_physique_checkin.py:2171:            "N/A로 기록하고 다음으로 넘어가자", fake.active_checkin_snapshot.return_value,
tests/gateway/test_telegram_physique_checkin.py-2172-        )
tests/gateway/test_telegram_physique_checkin.py-2173-        fake.apply_model_action.assert_called_once_with("skip", None)
tests/gateway/test_telegram_physique_checkin.py-2174-        rendered = adapter._render_physique_text_prompt.call_args.args[1]
tests/gateway/test_telegram_physique_checkin.py-2175-        assert rendered.prompt is not None
--
tests/gateway/test_telegram_physique_checkin.py-2178-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2179-
tests/gateway/test_telegram_physique_checkin.py-2180-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2181-    async def test_anonymous_sleep_quality_feedback_rewrites_the_active_card_without_generic_ingress(self):
tests/gateway/test_telegram_physique_checkin.py:2182:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2183-            "enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1",
tests/gateway/test_telegram_physique_checkin.py-2184-            "allow_anonymous_sender_chat": True,
tests/gateway/test_telegram_physique_checkin.py-2185-        }})
tests/gateway/test_telegram_physique_checkin.py-2186-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2187-        fake.handle_text.return_value = None
tests/gateway/test_telegram_physique_checkin.py:2188:        fake.active_checkin_snapshot.return_value = {
tests/gateway/test_telegram_physique_checkin.py-2189-            "step": "sleep_quality", "flow": "morning", "kst_day": "2026-07-18", "answers": {"sleep_duration": "7"},
tests/gateway/test_telegram_physique_checkin.py-2190-        }
tests/gateway/test_telegram_physique_checkin.py-2191-        prompt = WizardPrompt(
tests/gateway/test_telegram_physique_checkin.py-2192-            "수면 질 점수 (1=매우 나쁨 · 3=보통 · 5=매우 좋음)를 골라줘.",
tests/gateway/test_telegram_physique_checkin.py-2193-            (("1", "pc1:0123456789abcdef0123456789abcdef:sleep_quality:2:a1"),),
tests/gateway/test_telegram_physique_checkin.py-2194-        )
tests/gateway/test_telegram_physique_checkin.py-2195-        fake.apply_model_action.return_value = WizardReply(True, True, "", prompt, None)
tests/gateway/test_telegram_physique_checkin.py:2196:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2197-        adapter._interpret_physique_active_turn = AsyncMock(return_value=("rewrite_prompt", None, "맞아, 수면의 질을 묻는 점수야."))
tests/gateway/test_telegram_physique_checkin.py-2198-        adapter._render_physique_model_turn = AsyncMock()
tests/gateway/test_telegram_physique_checkin.py-2199-        adapter._render_physique_conversation_reply = AsyncMock()
tests/gateway/test_telegram_physique_checkin.py-2200-        adapter._enqueue_text_event = MagicMock()
--
tests/gateway/test_telegram_physique_checkin.py-2207-
tests/gateway/test_telegram_physique_checkin.py-2208-        await adapter._handle_text_message(SimpleNamespace(message=message, effective_message=message, update_id=1), None)
tests/gateway/test_telegram_physique_checkin.py-2209-
tests/gateway/test_telegram_physique_checkin.py-2210-        adapter._interpret_physique_active_turn.assert_awaited_once_with(
tests/gateway/test_telegram_physique_checkin.py:2211:            "점수도 무슨 점수를 말하는건지 고칠 수 있냐?", fake.active_checkin_snapshot.return_value,
tests/gateway/test_telegram_physique_checkin.py-2212-        )
tests/gateway/test_telegram_physique_checkin.py-2213-        fake.apply_model_action.assert_called_once_with("rewrite_prompt", None)
tests/gateway/test_telegram_physique_checkin.py-2214-        adapter._render_physique_model_turn.assert_awaited_once_with(
tests/gateway/test_telegram_physique_checkin.py-2215-            message, fake.apply_model_action.return_value, "맞아, 수면의 질을 묻는 점수야.", edit_current=True,
--
tests/gateway/test_telegram_physique_checkin.py-2218-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2219-
tests/gateway/test_telegram_physique_checkin.py-2220-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2221-    async def test_target_topic_conversation_uses_isolated_chos_coach_model_without_generic_ingress(self):
tests/gateway/test_telegram_physique_checkin.py:2222:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2223-            "enabled": True,
tests/gateway/test_telegram_physique_checkin.py-2224-            "owner_id": "owner-1",
tests/gateway/test_telegram_physique_checkin.py-2225-            "chat_id": "100",
tests/gateway/test_telegram_physique_checkin.py-2226-            "topic_id": "101",
--
tests/gateway/test_telegram_physique_checkin.py-2229-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2230-        fake.handle_text.return_value = None
tests/gateway/test_telegram_physique_checkin.py-2231-        fake.accepts_context.return_value = True
tests/gateway/test_telegram_physique_checkin.py-2232-        fake.latest_finalized_coaching_snapshot.return_value = None
tests/gateway/test_telegram_physique_checkin.py:2233:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2234-        adapter._generate_physique_conversation_reply = AsyncMock(return_value="오늘은 무리하지 말고 회복부터 챙겨.")
tests/gateway/test_telegram_physique_checkin.py-2235-        adapter._send_message_with_thread_fallback = AsyncMock(return_value=SimpleNamespace(message_id=77))
tests/gateway/test_telegram_physique_checkin.py-2236-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2237-        message = SimpleNamespace(
--
tests/gateway/test_telegram_physique_checkin.py-2253-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2254-
tests/gateway/test_telegram_physique_checkin.py-2255-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2256-    async def test_anonymous_typed_value_uses_owner_only_for_the_exact_opted_in_topic(self):
tests/gateway/test_telegram_physique_checkin.py:2257:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2258-            "enabled": True,
tests/gateway/test_telegram_physique_checkin.py-2259-            "owner_id": "owner-1",
tests/gateway/test_telegram_physique_checkin.py-2260-            "chat_id": "chat-1",
tests/gateway/test_telegram_physique_checkin.py-2261-            "topic_id": "topic-1",
tests/gateway/test_telegram_physique_checkin.py-2262-            "allow_anonymous_sender_chat": True,
tests/gateway/test_telegram_physique_checkin.py-2263-        }})
tests/gateway/test_telegram_physique_checkin.py-2264-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2265-        fake.handle_text.return_value = WizardReply(True, True, "", None, None)
tests/gateway/test_telegram_physique_checkin.py:2266:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2267-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2268-        message = SimpleNamespace(
tests/gateway/test_telegram_physique_checkin.py-2269-            text="70.2", chat=SimpleNamespace(id="chat-1", type="supergroup", is_forum=True),
tests/gateway/test_telegram_physique_checkin.py-2270-            from_user=None, sender_chat=SimpleNamespace(id="chat-1"),
--
tests/gateway/test_telegram_physique_checkin.py-2277-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2278-
tests/gateway/test_telegram_physique_checkin.py-2279-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2280-    async def test_anonymous_typed_value_uses_sender_chat_when_telegram_supplies_a_fake_user(self):
tests/gateway/test_telegram_physique_checkin.py:2281:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2282-            "enabled": True,
tests/gateway/test_telegram_physique_checkin.py-2283-            "owner_id": "owner-1",
tests/gateway/test_telegram_physique_checkin.py-2284-            "chat_id": "chat-1",
tests/gateway/test_telegram_physique_checkin.py-2285-            "topic_id": "topic-1",
tests/gateway/test_telegram_physique_checkin.py-2286-            "allow_anonymous_sender_chat": True,
tests/gateway/test_telegram_physique_checkin.py-2287-        }})
tests/gateway/test_telegram_physique_checkin.py-2288-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2289-        fake.handle_text.return_value = WizardReply(True, True, "", None, None)
tests/gateway/test_telegram_physique_checkin.py:2290:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2291-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2292-        message = SimpleNamespace(
tests/gateway/test_telegram_physique_checkin.py-2293-            text="70.2", chat=SimpleNamespace(id="chat-1", type="supergroup", is_forum=True),
tests/gateway/test_telegram_physique_checkin.py-2294-            from_user=SimpleNamespace(id="telegram-fake-user"), sender_chat=SimpleNamespace(id="chat-1"),
--
tests/gateway/test_telegram_physique_checkin.py-2300-        fake.handle_text.assert_called_once_with("70.2", "owner-1", "chat-1", "topic-1")
tests/gateway/test_telegram_physique_checkin.py-2301-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2302-
tests/gateway/test_telegram_physique_checkin.py-2303-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py:2304:    async def test_expired_target_numeric_answer_reopens_checkin_instead_of_reaching_generic_agent(self):
tests/gateway/test_telegram_physique_checkin.py:2305:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2306-            "enabled": True,
tests/gateway/test_telegram_physique_checkin.py-2307-            "owner_id": "owner-1",
tests/gateway/test_telegram_physique_checkin.py-2308-            "chat_id": "chat-1",
tests/gateway/test_telegram_physique_checkin.py-2309-            "topic_id": "topic-1",
tests/gateway/test_telegram_physique_checkin.py-2310-        }})
tests/gateway/test_telegram_physique_checkin.py-2311-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2312-        fake.handle_text.return_value = None
tests/gateway/test_telegram_physique_checkin.py-2313-        fake.accepts_context.return_value = True
tests/gateway/test_telegram_physique_checkin.py:2314:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2315-        adapter._bot.username = ""
tests/gateway/test_telegram_physique_checkin.py-2316-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2317-        adapter._should_process_message = MagicMock(return_value=False)
tests/gateway/test_telegram_physique_checkin.py-2318-        adapter._should_observe_unmentioned_group_message = MagicMock(return_value=False)
--
tests/gateway/test_telegram_physique_checkin.py-2325-        )
tests/gateway/test_telegram_physique_checkin.py-2326-
tests/gateway/test_telegram_physique_checkin.py-2327-        await adapter._handle_text_message(SimpleNamespace(message=message, effective_message=message, update_id=1), None)
tests/gateway/test_telegram_physique_checkin.py-2328-
tests/gateway/test_telegram_physique_checkin.py:2329:        adapter.send_inline_card.assert_awaited_once_with("physique-checkin-morning")
tests/gateway/test_telegram_physique_checkin.py-2330-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2331-
tests/gateway/test_telegram_physique_checkin.py-2332-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2333-    async def test_expired_real_binding_numeric_answer_is_recovered_before_generic_ingress(self, tmp_path):
--
tests/gateway/test_telegram_physique_checkin.py-2347-        )
tests/gateway/test_telegram_physique_checkin.py-2348-        assert started.accepted is True
tests/gateway/test_telegram_physique_checkin.py-2349-
tests/gateway/test_telegram_physique_checkin.py-2350-        # Loading at real current time discards the binding that expired at 700.
tests/gateway/test_telegram_physique_checkin.py:2351:        expired_bridge = PhysiqueCheckinBridge(
tests/gateway/test_telegram_physique_checkin.py-2352-            _config(), service=service, binding_path=binding_path,
tests/gateway/test_telegram_physique_checkin.py-2353-        )
tests/gateway/test_telegram_physique_checkin.py-2354-        assert expired_bridge._active_session_id is None
tests/gateway/test_telegram_physique_checkin.py-2355-
tests/gateway/test_telegram_physique_checkin.py:2356:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2357-            "enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1",
tests/gateway/test_telegram_physique_checkin.py-2358-        }})
tests/gateway/test_telegram_physique_checkin.py:2359:        adapter._physique_checkin = expired_bridge
tests/gateway/test_telegram_physique_checkin.py-2360-        adapter._bot.username = ""
tests/gateway/test_telegram_physique_checkin.py-2361-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2362-        adapter._should_process_message = MagicMock(return_value=False)
tests/gateway/test_telegram_physique_checkin.py-2363-        adapter._should_observe_unmentioned_group_message = MagicMock(return_value=False)
--
tests/gateway/test_telegram_physique_checkin.py-2372-        await adapter._handle_text_message(
tests/gateway/test_telegram_physique_checkin.py-2373-            SimpleNamespace(message=message, effective_message=message, update_id=1), None,
tests/gateway/test_telegram_physique_checkin.py-2374-        )
tests/gateway/test_telegram_physique_checkin.py-2375-
tests/gateway/test_telegram_physique_checkin.py:2376:        adapter.send_inline_card.assert_awaited_once_with("physique-checkin-morning")
tests/gateway/test_telegram_physique_checkin.py-2377-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2378-        assert service.answers == []
tests/gateway/test_telegram_physique_checkin.py-2379-
tests/gateway/test_telegram_physique_checkin.py-2380-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2381-    async def test_owner_recovery_command_sends_one_topic_bound_card_before_generic_ingress(self):
tests/gateway/test_telegram_physique_checkin.py:2382:        adapter = self._adapter({"physique_checkin": {"enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1"}})
tests/gateway/test_telegram_physique_checkin.py-2383-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2384-        fake.accepts_context.return_value = True
tests/gateway/test_telegram_physique_checkin.py-2385-        fake.handle_text.return_value = None
tests/gateway/test_telegram_physique_checkin.py:2386:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2387-        adapter.send_inline_card = AsyncMock()
tests/gateway/test_telegram_physique_checkin.py-2388-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2389-        message = SimpleNamespace(
tests/gateway/test_telegram_physique_checkin.py-2390-            text="체크인 시작", chat=SimpleNamespace(id="chat-1", type="supergroup", is_forum=True),
--
tests/gateway/test_telegram_physique_checkin.py-2392-        )
tests/gateway/test_telegram_physique_checkin.py-2393-
tests/gateway/test_telegram_physique_checkin.py-2394-        await adapter._handle_text_message(SimpleNamespace(message=message, effective_message=message, update_id=1), None)
tests/gateway/test_telegram_physique_checkin.py-2395-
tests/gateway/test_telegram_physique_checkin.py:2396:        adapter.send_inline_card.assert_awaited_once_with("physique-checkin-morning")
tests/gateway/test_telegram_physique_checkin.py-2397-        adapter._enqueue_text_event.assert_not_called()
tests/gateway/test_telegram_physique_checkin.py-2398-
tests/gateway/test_telegram_physique_checkin.py-2399-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2400-    async def test_foreign_or_wrong_topic_recovery_command_is_denied_before_generic_ingress(self):
tests/gateway/test_telegram_physique_checkin.py:2401:        adapter = self._adapter({"physique_checkin": {"enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1"}})
tests/gateway/test_telegram_physique_checkin.py-2402-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2403-        fake.accepts_context.return_value = False
tests/gateway/test_telegram_physique_checkin.py-2404-        fake.handle_text.return_value = None
tests/gateway/test_telegram_physique_checkin.py:2405:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2406-        adapter.send_inline_card = AsyncMock()
tests/gateway/test_telegram_physique_checkin.py-2407-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2408-        message = SimpleNamespace(
tests/gateway/test_telegram_physique_checkin.py-2409-            text="체크인 시작", chat=SimpleNamespace(id="chat-1", type="supergroup", is_forum=True),
--
tests/gateway/test_telegram_physique_checkin.py-2418-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2419-    async def test_failed_scheduled_launch_recovers_once_by_owner_command_without_automatic_retry(self, tmp_path):
tests/gateway/test_telegram_physique_checkin.py-2420-        class FailingScheduledTransport:
tests/gateway/test_telegram_physique_checkin.py-2421-            def is_inline_card_enabled(self, card: str) -> bool:
tests/gateway/test_telegram_physique_checkin.py:2422:                return card == "physique-checkin-morning"
tests/gateway/test_telegram_physique_checkin.py-2423-
tests/gateway/test_telegram_physique_checkin.py-2424-            def send_inline_card(self, card: str) -> bool:
tests/gateway/test_telegram_physique_checkin.py-2425-                return False
tests/gateway/test_telegram_physique_checkin.py-2426-
--
tests/gateway/test_telegram_physique_checkin.py-2428-            tmp_path,
tests/gateway/test_telegram_physique_checkin.py-2429-            FailingScheduledTransport(),
tests/gateway/test_telegram_physique_checkin.py-2430-            datetime(2031, 2, 3, 8, 11, tzinfo=ZoneInfo("Asia/Seoul")),
tests/gateway/test_telegram_physique_checkin.py-2431-        )
tests/gateway/test_telegram_physique_checkin.py:2432:        adapter = self._adapter({"physique_checkin": {"enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1"}})
tests/gateway/test_telegram_physique_checkin.py-2433-        bridge = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2434-        bridge.accepts_context.return_value = True
tests/gateway/test_telegram_physique_checkin.py:2435:        adapter._physique_checkin = bridge
tests/gateway/test_telegram_physique_checkin.py-2436-        adapter.send_inline_card = AsyncMock()
tests/gateway/test_telegram_physique_checkin.py-2437-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2438-        message = SimpleNamespace(
tests/gateway/test_telegram_physique_checkin.py-2439-            text="체크인 시작", chat=SimpleNamespace(id="chat-1", type="supergroup", is_forum=True),
--
tests/gateway/test_telegram_physique_checkin.py-2447-            datetime(2031, 2, 3, 8, 12, tzinfo=ZoneInfo("Asia/Seoul")),
tests/gateway/test_telegram_physique_checkin.py-2448-        )
tests/gateway/test_telegram_physique_checkin.py-2449-
tests/gateway/test_telegram_physique_checkin.py-2450-        assert scheduled.claimed is True and scheduled.sent is False
tests/gateway/test_telegram_physique_checkin.py:2451:        adapter.send_inline_card.assert_awaited_once_with("physique-checkin-morning")
tests/gateway/test_telegram_physique_checkin.py-2452-        assert automatic_retry.duplicate is True
tests/gateway/test_telegram_physique_checkin.py-2453-
tests/gateway/test_telegram_physique_checkin.py-2454-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2455-    async def test_enabled_pc1_callback_is_consumed_before_existing_callbacks(self):
tests/gateway/test_telegram_physique_checkin.py:2456:        adapter = self._adapter({"physique_checkin": {"enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1"}})
tests/gateway/test_telegram_physique_checkin.py-2457-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2458-        fake.handle_callback.return_value = WizardReply(True, False, "denied", None, None)
tests/gateway/test_telegram_physique_checkin.py:2459:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2460-        query = _callback("pc1:0123456789abcdef0123456789abcdef:launch:0:start")
tests/gateway/test_telegram_physique_checkin.py-2461-
tests/gateway/test_telegram_physique_checkin.py-2462-        await adapter._handle_callback_query(SimpleNamespace(callback_query=query), None)
tests/gateway/test_telegram_physique_checkin.py-2463-
--
tests/gateway/test_telegram_physique_checkin.py-2465-        query.answer.assert_awaited_once_with(text="denied")
tests/gateway/test_telegram_physique_checkin.py-2466-
tests/gateway/test_telegram_physique_checkin.py-2467-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2468-    async def test_typed_action_edits_the_same_topic_prompt_to_a_direct_question(self):
tests/gateway/test_telegram_physique_checkin.py:2469:        adapter = self._adapter({"physique_checkin": {"enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1"}})
tests/gateway/test_telegram_physique_checkin.py-2470-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2471-        fake.handle_callback.return_value = WizardReply(True, True, "", WizardPrompt("통증 위치·강도·언제부터인지 적어줘."), None)
tests/gateway/test_telegram_physique_checkin.py:2472:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2473-        query = _callback("pc1:0123456789abcdef0123456789abcdef:pain:0:a1")
tests/gateway/test_telegram_physique_checkin.py-2474-
tests/gateway/test_telegram_physique_checkin.py-2475-        await adapter._handle_callback_query(SimpleNamespace(callback_query=query), None)
tests/gateway/test_telegram_physique_checkin.py-2476-
--
tests/gateway/test_telegram_physique_checkin.py-2479-
tests/gateway/test_telegram_physique_checkin.py-2480-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2481-    async def test_save_callback_records_once_and_replaces_summary_with_confirmation(self, tmp_path):
tests/gateway/test_telegram_physique_checkin.py-2482-        """A real finalized wizard must visibly confirm instead of re-rendering Save."""
tests/gateway/test_telegram_physique_checkin.py:2483:        profile_source = "/home/cube/.hermes/profiles/physique-coach/workspace/checkin_cli"
tests/gateway/test_telegram_physique_checkin.py-2484-        if profile_source not in sys.path:
tests/gateway/test_telegram_physique_checkin.py-2485-            sys.path.insert(0, profile_source)
tests/gateway/test_telegram_physique_checkin.py:2486:        from checkin_cli.wizard import WizardService
tests/gateway/test_telegram_physique_checkin.py:2487:        from checkin_cli.wizard_models import WizardContext
tests/gateway/test_telegram_physique_checkin.py-2488-
tests/gateway/test_telegram_physique_checkin.py-2489-        # Given: a real profile draft at the summary step and a bound Telegram card.
tests/gateway/test_telegram_physique_checkin.py-2490-        service = WizardService.for_standalone(tmp_path / "wizard-state")
tests/gateway/test_telegram_physique_checkin.py-2491-        wizard_context = WizardContext("owner-1", "topic-1")
--
tests/gateway/test_telegram_physique_checkin.py-2497-        ):
tests/gateway/test_telegram_physique_checkin.py-2498-            transitioned = service.answer(wizard_context, session_id, version, action, value)
tests/gateway/test_telegram_physique_checkin.py-2499-            version = transitioned.version
tests/gateway/test_telegram_physique_checkin.py-2500-        assert transitioned.step == "summary"
tests/gateway/test_telegram_physique_checkin.py:2501:        bridge = PhysiqueCheckinBridge(_config(), service=service, binding_path=tmp_path / "bindings.json")
tests/gateway/test_telegram_physique_checkin.py-2502-        bridge._bindings[session_id] = WizardBinding(
tests/gateway/test_telegram_physique_checkin.py-2503-            session_id, "owner-1", "chat-1", "topic-1", "summary", version, "44", 4_000_000_000,
tests/gateway/test_telegram_physique_checkin.py-2504-        )
tests/gateway/test_telegram_physique_checkin.py-2505-        bridge._active_session_id = session_id
tests/gateway/test_telegram_physique_checkin.py-2506-        bridge._persist()
tests/gateway/test_telegram_physique_checkin.py:2507:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2508-            "enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1",
tests/gateway/test_telegram_physique_checkin.py-2509-        }})
tests/gateway/test_telegram_physique_checkin.py:2510:        adapter._physique_checkin = bridge
tests/gateway/test_telegram_physique_checkin.py-2511-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2512-        callback = CallbackData(session_id, "summary", version, "a0").encode()
tests/gateway/test_telegram_physique_checkin.py-2513-        query = _callback(callback)
tests/gateway/test_telegram_physique_checkin.py-2514-
--
tests/gateway/test_telegram_physique_checkin.py-2539-        assert replay.accepted is False
tests/gateway/test_telegram_physique_checkin.py-2540-        assert len((tmp_path / "wizard-state" / "events.jsonl").read_text().splitlines()) == 1
tests/gateway/test_telegram_physique_checkin.py-2541-
tests/gateway/test_telegram_physique_checkin.py-2542-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py:2543:    async def test_saved_checkin_adds_profile_gated_model_feedback_without_generic_ingress(self, tmp_path):
tests/gateway/test_telegram_physique_checkin.py-2544-        """Only an accepted finalized private check-in may ask the coach model."""
tests/gateway/test_telegram_physique_checkin.py:2545:        profile_source = "/home/cube/.hermes/profiles/physique-coach/workspace/checkin_cli"
tests/gateway/test_telegram_physique_checkin.py-2546-        if profile_source not in sys.path:
tests/gateway/test_telegram_physique_checkin.py-2547-            sys.path.insert(0, profile_source)
tests/gateway/test_telegram_physique_checkin.py:2548:        from checkin_cli.wizard import WizardService
tests/gateway/test_telegram_physique_checkin.py:2549:        from checkin_cli.wizard_models import WizardContext
tests/gateway/test_telegram_physique_checkin.py-2550-
tests/gateway/test_telegram_physique_checkin.py-2551-        service = WizardService.for_standalone(tmp_path / "wizard-state")
tests/gateway/test_telegram_physique_checkin.py-2552-        wizard_context = WizardContext("owner-1", "topic-1")
tests/gateway/test_telegram_physique_checkin.py-2553-        started = service.start_morning(wizard_context, "2031-02-03")
--
tests/gateway/test_telegram_physique_checkin.py-2557-            ("select", "none"), ("value", "2400"), ("select", "rest"), ("select", "skip"),
tests/gateway/test_telegram_physique_checkin.py-2558-        ):
tests/gateway/test_telegram_physique_checkin.py-2559-            transitioned = service.answer(wizard_context, session_id, version, action, value)
tests/gateway/test_telegram_physique_checkin.py-2560-            version = transitioned.version
tests/gateway/test_telegram_physique_checkin.py:2561:        bridge = PhysiqueCheckinBridge(_config(), service=service, binding_path=tmp_path / "bindings.json")
tests/gateway/test_telegram_physique_checkin.py-2562-        bridge._bindings[session_id] = WizardBinding(
tests/gateway/test_telegram_physique_checkin.py-2563-            session_id, "owner-1", "chat-1", "topic-1", "summary", version, "44", 4_000_000_000,
tests/gateway/test_telegram_physique_checkin.py-2564-        )
tests/gateway/test_telegram_physique_checkin.py-2565-        bridge._active_session_id = session_id
tests/gateway/test_telegram_physique_checkin.py-2566-        bridge._persist()
tests/gateway/test_telegram_physique_checkin.py:2567:        adapter = self._adapter({"physique_checkin": {
tests/gateway/test_telegram_physique_checkin.py-2568-            "enabled": True, "owner_id": "owner-1", "chat_id": "chat-1", "topic_id": "topic-1",
tests/gateway/test_telegram_physique_checkin.py-2569-            "coaching_feedback_enabled": True,
tests/gateway/test_telegram_physique_checkin.py-2570-        }})
tests/gateway/test_telegram_physique_checkin.py:2571:        adapter._physique_checkin = bridge
tests/gateway/test_telegram_physique_checkin.py-2572-        adapter._enqueue_text_event = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2573-        adapter._humanize_korean_copy = AsyncMock(
tests/gateway/test_telegram_physique_checkin.py-2574-            side_effect=lambda _surface, text, _grounding: text.replace(
tests/gateway/test_telegram_physique_checkin.py-2575-                "일부 항목이 기록되지 않아 저장된 내용만 안내합니다.",
--
tests/gateway/test_telegram_physique_checkin.py-2627-                assert tz is timezone.utc
tests/gateway/test_telegram_physique_checkin.py-2628-                return datetime(2026, 7, 17, 15, 0, tzinfo=timezone.utc)
tests/gateway/test_telegram_physique_checkin.py-2629-
tests/gateway/test_telegram_physique_checkin.py-2630-        monkeypatch.setattr(telegram_module, "datetime", _FixedDateTime)
tests/gateway/test_telegram_physique_checkin.py:2631:        adapter = self._adapter({"physique_checkin": {"enabled": True, "owner_id": "1", "chat_id": "100", "topic_id": "101"}})
tests/gateway/test_telegram_physique_checkin.py-2632-        fake = MagicMock()
tests/gateway/test_telegram_physique_checkin.py-2633-        fake.open_launcher.side_effect = (
tests/gateway/test_telegram_physique_checkin.py-2634-            WizardReply(True, True, "", None, "pc1:0123456789abcdef0123456789abcdef:launch:0:start"),
tests/gateway/test_telegram_physique_checkin.py-2635-            WizardReply(True, True, "", None, "pc1:fedcba9876543210fedcba9876543210:launch:0:start"),
tests/gateway/test_telegram_physique_checkin.py-2636-        )
tests/gateway/test_telegram_physique_checkin.py:2637:        adapter._physique_checkin = fake
tests/gateway/test_telegram_physique_checkin.py-2638-        adapter._send_message_with_thread_fallback = AsyncMock(return_value=SimpleNamespace(message_id=77))
tests/gateway/test_telegram_physique_checkin.py-2639-        adapter._thread_kwargs_for_send = MagicMock(return_value={"message_thread_id": 101})
tests/gateway/test_telegram_physique_checkin.py-2640-
tests/gateway/test_telegram_physique_checkin.py:2641:        result = await adapter.send_physique_checkin_launcher()
tests/gateway/test_telegram_physique_checkin.py-2642-
tests/gateway/test_telegram_physique_checkin.py-2643-        assert result.success is True
tests/gateway/test_telegram_physique_checkin.py-2644-        kwargs = adapter._send_message_with_thread_fallback.call_args.kwargs
tests/gateway/test_telegram_physique_checkin.py-2645-        assert kwargs["chat_id"] == 100
--
tests/gateway/test_telegram_physique_checkin.py-2652-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2653-    async def test_inline_card_capability_is_inert_when_profile_feature_is_disabled(self):
tests/gateway/test_telegram_physique_checkin.py-2654-        adapter = self._adapter({})
tests/gateway/test_telegram_physique_checkin.py-2655-
tests/gateway/test_telegram_physique_checkin.py:2656:        result = await adapter.send_inline_card("physique-checkin-morning")
tests/gateway/test_telegram_physique_checkin.py-2657-
tests/gateway/test_telegram_physique_checkin.py:2658:        assert adapter.is_inline_card_enabled("physique-checkin-morning") is False
tests/gateway/test_telegram_physique_checkin.py-2659-        assert result.success is False
tests/gateway/test_telegram_physique_checkin.py-2660-
tests/gateway/test_telegram_physique_checkin.py-2661-class TestNutritionCopySurfaces:
tests/gateway/test_telegram_physique_checkin.py-2662-    def test_daily_copy_keeps_the_approved_sections_and_spacing(self):
--
tests/gateway/test_telegram_physique_checkin.py-2714-        summary = SimpleNamespace(
tests/gateway/test_telegram_physique_checkin.py-2715-            average_weight_kg=78.47,
tests/gateway/test_telegram_physique_checkin.py-2716-            prior_average_weight_kg=78.34,
tests/gateway/test_telegram_physique_checkin.py-2717-            weekly_change_percent=0.16,
tests/gateway/test_telegram_physique_checkin.py:2718:            checkin_rate_percent=100,
tests/gateway/test_telegram_physique_checkin.py-2719-            goal_range="+0.10~+0.25%",
tests/gateway/test_telegram_physique_checkin.py-2720-            judgment="유지",
tests/gateway/test_telegram_physique_checkin.py-2721-            actions=(
tests/gateway/test_telegram_physique_checkin.py-2722-                "기준 섭취량: 2,600kcal 유지",
--
tests/gateway/test_telegram_physique_checkin.py-2762-            "기록이 충분하지 않아 이번 주 판단을 확정하지 않습니다."
tests/gateway/test_telegram_physique_checkin.py-2763-        )
tests/gateway/test_telegram_physique_checkin.py-2764-
tests/gateway/test_telegram_physique_checkin.py-2765-
tests/gateway/test_telegram_physique_checkin.py:2766:def _schedule_checkin_cli():
tests/gateway/test_telegram_physique_checkin.py:2767:    profile_source = "/home/cube/.hermes/profiles/physique-coach/workspace/checkin_cli"
tests/gateway/test_telegram_physique_checkin.py-2768-    if profile_source not in sys.path:
tests/gateway/test_telegram_physique_checkin.py-2769-        sys.path.insert(0, profile_source)
tests/gateway/test_telegram_physique_checkin.py:2770:    import checkin_cli
tests/gateway/test_telegram_physique_checkin.py-2771-
tests/gateway/test_telegram_physique_checkin.py:2772:    return checkin_cli
tests/gateway/test_telegram_physique_checkin.py-2773-
tests/gateway/test_telegram_physique_checkin.py-2774-
tests/gateway/test_telegram_physique_checkin.py-2775-class _ScheduleCoordinator:
tests/gateway/test_telegram_physique_checkin.py-2776-    def __init__(self, profile_root: Path):
--
tests/gateway/test_telegram_physique_checkin.py-2836-    adapter._thread_kwargs_for_send = lambda _chat, topic, _metadata: {
tests/gateway/test_telegram_physique_checkin.py-2837-        "message_thread_id": topic,
tests/gateway/test_telegram_physique_checkin.py-2838-    }
tests/gateway/test_telegram_physique_checkin.py-2839-    adapter._send_message_strict_topic = provider
tests/gateway/test_telegram_physique_checkin.py:2840:    adapter._customer_checkin_keyboard_topics = {
tests/gateway/test_telegram_physique_checkin.py-2841-        ("customer-chat", "customer-topic"),
tests/gateway/test_telegram_physique_checkin.py-2842-    }
tests/gateway/test_telegram_physique_checkin.py-2843-    adapter._physique_markup = lambda prompt: prompt
tests/gateway/test_telegram_physique_checkin.py-2844-    return adapter
tests/gateway/test_telegram_physique_checkin.py-2845-
tests/gateway/test_telegram_physique_checkin.py-2846-
tests/gateway/test_telegram_physique_checkin.py:2847:def _due_schedule_tasks(monkeypatch, checkin_cli, *tasks):
tests/gateway/test_telegram_physique_checkin.py-2848-    monkeypatch.setattr(
tests/gateway/test_telegram_physique_checkin.py:2849:        checkin_cli,
tests/gateway/test_telegram_physique_checkin.py-2850-        "build_due_customer_tasks",
tests/gateway/test_telegram_physique_checkin.py-2851-        lambda _registry, _now: tuple(tasks),
tests/gateway/test_telegram_physique_checkin.py-2852-    )
tests/gateway/test_telegram_physique_checkin.py-2853-
--
tests/gateway/test_telegram_physique_checkin.py-2862-        enabled=True,
tests/gateway/test_telegram_physique_checkin.py-2863-        token="test-token",
tests/gateway/test_telegram_physique_checkin.py-2864-        extra={
tests/gateway/test_telegram_physique_checkin.py-2865-            "nutrition_coaching": {
tests/gateway/test_telegram_physique_checkin.py:2866:                "missing_checkin_reminder": {
tests/gateway/test_telegram_physique_checkin.py-2867-                    "operator_approval": "topic-59-approved-reminder-evidence-v1",
tests/gateway/test_telegram_physique_checkin.py-2868-                    "response_window_ends_at": response_window_ends_at,
tests/gateway/test_telegram_physique_checkin.py-2869-                }
tests/gateway/test_telegram_physique_checkin.py-2870-            }
--
tests/gateway/test_telegram_physique_checkin.py-2872-    )
tests/gateway/test_telegram_physique_checkin.py-2873-
tests/gateway/test_telegram_physique_checkin.py-2874-
tests/gateway/test_telegram_physique_checkin.py-2875-class _ReminderDualCoach:
tests/gateway/test_telegram_physique_checkin.py:2876:    def __init__(self, checkin_cli, profile_root):
tests/gateway/test_telegram_physique_checkin.py:2877:        self._checkin_cli = checkin_cli
tests/gateway/test_telegram_physique_checkin.py-2878-        self._profile_root = profile_root
tests/gateway/test_telegram_physique_checkin.py-2879-        self.reservations = []
tests/gateway/test_telegram_physique_checkin.py-2880-        self.review_rows = []
tests/gateway/test_telegram_physique_checkin.py-2881-        self.after_first_reservation = None
--
tests/gateway/test_telegram_physique_checkin.py-2899-        if _schedule_rows(self._profile_root)[-1:][0].get("state") != "sending":
tests/gateway/test_telegram_physique_checkin.py-2900-            return None
tests/gateway/test_telegram_physique_checkin.py-2901-        return SimpleNamespace(occurred_at_kst="2026-07-21T08:16:00+09:00")
tests/gateway/test_telegram_physique_checkin.py-2902-
tests/gateway/test_telegram_physique_checkin.py:2903:    def reserve_missing_checkin_reminder(
tests/gateway/test_telegram_physique_checkin.py-2904-        self,
tests/gateway/test_telegram_physique_checkin.py-2905-        missing_window,
tests/gateway/test_telegram_physique_checkin.py-2906-        destination,
tests/gateway/test_telegram_physique_checkin.py-2907-        *,
--
tests/gateway/test_telegram_physique_checkin.py-2923-        self.reservations.append(reservation)
tests/gateway/test_telegram_physique_checkin.py-2924-        if len(self.reservations) == 1 and self.after_first_reservation:
tests/gateway/test_telegram_physique_checkin.py-2925-            self.after_first_reservation()
tests/gateway/test_telegram_physique_checkin.py-2926-        schedule = __import__(
tests/gateway/test_telegram_physique_checkin.py:2927:            "checkin_cli.customer_schedule",
tests/gateway/test_telegram_physique_checkin.py:2928:            fromlist=["reserve_missing_checkin_reminder"],
tests/gateway/test_telegram_physique_checkin.py-2929-        )
tests/gateway/test_telegram_physique_checkin.py:2930:        receipt = schedule.reserve_missing_checkin_reminder(
tests/gateway/test_telegram_physique_checkin.py-2931-            self._profile_root,
tests/gateway/test_telegram_physique_checkin.py-2932-            "client_001",
tests/gateway/test_telegram_physique_checkin.py-2933-            missing_window,
tests/gateway/test_telegram_physique_checkin.py-2934-            destination,
--
tests/gateway/test_telegram_physique_checkin.py-2953-        return row
tests/gateway/test_telegram_physique_checkin.py-2954-
tests/gateway/test_telegram_physique_checkin.py-2955-
tests/gateway/test_telegram_physique_checkin.py-2956-def _reminder_adapter(monkeypatch, tmp_path, *, provider):
tests/gateway/test_telegram_physique_checkin.py:2957:    checkin_cli = _schedule_checkin_cli()
tests/gateway/test_telegram_physique_checkin.py:2958:    task = checkin_cli.CustomerScheduleTask("client_001", "reminder", date(2026, 7, 21))
tests/gateway/test_telegram_physique_checkin.py:2959:    _due_schedule_tasks(monkeypatch, checkin_cli, task)
tests/gateway/test_telegram_physique_checkin.py-2960-    coordinator = _ScheduleCoordinator(tmp_path)
tests/gateway/test_telegram_physique_checkin.py-2961-    adapter = _schedule_adapter(coordinator, provider)
tests/gateway/test_telegram_physique_checkin.py-2962-    adapter.config = _approved_reminder_config()
tests/gateway/test_telegram_physique_checkin.py:2963:    dual_coach = _ReminderDualCoach(checkin_cli, tmp_path)
tests/gateway/test_telegram_physique_checkin.py-2964-    coaching = __import__(
tests/gateway/test_telegram_physique_checkin.py:2965:        "checkin_cli.customer_coaching",
tests/gateway/test_telegram_physique_checkin.py-2966-        fromlist=["RegisteredCustomerDualCoachCoordinator"],
tests/gateway/test_telegram_physique_checkin.py-2967-    )
tests/gateway/test_telegram_physique_checkin.py-2968-    monkeypatch.setattr(
tests/gateway/test_telegram_physique_checkin.py-2969-        coaching,
--
tests/gateway/test_telegram_physique_checkin.py-2975-
tests/gateway/test_telegram_physique_checkin.py-2976-class TestNutritionScheduleDelivery:
tests/gateway/test_telegram_physique_checkin.py-2977-    @pytest.mark.asyncio
tests/gateway/test_telegram_physique_checkin.py-2978-    async def test_tick_success_persists_audited_receipt(self, tmp_path, monkeypatch):
tests/gateway/test_telegram_physique_checkin.py:2979:        checkin_cli = _schedule_checkin_cli()
tests/gateway/test_telegram_physique_checkin.py:2980:        task = checkin_cli.CustomerScheduleTask(
tests/gateway/test_telegram_physique_checkin.py-2981-            "client_001", "daily", date(2026, 7, 21)
tests/gateway/test_telegram_physique_checkin.py-2982-        )
tests/gateway/test_telegram_physique_checkin.py:2983:        _due_schedule_tasks(monkeypatch, checkin_cli, task)
tests/gateway/test_telegram_physique_checkin.py-2984-        coordinator = _ScheduleCoordinator(tmp_path)
tests/gateway/test_telegram_physique_checkin.py-2985-        provider = AsyncMock(return_value=SimpleNamespace(message_id="message-1"))
tests/gateway/test_telegram_physique_checkin.py-2986-        adapter = _schedule_adapter(coordinator, provider)
tests/gateway/test_telegram_physique_checkin.py:2987:        adapter._ensure_customer_checkin_keyboard = AsyncMock()
rg: checkin_cli: No such file or directory (os error 2)

[Showing lines 1635-2168 of 2168 (50.0KB limit). Full output: /tmp/pi-bash-dac08e8edb861d7b.log]


PI environment:
PI_CODING_AGENT=true
PI_MODEL=gpt-5.6-terra
PI_PROMPT_CACHE_SAFE_WAIT_SECONDS=270
PI_PROVIDER=openai-codex
PI_REASONING_LEVEL=xhigh
PI_SESSION_FILE=/home/cube/projects/richard/traning coach/.omo/senpi-task/children/st_019ff57c/sessions/st_019ff57c/2026-08-12T10-19-58-226Z_019ff57c-6592-7d08-bf88-96157f5b256e.jsonl
PI_SESSION_ID=019ff57c-6592-7d08-bf88-96157f5b256e

Task22 venv path state:
drwx------ 4 cube cube 4096 Jul 18 17:13 /home/cube/projects/richard/hermes-agent/.venv
lrwxrwxrwx 1 cube cube 33 Jul 18 17:13 /home/cube/projects/richard/hermes-agent/.venv/bin/python -> /home/cube/miniconda3/bin/python3

Task22 verifier digest:
fd57070f322f4de6a8a416d2c306652deb4313d8ba2aa44dba0aaf9e43f08bd2  /home/cube/projects/richard/traning coach/.omo/evidence/verify-task22-supplemental-candidate.py
395f0daa6c88e888b2e5442916ad464f22593402f9cabb8041b2470af64bbb22  /home/cube/projects/richard/traning coach/.omo/evidence/task22-supplemental-compatibility-canonical.py

Systemd execution details:
ExecStart={ path=/home/cube/projects/richard/hermes-agent/.venv/bin/python ; argv[]=/home/cube/projects/richard/hermes-agent/.venv/bin/python -m hermes_cli.main --profile dualcoachtest gateway run ; ignore_errors=no ; start_time=[Wed 2026-08-12 18:36:04 KST] ; stop_time=[n/a] ; pid=675692 ; code=(null) ; status=0/0 }
Environment=PATH=/home/cube/projects/richard/hermes-agent/venv/bin:/home/cube/projects/richard/hermes-agent/node_modules/.bin:/home/cube/.nvm/versions/node/v24.18.1/bin:/home/cube/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin VIRTUAL_ENV=/home/cube/projects/richard/hermes-agent/.venv HERMES_HOME=/home/cube/.hermes/profiles/dualcoachtest
FragmentPath=/home/cube/.config/systemd/user/hermes-gateway-dualcoachtest.service
DropInPaths=


**Investigating session expiration and state corruption**

**Assessing bootstrap limitations and restart persistence**

**Clarifying audit task execution and stop conditions**

**Planning cutover command requirements**

**Investigating Task22 session activation requirements**

**Evaluating service restart risks**

**Evaluating service restart risks**

**Planning final evidence collection strategy**

**Deciding to run canonical audit for Task22**

bash

cd '/home/cube/projects/richard/traning coach' && /home/cube/projects/richard/hermes-agent/.venv/bin/python -I -B .omo/evidence/task22-supplemental-compatibility-canonical.py

cd /home/cube/projects/richard/hermes-agent && printf '%s\n' 'Task23 gate and exact session references:' && rg -n -C 5 'def require_task22_supplemental_for_activation|require_task22_supplemental_for_activation|_TASK22_SESSION|task22_dm_rehearsal|terminal activation recovery requires a new supported bootstrap' gateway/platforms scripts tests/gateway | head -1000 && printf '%s\n' 'Expire implementation:' && rg -n -A 90 -B 10 'def expire_unbound|def _expire_unbound_values|BootstrapState.EXPIRED|EXPIRED' gateway/platforms/telegram_room_bootstrap.py scripts/nutrition-room-bootstrap | head -800

cd /home/cube/projects/richard/hermes-agent && printf '%s\n' 'Task22/activation tests that establish invalid/stale/duplicate behavior:' && rg -n '^def test_.*(invalid|stale|duplicate|wrong|restart|replay|activation|task23)' tests/gateway/test_task22_supplemental_owner_review.py tests/gateway/test_telegram_room_bootstrap_cutover.py tests/gateway/test_telegram_nutrition_onboarding*.py | head -700 && printf '%s\n' 'Test tool availability:' && /home/cube/projects/richard/hermes-agent/.venv/bin/python -m pytest --version

set -eu
root=/home/cube/.hermes/profiles/dualcoachtest
python3 - <<'PY'
from pathlib import Path
import json,hashlib
root=Path('/home/cube/.hermes/profiles/dualcoachtest')
paths={
 'bootstrap_ledger':root/'data/onboarding/telegram-room-bootstrap-v1/ledger.json',
 'registry':root/'customers/registry.json',
 'task22_journal':root/'data/customers/task22_dm_rehearsal/nutrition-onboarding/migrations/task22-supplemental-owner-review-v1.json',
 'outbox':root/'data/onboarding/telegram-publication-outbox-v1/ledger.json',
 'callbacks':root/'data/onboarding/telegram-publication-outbox-v1/owner-callbacks.json',
}
for name,path in paths.items():
 data=path.read_bytes()
 print(f'{name} {hashlib.sha256(data).hexdigest()} {path.stat().st_mtime_ns} {path.stat().st_ino}')
 if name in {'task22_journal','outbox','callbacks'}:
  obj=json.loads(data)
  if name=='task22_journal': print('phase=',obj.get('phase_events', [])[-1] if obj.get('phase_events') else None,'review_update_id=',obj.get('review_update_id'))
  if name=='outbox':
   rows=[r for r in obj.get('records',[]) if r.get('session_id')=='rb_Ar-NuKG3LH8CqHPXUgc88g']
   print('generations=',[(r.get('generation'),r.get('role'),r.get('state'),r.get('message_id')) for r in rows])
  if name=='callbacks': print('callback_records=',len(obj.get('records', [])))
PY
printf 'Current service journal tail (read-only):\n'
journalctl --user-unit hermes-gateway-dualcoachtest.service --since '2026-08-12 19:20:00' --no-pager -o short-iso | tail -100


- one dedicated test profile and bot.

Before starting:

- provider auth preflight passes;
- candidate digest matches automated-test evidence;
- all other profiles are recorded and left untouched;
- customer is disabled;
- delivery outbox is empty.

### Specification 22 — Run onboarding and owner/operator review without intervention

Reuse the current synthetic session's committed Q1 through Q22 answers and attestation.
A supported journaled reconciliation must move its frozen `trainer_review` projection to
`owner_review` without re-answering, while preserving the answers, attestation, consent,
and zero downstream state.

Drive through real Telegram:

- the existing customer attestation and finalized questionnaire record;
- one owner/operator review card and Approve happy path;
- restart while awaiting the owner/operator action;
- owner Revise and Reject behavior through the exact automated matrix, not live clicks;
- wrong-actor, stale-card, and duplicate-callback behavior through the exact automated
  matrix.

Capture:

- customer DM and owner/operator handset screenshots;
- owner/operator callback update IDs;
- journaled reconciliation, restart, and owner/operator decision receipts;
- zero activation, generation, and coaching delivery.

No trainer marker, claim, invite, card, UI, route, data access, review, readiness,
activation requirement, or safety-hold path may be configured or exercised in v1. Any raw
listener, service replay, direct mutation, or forced republication fails the rehearsal.

### Specification 23 — Run activation and customer check-in

Activate the synthetic rehearsal customer only after owner/operator readiness passes.
No real customer is activated. Verify:

- activation notice reaches the customer DM;
- all 12 check-in questions render correctly;
- Q7 has choices `1` through `5` plus navigation;
- final save creates one check-in event and one generation job;
- restart during the check-in preserves answers;
- no coaching delivery occurs.

### Specification 24 — Run operator review and exactly-once delivery

Through the real staff Telegram card:

1. observe automatic generation pending;
2. observe draft created;
3. regenerate and edit;
4. approve;
5. prove customer delivery remains zero;
6. explicitly send;
7. observe the customer DM message;
8. prove one provider message and one `sent_audited` receipt;
9. repeat send, stale card, and wrong-role actions;
10. restart before and after the send boundary.

Acceptance:

- Every action uses Telegram, not direct service calls.
- Operator route uses the configured effective user/chat/message context.
- Owner/operator is the sole nutrition approval authority for the generated draft.
- Customer observes exactly one message.
- Negative actions create no second receipt or provider message.

### Specification 25 — Disable and clean the rehearsal

Use the canonical disable transaction. Verify:

- customer registry is disabled;
- scheduled jobs cannot deliver;
- bootstrap/activation projection is coherent;
- test gateway is stopped;
- gateway state reports stopped;
- no orphan monitors/processes remain;
- temporary credentials and evidence containing customer data are removed;
- other profiles retain their original PIDs and state;
- no trainer marker, claim, invite, card, route, observer, or temporary trainer artifact
  remains.

---

## Release Review

### Specification 26 — Run the multi-lane release review

Run independent reviewers for:

- objective and constraint coverage;
- code quality and maintainability;
- security, privacy, and authority;
- hands-on real-surface QA;
- context and evidence provenance;
- owner/operator-only authority and trainer-free v1 scope.

Every reviewer must receive:

- candidate manifest and digest;
- Golden Path and Recovery Runbook;
- automated evidence;
- live rehearsal evidence;
- durable receipts and cleanup evidence.

Acceptance:

- All reviewers return PASS.
- Missing, inconclusive, timed-out, or unverified lanes count as failure.
- Findings are fixed and the affected gates rerun against a new candidate digest.

### Specification 27 — Issue the release verdict

Release is permitted only when all previous tasks pass.

The release report must state:

- exact candidate digest;
- privacy architecture;
- all automated commands and results;
- real-surface evidence for the customer and owner/operator only;
- exactly-once delivery proof;
- recovery scenarios exercised;
- cleanup state;
- reviewer verdicts;
- residual risks;
- pilot scope and rollback criteria.

Initial rollout:

- one explicitly consenting pilot customer;
- no shared customer group;
- daily operator review;
- immediate disable on authorization, duplication, or delivery anomaly;
- expansion only after the defined observation window is clean.

## Todos

Every task below inherits **Execution constraints**, **Minimum-operation v1 scope
directive**, and **Per-checkbox execution rule**. A task's specification defines the
required operating behavior; it does not authorize customer-facing feature expansion,
speculative architecture, convenience work, or future scaffolding. The executor must
reuse already-correct live behavior, identify only the unsatisfied v1 gap, and record
the minimal delta plus rejected extras in its DoneClaim and ledger receipt.

- [x] 1. Build the immutable-candidate manifest
  - Recommended task executor category: `unspecified-high`
  - Dependencies: none.
  - References: Foundation, Specification 1.
  - Acceptance: reconcile every tracked/untracked file and produce the candidate digest.
  - Agent-executed QA: compare manifest counts and digests with Git and filesystem output.
  - Commit: none; the user prohibited commits in this workflow.

- [x] 2. Write the Golden Path contract
  - Recommended task executor category: `writing`
  - Dependencies: 1.
  - References: Foundation, Specification 2.
  - Acceptance: cover every actor, surface, state, visible artifact, and evidence gate.
  - Agent-executed QA: lint the runbook for missing lifecycle states and prohibited shortcuts.
  - Commit: none; the user prohibited commits in this workflow.

- [x] 3. Write the Recovery Runbook
  - Recommended task executor category: `writing`
  - Dependencies: 1-2.
  - References: Foundation, Specification 3.

[235 more lines in file. Use offset=870 to continue.]


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Prepare and inspect durable Telegram nutrition-room bootstrap sessions."
    )
    commands = parser.add_subparsers(dest="command", required=True)

    prepare = commands.add_parser("prepare", help="create an unbound 30-minute bootstrap session")
    _add_authority_location(prepare)
    prepare.add_argument("--draft", required=True)
    prepare.add_argument("--bot-username", required=True)

    pending_prepare = commands.add_parser(
        "prepare-from-pending-markers",
        help="resolve the dedicated pending rehearsal markers and prepare one private-role session",
    )
    pending_prepare.add_argument("--profile-root", required=True)
    pending_prepare.add_argument("--draft", required=True)
    pending_prepare.add_argument("--bot-username", required=True)

    status = commands.add_parser("status", help="show one session or the complete ledger")
    _add_authority_location(status)
    status.add_argument("--session-id")

    cancel = commands.add_parser("cancel", help="cancel a nonterminal session using generation CAS")
    _add_authority_location(cancel)
    cancel.add_argument("--session-id", required=True)
    cancel.add_argument("--generation", required=True, type=int)

    attempts = commands.add_parser(
        "recovery-status", help="list unresolved provider attempts"
    )
    _add_authority_location(attempts)
    attempts.add_argument("--session-id")

    bind = commands.add_parser(
        "recovery-bind", help="bind an existing Telegram topic/message receipt"
    )
    _add_recovery_authority(bind)
    bind.add_argument("--receipt-id", required=True)

    retry = commands.add_parser(
        "recovery-retry",
        help="acknowledge positive no-side-effect evidence and allow retry",
    )
    _add_recovery_authority(retry)

    cutover = commands.add_parser(
        "activate-cutover",
        help="preflight or execute one explicit canonical customer cutover",
    )
    cutover.add_argument("--profile-root", required=True)
    cutover.add_argument("--package-root")
    cutover.add_argument("--session-id", required=True)
    cutover.add_argument("--generation", required=True, type=int)
    cutover.add_argument("--customer-key", required=True)
    cutover.add_argument("--data-root", required=True)
    cutover.add_argument("--g1-g5-checklist", required=True)
    cutover.add_argument("--kst-date", required=True, type=date.fromisoformat)
    cutover.add_argument("--expected-current-customer", required=True)
    cutover.add_argument("--confirm-disable-current-customer", required=True)
    cutover.add_argument("--customer-user-id", required=True)
    cutover.add_argument("--provider-membership-receipt", required=True)
    cutover.add_argument(
        "--execute",
        action="store_true",
        help="commit the cutover; without this flag the command is read-only",
    )
    return parser

def _require_store(store: RoomBootstrapStore | None) -> RoomBootstrapStore:
    if store is None:
        raise RuntimeError("bootstrap store is unavailable")
    return store

def _run(args: argparse.Namespace) -> int:
    store: RoomBootstrapStore | None = None
    output: dict[str, object] = {}
    draft = (
        load_customer_draft(Path(args.draft))
        if args.command in {"prepare", "prepare-from-pending-markers"}
        else None
    )
    if args.command == "prepare":
        if draft is None:  # pragma: no cover - command branch narrows this
            raise RuntimeError("customer draft is unavailable")
        store = RoomBootstrapStore(_state_dir(args))
        prepared = store.prepare(
            draft,
            bot_username=args.bot_username,
        )
        output = {
            **_session_output(prepared.session),
            # The raw SID exists only in this one owner-facing prepare result.
            "sid": prepared.sid,
            "startgroup_link": prepared.startgroup_link,
        }
    elif args.command == "prepare-from-pending-markers":
        if draft is None:  # pragma: no cover - command branch narrows this
            raise RuntimeError("customer draft is unavailable")
        profile_root = _profile_root(args)
        expected_owner_id, expected_staff_chat_id = _rehearsal_staff_authority(
            profile_root,
            args.bot_username,
        )
        resolution = asyncio.run(
            resolve_pending_marker_identities(
                _profile_telegram_token(profile_root),
                bot_username=args.bot_username,
                not_before=_rehearsal_marker_cutoff(profile_root),
                expected_owner_id=expected_owner_id,
                expected_staff_chat_id=expected_staff_chat_id,
            )
        )
        if resolution.owner.user_id != _profile_owner_id(profile_root):
            raise ValueError("pending owner marker is not canonical")
        prepared = RoomBootstrapStore(_state_dir(args)).prepare_rehearsal_customer_invite(
            draft,
            bot_username=args.bot_username,
            owner_id=resolution.owner.user_id,
        )
        output = {
            "session_id": prepared.session.session_id,
            "state": prepared.session.state.value,
            "generation": prepared.session.generation,
            "customer_link": prepared.customer_link,
            "expires_at": prepared.expires_at.isoformat(),
            "pending_marker_intake": "resolved",
        }
    else:
        store = RoomBootstrapStore(_state_dir(args))
    if args.command in {"prepare", "prepare-from-pending-markers"}:
        pass
    elif args.command == "status":
        active_store = _require_store(store)
        active_store.expire_unbound()
        if args.session_id:
            output = _session_output(active_store.get(args.session_id))
        else:
            output = {"sessions": [_session_output(item) for item in active_store.list_sessions()]}
    elif args.command == "cancel":
        active_store = _require_store(store)
        output = _session_output(
            active_store.cancel(args.session_id, expected_generation=args.generation)
        )
    elif args.command == "recovery-status":
        active_store = _require_store(store)
        output = {
            "attempts": [
                _attempt_output(attempt)
                for attempt in active_store.unresolved_attempts(args.session_id)
            ]
        }
    elif args.command == "recovery-bind":
        active_store = _require_store(store)
        output = _session_output(active_store.bind_recovery_receipt(
            args.session_id,
            slot=RecoverySlot(args.slot),
            chat_id=args.chat_id,
            attempt_generation=args.attempt_generation,
            receipt_id=args.receipt_id,
        ))
    elif args.command == "recovery-retry":
        active_store = _require_store(store)
        output = _session_output(active_store.acknowledge_recovery_no_side_effect(
            args.session_id,
            slot=RecoverySlot(args.slot),
            chat_id=args.chat_id,
            attempt_generation=args.attempt_generation,
        ))
    elif args.command == "activate-cutover":
        active_store = _require_store(store)
        cutover = TelegramRoomBootstrapCutover(
            Path(args.profile_root),
            active_store,
            package_root=Path(args.package_root) if args.package_root else None,
        )
        operation = cutover.execute if args.execute else cutover.preflight
        receipt = operation(
            args.session_id,
            expected_generation=args.generation,
            customer_key=args.customer_key,
            data_root=Path(args.data_root),
            checklist_path=Path(args.g1_g5_checklist),
            kst_date=args.kst_date,
            expected_current_customer=args.expected_current_customer,
            confirm_disable_current_customer=args.confirm_disable_current_customer,
            readiness_authority=_activation_readiness_authority(args),
        )
        output = receipt.redacted_dict()
        output["mode"] = "execute" if args.execute else "preflight"
    else:  # pragma: no cover - argparse owns command choices
        raise RuntimeError("unsupported room-bootstrap command")
    print(json.dumps(output, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
    return 0

def main(argv: list[str] | None = None) -> int:
    parser = _parser()
    values = list(sys.argv[1:] if argv is None else argv)
    args = parser.parse_args(values)
    try:
        return _run(args)
    except (BootstrapError, OSError, ValueError) as exc:
        parser.error(str(exc))

if __name__ == "__main__":
    raise SystemExit(main())


            else:  # pragma: no cover - exhaustive enum guard
                raise BootstrapError("unsupported recovery slot")
            return replace(
                session,
                topics=tuple(sorted(topics.items(), key=lambda pair: pair[0].value)),
                claim_cards=tuple(sorted(cards.values(), key=lambda card: card.role.value)),
                owner_review_message_id=owner_review,
                consent_card_message_id=(
                    receipt
                    if slot is RecoverySlot.CONSENT_CARD
                    else session.consent_card_message_id
                ),
                recovery_attempts=(),
                failure_code=None,
                generation=(
                    session.generation + 1
                    if topic_slot is not None
                    else session.generation
                ),
                updated_at=now,
            )

        current = self.get(session_id)
        return self._mutate(session_id, current.generation, mutate)

    def acknowledge_recovery_no_side_effect(
        self,
        session_id: str,
        *,
        slot: RecoverySlot,
        chat_id: str,
        attempt_generation: int,
    ) -> RoomBootstrapSession:
        """Record positive operator/provider evidence that no side effect occurred."""
        chat = _telegram_id(chat_id, "chat")

        def mutate(session: RoomBootstrapSession, now: datetime) -> RoomBootstrapSession:
            self._require_recovery_attempt(session, slot, chat, attempt_generation)
            self._validate_recovery_boundary(session, slot, chat)
            return replace(
                session,
                recovery_attempts=(),
                failure_code=None,
                updated_at=now,
            )

        current = self.get(session_id)
        return self._mutate(session_id, current.generation, mutate)

    @staticmethod
    def _require_recovery_attempt(
        session: RoomBootstrapSession,
        slot: RecoverySlot,
        chat_id: str,
        generation: int,
    ) -> RecoveryAttempt:
        if type(generation) is not int:
            raise GenerationConflict("recovery attempt generation is stale")
        matches = [
            attempt
            for attempt in session.recovery_attempts
            if attempt.slot is slot
            and attempt.chat_id == chat_id
            and attempt.generation == generation
        ]
        if len(matches) != 1:
            raise GenerationConflict("recovery attempt authority is stale or inexact")
        return matches[0]

    @staticmethod
    def _validate_recovery_boundary(
        session: RoomBootstrapSession,
        slot: RecoverySlot,
        chat_id: str,
    ) -> None:
        topic_slot = _topic_slot_for_recovery(slot)
        role = _role_for_recovery(slot)
        if topic_slot is not None:
            if (
                session.state is not BootstrapState.PROVISIONING
                or session.chat_id != chat_id
                or session.topic_id(topic_slot) is not None
            ):
                raise BootstrapError("topic recovery is outside provisioning authority")
            return
        if role is not None:
            if (
                session.state is not BootstrapState.CLAIMING
                or session.chat_id != chat_id
                or session.topic_id(_slot_for_role(role)) is None
                or session.claim_card_message_id(role) is not None
            ):
                raise BootstrapError("claim-card recovery is outside claiming authority")
            return
        if slot is RecoverySlot.OWNER_REVIEW:
            if (
                session.state is not BootstrapState.ROLE_REVIEW
                or session.owner_review_message_id is not None
                or len(session.role_claims) != len(Role)
            ):
                raise BootstrapError("owner-card recovery is outside role-review authority")
            return
        if slot is RecoverySlot.CONSENT_CARD:
            customer = session.role_claim(Role.CUSTOMER)
            if (

[1167 more lines in file. Use offset=1245 to continue.]


            self.customer_topic_id = ""
            self.trainer_topic_id = ""
            return
        self.session_id = identifier
        self._pinned_session_id = session.session_id
        self.expected_chat_id = session.chat_id or ""
        self.bootstrap_message_id = session.anchor_message_id or ""
        self.customer_topic_id = session.topic_id(TopicSlot.CHECKIN) or ""
        self.trainer_topic_id = session.topic_id(TopicSlot.TRAINER_REVIEW) or ""

def _draft_from_mapping(value: Mapping[str, object]) -> CustomerDraft:
    keys = set(value)
    unknown = keys - _DRAFT_REQUIRED - _DRAFT_OPTIONAL
    missing = _DRAFT_REQUIRED - keys
    if unknown or missing:
        raise BootstrapError("customer draft schema is invalid")
    customer_key = _required_text(value.get("customer_key"), "customer key", 64)
    if _CUSTOMER_RE.fullmatch(customer_key) is None:
        raise BootstrapError("customer key is invalid")
    display_name = _required_text(value.get("display_name"), "display name", 120)
    starts_on = _required_text(value.get("starts_on"), "starts_on", 10)
    try:
        datetime.strptime(starts_on, "%Y-%m-%d")
    except ValueError as exc:
        raise BootstrapError("starts_on must be an ISO date") from exc
    daily_time = _required_text(value.get("daily_time"), "daily_time", 5)
    try:
        datetime.strptime(daily_time, "%H:%M")
    except ValueError as exc:
        raise BootstrapError("daily_time must be HH:MM") from exc
    weekly = _bounded_int(value.get("weekly_weekday"), "weekly weekday", 0, 6)
    monthly = _bounded_int(value.get("monthly_day"), "monthly day", 1, 28)
    calories = _bounded_int(value.get("calories_kcal"), "calories", 500, 10000)
    protein = _bounded_int(value.get("protein_g"), "protein", 0, 1000)
    meals = _text_tuple(value.get("meals"), "meals", required=True)
    customer_user_id = _optional_text(
        value.get("customer_user_id"), "customer user id", 24
    )
    trainer_user_id = _optional_text(
        value.get("trainer_user_id"), "trainer user id", 24
    )
    trainer_chat_id = _optional_text(
        value.get("trainer_chat_id"), "trainer chat id", 24
    )
    trainer_topic_id = _optional_text(
        value.get("trainer_topic_id"), "trainer topic id", 24
    )
    for label, user_id in (
        ("customer", customer_user_id),
        ("trainer", trainer_user_id),
        ("trainer chat", trainer_chat_id),
        ("trainer topic", trainer_topic_id),
    ):
        if user_id is not None and _ID_RE.fullmatch(user_id) is None:
            raise BootstrapError(f"{label} user id is invalid")
    return CustomerDraft(
        customer_key, display_name, starts_on, daily_time, weekly, monthly, calories, protein, meals,
        primary_goal=_optional_text(value.get("primary_goal"), "primary goal", 500) or "미정",
        dietary_restrictions=_text_tuple(value.get("dietary_restrictions", ()), "dietary restrictions"),
        allergies=_text_tuple(value.get("allergies", ()), "allergies"),
        food_preferences=_text_tuple(value.get("food_preferences", ()), "food preferences"),
        supplements=_text_tuple(value.get("supplements", ()), "supplements"),
        digestion_context=_optional_text(value.get("digestion_context"), "digestion context", 2000),
        sleep_goal_hours=_optional_number(value.get("sleep_goal_hours"), "sleep goal", 0, 24),
        recovery_goal=_optional_text(value.get("recovery_goal"), "recovery goal", 2000),
        training_context=_optional_text(value.get("training_context"), "training context", 2000),
        carbohydrate_g=_optional_int(value.get("carbohydrate_g"), "carbohydrate", 0, 2000),
        fat_g=_optional_int(value.get("fat_g"), "fat", 0, 1000),
        water_liters=_optional_number(value.get("water_liters"), "water", 0, 20),
        customer_user_id=customer_user_id,
        trainer_user_id=trainer_user_id,
        trainer_chat_id=trainer_chat_id,
        trainer_topic_id=trainer_topic_id,
    )

def _session_to_dict(session: RoomBootstrapSession) -> dict[str, object]:
    return {
        "session_id": session.session_id, "sid_hash": session.sid_hash,
        "customer_draft": session.customer_draft.to_dict(),
        "customer_draft_digest": session.customer_draft_digest,
        "bot_username": session.bot_username, "state": session.state.value,
        "generation": session.generation, "created_at": session.created_at.isoformat(),
        "updated_at": session.updated_at.isoformat(), "expires_at": session.expires_at.isoformat(),
        "chat_id": session.chat_id, "anchor_message_id": session.anchor_message_id,
        "owner_review_message_id": session.owner_review_message_id,
        "registration_approval_generation": session.registration_approval_generation,
        "consent_publication_attempt": session.consent_publication_attempt,
        "consent_card_message_id": session.consent_card_message_id,
        "owner_id": session.owner_id, "bot_id": session.bot_id,
        "topics": {slot.value: topic for slot, topic in session.topics},
        "claim_cards": [
            {"role": card.role.value, "message_id": card.message_id}
            for card in session.claim_cards
        ],
        "role_claims": [
            {
                "role": claim.role.value, "user_id": claim.user_id,
                "chat_id": claim.chat_id, "topic_id": claim.topic_id,
                "message_id": claim.message_id,
            }
            for claim in session.role_claims
        ],
        "recovery_attempt_generation": session.recovery_attempt_generation,
        "recovery_attempts": [
            {
                "session_id": attempt.session_id,
                "slot": attempt.slot.value,
                "chat_id": attempt.chat_id,
                "generation": attempt.generation,
                "created_at": attempt.created_at.isoformat(),
            }
            for attempt in session.recovery_attempts
        ],
        "failure_code": session.failure_code,
    }

def _session_from_dict(value: object) -> RoomBootstrapSession:
    if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
        raise BootstrapError("bootstrap session record is invalid")
    record: dict[str, object] = {str(key): item for key, item in value.items()}
    old_fields = {
        "session_id", "sid_hash", "customer_draft", "customer_draft_digest", "bot_username", "state",
        "generation", "created_at", "updated_at", "expires_at", "chat_id", "anchor_message_id",
        "topics", "role_claims", "failure_code",
    }
    intermediate_fields = old_fields | {"owner_id", "bot_id", "claim_cards"}
    review_fields = intermediate_fields | {"owner_review_message_id"}
    consent_fields = review_fields | {
        "registration_approval_generation", "consent_publication_attempt",
        "consent_card_message_id",
    }
    new_fields = consent_fields | {
        "recovery_attempt_generation", "recovery_attempts",
    }
    if frozenset(record) not in {
        frozenset(old_fields), frozenset(intermediate_fields),
        frozenset(review_fields), frozenset(consent_fields), frozenset(new_fields),
    }:
        raise BootstrapError("bootstrap session record is invalid")
    record.setdefault("owner_review_message_id", None)
    record.setdefault("registration_approval_generation", None)
    record.setdefault("consent_publication_attempt", 0)

[297 more lines in file. Use offset=2115 to continue.]


            raise ActivationCutoverError("bootstrap customer selector mismatch")
        if (
            live.state is BootstrapState.FAILED
            and live.failure_code == "activation_authority_rejected"
        ):
            raise ActivationCutoverError(
                "terminal activation recovery requires a new supported bootstrap"
            )
        if live.state not in {BootstrapState.AWAITING_ACTIVATION, BootstrapState.ACTIVE}:
            raise ActivationCutoverError("session is not AWAITING_ACTIVATION or completed")

        registry_path = admin._resolve_registry_path(self.profile_root)
        # This canonical validator also reconciles or rejects malformed activation
        # journals before any new authority operation.
        admin.validate_committed_activation(self.profile_root, registry_path)
        document = admin._read(registry_path)
        targets = tuple(
            row for row in document.customers
            if getattr(row, "customer_key", None) == customer_key
        )
        currents = tuple(
            row for row in document.customers
            if getattr(row, "customer_key", None) == expected_current_customer
        )
        if len(targets) != 1:
            raise ActivationCutoverError("canonical target customer is missing or ambiguous")
        if len(currents) != 1:
            raise ActivationCutoverError("expected currently enabled customer is missing")
        target = targets[0]
        current = currents[0]
        enabled = tuple(row for row in document.customers if row.enabled)
        enabled_keys = tuple(row.customer_key for row in enabled)
        if enabled_keys == (expected_current_customer,):
            phase = "ready"
        elif not enabled_keys and not current.enabled and not target.enabled:
            phase = "current_disabled"
        elif enabled_keys == (customer_key,) and not current.enabled:
            phase = "complete" if live.state is BootstrapState.ACTIVE else "target_activated"
        else:
            raise ActivationCutoverError("exact currently enabled customer does not match")
        if live.state is BootstrapState.ACTIVE and phase != "complete":
            raise ActivationCutoverError("completed bootstrap activation evidence mismatches")
        if phase in {"ready", "current_disabled"}:
            self._require_provider_authentication()
        if phase == "current_disabled":
            # A zero-enabled registry is accepted only as the exact crash boundary
            # produced by retiring the named current customer. Reconstruct the
            # immediately preceding registry and verify its canonical receipt.
            recovery_payload = document.model_dump(mode="json")
            recovery_payload["customers"] = [
                row.model_copy(
                    update={"enabled": row.customer_key == expected_current_customer}
                ).model_dump(mode="json")
                for row in document.customers
            ]
            recovery_document = admin.RegistryDocument.model_validate(recovery_payload)
            recovery_current = next(
                row
                for row in recovery_document.customers
                if row.customer_key == expected_current_customer
            )
            admin._require_committed_activation_receipt(
                self.profile_root,
                registry_path,
                recovery_document,
                recovery_current,
            )

        try:
            require_task22_supplemental_for_activation(
                self.profile_root,
                customer_key=live.customer_key,
                session_id=live.session_id,
            )
            if phase == "ready":
                require_activation_readiness(
                    live,
                    target,
                    readiness_authority,
                    consent_version=coaching.CONSENT_VERSION,
                    now=self._now().astimezone(ZoneInfo("UTC")),
                )
            else:
                readiness_generation = (
                    live.generation - 1
                    if phase == "complete"
                    else live.generation
                )
                validate_activation_readiness_receipt(
                    self.profile_root,
                    live,
                    target,
                    consent_version=coaching.CONSENT_VERSION,
                    now=self._now().astimezone(ZoneInfo("UTC")),
                    expected_readiness_generation=readiness_generation,
                )
        except (ActivationReadinessError, SupplementalReviewError) as exc:
            if phase in {"target_activated", "complete"}:
                raise _PostEnableAuthorityLoss(str(exc)) from exc
            if phase != "current_disabled":
                raise ActivationCutoverError(str(exc)) from exc
            require_activation_readiness(
                live,
                target,
                readiness_authority,
                consent_version=coaching.CONSENT_VERSION,
                now=self._now().astimezone(ZoneInfo("UTC")),
            )
            persist_activation_readiness_receipt(
                self.profile_root,
                live,
                target,
                readiness_authority,
                consent_version=coaching.CONSENT_VERSION,
                now=self._now().astimezone(ZoneInfo("UTC")),
            )

        self._validate_routes_and_consent(live, target, coaching)
        starts_on = target.plan.starts_on
        if not starts_on <= kst_date <= starts_on + timedelta(days=27):
            raise ActivationCutoverError("activation is outside the current KST plan window")
        requested_data = _safe_directory(data_root, "real customer data root")
        expected_data = (self.profile_root / "data" / "customers" / customer_key).resolve()
        if requested_data != expected_data:
            raise ActivationCutoverError("real customer data root does not match target")
        requested_checklist = _safe_file(checklist_path, "customer G1-G5 checklist")
        checklist_payload = self._customer_specific_checklist(
            requested_checklist,
            customer_key=customer_key,
            data_root=requested_data,
            registry_path=registry_path,
            kst_date=kst_date,
            consent_version=coaching.CONSENT_VERSION,
        )

        if phase in {"ready", "current_disabled"}:
            admin._validate_activation_data_root(
                self.profile_root,
                requested_data,
                expected_data,
                document,
                target,
            )
            target.validate_local_activation_prerequisites()
            projected = target.model_copy(update={"enabled": True})
            payload = document.model_dump(mode="json")
            payload["customers"] = [
                row.model_copy(update={"enabled": row.customer_key == customer_key}).model_dump(mode="json")
                for row in document.customers
            ]

[283 more lines in file. Use offset=610 to continue.]