"""Exercise the G3 rupture/repair HTTP ledger against a live API and DB. The smoke retains unique dev/E2E identities and records only IDs and metadata in its output. It proves authenticated evaluator ingestion, append-only retry semantics, role/cohort isolation, durable turn evidence, and teacher correction. """ from __future__ import annotations import argparse import asyncio import json import os import secrets import time import urllib.error import urllib.request from dataclasses import dataclass from http.cookiejar import CookieJar from pathlib import Path from typing import Any from uuid import UUID, uuid4 import asyncpg COHORT_ID = "e2e-hanshin" class SmokeError(RuntimeError): pass @dataclass(frozen=True) class ApiResponse: status: int body: Any class ApiClient: def __init__(self, base_url: str, timeout: float) -> None: self.base_url = base_url.rstrip("/") self.timeout = timeout self._opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(CookieJar()) ) def request( self, method: str, path: str, payload: dict[str, Any] | None = None, *, expected: set[int] | None = None, headers: dict[str, str] | None = None, ) -> ApiResponse: data = None request_headers = {"Accept": "application/json", **(headers or {})} if payload is not None: data = json.dumps(payload, ensure_ascii=False).encode("utf-8") request_headers["Content-Type"] = "application/json" request = urllib.request.Request( f"{self.base_url}{path}", data=data, headers=request_headers, method=method, ) try: with self._opener.open(request, timeout=self.timeout) as response: raw = response.read().decode("utf-8") result = ApiResponse( response.status, json.loads(raw) if raw else {}, ) except urllib.error.HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") try: body = json.loads(raw) if raw else {} except json.JSONDecodeError: body = {"detail": raw[:500]} result = ApiResponse(exc.code, body) except urllib.error.URLError as exc: raise SmokeError( f"{method} {path} transport failed: {type(exc.reason).__name__}" ) from exc allowed = expected or {200} if result.status not in allowed: detail = result.body.get("detail") if isinstance(result.body, dict) else None raise SmokeError( f"{method} {path} returned HTTP {result.status}; detail={detail!r}" ) return result def _load_api_env() -> None: env_path = Path("apps/api/.env") if not env_path.exists(): return for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) async def _fetch_db_state_proof( dsn: str, *, session_id: str, episode_ids: list[str], teacher_id: str, ) -> dict[str, Any]: conn = await asyncpg.connect(dsn) try: async with conn.transaction(): await conn.execute("SELECT set_config('app.ai_context', '', true)") await conn.execute("SELECT set_config('app.current_role', 'admin', true)") await conn.execute("SELECT set_config('app.current_uid', $1, true)", teacher_id) await conn.execute("SELECT set_config('app.current_cohort', $1, true)", COHORT_ID) rows = await conn.fetch( """ SELECT episode_id::text, sequence_no, event_kind, from_state, to_state, created_by_role, created_at FROM app.rupture_observation_event WHERE session_id = $1::uuid AND episode_id = ANY($2::uuid[]) ORDER BY episode_id, sequence_no """, session_id, [UUID(item) for item in episode_ids], ) finally: await conn.close() by_episode: dict[str, list[dict[str, Any]]] = {} for row in rows: by_episode.setdefault(str(row["episode_id"]), []).append(dict(row)) if set(by_episode) != set(episode_ids): raise SmokeError("Postgres omitted one or more three-state rupture episodes") for episode_id, events in by_episode.items(): sequence = [int(item["sequence_no"]) for item in events] if sequence != list(range(1, len(sequence) + 1)): raise SmokeError( f"Postgres rupture sequence is not contiguous for {episode_id}: {sequence}" ) return { "episode_count": len(by_episode), "observation_event_count": len(rows), "append_only_sequences_contiguous": True, "terminal_state_by_episode": { episode_id: str(events[-1]["to_state"]) for episode_id, events in by_episode.items() }, } def _sign_in( client: ApiClient, *, suffix: str, identity: str, role: str, cohort_ids: list[str], ) -> str: login = client.request( "POST", "/auth/dev-login", { "email": f"dev.e2e.rupture.{identity}.{suffix}@hs.ac.kr", "role": role, "display_name": f"Rupture {identity.title()}", "cohort_ids": cohort_ids, }, ) client.request( "POST", "/users/me/onboarding", { "legal_name": f"Rupture {identity.title()}", "affiliation": "한신대학교", "department": "상담심리학과", "grade_level": "통합검증", "phone": "010-0000-0000", "contact_address": "경기도 오산시 한신대학교", "nickname": f"Rupture {identity.title()}", "self_introduction": "G3 파열·수선 API 검증 fixture입니다.", "avatar_url": "", "terms_accepted": True, "privacy_accepted": True, }, ) user_id = str(login.body.get("user_id") or "") if not user_id: raise SmokeError("dev-login response omitted user_id") return user_id def _choose_persona(client: ApiClient) -> str: response = client.request("GET", "/personas") usable = [ item for item in response.body if isinstance(item, dict) and item.get("source") == "database" and not item.get("degraded") and item.get("code") ] if not usable: raise SmokeError("persona catalog has no non-degraded database persona") return str(next((item for item in usable if item["code"] == "P1"), usable[0])["code"]) def _durable_turn_ids(review: dict[str, Any]) -> list[str]: ids = [str(item["turn_id"]) for item in review.get("turns", []) if item.get("turn_id")] if len(ids) < 2: raise SmokeError("session review did not expose both durable turn UUIDs") return ids[:2] def _assert_no_total_score(value: Any, path: str = "response") -> None: if isinstance(value, dict): forbidden = {"total", "total_score", "overall_score"} & set(value) if forbidden: raise SmokeError(f"{path} exposed forbidden total score keys: {forbidden}") for key, child in value.items(): _assert_no_total_score(child, f"{path}.{key}") elif isinstance(value, list): for index, child in enumerate(value): _assert_no_total_score(child, f"{path}[{index}]") def run(args: argparse.Namespace) -> dict[str, Any]: _load_api_env() dsn = args.database_url or os.environ.get("DATABASE_URL") if not dsn: raise SmokeError("DATABASE_URL is required via --database-url or apps/api/.env") if len(args.internal_token) < 32: raise SmokeError("--internal-token must contain at least 32 characters") health_client = ApiClient(args.api_base_url, args.request_timeout) health = health_client.request("GET", "/health") if not health.body.get("db") or not health.body.get("engine"): raise SmokeError("API health is not DB+engine ready") suffix = f"{int(time.time())}.{secrets.token_hex(3)}" learner = ApiClient(args.api_base_url, args.request_timeout) teacher = ApiClient(args.api_base_url, args.request_timeout) other_learner = ApiClient(args.api_base_url, args.request_timeout) other_teacher = ApiClient(args.api_base_url, args.request_timeout) signed_in: dict[str, str] = {} for client, identity, role, cohorts in ( (learner, "learner", "learner", [COHORT_ID]), (teacher, "teacher", "teacher", [COHORT_ID]), (other_learner, "other-learner", "learner", [COHORT_ID]), (other_teacher, "other-teacher", "teacher", ["e2e-other-cohort"]), ): signed_in[identity] = _sign_in( client, suffix=suffix, identity=identity, role=role, cohort_ids=cohorts, ) started = learner.request( "POST", "/sessions", { "persona_code": _choose_persona(learner), "theory_mode": "humanistic", "goal_stages": ["라포", "탐색"], }, expected={201}, ) session_id = str(started.body["session_id"]) learner.request( "POST", f"/sessions/{session_id}/turn", {"text": "내가 느낀 감정과 다르게 들렸다면 바로 말해줘도 괜찮아요."}, ) learner.request("POST", f"/sessions/{session_id}/end") review = learner.request("GET", f"/sessions/{session_id}/review") turn_ids = _durable_turn_ids(review.body) internal = ApiClient(args.api_base_url, args.request_timeout) token_header = {"X-Vignette-Rupture-Token": args.internal_token} observation_idempotency = str(uuid4()) observation = { "episode_key": f"live-smoke:{uuid4()}", "idempotency_key": observation_idempotency, "event_kind": "rupture.detected", "from_state": None, "to_state": "onset", "rupture_type": "empathic_miss", "source_kind": "observed_runtime", "perspective": "runtime_observation", "ai_view": "evaluator", "confidence": 0.88, "uncertainty": 0.12, "evidence_turn_ids": turn_ids, "counterevidence": ["single_scene_requires_supervisor_review"], "safety_event_ids": [], } path = f"/internal/sessions/{session_id}/ruptures/observations" created = internal.request( "POST", path, observation, expected={201}, headers=token_header ) retried = internal.request( "POST", path, observation, expected={201}, headers=token_header ) if retried.body != created.body: raise SmokeError("same observation retry changed durable IDs") changed = dict(observation, uncertainty=0.13) internal.request("POST", path, changed, expected={409}, headers=token_header) def append_episode(events: list[tuple[str, str | None, str]]) -> tuple[str, str]: episode_key = f"live-smoke:{uuid4()}" first_episode_id = "" last_observation_id = "" for event_kind, from_state, to_state in events: payload = { **observation, "episode_key": episode_key, "idempotency_key": str(uuid4()), "event_kind": event_kind, "from_state": from_state, "to_state": to_state, } response = internal.request( "POST", path, payload, expected={201}, headers=token_header ) current_episode_id = str(response.body["episode_id"]) if first_episode_id and current_episode_id != first_episode_id: raise SmokeError("one lifecycle sequence split across rupture episodes") first_episode_id = current_episode_id last_observation_id = str(response.body["observation_id"]) return first_episode_id, last_observation_id missed_episode_id, _ = append_episode( [ ("rupture.detected", None, "onset"), ("rupture.missed", "onset", "missed"), ] ) partial_episode_id, _ = append_episode( [ ("rupture.detected", None, "onset"), ("rupture.recognized", "onset", "recognized"), ("repair.attempted", "recognized", "repair_attempted"), ("repair.partial", "repair_attempted", "partial"), ] ) episode_id = str(created.body["episode_id"]) observation_id = str(created.body["observation_id"]) learner_read = learner.request("GET", f"/sessions/{session_id}/ruptures") teacher.request("GET", f"/sessions/{session_id}/ruptures") other_learner.request("GET", f"/sessions/{session_id}/ruptures", expected={404}) other_teacher.request("GET", f"/sessions/{session_id}/ruptures", expected={404}) if learner_read.body.get("clinical_claim_allowed") is not False: raise SmokeError("learner rupture read omitted non-clinical boundary") if learner_read.body.get("requested_view") != "counselor": raise SmokeError("learner rupture read used the wrong role projection") _assert_no_total_score(learner_read.body) correction_idempotency = str(uuid4()) correction = { "idempotency_key": correction_idempotency, "supersedes_observation_id": observation_id, "rupture_type": "empathic_miss", "corrected_status": "resolved", "uncertainty": 0.05, "evidence_turn_ids": turn_ids, "counterevidence": [], "correction_reason": "후속 수퍼비전에서 영향 확인과 재합의 근거를 확인함", } correction_path = f"/sessions/{session_id}/ruptures/{episode_id}/corrections" corrected = teacher.request( "POST", correction_path, correction, expected={201} ) corrected_retry = teacher.request( "POST", correction_path, correction, expected={201} ) if corrected_retry.body != corrected.body: raise SmokeError("same teacher correction retry changed durable IDs") teacher.request( "POST", correction_path, dict(correction, uncertainty=0.06), expected={409}, ) final_read = learner.request("GET", f"/sessions/{session_id}/ruptures") episodes = final_read.body.get("episodes") or [] target = next((item for item in episodes if item.get("episode_id") == episode_id), None) if not target or target.get("current_status") != "resolved": raise SmokeError("teacher correction did not become the current append-only status") expected_states = { missed_episode_id: "missed", partial_episode_id: "partial", episode_id: "resolved", } actual_states = { str(item.get("episode_id")): str(item.get("current_status")) for item in episodes if str(item.get("episode_id")) in expected_states } if actual_states != expected_states: raise SmokeError( f"API three-state read model differs: {actual_states} != {expected_states}" ) db_proof = asyncio.run( _fetch_db_state_proof( dsn, session_id=session_id, episode_ids=list(expected_states), teacher_id=signed_in["teacher"], ) ) if db_proof["terminal_state_by_episode"] != expected_states: raise SmokeError( "Postgres three-state terminal rows differ from the API read model" ) return { "ok": True, "api_base_url": args.api_base_url, "fixture_policy": "retained unique dev:e2e identities; no fixture deletion", "session_id": session_id, "episode_id": episode_id, "three_state_episode_ids": { "missed": missed_episode_id, "partial": partial_episode_id, "resolved": episode_id, }, "observation_id": observation_id, "correction_id": corrected.body.get("observation_id"), "proof": { "durable_turn_uuid_count": len(turn_ids), "authenticated_internal_ingestion": True, "same_observation_retry_stable": True, "changed_observation_retry_rejected": True, "learner_role_projection": "counselor", "teacher_cohort_read": True, "other_learner_rejected": True, "cross_cohort_teacher_rejected": True, "same_correction_retry_stable": True, "changed_correction_retry_rejected": True, "append_only_current_status": target.get("current_status"), "api_state_coverage": sorted(set(actual_states.values())), "api_state_by_episode": actual_states, "postgres": db_proof, "no_total_score": True, "clinical_claim_allowed": final_read.body.get("clinical_claim_allowed"), }, } def main() -> None: _load_api_env() parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--api-base-url", default="http://127.0.0.1:8007") parser.add_argument( "--internal-token", default=os.environ.get("VIGNETTE_RUPTURE_INTERNAL_TOKEN", ""), ) parser.add_argument("--database-url", default="") parser.add_argument("--request-timeout", type=float, default=180.0) parser.add_argument("--out", default="") args = parser.parse_args() result = run(args) text = json.dumps(result, ensure_ascii=False, indent=2) if args.out: path = Path(args.out) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text + "\n", encoding="utf-8") print(text) if __name__ == "__main__": main()