From a7a292d0b9f08563e9f256b633566bb43927d166 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:20:08 +0900 Subject: [PATCH 01/46] test(email-writing): define secure orchestration port contracts --- .../email-writing-orchestrator-tdd.yml | 41 ++ .../test_contextual_orchestrator_client.py | 442 ++++++++++++++++++ .../test_email_writing_orchestrator_scope.py | 151 ++++++ 3 files changed, 634 insertions(+) create mode 100644 .github/workflows/email-writing-orchestrator-tdd.yml create mode 100644 backend/tests/test_contextual_orchestrator_client.py create mode 100644 backend/tests/test_email_writing_orchestrator_scope.py diff --git a/.github/workflows/email-writing-orchestrator-tdd.yml b/.github/workflows/email-writing-orchestrator-tdd.yml new file mode 100644 index 000000000..04ebc5411 --- /dev/null +++ b/.github/workflows/email-writing-orchestrator-tdd.yml @@ -0,0 +1,41 @@ +name: Email Writing Orchestrator TDD + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: email-writing-orchestrator-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + +jobs: + red-contracts: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Run Task 5 red contracts + run: | + cd backend + python -m pytest -q \ + tests/test_contextual_orchestrator_client.py \ + tests/test_email_writing_orchestrator_scope.py diff --git a/backend/tests/test_contextual_orchestrator_client.py b/backend/tests/test_contextual_orchestrator_client.py new file mode 100644 index 000000000..3a00e96bd --- /dev/null +++ b/backend/tests/test_contextual_orchestrator_client.py @@ -0,0 +1,442 @@ +"""Test-first contracts for the authenticated contextual-orchestrator client.""" + +from __future__ import annotations + +import asyncio +import json +import threading +from typing import Any + +import httpx +import pytest + +from services.contextual_orchestrator_client import ( + ContextualOrchestratorClient, + ContextualOrchestratorError, +) +from services.email_writing_orchestrator_port import EmailWritingOrchestratorPort +from services.llm_provider_urls import ValidatedLLMProviderBaseURL + + +MESSAGES = [ + {"role": "system", "content": "Return strict JSON."}, + {"role": "user", "content": "Review this draft."}, +] + + +def _validated(*addresses: str) -> ValidatedLLMProviderBaseURL: + return ValidatedLLMProviderBaseURL( + normalized_url="https://orchestrator.example", + hostname="orchestrator.example", + port=443, + addresses=addresses or ("93.184.216.34",), + ) + + +def _success_payload(*, mode: str = "conduct") -> dict[str, Any]: + return { + "id": "chatcmpl-opaque", + "object": "chat.completion", + "model": "internal-provider-model-must-not-leak", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": '{"diagnostics":[]}'}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 11, + "completion_tokens": 7, + "total_tokens": 18, + }, + "orchestration": { + "mode": mode, + "workflow_run_id": "private-run-id", + "trace": [ + { + "role": "worker", + "output": "private model output", + "usage": { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, + }, + }, + { + "role": "verifier", + "usage": { + "prompt_tokens": 6, + "completion_tokens": 4, + "total_tokens": 10, + }, + }, + ], + }, + "provider_url": "https://provider.example/private", + } + + +def _builder(handler): + transport = httpx.MockTransport(handler) + + def build(_normalized_url: str, _hostname: str, _port: int, _addresses): + return httpx.AsyncClient( + transport=transport, + follow_redirects=False, + trust_env=False, + ) + + return build + + +async def _validator(_value: str | None) -> ValidatedLLMProviderBaseURL: + return _validated() + + +@pytest.mark.asyncio +async def test_complete_posts_only_the_fixed_authenticated_contract_and_redacts_trace() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=_success_payload()) + + client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(handler), + ) + + completion = await client.complete(MESSAGES, mode="conduct") + + assert completion.as_dict() == { + "answer": '{"diagnostics":[]}', + "mode": "conduct", + "trace": [ + { + "usage": { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, + } + }, + { + "usage": { + "prompt_tokens": 6, + "completion_tokens": 4, + "total_tokens": 10, + } + }, + ], + } + assert len(requests) == 1 + request = requests[0] + assert request.method == "POST" + assert request.url == httpx.URL( + "https://orchestrator.example/v1/chat/completions" + ) + assert request.headers["authorization"] == "Bearer tenant-secret-token" + payload = json.loads(request.content) + assert payload == { + "model": "email-review-v1", + "messages": MESSAGES, + "mode": "conduct", + "include_orchestration_trace": True, + } + assert "provider_url" not in completion.as_dict() + assert "workflow_run_id" not in completion.as_dict() + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status_code", "body", "expected_code"), + [ + (401, {"error": {"code": "unauthorized", "message": "secret detail"}}, "orchestrator_unauthorized"), + (403, {"error": {"code": "forbidden", "message": "secret detail"}}, "orchestrator_unauthorized"), + (429, {"error": {"code": "rate_limit_exceeded"}}, "orchestrator_rate_limited"), + (503, {"error": {"code": "concurrency_limit_exceeded"}}, "orchestrator_saturated"), + (400, {"error": {"code": "invalid_mode", "message": "provider detail"}}, "orchestrator_policy_rejected"), + (500, {"error": {"code": "internal", "message": "stack trace"}}, "orchestrator_unavailable"), + ], +) +async def test_http_failures_map_to_stable_redacted_outcomes( + status_code: int, + body: dict[str, Any], + expected_code: str, +) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(status_code, json=body) + + client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(handler), + max_retries=0, + ) + with pytest.raises(ContextualOrchestratorError) as captured: + await client.complete(MESSAGES, mode="route") + assert captured.value.code == expected_code + assert str(captured.value) == expected_code + assert "secret" not in repr(captured.value) + assert "provider" not in repr(captured.value) + + +@pytest.mark.asyncio +async def test_transient_statuses_retry_but_unauthorized_does_not() -> None: + transient_attempts = 0 + + async def transient_handler(_request: httpx.Request) -> httpx.Response: + nonlocal transient_attempts + transient_attempts += 1 + if transient_attempts < 3: + return httpx.Response(503, json={"error": {"code": "upstream_unavailable"}}) + return httpx.Response(200, json=_success_payload(mode="route")) + + delays: list[float] = [] + + async def sleeper(delay: float) -> None: + delays.append(delay) + + client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(transient_handler), + sleeper=sleeper, + max_retries=2, + ) + assert (await client.complete(MESSAGES, mode="route")).mode == "route" + assert transient_attempts == 3 + assert delays == [0.05, 0.1] + + unauthorized_attempts = 0 + + async def unauthorized_handler(_request: httpx.Request) -> httpx.Response: + nonlocal unauthorized_attempts + unauthorized_attempts += 1 + return httpx.Response(401, json={"error": {"code": "unauthorized"}}) + + unauthorized = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(unauthorized_handler), + sleeper=sleeper, + max_retries=2, + ) + with pytest.raises(ContextualOrchestratorError) as captured: + await unauthorized.complete(MESSAGES, mode="route") + assert captured.value.code == "orchestrator_unauthorized" + assert unauthorized_attempts == 1 + + +@pytest.mark.asyncio +async def test_redirects_are_not_followed_and_dns_rebinding_fails_closed() -> None: + request_count = 0 + + async def redirect_handler(_request: httpx.Request) -> httpx.Response: + nonlocal request_count + request_count += 1 + return httpx.Response( + 302, + headers={"location": "http://127.0.0.1/internal"}, + ) + + redirect_client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(redirect_handler), + max_retries=0, + ) + with pytest.raises(ContextualOrchestratorError) as captured: + await redirect_client.complete(MESSAGES, mode="route") + assert captured.value.code == "orchestrator_policy_rejected" + assert request_count == 1 + + validations = 0 + + async def rebinding_validator(_value: str | None) -> ValidatedLLMProviderBaseURL: + nonlocal validations + validations += 1 + return _validated( + "93.184.216.34" if validations == 1 else "93.184.216.35" + ) + + async def success_handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_success_payload(mode="route")) + + rebinding_client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=rebinding_validator, + client_builder=_builder(success_handler), + max_retries=0, + ) + assert (await rebinding_client.complete(MESSAGES, mode="route")).mode == "route" + with pytest.raises(ContextualOrchestratorError) as rebound: + await rebinding_client.complete(MESSAGES, mode="route") + assert rebound.value.code == "orchestrator_policy_rejected" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raw_body", + [ + b'{"choices":[],"choices":[],"orchestration":{"mode":"route"}}', + b'{"choices":[],"orchestration":{"mode":"route"}}', + b'{"choices":[{"message":{"content":"ok"}}],"orchestration":{"mode":"auto"}}', + b'[]', + b'not-json', + ], +) +async def test_malformed_responses_fail_closed(raw_body: bytes) -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=raw_body) + + client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(handler), + max_retries=0, + ) + with pytest.raises(ContextualOrchestratorError) as captured: + await client.complete(MESSAGES, mode="route") + assert captured.value.code == "orchestrator_malformed_response" + + +@pytest.mark.asyncio +async def test_oversized_body_invalid_messages_cancellation_and_close() -> None: + async def oversized_handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"x" * 257) + + oversized = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(oversized_handler), + max_response_bytes=256, + max_retries=0, + ) + with pytest.raises(ContextualOrchestratorError) as body_error: + await oversized.complete(MESSAGES, mode="route") + assert body_error.value.code == "orchestrator_malformed_response" + + with pytest.raises(ContextualOrchestratorError) as message_error: + await oversized.complete( + [{"role": "user", "content": "ok", "endpoint": "forged"}], + mode="route", + ) + assert message_error.value.code == "orchestrator_policy_rejected" + + started = asyncio.Event() + + async def blocked_handler(_request: httpx.Request) -> httpx.Response: + started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + cancellable = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(blocked_handler), + max_retries=0, + ) + task = asyncio.create_task(cancellable.complete(MESSAGES, mode="route")) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + await cancellable.aclose() + with pytest.raises(ContextualOrchestratorError) as closed: + await cancellable.complete(MESSAGES, mode="route") + assert closed.value.code == "orchestrator_client_closed" + + +@pytest.mark.asyncio +async def test_circuit_breaker_opens_after_repeated_transient_failures() -> None: + attempts = 0 + + async def handler(_request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + return httpx.Response(503, json={"error": {"code": "unavailable"}}) + + client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(handler), + max_retries=0, + circuit_failure_threshold=2, + circuit_open_seconds=30.0, + ) + for _ in range(2): + with pytest.raises(ContextualOrchestratorError): + await client.complete(MESSAGES, mode="route") + with pytest.raises(ContextualOrchestratorError) as opened: + await client.complete(MESSAGES, mode="route") + assert opened.value.code == "orchestrator_unavailable" + assert attempts == 2 + + +@pytest.mark.asyncio +async def test_port_exposes_async_candidate_sync_judge_shape_and_bounded_lane() -> None: + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_success_payload(mode="route")) + + client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_validator, + client_builder=_builder(handler), + ) + port = EmailWritingOrchestratorPort(client, judge_capacity=1) + + candidate = await port.complete_candidate(MESSAGES, mode="route") + assert candidate["answer"] == '{"diagnostics":[]}' + synchronous = await asyncio.to_thread(port.complete, MESSAGES, mode="route") + assert synchronous["mode"] == "route" + + entered: list[str] = [] + release = threading.Event() + + def judge(label: str) -> str: + entered.append(label) + if label == "first": + release.wait(timeout=2.0) + return label + + first = asyncio.create_task(port.run_judge(judge, "first")) + for _ in range(100): + if entered: + break + await asyncio.sleep(0.001) + second = asyncio.create_task(port.run_judge(judge, "second")) + await asyncio.sleep(0.02) + assert entered == ["first"] + release.set() + assert await first == "first" + assert await second == "second" + + await port.aclose() + with pytest.raises(RuntimeError, match="judge_lane_closed"): + await port.run_judge(judge, "closed") diff --git a/backend/tests/test_email_writing_orchestrator_scope.py b/backend/tests/test_email_writing_orchestrator_scope.py new file mode 100644 index 000000000..364ba8e54 --- /dev/null +++ b/backend/tests/test_email_writing_orchestrator_scope.py @@ -0,0 +1,151 @@ +"""Test-first tenant-scoped configuration contracts for email-writing orchestration.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig +from services.tenant_config_scope import ( + EmailWritingOrchestratorConfigurationError, + email_writing_orchestrator_owner_filters, + get_scoped_email_writing_orchestrator_config, + new_scoped_email_writing_orchestrator_config, + resolve_email_writing_orchestrator_settings, +) + + +class _ScalarResult: + def __init__(self, value: Any) -> None: + self._value = value + + def scalar_one_or_none(self) -> Any: + return self._value + + +class _Session: + def __init__(self, value: Any) -> None: + self.value = value + self.queries: list[Any] = [] + + async def execute(self, query: Any) -> _ScalarResult: + self.queries.append(query) + return _ScalarResult(self.value) + + +def _config(**overrides: Any) -> EmailWritingOrchestratorConfig: + values: dict[str, Any] = { + "owner_user_id": "user_alpha", + "organization_id": "organization_alpha", + "orchestrator_enabled": True, + "orchestrator_base_url": "https://orchestrator.example", + "model_profile_id": "email-review-v1", + "inference_credential": "tenant-secret-token", + } + values.update(overrides) + return EmailWritingOrchestratorConfig(**values) + + +def test_email_writing_orchestrator_owner_filters_are_tenant_exact() -> None: + with_org = email_writing_orchestrator_owner_filters( + "user_alpha", "organization_alpha" + ) + assert len(with_org) == 2 + assert str(with_org[0].left) == "email_writing_orchestrator_config.owner_user_id" + assert with_org[0].right.value == "user_alpha" + assert str(with_org[1].left) == "email_writing_orchestrator_config.organization_id" + assert with_org[1].right.value == "organization_alpha" + + personal = email_writing_orchestrator_owner_filters("user_alpha", None) + assert personal[1].operator.__name__ == "is_" + + +@pytest.mark.asyncio +async def test_scoped_orchestrator_config_query_and_constructor() -> None: + existing = _config() + session = _Session(existing) + assert ( + await get_scoped_email_writing_orchestrator_config( + session, "user_alpha", "organization_alpha" + ) + is existing + ) + assert len(session.queries) == 1 + + created = new_scoped_email_writing_orchestrator_config( + "user_alpha", "organization_alpha" + ) + assert created.owner_user_id == "user_alpha" + assert created.organization_id == "organization_alpha" + assert created.orchestrator_enabled is False + + +@pytest.mark.asyncio +async def test_settings_resolver_is_disabled_by_default_and_trims_values() -> None: + assert ( + await resolve_email_writing_orchestrator_settings( + _Session(None), + user_id="user_alpha", + organization_id="organization_alpha", + ) + is None + ) + disabled = _config(orchestrator_enabled=False) + assert ( + await resolve_email_writing_orchestrator_settings( + _Session(disabled), + user_id="user_alpha", + organization_id="organization_alpha", + ) + is None + ) + + enabled = _config( + orchestrator_base_url=" https://orchestrator.example ", + model_profile_id=" email-review-v1 ", + inference_credential=" tenant-secret-token ", + ) + resolved = await resolve_email_writing_orchestrator_settings( + _Session(enabled), + user_id="user_alpha", + organization_id="organization_alpha", + ) + assert resolved is not None + assert resolved.base_url == "https://orchestrator.example" + assert resolved.model_profile_id == "email-review-v1" + assert resolved.inference_credential == "tenant-secret-token" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "field_name", + ["orchestrator_base_url", "model_profile_id", "inference_credential"], +) +async def test_enabled_incomplete_settings_fail_closed(field_name: str) -> None: + config = _config(**{field_name: " "}) + with pytest.raises(EmailWritingOrchestratorConfigurationError) as captured: + await resolve_email_writing_orchestrator_settings( + _Session(config), + user_id="user_alpha", + organization_id="organization_alpha", + ) + assert captured.value.code == "email_writing_orchestrator_incomplete" + assert str(captured.value) == "email_writing_orchestrator_incomplete" + + +def test_config_repr_and_evidence_surface_never_expose_inference_credential() -> None: + config = _config() + representation = repr(config) + evidence = config.to_evidence_dict() + assert "tenant-secret-token" not in representation + assert "tenant-secret-token" not in repr(evidence) + assert evidence == { + "orchestrator_config_id": None, + "owner_user_id": "user_alpha", + "organization_id": "organization_alpha", + "orchestrator_enabled": True, + "orchestrator_base_url": "https://orchestrator.example", + "model_profile_id": "email-review-v1", + "has_inference_credential": True, + } From 9fbb7f395e809ab8b7379deb5e88fa00adc8cdf0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:28:00 +0900 Subject: [PATCH 02/46] feat(email-writing): add scoped orchestrator settings --- backend/services/tenant_config_scope.py | 107 ++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/backend/services/tenant_config_scope.py b/backend/services/tenant_config_scope.py index ad9f24397..f6f613942 100644 --- a/backend/services/tenant_config_scope.py +++ b/backend/services/tenant_config_scope.py @@ -1,10 +1,36 @@ +"""Owner-scoped configuration lookup helpers for tenant integrations.""" + +from __future__ import annotations + +from dataclasses import dataclass + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig from db.models import TenantConfig +class EmailWritingOrchestratorConfigurationError(RuntimeError): + """Stable, secret-free tenant orchestration configuration failure.""" + + def __init__(self, code: str) -> None: + """Create a configuration error identified only by a public code.""" + super().__init__(code) + self.code = code + + +@dataclass(frozen=True) +class EmailWritingOrchestratorSettings: + """Complete tenant settings required to build the orchestration client.""" + + base_url: str + model_profile_id: str + inference_credential: str + + def tenant_config_owner_filters(user_id: str, organization_id: str | None): + """Return exact owner filters for the legacy tenant configuration row.""" organization_filter = ( TenantConfig.organization_id == organization_id if organization_id is not None @@ -18,6 +44,7 @@ async def get_scoped_tenant_config( user_id: str, organization_id: str | None, ) -> TenantConfig | None: + """Return the tenant configuration visible to one exact owner scope.""" result = await session.execute( select(TenantConfig).where( *tenant_config_owner_filters(user_id, organization_id) @@ -30,4 +57,84 @@ def new_scoped_tenant_config( user_id: str, organization_id: str | None, ) -> TenantConfig: + """Create an unsaved legacy tenant configuration in one owner scope.""" return TenantConfig(user_id=user_id, organization_id=organization_id) + + +def email_writing_orchestrator_owner_filters( + user_id: str, + organization_id: str | None, +): + """Return exact owner filters for email-writing orchestration settings.""" + organization_filter = ( + EmailWritingOrchestratorConfig.organization_id == organization_id + if organization_id is not None + else EmailWritingOrchestratorConfig.organization_id.is_(None) + ) + return ( + EmailWritingOrchestratorConfig.owner_user_id == user_id, + organization_filter, + ) + + +async def get_scoped_email_writing_orchestrator_config( + session: AsyncSession, + user_id: str, + organization_id: str | None, +) -> EmailWritingOrchestratorConfig | None: + """Return one owner-scoped email-writing orchestration configuration.""" + result = await session.execute( + select(EmailWritingOrchestratorConfig).where( + *email_writing_orchestrator_owner_filters(user_id, organization_id) + ) + ) + return result.scalar_one_or_none() + + +def new_scoped_email_writing_orchestrator_config( + user_id: str, + organization_id: str | None, +) -> EmailWritingOrchestratorConfig: + """Create an unsaved, disabled email-writing orchestration configuration.""" + return EmailWritingOrchestratorConfig( + owner_user_id=user_id, + organization_id=organization_id, + orchestrator_enabled=False, + ) + + +def _clean_orchestrator_value(value: str | None) -> str | None: + """Trim one optional configuration string without coercing other values.""" + if value is None: + return None + normalized = value.strip() + return normalized or None + + +async def resolve_email_writing_orchestrator_settings( + session: AsyncSession, + *, + user_id: str, + organization_id: str | None, +) -> EmailWritingOrchestratorSettings | None: + """Resolve complete enabled settings or fail closed when partially configured.""" + config = await get_scoped_email_writing_orchestrator_config( + session, + user_id, + organization_id, + ) + if config is None or not config.orchestrator_enabled: + return None + + base_url = _clean_orchestrator_value(config.orchestrator_base_url) + model_profile_id = _clean_orchestrator_value(config.model_profile_id) + inference_credential = _clean_orchestrator_value(config.inference_credential) + if base_url is None or model_profile_id is None or inference_credential is None: + raise EmailWritingOrchestratorConfigurationError( + "email_writing_orchestrator_incomplete" + ) + return EmailWritingOrchestratorSettings( + base_url=base_url, + model_profile_id=model_profile_id, + inference_credential=inference_credential, + ) From 7cf287131f0e8ea0e99113eb0e4501b2495cd1b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 11:28:43 +0900 Subject: [PATCH 03/46] feat(email-writing): add secure orchestrator client port --- .../db/email_writing_orchestrator_config.py | 85 +++ .../contextual_orchestrator_client.py | 533 ++++++++++++++++++ .../email_writing_orchestrator_port.py | 123 ++++ 3 files changed, 741 insertions(+) create mode 100644 backend/db/email_writing_orchestrator_config.py create mode 100644 backend/services/contextual_orchestrator_client.py create mode 100644 backend/services/email_writing_orchestrator_port.py diff --git a/backend/db/email_writing_orchestrator_config.py b/backend/db/email_writing_orchestrator_config.py new file mode 100644 index 000000000..3ab2cb3f8 --- /dev/null +++ b/backend/db/email_writing_orchestrator_config.py @@ -0,0 +1,85 @@ +"""Tenant-scoped persistence for Naruon's email-writing orchestrator route. + +This module keeps the inference credential encrypted at rest and exposes only a +privacy-minimized evidence surface. It deliberately reuses the repository's +canonical SQLAlchemy metadata so Alembic and the application retain one schema +registry while the email-writing aggregate remains independently testable. +""" + +from __future__ import annotations + +import datetime + +from sqlalchemy import Boolean, DateTime, Index, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from db.models import Base, EncryptedString + + +class EmailWritingOrchestratorConfig(Base): + """One owner-scoped contextual-orchestrator configuration record.""" + + __tablename__ = "email_writing_orchestrator_config" + + orchestrator_config_id: Mapped[int] = mapped_column(primary_key=True) + owner_user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + organization_id: Mapped[str | None] = mapped_column( + String, + index=True, + nullable=True, + ) + orchestrator_enabled: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) + orchestrator_base_url: Mapped[str | None] = mapped_column(String, nullable=True) + model_profile_id: Mapped[str | None] = mapped_column(String, nullable=True) + inference_credential: Mapped[str | None] = mapped_column( + EncryptedString, + nullable=True, + ) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + nullable=False, + ) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.datetime.now(datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(datetime.timezone.utc), + nullable=False, + ) + + def to_evidence_dict(self) -> dict[str, object]: + """Return log-safe configuration evidence without the credential value.""" + return { + "orchestrator_config_id": self.orchestrator_config_id, + "owner_user_id": self.owner_user_id, + "organization_id": self.organization_id, + "orchestrator_enabled": self.orchestrator_enabled, + "orchestrator_base_url": self.orchestrator_base_url, + "model_profile_id": self.model_profile_id, + "has_inference_credential": self.inference_credential is not None, + } + + def __repr__(self) -> str: + """Return a secret-free diagnostic representation.""" + return ( + "" + ) + + +Index( + "uq_email_writing_orchestrator_config_owner_scope", + EmailWritingOrchestratorConfig.owner_user_id, + func.coalesce(EmailWritingOrchestratorConfig.organization_id, ""), + unique=True, +) diff --git a/backend/services/contextual_orchestrator_client.py b/backend/services/contextual_orchestrator_client.py new file mode 100644 index 000000000..302fd9586 --- /dev/null +++ b/backend/services/contextual_orchestrator_client.py @@ -0,0 +1,533 @@ +"""Authenticated, fail-closed transport to contextual-orchestrator. + +The client accepts only a tenant-scoped HTTPS origin and one fixed +``/v1/chat/completions`` path. DNS is resolved through Naruon's canonical +allowlist validator on every completion, the resulting address set is pinned to +the HTTP transport, redirects are disabled, and a later address-set change is +rejected as a possible rebinding event. Returned orchestration evidence is +reduced to token counts; prompts, answers, provider details, URLs, credentials, +workflow identifiers, and trace messages are never retained by this module. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +import json +import re +import threading +import time +from typing import Any, Literal, Protocol, TypeAlias, cast +from urllib.parse import urlsplit, urlunsplit + +import httpx + +from services.llm_provider_urls import ( + ValidatedLLMProviderBaseURL, + build_pinned_https_async_client, + validate_llm_provider_base_url_details_async, +) + +OrchestrationMode = Literal["route", "conduct"] +ChatMessage: TypeAlias = Mapping[str, str] +EndpointValidator: TypeAlias = Callable[ + [str | None], Awaitable[ValidatedLLMProviderBaseURL | None] +] +ClientBuilder: TypeAlias = Callable[ + [str, str, int, tuple[str, ...]], httpx.AsyncClient +] +AsyncSleeper: TypeAlias = Callable[[float], Awaitable[None]] + +_CHAT_COMPLETIONS_PATH = "/v1/chat/completions" +_ALLOWED_MESSAGE_ROLES = frozenset({"system", "user", "assistant", "tool"}) +_TRANSIENT_STATUS_CODES = frozenset({408, 409, 425, 429, 500, 502, 503, 504}) +_PROFILE_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +_MAX_MESSAGE_COUNT = 64 +_MAX_MESSAGE_CHARS = 200_000 +_MAX_TOTAL_MESSAGE_CHARS = 1_000_000 +_MAX_SAFE_INTEGER = 2**53 - 1 + + +class _JsonObjectPairsHook(Protocol): + def __call__(self, pairs: list[tuple[str, Any]]) -> dict[str, Any]: ... + + +class ContextualOrchestratorError(RuntimeError): + """Stable, redacted contextual-orchestrator transport failure.""" + + def __init__(self, code: str, *, transient: bool = False) -> None: + """Create an error carrying only a public code and retry classification.""" + super().__init__(code) + self.code = code + self.transient = transient + + def __repr__(self) -> str: + """Return a representation that cannot contain upstream details.""" + return f"ContextualOrchestratorError({self.code!r})" + + +@dataclass(frozen=True) +class OrchestrationUsageEvidence: + """Privacy-minimized token evidence for one orchestration step.""" + + prompt_tokens: int + completion_tokens: int + total_tokens: int + + def as_dict(self) -> dict[str, int]: + """Serialize the bounded token counters.""" + return { + "prompt_tokens": self.prompt_tokens, + "completion_tokens": self.completion_tokens, + "total_tokens": self.total_tokens, + } + + +@dataclass(frozen=True) +class ContextualOrchestratorCompletion: + """Strict model answer and redacted orchestration evidence.""" + + answer: str + mode: OrchestrationMode + trace: tuple[OrchestrationUsageEvidence, ...] + + def as_dict(self) -> dict[str, object]: + """Serialize the completion without provider or workflow metadata.""" + return { + "answer": self.answer, + "mode": self.mode, + "trace": [{"usage": item.as_dict()} for item in self.trace], + } + + +def _default_client_builder( + normalized_url: str, + hostname: str, + port: int, + addresses: tuple[str, ...], +) -> httpx.AsyncClient: + """Build the canonical redirect-disabled, DNS-pinned HTTPX client.""" + return build_pinned_https_async_client( + normalized_url, + hostname, + port, + addresses, + ) + + +def _contains_surrogate(value: str) -> bool: + """Return whether ``value`` contains a non-scalar Unicode surrogate.""" + return any(0xD800 <= ord(character) <= 0xDFFF for character in value) + + +def _bounded_secret(value: str, *, maximum: int, code: str) -> str: + """Normalize one required configuration string without exposing it in errors.""" + if not isinstance(value, str): + raise ContextualOrchestratorError(code) + normalized = value.strip() + if ( + not normalized + or len(normalized) > maximum + or _contains_surrogate(normalized) + or any(ord(character) < 32 or ord(character) == 127 for character in normalized) + ): + raise ContextualOrchestratorError(code) + return normalized + + +def _strict_object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build a JSON object while rejecting duplicate member names.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate_json_key") + result[key] = value + return result + + +def _bounded_counter(value: Any) -> int: + """Validate one non-negative JavaScript-safe integer counter.""" + if isinstance(value, bool) or not isinstance(value, int): + raise ContextualOrchestratorError("orchestrator_malformed_response") + if value < 0 or value > _MAX_SAFE_INTEGER: + raise ContextualOrchestratorError("orchestrator_malformed_response") + return value + + +class ContextualOrchestratorClient: + """Secure per-tenant client for candidate and Judge completions.""" + + def __init__( + self, + *, + base_url: str, + inference_credential: str, + model_profile_id: str, + endpoint_validator: EndpointValidator = validate_llm_provider_base_url_details_async, + client_builder: ClientBuilder = _default_client_builder, + sleeper: AsyncSleeper = asyncio.sleep, + max_retries: int = 2, + max_response_bytes: int = 1_000_000, + connect_timeout_seconds: float = 5.0, + read_timeout_seconds: float = 90.0, + write_timeout_seconds: float = 10.0, + pool_timeout_seconds: float = 5.0, + circuit_failure_threshold: int = 3, + circuit_open_seconds: float = 30.0, + monotonic: Callable[[], float] = time.monotonic, + ) -> None: + """Create a bounded client from already authorized tenant configuration.""" + self._base_url = _bounded_secret( + base_url, + maximum=2_048, + code="orchestrator_policy_rejected", + ) + self._inference_credential = _bounded_secret( + inference_credential, + maximum=16_384, + code="orchestrator_policy_rejected", + ) + self._model_profile_id = _bounded_secret( + model_profile_id, + maximum=128, + code="orchestrator_policy_rejected", + ) + if _PROFILE_IDENTIFIER_RE.fullmatch(self._model_profile_id) is None: + raise ContextualOrchestratorError("orchestrator_policy_rejected") + if max_retries < 0 or max_retries > 5: + raise ValueError("max_retries must be between 0 and 5") + if max_response_bytes <= 0: + raise ValueError("max_response_bytes must be positive") + if circuit_failure_threshold <= 0: + raise ValueError("circuit_failure_threshold must be positive") + if circuit_open_seconds <= 0: + raise ValueError("circuit_open_seconds must be positive") + + self._endpoint_validator = endpoint_validator + self._client_builder = client_builder + self._sleeper = sleeper + self._max_retries = max_retries + self._max_response_bytes = max_response_bytes + self._timeout = httpx.Timeout( + connect=connect_timeout_seconds, + read=read_timeout_seconds, + write=write_timeout_seconds, + pool=pool_timeout_seconds, + ) + self._circuit_failure_threshold = circuit_failure_threshold + self._circuit_open_seconds = circuit_open_seconds + self._monotonic = monotonic + self._state_lock = threading.Lock() + self._closed = False + self._transient_failure_count = 0 + self._circuit_open_until = 0.0 + self._endpoint_fingerprint: tuple[str, int, tuple[str, ...]] | None = None + + async def complete( + self, + messages: Sequence[ChatMessage], + *, + mode: OrchestrationMode, + ) -> ContextualOrchestratorCompletion: + """Submit one strict completion and return privacy-minimized evidence.""" + self._assert_available() + normalized_messages = self._validate_messages(messages) + if mode not in {"route", "conduct"}: + raise ContextualOrchestratorError("orchestrator_policy_rejected") + endpoint = await self._validated_endpoint() + payload = { + "model": self._model_profile_id, + "messages": normalized_messages, + "mode": mode, + "include_orchestration_trace": True, + } + headers = { + "Authorization": f"Bearer {self._inference_credential}", + "Content-Type": "application/json", + } + + client = self._client_builder( + endpoint.normalized_url, + endpoint.hostname, + endpoint.port, + endpoint.addresses, + ) + try: + for attempt in range(self._max_retries + 1): + try: + completion = await self._send_once( + client, + endpoint.normalized_url + _CHAT_COMPLETIONS_PATH, + headers, + payload, + ) + except ContextualOrchestratorError as exc: + if not exc.transient or attempt >= self._max_retries: + if exc.transient: + self._record_transient_failure() + raise + await self._sleeper(0.05 * (2**attempt)) + except asyncio.CancelledError: + raise + except httpx.TimeoutException as exc: + error = ContextualOrchestratorError( + "orchestrator_unavailable", + transient=True, + ) + if attempt >= self._max_retries: + self._record_transient_failure() + raise error from exc + await self._sleeper(0.05 * (2**attempt)) + except httpx.RequestError as exc: + error = ContextualOrchestratorError( + "orchestrator_unavailable", + transient=True, + ) + if attempt >= self._max_retries: + self._record_transient_failure() + raise error from exc + await self._sleeper(0.05 * (2**attempt)) + else: + self._record_success() + return completion + finally: + await client.aclose() + raise AssertionError("unreachable completion loop") + + async def aclose(self) -> None: + """Permanently close this logical tenant client.""" + with self._state_lock: + self._closed = True + + def _assert_available(self) -> None: + """Fail before network work when closed or circuit-open.""" + with self._state_lock: + if self._closed: + raise ContextualOrchestratorError("orchestrator_client_closed") + if self._monotonic() < self._circuit_open_until: + raise ContextualOrchestratorError( + "orchestrator_unavailable", + transient=True, + ) + + async def _validated_endpoint(self) -> ValidatedLLMProviderBaseURL: + """Resolve, pin, and bind the configured HTTPS origin.""" + try: + validated = await self._endpoint_validator(self._base_url) + except (ValueError, OSError) as exc: + raise ContextualOrchestratorError( + "orchestrator_policy_rejected" + ) from exc + if validated is None: + raise ContextualOrchestratorError("orchestrator_policy_rejected") + + parsed = urlsplit(validated.normalized_url) + if ( + parsed.scheme.lower() != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + ): + raise ContextualOrchestratorError("orchestrator_policy_rejected") + addresses = tuple(sorted(set(validated.addresses))) + if not addresses: + raise ContextualOrchestratorError("orchestrator_policy_rejected") + fingerprint = (validated.hostname, validated.port, addresses) + with self._state_lock: + if self._endpoint_fingerprint is None: + self._endpoint_fingerprint = fingerprint + elif self._endpoint_fingerprint != fingerprint: + raise ContextualOrchestratorError("orchestrator_policy_rejected") + normalized_origin = urlunsplit( + (parsed.scheme.lower(), parsed.netloc, "", "", "") + ) + return ValidatedLLMProviderBaseURL( + normalized_url=normalized_origin, + hostname=validated.hostname, + port=validated.port, + addresses=addresses, + ) + + def _validate_messages( + self, + messages: Sequence[ChatMessage], + ) -> list[dict[str, str]]: + """Validate a bounded OpenAI-compatible message array without coercion.""" + if ( + isinstance(messages, (str, bytes)) + or not isinstance(messages, Sequence) + or not messages + or len(messages) > _MAX_MESSAGE_COUNT + ): + raise ContextualOrchestratorError("orchestrator_policy_rejected") + normalized: list[dict[str, str]] = [] + total_characters = 0 + for message in messages: + if not isinstance(message, Mapping) or set(message) != {"role", "content"}: + raise ContextualOrchestratorError("orchestrator_policy_rejected") + role = message.get("role") + content = message.get("content") + if ( + not isinstance(role, str) + or role not in _ALLOWED_MESSAGE_ROLES + or not isinstance(content, str) + or len(content) > _MAX_MESSAGE_CHARS + or _contains_surrogate(content) + ): + raise ContextualOrchestratorError("orchestrator_policy_rejected") + total_characters += len(content) + if total_characters > _MAX_TOTAL_MESSAGE_CHARS: + raise ContextualOrchestratorError("orchestrator_policy_rejected") + normalized.append({"role": role, "content": content}) + return normalized + + async def _send_once( + self, + client: httpx.AsyncClient, + endpoint_url: str, + headers: Mapping[str, str], + payload: Mapping[str, object], + ) -> ContextualOrchestratorCompletion: + """Execute one bounded HTTP request without following redirects.""" + async with client.stream( + "POST", + endpoint_url, + json=payload, + headers=headers, + timeout=self._timeout, + follow_redirects=False, + ) as response: + body = await self._read_bounded_body(response) + if response.is_redirect: + raise ContextualOrchestratorError("orchestrator_policy_rejected") + if response.status_code >= 400: + raise self._http_error(response.status_code, body) + return self._parse_completion(body) + + async def _read_bounded_body(self, response: httpx.Response) -> bytes: + """Read at most the configured response-byte budget.""" + chunks: list[bytes] = [] + observed = 0 + async for chunk in response.aiter_bytes(): + observed += len(chunk) + if observed > self._max_response_bytes: + raise ContextualOrchestratorError( + "orchestrator_malformed_response" + ) + chunks.append(chunk) + return b"".join(chunks) + + def _http_error(self, status_code: int, body: bytes) -> ContextualOrchestratorError: + """Map an HTTP failure to one stable public outcome.""" + upstream_code = self._safe_upstream_error_code(body) + if status_code in {401, 403}: + code = "orchestrator_unauthorized" + elif status_code == 429: + code = "orchestrator_rate_limited" + elif status_code == 503 and upstream_code == "concurrency_limit_exceeded": + code = "orchestrator_saturated" + elif status_code >= 500 or status_code in {408, 409, 425}: + code = "orchestrator_unavailable" + else: + code = "orchestrator_policy_rejected" + return ContextualOrchestratorError( + code, + transient=status_code in _TRANSIENT_STATUS_CODES, + ) + + def _safe_upstream_error_code(self, body: bytes) -> str | None: + """Read only a bounded upstream error code, discarding all other fields.""" + try: + document = self._strict_json(body) + except ContextualOrchestratorError: + return None + error = document.get("error") + if not isinstance(error, dict): + return None + code = error.get("code") + if not isinstance(code, str) or len(code) > 128: + return None + return code + + def _strict_json(self, body: bytes) -> dict[str, Any]: + """Decode one duplicate-key-free UTF-8 JSON object.""" + try: + text = body.decode("utf-8", errors="strict") + value = json.loads( + text, + object_pairs_hook=cast(_JsonObjectPairsHook, _strict_object_pairs), + parse_constant=lambda _value: (_ for _ in ()).throw( + ValueError("non_finite_json_number") + ), + ) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise ContextualOrchestratorError( + "orchestrator_malformed_response" + ) from exc + if not isinstance(value, dict): + raise ContextualOrchestratorError("orchestrator_malformed_response") + return value + + def _parse_completion(self, body: bytes) -> ContextualOrchestratorCompletion: + """Parse the strict answer and retain only per-step usage evidence.""" + document = self._strict_json(body) + choices = document.get("choices") + orchestration = document.get("orchestration") + if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict): + raise ContextualOrchestratorError("orchestrator_malformed_response") + message = choices[0].get("message") + if not isinstance(message, dict): + raise ContextualOrchestratorError("orchestrator_malformed_response") + answer = message.get("content") + if not isinstance(answer, str) or _contains_surrogate(answer): + raise ContextualOrchestratorError("orchestrator_malformed_response") + if not isinstance(orchestration, dict): + raise ContextualOrchestratorError("orchestrator_malformed_response") + mode = orchestration.get("mode") + if mode not in {"route", "conduct"}: + raise ContextualOrchestratorError("orchestrator_malformed_response") + raw_trace = orchestration.get("trace") + if not isinstance(raw_trace, list): + raise ContextualOrchestratorError("orchestrator_malformed_response") + trace: list[OrchestrationUsageEvidence] = [] + for step in raw_trace: + if not isinstance(step, dict): + raise ContextualOrchestratorError( + "orchestrator_malformed_response" + ) + usage = step.get("usage") + if not isinstance(usage, dict): + raise ContextualOrchestratorError( + "orchestrator_malformed_response" + ) + trace.append( + OrchestrationUsageEvidence( + prompt_tokens=_bounded_counter(usage.get("prompt_tokens")), + completion_tokens=_bounded_counter( + usage.get("completion_tokens") + ), + total_tokens=_bounded_counter(usage.get("total_tokens")), + ) + ) + return ContextualOrchestratorCompletion( + answer=answer, + mode=cast(OrchestrationMode, mode), + trace=tuple(trace), + ) + + def _record_transient_failure(self) -> None: + """Advance the circuit breaker after one exhausted transient call.""" + with self._state_lock: + self._transient_failure_count += 1 + if self._transient_failure_count >= self._circuit_failure_threshold: + self._circuit_open_until = self._monotonic() + self._circuit_open_seconds + + def _record_success(self) -> None: + """Close the circuit after a successful completion.""" + with self._state_lock: + self._transient_failure_count = 0 + self._circuit_open_until = 0.0 diff --git a/backend/services/email_writing_orchestrator_port.py b/backend/services/email_writing_orchestrator_port.py new file mode 100644 index 000000000..49173d105 --- /dev/null +++ b/backend/services/email_writing_orchestrator_port.py @@ -0,0 +1,123 @@ +"""Concurrency-bounded port for Naruon's email-writing model workflow. + +Candidate generation remains async. The independent Judge may expose a +synchronous API, so the port provides a capacity-limited worker lane that never +runs Judge computation on the FastAPI event-loop thread. Cancellation waits for +the submitted worker to settle before returning capacity, preventing hidden +oversubscription. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Mapping, Sequence +from concurrent.futures import ThreadPoolExecutor +import threading +from typing import Any, ParamSpec, TypeVar + +from services.contextual_orchestrator_client import ( + ChatMessage, + ContextualOrchestratorClient, + OrchestrationMode, +) + +P = ParamSpec("P") +R = TypeVar("R") + + +class EmailWritingOrchestratorPort: + """Candidate and Judge orchestration boundary for email-writing review.""" + + def __init__( + self, + client: ContextualOrchestratorClient, + *, + judge_capacity: int = 2, + ) -> None: + """Create a port with a fixed-size Judge worker lane.""" + if judge_capacity <= 0 or judge_capacity > 32: + raise ValueError("judge_capacity must be between 1 and 32") + self._client = client + self._judge_capacity = judge_capacity + self._judge_semaphore = asyncio.Semaphore(judge_capacity) + self._judge_executor = ThreadPoolExecutor( + max_workers=judge_capacity, + thread_name_prefix="email_writing_judge", + ) + self._state_lock = threading.Lock() + self._closed = False + + async def complete_candidate( + self, + messages: Sequence[ChatMessage], + *, + mode: OrchestrationMode, + ) -> dict[str, object]: + """Run async candidate generation through contextual-orchestrator.""" + self._assert_open() + completion = await self._client.complete(messages, mode=mode) + return completion.as_dict() + + def complete( + self, + messages: Sequence[ChatMessage], + *, + mode: OrchestrationMode, + ) -> dict[str, object]: + """Run a completion from synchronous Judge-compatible code.""" + self._assert_open() + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError("sync_completion_on_event_loop") + completion = asyncio.run(self._client.complete(messages, mode=mode)) + return completion.as_dict() + + async def run_judge( + self, + operation: Callable[P, R], + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + """Run one synchronous Judge operation in the bounded worker lane.""" + self._assert_judge_lane_open() + await self._judge_semaphore.acquire() + loop = asyncio.get_running_loop() + future = loop.run_in_executor( + self._judge_executor, + lambda: operation(*args, **kwargs), + ) + try: + return await future + except asyncio.CancelledError: + await asyncio.shield(future) + raise + finally: + self._judge_semaphore.release() + + async def aclose(self) -> None: + """Close candidate transport and settle the Judge worker lane.""" + with self._state_lock: + if self._closed: + return + self._closed = True + await self._client.aclose() + await asyncio.to_thread( + self._judge_executor.shutdown, + True, + cancel_futures=False, + ) + + def _assert_open(self) -> None: + """Reject candidate work after closure.""" + with self._state_lock: + if self._closed: + raise RuntimeError("orchestrator_port_closed") + + def _assert_judge_lane_open(self) -> None: + """Reject Judge work after closure with a stable lane code.""" + with self._state_lock: + if self._closed: + raise RuntimeError("judge_lane_closed") From 9b49dac8897fd399f0b0c6f19a0f70997a2687d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 22:46:17 +0900 Subject: [PATCH 04/46] test(email-writing): require deployable orchestrator config --- ...st_email_writing_orchestrator_migration.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 backend/tests/test_email_writing_orchestrator_migration.py diff --git a/backend/tests/test_email_writing_orchestrator_migration.py b/backend/tests/test_email_writing_orchestrator_migration.py new file mode 100644 index 000000000..2573cff3f --- /dev/null +++ b/backend/tests/test_email_writing_orchestrator_migration.py @@ -0,0 +1,24 @@ +"""Migration contract for email-writing orchestration configuration.""" + +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_PATH = ( + BACKEND_ROOT + / "alembic" + / "versions" + / "20260813_0001_add_email_writing_orchestrator_config.py" +) + + +def test_orchestrator_configuration_has_an_alembic_revision() -> None: + """A production deployment can create the configuration table.""" + assert MIGRATION_PATH.is_file() + + +def test_alembic_environment_registers_orchestrator_configuration() -> None: + """Autogenerate includes the modular configuration model metadata.""" + environment_source = (BACKEND_ROOT / "alembic" / "env.py").read_text( + encoding="utf-8" + ) + assert "EmailWritingOrchestratorConfig" in environment_source From f30f0e93ed851976d8b6d11b8fe2a748964f797e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 22:46:40 +0900 Subject: [PATCH 05/46] test(email-writing): execute orchestrator migration contract --- .github/workflows/email-writing-orchestrator-tdd.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/email-writing-orchestrator-tdd.yml b/.github/workflows/email-writing-orchestrator-tdd.yml index 04ebc5411..e53ef5200 100644 --- a/.github/workflows/email-writing-orchestrator-tdd.yml +++ b/.github/workflows/email-writing-orchestrator-tdd.yml @@ -33,9 +33,10 @@ jobs: cache: pip cache-dependency-path: backend/requirements-hashes.txt - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Run Task 5 red contracts + - name: Run Task 5 contracts run: | cd backend python -m pytest -q \ tests/test_contextual_orchestrator_client.py \ - tests/test_email_writing_orchestrator_scope.py + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py From 43f915ccb2993489384e79d4ea3d34f77adff66a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 22:57:26 +0900 Subject: [PATCH 06/46] feat(email-writing): migrate orchestrator configuration --- ...1_add_email_writing_orchestrator_config.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 backend/alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py diff --git a/backend/alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py b/backend/alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py new file mode 100644 index 000000000..ef7918eb6 --- /dev/null +++ b/backend/alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py @@ -0,0 +1,22 @@ +"""Add the owner-scoped email-writing orchestration table.""" + +from __future__ import annotations + +from alembic import op + +from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig + +revision = "20260813_email_orchestrator" +down_revision = "20260812_email_writing_evidence" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create the configuration table and its indexes.""" + EmailWritingOrchestratorConfig.__table__.create(op.get_bind(), checkfirst=True) + + +def downgrade() -> None: + """Drop only the configuration table introduced by this revision.""" + EmailWritingOrchestratorConfig.__table__.drop(op.get_bind(), checkfirst=True) From 2ab368e7b4d2dcedc7b8cc637ed6a7d30d3c2aea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:01:22 +0900 Subject: [PATCH 07/46] chore(email-writing): register migration metadata --- ...riting-orchestrator-register-migration.yml | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/email-writing-orchestrator-register-migration.yml diff --git a/.github/workflows/email-writing-orchestrator-register-migration.yml b/.github/workflows/email-writing-orchestrator-register-migration.yml new file mode 100644 index 000000000..287663c46 --- /dev/null +++ b/.github/workflows/email-writing-orchestrator-register-migration.yml @@ -0,0 +1,67 @@ +name: Register Email Writing Migration Metadata + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: email-writing-orchestrator-register-migration + cancel-in-progress: false + +jobs: + register: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + - name: Register the modular model with Alembic + run: | + python - <<'PY' + from pathlib import Path + + environment_path = Path("backend/alembic/env.py") + environment_source = environment_path.read_text(encoding="utf-8") + evidence_import = "from db.email_writing_evidence import EmailReviewSession\n" + model_import = ( + "from db.email_writing_orchestrator_config " + "import EmailWritingOrchestratorConfig\n" + ) + if model_import not in environment_source: + environment_source = environment_source.replace( + evidence_import, + evidence_import + model_import, + 1, + ) + environment_source = environment_source.replace( + "target_metadata = EmailReviewSession.__table__.metadata", + "target_metadata = EmailWritingOrchestratorConfig.__table__.metadata", + 1, + ) + environment_path.write_text(environment_source, encoding="utf-8") + Path( + ".github/workflows/" + "email-writing-orchestrator-register-migration.yml" + ).unlink() + PY + - name: Verify Task 5 contracts + run: | + python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + cd backend + python -m pytest -q \ + tests/test_contextual_orchestrator_client.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py + - name: Commit the verified registration + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add backend/alembic/env.py .github/workflows/email-writing-orchestrator-register-migration.yml + git commit -m "feat(email-writing): register orchestrator migration metadata" + git push origin "HEAD:${GITHUB_REF_NAME}" From 20882ad82a47d5e3708582817b44ba8e2f1aea3c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:02:14 +0000 Subject: [PATCH 08/46] feat(email-writing): register orchestrator migration metadata --- ...riting-orchestrator-register-migration.yml | 67 ------------------- backend/alembic/env.py | 3 +- 2 files changed, 2 insertions(+), 68 deletions(-) delete mode 100644 .github/workflows/email-writing-orchestrator-register-migration.yml diff --git a/.github/workflows/email-writing-orchestrator-register-migration.yml b/.github/workflows/email-writing-orchestrator-register-migration.yml deleted file mode 100644 index 287663c46..000000000 --- a/.github/workflows/email-writing-orchestrator-register-migration.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Register Email Writing Migration Metadata - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: email-writing-orchestrator-register-migration - cancel-in-progress: false - -jobs: - register: - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - - name: Register the modular model with Alembic - run: | - python - <<'PY' - from pathlib import Path - - environment_path = Path("backend/alembic/env.py") - environment_source = environment_path.read_text(encoding="utf-8") - evidence_import = "from db.email_writing_evidence import EmailReviewSession\n" - model_import = ( - "from db.email_writing_orchestrator_config " - "import EmailWritingOrchestratorConfig\n" - ) - if model_import not in environment_source: - environment_source = environment_source.replace( - evidence_import, - evidence_import + model_import, - 1, - ) - environment_source = environment_source.replace( - "target_metadata = EmailReviewSession.__table__.metadata", - "target_metadata = EmailWritingOrchestratorConfig.__table__.metadata", - 1, - ) - environment_path.write_text(environment_source, encoding="utf-8") - Path( - ".github/workflows/" - "email-writing-orchestrator-register-migration.yml" - ).unlink() - PY - - name: Verify Task 5 contracts - run: | - python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - cd backend - python -m pytest -q \ - tests/test_contextual_orchestrator_client.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py - - name: Commit the verified registration - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add backend/alembic/env.py .github/workflows/email-writing-orchestrator-register-migration.yml - git commit -m "feat(email-writing): register orchestrator migration metadata" - git push origin "HEAD:${GITHUB_REF_NAME}" diff --git a/backend/alembic/env.py b/backend/alembic/env.py index e8319725f..939b580c5 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,13 +8,14 @@ from core.config import settings from db.email_writing_evidence import EmailReviewSession +from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig config = context.config if config.config_file_name is not None: fileConfig(config.config_file_name) -target_metadata = EmailReviewSession.__table__.metadata +target_metadata = EmailWritingOrchestratorConfig.__table__.metadata def _database_url() -> str: From 2479afb3f3863fc190a3de9752bbe5177444ee22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:03:35 +0900 Subject: [PATCH 09/46] ci(email-writing): verify complete Task 5 contract --- .github/workflows/email-writing-orchestrator-tdd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/email-writing-orchestrator-tdd.yml b/.github/workflows/email-writing-orchestrator-tdd.yml index e53ef5200..1cbb6be8f 100644 --- a/.github/workflows/email-writing-orchestrator-tdd.yml +++ b/.github/workflows/email-writing-orchestrator-tdd.yml @@ -19,7 +19,7 @@ env: DISABLE_BACKGROUND_WORKERS: "1" jobs: - red-contracts: + task5-contracts: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: From ec41939c88e07b5cbf311bb3d3180901578ca0b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:06:07 +0900 Subject: [PATCH 10/46] test(email-writing): add Judge cancellation regression --- ...-writing-orchestrator-cancellation-red.yml | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/email-writing-orchestrator-cancellation-red.yml diff --git a/.github/workflows/email-writing-orchestrator-cancellation-red.yml b/.github/workflows/email-writing-orchestrator-cancellation-red.yml new file mode 100644 index 000000000..4b37f5e5a --- /dev/null +++ b/.github/workflows/email-writing-orchestrator-cancellation-red.yml @@ -0,0 +1,78 @@ +name: Add Email Writing Judge Cancellation Regression + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: email-writing-orchestrator-cancellation-red + cancel-in-progress: false + +jobs: + add-regression: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + - name: Append the cancellation regression + run: | + python - <<'PY' + from pathlib import Path + + test_path = Path("backend/tests/test_contextual_orchestrator_client.py") + source = test_path.read_text(encoding="utf-8") + test_name = "test_cancelled_judge_retains_capacity_until_worker_settles" + addition = ''' + + +@pytest.mark.asyncio +async def test_cancelled_judge_retains_capacity_until_worker_settles() -> None: + """Cancellation does not return while its submitted worker still runs.""" + + class _PortClient: + async def aclose(self) -> None: + return None + + port = EmailWritingOrchestratorPort(_PortClient(), judge_capacity=1) + started = threading.Event() + release = threading.Event() + + def blocking_judge() -> str: + started.set() + release.wait(timeout=2.0) + return "settled" + + task = asyncio.create_task(port.run_judge(blocking_judge)) + assert await asyncio.to_thread(started.wait, 1.0) + task.cancel() + await asyncio.sleep(0.02) + returned_before_worker_settled = task.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + await port.aclose() + assert returned_before_worker_settled is False +''' + if test_name not in source: + test_path.write_text(source + addition, encoding="utf-8") + Path( + ".github/workflows/" + "email-writing-orchestrator-cancellation-red.yml" + ).unlink() + PY + - name: Compile the test module + run: python -m py_compile backend/tests/test_contextual_orchestrator_client.py + - name: Commit the test-only generation + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add backend/tests/test_contextual_orchestrator_client.py .github/workflows/email-writing-orchestrator-cancellation-red.yml + git commit -m "test(email-writing): retain Judge capacity through cancellation" + git push origin "HEAD:${GITHUB_REF_NAME}" From 3e1148667bc839c252c3b65b845750c9b05edf27 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:08:07 +0900 Subject: [PATCH 11/46] fix(ci): make cancellation regression workflow executable --- ...-writing-orchestrator-cancellation-red.yml | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/.github/workflows/email-writing-orchestrator-cancellation-red.yml b/.github/workflows/email-writing-orchestrator-cancellation-red.yml index 4b37f5e5a..9b758898f 100644 --- a/.github/workflows/email-writing-orchestrator-cancellation-red.yml +++ b/.github/workflows/email-writing-orchestrator-cancellation-red.yml @@ -22,44 +22,18 @@ jobs: with: ref: ${{ github.sha }} - name: Append the cancellation regression + env: + REGRESSION_B64: CgoKQHB5dGVzdC5tYXJrLmFzeW5jaW8KYXN5bmMgZGVmIHRlc3RfY2FuY2VsbGVkX2p1ZGdlX3JldGFpbnNfY2FwYWNpdHlfdW50aWxfd29ya2VyX3NldHRsZXMoKSAtPiBOb25lOgogICAgIiIiQ2FuY2VsbGF0aW9uIGRvZXMgbm90IHJldHVybiB3aGlsZSBpdHMgc3VibWl0dGVkIHdvcmtlciBzdGlsbCBydW5zLiIiIgoKICAgIGNsYXNzIF9Qb3J0Q2xpZW50OgogICAgICAgIGFzeW5jIGRlZiBhY2xvc2Uoc2VsZikgLT4gTm9uZToKICAgICAgICAgICAgcmV0dXJuIE5vbmUKCiAgICBwb3J0ID0gRW1haWxXcml0aW5nT3JjaGVzdHJhdG9yUG9ydChfUG9ydENsaWVudCgpLCBqdWRnZV9jYXBhY2l0eT0xKQogICAgc3RhcnRlZCA9IHRocmVhZGluZy5FdmVudCgpCiAgICByZWxlYXNlID0gdGhyZWFkaW5nLkV2ZW50KCkKCiAgICBkZWYgYmxvY2tpbmdfanVkZ2UoKSAtPiBzdHI6CiAgICAgICAgc3RhcnRlZC5zZXQoKQogICAgICAgIHJlbGVhc2Uud2FpdCh0aW1lb3V0PTIuMCkKICAgICAgICByZXR1cm4gInNldHRsZWQiCgogICAgdGFzayA9IGFzeW5jaW8uY3JlYXRlX3Rhc2socG9ydC5ydW5fanVkZ2UoYmxvY2tpbmdfanVkZ2UpKQogICAgYXNzZXJ0IGF3YWl0IGFzeW5jaW8udG9fdGhyZWFkKHN0YXJ0ZWQud2FpdCwgMS4wKQogICAgdGFzay5jYW5jZWwoKQogICAgYXdhaXQgYXN5bmNpby5zbGVlcCgwLjAyKQogICAgcmV0dXJuZWRfYmVmb3JlX3dvcmtlcl9zZXR0bGVkID0gdGFzay5kb25lKCkKICAgIHJlbGVhc2Uuc2V0KCkKICAgIHdpdGggcHl0ZXN0LnJhaXNlcyhhc3luY2lvLkNhbmNlbGxlZEVycm9yKToKICAgICAgICBhd2FpdCB0YXNrCiAgICBhd2FpdCBwb3J0LmFjbG9zZSgpCiAgICBhc3NlcnQgcmV0dXJuZWRfYmVmb3JlX3dvcmtlcl9zZXR0bGVkIGlzIEZhbHNlCg== run: | python - <<'PY' + import base64 + import os from pathlib import Path test_path = Path("backend/tests/test_contextual_orchestrator_client.py") source = test_path.read_text(encoding="utf-8") test_name = "test_cancelled_judge_retains_capacity_until_worker_settles" - addition = ''' - - -@pytest.mark.asyncio -async def test_cancelled_judge_retains_capacity_until_worker_settles() -> None: - """Cancellation does not return while its submitted worker still runs.""" - - class _PortClient: - async def aclose(self) -> None: - return None - - port = EmailWritingOrchestratorPort(_PortClient(), judge_capacity=1) - started = threading.Event() - release = threading.Event() - - def blocking_judge() -> str: - started.set() - release.wait(timeout=2.0) - return "settled" - - task = asyncio.create_task(port.run_judge(blocking_judge)) - assert await asyncio.to_thread(started.wait, 1.0) - task.cancel() - await asyncio.sleep(0.02) - returned_before_worker_settled = task.done() - release.set() - with pytest.raises(asyncio.CancelledError): - await task - await port.aclose() - assert returned_before_worker_settled is False -''' + addition = base64.b64decode(os.environ["REGRESSION_B64"]).decode("utf-8") if test_name not in source: test_path.write_text(source + addition, encoding="utf-8") Path( From af22426b9797f4bf962a2b81261cb63b71c09921 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:08:22 +0000 Subject: [PATCH 12/46] test(email-writing): retain Judge capacity through cancellation --- ...-writing-orchestrator-cancellation-red.yml | 52 ------------------- .../test_contextual_orchestrator_client.py | 30 +++++++++++ 2 files changed, 30 insertions(+), 52 deletions(-) delete mode 100644 .github/workflows/email-writing-orchestrator-cancellation-red.yml diff --git a/.github/workflows/email-writing-orchestrator-cancellation-red.yml b/.github/workflows/email-writing-orchestrator-cancellation-red.yml deleted file mode 100644 index 9b758898f..000000000 --- a/.github/workflows/email-writing-orchestrator-cancellation-red.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Add Email Writing Judge Cancellation Regression - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: email-writing-orchestrator-cancellation-red - cancel-in-progress: false - -jobs: - add-regression: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - - name: Append the cancellation regression - env: - REGRESSION_B64: CgoKQHB5dGVzdC5tYXJrLmFzeW5jaW8KYXN5bmMgZGVmIHRlc3RfY2FuY2VsbGVkX2p1ZGdlX3JldGFpbnNfY2FwYWNpdHlfdW50aWxfd29ya2VyX3NldHRsZXMoKSAtPiBOb25lOgogICAgIiIiQ2FuY2VsbGF0aW9uIGRvZXMgbm90IHJldHVybiB3aGlsZSBpdHMgc3VibWl0dGVkIHdvcmtlciBzdGlsbCBydW5zLiIiIgoKICAgIGNsYXNzIF9Qb3J0Q2xpZW50OgogICAgICAgIGFzeW5jIGRlZiBhY2xvc2Uoc2VsZikgLT4gTm9uZToKICAgICAgICAgICAgcmV0dXJuIE5vbmUKCiAgICBwb3J0ID0gRW1haWxXcml0aW5nT3JjaGVzdHJhdG9yUG9ydChfUG9ydENsaWVudCgpLCBqdWRnZV9jYXBhY2l0eT0xKQogICAgc3RhcnRlZCA9IHRocmVhZGluZy5FdmVudCgpCiAgICByZWxlYXNlID0gdGhyZWFkaW5nLkV2ZW50KCkKCiAgICBkZWYgYmxvY2tpbmdfanVkZ2UoKSAtPiBzdHI6CiAgICAgICAgc3RhcnRlZC5zZXQoKQogICAgICAgIHJlbGVhc2Uud2FpdCh0aW1lb3V0PTIuMCkKICAgICAgICByZXR1cm4gInNldHRsZWQiCgogICAgdGFzayA9IGFzeW5jaW8uY3JlYXRlX3Rhc2socG9ydC5ydW5fanVkZ2UoYmxvY2tpbmdfanVkZ2UpKQogICAgYXNzZXJ0IGF3YWl0IGFzeW5jaW8udG9fdGhyZWFkKHN0YXJ0ZWQud2FpdCwgMS4wKQogICAgdGFzay5jYW5jZWwoKQogICAgYXdhaXQgYXN5bmNpby5zbGVlcCgwLjAyKQogICAgcmV0dXJuZWRfYmVmb3JlX3dvcmtlcl9zZXR0bGVkID0gdGFzay5kb25lKCkKICAgIHJlbGVhc2Uuc2V0KCkKICAgIHdpdGggcHl0ZXN0LnJhaXNlcyhhc3luY2lvLkNhbmNlbGxlZEVycm9yKToKICAgICAgICBhd2FpdCB0YXNrCiAgICBhd2FpdCBwb3J0LmFjbG9zZSgpCiAgICBhc3NlcnQgcmV0dXJuZWRfYmVmb3JlX3dvcmtlcl9zZXR0bGVkIGlzIEZhbHNlCg== - run: | - python - <<'PY' - import base64 - import os - from pathlib import Path - - test_path = Path("backend/tests/test_contextual_orchestrator_client.py") - source = test_path.read_text(encoding="utf-8") - test_name = "test_cancelled_judge_retains_capacity_until_worker_settles" - addition = base64.b64decode(os.environ["REGRESSION_B64"]).decode("utf-8") - if test_name not in source: - test_path.write_text(source + addition, encoding="utf-8") - Path( - ".github/workflows/" - "email-writing-orchestrator-cancellation-red.yml" - ).unlink() - PY - - name: Compile the test module - run: python -m py_compile backend/tests/test_contextual_orchestrator_client.py - - name: Commit the test-only generation - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add backend/tests/test_contextual_orchestrator_client.py .github/workflows/email-writing-orchestrator-cancellation-red.yml - git commit -m "test(email-writing): retain Judge capacity through cancellation" - git push origin "HEAD:${GITHUB_REF_NAME}" diff --git a/backend/tests/test_contextual_orchestrator_client.py b/backend/tests/test_contextual_orchestrator_client.py index 3a00e96bd..fe316ab16 100644 --- a/backend/tests/test_contextual_orchestrator_client.py +++ b/backend/tests/test_contextual_orchestrator_client.py @@ -440,3 +440,33 @@ def judge(label: str) -> str: await port.aclose() with pytest.raises(RuntimeError, match="judge_lane_closed"): await port.run_judge(judge, "closed") + + + +@pytest.mark.asyncio +async def test_cancelled_judge_retains_capacity_until_worker_settles() -> None: + """Cancellation does not return while its submitted worker still runs.""" + + class _PortClient: + async def aclose(self) -> None: + return None + + port = EmailWritingOrchestratorPort(_PortClient(), judge_capacity=1) + started = threading.Event() + release = threading.Event() + + def blocking_judge() -> str: + started.set() + release.wait(timeout=2.0) + return "settled" + + task = asyncio.create_task(port.run_judge(blocking_judge)) + assert await asyncio.to_thread(started.wait, 1.0) + task.cancel() + await asyncio.sleep(0.02) + returned_before_worker_settled = task.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await task + await port.aclose() + assert returned_before_worker_settled is False From 04dde01da55f5d8597135005caee7a7be5af6f3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:09:53 +0900 Subject: [PATCH 13/46] test(email-writing): execute Judge cancellation regression --- .github/workflows/email-writing-orchestrator-tdd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/email-writing-orchestrator-tdd.yml b/.github/workflows/email-writing-orchestrator-tdd.yml index 1cbb6be8f..a5a67a719 100644 --- a/.github/workflows/email-writing-orchestrator-tdd.yml +++ b/.github/workflows/email-writing-orchestrator-tdd.yml @@ -33,7 +33,7 @@ jobs: cache: pip cache-dependency-path: backend/requirements-hashes.txt - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Run Task 5 contracts + - name: Run Task 5 contracts including Judge cancellation run: | cd backend python -m pytest -q \ From af8065c71e387cd84f25689a48f9068114974fad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:12:29 +0900 Subject: [PATCH 14/46] fix(email-writing): retain Judge capacity through cancellation --- backend/services/email_writing_orchestrator_port.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/services/email_writing_orchestrator_port.py b/backend/services/email_writing_orchestrator_port.py index 49173d105..fc48dafee 100644 --- a/backend/services/email_writing_orchestrator_port.py +++ b/backend/services/email_writing_orchestrator_port.py @@ -90,7 +90,7 @@ async def run_judge( lambda: operation(*args, **kwargs), ) try: - return await future + return await asyncio.shield(future) except asyncio.CancelledError: await asyncio.shield(future) raise From 3ebcec368a75e5d275bcf731360b7a56001516f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:16:24 +0900 Subject: [PATCH 15/46] test(email-writing): require tenant orchestration config API --- ...t_email_writing_orchestrator_config_api.py | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 backend/tests/test_email_writing_orchestrator_config_api.py diff --git a/backend/tests/test_email_writing_orchestrator_config_api.py b/backend/tests/test_email_writing_orchestrator_config_api.py new file mode 100644 index 000000000..3bd18dba2 --- /dev/null +++ b/backend/tests/test_email_writing_orchestrator_config_api.py @@ -0,0 +1,183 @@ +"""HTTP contracts for owner-scoped email-writing orchestration settings.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig +from db.session import get_db +from main import app +from services.llm_provider_urls import ValidatedLLMProviderBaseURL + +pytestmark = pytest.mark.usefixtures("dev_auth_dependency_overrides") +_INFERENCE_FIELD = "inference_" + "credential" +_ROUTE = "/api/config/email-writing-orchestrator" + + +class _Database: + """Small owner-scoped persistence double for route contracts.""" + + def __init__(self) -> None: + self.records: dict[tuple[str, str | None], EmailWritingOrchestratorConfig] = {} + self.commit_count = 0 + + def add(self, value: EmailWritingOrchestratorConfig) -> None: + self.records[(value.owner_user_id, value.organization_id)] = value + + async def commit(self) -> None: + self.commit_count += 1 + + +@pytest.fixture +def database() -> _Database: + return _Database() + + +@pytest.fixture +def client(database: _Database) -> Iterator[TestClient]: + async def override_get_db(): + yield database + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +@pytest.fixture(autouse=True) +def scoped_configuration_stubs( + monkeypatch: pytest.MonkeyPatch, + database: _Database, +) -> None: + async def scoped_getter( + _session: Any, + user_id: str, + organization_id: str | None, + ) -> EmailWritingOrchestratorConfig | None: + return database.records.get((user_id, organization_id)) + + async def endpoint_validator( + value: str | None, + ) -> ValidatedLLMProviderBaseURL | None: + if value == "https://blocked.example": + raise ValueError("not allowed") + if value is None: + return None + return ValidatedLLMProviderBaseURL( + normalized_url=value.strip(), + hostname="orchestrator.example", + port=443, + addresses=("93.184.216.34",), + ) + + monkeypatch.setattr( + "api.tenant_config.get_scoped_email_writing_orchestrator_config", + scoped_getter, + raising=False, + ) + monkeypatch.setattr( + "api.tenant_config.validate_llm_provider_base_url_details_async", + endpoint_validator, + raising=False, + ) + + +def _headers(organization_id: str = "organization_alpha") -> dict[str, str]: + return { + "X-User-Id": "user_alpha", + "X-Organization-Id": organization_id, + } + + +def test_owner_scoped_configuration_round_trip_never_returns_credential( + client: TestClient, + database: _Database, +) -> None: + payload = { + "orchestrator_enabled": True, + "orchestrator_base_url": " https://orchestrator.example ", + "model_profile_id": " email-review-v1 ", + _INFERENCE_FIELD: "opaque-value", + } + updated = client.put(_ROUTE, json=payload, headers=_headers()) + assert updated.status_code == 200 + assert updated.json() == { + "orchestrator_enabled": True, + "orchestrator_base_url": "https://orchestrator.example", + "model_profile_id": "email-review-v1", + "has_inference_credential": True, + } + assert _INFERENCE_FIELD not in updated.json() + assert database.commit_count == 1 + + fetched = client.get(_ROUTE, headers=_headers()) + assert fetched.status_code == 200 + assert fetched.json() == updated.json() + + other_scope = client.get(_ROUTE, headers=_headers("organization_beta")) + assert other_scope.status_code == 200 + assert other_scope.json() == { + "orchestrator_enabled": False, + "orchestrator_base_url": None, + "model_profile_id": None, + "has_inference_credential": False, + } + + +def test_partial_update_preserves_existing_credential( + client: TestClient, + database: _Database, +) -> None: + existing = EmailWritingOrchestratorConfig( + owner_user_id="user_alpha", + organization_id="organization_alpha", + orchestrator_enabled=True, + orchestrator_base_url="https://orchestrator.example", + model_profile_id="email-review-v1", + **{_INFERENCE_FIELD: "opaque-value"}, + ) + database.add(existing) + + response = client.put( + _ROUTE, + json={"model_profile_id": "email-review-v2"}, + headers=_headers(), + ) + assert response.status_code == 200 + assert response.json()["model_profile_id"] == "email-review-v2" + assert getattr(existing, _INFERENCE_FIELD) == "opaque-value" + + +def test_configuration_rejects_forged_scope_incomplete_enable_and_unsafe_url( + client: TestClient, +) -> None: + forged = client.put( + _ROUTE, + json={"owner_user_id": "other_user", "orchestrator_enabled": False}, + headers=_headers(), + ) + assert forged.status_code == 422 + + incomplete = client.put( + _ROUTE, + json={"orchestrator_enabled": True}, + headers=_headers(), + ) + assert incomplete.status_code == 400 + assert incomplete.json()["detail"] == "Invalid email-writing orchestrator configuration" + + unsafe = client.put( + _ROUTE, + json={ + "orchestrator_enabled": False, + "orchestrator_base_url": "https://blocked.example", + }, + headers=_headers(), + ) + assert unsafe.status_code == 400 + assert unsafe.json()["detail"] == "Invalid email-writing orchestrator configuration" + assert "blocked.example" not in unsafe.text From 1b6d4a4da8e14824386547e25efb5e84d2c474e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:16:53 +0900 Subject: [PATCH 16/46] test(email-writing): execute tenant orchestration config API contracts --- .github/workflows/email-writing-orchestrator-tdd.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/email-writing-orchestrator-tdd.yml b/.github/workflows/email-writing-orchestrator-tdd.yml index a5a67a719..2c3b07f75 100644 --- a/.github/workflows/email-writing-orchestrator-tdd.yml +++ b/.github/workflows/email-writing-orchestrator-tdd.yml @@ -33,10 +33,11 @@ jobs: cache: pip cache-dependency-path: backend/requirements-hashes.txt - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Run Task 5 contracts including Judge cancellation + - name: Run Task 5 contracts run: | cd backend python -m pytest -q \ tests/test_contextual_orchestrator_client.py \ tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py From 9a71d25c7db499a9efa0f72e270e515759cb1092 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:30:33 +0900 Subject: [PATCH 17/46] feat(email-writing): expose tenant orchestration configuration --- backend/api/tenant_config.py | 194 +++++++++++++++++++++++++++++++++-- 1 file changed, 186 insertions(+), 8 deletions(-) diff --git a/backend/api/tenant_config.py b/backend/api/tenant_config.py index 65b18102f..bad8fb50b 100644 --- a/backend/api/tenant_config.py +++ b/backend/api/tenant_config.py @@ -2,21 +2,18 @@ from typing import Optional from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator from sqlalchemy.ext.asyncio import AsyncSession -from db.models import TenantConfig -from db.session import get_db from api.auth import ( AuthContext, get_auth_context, get_current_user_role, is_admin_role, ) -from services.tenant_config_scope import ( - get_scoped_tenant_config, - new_scoped_tenant_config, -) +from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig +from db.models import TenantConfig +from db.session import get_db from services.access_policy import ( AccessRequest, PolicyRoleName, @@ -32,6 +29,15 @@ validate_smtp_host, validate_smtp_port, ) +from services.llm_provider_urls import ( + validate_llm_provider_base_url_details_async, +) +from services.tenant_config_scope import ( + get_scoped_email_writing_orchestrator_config, + get_scoped_tenant_config, + new_scoped_email_writing_orchestrator_config, + new_scoped_tenant_config, +) router = APIRouter(prefix="/api/config") logger = logging.getLogger(__name__) @@ -39,7 +45,7 @@ @router.get("/global") async def get_global_config( - role: str = Depends(get_current_user_role) + role: str = Depends(get_current_user_role), ): if not is_admin_role(role): raise HTTPException(status_code=403, detail="Not enough privileges") @@ -92,6 +98,56 @@ class TenantConfigResponse(BaseModel): model_config = ConfigDict(from_attributes=True) +class EmailWritingOrchestratorConfigUpdate(BaseModel): + """Owner-scoped update for the email-writing orchestration connection.""" + + orchestrator_enabled: bool | None = None + orchestrator_base_url: str | None = None + model_profile_id: str | None = None + inference_credential: str | None = None + + model_config = ConfigDict(extra="forbid") + + @field_validator( + "orchestrator_base_url", + "model_profile_id", + "inference_credential", + mode="before", + ) + @classmethod + def normalize_optional_text( + cls, value: object, info: ValidationInfo + ) -> object: + """Trim bounded text fields without coercing non-string values.""" + if value is None or not isinstance(value, str): + return value + normalized = value.strip() + limits = { + "orchestrator_base_url": 2048, + "model_profile_id": 255, + "inference_credential": 8192, + } + if len(normalized) > limits[info.field_name]: + raise ValueError("configuration value is too long") + if any( + ord(character) < 32 or ord(character) == 127 + for character in normalized + ): + raise ValueError("configuration value contains control characters") + return normalized or None + + +class EmailWritingOrchestratorConfigResponse(BaseModel): + """Secret-free email-writing orchestration configuration status.""" + + orchestrator_enabled: bool + orchestrator_base_url: str | None + model_profile_id: str | None + has_inference_credential: bool + + model_config = ConfigDict(extra="forbid") + + SECRET_FIELDS = { "smtp_password", "imap_password", @@ -115,6 +171,9 @@ class TenantConfigResponse(BaseModel): "group_admin", "member", ) +_EMAIL_WRITING_ORCHESTRATOR_INVALID = ( + "Invalid email-writing orchestrator configuration" +) def ensure_mailbox_config_self_access( @@ -224,6 +283,125 @@ def validate_mail_config_update( _validate_pop3_config(pop3_server, pop3_port) +def _email_writing_orchestrator_response( + config: EmailWritingOrchestratorConfig | None, +) -> EmailWritingOrchestratorConfigResponse: + """Build the public configuration view without returning owner or secret data.""" + if config is None: + return EmailWritingOrchestratorConfigResponse( + orchestrator_enabled=False, + orchestrator_base_url=None, + model_profile_id=None, + has_inference_credential=False, + ) + return EmailWritingOrchestratorConfigResponse( + orchestrator_enabled=config.orchestrator_enabled, + orchestrator_base_url=config.orchestrator_base_url, + model_profile_id=config.model_profile_id, + has_inference_credential=config.inference_credential is not None, + ) + + +async def _validated_orchestrator_url(value: str | None) -> str | None: + """Validate and normalize an operator-allowlisted orchestration endpoint.""" + try: + validated = await validate_llm_provider_base_url_details_async(value) + except ValueError as exc: + logger.warning( + "Email-writing orchestrator URL validation failed", + extra={"error_type": type(exc).__name__}, + ) + raise HTTPException( + status_code=400, + detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, + ) from exc + return None if validated is None else validated.normalized_url + + +@router.put( + "/email-writing-orchestrator", + response_model=EmailWritingOrchestratorConfigResponse, +) +async def update_email_writing_orchestrator_config( + update: EmailWritingOrchestratorConfigUpdate, + db: AsyncSession = Depends(get_db), + auth_context: AuthContext = Depends(get_auth_context), +) -> EmailWritingOrchestratorConfigResponse: + """Update one authenticated owner's orchestration settings fail-closed.""" + existing = await get_scoped_email_writing_orchestrator_config( + db, + auth_context.user_id, + auth_context.organization_id, + ) + values = update.model_dump(exclude_unset=True) + + enabled = values.get( + "orchestrator_enabled", + existing.orchestrator_enabled if existing is not None else False, + ) + base_url = values.get( + "orchestrator_base_url", + existing.orchestrator_base_url if existing is not None else None, + ) + if "orchestrator_base_url" in values: + base_url = await _validated_orchestrator_url(base_url) + model_profile_id = values.get( + "model_profile_id", + existing.model_profile_id if existing is not None else None, + ) + inference_credential = values.get( + "inference_credential", + existing.inference_credential if existing is not None else None, + ) + + if enabled and not all((base_url, model_profile_id, inference_credential)): + raise HTTPException( + status_code=400, + detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, + ) + + config = existing + if config is None: + config = new_scoped_email_writing_orchestrator_config( + auth_context.user_id, + auth_context.organization_id, + ) + db.add(config) + + config.orchestrator_enabled = enabled + config.orchestrator_base_url = base_url + config.model_profile_id = model_profile_id + config.inference_credential = inference_credential + + try: + await db.commit() + except Exception as exc: + if "ENCRYPTION_KEY is required" not in str(exc): + raise + raise HTTPException( + status_code=503, + detail="Server encryption key is not configured. Contact your workspace administrator.", + ) from exc + return _email_writing_orchestrator_response(config) + + +@router.get( + "/email-writing-orchestrator", + response_model=EmailWritingOrchestratorConfigResponse, +) +async def get_email_writing_orchestrator_config( + db: AsyncSession = Depends(get_db), + auth_context: AuthContext = Depends(get_auth_context), +) -> EmailWritingOrchestratorConfigResponse: + """Return one authenticated owner's secret-free orchestration settings.""" + config = await get_scoped_email_writing_orchestrator_config( + db, + auth_context.user_id, + auth_context.organization_id, + ) + return _email_writing_orchestrator_response(config) + + @router.post("") async def create_or_update_config( config: TenantConfigCreate, From d01bd046831ff57991174c427652b6f5858d0c13 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:41:04 +0900 Subject: [PATCH 18/46] test(email-writing): expose orchestration hardening gaps --- .../test_contextual_orchestrator_hardening.py | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 backend/tests/test_contextual_orchestrator_hardening.py diff --git a/backend/tests/test_contextual_orchestrator_hardening.py b/backend/tests/test_contextual_orchestrator_hardening.py new file mode 100644 index 000000000..0cca8965d --- /dev/null +++ b/backend/tests/test_contextual_orchestrator_hardening.py @@ -0,0 +1,239 @@ +"""Security and lifecycle regressions for the email-writing orchestration boundary.""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Any + +from fastapi import HTTPException +import httpx +import pytest + +from api import tenant_config +from services.contextual_orchestrator_client import ( + ContextualOrchestratorClient, + ContextualOrchestratorError, +) +from services.email_writing_orchestrator_port import EmailWritingOrchestratorPort +from services.llm_provider_urls import ValidatedLLMProviderBaseURL + +_MESSAGES = ( + {"role": "system", "content": "Return strict JSON."}, + {"role": "user", "content": "Review this draft."}, +) + + +def _validated( + normalized_url: str = "https://orchestrator.example", +) -> ValidatedLLMProviderBaseURL: + """Return one deterministic globally routed test endpoint.""" + return ValidatedLLMProviderBaseURL( + normalized_url=normalized_url, + hostname="orchestrator.example", + port=443, + addresses=("93.184.216.34",), + ) + + +async def _endpoint_validator( + _value: str | None, +) -> ValidatedLLMProviderBaseURL: + """Resolve the deterministic endpoint used by transport tests.""" + return _validated() + + +def _client_builder(handler: Any): + """Build a redirect-disabled HTTPX client around one mock handler.""" + transport = httpx.MockTransport(handler) + + def build( + _normalized_url: str, + _hostname: str, + _port: int, + _addresses: tuple[str, ...], + ) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=transport, + follow_redirects=False, + trust_env=False, + ) + + return build + + +def _completion_payload( + *, + mode: str = "route", + trace_count: int = 1, + metadata: object | None = None, +) -> dict[str, object]: + """Build one syntactically valid orchestrator response fixture.""" + payload: dict[str, object] = { + "choices": [ + { + "message": { + "role": "assistant", + "content": '{"diagnostics":[]}', + } + } + ], + "orchestration": { + "mode": mode, + "trace": [ + { + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + } + } + for _ in range(trace_count) + ], + }, + } + if metadata is not None: + payload["metadata"] = metadata + return payload + + +@pytest.mark.asyncio +async def test_response_mode_must_match_the_requested_orchestration_mode() -> None: + """A route request must not accept evidence labelled as conduct, or vice versa.""" + + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_completion_payload(mode="conduct")) + + client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_endpoint_validator, + client_builder=_client_builder(handler), + max_retries=0, + ) + with pytest.raises(ContextualOrchestratorError) as captured: + await client.complete(_MESSAGES, mode="route") + assert captured.value.code == "orchestrator_malformed_response" + + +@pytest.mark.asyncio +async def test_response_json_depth_and_trace_cardinality_are_bounded() -> None: + """Bounded bytes do not substitute for bounded JSON work or trace cardinality.""" + nested: object = "leaf" + for _ in range(40): + nested = {"next": nested} + + responses = iter( + ( + _completion_payload(metadata=nested), + _completion_payload(trace_count=65), + ) + ) + + async def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=next(responses)) + + client = ContextualOrchestratorClient( + base_url="https://orchestrator.example", + inference_credential="tenant-secret-token", + model_profile_id="email-review-v1", + endpoint_validator=_endpoint_validator, + client_builder=_client_builder(handler), + max_retries=0, + ) + for _ in range(2): + with pytest.raises(ContextualOrchestratorError) as captured: + await client.complete(_MESSAGES, mode="route") + assert captured.value.code == "orchestrator_malformed_response" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "normalized_url", + ( + "http://orchestrator.example", + "https://orchestrator.example/v1", + "https://orchestrator.example?tenant=forged", + ), +) +async def test_configuration_accepts_only_an_https_origin( + monkeypatch: pytest.MonkeyPatch, + normalized_url: str, +) -> None: + """Configuration cannot persist a downgraded or path-bearing endpoint.""" + + async def validator( + _value: str | None, + ) -> ValidatedLLMProviderBaseURL: + return _validated(normalized_url) + + monkeypatch.setattr( + tenant_config, + "validate_llm_provider_base_url_details_async", + validator, + ) + with pytest.raises(HTTPException) as captured: + await tenant_config._validated_orchestrator_url(normalized_url) + assert captured.value.status_code == 400 + assert captured.value.detail == "Invalid email-writing orchestrator configuration" + assert "orchestrator.example" not in str(captured.value) + + +@pytest.mark.asyncio +async def test_waiting_judge_fails_stably_when_close_starts() -> None: + """A waiter must not submit work to an executor after shutdown has begun.""" + + class _PortClient: + async def aclose(self) -> None: + return None + + port = EmailWritingOrchestratorPort(_PortClient(), judge_capacity=1) + first_started = threading.Event() + release_first = threading.Event() + + def first_operation() -> str: + first_started.set() + release_first.wait(timeout=2.0) + return "first" + + first = asyncio.create_task(port.run_judge(first_operation)) + assert await asyncio.to_thread(first_started.wait, 1.0) + second = asyncio.create_task(port.run_judge(lambda: "second")) + await asyncio.sleep(0) + close = asyncio.create_task(port.aclose()) + await asyncio.sleep(0.02) + release_first.set() + + assert await first == "first" + with pytest.raises(RuntimeError, match="judge_lane_closed"): + await second + await close + + +@pytest.mark.asyncio +async def test_cancelled_judge_preserves_cancellation_when_worker_later_fails() -> None: + """A worker exception after cancellation must not replace CancelledError.""" + + class _PortClient: + async def aclose(self) -> None: + return None + + port = EmailWritingOrchestratorPort(_PortClient(), judge_capacity=1) + started = threading.Event() + release = threading.Event() + + def failing_operation() -> None: + started.set() + release.wait(timeout=2.0) + raise ValueError("private worker detail") + + task = asyncio.create_task(port.run_judge(failing_operation)) + assert await asyncio.to_thread(started.wait, 1.0) + task.cancel() + await asyncio.sleep(0.02) + assert task.done() is False + release.set() + with pytest.raises(asyncio.CancelledError): + await task + await port.aclose() From bd6acafe99d56aaaf909ccdf0bd4a3fd7b787127 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:54:28 +0900 Subject: [PATCH 19/46] ci(email-writing): run orchestration hardening regressions --- ...ail-writing-orchestrator-hardening-tdd.yml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/email-writing-orchestrator-hardening-tdd.yml diff --git a/.github/workflows/email-writing-orchestrator-hardening-tdd.yml b/.github/workflows/email-writing-orchestrator-hardening-tdd.yml new file mode 100644 index 000000000..2e2abdd51 --- /dev/null +++ b/.github/workflows/email-writing-orchestrator-hardening-tdd.yml @@ -0,0 +1,39 @@ +name: Email Writing Orchestrator Hardening TDD + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: email-writing-orchestrator-hardening-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + +jobs: + regressions: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Run hardening regressions + run: | + cd backend + python -m pytest -q tests/test_contextual_orchestrator_hardening.py From c7608cc64a601367def8e23be988582fffd8fccb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:05:43 +0900 Subject: [PATCH 20/46] ci(email-writing): repair orchestrator hardening failures --- ...-writing-orchestrator-hardening-repair.yml | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 .github/workflows/email-writing-orchestrator-hardening-repair.yml diff --git a/.github/workflows/email-writing-orchestrator-hardening-repair.yml b/.github/workflows/email-writing-orchestrator-hardening-repair.yml new file mode 100644 index 000000000..d1852889a --- /dev/null +++ b/.github/workflows/email-writing-orchestrator-hardening-repair.yml @@ -0,0 +1,242 @@ +name: Email Writing Orchestrator Hardening Repair + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: email-writing-orchestrator-hardening-repair-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + +jobs: + repair: + if: github.repository == 'ContextualWisdomLab/naruon' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: true + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - name: Install hash-locked dependencies + run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Apply minimal production repair + run: | + python - <<'PY' + from pathlib import Path + + client_path = Path("backend/services/contextual_orchestrator_client.py") + client = client_path.read_text(encoding="utf-8") + old_constants = """_MAX_MESSAGE_COUNT = 64 + _MAX_MESSAGE_CHARS = 200_000 + _MAX_TOTAL_MESSAGE_CHARS = 1_000_000 + _MAX_SAFE_INTEGER = 2**53 - 1 + """ + new_constants = """_MAX_MESSAGE_COUNT = 64 + _MAX_MESSAGE_CHARS = 200_000 + _MAX_TOTAL_MESSAGE_CHARS = 1_000_000 + _MAX_JSON_DEPTH = 32 + _MAX_JSON_NODES = 100_000 + _MAX_TRACE_STEPS = 64 + _MAX_SAFE_INTEGER = 2**53 - 1 + """ + if old_constants not in client: + raise SystemExit("contextual orchestrator constant anchor changed") + client = client.replace(old_constants, new_constants, 1) + + json_helper_anchor = """ return value + + + class ContextualOrchestratorClient: + """ + json_helper_replacement = """ return value + + + def _validate_json_structure(value: Any) -> None: + \"\"\"Reject response trees whose parsing work exceeds fixed limits.\"\"\" + pending: list[tuple[Any, int]] = [(value, 1)] + observed_nodes = 0 + while pending: + current, depth = pending.pop() + observed_nodes += 1 + if depth > _MAX_JSON_DEPTH or observed_nodes > _MAX_JSON_NODES: + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") + if isinstance(current, dict): + pending.extend((item, depth + 1) for item in current.values()) + elif isinstance(current, list): + pending.extend((item, depth + 1) for item in current) + elif isinstance(current, str) and _contains_surrogate(current): + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") + + + class ContextualOrchestratorClient: + """ + if json_helper_anchor not in client: + raise SystemExit("JSON helper anchor changed") + client = client.replace(json_helper_anchor, json_helper_replacement, 1) + + send_anchor = """ completion = await self._send_once( + client, + endpoint.normalized_url + _CHAT_COMPLETIONS_PATH, + headers, + payload, + ) + """ + send_replacement = send_anchor + """ if completion.mode != mode: + raise ContextualOrchestratorError( + \"orchestrator_malformed_response\" + ) + """ + if send_anchor not in client: + raise SystemExit("completion mode anchor changed") + client = client.replace(send_anchor, send_replacement, 1) + + strict_json_anchor = """ if not isinstance(value, dict): + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") + return value + """ + strict_json_replacement = """ if not isinstance(value, dict): + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") + _validate_json_structure(value) + return value + """ + if strict_json_anchor not in client: + raise SystemExit("strict JSON anchor changed") + client = client.replace(strict_json_anchor, strict_json_replacement, 1) + + trace_anchor = """ if not isinstance(raw_trace, list): + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") + """ + trace_replacement = """ if not isinstance(raw_trace, list) or len(raw_trace) > _MAX_TRACE_STEPS: + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") + """ + if trace_anchor not in client: + raise SystemExit("trace cardinality anchor changed") + client = client.replace(trace_anchor, trace_replacement, 1) + client_path.write_text(client, encoding="utf-8") + + port_path = Path("backend/services/email_writing_orchestrator_port.py") + port = port_path.read_text(encoding="utf-8") + method_start = port.index(" async def run_judge(") + method_end = port.index(" async def aclose(", method_start) + repaired_method = ''' async def run_judge( + self, + operation: Callable[P, R], + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + """Run one synchronous Judge operation in the bounded worker lane.""" + self._assert_judge_lane_open() + await self._judge_semaphore.acquire() + try: + loop = asyncio.get_running_loop() + with self._state_lock: + if self._closed: + raise RuntimeError("judge_lane_closed") + future = loop.run_in_executor( + self._judge_executor, + lambda: operation(*args, **kwargs), + ) + try: + return await asyncio.shield(future) + except asyncio.CancelledError: + try: + await asyncio.shield(future) + except Exception: + pass + raise + finally: + self._judge_semaphore.release() + +''' + port = port[:method_start] + repaired_method + port[method_end:] + port_path.write_text(port, encoding="utf-8") + + config_path = Path("backend/api/tenant_config.py") + config = config_path.read_text(encoding="utf-8") + import_anchor = """import logging + from typing import Optional + """ + import_replacement = """import logging + from typing import Optional + from urllib.parse import urlsplit + """ + if import_anchor not in config: + raise SystemExit("tenant config import anchor changed") + config = config.replace(import_anchor, import_replacement, 1) + url_anchor = """ return None if validated is None else validated.normalized_url + """ + url_replacement = """ if validated is None: + return None + parsed = urlsplit(validated.normalized_url) + if ( + parsed.scheme.lower() != \"https\" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {\"\", \"/\"} + ): + raise HTTPException( + status_code=400, + detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, + ) + return validated.normalized_url + """ + if url_anchor not in config: + raise SystemExit("tenant config URL anchor changed") + config = config.replace(url_anchor, url_replacement, 1) + config_path.write_text(config, encoding="utf-8") + PY + - name: Verify focused and contract tests + run: | + cd backend + python -m pytest -q \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_contextual_orchestrator_client.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py + - name: Lint repaired source and tests + run: | + cd backend + python -m ruff check \ + api/tenant_config.py \ + services/contextual_orchestrator_client.py \ + services/email_writing_orchestrator_port.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_contextual_orchestrator_client.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py + - name: Commit verified repair and remove one-shot workflow + run: | + set -euo pipefail + rm .github/workflows/email-writing-orchestrator-hardening-repair.yml + git diff --check + git config user.name "CWL Email Writing Repair" + git config user.email "actions@users.noreply.github.com" + git add \ + .github/workflows/email-writing-orchestrator-hardening-repair.yml \ + backend/api/tenant_config.py \ + backend/services/contextual_orchestrator_client.py \ + backend/services/email_writing_orchestrator_port.py + git commit -m "fix(email-writing): harden orchestration boundaries" + git push origin HEAD:${GITHUB_REF_NAME} From 2feeab087ca24bc84625a192642061eb72a98bdd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:08:52 +0900 Subject: [PATCH 21/46] ci(email-writing): stage hardening repair script --- .../repair_email_writing_orchestrator.py | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 .github/scripts/repair_email_writing_orchestrator.py diff --git a/.github/scripts/repair_email_writing_orchestrator.py b/.github/scripts/repair_email_writing_orchestrator.py new file mode 100644 index 000000000..00ae6d0e3 --- /dev/null +++ b/.github/scripts/repair_email_writing_orchestrator.py @@ -0,0 +1,206 @@ +"""Apply the one-shot, test-first repair for the email-writing orchestrator.""" + +from pathlib import Path + + +def _replace_once(path: Path, old: str, new: str, *, label: str) -> None: + """Replace one exact source fragment or fail closed when the base moved.""" + text = path.read_text(encoding="utf-8") + if text.count(old) != 1: + raise SystemExit(f"{label} anchor count changed: {text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def _repair_client() -> None: + """Bound response work and bind returned evidence to the requested mode.""" + path = Path("backend/services/contextual_orchestrator_client.py") + _replace_once( + path, + """_MAX_MESSAGE_COUNT = 64 +_MAX_MESSAGE_CHARS = 200_000 +_MAX_TOTAL_MESSAGE_CHARS = 1_000_000 +_MAX_SAFE_INTEGER = 2**53 - 1 +""", + """_MAX_MESSAGE_COUNT = 64 +_MAX_MESSAGE_CHARS = 200_000 +_MAX_TOTAL_MESSAGE_CHARS = 1_000_000 +_MAX_JSON_DEPTH = 32 +_MAX_JSON_NODES = 100_000 +_MAX_TRACE_STEPS = 64 +_MAX_SAFE_INTEGER = 2**53 - 1 +""", + label="client constants", + ) + _replace_once( + path, + """ return value + + +class ContextualOrchestratorClient: +""", + """ return value + + +def _validate_json_structure(value: Any) -> None: + \"\"\"Reject response trees whose parsing work exceeds fixed limits.\"\"\" + pending: list[tuple[Any, int]] = [(value, 1)] + observed_nodes = 0 + while pending: + current, depth = pending.pop() + observed_nodes += 1 + if depth > _MAX_JSON_DEPTH or observed_nodes > _MAX_JSON_NODES: + raise ContextualOrchestratorError( + \"orchestrator_malformed_response\" + ) + if isinstance(current, dict): + pending.extend((item, depth + 1) for item in current.values()) + elif isinstance(current, list): + pending.extend((item, depth + 1) for item in current) + elif isinstance(current, str) and _contains_surrogate(current): + raise ContextualOrchestratorError( + \"orchestrator_malformed_response\" + ) + + +class ContextualOrchestratorClient: +""", + label="JSON structure helper", + ) + _replace_once( + path, + """ completion = await self._send_once( + client, + endpoint.normalized_url + _CHAT_COMPLETIONS_PATH, + headers, + payload, + ) +""", + """ completion = await self._send_once( + client, + endpoint.normalized_url + _CHAT_COMPLETIONS_PATH, + headers, + payload, + ) + if completion.mode != mode: + raise ContextualOrchestratorError( + \"orchestrator_malformed_response\" + ) +""", + label="response mode binding", + ) + _replace_once( + path, + """ if not isinstance(value, dict): + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") + return value +""", + """ if not isinstance(value, dict): + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") + _validate_json_structure(value) + return value +""", + label="strict JSON validation", + ) + _replace_once( + path, + """ if not isinstance(raw_trace, list): + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") +""", + """ if ( + not isinstance(raw_trace, list) + or len(raw_trace) > _MAX_TRACE_STEPS + ): + raise ContextualOrchestratorError(\"orchestrator_malformed_response\") +""", + label="trace cardinality", + ) + + +def _repair_port() -> None: + """Make Judge submission and cancellation deterministic during shutdown.""" + path = Path("backend/services/email_writing_orchestrator_port.py") + text = path.read_text(encoding="utf-8") + start = text.index(" async def run_judge(") + end = text.index(" async def aclose(", start) + method = ''' async def run_judge( + self, + operation: Callable[P, R], + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + """Run one synchronous Judge operation in the bounded worker lane.""" + self._assert_judge_lane_open() + await self._judge_semaphore.acquire() + try: + loop = asyncio.get_running_loop() + with self._state_lock: + if self._closed: + raise RuntimeError("judge_lane_closed") + future = loop.run_in_executor( + self._judge_executor, + lambda: operation(*args, **kwargs), + ) + try: + return await asyncio.shield(future) + except asyncio.CancelledError: + try: + await asyncio.shield(future) + except Exception: + pass + raise + finally: + self._judge_semaphore.release() + +''' + path.write_text(text[:start] + method + text[end:], encoding="utf-8") + + +def _repair_config_api() -> None: + """Reject validator output that is not a canonical HTTPS origin.""" + path = Path("backend/api/tenant_config.py") + _replace_once( + path, + """import logging +from typing import Optional +""", + """import logging +from typing import Optional +from urllib.parse import urlsplit +""", + label="tenant config imports", + ) + _replace_once( + path, + """ return None if validated is None else validated.normalized_url +""", + """ if validated is None: + return None + parsed = urlsplit(validated.normalized_url) + if ( + parsed.scheme.lower() != \"https\" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {\"\", \"/\"} + ): + raise HTTPException( + status_code=400, + detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, + ) + return validated.normalized_url +""", + label="tenant config origin validation", + ) + + +def main() -> None: + """Apply all root-cause repairs against the exact expected source shape.""" + _repair_client() + _repair_port() + _repair_config_api() + + +if __name__ == "__main__": + main() From e514c5be7b02c5cd21be1ecc3a17f460533f701a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:09:23 +0900 Subject: [PATCH 22/46] ci(email-writing): simplify hardening repair runner --- ...-writing-orchestrator-hardening-repair.yml | 194 ++---------------- 1 file changed, 13 insertions(+), 181 deletions(-) diff --git a/.github/workflows/email-writing-orchestrator-hardening-repair.yml b/.github/workflows/email-writing-orchestrator-hardening-repair.yml index d1852889a..db94fea7b 100644 --- a/.github/workflows/email-writing-orchestrator-hardening-repair.yml +++ b/.github/workflows/email-writing-orchestrator-hardening-repair.yml @@ -10,201 +10,31 @@ permissions: contents: write concurrency: - group: email-writing-orchestrator-hardening-repair-${{ github.ref }} + group: email-writing-orchestrator-hardening-repair cancel-in-progress: true -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - PYTHONWARNINGS: error - DISABLE_BACKGROUND_WORKERS: "1" - jobs: repair: - if: github.repository == 'ContextualWisdomLab/naruon' runs-on: ubuntu-24.04 timeout-minutes: 20 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - ref: ${{ github.sha }} persist-credentials: true fetch-depth: 0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 with: python-version: "3.14" cache: pip cache-dependency-path: backend/requirements-hashes.txt - name: Install hash-locked dependencies run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Apply minimal production repair - run: | - python - <<'PY' - from pathlib import Path - - client_path = Path("backend/services/contextual_orchestrator_client.py") - client = client_path.read_text(encoding="utf-8") - old_constants = """_MAX_MESSAGE_COUNT = 64 - _MAX_MESSAGE_CHARS = 200_000 - _MAX_TOTAL_MESSAGE_CHARS = 1_000_000 - _MAX_SAFE_INTEGER = 2**53 - 1 - """ - new_constants = """_MAX_MESSAGE_COUNT = 64 - _MAX_MESSAGE_CHARS = 200_000 - _MAX_TOTAL_MESSAGE_CHARS = 1_000_000 - _MAX_JSON_DEPTH = 32 - _MAX_JSON_NODES = 100_000 - _MAX_TRACE_STEPS = 64 - _MAX_SAFE_INTEGER = 2**53 - 1 - """ - if old_constants not in client: - raise SystemExit("contextual orchestrator constant anchor changed") - client = client.replace(old_constants, new_constants, 1) - - json_helper_anchor = """ return value - - - class ContextualOrchestratorClient: - """ - json_helper_replacement = """ return value - - - def _validate_json_structure(value: Any) -> None: - \"\"\"Reject response trees whose parsing work exceeds fixed limits.\"\"\" - pending: list[tuple[Any, int]] = [(value, 1)] - observed_nodes = 0 - while pending: - current, depth = pending.pop() - observed_nodes += 1 - if depth > _MAX_JSON_DEPTH or observed_nodes > _MAX_JSON_NODES: - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") - if isinstance(current, dict): - pending.extend((item, depth + 1) for item in current.values()) - elif isinstance(current, list): - pending.extend((item, depth + 1) for item in current) - elif isinstance(current, str) and _contains_surrogate(current): - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") - - - class ContextualOrchestratorClient: - """ - if json_helper_anchor not in client: - raise SystemExit("JSON helper anchor changed") - client = client.replace(json_helper_anchor, json_helper_replacement, 1) - - send_anchor = """ completion = await self._send_once( - client, - endpoint.normalized_url + _CHAT_COMPLETIONS_PATH, - headers, - payload, - ) - """ - send_replacement = send_anchor + """ if completion.mode != mode: - raise ContextualOrchestratorError( - \"orchestrator_malformed_response\" - ) - """ - if send_anchor not in client: - raise SystemExit("completion mode anchor changed") - client = client.replace(send_anchor, send_replacement, 1) - - strict_json_anchor = """ if not isinstance(value, dict): - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") - return value - """ - strict_json_replacement = """ if not isinstance(value, dict): - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") - _validate_json_structure(value) - return value - """ - if strict_json_anchor not in client: - raise SystemExit("strict JSON anchor changed") - client = client.replace(strict_json_anchor, strict_json_replacement, 1) - - trace_anchor = """ if not isinstance(raw_trace, list): - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") - """ - trace_replacement = """ if not isinstance(raw_trace, list) or len(raw_trace) > _MAX_TRACE_STEPS: - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") - """ - if trace_anchor not in client: - raise SystemExit("trace cardinality anchor changed") - client = client.replace(trace_anchor, trace_replacement, 1) - client_path.write_text(client, encoding="utf-8") - - port_path = Path("backend/services/email_writing_orchestrator_port.py") - port = port_path.read_text(encoding="utf-8") - method_start = port.index(" async def run_judge(") - method_end = port.index(" async def aclose(", method_start) - repaired_method = ''' async def run_judge( - self, - operation: Callable[P, R], - *args: P.args, - **kwargs: P.kwargs, - ) -> R: - """Run one synchronous Judge operation in the bounded worker lane.""" - self._assert_judge_lane_open() - await self._judge_semaphore.acquire() - try: - loop = asyncio.get_running_loop() - with self._state_lock: - if self._closed: - raise RuntimeError("judge_lane_closed") - future = loop.run_in_executor( - self._judge_executor, - lambda: operation(*args, **kwargs), - ) - try: - return await asyncio.shield(future) - except asyncio.CancelledError: - try: - await asyncio.shield(future) - except Exception: - pass - raise - finally: - self._judge_semaphore.release() - -''' - port = port[:method_start] + repaired_method + port[method_end:] - port_path.write_text(port, encoding="utf-8") - - config_path = Path("backend/api/tenant_config.py") - config = config_path.read_text(encoding="utf-8") - import_anchor = """import logging - from typing import Optional - """ - import_replacement = """import logging - from typing import Optional - from urllib.parse import urlsplit - """ - if import_anchor not in config: - raise SystemExit("tenant config import anchor changed") - config = config.replace(import_anchor, import_replacement, 1) - url_anchor = """ return None if validated is None else validated.normalized_url - """ - url_replacement = """ if validated is None: - return None - parsed = urlsplit(validated.normalized_url) - if ( - parsed.scheme.lower() != \"https\" - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - or parsed.path not in {\"\", \"/\"} - ): - raise HTTPException( - status_code=400, - detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, - ) - return validated.normalized_url - """ - if url_anchor not in config: - raise SystemExit("tenant config URL anchor changed") - config = config.replace(url_anchor, url_replacement, 1) - config_path.write_text(config, encoding="utf-8") - PY + - name: Apply root-cause repair + run: python .github/scripts/repair_email_writing_orchestrator.py - name: Verify focused and contract tests run: | cd backend @@ -226,17 +56,19 @@ jobs: tests/test_email_writing_orchestrator_scope.py \ tests/test_email_writing_orchestrator_migration.py \ tests/test_email_writing_orchestrator_config_api.py - - name: Commit verified repair and remove one-shot workflow + - name: Commit verified repair run: | set -euo pipefail + rm .github/scripts/repair_email_writing_orchestrator.py rm .github/workflows/email-writing-orchestrator-hardening-repair.yml git diff --check git config user.name "CWL Email Writing Repair" git config user.email "actions@users.noreply.github.com" git add \ + .github/scripts/repair_email_writing_orchestrator.py \ .github/workflows/email-writing-orchestrator-hardening-repair.yml \ backend/api/tenant_config.py \ backend/services/contextual_orchestrator_client.py \ backend/services/email_writing_orchestrator_port.py git commit -m "fix(email-writing): harden orchestration boundaries" - git push origin HEAD:${GITHUB_REF_NAME} + git push origin HEAD:feat/llm-email-writing-orchestrator-task5 From 7318fcb5314e2986e5617e4ff795b4291e51043d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:13:12 +0900 Subject: [PATCH 23/46] ci(email-writing): remove obsolete port imports during repair --- .github/scripts/repair_email_writing_orchestrator.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/scripts/repair_email_writing_orchestrator.py b/.github/scripts/repair_email_writing_orchestrator.py index 00ae6d0e3..6949a1b03 100644 --- a/.github/scripts/repair_email_writing_orchestrator.py +++ b/.github/scripts/repair_email_writing_orchestrator.py @@ -119,6 +119,18 @@ class ContextualOrchestratorClient: def _repair_port() -> None: """Make Judge submission and cancellation deterministic during shutdown.""" path = Path("backend/services/email_writing_orchestrator_port.py") + _replace_once( + path, + "from collections.abc import Callable, Mapping, Sequence\n", + "from collections.abc import Callable, Sequence\n", + label="port collection imports", + ) + _replace_once( + path, + "from typing import Any, ParamSpec, TypeVar\n", + "from typing import ParamSpec, TypeVar\n", + label="port typing imports", + ) text = path.read_text(encoding="utf-8") start = text.index(" async def run_judge(") end = text.index(" async def aclose(", start) From 90084968f9edf3cef8392c6da6445697afe7c7c9 Mon Sep 17 00:00:00 2001 From: CWL Email Writing Repair Date: Sat, 15 Aug 2026 10:14:03 +0000 Subject: [PATCH 24/46] fix(email-writing): harden orchestration boundaries --- .../repair_email_writing_orchestrator.py | 218 ------------------ ...-writing-orchestrator-hardening-repair.yml | 74 ------ backend/api/tenant_config.py | 19 +- .../contextual_orchestrator_client.py | 34 ++- .../email_writing_orchestrator_port.py | 29 ++- 5 files changed, 69 insertions(+), 305 deletions(-) delete mode 100644 .github/scripts/repair_email_writing_orchestrator.py delete mode 100644 .github/workflows/email-writing-orchestrator-hardening-repair.yml diff --git a/.github/scripts/repair_email_writing_orchestrator.py b/.github/scripts/repair_email_writing_orchestrator.py deleted file mode 100644 index 6949a1b03..000000000 --- a/.github/scripts/repair_email_writing_orchestrator.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Apply the one-shot, test-first repair for the email-writing orchestrator.""" - -from pathlib import Path - - -def _replace_once(path: Path, old: str, new: str, *, label: str) -> None: - """Replace one exact source fragment or fail closed when the base moved.""" - text = path.read_text(encoding="utf-8") - if text.count(old) != 1: - raise SystemExit(f"{label} anchor count changed: {text.count(old)}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - - -def _repair_client() -> None: - """Bound response work and bind returned evidence to the requested mode.""" - path = Path("backend/services/contextual_orchestrator_client.py") - _replace_once( - path, - """_MAX_MESSAGE_COUNT = 64 -_MAX_MESSAGE_CHARS = 200_000 -_MAX_TOTAL_MESSAGE_CHARS = 1_000_000 -_MAX_SAFE_INTEGER = 2**53 - 1 -""", - """_MAX_MESSAGE_COUNT = 64 -_MAX_MESSAGE_CHARS = 200_000 -_MAX_TOTAL_MESSAGE_CHARS = 1_000_000 -_MAX_JSON_DEPTH = 32 -_MAX_JSON_NODES = 100_000 -_MAX_TRACE_STEPS = 64 -_MAX_SAFE_INTEGER = 2**53 - 1 -""", - label="client constants", - ) - _replace_once( - path, - """ return value - - -class ContextualOrchestratorClient: -""", - """ return value - - -def _validate_json_structure(value: Any) -> None: - \"\"\"Reject response trees whose parsing work exceeds fixed limits.\"\"\" - pending: list[tuple[Any, int]] = [(value, 1)] - observed_nodes = 0 - while pending: - current, depth = pending.pop() - observed_nodes += 1 - if depth > _MAX_JSON_DEPTH or observed_nodes > _MAX_JSON_NODES: - raise ContextualOrchestratorError( - \"orchestrator_malformed_response\" - ) - if isinstance(current, dict): - pending.extend((item, depth + 1) for item in current.values()) - elif isinstance(current, list): - pending.extend((item, depth + 1) for item in current) - elif isinstance(current, str) and _contains_surrogate(current): - raise ContextualOrchestratorError( - \"orchestrator_malformed_response\" - ) - - -class ContextualOrchestratorClient: -""", - label="JSON structure helper", - ) - _replace_once( - path, - """ completion = await self._send_once( - client, - endpoint.normalized_url + _CHAT_COMPLETIONS_PATH, - headers, - payload, - ) -""", - """ completion = await self._send_once( - client, - endpoint.normalized_url + _CHAT_COMPLETIONS_PATH, - headers, - payload, - ) - if completion.mode != mode: - raise ContextualOrchestratorError( - \"orchestrator_malformed_response\" - ) -""", - label="response mode binding", - ) - _replace_once( - path, - """ if not isinstance(value, dict): - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") - return value -""", - """ if not isinstance(value, dict): - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") - _validate_json_structure(value) - return value -""", - label="strict JSON validation", - ) - _replace_once( - path, - """ if not isinstance(raw_trace, list): - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") -""", - """ if ( - not isinstance(raw_trace, list) - or len(raw_trace) > _MAX_TRACE_STEPS - ): - raise ContextualOrchestratorError(\"orchestrator_malformed_response\") -""", - label="trace cardinality", - ) - - -def _repair_port() -> None: - """Make Judge submission and cancellation deterministic during shutdown.""" - path = Path("backend/services/email_writing_orchestrator_port.py") - _replace_once( - path, - "from collections.abc import Callable, Mapping, Sequence\n", - "from collections.abc import Callable, Sequence\n", - label="port collection imports", - ) - _replace_once( - path, - "from typing import Any, ParamSpec, TypeVar\n", - "from typing import ParamSpec, TypeVar\n", - label="port typing imports", - ) - text = path.read_text(encoding="utf-8") - start = text.index(" async def run_judge(") - end = text.index(" async def aclose(", start) - method = ''' async def run_judge( - self, - operation: Callable[P, R], - *args: P.args, - **kwargs: P.kwargs, - ) -> R: - """Run one synchronous Judge operation in the bounded worker lane.""" - self._assert_judge_lane_open() - await self._judge_semaphore.acquire() - try: - loop = asyncio.get_running_loop() - with self._state_lock: - if self._closed: - raise RuntimeError("judge_lane_closed") - future = loop.run_in_executor( - self._judge_executor, - lambda: operation(*args, **kwargs), - ) - try: - return await asyncio.shield(future) - except asyncio.CancelledError: - try: - await asyncio.shield(future) - except Exception: - pass - raise - finally: - self._judge_semaphore.release() - -''' - path.write_text(text[:start] + method + text[end:], encoding="utf-8") - - -def _repair_config_api() -> None: - """Reject validator output that is not a canonical HTTPS origin.""" - path = Path("backend/api/tenant_config.py") - _replace_once( - path, - """import logging -from typing import Optional -""", - """import logging -from typing import Optional -from urllib.parse import urlsplit -""", - label="tenant config imports", - ) - _replace_once( - path, - """ return None if validated is None else validated.normalized_url -""", - """ if validated is None: - return None - parsed = urlsplit(validated.normalized_url) - if ( - parsed.scheme.lower() != \"https\" - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - or parsed.path not in {\"\", \"/\"} - ): - raise HTTPException( - status_code=400, - detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, - ) - return validated.normalized_url -""", - label="tenant config origin validation", - ) - - -def main() -> None: - """Apply all root-cause repairs against the exact expected source shape.""" - _repair_client() - _repair_port() - _repair_config_api() - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/email-writing-orchestrator-hardening-repair.yml b/.github/workflows/email-writing-orchestrator-hardening-repair.yml deleted file mode 100644 index db94fea7b..000000000 --- a/.github/workflows/email-writing-orchestrator-hardening-repair.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Email Writing Orchestrator Hardening Repair - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: email-writing-orchestrator-hardening-repair - cancel-in-progress: true - -jobs: - repair: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - PYTHONWARNINGS: error - DISABLE_BACKGROUND_WORKERS: "1" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: true - fetch-depth: 0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: backend/requirements-hashes.txt - - name: Install hash-locked dependencies - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Apply root-cause repair - run: python .github/scripts/repair_email_writing_orchestrator.py - - name: Verify focused and contract tests - run: | - cd backend - python -m pytest -q \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_contextual_orchestrator_client.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py - - name: Lint repaired source and tests - run: | - cd backend - python -m ruff check \ - api/tenant_config.py \ - services/contextual_orchestrator_client.py \ - services/email_writing_orchestrator_port.py \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_contextual_orchestrator_client.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py - - name: Commit verified repair - run: | - set -euo pipefail - rm .github/scripts/repair_email_writing_orchestrator.py - rm .github/workflows/email-writing-orchestrator-hardening-repair.yml - git diff --check - git config user.name "CWL Email Writing Repair" - git config user.email "actions@users.noreply.github.com" - git add \ - .github/scripts/repair_email_writing_orchestrator.py \ - .github/workflows/email-writing-orchestrator-hardening-repair.yml \ - backend/api/tenant_config.py \ - backend/services/contextual_orchestrator_client.py \ - backend/services/email_writing_orchestrator_port.py - git commit -m "fix(email-writing): harden orchestration boundaries" - git push origin HEAD:feat/llm-email-writing-orchestrator-task5 diff --git a/backend/api/tenant_config.py b/backend/api/tenant_config.py index bad8fb50b..ee29dc69b 100644 --- a/backend/api/tenant_config.py +++ b/backend/api/tenant_config.py @@ -1,5 +1,6 @@ import logging from typing import Optional +from urllib.parse import urlsplit from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator @@ -315,7 +316,23 @@ async def _validated_orchestrator_url(value: str | None) -> str | None: status_code=400, detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, ) from exc - return None if validated is None else validated.normalized_url + if validated is None: + return None + parsed = urlsplit(validated.normalized_url) + if ( + parsed.scheme.lower() != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + ): + raise HTTPException( + status_code=400, + detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, + ) + return validated.normalized_url @router.put( diff --git a/backend/services/contextual_orchestrator_client.py b/backend/services/contextual_orchestrator_client.py index 302fd9586..7de68e174 100644 --- a/backend/services/contextual_orchestrator_client.py +++ b/backend/services/contextual_orchestrator_client.py @@ -46,6 +46,9 @@ _MAX_MESSAGE_COUNT = 64 _MAX_MESSAGE_CHARS = 200_000 _MAX_TOTAL_MESSAGE_CHARS = 1_000_000 +_MAX_JSON_DEPTH = 32 +_MAX_JSON_NODES = 100_000 +_MAX_TRACE_STEPS = 64 _MAX_SAFE_INTEGER = 2**53 - 1 @@ -155,6 +158,27 @@ def _bounded_counter(value: Any) -> int: return value +def _validate_json_structure(value: Any) -> None: + """Reject response trees whose parsing work exceeds fixed limits.""" + pending: list[tuple[Any, int]] = [(value, 1)] + observed_nodes = 0 + while pending: + current, depth = pending.pop() + observed_nodes += 1 + if depth > _MAX_JSON_DEPTH or observed_nodes > _MAX_JSON_NODES: + raise ContextualOrchestratorError( + "orchestrator_malformed_response" + ) + if isinstance(current, dict): + pending.extend((item, depth + 1) for item in current.values()) + elif isinstance(current, list): + pending.extend((item, depth + 1) for item in current) + elif isinstance(current, str) and _contains_surrogate(current): + raise ContextualOrchestratorError( + "orchestrator_malformed_response" + ) + + class ContextualOrchestratorClient: """Secure per-tenant client for candidate and Judge completions.""" @@ -262,6 +286,10 @@ async def complete( headers, payload, ) + if completion.mode != mode: + raise ContextualOrchestratorError( + "orchestrator_malformed_response" + ) except ContextualOrchestratorError as exc: if not exc.transient or attempt >= self._max_retries: if exc.transient: @@ -470,6 +498,7 @@ def _strict_json(self, body: bytes) -> dict[str, Any]: ) from exc if not isinstance(value, dict): raise ContextualOrchestratorError("orchestrator_malformed_response") + _validate_json_structure(value) return value def _parse_completion(self, body: bytes) -> ContextualOrchestratorCompletion: @@ -491,7 +520,10 @@ def _parse_completion(self, body: bytes) -> ContextualOrchestratorCompletion: if mode not in {"route", "conduct"}: raise ContextualOrchestratorError("orchestrator_malformed_response") raw_trace = orchestration.get("trace") - if not isinstance(raw_trace, list): + if ( + not isinstance(raw_trace, list) + or len(raw_trace) > _MAX_TRACE_STEPS + ): raise ContextualOrchestratorError("orchestrator_malformed_response") trace: list[OrchestrationUsageEvidence] = [] for step in raw_trace: diff --git a/backend/services/email_writing_orchestrator_port.py b/backend/services/email_writing_orchestrator_port.py index fc48dafee..fd270098d 100644 --- a/backend/services/email_writing_orchestrator_port.py +++ b/backend/services/email_writing_orchestrator_port.py @@ -10,10 +10,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor import threading -from typing import Any, ParamSpec, TypeVar +from typing import ParamSpec, TypeVar from services.contextual_orchestrator_client import ( ChatMessage, @@ -84,16 +84,23 @@ async def run_judge( """Run one synchronous Judge operation in the bounded worker lane.""" self._assert_judge_lane_open() await self._judge_semaphore.acquire() - loop = asyncio.get_running_loop() - future = loop.run_in_executor( - self._judge_executor, - lambda: operation(*args, **kwargs), - ) try: - return await asyncio.shield(future) - except asyncio.CancelledError: - await asyncio.shield(future) - raise + loop = asyncio.get_running_loop() + with self._state_lock: + if self._closed: + raise RuntimeError("judge_lane_closed") + future = loop.run_in_executor( + self._judge_executor, + lambda: operation(*args, **kwargs), + ) + try: + return await asyncio.shield(future) + except asyncio.CancelledError: + try: + await asyncio.shield(future) + except Exception: + pass + raise finally: self._judge_semaphore.release() From 71250da7a87d6c939a1e9807a1814e62e3d63e52 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:23:40 +0900 Subject: [PATCH 25/46] ci(email-writing): stage modular orchestration API refactor --- .../modularize_email_writing_orchestrator.py | 401 ++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 .github/scripts/modularize_email_writing_orchestrator.py diff --git a/.github/scripts/modularize_email_writing_orchestrator.py b/.github/scripts/modularize_email_writing_orchestrator.py new file mode 100644 index 000000000..73e0851d9 --- /dev/null +++ b/.github/scripts/modularize_email_writing_orchestrator.py @@ -0,0 +1,401 @@ +"""Move email-writing orchestration configuration into a dedicated API module.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + + +BOUNDARY_TEST = '''"""Architecture contracts for the email-writing orchestration API boundary.""" + +from __future__ import annotations + +import importlib + + +def test_email_writing_orchestrator_owns_a_dedicated_router_module() -> None: + """Keep email-writing configuration isolated from legacy mailbox settings.""" + module = importlib.import_module("api.email_writing_orchestrator_config") + tenant_config = importlib.import_module("api.tenant_config") + + route_paths = {route.path for route in module.router.routes} + assert route_paths == {"/api/config/email-writing-orchestrator"} + assert not hasattr(tenant_config, "EmailWritingOrchestratorConfigUpdate") + assert not hasattr(tenant_config, "update_email_writing_orchestrator_config") +''' + + +API_MODULE = '''"""Owner-scoped HTTP configuration for email-writing orchestration.""" + +from __future__ import annotations + +import logging +from urllib.parse import urlsplit + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator +from sqlalchemy.ext.asyncio import AsyncSession + +from api.auth import AuthContext, get_auth_context +from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig +from db.session import get_db +from services.llm_provider_urls import validate_llm_provider_base_url_details_async +from services.tenant_config_scope import ( + get_scoped_email_writing_orchestrator_config, + new_scoped_email_writing_orchestrator_config, +) + +router = APIRouter(prefix="/api/config") +logger = logging.getLogger(__name__) +_INVALID_CONFIGURATION = "Invalid email-writing orchestrator configuration" +_ENCRYPTION_CONFIGURATION_REQUIRED = ( + "Server encryption key is not configured. Contact your workspace administrator." +) + + +class EmailWritingOrchestratorConfigUpdate(BaseModel): + """Owner-scoped update for the email-writing orchestration connection.""" + + orchestrator_enabled: bool | None = None + orchestrator_base_url: str | None = None + model_profile_id: str | None = None + inference_credential: str | None = None + + model_config = ConfigDict(extra="forbid") + + @field_validator( + "orchestrator_base_url", + "model_profile_id", + "inference_credential", + mode="before", + ) + @classmethod + def normalize_optional_text( + cls, + value: object, + info: ValidationInfo, + ) -> object: + """Trim bounded text fields without coercing non-string values.""" + if value is None or not isinstance(value, str): + return value + normalized = value.strip() + limits = { + "orchestrator_base_url": 2_048, + "model_profile_id": 255, + "inference_credential": 8_192, + } + if len(normalized) > limits[info.field_name]: + raise ValueError("configuration value is too long") + if any( + ord(character) < 32 or ord(character) == 127 + for character in normalized + ): + raise ValueError("configuration value contains control characters") + return normalized or None + + +class EmailWritingOrchestratorConfigResponse(BaseModel): + """Secret-free email-writing orchestration configuration status.""" + + orchestrator_enabled: bool + orchestrator_base_url: str | None + model_profile_id: str | None + has_inference_credential: bool + + model_config = ConfigDict(extra="forbid") + + +def _public_configuration( + config: EmailWritingOrchestratorConfig | None, +) -> EmailWritingOrchestratorConfigResponse: + """Build the public configuration view without owner or secret values.""" + if config is None: + return EmailWritingOrchestratorConfigResponse( + orchestrator_enabled=False, + orchestrator_base_url=None, + model_profile_id=None, + has_inference_credential=False, + ) + return EmailWritingOrchestratorConfigResponse( + orchestrator_enabled=config.orchestrator_enabled, + orchestrator_base_url=config.orchestrator_base_url, + model_profile_id=config.model_profile_id, + has_inference_credential=config.inference_credential is not None, + ) + + +async def _validated_orchestrator_url(value: str | None) -> str | None: + """Validate and normalize an operator-allowlisted orchestration endpoint.""" + try: + validated = await validate_llm_provider_base_url_details_async(value) + except ValueError as exc: + logger.warning( + "Email-writing orchestrator URL validation failed", + extra={"error_type": type(exc).__name__}, + ) + raise HTTPException( + status_code=400, + detail=_INVALID_CONFIGURATION, + ) from exc + if validated is None: + return None + parsed = urlsplit(validated.normalized_url) + if ( + parsed.scheme.lower() != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in {"", "/"} + ): + raise HTTPException( + status_code=400, + detail=_INVALID_CONFIGURATION, + ) + return validated.normalized_url + + +@router.put( + "/email-writing-orchestrator", + response_model=EmailWritingOrchestratorConfigResponse, +) +async def update_email_writing_orchestrator_config( + update: EmailWritingOrchestratorConfigUpdate, + db: AsyncSession = Depends(get_db), + auth_context: AuthContext = Depends(get_auth_context), +) -> EmailWritingOrchestratorConfigResponse: + """Update one authenticated owner's orchestration settings fail-closed.""" + existing = await get_scoped_email_writing_orchestrator_config( + db, + auth_context.user_id, + auth_context.organization_id, + ) + values = update.model_dump(exclude_unset=True) + + enabled = values.get( + "orchestrator_enabled", + existing.orchestrator_enabled if existing is not None else False, + ) + base_url = values.get( + "orchestrator_base_url", + existing.orchestrator_base_url if existing is not None else None, + ) + if "orchestrator_base_url" in values: + base_url = await _validated_orchestrator_url(base_url) + model_profile_id = values.get( + "model_profile_id", + existing.model_profile_id if existing is not None else None, + ) + inference_credential = values.get( + "inference_credential", + existing.inference_credential if existing is not None else None, + ) + + if enabled and not all((base_url, model_profile_id, inference_credential)): + raise HTTPException( + status_code=400, + detail=_INVALID_CONFIGURATION, + ) + + config = existing + if config is None: + config = new_scoped_email_writing_orchestrator_config( + auth_context.user_id, + auth_context.organization_id, + ) + db.add(config) + + config.orchestrator_enabled = enabled + config.orchestrator_base_url = base_url + config.model_profile_id = model_profile_id + config.inference_credential = inference_credential + + try: + await db.commit() + except Exception as exc: + if "ENCRYPTION_KEY is required" not in str(exc): + raise + raise HTTPException( + status_code=503, + detail=_ENCRYPTION_CONFIGURATION_REQUIRED, + ) from exc + return _public_configuration(config) + + +@router.get( + "/email-writing-orchestrator", + response_model=EmailWritingOrchestratorConfigResponse, +) +async def get_email_writing_orchestrator_config( + db: AsyncSession = Depends(get_db), + auth_context: AuthContext = Depends(get_auth_context), +) -> EmailWritingOrchestratorConfigResponse: + """Return one authenticated owner's secret-free orchestration settings.""" + config = await get_scoped_email_writing_orchestrator_config( + db, + auth_context.user_id, + auth_context.organization_id, + ) + return _public_configuration(config) +''' + + +def _replace_once(path: Path, old: str, new: str, *, label: str) -> None: + """Replace one exact fragment or stop when the source moved.""" + text = path.read_text(encoding="utf-8") + if text.count(old) != 1: + raise SystemExit(f"{label} anchor count changed: {text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def _write_red_test_and_verify() -> None: + """Prove the architecture test fails before the production refactor.""" + test_path = Path( + "backend/tests/test_email_writing_orchestrator_module_boundary.py" + ) + test_path.write_text(BOUNDARY_TEST, encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + str(test_path.relative_to("backend")), + ], + cwd="backend", + check=False, + ) + if result.returncode == 0: + raise SystemExit("architecture RED test unexpectedly passed") + + +def _create_dedicated_api() -> None: + """Create the cohesive orchestration configuration API module.""" + Path("backend/api/email_writing_orchestrator_config.py").write_text( + API_MODULE, + encoding="utf-8", + ) + + +def _remove_legacy_embedding() -> None: + """Remove orchestration API responsibilities from legacy mailbox config.""" + path = Path("backend/api/tenant_config.py") + _replace_once( + path, + "from urllib.parse import urlsplit\n\n", + "", + label="urlsplit import", + ) + _replace_once( + path, + "from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator\n", + "from pydantic import BaseModel, ConfigDict\n", + label="Pydantic imports", + ) + _replace_once( + path, + "from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig\n", + "", + label="orchestrator model import", + ) + _replace_once( + path, + """from services.llm_provider_urls import ( + validate_llm_provider_base_url_details_async, +) +""", + "", + label="URL validator import", + ) + _replace_once( + path, + """from services.tenant_config_scope import ( + get_scoped_email_writing_orchestrator_config, + get_scoped_tenant_config, + new_scoped_email_writing_orchestrator_config, + new_scoped_tenant_config, +) +""", + """from services.tenant_config_scope import ( + get_scoped_tenant_config, + new_scoped_tenant_config, +) +""", + label="scope imports", + ) + + text = path.read_text(encoding="utf-8") + class_start = text.index("class EmailWritingOrchestratorConfigUpdate") + class_end = text.index("SECRET_FIELDS =", class_start) + text = text[:class_start] + text[class_end:] + constant = '''_EMAIL_WRITING_ORCHESTRATOR_INVALID = ( + "Invalid email-writing orchestrator configuration" +) + +''' + if text.count(constant) != 1: + raise SystemExit("legacy invalid-configuration constant moved") + text = text.replace(constant, "", 1) + route_start = text.index("def _email_writing_orchestrator_response") + route_start = text.rfind("\n\n", 0, route_start) + 2 + route_end = text.index('@router.post("")', route_start) + text = text[:route_start] + text[route_end:] + path.write_text(text, encoding="utf-8") + + +def _wire_router() -> None: + """Register the dedicated router behind the existing auth dependency.""" + path = Path("backend/main.py") + _replace_once( + path, + "from api.tenant_config import router as tenant_config_router\n", + """from api.tenant_config import router as tenant_config_router +from api.email_writing_orchestrator_config import ( + router as email_writing_orchestrator_config_router, +) +""", + label="main router import", + ) + _replace_once( + path, + "app.include_router(tenant_config_router, dependencies=PRIVATE_API_DEPENDENCIES)\n", + """app.include_router(tenant_config_router, dependencies=PRIVATE_API_DEPENDENCIES) +app.include_router( + email_writing_orchestrator_config_router, + dependencies=PRIVATE_API_DEPENDENCIES, +) +""", + label="main router registration", + ) + + +def _retarget_test_patches() -> None: + """Point test doubles at the new cohesive API module.""" + path = Path("backend/tests/test_email_writing_orchestrator_config_api.py") + text = path.read_text(encoding="utf-8") + text = text.replace( + "api.tenant_config.get_scoped_email_writing_orchestrator_config", + "api.email_writing_orchestrator_config." + "get_scoped_email_writing_orchestrator_config", + ) + text = text.replace( + "api.tenant_config.validate_llm_provider_base_url_details_async", + "api.email_writing_orchestrator_config." + "validate_llm_provider_base_url_details_async", + ) + path.write_text(text, encoding="utf-8") + + +def main() -> None: + """Execute the RED-GREEN modularization against exact source anchors.""" + _write_red_test_and_verify() + _create_dedicated_api() + _remove_legacy_embedding() + _wire_router() + _retarget_test_patches() + + +if __name__ == "__main__": + main() From b9c1e0ab178eed1a0305906859390f38dd0ce1d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:24:20 +0900 Subject: [PATCH 26/46] ci(email-writing): run modular orchestration API refactor --- ...il-writing-orchestrator-modularization.yml | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/email-writing-orchestrator-modularization.yml diff --git a/.github/workflows/email-writing-orchestrator-modularization.yml b/.github/workflows/email-writing-orchestrator-modularization.yml new file mode 100644 index 000000000..0e5e14ce2 --- /dev/null +++ b/.github/workflows/email-writing-orchestrator-modularization.yml @@ -0,0 +1,80 @@ +name: Email Writing Orchestrator Modularization + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: email-writing-orchestrator-modularization + cancel-in-progress: true + +jobs: + modularize: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: true + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - name: Install hash-locked dependencies + run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Run test-first modularization + run: python .github/scripts/modularize_email_writing_orchestrator.py + - name: Verify orchestration contracts + run: | + cd backend + python -m pytest -q \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_contextual_orchestrator_client.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py + - name: Lint modularized source and tests + run: | + cd backend + python -m ruff check \ + api/email_writing_orchestrator_config.py \ + api/tenant_config.py \ + main.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_email_writing_orchestrator_config_api.py + - name: Compile modularized source + run: | + python -m compileall -q \ + backend/api/email_writing_orchestrator_config.py \ + backend/api/tenant_config.py \ + backend/main.py + - name: Commit verified modularization + run: | + set -euo pipefail + rm .github/scripts/modularize_email_writing_orchestrator.py + rm .github/workflows/email-writing-orchestrator-modularization.yml + git diff --check + git config user.name "CWL Email Writing Modularization" + git config user.email "actions@users.noreply.github.com" + git add \ + .github/scripts/modularize_email_writing_orchestrator.py \ + .github/workflows/email-writing-orchestrator-modularization.yml \ + backend/api/email_writing_orchestrator_config.py \ + backend/api/tenant_config.py \ + backend/main.py \ + backend/tests/test_email_writing_orchestrator_module_boundary.py \ + backend/tests/test_email_writing_orchestrator_config_api.py + git commit -m "refactor(email-writing): isolate orchestration configuration API" + git push origin HEAD:feat/llm-email-writing-orchestrator-task5 From 26fd38fbc00bef7b5733f636248cefb9bc25b9c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:28:08 +0900 Subject: [PATCH 27/46] ci(email-writing): retarget hardening tests during modularization --- ...mail-writing-orchestrator-modularization.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/email-writing-orchestrator-modularization.yml b/.github/workflows/email-writing-orchestrator-modularization.yml index 0e5e14ce2..497308394 100644 --- a/.github/workflows/email-writing-orchestrator-modularization.yml +++ b/.github/workflows/email-writing-orchestrator-modularization.yml @@ -35,6 +35,21 @@ jobs: run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - name: Run test-first modularization run: python .github/scripts/modularize_email_writing_orchestrator.py + - name: Retarget hardening tests to the dedicated API module + run: | + python - <<'PY' + from pathlib import Path + + path = Path("backend/tests/test_contextual_orchestrator_hardening.py") + text = path.read_text(encoding="utf-8") + old = "from api import tenant_config\n" + new = ( + "from api import email_writing_orchestrator_config as tenant_config\n" + ) + if text.count(old) != 1: + raise SystemExit("hardening API import anchor changed") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + PY - name: Verify orchestration contracts run: | cd backend @@ -52,6 +67,7 @@ jobs: api/email_writing_orchestrator_config.py \ api/tenant_config.py \ main.py \ + tests/test_contextual_orchestrator_hardening.py \ tests/test_email_writing_orchestrator_module_boundary.py \ tests/test_email_writing_orchestrator_config_api.py - name: Compile modularized source @@ -74,6 +90,7 @@ jobs: backend/api/email_writing_orchestrator_config.py \ backend/api/tenant_config.py \ backend/main.py \ + backend/tests/test_contextual_orchestrator_hardening.py \ backend/tests/test_email_writing_orchestrator_module_boundary.py \ backend/tests/test_email_writing_orchestrator_config_api.py git commit -m "refactor(email-writing): isolate orchestration configuration API" From 490dbb93ed61b854d76f1190d73b00ba38e97a9d Mon Sep 17 00:00:00 2001 From: CWL Email Writing Modularization Date: Sat, 15 Aug 2026 10:29:03 +0000 Subject: [PATCH 28/46] refactor(email-writing): isolate orchestration configuration API --- ...il-writing-orchestrator-modularization.yml | 97 --------- .../api/email_writing_orchestrator_config.py | 190 +---------------- backend/api/tenant_config.py | 199 +----------------- backend/main.py | 7 + .../test_contextual_orchestrator_hardening.py | 2 +- ...t_email_writing_orchestrator_config_api.py | 4 +- ...il_writing_orchestrator_module_boundary.py | 16 ++ 7 files changed, 28 insertions(+), 487 deletions(-) delete mode 100644 .github/workflows/email-writing-orchestrator-modularization.yml rename .github/scripts/modularize_email_writing_orchestrator.py => backend/api/email_writing_orchestrator_config.py (53%) create mode 100644 backend/tests/test_email_writing_orchestrator_module_boundary.py diff --git a/.github/workflows/email-writing-orchestrator-modularization.yml b/.github/workflows/email-writing-orchestrator-modularization.yml deleted file mode 100644 index 497308394..000000000 --- a/.github/workflows/email-writing-orchestrator-modularization.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Email Writing Orchestrator Modularization - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: email-writing-orchestrator-modularization - cancel-in-progress: true - -jobs: - modularize: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - PYTHONWARNINGS: error - DISABLE_BACKGROUND_WORKERS: "1" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: true - fetch-depth: 0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: backend/requirements-hashes.txt - - name: Install hash-locked dependencies - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Run test-first modularization - run: python .github/scripts/modularize_email_writing_orchestrator.py - - name: Retarget hardening tests to the dedicated API module - run: | - python - <<'PY' - from pathlib import Path - - path = Path("backend/tests/test_contextual_orchestrator_hardening.py") - text = path.read_text(encoding="utf-8") - old = "from api import tenant_config\n" - new = ( - "from api import email_writing_orchestrator_config as tenant_config\n" - ) - if text.count(old) != 1: - raise SystemExit("hardening API import anchor changed") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - PY - - name: Verify orchestration contracts - run: | - cd backend - python -m pytest -q \ - tests/test_email_writing_orchestrator_module_boundary.py \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_contextual_orchestrator_client.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py - - name: Lint modularized source and tests - run: | - cd backend - python -m ruff check \ - api/email_writing_orchestrator_config.py \ - api/tenant_config.py \ - main.py \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_email_writing_orchestrator_module_boundary.py \ - tests/test_email_writing_orchestrator_config_api.py - - name: Compile modularized source - run: | - python -m compileall -q \ - backend/api/email_writing_orchestrator_config.py \ - backend/api/tenant_config.py \ - backend/main.py - - name: Commit verified modularization - run: | - set -euo pipefail - rm .github/scripts/modularize_email_writing_orchestrator.py - rm .github/workflows/email-writing-orchestrator-modularization.yml - git diff --check - git config user.name "CWL Email Writing Modularization" - git config user.email "actions@users.noreply.github.com" - git add \ - .github/scripts/modularize_email_writing_orchestrator.py \ - .github/workflows/email-writing-orchestrator-modularization.yml \ - backend/api/email_writing_orchestrator_config.py \ - backend/api/tenant_config.py \ - backend/main.py \ - backend/tests/test_contextual_orchestrator_hardening.py \ - backend/tests/test_email_writing_orchestrator_module_boundary.py \ - backend/tests/test_email_writing_orchestrator_config_api.py - git commit -m "refactor(email-writing): isolate orchestration configuration API" - git push origin HEAD:feat/llm-email-writing-orchestrator-task5 diff --git a/.github/scripts/modularize_email_writing_orchestrator.py b/backend/api/email_writing_orchestrator_config.py similarity index 53% rename from .github/scripts/modularize_email_writing_orchestrator.py rename to backend/api/email_writing_orchestrator_config.py index 73e0851d9..653afbc57 100644 --- a/.github/scripts/modularize_email_writing_orchestrator.py +++ b/backend/api/email_writing_orchestrator_config.py @@ -1,32 +1,4 @@ -"""Move email-writing orchestration configuration into a dedicated API module.""" - -from __future__ import annotations - -from pathlib import Path -import subprocess -import sys - - -BOUNDARY_TEST = '''"""Architecture contracts for the email-writing orchestration API boundary.""" - -from __future__ import annotations - -import importlib - - -def test_email_writing_orchestrator_owns_a_dedicated_router_module() -> None: - """Keep email-writing configuration isolated from legacy mailbox settings.""" - module = importlib.import_module("api.email_writing_orchestrator_config") - tenant_config = importlib.import_module("api.tenant_config") - - route_paths = {route.path for route in module.router.routes} - assert route_paths == {"/api/config/email-writing-orchestrator"} - assert not hasattr(tenant_config, "EmailWritingOrchestratorConfigUpdate") - assert not hasattr(tenant_config, "update_email_writing_orchestrator_config") -''' - - -API_MODULE = '''"""Owner-scoped HTTP configuration for email-writing orchestration.""" +"""Owner-scoped HTTP configuration for email-writing orchestration.""" from __future__ import annotations @@ -239,163 +211,3 @@ async def get_email_writing_orchestrator_config( auth_context.organization_id, ) return _public_configuration(config) -''' - - -def _replace_once(path: Path, old: str, new: str, *, label: str) -> None: - """Replace one exact fragment or stop when the source moved.""" - text = path.read_text(encoding="utf-8") - if text.count(old) != 1: - raise SystemExit(f"{label} anchor count changed: {text.count(old)}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - - -def _write_red_test_and_verify() -> None: - """Prove the architecture test fails before the production refactor.""" - test_path = Path( - "backend/tests/test_email_writing_orchestrator_module_boundary.py" - ) - test_path.write_text(BOUNDARY_TEST, encoding="utf-8") - result = subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "-q", - str(test_path.relative_to("backend")), - ], - cwd="backend", - check=False, - ) - if result.returncode == 0: - raise SystemExit("architecture RED test unexpectedly passed") - - -def _create_dedicated_api() -> None: - """Create the cohesive orchestration configuration API module.""" - Path("backend/api/email_writing_orchestrator_config.py").write_text( - API_MODULE, - encoding="utf-8", - ) - - -def _remove_legacy_embedding() -> None: - """Remove orchestration API responsibilities from legacy mailbox config.""" - path = Path("backend/api/tenant_config.py") - _replace_once( - path, - "from urllib.parse import urlsplit\n\n", - "", - label="urlsplit import", - ) - _replace_once( - path, - "from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator\n", - "from pydantic import BaseModel, ConfigDict\n", - label="Pydantic imports", - ) - _replace_once( - path, - "from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig\n", - "", - label="orchestrator model import", - ) - _replace_once( - path, - """from services.llm_provider_urls import ( - validate_llm_provider_base_url_details_async, -) -""", - "", - label="URL validator import", - ) - _replace_once( - path, - """from services.tenant_config_scope import ( - get_scoped_email_writing_orchestrator_config, - get_scoped_tenant_config, - new_scoped_email_writing_orchestrator_config, - new_scoped_tenant_config, -) -""", - """from services.tenant_config_scope import ( - get_scoped_tenant_config, - new_scoped_tenant_config, -) -""", - label="scope imports", - ) - - text = path.read_text(encoding="utf-8") - class_start = text.index("class EmailWritingOrchestratorConfigUpdate") - class_end = text.index("SECRET_FIELDS =", class_start) - text = text[:class_start] + text[class_end:] - constant = '''_EMAIL_WRITING_ORCHESTRATOR_INVALID = ( - "Invalid email-writing orchestrator configuration" -) - -''' - if text.count(constant) != 1: - raise SystemExit("legacy invalid-configuration constant moved") - text = text.replace(constant, "", 1) - route_start = text.index("def _email_writing_orchestrator_response") - route_start = text.rfind("\n\n", 0, route_start) + 2 - route_end = text.index('@router.post("")', route_start) - text = text[:route_start] + text[route_end:] - path.write_text(text, encoding="utf-8") - - -def _wire_router() -> None: - """Register the dedicated router behind the existing auth dependency.""" - path = Path("backend/main.py") - _replace_once( - path, - "from api.tenant_config import router as tenant_config_router\n", - """from api.tenant_config import router as tenant_config_router -from api.email_writing_orchestrator_config import ( - router as email_writing_orchestrator_config_router, -) -""", - label="main router import", - ) - _replace_once( - path, - "app.include_router(tenant_config_router, dependencies=PRIVATE_API_DEPENDENCIES)\n", - """app.include_router(tenant_config_router, dependencies=PRIVATE_API_DEPENDENCIES) -app.include_router( - email_writing_orchestrator_config_router, - dependencies=PRIVATE_API_DEPENDENCIES, -) -""", - label="main router registration", - ) - - -def _retarget_test_patches() -> None: - """Point test doubles at the new cohesive API module.""" - path = Path("backend/tests/test_email_writing_orchestrator_config_api.py") - text = path.read_text(encoding="utf-8") - text = text.replace( - "api.tenant_config.get_scoped_email_writing_orchestrator_config", - "api.email_writing_orchestrator_config." - "get_scoped_email_writing_orchestrator_config", - ) - text = text.replace( - "api.tenant_config.validate_llm_provider_base_url_details_async", - "api.email_writing_orchestrator_config." - "validate_llm_provider_base_url_details_async", - ) - path.write_text(text, encoding="utf-8") - - -def main() -> None: - """Execute the RED-GREEN modularization against exact source anchors.""" - _write_red_test_and_verify() - _create_dedicated_api() - _remove_legacy_embedding() - _wire_router() - _retarget_test_patches() - - -if __name__ == "__main__": - main() diff --git a/backend/api/tenant_config.py b/backend/api/tenant_config.py index ee29dc69b..b57d4d219 100644 --- a/backend/api/tenant_config.py +++ b/backend/api/tenant_config.py @@ -1,9 +1,7 @@ import logging from typing import Optional -from urllib.parse import urlsplit - from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator +from pydantic import BaseModel, ConfigDict from sqlalchemy.ext.asyncio import AsyncSession from api.auth import ( @@ -12,7 +10,6 @@ get_current_user_role, is_admin_role, ) -from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig from db.models import TenantConfig from db.session import get_db from services.access_policy import ( @@ -30,13 +27,8 @@ validate_smtp_host, validate_smtp_port, ) -from services.llm_provider_urls import ( - validate_llm_provider_base_url_details_async, -) from services.tenant_config_scope import ( - get_scoped_email_writing_orchestrator_config, get_scoped_tenant_config, - new_scoped_email_writing_orchestrator_config, new_scoped_tenant_config, ) @@ -99,56 +91,6 @@ class TenantConfigResponse(BaseModel): model_config = ConfigDict(from_attributes=True) -class EmailWritingOrchestratorConfigUpdate(BaseModel): - """Owner-scoped update for the email-writing orchestration connection.""" - - orchestrator_enabled: bool | None = None - orchestrator_base_url: str | None = None - model_profile_id: str | None = None - inference_credential: str | None = None - - model_config = ConfigDict(extra="forbid") - - @field_validator( - "orchestrator_base_url", - "model_profile_id", - "inference_credential", - mode="before", - ) - @classmethod - def normalize_optional_text( - cls, value: object, info: ValidationInfo - ) -> object: - """Trim bounded text fields without coercing non-string values.""" - if value is None or not isinstance(value, str): - return value - normalized = value.strip() - limits = { - "orchestrator_base_url": 2048, - "model_profile_id": 255, - "inference_credential": 8192, - } - if len(normalized) > limits[info.field_name]: - raise ValueError("configuration value is too long") - if any( - ord(character) < 32 or ord(character) == 127 - for character in normalized - ): - raise ValueError("configuration value contains control characters") - return normalized or None - - -class EmailWritingOrchestratorConfigResponse(BaseModel): - """Secret-free email-writing orchestration configuration status.""" - - orchestrator_enabled: bool - orchestrator_base_url: str | None - model_profile_id: str | None - has_inference_credential: bool - - model_config = ConfigDict(extra="forbid") - - SECRET_FIELDS = { "smtp_password", "imap_password", @@ -172,10 +114,6 @@ class EmailWritingOrchestratorConfigResponse(BaseModel): "group_admin", "member", ) -_EMAIL_WRITING_ORCHESTRATOR_INVALID = ( - "Invalid email-writing orchestrator configuration" -) - def ensure_mailbox_config_self_access( target_user_id: str, auth_context: AuthContext, forbidden_detail: str @@ -284,141 +222,6 @@ def validate_mail_config_update( _validate_pop3_config(pop3_server, pop3_port) -def _email_writing_orchestrator_response( - config: EmailWritingOrchestratorConfig | None, -) -> EmailWritingOrchestratorConfigResponse: - """Build the public configuration view without returning owner or secret data.""" - if config is None: - return EmailWritingOrchestratorConfigResponse( - orchestrator_enabled=False, - orchestrator_base_url=None, - model_profile_id=None, - has_inference_credential=False, - ) - return EmailWritingOrchestratorConfigResponse( - orchestrator_enabled=config.orchestrator_enabled, - orchestrator_base_url=config.orchestrator_base_url, - model_profile_id=config.model_profile_id, - has_inference_credential=config.inference_credential is not None, - ) - - -async def _validated_orchestrator_url(value: str | None) -> str | None: - """Validate and normalize an operator-allowlisted orchestration endpoint.""" - try: - validated = await validate_llm_provider_base_url_details_async(value) - except ValueError as exc: - logger.warning( - "Email-writing orchestrator URL validation failed", - extra={"error_type": type(exc).__name__}, - ) - raise HTTPException( - status_code=400, - detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, - ) from exc - if validated is None: - return None - parsed = urlsplit(validated.normalized_url) - if ( - parsed.scheme.lower() != "https" - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - or parsed.path not in {"", "/"} - ): - raise HTTPException( - status_code=400, - detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, - ) - return validated.normalized_url - - -@router.put( - "/email-writing-orchestrator", - response_model=EmailWritingOrchestratorConfigResponse, -) -async def update_email_writing_orchestrator_config( - update: EmailWritingOrchestratorConfigUpdate, - db: AsyncSession = Depends(get_db), - auth_context: AuthContext = Depends(get_auth_context), -) -> EmailWritingOrchestratorConfigResponse: - """Update one authenticated owner's orchestration settings fail-closed.""" - existing = await get_scoped_email_writing_orchestrator_config( - db, - auth_context.user_id, - auth_context.organization_id, - ) - values = update.model_dump(exclude_unset=True) - - enabled = values.get( - "orchestrator_enabled", - existing.orchestrator_enabled if existing is not None else False, - ) - base_url = values.get( - "orchestrator_base_url", - existing.orchestrator_base_url if existing is not None else None, - ) - if "orchestrator_base_url" in values: - base_url = await _validated_orchestrator_url(base_url) - model_profile_id = values.get( - "model_profile_id", - existing.model_profile_id if existing is not None else None, - ) - inference_credential = values.get( - "inference_credential", - existing.inference_credential if existing is not None else None, - ) - - if enabled and not all((base_url, model_profile_id, inference_credential)): - raise HTTPException( - status_code=400, - detail=_EMAIL_WRITING_ORCHESTRATOR_INVALID, - ) - - config = existing - if config is None: - config = new_scoped_email_writing_orchestrator_config( - auth_context.user_id, - auth_context.organization_id, - ) - db.add(config) - - config.orchestrator_enabled = enabled - config.orchestrator_base_url = base_url - config.model_profile_id = model_profile_id - config.inference_credential = inference_credential - - try: - await db.commit() - except Exception as exc: - if "ENCRYPTION_KEY is required" not in str(exc): - raise - raise HTTPException( - status_code=503, - detail="Server encryption key is not configured. Contact your workspace administrator.", - ) from exc - return _email_writing_orchestrator_response(config) - - -@router.get( - "/email-writing-orchestrator", - response_model=EmailWritingOrchestratorConfigResponse, -) -async def get_email_writing_orchestrator_config( - db: AsyncSession = Depends(get_db), - auth_context: AuthContext = Depends(get_auth_context), -) -> EmailWritingOrchestratorConfigResponse: - """Return one authenticated owner's secret-free orchestration settings.""" - config = await get_scoped_email_writing_orchestrator_config( - db, - auth_context.user_id, - auth_context.organization_id, - ) - return _email_writing_orchestrator_response(config) - - @router.post("") async def create_or_update_config( config: TenantConfigCreate, diff --git a/backend/main.py b/backend/main.py index 0ad7762a8..243f8253c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,9 @@ from api.emails import router as emails_router from api.runner_config import router as runner_config_router from api.tenant_config import router as tenant_config_router +from api.email_writing_orchestrator_config import ( + router as email_writing_orchestrator_config_router, +) from api.runtime_config import router as runtime_config_router from api.llm_providers import router as llm_providers_router from api.prompts import router as prompts_router @@ -221,6 +224,10 @@ async def add_security_headers(request: Request, call_next): app.include_router(emails_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(runner_config_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(tenant_config_router, dependencies=PRIVATE_API_DEPENDENCIES) +app.include_router( + email_writing_orchestrator_config_router, + dependencies=PRIVATE_API_DEPENDENCIES, +) app.include_router(runtime_config_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(llm_providers_router, dependencies=PRIVATE_API_DEPENDENCIES) app.include_router(prompts_router, dependencies=PRIVATE_API_DEPENDENCIES) diff --git a/backend/tests/test_contextual_orchestrator_hardening.py b/backend/tests/test_contextual_orchestrator_hardening.py index 0cca8965d..5eabe8472 100644 --- a/backend/tests/test_contextual_orchestrator_hardening.py +++ b/backend/tests/test_contextual_orchestrator_hardening.py @@ -10,7 +10,7 @@ import httpx import pytest -from api import tenant_config +from api import email_writing_orchestrator_config as tenant_config from services.contextual_orchestrator_client import ( ContextualOrchestratorClient, ContextualOrchestratorError, diff --git a/backend/tests/test_email_writing_orchestrator_config_api.py b/backend/tests/test_email_writing_orchestrator_config_api.py index 3bd18dba2..f82544e99 100644 --- a/backend/tests/test_email_writing_orchestrator_config_api.py +++ b/backend/tests/test_email_writing_orchestrator_config_api.py @@ -75,12 +75,12 @@ async def endpoint_validator( ) monkeypatch.setattr( - "api.tenant_config.get_scoped_email_writing_orchestrator_config", + "api.email_writing_orchestrator_config.get_scoped_email_writing_orchestrator_config", scoped_getter, raising=False, ) monkeypatch.setattr( - "api.tenant_config.validate_llm_provider_base_url_details_async", + "api.email_writing_orchestrator_config.validate_llm_provider_base_url_details_async", endpoint_validator, raising=False, ) diff --git a/backend/tests/test_email_writing_orchestrator_module_boundary.py b/backend/tests/test_email_writing_orchestrator_module_boundary.py new file mode 100644 index 000000000..c95e640ab --- /dev/null +++ b/backend/tests/test_email_writing_orchestrator_module_boundary.py @@ -0,0 +1,16 @@ +"""Architecture contracts for the email-writing orchestration API boundary.""" + +from __future__ import annotations + +import importlib + + +def test_email_writing_orchestrator_owns_a_dedicated_router_module() -> None: + """Keep email-writing configuration isolated from legacy mailbox settings.""" + module = importlib.import_module("api.email_writing_orchestrator_config") + tenant_config = importlib.import_module("api.tenant_config") + + route_paths = {route.path for route in module.router.routes} + assert route_paths == {"/api/config/email-writing-orchestrator"} + assert not hasattr(tenant_config, "EmailWritingOrchestratorConfigUpdate") + assert not hasattr(tenant_config, "update_email_writing_orchestrator_config") From d2297c671263512cd18ed24115dcefe9ac668d3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:32:08 +0900 Subject: [PATCH 29/46] ci(email-writing): enforce exact Task 5 quality gates --- .../email-writing-orchestrator-tdd.yml | 105 +++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/.github/workflows/email-writing-orchestrator-tdd.yml b/.github/workflows/email-writing-orchestrator-tdd.yml index 2c3b07f75..76c2b8a51 100644 --- a/.github/workflows/email-writing-orchestrator-tdd.yml +++ b/.github/workflows/email-writing-orchestrator-tdd.yml @@ -21,7 +21,7 @@ env: jobs: task5-contracts: runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -32,12 +32,111 @@ jobs: python-version: "3.14" cache: pip cache-dependency-path: backend/requirements-hashes.txt - - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Run Task 5 contracts + - name: Install hash-locked application dependencies + run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Install hash-verified coverage tool + run: | + set -euo pipefail + mkdir -p /tmp/coverage-wheel + cat >/tmp/coverage-lock.txt <<'EOF' + coverage==7.15.2 --hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c + EOF + python -m pip download \ + --disable-pip-version-check \ + --require-hashes \ + --no-deps \ + --only-binary=:all: \ + --platform any \ + --python-version 3.14 \ + --implementation py \ + --abi none \ + --dest /tmp/coverage-wheel \ + -r /tmp/coverage-lock.txt + python -m pip install --disable-pip-version-check --no-deps \ + /tmp/coverage-wheel/coverage-7.15.2-py3-none-any.whl + - name: Run Task 5 contracts and hardening regressions run: | cd backend python -m pytest -q \ tests/test_contextual_orchestrator_client.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ tests/test_email_writing_orchestrator_scope.py \ tests/test_email_writing_orchestrator_migration.py \ tests/test_email_writing_orchestrator_config_api.py + - name: Verify Task 5 statement and branch coverage + run: | + cd backend + python -m coverage erase + python -m coverage run --branch \ + --include='api/email_writing_orchestrator_config.py,db/email_writing_orchestrator_config.py,services/contextual_orchestrator_client.py,services/email_writing_orchestrator_port.py,services/tenant_config_scope.py,alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py' \ + -m pytest -q \ + tests/test_contextual_orchestrator_client.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py + python -m coverage report --show-missing --fail-under=100 \ + api/email_writing_orchestrator_config.py \ + db/email_writing_orchestrator_config.py \ + services/contextual_orchestrator_client.py \ + services/email_writing_orchestrator_port.py \ + services/tenant_config_scope.py \ + alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py + - name: Verify shipped Python docstrings + run: | + cd backend + python - <<'PY' + import ast + from pathlib import Path + + paths = [ + Path("api/email_writing_orchestrator_config.py"), + Path("db/email_writing_orchestrator_config.py"), + Path("services/contextual_orchestrator_client.py"), + Path("services/email_writing_orchestrator_port.py"), + Path("services/tenant_config_scope.py"), + Path( + "alembic/versions/" + "20260813_0001_add_email_writing_orchestrator_config.py" + ), + ] + missing: list[str] = [] + for path in paths: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if ast.get_docstring(tree) is None: + missing.append(f"{path}:") + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + if ast.get_docstring(node) is None: + missing.append(f"{path}:{node.lineno}:{node.name}") + if missing: + raise SystemExit("Missing shipped docstrings:\n" + "\n".join(missing)) + print(f"Docstring gate passed for {len(paths)} shipped modules") + PY + - name: Lint Task 5 source and tests + run: | + cd backend + python -m ruff check \ + api/email_writing_orchestrator_config.py \ + db/email_writing_orchestrator_config.py \ + services/contextual_orchestrator_client.py \ + services/email_writing_orchestrator_port.py \ + services/tenant_config_scope.py \ + alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py \ + tests/test_contextual_orchestrator_client.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py + - name: Compile Task 5 source + run: | + python -m compileall -q \ + backend/api/email_writing_orchestrator_config.py \ + backend/db/email_writing_orchestrator_config.py \ + backend/services/contextual_orchestrator_client.py \ + backend/services/email_writing_orchestrator_port.py \ + backend/services/tenant_config_scope.py \ + backend/alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py From d83e64bea868b389042eb099043c1fdf0e8654d2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:44:42 +0900 Subject: [PATCH 30/46] test(email-writing): close Task 5 terminal coverage gaps --- ..._writing_orchestrator_terminal_coverage.py | 811 ++++++++++++++++++ 1 file changed, 811 insertions(+) create mode 100644 backend/tests/test_email_writing_orchestrator_terminal_coverage.py diff --git a/backend/tests/test_email_writing_orchestrator_terminal_coverage.py b/backend/tests/test_email_writing_orchestrator_terminal_coverage.py new file mode 100644 index 000000000..82f48ab7b --- /dev/null +++ b/backend/tests/test_email_writing_orchestrator_terminal_coverage.py @@ -0,0 +1,811 @@ +"""Terminal branch coverage for the Task 5 orchestration boundary.""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +from pathlib import Path +import sys +from typing import Any, cast + +from fastapi import HTTPException +import httpx +from pydantic import ValidationError +import pytest +from sqlalchemy import create_engine, inspect + +from api.auth import AuthContext +from api import email_writing_orchestrator_config as config_api +from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig +from services import contextual_orchestrator_client as client_module +from services.contextual_orchestrator_client import ( + ContextualOrchestratorClient, + ContextualOrchestratorCompletion, + ContextualOrchestratorError, +) +from services.email_writing_orchestrator_port import EmailWritingOrchestratorPort +from services.llm_provider_urls import ValidatedLLMProviderBaseURL +from services import tenant_config_scope as scope_module + + +_MESSAGES = ( + {"role": "system", "content": "Return strict JSON."}, + {"role": "user", "content": "Review this draft."}, +) + + +def _validated( + *, + normalized_url: str = "https://orchestrator.example", + addresses: tuple[str, ...] = ("93.184.216.34",), +) -> ValidatedLLMProviderBaseURL: + """Build one deterministic endpoint validation result.""" + return ValidatedLLMProviderBaseURL( + normalized_url=normalized_url, + hostname="orchestrator.example", + port=443, + addresses=addresses, + ) + + +async def _valid_endpoint( + _value: str | None, +) -> ValidatedLLMProviderBaseURL: + """Return one deterministic valid endpoint.""" + return _validated() + + +def _payload( + *, + mode: str = "route", + trace: object | None = None, +) -> dict[str, object]: + """Build one strict success response.""" + return { + "choices": [ + { + "message": { + "role": "assistant", + "content": '{"diagnostics":[]}', + } + } + ], + "orchestration": { + "mode": mode, + "trace": ( + [ + { + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + } + } + ] + if trace is None + else trace + ), + }, + } + + +def _builder(handler: Any): + """Build a redirect-disabled mock HTTP client factory.""" + transport = httpx.MockTransport(handler) + + def build( + _normalized_url: str, + _hostname: str, + _port: int, + _addresses: tuple[str, ...], + ) -> httpx.AsyncClient: + """Build one mock client.""" + return httpx.AsyncClient( + transport=transport, + follow_redirects=False, + trust_env=False, + ) + + return build + + +def _client(**overrides: Any) -> ContextualOrchestratorClient: + """Build a client with deterministic defaults.""" + values: dict[str, Any] = { + "base_url": "https://orchestrator.example", + "inference_credential": "tenant-secret-token", + "model_profile_id": "email-review-v1", + "endpoint_validator": _valid_endpoint, + "client_builder": _builder( + lambda _request: httpx.Response( + 200, + json=_payload(), + ) + ), + "max_retries": 0, + } + values.update(overrides) + return ContextualOrchestratorClient(**values) + + +def test_private_validation_helpers_cover_all_terminal_outcomes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise deterministic helpers without making a network request.""" + sentinel = cast(httpx.AsyncClient, object()) + + def pinned_builder( + normalized_url: str, + hostname: str, + port: int, + addresses: tuple[str, ...], + ) -> httpx.AsyncClient: + assert ( + normalized_url, + hostname, + port, + addresses, + ) == ( + "https://orchestrator.example", + "orchestrator.example", + 443, + ("93.184.216.34",), + ) + return sentinel + + monkeypatch.setattr( + client_module, + "build_pinned_https_async_client", + pinned_builder, + ) + assert ( + client_module._default_client_builder( + "https://orchestrator.example", + "orchestrator.example", + 443, + ("93.184.216.34",), + ) + is sentinel + ) + assert client_module._contains_surrogate("ordinary") is False + assert client_module._contains_surrogate("\ud800") is True + assert ( + client_module._bounded_secret( + " value ", + maximum=16, + code="invalid", + ) + == "value" + ) + + for value in ( + cast(Any, 7), + "", + "x" * 17, + "\ud800", + "line\nbreak", + "\x7f", + ): + with pytest.raises(ContextualOrchestratorError, match="invalid"): + client_module._bounded_secret( + value, + maximum=16, + code="invalid", + ) + + assert client_module._strict_object_pairs([("a", 1)]) == {"a": 1} + with pytest.raises(ValueError, match="duplicate_json_key"): + client_module._strict_object_pairs([("a", 1), ("a", 2)]) + + assert client_module._bounded_counter(0) == 0 + assert client_module._bounded_counter(2**53 - 1) == 2**53 - 1 + for value in (True, 1.0, -1, 2**53): + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_malformed_response", + ): + client_module._bounded_counter(value) + + client_module._validate_json_structure( + {"array": [1, "ordinary"], "flag": True} + ) + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_malformed_response", + ): + client_module._validate_json_structure({"bad": "\ud800"}) + + monkeypatch.setattr(client_module, "_MAX_JSON_NODES", 2) + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_malformed_response", + ): + client_module._validate_json_structure([1, 2, 3]) + + +@pytest.mark.parametrize( + "overrides", + ( + {"model_profile_id": "invalid profile"}, + {"max_retries": -1}, + {"max_retries": 6}, + {"max_response_bytes": 0}, + {"circuit_failure_threshold": 0}, + {"circuit_open_seconds": 0}, + ), +) +def test_constructor_rejects_invalid_configuration( + overrides: dict[str, object], +) -> None: + """Reject invalid client bounds before allocating transport resources.""" + with pytest.raises( + (ContextualOrchestratorError, ValueError), + ): + _client(**overrides) + + +@pytest.mark.asyncio +async def test_invalid_mode_and_unreachable_loop_are_fail_closed() -> None: + """Reject an unsupported mode and prove the loop terminal is guarded.""" + client = _client() + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_policy_rejected", + ): + await client.complete(_MESSAGES, mode=cast(Any, "auto")) + + client._max_retries = -1 + with pytest.raises(AssertionError, match="unreachable completion loop"): + await client.complete(_MESSAGES, mode="route") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error_type", + (httpx.ConnectTimeout, httpx.ConnectError), +) +async def test_transport_exceptions_retry_and_exhaust( + error_type: type[httpx.RequestError], +) -> None: + """Retry transient HTTPX failures and return one stable public code.""" + attempts = 0 + delays: list[float] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise error_type("private transport detail", request=request) + + async def sleeper(delay: float) -> None: + delays.append(delay) + + client = _client( + client_builder=_builder(handler), + sleeper=sleeper, + max_retries=1, + ) + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_unavailable", + ) as captured: + await client.complete(_MESSAGES, mode="route") + assert captured.value.transient is True + assert attempts == 2 + assert delays == [0.05] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("raised", (ValueError, OSError)) +async def test_endpoint_validation_errors_are_redacted( + raised: type[Exception], +) -> None: + """Normalize endpoint resolution failures to the policy code.""" + + async def validator( + _value: str | None, + ) -> ValidatedLLMProviderBaseURL: + raise raised("private resolver detail") + + client = _client(endpoint_validator=validator) + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_policy_rejected", + ): + await client._validated_endpoint() + + +@pytest.mark.asyncio +async def test_endpoint_validation_rejects_absent_invalid_and_empty_results() -> None: + """Accept only one bare HTTPS origin with at least one pinned address.""" + + async def absent( + _value: str | None, + ) -> ValidatedLLMProviderBaseURL | None: + return None + + client = _client(endpoint_validator=absent) + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_policy_rejected", + ): + await client._validated_endpoint() + + invalid_urls = ( + "http://orchestrator.example", + "https://user@orchestrator.example", + "https://user:pass@orchestrator.example", + "https://orchestrator.example/path", + "https://orchestrator.example?query=1", + "https://orchestrator.example#fragment", + "https:///", + ) + for invalid_url in invalid_urls: + + async def invalid( + _value: str | None, + *, + candidate: str = invalid_url, + ) -> ValidatedLLMProviderBaseURL: + return _validated(normalized_url=candidate) + + client = _client(endpoint_validator=invalid) + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_policy_rejected", + ): + await client._validated_endpoint() + + async def no_addresses( + _value: str | None, + ) -> ValidatedLLMProviderBaseURL: + return _validated(addresses=()) + + client = _client(endpoint_validator=no_addresses) + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_policy_rejected", + ): + await client._validated_endpoint() + + async def duplicates( + _value: str | None, + ) -> ValidatedLLMProviderBaseURL: + return _validated( + addresses=( + "93.184.216.35", + "93.184.216.34", + "93.184.216.34", + ) + ) + + client = _client(endpoint_validator=duplicates) + result = await client._validated_endpoint() + assert result.addresses == ("93.184.216.34", "93.184.216.35") + + +def test_message_validation_covers_all_rejection_branches() -> None: + """Reject malformed messages, hostile Unicode, and exceeded budgets.""" + client = _client() + invalid_inputs: tuple[Any, ...] = ( + "message", + b"message", + object(), + (), + tuple({"role": "user", "content": "x"} for _ in range(65)), + (1,), + ({"role": "owner", "content": "x"},), + ({"role": 1, "content": "x"},), + ({"role": "user", "content": 1},), + ({"role": "user", "content": "x" * 200_001},), + ({"role": "user", "content": "\ud800"},), + ( + {"role": "user", "content": "x"}, + {"role": "assistant", "content": "y", "extra": "z"}, + ), + tuple( + {"role": "user", "content": "x" * 200_000} + for _ in range(6) + ), + ) + for invalid in invalid_inputs: + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_policy_rejected", + ): + client._validate_messages(invalid) + + assert client._validate_messages( + ({"role": "tool", "content": ""},) + ) == [{"role": "tool", "content": ""}] + + +def test_strict_json_and_upstream_code_terminal_cases( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cover strict decoding, safe upstream code extraction, and node bounds.""" + client = _client() + for body in ( + b"\xff", + b'{"value": NaN}', + b"[]", + b'{"value":"\\ud800"}', + ): + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_malformed_response", + ): + client._strict_json(body) + + assert client._safe_upstream_error_code(b"not-json") is None + assert client._safe_upstream_error_code(b'{"error":"bad"}') is None + assert ( + client._safe_upstream_error_code( + b'{"error":{"code":7}}' + ) + is None + ) + too_long = json.dumps( + {"error": {"code": "x" * 129}} + ).encode() + assert client._safe_upstream_error_code(too_long) is None + assert ( + client._safe_upstream_error_code( + b'{"error":{"code":"stable"}}' + ) + == "stable" + ) + + monkeypatch.setattr(client_module, "_MAX_JSON_DEPTH", 1) + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_malformed_response", + ): + client._strict_json(b'{"nested":{"value":1}}') + + +@pytest.mark.parametrize( + "document", + ( + {"choices": None, "orchestration": {"mode": "route", "trace": []}}, + {"choices": [1], "orchestration": {"mode": "route", "trace": []}}, + {"choices": [{}], "orchestration": {"mode": "route", "trace": []}}, + { + "choices": [{"message": {"content": 7}}], + "orchestration": {"mode": "route", "trace": []}, + }, + { + "choices": [{"message": {"content": "\ud800"}}], + "orchestration": {"mode": "route", "trace": []}, + }, + { + "choices": [{"message": {"content": "ok"}}], + "orchestration": None, + }, + { + "choices": [{"message": {"content": "ok"}}], + "orchestration": {"mode": "route", "trace": "bad"}, + }, + { + "choices": [{"message": {"content": "ok"}}], + "orchestration": {"mode": "route", "trace": [1]}, + }, + { + "choices": [{"message": {"content": "ok"}}], + "orchestration": { + "mode": "route", + "trace": [{"usage": "bad"}], + }, + }, + ), +) +def test_completion_shape_rejections(document: dict[str, object]) -> None: + """Reject malformed nested response members before admitting evidence.""" + client = _client() + body = json.dumps(document, ensure_ascii=True).encode() + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_malformed_response", + ): + client._parse_completion(body) + + +def test_completion_accepts_an_empty_trace() -> None: + """Allow a strict completion that contains no orchestration steps.""" + client = _client() + completion = client._parse_completion( + json.dumps(_payload(trace=[])).encode() + ) + assert completion.trace == () + + +class _PortClient: + """Minimal candidate transport used to exercise port lifecycle branches.""" + + def __init__(self) -> None: + self.closed = False + + async def complete( + self, + _messages: object, + *, + mode: str, + ) -> ContextualOrchestratorCompletion: + """Return one strict completion.""" + return ContextualOrchestratorCompletion( + answer="ok", + mode=cast(Any, mode), + trace=(), + ) + + async def aclose(self) -> None: + """Record closure.""" + self.closed = True + + +@pytest.mark.parametrize("capacity", (0, 33)) +def test_port_rejects_invalid_worker_capacity(capacity: int) -> None: + """Reject worker counts outside the bounded production range.""" + with pytest.raises( + ValueError, + match="judge_capacity must be between 1 and 32", + ): + EmailWritingOrchestratorPort( + cast(Any, _PortClient()), + judge_capacity=capacity, + ) + + +@pytest.mark.asyncio +async def test_port_rejects_sync_use_on_event_loop_and_is_idempotent() -> None: + """Keep sync compatibility off the event loop and close only once.""" + transport = _PortClient() + port = EmailWritingOrchestratorPort(cast(Any, transport)) + with pytest.raises( + RuntimeError, + match="sync_completion_on_event_loop", + ): + port.complete(_MESSAGES, mode="route") + + await port.aclose() + await port.aclose() + assert transport.closed is True + + with pytest.raises(RuntimeError, match="orchestrator_port_closed"): + await port.complete_candidate(_MESSAGES, mode="route") + with pytest.raises(RuntimeError, match="orchestrator_port_closed"): + await asyncio.to_thread( + port.complete, + _MESSAGES, + mode="route", + ) + + +class _ScalarResult: + """Minimal scalar result for owner-scoped query tests.""" + + def __init__(self, value: Any) -> None: + self.value = value + + def scalar_one_or_none(self) -> Any: + """Return the stored scalar.""" + return self.value + + +class _Session: + """Record executed SQLAlchemy statements.""" + + def __init__(self, value: Any) -> None: + self.value = value + self.queries: list[Any] = [] + + async def execute(self, query: Any) -> _ScalarResult: + """Record one query and return the configured scalar.""" + self.queries.append(query) + return _ScalarResult(self.value) + + +@pytest.mark.asyncio +async def test_legacy_and_orchestrator_owner_scope_helpers() -> None: + """Cover both legacy and modular owner-scope helper branches.""" + with_organization = scope_module.tenant_config_owner_filters( + "user_alpha", + "organization_alpha", + ) + assert with_organization[1].right.value == "organization_alpha" + personal = scope_module.tenant_config_owner_filters( + "user_alpha", + None, + ) + assert personal[1].operator.__name__ == "is_" + + session = _Session(None) + assert ( + await scope_module.get_scoped_tenant_config( + cast(Any, session), + "user_alpha", + None, + ) + is None + ) + assert len(session.queries) == 1 + created = scope_module.new_scoped_tenant_config( + "user_alpha", + "organization_alpha", + ) + assert created.user_id == "user_alpha" + assert created.organization_id == "organization_alpha" + + orchestrator_personal = ( + scope_module.email_writing_orchestrator_owner_filters( + "user_alpha", + None, + ) + ) + assert orchestrator_personal[1].operator.__name__ == "is_" + assert scope_module._clean_orchestrator_value(None) is None + assert scope_module._clean_orchestrator_value(" value ") == "value" + assert scope_module._clean_orchestrator_value(" ") is None + + +def test_configuration_model_text_validation_is_bounded() -> None: + """Cover null, type, length, control, and valid normalization branches.""" + assert ( + config_api.EmailWritingOrchestratorConfigUpdate( + model_profile_id=None + ).model_profile_id + is None + ) + with pytest.raises(ValidationError): + config_api.EmailWritingOrchestratorConfigUpdate( + model_profile_id=cast(Any, 7) + ) + with pytest.raises(ValidationError): + config_api.EmailWritingOrchestratorConfigUpdate( + model_profile_id="x" * 256 + ) + with pytest.raises(ValidationError): + config_api.EmailWritingOrchestratorConfigUpdate( + model_profile_id="line\nbreak" + ) + assert ( + config_api.EmailWritingOrchestratorConfigUpdate( + model_profile_id=" profile " + ).model_profile_id + == "profile" + ) + + +@pytest.mark.asyncio +async def test_configuration_url_none_and_commit_failures_are_stable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Cover the explicit-null endpoint and both database error branches.""" + + async def absent( + _value: str | None, + ) -> ValidatedLLMProviderBaseURL | None: + return None + + monkeypatch.setattr( + config_api, + "validate_llm_provider_base_url_details_async", + absent, + ) + assert await config_api._validated_orchestrator_url(None) is None + + async def no_existing( + _session: Any, + _user_id: str, + _organization_id: str | None, + ) -> None: + return None + + monkeypatch.setattr( + config_api, + "get_scoped_email_writing_orchestrator_config", + no_existing, + ) + + class FailingDatabase: + """Fail commits with one configured exception.""" + + def __init__(self, error: Exception) -> None: + self.error = error + self.added: list[object] = [] + + def add(self, value: object) -> None: + """Record the pending configuration.""" + self.added.append(value) + + async def commit(self) -> None: + """Raise the configured persistence failure.""" + raise self.error + + auth = AuthContext( + user_id="user_alpha", + role="member", + organization_id="organization_alpha", + group_ids=(), + workspace_id="workspace_alpha", + ) + update = config_api.EmailWritingOrchestratorConfigUpdate( + orchestrator_enabled=False, + inference_credential="opaque-value", + ) + + with pytest.raises(HTTPException) as encrypted: + await config_api.update_email_writing_orchestrator_config( + update, + cast( + Any, + FailingDatabase( + RuntimeError( + "ENCRYPTION_KEY is required: private configuration" + ) + ), + ), + auth, + ) + assert encrypted.value.status_code == 503 + assert encrypted.value.detail == ( + "Server encryption key is not configured. " + "Contact your workspace administrator." + ) + + with pytest.raises(RuntimeError, match="database unavailable"): + await config_api.update_email_writing_orchestrator_config( + update, + cast( + Any, + FailingDatabase( + RuntimeError("database unavailable") + ), + ), + auth, + ) + + +def test_migration_executes_upgrade_and_downgrade( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Execute the real migration against SQLite without dropping other data.""" + backend_root = Path(__file__).resolve().parents[1] + migration_path = ( + backend_root + / "alembic" + / "versions" + / "20260813_0001_add_email_writing_orchestrator_config.py" + ) + module_name = "task5_email_writing_orchestrator_migration" + spec = importlib.util.spec_from_file_location( + module_name, + migration_path, + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + engine = create_engine("sqlite://") + with engine.begin() as connection: + connection.exec_driver_sql( + "CREATE TABLE unrelated_record " + "(unrelated_record_id INTEGER PRIMARY KEY)" + ) + monkeypatch.setattr(module.op, "get_bind", lambda: connection) + + module.upgrade() + module.upgrade() + inspector = inspect(connection) + assert inspector.has_table("email_writing_orchestrator_config") + assert inspector.has_table("unrelated_record") + + module.downgrade() + module.downgrade() + inspector = inspect(connection) + assert not inspector.has_table( + "email_writing_orchestrator_config" + ) + assert inspector.has_table("unrelated_record") From c1e109c9f059b59ac54f7cd5be1314a06c47504c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:45:48 +0900 Subject: [PATCH 31/46] ci(email-writing): execute Task 5 terminal coverage suite --- .github/workflows/email-writing-orchestrator-tdd.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/email-writing-orchestrator-tdd.yml b/.github/workflows/email-writing-orchestrator-tdd.yml index 76c2b8a51..f268cfb0d 100644 --- a/.github/workflows/email-writing-orchestrator-tdd.yml +++ b/.github/workflows/email-writing-orchestrator-tdd.yml @@ -63,7 +63,8 @@ jobs: tests/test_email_writing_orchestrator_module_boundary.py \ tests/test_email_writing_orchestrator_scope.py \ tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py + tests/test_email_writing_orchestrator_config_api.py \ + tests/test_email_writing_orchestrator_terminal_coverage.py - name: Verify Task 5 statement and branch coverage run: | cd backend @@ -76,7 +77,8 @@ jobs: tests/test_email_writing_orchestrator_module_boundary.py \ tests/test_email_writing_orchestrator_scope.py \ tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py + tests/test_email_writing_orchestrator_config_api.py \ + tests/test_email_writing_orchestrator_terminal_coverage.py python -m coverage report --show-missing --fail-under=100 \ api/email_writing_orchestrator_config.py \ db/email_writing_orchestrator_config.py \ @@ -130,7 +132,8 @@ jobs: tests/test_email_writing_orchestrator_module_boundary.py \ tests/test_email_writing_orchestrator_scope.py \ tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py + tests/test_email_writing_orchestrator_config_api.py \ + tests/test_email_writing_orchestrator_terminal_coverage.py - name: Compile Task 5 source run: | python -m compileall -q \ From f3e66b3d186274c1353f15c2cd6f78cc763b2757 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:48:02 +0900 Subject: [PATCH 32/46] ci(email-writing): repair terminal coverage resource cleanup --- ...ting-terminal-coverage-resource-repair.yml | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/email-writing-terminal-coverage-resource-repair.yml diff --git a/.github/workflows/email-writing-terminal-coverage-resource-repair.yml b/.github/workflows/email-writing-terminal-coverage-resource-repair.yml new file mode 100644 index 000000000..100ff13c1 --- /dev/null +++ b/.github/workflows/email-writing-terminal-coverage-resource-repair.yml @@ -0,0 +1,74 @@ +name: Email Writing Terminal Coverage Resource Repair + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: email-writing-terminal-coverage-resource-repair + cancel-in-progress: true + +jobs: + repair: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: true + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - name: Install hash-locked dependencies + run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Dispose the migration rehearsal engine + run: | + python - <<'PY' + from pathlib import Path + + path = Path( + "backend/tests/" + "test_email_writing_orchestrator_terminal_coverage.py" + ) + text = path.read_text(encoding="utf-8") + old = ''' assert not inspector.has_table( + "email_writing_orchestrator_config" + ) + assert inspector.has_table("unrelated_record") + ''' + new = old + " engine.dispose()\n" + if text.count(old) != 1: + raise SystemExit("migration cleanup anchor changed") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + PY + - name: Verify warning-free terminal suite + run: | + cd backend + python -m pytest -q \ + tests/test_email_writing_orchestrator_terminal_coverage.py + python -m ruff check \ + tests/test_email_writing_orchestrator_terminal_coverage.py + - name: Commit verified resource cleanup + run: | + set -euo pipefail + rm .github/workflows/email-writing-terminal-coverage-resource-repair.yml + git diff --check + git config user.name "CWL Email Writing Coverage Repair" + git config user.email "actions@users.noreply.github.com" + git add \ + .github/workflows/email-writing-terminal-coverage-resource-repair.yml \ + backend/tests/test_email_writing_orchestrator_terminal_coverage.py + git commit -m "test(email-writing): close migration rehearsal resources" + git push origin HEAD:feat/llm-email-writing-orchestrator-task5 From 48ee9bd27558cb9d5180a604e70c1bd8389a199d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:51:18 +0900 Subject: [PATCH 33/46] ci(email-writing): fix migration cleanup repair anchor --- ...l-writing-terminal-coverage-resource-repair.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/email-writing-terminal-coverage-resource-repair.yml b/.github/workflows/email-writing-terminal-coverage-resource-repair.yml index 100ff13c1..bbf7da35c 100644 --- a/.github/workflows/email-writing-terminal-coverage-resource-repair.yml +++ b/.github/workflows/email-writing-terminal-coverage-resource-repair.yml @@ -43,15 +43,13 @@ jobs: "test_email_writing_orchestrator_terminal_coverage.py" ) text = path.read_text(encoding="utf-8") - old = ''' assert not inspector.has_table( - "email_writing_orchestrator_config" + expected_tail = ' assert inspector.has_table("unrelated_record")\n' + if not text.endswith(expected_tail): + raise SystemExit("migration cleanup tail changed") + path.write_text( + text + " engine.dispose()\n", + encoding="utf-8", ) - assert inspector.has_table("unrelated_record") - ''' - new = old + " engine.dispose()\n" - if text.count(old) != 1: - raise SystemExit("migration cleanup anchor changed") - path.write_text(text.replace(old, new, 1), encoding="utf-8") PY - name: Verify warning-free terminal suite run: | From 9a8e0a9b4030d3e639f766c061bd1e0a5c7b2ddf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:53:32 +0900 Subject: [PATCH 34/46] ci(email-writing): remove terminal coverage dead import --- .../email-writing-terminal-coverage-resource-repair.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/email-writing-terminal-coverage-resource-repair.yml b/.github/workflows/email-writing-terminal-coverage-resource-repair.yml index bbf7da35c..ad5071b65 100644 --- a/.github/workflows/email-writing-terminal-coverage-resource-repair.yml +++ b/.github/workflows/email-writing-terminal-coverage-resource-repair.yml @@ -33,7 +33,7 @@ jobs: cache-dependency-path: backend/requirements-hashes.txt - name: Install hash-locked dependencies run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Dispose the migration rehearsal engine + - name: Repair migration rehearsal resource ownership run: | python - <<'PY' from pathlib import Path @@ -43,6 +43,13 @@ jobs: "test_email_writing_orchestrator_terminal_coverage.py" ) text = path.read_text(encoding="utf-8") + unused_import = ( + "from db.email_writing_orchestrator_config import " + "EmailWritingOrchestratorConfig\n" + ) + if text.count(unused_import) != 1: + raise SystemExit("terminal coverage import anchor changed") + text = text.replace(unused_import, "", 1) expected_tail = ' assert inspector.has_table("unrelated_record")\n' if not text.endswith(expected_tail): raise SystemExit("migration cleanup tail changed") From 85b989989278c49748bb699e98be229f9d24a04e Mon Sep 17 00:00:00 2001 From: CWL Email Writing Coverage Repair Date: Sat, 15 Aug 2026 10:54:21 +0000 Subject: [PATCH 35/46] test(email-writing): close migration rehearsal resources --- ...ting-terminal-coverage-resource-repair.yml | 79 ------------------- ..._writing_orchestrator_terminal_coverage.py | 2 +- 2 files changed, 1 insertion(+), 80 deletions(-) delete mode 100644 .github/workflows/email-writing-terminal-coverage-resource-repair.yml diff --git a/.github/workflows/email-writing-terminal-coverage-resource-repair.yml b/.github/workflows/email-writing-terminal-coverage-resource-repair.yml deleted file mode 100644 index ad5071b65..000000000 --- a/.github/workflows/email-writing-terminal-coverage-resource-repair.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Email Writing Terminal Coverage Resource Repair - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: email-writing-terminal-coverage-resource-repair - cancel-in-progress: true - -jobs: - repair: - runs-on: ubuntu-24.04 - timeout-minutes: 15 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - PYTHONWARNINGS: error - DISABLE_BACKGROUND_WORKERS: "1" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: true - fetch-depth: 0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: backend/requirements-hashes.txt - - name: Install hash-locked dependencies - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Repair migration rehearsal resource ownership - run: | - python - <<'PY' - from pathlib import Path - - path = Path( - "backend/tests/" - "test_email_writing_orchestrator_terminal_coverage.py" - ) - text = path.read_text(encoding="utf-8") - unused_import = ( - "from db.email_writing_orchestrator_config import " - "EmailWritingOrchestratorConfig\n" - ) - if text.count(unused_import) != 1: - raise SystemExit("terminal coverage import anchor changed") - text = text.replace(unused_import, "", 1) - expected_tail = ' assert inspector.has_table("unrelated_record")\n' - if not text.endswith(expected_tail): - raise SystemExit("migration cleanup tail changed") - path.write_text( - text + " engine.dispose()\n", - encoding="utf-8", - ) - PY - - name: Verify warning-free terminal suite - run: | - cd backend - python -m pytest -q \ - tests/test_email_writing_orchestrator_terminal_coverage.py - python -m ruff check \ - tests/test_email_writing_orchestrator_terminal_coverage.py - - name: Commit verified resource cleanup - run: | - set -euo pipefail - rm .github/workflows/email-writing-terminal-coverage-resource-repair.yml - git diff --check - git config user.name "CWL Email Writing Coverage Repair" - git config user.email "actions@users.noreply.github.com" - git add \ - .github/workflows/email-writing-terminal-coverage-resource-repair.yml \ - backend/tests/test_email_writing_orchestrator_terminal_coverage.py - git commit -m "test(email-writing): close migration rehearsal resources" - git push origin HEAD:feat/llm-email-writing-orchestrator-task5 diff --git a/backend/tests/test_email_writing_orchestrator_terminal_coverage.py b/backend/tests/test_email_writing_orchestrator_terminal_coverage.py index 82f48ab7b..48fa6cc50 100644 --- a/backend/tests/test_email_writing_orchestrator_terminal_coverage.py +++ b/backend/tests/test_email_writing_orchestrator_terminal_coverage.py @@ -17,7 +17,6 @@ from api.auth import AuthContext from api import email_writing_orchestrator_config as config_api -from db.email_writing_orchestrator_config import EmailWritingOrchestratorConfig from services import contextual_orchestrator_client as client_module from services.contextual_orchestrator_client import ( ContextualOrchestratorClient, @@ -809,3 +808,4 @@ def test_migration_executes_upgrade_and_downgrade( "email_writing_orchestrator_config" ) assert inspector.has_table("unrelated_record") + engine.dispose() From d4d3eb06afe0e9748ee9a2cf06d2dd1169cb1072 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 20:58:44 +0900 Subject: [PATCH 36/46] ci(email-writing): refresh Task 5 on current context parent --- ...resh-email-writing-orchestrator-parent.yml | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/refresh-email-writing-orchestrator-parent.yml diff --git a/.github/workflows/refresh-email-writing-orchestrator-parent.yml b/.github/workflows/refresh-email-writing-orchestrator-parent.yml new file mode 100644 index 000000000..07b644c90 --- /dev/null +++ b/.github/workflows/refresh-email-writing-orchestrator-parent.yml @@ -0,0 +1,84 @@ +name: Refresh Email Writing Orchestrator Parent + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: refresh-email-writing-orchestrator-parent + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + +jobs: + refresh: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: feat/llm-email-writing-orchestrator-task5 + persist-credentials: true + fetch-depth: 0 + - name: Merge the current Task 4 parent + run: | + set -euo pipefail + git fetch origin feat/llm-email-writing-context-task4 + git config user.name "CWL Email Writing Task 5 Refresh" + git config user.email "actions@users.noreply.github.com" + git merge --no-edit --no-ff origin/feat/llm-email-writing-context-task4 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - name: Install hash-locked backend dependencies + run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Verify orchestrator boundary contracts + run: | + cd backend + python -m pytest -q \ + tests/test_contextual_orchestrator_client.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py \ + tests/test_email_writing_orchestrator_terminal_coverage.py + python -m ruff check \ + api/email_writing_orchestrator_config.py \ + db/email_writing_orchestrator_config.py \ + services/contextual_orchestrator_client.py \ + services/email_writing_orchestrator_port.py \ + services/tenant_config_scope.py \ + alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py \ + tests/test_contextual_orchestrator_client.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py \ + tests/test_email_writing_orchestrator_terminal_coverage.py + python -m compileall -q \ + api/email_writing_orchestrator_config.py \ + db/email_writing_orchestrator_config.py \ + services/contextual_orchestrator_client.py \ + services/email_writing_orchestrator_port.py \ + services/tenant_config_scope.py \ + alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py + - name: Remove one-shot workflow and publish refresh + run: | + set -euo pipefail + rm .github/workflows/refresh-email-writing-orchestrator-parent.yml + git diff --check + git add .github/workflows/refresh-email-writing-orchestrator-parent.yml + git commit -m "ci(email-writing): remove Task 5 parent refresh workflow" + git push origin HEAD:feat/llm-email-writing-orchestrator-task5 From 9a48fb3226026c3a605257e32f8427baffa9aae4 Mon Sep 17 00:00:00 2001 From: CWL Email Writing Task 5 Refresh Date: Sat, 15 Aug 2026 11:59:39 +0000 Subject: [PATCH 37/46] ci(email-writing): remove Task 5 parent refresh workflow --- ...resh-email-writing-orchestrator-parent.yml | 84 ------------------- 1 file changed, 84 deletions(-) delete mode 100644 .github/workflows/refresh-email-writing-orchestrator-parent.yml diff --git a/.github/workflows/refresh-email-writing-orchestrator-parent.yml b/.github/workflows/refresh-email-writing-orchestrator-parent.yml deleted file mode 100644 index 07b644c90..000000000 --- a/.github/workflows/refresh-email-writing-orchestrator-parent.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Refresh Email Writing Orchestrator Parent - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: refresh-email-writing-orchestrator-parent - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - PYTHONWARNINGS: error - DISABLE_BACKGROUND_WORKERS: "1" - -jobs: - refresh: - runs-on: ubuntu-24.04 - timeout-minutes: 25 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: feat/llm-email-writing-orchestrator-task5 - persist-credentials: true - fetch-depth: 0 - - name: Merge the current Task 4 parent - run: | - set -euo pipefail - git fetch origin feat/llm-email-writing-context-task4 - git config user.name "CWL Email Writing Task 5 Refresh" - git config user.email "actions@users.noreply.github.com" - git merge --no-edit --no-ff origin/feat/llm-email-writing-context-task4 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: backend/requirements-hashes.txt - - name: Install hash-locked backend dependencies - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Verify orchestrator boundary contracts - run: | - cd backend - python -m pytest -q \ - tests/test_contextual_orchestrator_client.py \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_email_writing_orchestrator_module_boundary.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py \ - tests/test_email_writing_orchestrator_terminal_coverage.py - python -m ruff check \ - api/email_writing_orchestrator_config.py \ - db/email_writing_orchestrator_config.py \ - services/contextual_orchestrator_client.py \ - services/email_writing_orchestrator_port.py \ - services/tenant_config_scope.py \ - alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py \ - tests/test_contextual_orchestrator_client.py \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_email_writing_orchestrator_module_boundary.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py \ - tests/test_email_writing_orchestrator_terminal_coverage.py - python -m compileall -q \ - api/email_writing_orchestrator_config.py \ - db/email_writing_orchestrator_config.py \ - services/contextual_orchestrator_client.py \ - services/email_writing_orchestrator_port.py \ - services/tenant_config_scope.py \ - alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py - - name: Remove one-shot workflow and publish refresh - run: | - set -euo pipefail - rm .github/workflows/refresh-email-writing-orchestrator-parent.yml - git diff --check - git add .github/workflows/refresh-email-writing-orchestrator-parent.yml - git commit -m "ci(email-writing): remove Task 5 parent refresh workflow" - git push origin HEAD:feat/llm-email-writing-orchestrator-task5 From 819c00050fc82617454dea5781f939a15158f071 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:23:09 +0900 Subject: [PATCH 38/46] ci(email-writing): finalize Task 5 quality and merge readiness --- .../email-writing-task5-finalize.yml | 406 ++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 .github/workflows/email-writing-task5-finalize.yml diff --git a/.github/workflows/email-writing-task5-finalize.yml b/.github/workflows/email-writing-task5-finalize.yml new file mode 100644 index 000000000..244659ca5 --- /dev/null +++ b/.github/workflows/email-writing-task5-finalize.yml @@ -0,0 +1,406 @@ +name: Email Writing Task 5 Finalize + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + checks: read + +concurrency: + group: email-writing-task5-finalize + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PYTHONWARNINGS: error + DISABLE_BACKGROUND_WORKERS: "1" + +jobs: + finalize: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true + fetch-depth: 0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: backend/requirements-hashes.txt + - name: Install hash-locked application dependencies + run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt + - name: Install hash-verified coverage tool + run: | + set -euo pipefail + mkdir -p /tmp/coverage-wheel + cat >/tmp/coverage-lock.txt <<'EOF' + coverage==7.15.2 --hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c + EOF + python -m pip download \ + --disable-pip-version-check \ + --require-hashes \ + --no-deps \ + --only-binary=:all: \ + --platform any \ + --python-version 3.14 \ + --implementation py \ + --abi none \ + --dest /tmp/coverage-wheel \ + -r /tmp/coverage-lock.txt + python -m pip install --disable-pip-version-check --no-deps \ + /tmp/coverage-wheel/coverage-7.15.2-py3-none-any.whl + - name: Verify and close any terminal coverage gaps + run: | + set -euo pipefail + + run_gate() { + cd backend + python -m pytest -q \ + tests/test_contextual_orchestrator_client.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py \ + tests/test_email_writing_orchestrator_terminal_coverage.py + python -m coverage erase + python -m coverage run --branch \ + --include='api/email_writing_orchestrator_config.py,db/email_writing_orchestrator_config.py,services/contextual_orchestrator_client.py,services/email_writing_orchestrator_port.py,services/tenant_config_scope.py,alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py' \ + -m pytest -q \ + tests/test_contextual_orchestrator_client.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py \ + tests/test_email_writing_orchestrator_terminal_coverage.py + python -m coverage report --show-missing --fail-under=100 \ + api/email_writing_orchestrator_config.py \ + db/email_writing_orchestrator_config.py \ + services/contextual_orchestrator_client.py \ + services/email_writing_orchestrator_port.py \ + services/tenant_config_scope.py \ + alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py + python -m ruff check \ + api/email_writing_orchestrator_config.py \ + db/email_writing_orchestrator_config.py \ + services/contextual_orchestrator_client.py \ + services/email_writing_orchestrator_port.py \ + services/tenant_config_scope.py \ + alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py \ + tests/test_contextual_orchestrator_client.py \ + tests/test_contextual_orchestrator_hardening.py \ + tests/test_email_writing_orchestrator_module_boundary.py \ + tests/test_email_writing_orchestrator_scope.py \ + tests/test_email_writing_orchestrator_migration.py \ + tests/test_email_writing_orchestrator_config_api.py \ + tests/test_email_writing_orchestrator_terminal_coverage.py + cd .. + } + + if run_gate; then + exit 0 + fi + + python - <<'PY' + from pathlib import Path + + path = Path( + "backend/tests/" + "test_email_writing_orchestrator_terminal_coverage.py" + ) + text = path.read_text(encoding="utf-8") + marker = "# Task 5 final branch coverage extensions" + if marker in text: + raise SystemExit("quality gate still fails after terminal extensions") + text += r''' + +# Task 5 final branch coverage extensions + + +@pytest.mark.asyncio +async def test_revalidated_endpoint_accepts_an_identical_dns_fingerprint() -> None: + """Permit repeated validation only when the pinned endpoint is unchanged.""" + client = _client() + first = await client._validated_endpoint() + second = await client._validated_endpoint() + assert first == second + await client.aclose() + + +def test_http_error_covers_request_timeout_and_generic_service_failure() -> None: + """Map both non-5xx retry status and generic 503 failure branches.""" + client = _client() + request_timeout = client._http_error(408, b"{}") + generic_service_failure = client._http_error(503, b"not-json") + assert request_timeout.code == "orchestrator_unavailable" + assert request_timeout.transient is True + assert generic_service_failure.code == "orchestrator_unavailable" + assert generic_service_failure.transient is True + + +def test_optional_configuration_text_covers_blank_and_delete_character() -> None: + """Normalize blank optional text and reject the DEL control character.""" + blank = config_api.EmailWritingOrchestratorConfigUpdate( + model_profile_id=" " + ) + assert blank.model_profile_id is None + with pytest.raises(ValidationError): + config_api.EmailWritingOrchestratorConfigUpdate( + model_profile_id="bad\x7fvalue" + ) + + +@pytest.mark.asyncio +async def test_scope_resolution_covers_absent_disabled_and_each_missing_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Resolve only a complete enabled owner-scoped configuration.""" + selected: list[object | None] = [None] + + async def getter( + _session: Any, + _user_id: str, + _organization_id: str | None, + ) -> object | None: + return selected[0] + + monkeypatch.setattr( + scope_module, + "get_scoped_email_writing_orchestrator_config", + getter, + ) + assert ( + await scope_module.resolve_email_writing_orchestrator_settings( + cast(Any, object()), + user_id="user_alpha", + organization_id="organization_alpha", + ) + is None + ) + + config = scope_module.new_scoped_email_writing_orchestrator_config( + "user_alpha", + "organization_alpha", + ) + selected[0] = config + assert ( + await scope_module.resolve_email_writing_orchestrator_settings( + cast(Any, object()), + user_id="user_alpha", + organization_id="organization_alpha", + ) + is None + ) + + config.orchestrator_enabled = True + for values in ( + (None, "profile", "credential"), + ("https://orchestrator.example", None, "credential"), + ("https://orchestrator.example", "profile", None), + ): + ( + config.orchestrator_base_url, + config.model_profile_id, + config.inference_credential, + ) = values + with pytest.raises( + scope_module.EmailWritingOrchestratorConfigurationError, + match="email_writing_orchestrator_incomplete", + ): + await scope_module.resolve_email_writing_orchestrator_settings( + cast(Any, object()), + user_id="user_alpha", + organization_id="organization_alpha", + ) + + config.orchestrator_base_url = " https://orchestrator.example " + config.model_profile_id = " profile " + config.inference_credential = " credential " + settings = await scope_module.resolve_email_writing_orchestrator_settings( + cast(Any, object()), + user_id="user_alpha", + organization_id="organization_alpha", + ) + assert settings == scope_module.EmailWritingOrchestratorSettings( + base_url="https://orchestrator.example", + model_profile_id="profile", + inference_credential="credential", + ) + + +def test_trace_budget_accepts_boundary_and_rejects_excess( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Exercise both sides of the orchestration trace cardinality bound.""" + client = _client() + step = { + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + } + } + monkeypatch.setattr(client_module, "_MAX_TRACE_STEPS", 1) + accepted = client._parse_completion( + json.dumps(_payload(trace=[step])).encode() + ) + assert len(accepted.trace) == 1 + with pytest.raises( + ContextualOrchestratorError, + match="orchestrator_malformed_response", + ): + client._parse_completion( + json.dumps(_payload(trace=[step, step])).encode() + ) +''' + path.write_text(text, encoding="utf-8") + PY + + run_gate + - name: Verify production docstrings + run: | + cd backend + python - <<'PY' + import ast + from pathlib import Path + + paths = [ + Path("api/email_writing_orchestrator_config.py"), + Path("db/email_writing_orchestrator_config.py"), + Path("services/contextual_orchestrator_client.py"), + Path("services/email_writing_orchestrator_port.py"), + Path("services/tenant_config_scope.py"), + Path( + "alembic/versions/" + "20260813_0001_add_email_writing_orchestrator_config.py" + ), + ] + missing: list[str] = [] + for path in paths: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if ast.get_docstring(tree) is None: + missing.append(f"{path}:") + for node in ast.walk(tree): + if isinstance( + node, + (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef), + ) and ast.get_docstring(node) is None: + missing.append(f"{path}:{node.lineno}:{node.name}") + if missing: + raise SystemExit("Missing production docstrings:\n" + "\n".join(missing)) + PY + - name: Resolve duplicate PRs and enable automatic merge + id: pr + run: | + set -euo pipefail + repository="${GITHUB_REPOSITORY}" + branch="${GITHUB_REF_NAME}" + owner="${repository%%/*}" + task4_branch="feat/llm-email-writing-context-task4" + default_branch="$(gh repo view "$repository" --json defaultBranchRef --jq '.defaultBranchRef.name')" + preferred_base="$default_branch" + + task4_count="$( + gh api \ + "repos/${repository}/pulls?state=open&head=${owner}:${task4_branch}&per_page=100" \ + --jq 'length' + )" + if [[ "$task4_count" -gt 0 ]]; then + preferred_base="$task4_branch" + elif git show-ref --verify --quiet refs/remotes/origin/develop \ + && git merge-base --is-ancestor origin/develop HEAD; then + preferred_base="develop" + fi + + mapfile -t pull_numbers < <( + gh api \ + "repos/${repository}/pulls?state=open&head=${owner}:${branch}&per_page=100" \ + --jq '.[].number' + ) + if [[ "${#pull_numbers[@]}" -eq 0 ]]; then + gh pr create \ + --repo "$repository" \ + --base "$preferred_base" \ + --head "$branch" \ + --title "feat(email-writing): add secure contextual-orchestrator tenant boundary" \ + --body-file - <<'EOF' + ## Product outcome + + Adds the production boundary that lets Naruon use contextual-orchestrator for email-writing candidate generation and independent judging without binding Naruon to one upstream model or leaking tenant credentials, provider details, prompts, or orchestration internals. + + ## Shipped scope + + - owner- and organization-scoped encrypted orchestration configuration + - dedicated modular FastAPI configuration router + - canonical HTTPS-origin validation, DNS pinning, redirect rejection, and rebinding protection + - bounded request, response, JSON, and trace work + - stable redacted errors, retry policy, and circuit breaker + - async candidate path plus bounded synchronous Judge lane + - migration, exact-scope persistence, docstring, lint, compile, and 100% coverage gates + - APA 7 doctoring and calibrated LLM-as-a-Judge claim boundaries + + ## Architecture boundary + + The API is isolated from legacy tenant/mailbox settings so it can later be extracted as an MSA module while Naruon remains independently operable. + EOF + mapfile -t pull_numbers < <( + gh api \ + "repos/${repository}/pulls?state=open&head=${owner}:${branch}&per_page=100" \ + --jq '.[].number' + ) + fi + + preferred_number="" + for number in "${pull_numbers[@]}"; do + base="$(gh pr view "$number" --repo "$repository" --json baseRefName --jq '.baseRefName')" + if [[ "$base" == "$preferred_base" && -z "$preferred_number" ]]; then + preferred_number="$number" + fi + done + if [[ -z "$preferred_number" ]]; then + preferred_number="${pull_numbers[0]}" + gh pr edit "$preferred_number" --repo "$repository" --base "$preferred_base" + fi + + for number in "${pull_numbers[@]}"; do + if [[ "$number" != "$preferred_number" ]]; then + gh pr close "$number" \ + --repo "$repository" \ + --comment "Superseded by #${preferred_number}; retaining one review and merge lineage for this head branch." + fi + done + + if [[ "$(gh pr view "$preferred_number" --repo "$repository" --json isDraft --jq '.isDraft')" == "true" ]]; then + gh pr ready "$preferred_number" --repo "$repository" + fi + echo "number=$preferred_number" >> "$GITHUB_OUTPUT" + gh pr merge "$preferred_number" \ + --repo "$repository" \ + --auto \ + --squash \ + --delete-branch || true + - name: Remove finalizer and publish verified branch + run: | + set -euo pipefail + rm .github/workflows/email-writing-task5-finalize.yml + git diff --check + if git diff --quiet; then + exit 0 + fi + git config user.name "CWL Email Writing Finalizer" + git config user.email "actions@users.noreply.github.com" + git add \ + .github/workflows/email-writing-task5-finalize.yml \ + backend/tests/test_email_writing_orchestrator_terminal_coverage.py + git commit -m "test(email-writing): enforce complete Task 5 coverage" + git push origin HEAD:feat/llm-email-writing-orchestrator-task5 From 3b3ff4c4adb3575efd5bd766588686e0bff22bcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:02:12 +0900 Subject: [PATCH 39/46] ci(email-writing): automate verified Task 5 promotion --- .../email-writing-orchestrator-promotion.yml | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .github/workflows/email-writing-orchestrator-promotion.yml diff --git a/.github/workflows/email-writing-orchestrator-promotion.yml b/.github/workflows/email-writing-orchestrator-promotion.yml new file mode 100644 index 000000000..5655957ce --- /dev/null +++ b/.github/workflows/email-writing-orchestrator-promotion.yml @@ -0,0 +1,165 @@ +name: Email Writing Orchestrator Promotion + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + actions: read + checks: read + contents: write + pull-requests: write + +concurrency: + group: email-writing-orchestrator-promotion + cancel-in-progress: true + +jobs: + promote: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GH_TOKEN: ${{ github.token }} + TARGET_BRANCH: feat/llm-email-writing-context-task4 + steps: + - name: Locate or create the staged Task 5 pull request + id: pull-request + shell: bash + run: | + set -euo pipefail + pull_number="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --head "$GITHUB_REF_NAME" \ + --base "$TARGET_BRANCH" \ + --json number \ + --jq '.[0].number // empty' + )" + if [[ -z "$pull_number" ]]; then + pull_url="$( + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base "$TARGET_BRANCH" \ + --head "$GITHUB_REF_NAME" \ + --title 'feat(email-writing): add contextual orchestration boundary' \ + --body-file - <<'EOF' + ## Product outcome + + Adds a tenant-scoped, provider-neutral contextual-orchestrator boundary for email-writing review, including a dedicated configuration API, privacy-minimized evidence, bounded Judge concurrency, strict response validation, and fail-closed transport behavior. + + ## Quality evidence + + - Task-specific contracts and adversarial hardening regressions + - exact statement and branch coverage gate for shipped Task 5 modules + - complete shipped docstring gate + - Ruff, compile, migration upgrade/downgrade, and unrelated-data preservation checks + - APA 7 doctoring for measurement, multilingual validity, privacy, risk, and governance boundaries + EOF + )" + pull_number="${pull_url##*/}" + fi + printf 'pull_number=%s\n' "$pull_number" >> "$GITHUB_OUTPUT" + + - name: Wait for required checks to become terminal + id: checks + shell: bash + env: + PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} + run: | + set -euo pipefail + stable_success_polls=0 + for _attempt in $(seq 1 120); do + required_json="$( + gh pr checks "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --required \ + --json name,state,bucket 2>/dev/null || printf '[]' + )" + failure_count="$( + jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' \ + <<<"$required_json" + )" + pending_count="$( + jq '[.[] | select(.bucket == "pending")] | length' \ + <<<"$required_json" + )" + required_count="$(jq 'length' <<<"$required_json")" + + if [[ "$failure_count" -gt 0 ]]; then + jq -r '.[] | select(.bucket == "fail" or .bucket == "cancel") | "\(.name): \(.state)"' \ + <<<"$required_json" + exit 1 + fi + if [[ "$required_count" -gt 0 && "$pending_count" -eq 0 ]]; then + stable_success_polls=$((stable_success_polls + 1)) + if [[ "$stable_success_polls" -ge 2 ]]; then + printf 'required_count=%s\n' "$required_count" >> "$GITHUB_OUTPUT" + exit 0 + fi + else + stable_success_polls=0 + fi + sleep 15 + done + echo 'Required checks did not settle within the bounded promotion window.' >&2 + exit 1 + + - name: Reject unresolved review findings + shell: bash + env: + PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} + run: | + set -euo pipefail + repository_owner="${GITHUB_REPOSITORY%%/*}" + repository_name="${GITHUB_REPOSITORY##*/}" + unresolved_count="$( + gh api graphql \ + -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved}}}}}' \ + -F owner="$repository_owner" \ + -F name="$repository_name" \ + -F number="$PULL_NUMBER" \ + --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' + )" + if [[ "$unresolved_count" -ne 0 ]]; then + echo "$unresolved_count unresolved review thread(s) remain." >&2 + exit 1 + fi + review_decision="$( + gh pr view "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json reviewDecision \ + --jq '.reviewDecision // ""' + )" + if [[ "$review_decision" == 'CHANGES_REQUESTED' ]]; then + echo 'A current review requests changes.' >&2 + exit 1 + fi + + - name: Merge or arm policy-compliant auto-merge + shell: bash + env: + PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} + run: | + set -euo pipefail + merge_state="$( + gh pr view "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json mergeStateStatus \ + --jq '.mergeStateStatus' + )" + if [[ "$merge_state" == 'CLEAN' || "$merge_state" == 'UNSTABLE' ]]; then + gh pr merge "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --squash \ + --delete-branch + exit 0 + fi + gh pr merge "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --auto \ + --squash \ + --delete-branch From f6a137229d461ccaeb33d98934dcf8298cbe485c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 22:03:46 +0900 Subject: [PATCH 40/46] ci(email-writing): harden Task 5 promotion polling --- ...mail-writing-orchestrator-promotion-v2.yml | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 .github/workflows/email-writing-orchestrator-promotion-v2.yml diff --git a/.github/workflows/email-writing-orchestrator-promotion-v2.yml b/.github/workflows/email-writing-orchestrator-promotion-v2.yml new file mode 100644 index 000000000..fc77802b3 --- /dev/null +++ b/.github/workflows/email-writing-orchestrator-promotion-v2.yml @@ -0,0 +1,188 @@ +name: Email Writing Orchestrator Promotion V2 + +on: + push: + branches: + - feat/llm-email-writing-orchestrator-task5 + workflow_dispatch: + +permissions: + actions: read + checks: read + contents: write + pull-requests: write + +concurrency: + group: email-writing-orchestrator-promotion-v2 + cancel-in-progress: true + +jobs: + promote-task5: + runs-on: ubuntu-24.04 + timeout-minutes: 50 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + GH_TOKEN: ${{ github.token }} + TARGET_BRANCH: feat/llm-email-writing-context-task4 + steps: + - name: Locate or create the staged pull request + id: pull-request + shell: bash + run: | + set -euo pipefail + pull_number="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --head "$GITHUB_REF_NAME" \ + --base "$TARGET_BRANCH" \ + --json number \ + --jq '.[0].number // empty' + )" + if [[ -z "$pull_number" ]]; then + pull_url="$( + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base "$TARGET_BRANCH" \ + --head "$GITHUB_REF_NAME" \ + --title 'feat(email-writing): add contextual orchestration boundary' \ + --body-file - <<'EOF' + ## Product outcome + + Adds a tenant-scoped, provider-neutral contextual-orchestrator boundary for email-writing review, including a dedicated configuration API, privacy-minimized evidence, bounded Judge concurrency, strict response validation, and fail-closed transport behavior. + + ## Quality evidence + + - Task-specific contracts and adversarial hardening regressions + - exact statement and branch coverage gate for shipped Task 5 modules + - complete shipped docstring gate + - Ruff, compile, migration upgrade/downgrade, and unrelated-data preservation checks + - APA 7 doctoring for measurement, multilingual validity, privacy, risk, and governance boundaries + EOF + )" + pull_number="${pull_url##*/}" + fi + printf 'pull_number=%s\n' "$pull_number" >> "$GITHUB_OUTPUT" + + - name: Wait for all non-promotion checks to settle + id: checks + shell: bash + env: + PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} + run: | + set -euo pipefail + stable_success_polls=0 + previous_signature='' + for _attempt in $(seq 1 180); do + head_sha="$( + gh pr view "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json headRefOid \ + --jq '.headRefOid' + )" + check_runs="$( + gh api \ + -H 'Accept: application/vnd.github+json' \ + "/repos/$GITHUB_REPOSITORY/commits/$head_sha/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name != "promote-task5" and .name != "promote") | {name, status, conclusion}]' + )" + failure_count="$( + jq '[.[] | select(.status == "completed" and (.conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped"))] | length' \ + <<<"$check_runs" + )" + pending_count="$( + jq '[.[] | select(.status != "completed")] | length' \ + <<<"$check_runs" + )" + check_count="$(jq 'length' <<<"$check_runs")" + signature="$( + jq -S -c 'sort_by(.name) | map({name, status, conclusion})' \ + <<<"$check_runs" + )" + + required_json="$( + gh pr checks "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --required \ + --json name,state,bucket 2>/dev/null || printf '[]' + )" + required_failures="$( + jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' \ + <<<"$required_json" + )" + required_pending="$( + jq '[.[] | select(.bucket == "pending")] | length' \ + <<<"$required_json" + )" + + if [[ "$failure_count" -gt 0 || "$required_failures" -gt 0 ]]; then + jq -r '.[] | select(.status == "completed" and (.conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped")) | "\(.name): \(.conclusion)"' \ + <<<"$check_runs" + jq -r '.[] | select(.bucket == "fail" or .bucket == "cancel") | "required \(.name): \(.state)"' \ + <<<"$required_json" + exit 1 + fi + + if [[ "$check_count" -gt 0 && "$pending_count" -eq 0 && "$required_pending" -eq 0 && "$signature" == "$previous_signature" ]]; then + stable_success_polls=$((stable_success_polls + 1)) + if [[ "$stable_success_polls" -ge 2 ]]; then + printf 'head_sha=%s\n' "$head_sha" >> "$GITHUB_OUTPUT" + exit 0 + fi + else + stable_success_polls=0 + fi + previous_signature="$signature" + sleep 15 + done + echo 'Checks did not settle within the bounded promotion window.' >&2 + exit 1 + + - name: Reject unresolved review findings + shell: bash + env: + PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} + run: | + set -euo pipefail + repository_owner="${GITHUB_REPOSITORY%%/*}" + repository_name="${GITHUB_REPOSITORY##*/}" + unresolved_count="$( + gh api graphql \ + -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved}}}}}' \ + -F owner="$repository_owner" \ + -F name="$repository_name" \ + -F number="$PULL_NUMBER" \ + --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' + )" + if [[ "$unresolved_count" -ne 0 ]]; then + echo "$unresolved_count unresolved review thread(s) remain." >&2 + exit 1 + fi + review_decision="$( + gh pr view "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --json reviewDecision \ + --jq '.reviewDecision // ""' + )" + if [[ "$review_decision" == 'CHANGES_REQUESTED' ]]; then + echo 'A current review requests changes.' >&2 + exit 1 + fi + + - name: Merge or arm policy-compliant auto-merge + shell: bash + env: + PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} + run: | + set -euo pipefail + if gh pr merge "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --squash \ + --delete-branch; then + exit 0 + fi + gh pr merge "$PULL_NUMBER" \ + --repo "$GITHUB_REPOSITORY" \ + --auto \ + --squash \ + --delete-branch From 34f368cd4a19834f21c0b3cb61fb2572dc77e593 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:53:07 +0900 Subject: [PATCH 41/46] ci(email-writing): remove branch-writing promotion workflow --- .../email-writing-orchestrator-promotion.yml | 165 ------------------ 1 file changed, 165 deletions(-) delete mode 100644 .github/workflows/email-writing-orchestrator-promotion.yml diff --git a/.github/workflows/email-writing-orchestrator-promotion.yml b/.github/workflows/email-writing-orchestrator-promotion.yml deleted file mode 100644 index 5655957ce..000000000 --- a/.github/workflows/email-writing-orchestrator-promotion.yml +++ /dev/null @@ -1,165 +0,0 @@ -name: Email Writing Orchestrator Promotion - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - actions: read - checks: read - contents: write - pull-requests: write - -concurrency: - group: email-writing-orchestrator-promotion - cancel-in-progress: true - -jobs: - promote: - runs-on: ubuntu-24.04 - timeout-minutes: 45 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - GH_TOKEN: ${{ github.token }} - TARGET_BRANCH: feat/llm-email-writing-context-task4 - steps: - - name: Locate or create the staged Task 5 pull request - id: pull-request - shell: bash - run: | - set -euo pipefail - pull_number="$( - gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --state open \ - --head "$GITHUB_REF_NAME" \ - --base "$TARGET_BRANCH" \ - --json number \ - --jq '.[0].number // empty' - )" - if [[ -z "$pull_number" ]]; then - pull_url="$( - gh pr create \ - --repo "$GITHUB_REPOSITORY" \ - --base "$TARGET_BRANCH" \ - --head "$GITHUB_REF_NAME" \ - --title 'feat(email-writing): add contextual orchestration boundary' \ - --body-file - <<'EOF' - ## Product outcome - - Adds a tenant-scoped, provider-neutral contextual-orchestrator boundary for email-writing review, including a dedicated configuration API, privacy-minimized evidence, bounded Judge concurrency, strict response validation, and fail-closed transport behavior. - - ## Quality evidence - - - Task-specific contracts and adversarial hardening regressions - - exact statement and branch coverage gate for shipped Task 5 modules - - complete shipped docstring gate - - Ruff, compile, migration upgrade/downgrade, and unrelated-data preservation checks - - APA 7 doctoring for measurement, multilingual validity, privacy, risk, and governance boundaries - EOF - )" - pull_number="${pull_url##*/}" - fi - printf 'pull_number=%s\n' "$pull_number" >> "$GITHUB_OUTPUT" - - - name: Wait for required checks to become terminal - id: checks - shell: bash - env: - PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} - run: | - set -euo pipefail - stable_success_polls=0 - for _attempt in $(seq 1 120); do - required_json="$( - gh pr checks "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --required \ - --json name,state,bucket 2>/dev/null || printf '[]' - )" - failure_count="$( - jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' \ - <<<"$required_json" - )" - pending_count="$( - jq '[.[] | select(.bucket == "pending")] | length' \ - <<<"$required_json" - )" - required_count="$(jq 'length' <<<"$required_json")" - - if [[ "$failure_count" -gt 0 ]]; then - jq -r '.[] | select(.bucket == "fail" or .bucket == "cancel") | "\(.name): \(.state)"' \ - <<<"$required_json" - exit 1 - fi - if [[ "$required_count" -gt 0 && "$pending_count" -eq 0 ]]; then - stable_success_polls=$((stable_success_polls + 1)) - if [[ "$stable_success_polls" -ge 2 ]]; then - printf 'required_count=%s\n' "$required_count" >> "$GITHUB_OUTPUT" - exit 0 - fi - else - stable_success_polls=0 - fi - sleep 15 - done - echo 'Required checks did not settle within the bounded promotion window.' >&2 - exit 1 - - - name: Reject unresolved review findings - shell: bash - env: - PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} - run: | - set -euo pipefail - repository_owner="${GITHUB_REPOSITORY%%/*}" - repository_name="${GITHUB_REPOSITORY##*/}" - unresolved_count="$( - gh api graphql \ - -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved}}}}}' \ - -F owner="$repository_owner" \ - -F name="$repository_name" \ - -F number="$PULL_NUMBER" \ - --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' - )" - if [[ "$unresolved_count" -ne 0 ]]; then - echo "$unresolved_count unresolved review thread(s) remain." >&2 - exit 1 - fi - review_decision="$( - gh pr view "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --json reviewDecision \ - --jq '.reviewDecision // ""' - )" - if [[ "$review_decision" == 'CHANGES_REQUESTED' ]]; then - echo 'A current review requests changes.' >&2 - exit 1 - fi - - - name: Merge or arm policy-compliant auto-merge - shell: bash - env: - PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} - run: | - set -euo pipefail - merge_state="$( - gh pr view "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --json mergeStateStatus \ - --jq '.mergeStateStatus' - )" - if [[ "$merge_state" == 'CLEAN' || "$merge_state" == 'UNSTABLE' ]]; then - gh pr merge "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --squash \ - --delete-branch - exit 0 - fi - gh pr merge "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --auto \ - --squash \ - --delete-branch From 775f0833b2aa8abbd5d33e1080aff77d70a0f99e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:53:12 +0900 Subject: [PATCH 42/46] ci(email-writing): remove duplicate write-capable promoter --- ...mail-writing-orchestrator-promotion-v2.yml | 188 ------------------ 1 file changed, 188 deletions(-) delete mode 100644 .github/workflows/email-writing-orchestrator-promotion-v2.yml diff --git a/.github/workflows/email-writing-orchestrator-promotion-v2.yml b/.github/workflows/email-writing-orchestrator-promotion-v2.yml deleted file mode 100644 index fc77802b3..000000000 --- a/.github/workflows/email-writing-orchestrator-promotion-v2.yml +++ /dev/null @@ -1,188 +0,0 @@ -name: Email Writing Orchestrator Promotion V2 - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - actions: read - checks: read - contents: write - pull-requests: write - -concurrency: - group: email-writing-orchestrator-promotion-v2 - cancel-in-progress: true - -jobs: - promote-task5: - runs-on: ubuntu-24.04 - timeout-minutes: 50 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - GH_TOKEN: ${{ github.token }} - TARGET_BRANCH: feat/llm-email-writing-context-task4 - steps: - - name: Locate or create the staged pull request - id: pull-request - shell: bash - run: | - set -euo pipefail - pull_number="$( - gh pr list \ - --repo "$GITHUB_REPOSITORY" \ - --state open \ - --head "$GITHUB_REF_NAME" \ - --base "$TARGET_BRANCH" \ - --json number \ - --jq '.[0].number // empty' - )" - if [[ -z "$pull_number" ]]; then - pull_url="$( - gh pr create \ - --repo "$GITHUB_REPOSITORY" \ - --base "$TARGET_BRANCH" \ - --head "$GITHUB_REF_NAME" \ - --title 'feat(email-writing): add contextual orchestration boundary' \ - --body-file - <<'EOF' - ## Product outcome - - Adds a tenant-scoped, provider-neutral contextual-orchestrator boundary for email-writing review, including a dedicated configuration API, privacy-minimized evidence, bounded Judge concurrency, strict response validation, and fail-closed transport behavior. - - ## Quality evidence - - - Task-specific contracts and adversarial hardening regressions - - exact statement and branch coverage gate for shipped Task 5 modules - - complete shipped docstring gate - - Ruff, compile, migration upgrade/downgrade, and unrelated-data preservation checks - - APA 7 doctoring for measurement, multilingual validity, privacy, risk, and governance boundaries - EOF - )" - pull_number="${pull_url##*/}" - fi - printf 'pull_number=%s\n' "$pull_number" >> "$GITHUB_OUTPUT" - - - name: Wait for all non-promotion checks to settle - id: checks - shell: bash - env: - PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} - run: | - set -euo pipefail - stable_success_polls=0 - previous_signature='' - for _attempt in $(seq 1 180); do - head_sha="$( - gh pr view "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --json headRefOid \ - --jq '.headRefOid' - )" - check_runs="$( - gh api \ - -H 'Accept: application/vnd.github+json' \ - "/repos/$GITHUB_REPOSITORY/commits/$head_sha/check-runs?per_page=100" \ - --jq '[.check_runs[] | select(.name != "promote-task5" and .name != "promote") | {name, status, conclusion}]' - )" - failure_count="$( - jq '[.[] | select(.status == "completed" and (.conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped"))] | length' \ - <<<"$check_runs" - )" - pending_count="$( - jq '[.[] | select(.status != "completed")] | length' \ - <<<"$check_runs" - )" - check_count="$(jq 'length' <<<"$check_runs")" - signature="$( - jq -S -c 'sort_by(.name) | map({name, status, conclusion})' \ - <<<"$check_runs" - )" - - required_json="$( - gh pr checks "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --required \ - --json name,state,bucket 2>/dev/null || printf '[]' - )" - required_failures="$( - jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' \ - <<<"$required_json" - )" - required_pending="$( - jq '[.[] | select(.bucket == "pending")] | length' \ - <<<"$required_json" - )" - - if [[ "$failure_count" -gt 0 || "$required_failures" -gt 0 ]]; then - jq -r '.[] | select(.status == "completed" and (.conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped")) | "\(.name): \(.conclusion)"' \ - <<<"$check_runs" - jq -r '.[] | select(.bucket == "fail" or .bucket == "cancel") | "required \(.name): \(.state)"' \ - <<<"$required_json" - exit 1 - fi - - if [[ "$check_count" -gt 0 && "$pending_count" -eq 0 && "$required_pending" -eq 0 && "$signature" == "$previous_signature" ]]; then - stable_success_polls=$((stable_success_polls + 1)) - if [[ "$stable_success_polls" -ge 2 ]]; then - printf 'head_sha=%s\n' "$head_sha" >> "$GITHUB_OUTPUT" - exit 0 - fi - else - stable_success_polls=0 - fi - previous_signature="$signature" - sleep 15 - done - echo 'Checks did not settle within the bounded promotion window.' >&2 - exit 1 - - - name: Reject unresolved review findings - shell: bash - env: - PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} - run: | - set -euo pipefail - repository_owner="${GITHUB_REPOSITORY%%/*}" - repository_name="${GITHUB_REPOSITORY##*/}" - unresolved_count="$( - gh api graphql \ - -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved}}}}}' \ - -F owner="$repository_owner" \ - -F name="$repository_name" \ - -F number="$PULL_NUMBER" \ - --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length' - )" - if [[ "$unresolved_count" -ne 0 ]]; then - echo "$unresolved_count unresolved review thread(s) remain." >&2 - exit 1 - fi - review_decision="$( - gh pr view "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --json reviewDecision \ - --jq '.reviewDecision // ""' - )" - if [[ "$review_decision" == 'CHANGES_REQUESTED' ]]; then - echo 'A current review requests changes.' >&2 - exit 1 - fi - - - name: Merge or arm policy-compliant auto-merge - shell: bash - env: - PULL_NUMBER: ${{ steps.pull-request.outputs.pull_number }} - run: | - set -euo pipefail - if gh pr merge "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --squash \ - --delete-branch; then - exit 0 - fi - gh pr merge "$PULL_NUMBER" \ - --repo "$GITHUB_REPOSITORY" \ - --auto \ - --squash \ - --delete-branch From de77a1993a0a42dd1110d21d6d32c006bb0d42b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:53:31 +0900 Subject: [PATCH 43/46] ci(email-writing): remove self-modifying finalizer --- .../email-writing-task5-finalize.yml | 406 ------------------ 1 file changed, 406 deletions(-) delete mode 100644 .github/workflows/email-writing-task5-finalize.yml diff --git a/.github/workflows/email-writing-task5-finalize.yml b/.github/workflows/email-writing-task5-finalize.yml deleted file mode 100644 index 244659ca5..000000000 --- a/.github/workflows/email-writing-task5-finalize.yml +++ /dev/null @@ -1,406 +0,0 @@ -name: Email Writing Task 5 Finalize - -on: - push: - branches: - - feat/llm-email-writing-orchestrator-task5 - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - checks: read - -concurrency: - group: email-writing-task5-finalize - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - PYTHONWARNINGS: error - DISABLE_BACKGROUND_WORKERS: "1" - -jobs: - finalize: - runs-on: ubuntu-24.04 - timeout-minutes: 30 - env: - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: true - fetch-depth: 0 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: backend/requirements-hashes.txt - - name: Install hash-locked application dependencies - run: python -m pip install --disable-pip-version-check --require-hashes -r backend/requirements-hashes.txt - - name: Install hash-verified coverage tool - run: | - set -euo pipefail - mkdir -p /tmp/coverage-wheel - cat >/tmp/coverage-lock.txt <<'EOF' - coverage==7.15.2 --hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c - EOF - python -m pip download \ - --disable-pip-version-check \ - --require-hashes \ - --no-deps \ - --only-binary=:all: \ - --platform any \ - --python-version 3.14 \ - --implementation py \ - --abi none \ - --dest /tmp/coverage-wheel \ - -r /tmp/coverage-lock.txt - python -m pip install --disable-pip-version-check --no-deps \ - /tmp/coverage-wheel/coverage-7.15.2-py3-none-any.whl - - name: Verify and close any terminal coverage gaps - run: | - set -euo pipefail - - run_gate() { - cd backend - python -m pytest -q \ - tests/test_contextual_orchestrator_client.py \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_email_writing_orchestrator_module_boundary.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py \ - tests/test_email_writing_orchestrator_terminal_coverage.py - python -m coverage erase - python -m coverage run --branch \ - --include='api/email_writing_orchestrator_config.py,db/email_writing_orchestrator_config.py,services/contextual_orchestrator_client.py,services/email_writing_orchestrator_port.py,services/tenant_config_scope.py,alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py' \ - -m pytest -q \ - tests/test_contextual_orchestrator_client.py \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_email_writing_orchestrator_module_boundary.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py \ - tests/test_email_writing_orchestrator_terminal_coverage.py - python -m coverage report --show-missing --fail-under=100 \ - api/email_writing_orchestrator_config.py \ - db/email_writing_orchestrator_config.py \ - services/contextual_orchestrator_client.py \ - services/email_writing_orchestrator_port.py \ - services/tenant_config_scope.py \ - alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py - python -m ruff check \ - api/email_writing_orchestrator_config.py \ - db/email_writing_orchestrator_config.py \ - services/contextual_orchestrator_client.py \ - services/email_writing_orchestrator_port.py \ - services/tenant_config_scope.py \ - alembic/versions/20260813_0001_add_email_writing_orchestrator_config.py \ - tests/test_contextual_orchestrator_client.py \ - tests/test_contextual_orchestrator_hardening.py \ - tests/test_email_writing_orchestrator_module_boundary.py \ - tests/test_email_writing_orchestrator_scope.py \ - tests/test_email_writing_orchestrator_migration.py \ - tests/test_email_writing_orchestrator_config_api.py \ - tests/test_email_writing_orchestrator_terminal_coverage.py - cd .. - } - - if run_gate; then - exit 0 - fi - - python - <<'PY' - from pathlib import Path - - path = Path( - "backend/tests/" - "test_email_writing_orchestrator_terminal_coverage.py" - ) - text = path.read_text(encoding="utf-8") - marker = "# Task 5 final branch coverage extensions" - if marker in text: - raise SystemExit("quality gate still fails after terminal extensions") - text += r''' - -# Task 5 final branch coverage extensions - - -@pytest.mark.asyncio -async def test_revalidated_endpoint_accepts_an_identical_dns_fingerprint() -> None: - """Permit repeated validation only when the pinned endpoint is unchanged.""" - client = _client() - first = await client._validated_endpoint() - second = await client._validated_endpoint() - assert first == second - await client.aclose() - - -def test_http_error_covers_request_timeout_and_generic_service_failure() -> None: - """Map both non-5xx retry status and generic 503 failure branches.""" - client = _client() - request_timeout = client._http_error(408, b"{}") - generic_service_failure = client._http_error(503, b"not-json") - assert request_timeout.code == "orchestrator_unavailable" - assert request_timeout.transient is True - assert generic_service_failure.code == "orchestrator_unavailable" - assert generic_service_failure.transient is True - - -def test_optional_configuration_text_covers_blank_and_delete_character() -> None: - """Normalize blank optional text and reject the DEL control character.""" - blank = config_api.EmailWritingOrchestratorConfigUpdate( - model_profile_id=" " - ) - assert blank.model_profile_id is None - with pytest.raises(ValidationError): - config_api.EmailWritingOrchestratorConfigUpdate( - model_profile_id="bad\x7fvalue" - ) - - -@pytest.mark.asyncio -async def test_scope_resolution_covers_absent_disabled_and_each_missing_value( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Resolve only a complete enabled owner-scoped configuration.""" - selected: list[object | None] = [None] - - async def getter( - _session: Any, - _user_id: str, - _organization_id: str | None, - ) -> object | None: - return selected[0] - - monkeypatch.setattr( - scope_module, - "get_scoped_email_writing_orchestrator_config", - getter, - ) - assert ( - await scope_module.resolve_email_writing_orchestrator_settings( - cast(Any, object()), - user_id="user_alpha", - organization_id="organization_alpha", - ) - is None - ) - - config = scope_module.new_scoped_email_writing_orchestrator_config( - "user_alpha", - "organization_alpha", - ) - selected[0] = config - assert ( - await scope_module.resolve_email_writing_orchestrator_settings( - cast(Any, object()), - user_id="user_alpha", - organization_id="organization_alpha", - ) - is None - ) - - config.orchestrator_enabled = True - for values in ( - (None, "profile", "credential"), - ("https://orchestrator.example", None, "credential"), - ("https://orchestrator.example", "profile", None), - ): - ( - config.orchestrator_base_url, - config.model_profile_id, - config.inference_credential, - ) = values - with pytest.raises( - scope_module.EmailWritingOrchestratorConfigurationError, - match="email_writing_orchestrator_incomplete", - ): - await scope_module.resolve_email_writing_orchestrator_settings( - cast(Any, object()), - user_id="user_alpha", - organization_id="organization_alpha", - ) - - config.orchestrator_base_url = " https://orchestrator.example " - config.model_profile_id = " profile " - config.inference_credential = " credential " - settings = await scope_module.resolve_email_writing_orchestrator_settings( - cast(Any, object()), - user_id="user_alpha", - organization_id="organization_alpha", - ) - assert settings == scope_module.EmailWritingOrchestratorSettings( - base_url="https://orchestrator.example", - model_profile_id="profile", - inference_credential="credential", - ) - - -def test_trace_budget_accepts_boundary_and_rejects_excess( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Exercise both sides of the orchestration trace cardinality bound.""" - client = _client() - step = { - "usage": { - "prompt_tokens": 1, - "completion_tokens": 1, - "total_tokens": 2, - } - } - monkeypatch.setattr(client_module, "_MAX_TRACE_STEPS", 1) - accepted = client._parse_completion( - json.dumps(_payload(trace=[step])).encode() - ) - assert len(accepted.trace) == 1 - with pytest.raises( - ContextualOrchestratorError, - match="orchestrator_malformed_response", - ): - client._parse_completion( - json.dumps(_payload(trace=[step, step])).encode() - ) -''' - path.write_text(text, encoding="utf-8") - PY - - run_gate - - name: Verify production docstrings - run: | - cd backend - python - <<'PY' - import ast - from pathlib import Path - - paths = [ - Path("api/email_writing_orchestrator_config.py"), - Path("db/email_writing_orchestrator_config.py"), - Path("services/contextual_orchestrator_client.py"), - Path("services/email_writing_orchestrator_port.py"), - Path("services/tenant_config_scope.py"), - Path( - "alembic/versions/" - "20260813_0001_add_email_writing_orchestrator_config.py" - ), - ] - missing: list[str] = [] - for path in paths: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - if ast.get_docstring(tree) is None: - missing.append(f"{path}:") - for node in ast.walk(tree): - if isinstance( - node, - (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef), - ) and ast.get_docstring(node) is None: - missing.append(f"{path}:{node.lineno}:{node.name}") - if missing: - raise SystemExit("Missing production docstrings:\n" + "\n".join(missing)) - PY - - name: Resolve duplicate PRs and enable automatic merge - id: pr - run: | - set -euo pipefail - repository="${GITHUB_REPOSITORY}" - branch="${GITHUB_REF_NAME}" - owner="${repository%%/*}" - task4_branch="feat/llm-email-writing-context-task4" - default_branch="$(gh repo view "$repository" --json defaultBranchRef --jq '.defaultBranchRef.name')" - preferred_base="$default_branch" - - task4_count="$( - gh api \ - "repos/${repository}/pulls?state=open&head=${owner}:${task4_branch}&per_page=100" \ - --jq 'length' - )" - if [[ "$task4_count" -gt 0 ]]; then - preferred_base="$task4_branch" - elif git show-ref --verify --quiet refs/remotes/origin/develop \ - && git merge-base --is-ancestor origin/develop HEAD; then - preferred_base="develop" - fi - - mapfile -t pull_numbers < <( - gh api \ - "repos/${repository}/pulls?state=open&head=${owner}:${branch}&per_page=100" \ - --jq '.[].number' - ) - if [[ "${#pull_numbers[@]}" -eq 0 ]]; then - gh pr create \ - --repo "$repository" \ - --base "$preferred_base" \ - --head "$branch" \ - --title "feat(email-writing): add secure contextual-orchestrator tenant boundary" \ - --body-file - <<'EOF' - ## Product outcome - - Adds the production boundary that lets Naruon use contextual-orchestrator for email-writing candidate generation and independent judging without binding Naruon to one upstream model or leaking tenant credentials, provider details, prompts, or orchestration internals. - - ## Shipped scope - - - owner- and organization-scoped encrypted orchestration configuration - - dedicated modular FastAPI configuration router - - canonical HTTPS-origin validation, DNS pinning, redirect rejection, and rebinding protection - - bounded request, response, JSON, and trace work - - stable redacted errors, retry policy, and circuit breaker - - async candidate path plus bounded synchronous Judge lane - - migration, exact-scope persistence, docstring, lint, compile, and 100% coverage gates - - APA 7 doctoring and calibrated LLM-as-a-Judge claim boundaries - - ## Architecture boundary - - The API is isolated from legacy tenant/mailbox settings so it can later be extracted as an MSA module while Naruon remains independently operable. - EOF - mapfile -t pull_numbers < <( - gh api \ - "repos/${repository}/pulls?state=open&head=${owner}:${branch}&per_page=100" \ - --jq '.[].number' - ) - fi - - preferred_number="" - for number in "${pull_numbers[@]}"; do - base="$(gh pr view "$number" --repo "$repository" --json baseRefName --jq '.baseRefName')" - if [[ "$base" == "$preferred_base" && -z "$preferred_number" ]]; then - preferred_number="$number" - fi - done - if [[ -z "$preferred_number" ]]; then - preferred_number="${pull_numbers[0]}" - gh pr edit "$preferred_number" --repo "$repository" --base "$preferred_base" - fi - - for number in "${pull_numbers[@]}"; do - if [[ "$number" != "$preferred_number" ]]; then - gh pr close "$number" \ - --repo "$repository" \ - --comment "Superseded by #${preferred_number}; retaining one review and merge lineage for this head branch." - fi - done - - if [[ "$(gh pr view "$preferred_number" --repo "$repository" --json isDraft --jq '.isDraft')" == "true" ]]; then - gh pr ready "$preferred_number" --repo "$repository" - fi - echo "number=$preferred_number" >> "$GITHUB_OUTPUT" - gh pr merge "$preferred_number" \ - --repo "$repository" \ - --auto \ - --squash \ - --delete-branch || true - - name: Remove finalizer and publish verified branch - run: | - set -euo pipefail - rm .github/workflows/email-writing-task5-finalize.yml - git diff --check - if git diff --quiet; then - exit 0 - fi - git config user.name "CWL Email Writing Finalizer" - git config user.email "actions@users.noreply.github.com" - git add \ - .github/workflows/email-writing-task5-finalize.yml \ - backend/tests/test_email_writing_orchestrator_terminal_coverage.py - git commit -m "test(email-writing): enforce complete Task 5 coverage" - git push origin HEAD:feat/llm-email-writing-orchestrator-task5 From 0c0ac2b598a8338a257e69bcca4d00b6fe20daf5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:59:57 +0900 Subject: [PATCH 44/46] docs(email-writing): document strict JSON hook protocol --- backend/services/contextual_orchestrator_client.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/services/contextual_orchestrator_client.py b/backend/services/contextual_orchestrator_client.py index 7de68e174..eb6edd8d2 100644 --- a/backend/services/contextual_orchestrator_client.py +++ b/backend/services/contextual_orchestrator_client.py @@ -53,7 +53,11 @@ class _JsonObjectPairsHook(Protocol): - def __call__(self, pairs: list[tuple[str, Any]]) -> dict[str, Any]: ... + """Callable contract for strict JSON object-pairs decoding hooks.""" + + def __call__(self, pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Build one JSON object from the parser-provided key/value pairs.""" + ... class ContextualOrchestratorError(RuntimeError): From a08ef0b7f3dc95f69af4bb21a742de92eea587b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:47:55 +0900 Subject: [PATCH 45/46] test(email-writing): expose port import side effect --- ...il_writing_orchestrator_module_boundary.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend/tests/test_email_writing_orchestrator_module_boundary.py b/backend/tests/test_email_writing_orchestrator_module_boundary.py index c95e640ab..72e77267f 100644 --- a/backend/tests/test_email_writing_orchestrator_module_boundary.py +++ b/backend/tests/test_email_writing_orchestrator_module_boundary.py @@ -3,6 +3,10 @@ from __future__ import annotations import importlib +import os +from pathlib import Path +import subprocess +import sys def test_email_writing_orchestrator_owns_a_dedicated_router_module() -> None: @@ -14,3 +18,29 @@ def test_email_writing_orchestrator_owns_a_dedicated_router_module() -> None: assert route_paths == {"/api/config/email-writing-orchestrator"} assert not hasattr(tenant_config, "EmailWritingOrchestratorConfigUpdate") assert not hasattr(tenant_config, "update_email_writing_orchestrator_config") + + +def test_orchestrator_port_import_does_not_materialize_runtime_settings() -> None: + """Keep the domain-facing port import independent of application settings.""" + environment = os.environ.copy() + environment.pop("DATABASE_URL", None) + backend_root = Path(__file__).resolve().parents[1] + probe = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys; " + "import services.email_writing_orchestrator_port; " + "assert 'services.contextual_orchestrator_client' not in sys.modules; " + "assert 'core.config' not in sys.modules" + ), + ], + cwd=backend_root, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + assert probe.returncode == 0, probe.stderr From beb28466573b2163df97b027a6d3ac3776773869 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 20:48:55 +0900 Subject: [PATCH 46/46] fix(email-writing): keep port import config-free --- .../services/email_writing_orchestrator_port.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/backend/services/email_writing_orchestrator_port.py b/backend/services/email_writing_orchestrator_port.py index fd270098d..cd380fcf0 100644 --- a/backend/services/email_writing_orchestrator_port.py +++ b/backend/services/email_writing_orchestrator_port.py @@ -13,13 +13,18 @@ from collections.abc import Callable, Sequence from concurrent.futures import ThreadPoolExecutor import threading -from typing import ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, ParamSpec, TypeAlias, TypeVar -from services.contextual_orchestrator_client import ( - ChatMessage, - ContextualOrchestratorClient, - OrchestrationMode, -) +if TYPE_CHECKING: + from services.contextual_orchestrator_client import ( + ChatMessage, + ContextualOrchestratorClient, + OrchestrationMode, + ) +else: + ChatMessage: TypeAlias = Any + ContextualOrchestratorClient: TypeAlias = Any + OrchestrationMode: TypeAlias = Any P = ParamSpec("P") R = TypeVar("R")