From 4bb38312124258347bb90562a466842b1cb6b337 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:32:59 +0200 Subject: [PATCH 01/35] refactor: remove _group_records_by_agent wrapper The wrapper was a one-line passthrough to budget._aggregation.group_by_agent. Callers now import group_by_agent directly, removing a layer of indirection that obscured the call graph without adding any value. Refs #1733 (R11) --- src/synthorg/budget/_optimizer_helpers.py | 7 ------- src/synthorg/budget/optimizer.py | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/synthorg/budget/_optimizer_helpers.py b/src/synthorg/budget/_optimizer_helpers.py index 8e16e91c8f..a040fc5a7c 100644 --- a/src/synthorg/budget/_optimizer_helpers.py +++ b/src/synthorg/budget/_optimizer_helpers.py @@ -390,10 +390,3 @@ def _compute_alert_level( if used_pct >= alerts.warn_at: return BudgetAlertLevel.WARNING return BudgetAlertLevel.NORMAL - - -def _group_records_by_agent( - records: Sequence[CostRecord], -) -> dict[str, list[CostRecord]]: - """Group records by agent_id for efficient per-agent iteration.""" - return group_by_agent(records) diff --git a/src/synthorg/budget/optimizer.py b/src/synthorg/budget/optimizer.py index add4c8f384..a631dc29ca 100644 --- a/src/synthorg/budget/optimizer.py +++ b/src/synthorg/budget/optimizer.py @@ -15,6 +15,7 @@ from datetime import UTC, datetime from typing import TYPE_CHECKING +from synthorg.budget._aggregation import group_by_agent from synthorg.budget._optimizer_helpers import ( _build_downgrade_recommendation, _build_efficiency_from_records, @@ -22,7 +23,6 @@ _compute_window_costs, _detect_spike_anomaly, _find_most_used_model, - _group_records_by_agent, ) from synthorg.budget.billing import billing_period_start from synthorg.budget.currency import format_cost @@ -173,7 +173,7 @@ async def detect_anomalies( window_starts = tuple(start + window_duration * i for i in range(window_count)) # Pre-group records by agent for O(N+M) complexity. - by_agent = _group_records_by_agent(records) + by_agent = group_by_agent(records) agent_ids = sorted(by_agent) anomalies: list[SpendingAnomaly] = [] @@ -338,7 +338,7 @@ async def recommend_downgrades( global_avg_cost_per_1k=efficiency.global_avg_cost_per_1k, ) - by_agent = _group_records_by_agent(records) + by_agent = group_by_agent(records) recommendations = self._build_recommendations( efficiency=efficiency, by_agent=by_agent, @@ -404,7 +404,7 @@ async def suggest_routing_optimizations( end=end, ) - by_agent = _group_records_by_agent(records) + by_agent = group_by_agent(records) all_models = self._model_resolver.all_models_sorted_by_cost() suggestions = self._find_routing_suggestions(by_agent, all_models) From 712617ec7d057a5d7a5229696a257ea27e08c0d0 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:35:31 +0200 Subject: [PATCH 02/35] refactor: consolidate _deduplicate_tags into memory.utils MemoryMetadata and MemoryQuery had identical post-init dedup validators; ProcedureLearningRecord had a similar but cap-truncated variant. Extract the dedup primitive to synthorg.memory.utils.deduplicate_tags so all three share one implementation. Procedural model still composes its own truncation on top. Refs #1733 (R9) --- src/synthorg/memory/models.py | 5 ++-- src/synthorg/memory/procedural/models.py | 3 ++- src/synthorg/memory/utils.py | 11 +++++++++ tests/unit/memory/test_utils.py | 29 ++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/synthorg/memory/utils.py create mode 100644 tests/unit/memory/test_utils.py diff --git a/src/synthorg/memory/models.py b/src/synthorg/memory/models.py index 2b0806c6dd..5c1385b588 100644 --- a/src/synthorg/memory/models.py +++ b/src/synthorg/memory/models.py @@ -11,6 +11,7 @@ from synthorg.core.enums import MemoryCategory # noqa: TC001 from synthorg.core.types import NotBlankStr # noqa: TC001 +from synthorg.memory.utils import deduplicate_tags from synthorg.observability import get_logger from synthorg.observability.events.memory import MEMORY_MODEL_INVALID @@ -46,7 +47,7 @@ class MemoryMetadata(BaseModel): @model_validator(mode="after") def _deduplicate_tags(self) -> Self: """Remove duplicate tags while preserving order.""" - unique = tuple(dict.fromkeys(self.tags)) + unique = deduplicate_tags(self.tags) if len(unique) != len(self.tags): object.__setattr__(self, "tags", unique) return self @@ -221,7 +222,7 @@ class MemoryQuery(BaseModel): @model_validator(mode="after") def _deduplicate_tags(self) -> Self: """Remove duplicate tags while preserving order.""" - unique = tuple(dict.fromkeys(self.tags)) + unique = deduplicate_tags(self.tags) if len(unique) != len(self.tags): object.__setattr__(self, "tags", unique) return self diff --git a/src/synthorg/memory/procedural/models.py b/src/synthorg/memory/procedural/models.py index 9d4d0e89f6..a5ae4545db 100644 --- a/src/synthorg/memory/procedural/models.py +++ b/src/synthorg/memory/procedural/models.py @@ -19,6 +19,7 @@ from synthorg.core.enums import TaskType # noqa: TC001 from synthorg.core.types import NotBlankStr # noqa: TC001 +from synthorg.memory.utils import deduplicate_tags from synthorg.observability import get_logger logger = get_logger(__name__) @@ -192,7 +193,7 @@ class ProceduralMemoryProposal(BaseModel): def _deduplicate_tags(cls, v: object) -> object: """Deduplicate tags before max_length validation.""" if isinstance(v, list | tuple): - deduped = tuple(dict.fromkeys(v)) + deduped = deduplicate_tags(v) max_tags = 20 return deduped if len(deduped) <= max_tags else deduped[:max_tags] return v diff --git a/src/synthorg/memory/utils.py b/src/synthorg/memory/utils.py new file mode 100644 index 0000000000..93e2415532 --- /dev/null +++ b/src/synthorg/memory/utils.py @@ -0,0 +1,11 @@ +"""Shared helpers for memory domain models.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + + +def deduplicate_tags[T](tags: Iterable[T]) -> tuple[T, ...]: + """Return ``tags`` with duplicates removed, preserving insertion order.""" + return tuple(dict.fromkeys(tags)) diff --git a/tests/unit/memory/test_utils.py b/tests/unit/memory/test_utils.py new file mode 100644 index 0000000000..28637a7456 --- /dev/null +++ b/tests/unit/memory/test_utils.py @@ -0,0 +1,29 @@ +"""Unit tests for synthorg.memory.utils.""" + +import pytest + +from synthorg.memory.utils import deduplicate_tags + + +@pytest.mark.unit +class TestDeduplicateTags: + """Tag dedup helper preserves order and removes duplicates.""" + + def test_empty_input_returns_empty_tuple(self) -> None: + assert deduplicate_tags([]) == () + assert deduplicate_tags(()) == () + + def test_already_unique_returns_same_order(self) -> None: + assert deduplicate_tags(("a", "b", "c")) == ("a", "b", "c") + + def test_removes_duplicates_preserves_first_occurrence(self) -> None: + assert deduplicate_tags(("a", "b", "a", "c", "b")) == ("a", "b", "c") + + def test_accepts_list_input(self) -> None: + assert deduplicate_tags(["x", "y", "x"]) == ("x", "y") + + def test_accepts_generator_input(self) -> None: + assert deduplicate_tags(s for s in ("a", "a", "b")) == ("a", "b") + + def test_preserves_int_tag_types(self) -> None: + assert deduplicate_tags((1, 2, 1, 3)) == (1, 2, 3) From 59fc7edfa3c3660ba4f43fa8c1a88629af2335b5 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:41:42 +0200 Subject: [PATCH 03/35] refactor: add core normalisation helpers and migrate URL/path call sites Add three new helpers to synthorg.core.normalization: - strip_trailing_slash(url): replaces inline url.rstrip('/') at A2A agent cards, OAuth redirect, OTLP handlers, telemetry emitter, ntfy adapter, provider probing. - normalize_optional_string(raw): replaces (raw.strip() or None) if raw else None at setup-agents helpers. - normalize_path(path): replaces (path or '').rstrip('/') or '/' at CSRF middleware. 17 inline call sites migrated. Sites with non-canonical shapes (citation normalizer's conditional strip, validate_subworkflow's elif-chain, mem0 sparse_search's isinstance ternary) left untouched. Refs #1733 (R4) --- src/synthorg/a2a/well_known.py | 7 +- src/synthorg/api/auth/csrf.py | 3 +- src/synthorg/api/controllers/oauth.py | 3 +- src/synthorg/api/controllers/setup_agents.py | 11 +-- src/synthorg/core/normalization.py | 55 ++++++++++++++ src/synthorg/meta/telemetry/emitter.py | 5 +- src/synthorg/notifications/adapters/ntfy.py | 3 +- src/synthorg/observability/otlp_handler.py | 3 +- .../observability/otlp_trace_handler.py | 3 +- src/synthorg/providers/health_prober.py | 3 +- .../providers/management/local_models.py | 3 +- src/synthorg/providers/probing.py | 3 +- tests/unit/core/test_normalization.py | 72 ++++++++++++++++++- 13 files changed, 156 insertions(+), 18 deletions(-) diff --git a/src/synthorg/a2a/well_known.py b/src/synthorg/a2a/well_known.py index 279e1c6ac0..4460723df4 100644 --- a/src/synthorg/a2a/well_known.py +++ b/src/synthorg/a2a/well_known.py @@ -19,6 +19,7 @@ from litestar.response import Response from synthorg.a2a.agent_card import AgentCardBuilder # noqa: TC001 +from synthorg.core.normalization import strip_trailing_slash from synthorg.observability import get_logger from synthorg.observability.events.a2a import ( A2A_AGENT_CARD_CACHE_HIT, @@ -115,7 +116,7 @@ async def company_agent_card( a2a_config = app_state.config.a2a ttl = a2a_config.agent_card_cache_ttl_seconds - host_base = str(request.base_url).rstrip("/") + host_base = strip_trailing_slash(str(request.base_url)) company_cache_key = f"__company__:{host_base}" # Fingerprint not checked on read for company card (requires # listing all agents); TTL-based expiry is the primary guard. @@ -143,7 +144,7 @@ async def company_agent_card( try: identities = await registry.list_active() - base_url = str(request.base_url).rstrip("/") + base_url = strip_trailing_slash(str(request.base_url)) card = builder.build_company_card( identities=identities, base_url=f"{base_url}/api/v1/a2a", @@ -210,7 +211,7 @@ async def agent_card( a2a_config = app_state.config.a2a ttl = a2a_config.agent_card_cache_ttl_seconds - host_base = str(request.base_url).rstrip("/") + host_base = strip_trailing_slash(str(request.base_url)) agent_cache_key = f"{agent_id}:{host_base}" cached = await _get_cached_card(agent_cache_key, ttl) if cached is not None: diff --git a/src/synthorg/api/auth/csrf.py b/src/synthorg/api/auth/csrf.py index 4d1966f394..6077052a32 100644 --- a/src/synthorg/api/auth/csrf.py +++ b/src/synthorg/api/auth/csrf.py @@ -18,6 +18,7 @@ from litestar.types import ASGIApp, Receive, Scope, Send # noqa: TC002 from synthorg.api.auth.config import AuthConfig # noqa: TC001 +from synthorg.core.normalization import normalize_path from synthorg.observability import get_logger from synthorg.observability.events.api import ( API_CSRF_SKIPPED, @@ -81,7 +82,7 @@ async def __call__( await self.app(scope, receive, send) return - path = (scope.get("path", "") or "").rstrip("/") or "/" + path = normalize_path(scope.get("path", "")) if path in self._exempt_paths: await self.app(scope, receive, send) return diff --git a/src/synthorg/api/controllers/oauth.py b/src/synthorg/api/controllers/oauth.py index 2176d5b3d6..a0ea3c437d 100644 --- a/src/synthorg/api/controllers/oauth.py +++ b/src/synthorg/api/controllers/oauth.py @@ -16,6 +16,7 @@ from synthorg.api.path_params import PathName # noqa: TC001 -- runtime annotation from synthorg.api.rate_limits import per_op_rate_limit_from_policy from synthorg.core.domain_errors import ValidationError +from synthorg.core.normalization import strip_trailing_slash from synthorg.core.types import ( NotBlankStr, # noqa: TC001 -- Pydantic field annotation evaluated at runtime ) @@ -111,7 +112,7 @@ async def initiate_flow( # provider a URL this app never actually serves. api_prefix = state["app_state"].config.api.api_prefix redirect_uri = ( - config.redirect_uri_base.rstrip("/") + strip_trailing_slash(config.redirect_uri_base) + "/" + api_prefix.strip("/") + "/oauth/callback" diff --git a/src/synthorg/api/controllers/setup_agents.py b/src/synthorg/api/controllers/setup_agents.py index fd540dc991..d5dd0029b4 100644 --- a/src/synthorg/api/controllers/setup_agents.py +++ b/src/synthorg/api/controllers/setup_agents.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any from synthorg.core.domain_errors import NotFoundError, ValidationError +from synthorg.core.normalization import normalize_optional_string from synthorg.observability import get_logger from synthorg.observability.events.setup import ( SETUP_AGENT_SUMMARY_MISSING_FIELDS, @@ -444,7 +445,7 @@ def validate_agents_value(raw: str, *, strict: bool) -> bool: def normalize_description(raw: str | None) -> str | None: """Strip whitespace from description, treating blank as None.""" - return (raw.strip() or None) if raw else None + return normalize_optional_string(raw) def departments_to_json( @@ -495,9 +496,9 @@ def agent_dict_to_summary( name=name, role=role, department=department, - level=(agent.get("level") or "").strip() or None, # type: ignore[arg-type] - model_provider=(model.get("provider") or "").strip() or None, - model_id=(model.get("model_id") or "").strip() or None, + level=normalize_optional_string(agent.get("level")), # type: ignore[arg-type] + model_provider=normalize_optional_string(model.get("provider")), + model_id=normalize_optional_string(model.get("model_id")), tier=(agent.get("tier") or "").strip() or "medium", # type: ignore[arg-type] - personality_preset=(agent.get("personality_preset") or "").strip() or None, + personality_preset=normalize_optional_string(agent.get("personality_preset")), ) diff --git a/src/synthorg/core/normalization.py b/src/synthorg/core/normalization.py index b7f92fe93e..022e07e39f 100644 --- a/src/synthorg/core/normalization.py +++ b/src/synthorg/core/normalization.py @@ -73,3 +73,58 @@ def find_by_name_ci[T]( if isinstance(value, str) and normalize_identifier(value) == target_normalised: return item return None + + +def strip_trailing_slash(url: str) -> str: + """Return ``url`` without trailing slashes; idempotent. + + Strips every trailing forward slash, mirroring the inline + ``url.rstrip("/")`` pattern used at A2A agent-card, OAuth, OTLP, + and provider-probing call sites. Empty input returns empty string. + + Args: + url: URL or base URL string to strip. + + Returns: + ``url`` with all trailing ``/`` characters removed. + """ + return url.rstrip("/") + + +def normalize_optional_string(raw: str | None) -> str | None: + """Strip whitespace; collapse empty-after-strip to ``None``. + + Replaces the inline ``(raw.strip() or None) if raw else None`` + pattern used in setup agents, workflow validation, and memory + metadata fields where a blank user input should not be treated + as a real value. + + Args: + raw: Optional string from external input. + + Returns: + ``None`` if ``raw`` is None or strips to empty, otherwise the + stripped value. + """ + if raw is None: + return None + stripped = raw.strip() + return stripped or None + + +def normalize_path(path: str | None) -> str: + """Return a normalised URL path: strip trailing slashes, default to ``"/"``. + + Replaces the inline ``(path or "").rstrip("/") or "/"`` pattern + used by CSRF validation, docs routing, and ETag-key matching + where a missing or root-equivalent path must canonicalise to + ``"/"`` for stable comparison. + + Args: + path: Optional URL path (e.g. ``"/foo/"``, ``""``, ``None``). + + Returns: + Path with trailing slashes stripped, or ``"/"`` if the result + would otherwise be empty. + """ + return (path or "").rstrip("/") or "/" diff --git a/src/synthorg/meta/telemetry/emitter.py b/src/synthorg/meta/telemetry/emitter.py index 84152ffce3..a467cc6b46 100644 --- a/src/synthorg/meta/telemetry/emitter.py +++ b/src/synthorg/meta/telemetry/emitter.py @@ -14,6 +14,7 @@ import httpx +from synthorg.core.normalization import strip_trailing_slash from synthorg.core.resilience import GeneralRetryHandler from synthorg.meta.telemetry.anonymizer import anonymize_decision, anonymize_rollout from synthorg.meta.telemetry.models import AnonymizedOutcomeEvent, EventBatch @@ -397,7 +398,9 @@ async def _send_batch( if self._analytics_config.collector_url is None: msg = "collector_url is required when analytics is enabled" raise ValueError(msg) - url = str(self._analytics_config.collector_url).rstrip("/") + "/events" + url = ( + strip_trailing_slash(str(self._analytics_config.collector_url)) + "/events" + ) payload = EventBatch(events=events).model_dump(mode="json") event_count = len(events) diff --git a/src/synthorg/notifications/adapters/ntfy.py b/src/synthorg/notifications/adapters/ntfy.py index f274052dc0..12d7be2d66 100644 --- a/src/synthorg/notifications/adapters/ntfy.py +++ b/src/synthorg/notifications/adapters/ntfy.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from types import TracebackType +from synthorg.core.normalization import strip_trailing_slash from synthorg.notifications.models import ( Notification, NotificationSeverity, @@ -107,7 +108,7 @@ def __init__( f"{webhook_timeout_seconds}" ) raise ValueError(msg) - self._server_url = server_url.rstrip("/") + self._server_url = strip_trailing_slash(server_url) self._topic = topic self._token = token self._webhook_timeout_seconds = webhook_timeout_seconds diff --git a/src/synthorg/observability/otlp_handler.py b/src/synthorg/observability/otlp_handler.py index fc2074e66c..5ecba699dc 100644 --- a/src/synthorg/observability/otlp_handler.py +++ b/src/synthorg/observability/otlp_handler.py @@ -18,6 +18,7 @@ import structlog from structlog.stdlib import ProcessorFormatter +from synthorg.core.normalization import strip_trailing_slash from synthorg.observability import safe_error_description from synthorg.observability.enums import OtlpProtocol from synthorg.observability.events.metrics import ( @@ -298,7 +299,7 @@ def _export_batch(self, records: list[logging.LogRecord]) -> None: body = json.dumps(payload).encode() # Use /v1/logs path for OTLP HTTP JSON - url = self._endpoint.rstrip("/") + "/v1/logs" + url = strip_trailing_slash(self._endpoint) + "/v1/logs" request = urllib.request.Request(url, data=body, method="POST") # noqa: S310 request.add_header("Content-Type", "application/json") for name, value in self._extra_headers.items(): diff --git a/src/synthorg/observability/otlp_trace_handler.py b/src/synthorg/observability/otlp_trace_handler.py index e77e50d5fa..bf35527ab1 100644 --- a/src/synthorg/observability/otlp_trace_handler.py +++ b/src/synthorg/observability/otlp_trace_handler.py @@ -23,6 +23,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.sampling import TraceIdRatioBased +from synthorg.core.normalization import strip_trailing_slash from synthorg.observability import get_logger from synthorg.observability.events.metrics import ( METRICS_OTLP_EXPORT_FAILED, @@ -195,4 +196,4 @@ def _resolve_traces_endpoint(base_endpoint: str) -> str: """Append ``/v1/traces`` to *base_endpoint* if not already present.""" if base_endpoint.endswith(_TRACES_ENDPOINT_SUFFIX): return base_endpoint - return base_endpoint.rstrip("/") + _TRACES_ENDPOINT_SUFFIX + return strip_trailing_slash(base_endpoint) + _TRACES_ENDPOINT_SUFFIX diff --git a/src/synthorg/providers/health_prober.py b/src/synthorg/providers/health_prober.py index b47d5896a7..19e6f9241c 100644 --- a/src/synthorg/providers/health_prober.py +++ b/src/synthorg/providers/health_prober.py @@ -15,6 +15,7 @@ import httpx from synthorg.core.clock import Clock, SystemClock +from synthorg.core.normalization import strip_trailing_slash from synthorg.observability import get_logger, safe_error_description from synthorg.observability.events.provider import ( PROVIDER_HEALTH_PROBE_FAILED, @@ -84,7 +85,7 @@ def _build_ping_url( if not 1 <= ollama_port <= 65535: # noqa: PLR2004 -- TCP port range msg = f"ollama_port must be in 1-65535, got {ollama_port!r}" raise ValueError(msg) - stripped = base_url.rstrip("/") + stripped = strip_trailing_slash(base_url) is_ollama = litellm_provider == "ollama" or urlparse(stripped).port == ollama_port if is_ollama: return stripped # Root URL returns a liveness string diff --git a/src/synthorg/providers/management/local_models.py b/src/synthorg/providers/management/local_models.py index 5afa0e1edd..2c08f2eb2d 100644 --- a/src/synthorg/providers/management/local_models.py +++ b/src/synthorg/providers/management/local_models.py @@ -13,6 +13,7 @@ import httpx from pydantic import BaseModel, ConfigDict, Field, model_validator +from synthorg.core.normalization import strip_trailing_slash from synthorg.core.types import NotBlankStr # noqa: TC001 from synthorg.observability import get_logger from synthorg.observability.events.provider import ( @@ -125,7 +126,7 @@ def __init__( if not base_url or not base_url.strip(): msg = "base_url must be a non-empty URL" raise ValueError(msg) - self._base_url = base_url.rstrip("/") + self._base_url = strip_trailing_slash(base_url) self._client = client @staticmethod diff --git a/src/synthorg/providers/probing.py b/src/synthorg/providers/probing.py index 68a1dc12f1..c2008b6a78 100644 --- a/src/synthorg/providers/probing.py +++ b/src/synthorg/providers/probing.py @@ -13,6 +13,7 @@ from pydantic import BaseModel, ConfigDict, Field from synthorg.config.schema import ProviderModelConfig +from synthorg.core.normalization import strip_trailing_slash from synthorg.core.types import NotBlankStr # noqa: TC001 from synthorg.observability import get_logger from synthorg.observability.events.provider import ( @@ -133,7 +134,7 @@ def _build_probe_endpoint(base_url: str, preset_name: str) -> str: Returns: Full URL to the model-listing endpoint. """ - stripped = base_url.rstrip("/") + stripped = strip_trailing_slash(base_url) if preset_name == "ollama": return f"{stripped}/api/tags" return f"{stripped}/models" diff --git a/tests/unit/core/test_normalization.py b/tests/unit/core/test_normalization.py index ba0843a9e7..f65ea1bb89 100644 --- a/tests/unit/core/test_normalization.py +++ b/tests/unit/core/test_normalization.py @@ -6,7 +6,13 @@ from hypothesis import example, given from hypothesis import strategies as st -from synthorg.core.normalization import find_by_name_ci, normalize_identifier +from synthorg.core.normalization import ( + find_by_name_ci, + normalize_identifier, + normalize_optional_string, + normalize_path, + strip_trailing_slash, +) @pytest.mark.unit @@ -107,3 +113,67 @@ def test_empty_iterable(self) -> None: def test_match_strips_and_casefolds_target(self) -> None: items = (self.Item("Alice"),) assert find_by_name_ci(items, " ALICE ") is items[0] + + +@pytest.mark.unit +class TestStripTrailingSlash: + """``strip_trailing_slash`` strips trailing forward slashes.""" + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("https://example.com/", "https://example.com"), + ("https://example.com//", "https://example.com"), + ("https://example.com", "https://example.com"), + ("/", ""), + ("//", ""), + ("", ""), + ], + ) + def test_cases(self, value: str, expected: str) -> None: + assert strip_trailing_slash(value) == expected + + def test_idempotent(self) -> None: + once = strip_trailing_slash("https://api.example.com/") + assert strip_trailing_slash(once) == once + + +@pytest.mark.unit +class TestNormalizeOptionalString: + """``normalize_optional_string`` strips and collapses blank to None.""" + + @pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, None), + ("", None), + (" ", None), + ("\t\n", None), + ("alice", "alice"), + (" alice ", "alice"), + ("Alice Bob", "Alice Bob"), + ], + ) + def test_cases(self, raw: str | None, expected: str | None) -> None: + assert normalize_optional_string(raw) == expected + + +@pytest.mark.unit +class TestNormalizePath: + """``normalize_path`` strips trailing slashes; defaults to ``/``.""" + + @pytest.mark.parametrize( + ("path", "expected"), + [ + (None, "/"), + ("", "/"), + ("/", "/"), + ("//", "/"), + ("/foo", "/foo"), + ("/foo/", "/foo"), + ("/foo//", "/foo"), + ("/foo/bar/", "/foo/bar"), + ], + ) + def test_cases(self, path: str | None, expected: str) -> None: + assert normalize_path(path) == expected From 7ce2b313c6124206941f9fb1578f8a4fd83ceb13 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:46:31 +0200 Subject: [PATCH 04/35] refactor: consolidate org-chart getNodeLabel + budget dimension switch Extract getNodeLabel + node-data shape guards from useOrgChartSelection into a shared web/src/pages/org/node-utils.ts. OrgChartFilter previously had a similar but cast-based copy; now both call the guard-protected version, so a record with a blank/whitespace name can never surface as an empty UI label. Replace the two parallel switch statements in computeCostBreakdown with a DIMENSION_RESOLVERS strategy table keyed by BreakdownDimension, so the key/label resolution stays exhaustively typed against the union and each dimension's logic lives in one place. Refs #1733 (R8) --- web/src/pages/org/OrgChartFilter.tsx | 17 +----- web/src/pages/org/node-utils.ts | 72 +++++++++++++++++++++++ web/src/pages/org/useOrgChartSelection.ts | 64 +------------------- web/src/utils/budget.ts | 46 +++++++-------- 4 files changed, 98 insertions(+), 101 deletions(-) create mode 100644 web/src/pages/org/node-utils.ts diff --git a/web/src/pages/org/OrgChartFilter.tsx b/web/src/pages/org/OrgChartFilter.tsx index d3abf86ae5..bf79759ec4 100644 --- a/web/src/pages/org/OrgChartFilter.tsx +++ b/web/src/pages/org/OrgChartFilter.tsx @@ -1,22 +1,9 @@ import { useEffect, useMemo, useState } from 'react' import type { Node } from '@xyflow/react' -import type { AgentNodeData, DepartmentGroupData, OwnerNodeData } from './build-org-tree' +import type { AgentNodeData } from './build-org-tree' +import { getNodeLabel } from './node-utils' import { OrgChartSearchOverlay } from './OrgChartSearchOverlay' -function getNodeLabel(node: Node): string { - switch (node.type) { - case 'agent': - case 'ceo': - return (node.data as AgentNodeData).name - case 'department': - return (node.data as DepartmentGroupData).displayName - case 'owner': - return (node.data as OwnerNodeData).displayName - default: - return node.id - } -} - export interface OrgChartFilterResult { searchOpen: boolean searchMatchIds: Set | null diff --git a/web/src/pages/org/node-utils.ts b/web/src/pages/org/node-utils.ts new file mode 100644 index 0000000000..22913ff2ee --- /dev/null +++ b/web/src/pages/org/node-utils.ts @@ -0,0 +1,72 @@ +import type { Node } from '@xyflow/react' +import type { AgentNodeData, DepartmentGroupData, OwnerNodeData } from './build-org-tree' + +/** Non-null, non-array, object-shaped value (shared pre-check for shape guards). */ +function isObjectRecord(data: unknown): data is Record { + return typeof data === 'object' && data !== null && !Array.isArray(data) +} + +/** + * Build a type predicate that asserts every listed field is a non-empty + * `string` on the input object. Used by the node-data shape guards so each + * one validates the full string-typed surface of its interface, not just + * one field. + */ +function makeStringFieldGuard( + requiredStringFields: readonly (keyof T & string)[], +): (data: unknown) => data is T { + return (data: unknown): data is T => { + if (!isObjectRecord(data)) return false + for (const key of requiredStringFields) { + const value = (data as Record)[key] + // Reject whitespace-only strings as well as empty ones -- a label + // like ' ' would otherwise pass this guard and surface as a + // blank name / role / id in the UI instead of falling back to + // node.id via the outer code path. + if (typeof value !== 'string' || value.trim().length === 0) return false + } + return true + } +} + +export const isAgentNodeData = makeStringFieldGuard([ + 'agentId', + 'name', + 'role', + 'department', + 'level', + 'runtimeStatus', +]) + +export const isDepartmentGroupData = makeStringFieldGuard([ + 'departmentName', + 'displayName', +]) + +export const isOwnerNodeData = makeStringFieldGuard([ + 'ownerId', + 'displayName', + 'role', +]) + +/** + * Resolve a display label for an org-chart node. + * + * Falls back to `node.id` when the node has no recognised type, when the + * data shape fails the guard for the matched type, or when the type is + * absent. The shape guards reject blank/whitespace-only fields so a stale + * record never surfaces an empty label in the UI. + */ +export function getNodeLabel(node: Node): string { + switch (node.type) { + case 'agent': + case 'ceo': + return isAgentNodeData(node.data) ? node.data.name : node.id + case 'department': + return isDepartmentGroupData(node.data) ? node.data.displayName : node.id + case 'owner': + return isOwnerNodeData(node.data) ? node.data.displayName : node.id + default: + return node.id + } +} diff --git a/web/src/pages/org/useOrgChartSelection.ts b/web/src/pages/org/useOrgChartSelection.ts index d15f17e036..a2e7e86c63 100644 --- a/web/src/pages/org/useOrgChartSelection.ts +++ b/web/src/pages/org/useOrgChartSelection.ts @@ -3,7 +3,7 @@ import type { MouseEvent as ReactMouseEvent } from 'react' import type { Node } from '@xyflow/react' import { useNavigate } from 'react-router' import { useToastStore } from '@/stores/toast' -import type { AgentNodeData, DepartmentGroupData, OwnerNodeData } from './build-org-tree' +import { getNodeLabel } from './node-utils' type NodeType = ContextMenuState['nodeType'] @@ -17,68 +17,6 @@ function isValidNodeType(value: string | undefined): value is NodeType { return value !== undefined && (VALID_NODE_TYPES as ReadonlySet).has(value) } -/** Non-null, non-array, object-shaped value (shared pre-check for shape guards). */ -function isObjectRecord(data: unknown): data is Record { - return typeof data === 'object' && data !== null && !Array.isArray(data) -} - -/** - * Build a type predicate that asserts every listed field is a non-empty - * ``string`` on the input object. Used by the node-data shape guards so - * each one validates the full string-typed surface of its interface, - * not just one field. - */ -function makeStringFieldGuard( - requiredStringFields: readonly (keyof T & string)[], -): (data: unknown) => data is T { - return (data: unknown): data is T => { - if (!isObjectRecord(data)) return false - for (const key of requiredStringFields) { - const value = (data as Record)[key] - // Reject whitespace-only strings as well as empty ones -- a - // label like ``' '`` would otherwise pass this guard and - // surface as a blank name / role / id in the UI instead of - // falling back to ``node.id`` via the outer code path. - if (typeof value !== 'string' || value.trim().length === 0) return false - } - return true - } -} - -const isAgentNodeData = makeStringFieldGuard([ - 'agentId', - 'name', - 'role', - 'department', - 'level', - 'runtimeStatus', -]) - -const isDepartmentGroupData = makeStringFieldGuard([ - 'departmentName', - 'displayName', -]) - -const isOwnerNodeData = makeStringFieldGuard([ - 'ownerId', - 'displayName', - 'role', -]) - -function getNodeLabel(node: Node): string { - switch (node.type) { - case 'agent': - case 'ceo': - return isAgentNodeData(node.data) ? node.data.name : node.id - case 'department': - return isDepartmentGroupData(node.data) ? node.data.displayName : node.id - case 'owner': - return isOwnerNodeData(node.data) ? node.data.displayName : node.id - default: - return node.id - } -} - export interface ContextMenuState { nodeId: string nodeType: 'agent' | 'ceo' | 'department' diff --git a/web/src/utils/budget.ts b/web/src/utils/budget.ts index 4fbfd40553..fa295b26cc 100644 --- a/web/src/utils/budget.ts +++ b/web/src/utils/budget.ts @@ -118,6 +118,26 @@ export function computeAgentSpending( return rows.sort((a, b) => b.totalCost - a.totalCost) } +interface DimensionResolver { + key: (record: CostRecord, agentDeptMap: ReadonlyMap) => string + label: (key: string, agentNameMap: ReadonlyMap) => string +} + +const DIMENSION_RESOLVERS: Record = { + agent: { + key: (r) => r.agent_id, + label: (key, agentNameMap) => agentNameMap.get(key) ?? key, + }, + provider: { + key: (r) => r.provider, + label: (key) => key, + }, + department: { + key: (r, agentDeptMap) => agentDeptMap.get(r.agent_id) ?? 'Unknown', + label: (key) => key, + }, +} + /** * Group cost records by the given dimension and compute breakdown slices. * @@ -132,22 +152,12 @@ export function computeCostBreakdown( ): BreakdownSlice[] { if (records.length === 0) return [] + const resolver = DIMENSION_RESOLVERS[dimension] const groups = new Map() let totalCost = 0 for (const r of records) { - let key: string - switch (dimension) { - case 'agent': - key = r.agent_id - break - case 'provider': - key = r.provider - break - case 'department': - key = agentDeptMap.get(r.agent_id) ?? 'Unknown' - break - } + const key = resolver.key(r, agentDeptMap) groups.set(key, (groups.get(key) ?? 0) + r.cost) totalCost += r.cost } @@ -156,19 +166,9 @@ export function computeCostBreakdown( // so the highest-cost slice always gets the first palette color. const unsorted: Omit[] = [] for (const [key, cost] of groups) { - let label: string - switch (dimension) { - case 'agent': - label = agentNameMap.get(key) ?? key - break - case 'provider': - case 'department': - label = key - break - } unsorted.push({ key, - label, + label: resolver.label(key, agentNameMap), cost, percent: totalCost > 0 ? (cost / totalCost) * 100 : 0, }) From 6dafa16cf393cb2ea43678a5dfe9528a4d3c361b Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:48:31 +0200 Subject: [PATCH 05/35] fix: switch DigiCert + Sectigo TSA defaults to https The audit-chain RFC 3161 timestamping defaults shipped with bare http:// URLs across three sources of truth: settings/definitions/observability.py registry entries, settings/bridge_configs.py Pydantic field defaults, and audit_chain/config.py's _DEFAULT_PRESET_URLS map. Both endpoints serve TLS today; switch all three to https:// so a fresh-install operator does not silently round-trip signed audit events over an unencrypted hop. Both definitions are restart_required=True, so existing operators keep their current YAML/DB value; only fresh installs and reset-to-default flows pick up the new scheme. Refs #1733 (#30) --- src/synthorg/observability/audit_chain/config.py | 4 ++-- src/synthorg/settings/bridge_configs.py | 4 ++-- src/synthorg/settings/definitions/observability.py | 4 ++-- .../unit/observability/audit_chain/test_tsa_config.py | 10 +++++----- tests/unit/settings/test_resolver_bridge_configs.py | 8 ++++---- tests/unit/settings/test_url_port_entries.py | 4 ++-- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/synthorg/observability/audit_chain/config.py b/src/synthorg/observability/audit_chain/config.py index 8052c21beb..d1086e1393 100644 --- a/src/synthorg/observability/audit_chain/config.py +++ b/src/synthorg/observability/audit_chain/config.py @@ -34,8 +34,8 @@ class TsaPreset(StrEnum): _DEFAULT_PRESET_URLS: MappingProxyType[TsaPreset, str] = MappingProxyType( { TsaPreset.FREETSA: "https://freetsa.org/tsr", - TsaPreset.DIGICERT: "http://timestamp.digicert.com", - TsaPreset.SECTIGO: "http://timestamp.sectigo.com", + TsaPreset.DIGICERT: "https://timestamp.digicert.com", + TsaPreset.SECTIGO: "https://timestamp.sectigo.com", } ) """Hardcoded baseline URLs for each non-CUSTOM TSA preset, kept in sync diff --git a/src/synthorg/settings/bridge_configs.py b/src/synthorg/settings/bridge_configs.py index 70e44847f9..3a0691e41b 100644 --- a/src/synthorg/settings/bridge_configs.py +++ b/src/synthorg/settings/bridge_configs.py @@ -138,11 +138,11 @@ class ObservabilityBridgeConfig(BaseModel): pattern=r"^https?://[\w.\-:]+(?:/.*)?$", ) tsa_endpoint_digicert: NotBlankStr = Field( - default=NotBlankStr("http://timestamp.digicert.com"), + default=NotBlankStr("https://timestamp.digicert.com"), pattern=r"^https?://[\w.\-:]+(?:/.*)?$", ) tsa_endpoint_sectigo: NotBlankStr = Field( - default=NotBlankStr("http://timestamp.sectigo.com"), + default=NotBlankStr("https://timestamp.sectigo.com"), pattern=r"^https?://[\w.\-:]+(?:/.*)?$", ) diff --git a/src/synthorg/settings/definitions/observability.py b/src/synthorg/settings/definitions/observability.py index 637ee4c367..4dc6cf566e 100644 --- a/src/synthorg/settings/definitions/observability.py +++ b/src/synthorg/settings/definitions/observability.py @@ -251,7 +251,7 @@ namespace=SettingNamespace.OBSERVABILITY, key="tsa_endpoint_digicert", type=SettingType.STRING, - default="http://timestamp.digicert.com", + default="https://timestamp.digicert.com", description=( "RFC 3161 Time-Stamp Authority endpoint URL for the DigiCert" " preset. Override only if DigiCert changes its endpoint." @@ -269,7 +269,7 @@ namespace=SettingNamespace.OBSERVABILITY, key="tsa_endpoint_sectigo", type=SettingType.STRING, - default="http://timestamp.sectigo.com", + default="https://timestamp.sectigo.com", description=( "RFC 3161 Time-Stamp Authority endpoint URL for the Sectigo" " preset. Override only if Sectigo changes its endpoint." diff --git a/tests/unit/observability/audit_chain/test_tsa_config.py b/tests/unit/observability/audit_chain/test_tsa_config.py index aaa3d3988a..a28436345a 100644 --- a/tests/unit/observability/audit_chain/test_tsa_config.py +++ b/tests/unit/observability/audit_chain/test_tsa_config.py @@ -47,12 +47,12 @@ def test_custom_preset_with_url_resolves() -> None: ( TsaPreset.DIGICERT, Path("tests/data/digicert_roots.pem"), - "http://timestamp.digicert.com", + "https://timestamp.digicert.com", ), ( TsaPreset.SECTIGO, Path("tests/data/sectigo_roots.pem"), - "http://timestamp.sectigo.com", + "https://timestamp.sectigo.com", ), ], ) @@ -134,7 +134,7 @@ def test_timeout_accepts_boundary_values(value: float) -> None: (TsaPreset.CUSTOM, None, None), # Named presets resolve to their documented canonical URL when # no override is supplied, and accept overrides transparently. - (TsaPreset.DIGICERT, None, "http://timestamp.digicert.com"), + (TsaPreset.DIGICERT, None, "https://timestamp.digicert.com"), (TsaPreset.FREETSA, "override", "override"), ], ) @@ -168,7 +168,7 @@ def test_resolve_tsa_url_falls_back_when_preset_urls_none() -> None: """``preset_urls=None`` falls back to the documented baseline.""" assert ( resolve_tsa_url(TsaPreset.DIGICERT, None, preset_urls=None) - == "http://timestamp.digicert.com" + == "https://timestamp.digicert.com" ) @@ -212,5 +212,5 @@ def test_resolve_tsa_url_incomplete_preset_urls_falls_back() -> None: # DIGICERT is missing; falls back to documented default rather than KeyError. assert ( resolve_tsa_url(TsaPreset.DIGICERT, None, preset_urls=partial) - == "http://timestamp.digicert.com" + == "https://timestamp.digicert.com" ) diff --git a/tests/unit/settings/test_resolver_bridge_configs.py b/tests/unit/settings/test_resolver_bridge_configs.py index 5fe9ed11b7..e885c47227 100644 --- a/tests/unit/settings/test_resolver_bridge_configs.py +++ b/tests/unit/settings/test_resolver_bridge_configs.py @@ -255,16 +255,16 @@ async def _side_effect(namespace: str, key: str) -> SettingValue: ( "observability", "tsa_endpoint_digicert", - ): "http://timestamp.digicert.com", - ("observability", "tsa_endpoint_sectigo"): "http://timestamp.sectigo.com", + ): "https://timestamp.digicert.com", + ("observability", "tsa_endpoint_sectigo"): "https://timestamp.sectigo.com", }, { "http_batch_size": 250, "http_max_retries": 5, "audit_chain_signing_timeout_seconds": 10.0, "tsa_endpoint_freetsa": "https://tsa.example.com/tsr", - "tsa_endpoint_digicert": "http://timestamp.digicert.com", - "tsa_endpoint_sectigo": "http://timestamp.sectigo.com", + "tsa_endpoint_digicert": "https://timestamp.digicert.com", + "tsa_endpoint_sectigo": "https://timestamp.sectigo.com", }, ), ( diff --git a/tests/unit/settings/test_url_port_entries.py b/tests/unit/settings/test_url_port_entries.py index 5bd37608f8..683d0b2e26 100644 --- a/tests/unit/settings/test_url_port_entries.py +++ b/tests/unit/settings/test_url_port_entries.py @@ -56,14 +56,14 @@ def service() -> SettingsService: "observability", "tsa_endpoint_digicert", SettingType.STRING, - "http://timestamp.digicert.com", + "https://timestamp.digicert.com", "https://timestamp.digicert.example.com", ), ( "observability", "tsa_endpoint_sectigo", SettingType.STRING, - "http://timestamp.sectigo.com", + "https://timestamp.sectigo.com", "https://timestamp.sectigo.example.com", ), ( From 0ad7894a737d39a96e24beb656b2347e87c04409 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:52:02 +0200 Subject: [PATCH 06/35] fix: route SSRF audit events to security audit chain The SsrfViolationService recorded mutations on the api.* event namespace which excludes them from the signed audit chain. Switch to SECURITY_SSRF_VIOLATION_RECORDED for create and route the resolution event to SECURITY_SSRF_VIOLATION_ALLOWED / _DENIED based on the new status so a single audit-chain reader can reconstruct the WHO+WHEN of allow vs deny without unpacking the payload. The two read-side events (API_SSRF_VIOLATION_LISTED, API_SSRF_VIOLATION_FETCH_FAILED) stay on the api.* namespace because list/fetch failures carry no audit-chain implication. The two now-unused mutation constants are dropped from observability/events/api.py. Refs #1733 (#2) --- .../api/services/ssrf_violation_service.py | 45 +++++++++++++------ src/synthorg/observability/events/api.py | 7 ++- .../services/test_ssrf_violation_service.py | 23 ++++++---- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/synthorg/api/services/ssrf_violation_service.py b/src/synthorg/api/services/ssrf_violation_service.py index fdc0cba825..62fa820495 100644 --- a/src/synthorg/api/services/ssrf_violation_service.py +++ b/src/synthorg/api/services/ssrf_violation_service.py @@ -9,7 +9,11 @@ Resolution is a security-sensitive event: the WHO + WHEN of an operator allowing or denying a previously-blocked URL is captured at -this layer via :data:`API_SSRF_VIOLATION_STATUS_UPDATED`. +this layer via :data:`SECURITY_SSRF_VIOLATION_ALLOWED` / +:data:`SECURITY_SSRF_VIOLATION_DENIED` so the entries land on the +signed audit chain alongside other security mutations. Read-side +fetch / list events stay on the API namespace because they carry no +audit-chain implication. """ from typing import TYPE_CHECKING @@ -19,12 +23,15 @@ from synthorg.observability.events.api import ( API_SSRF_VIOLATION_FETCH_FAILED, API_SSRF_VIOLATION_LISTED, - API_SSRF_VIOLATION_RECORDED, - API_SSRF_VIOLATION_STATUS_UPDATED, +) +from synthorg.observability.events.security import ( + SECURITY_SSRF_VIOLATION_ALLOWED, + SECURITY_SSRF_VIOLATION_DENIED, + SECURITY_SSRF_VIOLATION_RECORDED, ) from synthorg.security.ssrf_violation import ( - SsrfViolation, # noqa: TC001 - SsrfViolationStatus, # noqa: TC001 + SsrfViolation, + SsrfViolationStatus, ) if TYPE_CHECKING: @@ -75,14 +82,14 @@ async def record(self, violation: SsrfViolation) -> None: raise except Exception as exc: logger.warning( - API_SSRF_VIOLATION_RECORDED, + SECURITY_SSRF_VIOLATION_RECORDED, violation_id=violation.id, error_type=type(exc).__name__, error=safe_error_description(exc), ) raise logger.info( - API_SSRF_VIOLATION_RECORDED, + SECURITY_SSRF_VIOLATION_RECORDED, violation_id=violation.id, hostname=violation.hostname, port=violation.port, @@ -175,10 +182,12 @@ async def update_status( ) -> bool: """Transition a pending violation to ALLOWED or DENIED. - Audit-critical: emits :data:`API_SSRF_VIOLATION_STATUS_UPDATED` - with the resolver identity and resolution timestamp on success. - Skipped when the row was missing or already resolved -- in - those cases the repository returns ``False`` and no audit fires. + Audit-critical: emits one of the security audit-chain events + (:data:`SECURITY_SSRF_VIOLATION_ALLOWED` or + :data:`SECURITY_SSRF_VIOLATION_DENIED`) with the resolver + identity and resolution timestamp on success. Skipped when + the row was missing or already resolved -- in those cases the + repository returns ``False`` and no audit fires. Args: violation_id: Identifier of the violation to update. @@ -198,6 +207,14 @@ async def update_status( QueryError: Repository write failure (logged at WARNING before propagating). """ + # Branch the resolution event on the new status so the audit + # chain carries a distinct verb (allowed vs denied) rather than + # forcing readers to introspect the payload. + success_event = ( + SECURITY_SSRF_VIOLATION_ALLOWED + if status is SsrfViolationStatus.ALLOWED + else SECURITY_SSRF_VIOLATION_DENIED + ) try: updated = await self._repo.update_status( violation_id, @@ -213,7 +230,7 @@ async def update_status( # WARNING with full context before propagating per # CLAUDE.md `## Logging`. logger.warning( - API_SSRF_VIOLATION_STATUS_UPDATED, + success_event, violation_id=violation_id, status=status.value, error_type=type(exc).__name__, @@ -222,7 +239,7 @@ async def update_status( raise except Exception as exc: logger.warning( - API_SSRF_VIOLATION_STATUS_UPDATED, + success_event, violation_id=violation_id, status=status.value, error_type=type(exc).__name__, @@ -231,7 +248,7 @@ async def update_status( raise if updated: logger.info( - API_SSRF_VIOLATION_STATUS_UPDATED, + success_event, violation_id=violation_id, status=status.value, resolved_by=resolved_by, diff --git a/src/synthorg/observability/events/api.py b/src/synthorg/observability/events/api.py index 71037c9bf9..c9c2f2402a 100644 --- a/src/synthorg/observability/events/api.py +++ b/src/synthorg/observability/events/api.py @@ -217,10 +217,9 @@ API_ARTIFACT_UPDATED: Final[str] = "api.artifact.updated" API_ARTIFACT_DELETED: Final[str] = "api.artifact.deleted" -# SSRF violation mutations (recorded by self-healing security flow, -# resolved by an operator via the dashboard). -API_SSRF_VIOLATION_RECORDED: Final[str] = "api.ssrf_violation.recorded" -API_SSRF_VIOLATION_STATUS_UPDATED: Final[str] = "api.ssrf_violation.status_updated" +# SSRF violation read-side events (mutations live on the security audit +# chain via SECURITY_SSRF_VIOLATION_* in observability/events/security.py +# so signed audit consumers see the WHO+WHEN of recordings + resolutions). API_SSRF_VIOLATION_LISTED: Final[str] = "api.ssrf_violation.listed" API_SSRF_VIOLATION_FETCH_FAILED: Final[str] = "api.ssrf_violation.fetch_failed" diff --git a/tests/unit/api/services/test_ssrf_violation_service.py b/tests/unit/api/services/test_ssrf_violation_service.py index d0816c0405..0224dae153 100644 --- a/tests/unit/api/services/test_ssrf_violation_service.py +++ b/tests/unit/api/services/test_ssrf_violation_service.py @@ -22,8 +22,11 @@ from synthorg.observability.events.api import ( API_SSRF_VIOLATION_FETCH_FAILED, API_SSRF_VIOLATION_LISTED, - API_SSRF_VIOLATION_RECORDED, - API_SSRF_VIOLATION_STATUS_UPDATED, +) +from synthorg.observability.events.security import ( + SECURITY_SSRF_VIOLATION_ALLOWED, + SECURITY_SSRF_VIOLATION_DENIED, + SECURITY_SSRF_VIOLATION_RECORDED, ) from synthorg.security.ssrf_violation import SsrfViolation, SsrfViolationStatus @@ -105,7 +108,7 @@ def _make_violation( async def test_record_persists_and_emits_audit() -> None: - """``record`` saves the violation and fires ``API_SSRF_VIOLATION_RECORDED``. + """``record`` saves the violation and fires ``SECURITY_SSRF_VIOLATION_RECORDED``. Asserts the structured kwargs (``violation_id``, ``hostname``, ``port``, ``provider_name``, ``status``) so a future refactor that @@ -121,7 +124,7 @@ async def test_record_persists_and_emits_audit() -> None: fetched = await repo.get(violation.id) assert fetched == violation - events = [log for log in logs if log["event"] == API_SSRF_VIOLATION_RECORDED] + events = [log for log in logs if log["event"] == SECURITY_SSRF_VIOLATION_RECORDED] assert len(events) == 1, f"expected one event in {logs}" event = events[0] assert event["violation_id"] == violation.id @@ -150,7 +153,7 @@ async def test_record_propagates_duplicate_error() -> None: ): await service.record(violation) - audits = [log for log in logs if log["event"] == API_SSRF_VIOLATION_RECORDED] + audits = [log for log in logs if log["event"] == SECURITY_SSRF_VIOLATION_RECORDED] info_audits = [log for log in audits if log.get("log_level") == "info"] warning_audits = [log for log in audits if log.get("log_level") == "warning"] assert info_audits == [], ( @@ -309,7 +312,7 @@ async def test_update_status_emits_audit_on_success() -> None: assert fetched.resolved_by == "op-1" assert fetched.resolved_at == resolved_at - events = [log for log in logs if log["event"] == API_SSRF_VIOLATION_STATUS_UPDATED] + events = [log for log in logs if log["event"] == SECURITY_SSRF_VIOLATION_ALLOWED] assert len(events) == 1 event = events[0] assert event["violation_id"] == violation.id @@ -336,7 +339,7 @@ async def test_update_status_no_audit_when_row_missing() -> None: ) assert result is False - audits = [log for log in logs if log["event"] == API_SSRF_VIOLATION_STATUS_UPDATED] + audits = [log for log in logs if log["event"] == SECURITY_SSRF_VIOLATION_DENIED] assert audits == [] @@ -365,7 +368,9 @@ async def test_update_status_rejects_pending_target() -> None: resolved_at=resolved_at, ) - audits = [log for log in logs if log["event"] == API_SSRF_VIOLATION_STATUS_UPDATED] + # PENDING is not ALLOWED, so the service routes the warning event + # to SECURITY_SSRF_VIOLATION_DENIED (the non-allowed branch). + audits = [log for log in logs if log["event"] == SECURITY_SSRF_VIOLATION_DENIED] info_audits = [log for log in audits if log.get("log_level") == "info"] warning_audits = [log for log in audits if log.get("log_level") == "warning"] assert info_audits == [], ( @@ -402,5 +407,5 @@ async def test_update_status_no_audit_when_already_resolved() -> None: ) assert result is False - audits = [log for log in logs if log["event"] == API_SSRF_VIOLATION_STATUS_UPDATED] + audits = [log for log in logs if log["event"] == SECURITY_SSRF_VIOLATION_DENIED] assert audits == [] From 0a4f2dcdd6dccba1faf0434e38e37d9269449b0a Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:54:20 +0200 Subject: [PATCH 07/35] feat: rate-limit /oauth/initiate and /settings/security/import Two write endpoints had no per-op rate limit despite being expensive: /oauth/initiate kicks off an external authorization flow, and /settings/security/import validates and persists a full SecurityConfig payload. Add policies oauth.initiate (10 req/60s/user) and settings.import (5 req/3600s/user) to RATE_LIMIT_POLICIES, and attach per_op_rate_limit_from_policy guards to both routes. Refs #1733 (#10) --- src/synthorg/api/controllers/oauth.py | 5 ++++- src/synthorg/api/controllers/settings.py | 8 +++++++- src/synthorg/api/rate_limits/policies.py | 2 ++ tests/unit/api/rate_limits/test_policies.py | 2 ++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/synthorg/api/controllers/oauth.py b/src/synthorg/api/controllers/oauth.py index a0ea3c437d..334530d0a9 100644 --- a/src/synthorg/api/controllers/oauth.py +++ b/src/synthorg/api/controllers/oauth.py @@ -73,7 +73,10 @@ class OAuthController(Controller): @post( "/initiate", - guards=[require_write_access], + guards=[ + require_write_access, + per_op_rate_limit_from_policy("oauth.initiate", key="user"), + ], summary="Start an OAuth flow", ) async def initiate_flow( diff --git a/src/synthorg/api/controllers/settings.py b/src/synthorg/api/controllers/settings.py index a5879b9702..420573c73f 100644 --- a/src/synthorg/api/controllers/settings.py +++ b/src/synthorg/api/controllers/settings.py @@ -640,7 +640,13 @@ async def export_security_config( ), ) - @post("/security/import", guards=[require_ceo_or_manager]) + @post( + "/security/import", + guards=[ + require_ceo_or_manager, + per_op_rate_limit_from_policy("settings.import", key="user"), + ], + ) async def import_security_config( self, state: State, diff --git a/src/synthorg/api/rate_limits/policies.py b/src/synthorg/api/rate_limits/policies.py index 606d223882..df51d20663 100644 --- a/src/synthorg/api/rate_limits/policies.py +++ b/src/synthorg/api/rate_limits/policies.py @@ -109,6 +109,7 @@ "memory.fine_tune_resume": (5, 3600), # oauth "oauth.callback": (30, 60), + "oauth.initiate": (10, 60), # ontology "ontology.admin_derive": (5, 60), "ontology.admin_sync_org_memory": (5, 60), @@ -157,6 +158,7 @@ "scaling.update_strategy": (30, 60), # settings "settings.delete": (60, 60), + "settings.import": (5, 3600), "settings.update": (60, 60), # setup "setup.complete": (5, 3600), diff --git a/tests/unit/api/rate_limits/test_policies.py b/tests/unit/api/rate_limits/test_policies.py index f8e7dbfc80..a87b14a27c 100644 --- a/tests/unit/api/rate_limits/test_policies.py +++ b/tests/unit/api/rate_limits/test_policies.py @@ -155,6 +155,8 @@ def test_meta_chat_guard_builds(self) -> None: "messages.delete": (100, 3600), "meta.ingest_events": (60, 60), "meta.trigger_cycle": (1, 60), + "oauth.initiate": (10, 60), + "settings.import": (5, 3600), "simulations.cancel": (30, 60), "tasks.coordinate": (10, 60), } From 10648746eaecba1d175e9767a80f121c2a09b55e Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:56:20 +0200 Subject: [PATCH 08/35] refactor: extract REJECTION_REASON_REQUIRED to shared module The 'Rejection requires a reason for the approval record. Provide a brief explanation.' literal lived inline at three sites: the approval detail drawer's inline field error + toast, and the batch reject toast on ApprovalsPage. Move to web/src/pages/approvals/errors.ts so a future copy tweak lands in one place. Refs #1733 (#28) --- web/src/pages/ApprovalsPage.tsx | 4 ++-- web/src/pages/approvals/ApprovalDetailDrawer.tsx | 8 +++----- web/src/pages/approvals/errors.ts | 11 +++++++++++ 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 web/src/pages/approvals/errors.ts diff --git a/web/src/pages/ApprovalsPage.tsx b/web/src/pages/ApprovalsPage.tsx index 8dc8d23830..655238da28 100644 --- a/web/src/pages/ApprovalsPage.tsx +++ b/web/src/pages/ApprovalsPage.tsx @@ -23,6 +23,7 @@ import { formatNumber } from '@/utils/format' import { ApprovalFilterBar } from './approvals/ApprovalFilterBar' import { ApprovalRiskGroupSection } from './approvals/ApprovalRiskGroupSection' import { ApprovalDetailDrawer } from './approvals/ApprovalDetailDrawer' +import { REJECTION_REASON_REQUIRED } from './approvals/errors' import { BatchActionBar } from './approvals/BatchActionBar' import { ApprovalsSkeleton } from './approvals/ApprovalsSkeleton' import type { ApprovalRiskLevel } from '@/api/types/enums' @@ -181,8 +182,7 @@ export default function ApprovalsPage() { useToastStore.getState().add({ variant: 'error', title: 'Rejection reason required', - description: - 'Rejection requires a reason for the approval record. Provide a brief explanation.', + description: REJECTION_REASON_REQUIRED, }) return } diff --git a/web/src/pages/approvals/ApprovalDetailDrawer.tsx b/web/src/pages/approvals/ApprovalDetailDrawer.tsx index ea82cb9186..2fba839363 100644 --- a/web/src/pages/approvals/ApprovalDetailDrawer.tsx +++ b/web/src/pages/approvals/ApprovalDetailDrawer.tsx @@ -8,6 +8,7 @@ import { ErrorBanner } from '@/components/ui/error-banner' import { InputField } from '@/components/ui/input-field' import { springDefault, overlayBackdrop, tweenExitFast } from '@/lib/motion' import { ApprovalTimeline } from './ApprovalTimeline' +import { REJECTION_REASON_REQUIRED } from './errors' import { getApprovalStatusLabel, getRiskLevelColor, @@ -189,14 +190,11 @@ export function ApprovalDetailDrawer({ // Inline field error so the user sees a red border + helper // text on the InputField itself, not just a toast that flies // away. The toast remains as a secondary live-region signal. - setReasonError( - 'Rejection requires a reason for the approval record. Provide a brief explanation.', - ) + setReasonError(REJECTION_REASON_REQUIRED) useToastStore.getState().add({ variant: 'error', title: 'Rejection reason required', - description: - 'Rejection requires a reason for the approval record. Provide a brief explanation.', + description: REJECTION_REASON_REQUIRED, }) return false } diff --git a/web/src/pages/approvals/errors.ts b/web/src/pages/approvals/errors.ts new file mode 100644 index 0000000000..c9dec0082b --- /dev/null +++ b/web/src/pages/approvals/errors.ts @@ -0,0 +1,11 @@ +/** + * User-facing error / hint strings for the approvals workflow. + * + * The rejection-reason validation hint is duplicated across the + * approval drawer (real-time character counter + form-submit guard) + * and the ApprovalsPage list-level reject action; centralising the + * literal here keeps the wording in lockstep when a future copy + * tweak lands. + */ +export const REJECTION_REASON_REQUIRED = + 'Rejection requires a reason for the approval record. Provide a brief explanation.' From 5a227fd6fda7d6b3c4ee01193f3ead535a74d72c Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 19:57:27 +0200 Subject: [PATCH 09/35] fix: close tar.extractfile handles in backup service + retention tarfile.TarFile.extractfile() returns a file-like object that must be closed; on CPython the GC closes eventually, but on PyPy or fast restart paths the file descriptor can outlive the request. Both manifest readers now wrap the returned object in a 'with' block so the fd is released as soon as the read completes, before the JSON-validation step. Refs #1733 (#9) --- src/synthorg/backup/retention.py | 8 +++++--- src/synthorg/backup/service_archive.py | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/synthorg/backup/retention.py b/src/synthorg/backup/retention.py index e20bd71c08..709b04e092 100644 --- a/src/synthorg/backup/retention.py +++ b/src/synthorg/backup/retention.py @@ -188,10 +188,12 @@ def _load_archive_manifest(entry: Path) -> BackupManifest | None: member = tar.getmember("manifest.json") except KeyError: return None - f = tar.extractfile(member) - if f is None: + extracted = tar.extractfile(member) + if extracted is None: return None - data = json.loads(f.read()) + with extracted as f: + raw = f.read() + data = json.loads(raw) return BackupManifest.model_validate(data) except MemoryError, RecursionError: raise diff --git a/src/synthorg/backup/service_archive.py b/src/synthorg/backup/service_archive.py index 3a9126f1f9..d4d653ac98 100644 --- a/src/synthorg/backup/service_archive.py +++ b/src/synthorg/backup/service_archive.py @@ -417,10 +417,11 @@ def _read_manifest_from_archive( member = tar.getmember("manifest.json") except KeyError: return None - f = tar.extractfile(member) - if f is None: + extracted = tar.extractfile(member) + if extracted is None: return None - raw = f.read(_MANIFEST_MAX_SIZE + 1) + with extracted as f: + raw = f.read(_MANIFEST_MAX_SIZE + 1) if len(raw) > _MANIFEST_MAX_SIZE: logger.warning( BACKUP_MANIFEST_INVALID, From c66d2be1d44848dfb04237eb3b1147a9bd00c7dd Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:01:42 +0200 Subject: [PATCH 10/35] refactor: extract scoring magic numbers to module-level constants Extract weight/threshold literals to named constants in three scoring modules without changing their values. The constants document intent, let docstrings reference symbols rather than restate numbers, and prepare the way for future tuning experiments to land as single-line diffs. - engine/routing/scorer.py: PRIMARY_SKILL_WEIGHT (0.4), SECONDARY_SKILL_WEIGHT (0.2), TAG_MATCH_BONUS (0.1), ROLE_MATCH_BONUS (0.2), SENIORITY_ALIGNMENT_BONUS (0.2). - engine/quality/graders/heuristic.py: PASS_GRADE (0.8), FAIL_GRADE (0.3), CONFIDENCE_CEILING (0.9), CONFIDENCE_BIAS (0.1). - templates/model_matcher.py: TIER_BASE_SCORE (0.5), HEADROOM_MAX_BONUS (0.25), PRIORITY_MAX_BONUS (0.25), HEADROOM_RATIO_CAP (2.0), BALANCED_PARTIAL_CREDIT (0.125). Refs #1733 (#22) --- .../engine/quality/graders/heuristic.py | 8 +++- src/synthorg/engine/routing/scorer.py | 24 ++++++++---- src/synthorg/templates/model_matcher.py | 37 ++++++++++++++----- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/synthorg/engine/quality/graders/heuristic.py b/src/synthorg/engine/quality/graders/heuristic.py index 48b1513c09..b45d50ac44 100644 --- a/src/synthorg/engine/quality/graders/heuristic.py +++ b/src/synthorg/engine/quality/graders/heuristic.py @@ -22,6 +22,10 @@ logger = get_logger(__name__) _PASS_THRESHOLD = 0.5 +_PASS_GRADE = 0.8 +_FAIL_GRADE = 0.3 +_CONFIDENCE_CEILING = 0.9 +_CONFIDENCE_BIAS = 0.1 class HeuristicRubricGrader: @@ -85,10 +89,10 @@ async def grade( # All criteria share the global probe_ratio because the # data model has no probe-to-criterion mapping yet. per_criterion_grades[criterion.name] = ( - 0.8 if probe_ratio >= _PASS_THRESHOLD else 0.3 + _PASS_GRADE if probe_ratio >= _PASS_THRESHOLD else _FAIL_GRADE ) - confidence = min(0.9, probe_ratio + 0.1) + confidence = min(_CONFIDENCE_CEILING, probe_ratio + _CONFIDENCE_BIAS) min_conf = rubric.min_confidence if confidence < min_conf: diff --git a/src/synthorg/engine/routing/scorer.py b/src/synthorg/engine/routing/scorer.py index 7a2c4d1254..8f2dcb5bea 100644 --- a/src/synthorg/engine/routing/scorer.py +++ b/src/synthorg/engine/routing/scorer.py @@ -20,6 +20,16 @@ logger = get_logger(__name__) +# Score-component weights. Tuned empirically; sum to 1.1 with the tag +# bonus, capped at 1.0 by the caller. Extracted to module-level +# constants so the same value drives both the docstring and the +# arithmetic in one place. +_PRIMARY_SKILL_WEIGHT = 0.4 +_SECONDARY_SKILL_WEIGHT = 0.2 +_TAG_MATCH_BONUS = 0.1 +_ROLE_MATCH_BONUS = 0.2 +_SENIORITY_ALIGNMENT_BONUS = 0.2 + # Seniority-to-complexity alignment mapping _SENIORITY_COMPLEXITY: dict[SeniorityLevel, tuple[Complexity, ...]] = { SeniorityLevel.JUNIOR: (Complexity.SIMPLE,), @@ -155,7 +165,7 @@ def _score_skill_tiers( primary_contrib = ( sum(primary_by_id[sid].proficiency for sid in primary_matched) / len(required) - * 0.4 + * _PRIMARY_SKILL_WEIGHT ) score += primary_contrib all_matched.extend(primary_matched) @@ -165,7 +175,7 @@ def _score_skill_tiers( secondary_contrib = ( sum(secondary_by_id[sid].proficiency for sid in secondary_matched) / len(required) - * 0.2 + * _SECONDARY_SKILL_WEIGHT ) score += secondary_contrib all_matched.extend(secondary_matched) @@ -180,7 +190,7 @@ def _score_skill_tiers( for sid in secondary_matched: matched_tags.update(secondary_by_id[sid].tags) if required_tags <= matched_tags: - score += 0.1 + score += _TAG_MATCH_BONUS reasons.append(f"tag match: {sorted(required_tags)}") return score, all_matched @@ -191,13 +201,13 @@ def _score_role( subtask: SubtaskDefinition, reasons: list[str], ) -> float: - """Award 0.2 when the subtask's required_role matches the agent's role.""" + """Award the role-match bonus when the agent's role matches required_role.""" if ( subtask.required_role is not None and agent.role.casefold() == subtask.required_role.casefold() ): reasons.append("role match") - return 0.2 + return _ROLE_MATCH_BONUS return 0.0 @@ -206,12 +216,12 @@ def _score_seniority_alignment( subtask: SubtaskDefinition, reasons: list[str], ) -> float: - """Award 0.2 when the agent's seniority matches the subtask's complexity.""" + """Award the seniority-alignment bonus when level matches complexity.""" aligned = _SENIORITY_COMPLEXITY.get(agent.level, ()) if subtask.estimated_complexity in aligned: reasons.append( f"seniority {agent.level.value} aligns with " f"complexity {subtask.estimated_complexity.value}" ) - return 0.2 + return _SENIORITY_ALIGNMENT_BONUS return 0.0 diff --git a/src/synthorg/templates/model_matcher.py b/src/synthorg/templates/model_matcher.py index 8fcc2c59e3..4a43a5ca5a 100644 --- a/src/synthorg/templates/model_matcher.py +++ b/src/synthorg/templates/model_matcher.py @@ -358,6 +358,18 @@ def _rank_by_priority( return min(models, key=lambda m: abs(m.cost_per_1k_input - mid)) +# Three score components, each contributing up to ``_TIER_BASE`` / +# ``_HEADROOM_MAX`` / ``_PRIORITY_MAX``. Sum is capped at 1.0 by +# ``_compute_score``. ``_HEADROOM_RATIO_CAP`` clamps the headroom curve +# so a model with 10x the requested context does not displace a tighter +# fit on the priority axis. +_TIER_BASE_SCORE = 0.5 +_HEADROOM_MAX_BONUS = 0.25 +_PRIORITY_MAX_BONUS = 0.25 +_HEADROOM_RATIO_CAP = 2.0 +_BALANCED_PARTIAL_CREDIT = 0.125 + + def _compute_score( model: ProviderModelConfig, requirement: ModelRequirement, @@ -365,21 +377,26 @@ def _compute_score( ) -> float: """Compute a 0-1 quality score for a match. - Factors: base score (0.5), context headroom (0.25), priority - alignment (0.25). + Factors: base score (_TIER_BASE_SCORE), context headroom + (_HEADROOM_MAX_BONUS), priority alignment (_PRIORITY_MAX_BONUS). """ - score = 0.5 # Base score for being in the right tier. + score = _TIER_BASE_SCORE # Context headroom bonus. if requirement.min_context > 0: headroom = model.max_context / requirement.min_context - score += min(0.25, 0.25 * min(headroom, 2.0) / 2.0) + score += min( + _HEADROOM_MAX_BONUS, + _HEADROOM_MAX_BONUS + * min(headroom, _HEADROOM_RATIO_CAP) + / _HEADROOM_RATIO_CAP, + ) else: - score += 0.25 + score += _HEADROOM_MAX_BONUS # Priority alignment bonus. if len(tier_candidates) <= 1: - score += 0.25 + score += _PRIORITY_MAX_BONUS else: score += _priority_alignment_bonus( model, @@ -414,9 +431,9 @@ def _priority_alignment_bonus( max_rank = len(ranked) - 1 if priority == "quality": - return 0.25 * (model_rank / max_rank) + return _PRIORITY_MAX_BONUS * (model_rank / max_rank) if priority == "cost": - return 0.25 * (1 - model_rank / max_rank) + return _PRIORITY_MAX_BONUS * (1 - model_rank / max_rank) if priority == "speed": # Rank by latency: lowest latency gets full bonus. latency_ranked = sorted( @@ -429,6 +446,6 @@ def _priority_alignment_bonus( ) latency_map = {id(m): r for r, m in enumerate(latency_ranked)} latency_rank = latency_map.get(id(model), 0) - return 0.25 * (1 - latency_rank / max_rank) + return _PRIORITY_MAX_BONUS * (1 - latency_rank / max_rank) # "balanced" -- partial credit. - return 0.125 + return _BALANCED_PARTIAL_CREDIT From 3b060980f87ce4acda592e9c98291c8914de9052 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:07:07 +0200 Subject: [PATCH 11/35] feat: bound Prometheus tool_name label via snapshot from ToolRegistry record_tool_invocation accepted free-form tool_name, leaving the metric vulnerable to cardinality explosion if a buggy caller fabricated names. Add validate_tool_name() that consults the same process-global label snapshot used by validate_agent_id and validate_workflow_definition_id; the snapshot's tool_names set is refreshed from app_state.tool_registry.list_tools() on every scrape. Plugin-loaded tools are picked up the next time refresh() runs, so the bound stays in lockstep with the registry without requiring runtime callers to manage the allowlist. Refs #1733 (#12) --- .../observability/prometheus_collector.py | 36 +++++++++++++++++++ .../observability/prometheus_labels.py | 17 +++++++++ .../observability/prometheus_recording.py | 5 +++ .../test_prometheus_collector_new_metrics.py | 28 ++++++++++++++- 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/synthorg/observability/prometheus_collector.py b/src/synthorg/observability/prometheus_collector.py index 6f81e9c599..36003b3b08 100644 --- a/src/synthorg/observability/prometheus_collector.py +++ b/src/synthorg/observability/prometheus_collector.py @@ -114,6 +114,33 @@ async def _fetch_departments(app_state: AppState) -> frozenset[str] | None: return frozenset(str(r.name) for r in records) +async def _fetch_tool_names(app_state: AppState) -> frozenset[str] | None: + """Pull the registered tool-name set from the tool registry. + + Same return contract as :func:`_fetch_departments`: empty + frozenset when the registry is not wired, real set on success, + ``None`` on exception so the merge step preserves the previous + allowlist. Synchronous reads from a frozen ``MappingProxyType`` + cannot raise meaningfully today, but the registry exposure path + may grow async I/O later (plugin lazy-load, MCP server discovery) + so this is wrapped for symmetry with the other registry fetchers. + """ + try: + registry = getattr(app_state, "tool_registry", None) + if registry is None: + return frozenset() + return frozenset(registry.list_tools()) + except MemoryError, RecursionError: + raise + except Exception: + logger.warning( + METRICS_SCRAPE_FAILED, + component="tool_registry", + exc_info=True, + ) + return None + + class PrometheusCollector(RecordingMixin): """Collects business metrics from SynthOrg services for Prometheus. @@ -330,10 +357,12 @@ async def _rebuild_label_snapshot( async with asyncio.TaskGroup() as tg: wf_task = tg.create_task(_fetch_workflow_definitions(app_state)) dept_task = tg.create_task(_fetch_departments(app_state)) + tool_task = tg.create_task(_fetch_tool_names(app_state)) await self._merge_and_update_snapshot( agent_ids=agent_ids, wf_ids=wf_task.result(), dept_ids=dept_task.result(), + tool_names=tool_task.result(), ) @staticmethod @@ -342,6 +371,7 @@ async def _merge_and_update_snapshot( agent_ids: frozenset[str] | None, wf_ids: frozenset[str] | None, dept_ids: frozenset[str] | None, + tool_names: frozenset[str] | None, ) -> None: """Merge with the previous snapshot and atomically rebind. @@ -368,11 +398,15 @@ async def _merge_and_update_snapshot( merged_departments = ( dept_ids if dept_ids is not None else previous.departments ) + merged_tool_names = ( + tool_names if tool_names is not None else previous.tool_names + ) update_label_snapshot( _LabelSnapshot( agent_ids=merged_agent_ids, workflow_definition_ids=merged_workflow_ids, departments=merged_departments, + tool_names=merged_tool_names, agent_ids_seeded=previous.agent_ids_seeded or (agent_ids is not None), workflow_definition_ids_seeded=( @@ -380,6 +414,8 @@ async def _merge_and_update_snapshot( ), departments_seeded=previous.departments_seeded or (dept_ids is not None), + tool_names_seeded=previous.tool_names_seeded + or (tool_names is not None), ), ) diff --git a/src/synthorg/observability/prometheus_labels.py b/src/synthorg/observability/prometheus_labels.py index c9533f0bbd..56331ddfc9 100644 --- a/src/synthorg/observability/prometheus_labels.py +++ b/src/synthorg/observability/prometheus_labels.py @@ -47,6 +47,7 @@ "update_label_snapshot", "validate_agent_id", "validate_department", + "validate_tool_name", "validate_workflow_definition_id", ] @@ -292,9 +293,11 @@ class _LabelSnapshot: agent_ids: frozenset[str] = frozenset() workflow_definition_ids: frozenset[str] = frozenset() departments: frozenset[str] = frozenset() + tool_names: frozenset[str] = frozenset() agent_ids_seeded: bool = False workflow_definition_ids_seeded: bool = False departments_seeded: bool = False + tool_names_seeded: bool = False _INITIAL_SNAPSHOT: Final[_LabelSnapshot] = _LabelSnapshot() @@ -386,6 +389,20 @@ def validate_department(value: str) -> None: require_label_summary("department", value, snapshot.departments) +def validate_tool_name(value: str) -> None: + """Raise ``ValueError`` if *value* is not a registered tool name. + + Bounds the ``tool_name`` Prometheus label against the running + ToolRegistry so plugin-loaded tools are accepted but a runaway + caller that fabricates names cannot inflate cardinality. Fails + closed during bootstrap (no snapshot seeded yet); push-time + callers go through ``metrics_hub._safe_record`` so the rejected + sample drops cleanly. + """ + snapshot = _snapshot + require_label_summary("tool_name", value, snapshot.tool_names) + + def is_known_agent_id(value: str) -> bool: """Return ``True`` if *value* is a known agent id. diff --git a/src/synthorg/observability/prometheus_recording.py b/src/synthorg/observability/prometheus_recording.py index 109e454f20..833a61f17d 100644 --- a/src/synthorg/observability/prometheus_recording.py +++ b/src/synthorg/observability/prometheus_recording.py @@ -48,6 +48,7 @@ status_class, validate_agent_id, validate_department, + validate_tool_name, validate_workflow_definition_id, ) @@ -231,6 +232,10 @@ def record_tool_invocation( ValueError: If *outcome* is not a valid value or ``duration_sec`` is negative. """ + # tool_name is bounded against the running ToolRegistry's + # snapshot; fabricated names are rejected at push time so + # cardinality cannot grow beyond the registry's size. + validate_tool_name(tool_name) require_label("tool outcome", outcome, VALID_TOOL_OUTCOMES) require_non_negative("record_tool_invocation: duration_sec", duration_sec) self._tool_invocations.labels( diff --git a/tests/unit/observability/test_prometheus_collector_new_metrics.py b/tests/unit/observability/test_prometheus_collector_new_metrics.py index b9829661fa..996bedc45a 100644 --- a/tests/unit/observability/test_prometheus_collector_new_metrics.py +++ b/tests/unit/observability/test_prometheus_collector_new_metrics.py @@ -7,16 +7,42 @@ instead of silently polluting the metric family. """ +from collections.abc import Generator + import pytest from prometheus_client import generate_latest from prometheus_client.parser import text_string_to_metric_families from synthorg.observability.prometheus_collector import PrometheusCollector -from synthorg.observability.prometheus_labels import status_class +from synthorg.observability.prometheus_labels import ( + _LabelSnapshot, + _reset_label_snapshot_for_tests, + status_class, + update_label_snapshot, +) pytestmark = pytest.mark.unit +@pytest.fixture(autouse=True) +def _seed_tool_name_snapshot() -> Generator[None]: + """Seed the prometheus label snapshot with bounded tool-name values. + + record_tool_invocation now bounds ``tool_name`` against the + snapshot maintained by PrometheusCollector.refresh(); these unit + tests never invoke refresh() so we seed manually. Reset on + teardown so cross-file tests start from a clean snapshot. + """ + update_label_snapshot( + _LabelSnapshot( + tool_names=frozenset({"web_search", "calculator", "t"}), + tool_names_seeded=True, + ), + ) + yield + _reset_label_snapshot_for_tests() + + def _parse( collector: PrometheusCollector, ) -> dict[str, list[tuple[dict[str, str], float]]]: From f9dbcf376fe7c5ac740db10bedfcedf9ecb9816c Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:09:57 +0200 Subject: [PATCH 12/35] fix: serialise circuit_breaker / trust / inmemory mutations under locks Three hot-path classes mutated shared state without synchronisation: - DelegationCircuitBreaker (loop_prevention/circuit_breaker.py): _pairs and _dirty are mutated by sync get_state() and record_delegation() called from many concurrent async paths. Add a threading.RLock guarding the read-modify-write regions; RLock so a future caller that re-enters via get_state() inside record_delegation() does not deadlock. - TrustService.apply_trust_change / evaluate_agent (security/trust/service.py): the setdefault().append() pattern on _change_history plus the dict assignment to _trust_states formed a multi-step read-modify-write that two concurrent same-agent calls could interleave, dropping one update or producing a phantom _change_history entry that no _trust_states row backs. Add an asyncio.Lock and hold it across both writes. - InMemoryBackend.store (memory/backends/inmemory/adapter.py): the setdefault() / capacity check / assign chain could silently exceed max_memories_per_agent under concurrent load. Add a separate _store_lock (kept distinct from _connect_lock so connect/disconnect do not serialise hot-path traffic). Refs #1733 (#6) --- .../loop_prevention/circuit_breaker.py | 92 +++++++++++-------- .../memory/backends/inmemory/adapter.py | 40 ++++---- src/synthorg/security/trust/service.py | 35 +++++-- 3 files changed, 105 insertions(+), 62 deletions(-) diff --git a/src/synthorg/communication/loop_prevention/circuit_breaker.py b/src/synthorg/communication/loop_prevention/circuit_breaker.py index 25299b23f8..8aaaf17a88 100644 --- a/src/synthorg/communication/loop_prevention/circuit_breaker.py +++ b/src/synthorg/communication/loop_prevention/circuit_breaker.py @@ -1,5 +1,6 @@ """Circuit breaker for delegation bounces between agent pairs.""" +import threading import time from collections.abc import Callable # noqa: TC003 from enum import StrEnum @@ -67,7 +68,14 @@ class DelegationCircuitBreaker: restarts. """ - __slots__ = ("_clock", "_config", "_dirty", "_pairs", "_state_repo") + __slots__ = ( + "_clock", + "_config", + "_dirty", + "_pairs", + "_state_lock", + "_state_repo", + ) def __init__( self, @@ -81,6 +89,12 @@ def __init__( self._state_repo = state_repo self._pairs: dict[tuple[str, str], _PairState] = {} self._dirty: set[tuple[str, str]] = set() + # Sync RLock guards _pairs + _dirty mutations. The breaker is + # called from sync code paths inside async tasks; a + # threading.RLock works in both contexts (pure Python Lock + # would re-enter and deadlock if get_state calls the same + # locked region indirectly via the repo). + self._state_lock = threading.RLock() def _get_pair( self, @@ -136,26 +150,27 @@ def get_state( Returns: Current state of the circuit breaker. """ - pair = self._get_pair(delegator_id, delegatee_id) - if pair is None: - return CircuitBreakerState.CLOSED - if pair.opened_at is not None: - elapsed = self._clock() - pair.opened_at - cooldown = self._compute_cooldown(pair.trip_count) - if elapsed < cooldown: - return CircuitBreakerState.OPEN - # Cooldown expired: reset bounce count, preserve trip history - key = pair_key(delegator_id, delegatee_id) - pair.bounce_count = 0 - pair.opened_at = None - self._dirty.add(key) - logger.info( - DELEGATION_LOOP_CIRCUIT_RESET, - delegator=delegator_id, - delegatee=delegatee_id, - cooldown_seconds=cooldown, - trip_count=pair.trip_count, - ) + with self._state_lock: + pair = self._get_pair(delegator_id, delegatee_id) + if pair is None: + return CircuitBreakerState.CLOSED + if pair.opened_at is not None: + elapsed = self._clock() - pair.opened_at + cooldown = self._compute_cooldown(pair.trip_count) + if elapsed < cooldown: + return CircuitBreakerState.OPEN + # Cooldown expired: reset bounce count, preserve trip history + key = pair_key(delegator_id, delegatee_id) + pair.bounce_count = 0 + pair.opened_at = None + self._dirty.add(key) + logger.info( + DELEGATION_LOOP_CIRCUIT_RESET, + delegator=delegator_id, + delegatee=delegatee_id, + cooldown_seconds=cooldown, + trip_count=pair.trip_count, + ) return CircuitBreakerState.CLOSED def check( @@ -218,23 +233,24 @@ def record_delegation( state = self.get_state(delegator_id, delegatee_id) if state is CircuitBreakerState.OPEN: return - pair = self._get_or_create_pair(delegator_id, delegatee_id) - pair.bounce_count += 1 - if pair.bounce_count >= self._config.bounce_threshold: - pair.trip_count += 1 - pair.opened_at = self._clock() - cooldown = self._compute_cooldown(pair.trip_count) - key = pair_key(delegator_id, delegatee_id) - self._dirty.add(key) - logger.warning( - DELEGATION_LOOP_CIRCUIT_BACKOFF, - delegator=delegator_id, - delegatee=delegatee_id, - bounce_count=pair.bounce_count, - threshold=self._config.bounce_threshold, - trip_count=pair.trip_count, - cooldown_seconds=cooldown, - ) + with self._state_lock: + pair = self._get_or_create_pair(delegator_id, delegatee_id) + pair.bounce_count += 1 + if pair.bounce_count >= self._config.bounce_threshold: + pair.trip_count += 1 + pair.opened_at = self._clock() + cooldown = self._compute_cooldown(pair.trip_count) + key = pair_key(delegator_id, delegatee_id) + self._dirty.add(key) + logger.warning( + DELEGATION_LOOP_CIRCUIT_BACKOFF, + delegator=delegator_id, + delegatee=delegatee_id, + bounce_count=pair.bounce_count, + threshold=self._config.bounce_threshold, + trip_count=pair.trip_count, + cooldown_seconds=cooldown, + ) # Persistence helpers (async, called outside hot path) diff --git a/src/synthorg/memory/backends/inmemory/adapter.py b/src/synthorg/memory/backends/inmemory/adapter.py index 7db8bacbb3..93c96b1df6 100644 --- a/src/synthorg/memory/backends/inmemory/adapter.py +++ b/src/synthorg/memory/backends/inmemory/adapter.py @@ -64,6 +64,13 @@ def __init__( self._store: dict[str, dict[str, MemoryEntry]] = {} self._connected = False self._connect_lock = asyncio.Lock() + # Hot-path lock guarding _store mutations. Without it, two + # concurrent store() calls for the same agent can race + # between the setdefault() / capacity check and the assign, + # silently exceeding max_memories_per_agent. Separate from + # the connect_lock so connect()/disconnect() do not serialise + # store/retrieve traffic. + self._store_lock = asyncio.Lock() # -- Lifecycle ---------------------------------------------------- @@ -169,21 +176,6 @@ async def store( MemoryStoreError: If the per-agent limit is reached. """ self._require_connected() - agent_store = self._store.setdefault(str(agent_id), {}) - # Prune expired entries before checking quota. - _prune_expired(agent_store) - if len(agent_store) >= self._max_memories_per_agent: - msg = ( - f"Agent {agent_id} has reached the memory limit " - f"({self._max_memories_per_agent})" - ) - logger.warning( - MEMORY_ENTRY_STORE_FAILED, - agent_id=agent_id, - reason="limit_reached", - error=msg, - ) - raise MemoryStoreError(msg) memory_id = NotBlankStr(str(uuid.uuid4())) now = datetime.now(UTC) entry = MemoryEntry( @@ -196,7 +188,23 @@ async def store( created_at=now, expires_at=request.expires_at, ) - agent_store[str(memory_id)] = entry + async with self._store_lock: + agent_store = self._store.setdefault(str(agent_id), {}) + # Prune expired entries before checking quota. + _prune_expired(agent_store) + if len(agent_store) >= self._max_memories_per_agent: + msg = ( + f"Agent {agent_id} has reached the memory limit " + f"({self._max_memories_per_agent})" + ) + logger.warning( + MEMORY_ENTRY_STORE_FAILED, + agent_id=agent_id, + reason="limit_reached", + error=msg, + ) + raise MemoryStoreError(msg) + agent_store[str(memory_id)] = entry logger.debug( MEMORY_ENTRY_STORED, backend="inmemory", diff --git a/src/synthorg/security/trust/service.py b/src/synthorg/security/trust/service.py index 2463c85300..2060b864af 100644 --- a/src/synthorg/security/trust/service.py +++ b/src/synthorg/security/trust/service.py @@ -4,6 +4,7 @@ and trust level changes for agents. """ +import asyncio from datetime import UTC, datetime from typing import TYPE_CHECKING from uuid import uuid4 @@ -63,6 +64,14 @@ def __init__( self._approval_store = approval_store self._trust_states: dict[str, TrustState] = {} self._change_history: dict[str, list[TrustChangeRecord]] = {} + # Hot-path lock guarding _trust_states + _change_history. + # Two concurrent apply_trust_change / evaluate_agent calls for + # the same agent could otherwise interleave between read of + # _trust_states[key] and the assignment back, losing one + # update; the setdefault().append() pattern at the end of + # apply_trust_change is similarly non-atomic. Lock the full + # read-modify-write region in both methods. + self._state_lock = asyncio.Lock() def initialize_agent(self, agent_id: NotBlankStr) -> TrustState: """Create initial trust state for a new agent. @@ -105,7 +114,8 @@ async def evaluate_agent( evaluation fails. """ key = str(agent_id) - state = self._trust_states.get(key) + async with self._state_lock: + state = self._trust_states.get(key) if state is None: msg = f"Agent {agent_id!r} not initialized for trust tracking" logger.warning( @@ -132,10 +142,13 @@ async def evaluate_agent( # Update last_evaluated_at now = datetime.now(UTC) - updated_state = state.model_copy( - update={"last_evaluated_at": now}, - ) - self._trust_states[key] = updated_state + async with self._state_lock: + # Re-read in case another coroutine raced us; merge the + # last_evaluated_at update onto whatever the latest state is. + current = self._trust_states.get(key, state) + self._trust_states[key] = current.model_copy( + update={"last_evaluated_at": now}, + ) logger.debug( TRUST_EVALUATE_COMPLETE, @@ -215,9 +228,15 @@ async def apply_trust_change( } if is_promotion: state_update["last_promoted_at"] = now - updated = state.model_copy(update=state_update) - self._trust_states[key] = updated - self._change_history.setdefault(key, []).append(record) + # Lock both writes so a concurrent apply_trust_change / + # evaluate_agent for the same agent cannot interleave between + # the dict assign and the change_history append (both must + # land or neither, otherwise log readers see a phantom + # transition that no state row backs). + async with self._state_lock: + updated = state.model_copy(update=state_update) + self._trust_states[key] = updated + self._change_history.setdefault(key, []).append(record) logger.info( TRUST_LEVEL_CHANGED, From 86c1f48328418c750442f30abd2ffdf07ae5a5e9 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:12:57 +0200 Subject: [PATCH 13/35] feat: install lifecycle locks on Worker + ApprovalTimeoutScheduler Two service classes had no lifecycle synchronisation despite owning async start/stop methods: - Worker (workers/worker.py): an in-place runner where run() is the loop body itself, so the lock guards only the _running flag transition (acquire briefly to check-and-set, release before the loop body, re-acquire in the finally to clear). Holding it across the whole run() body would deadlock a second concurrent caller. - ApprovalTimeoutScheduler (security/timeout/scheduler.py): start() was sync; converted to async. Added _lifecycle_lock held across the full body of both start() and stop() so two concurrent start() callers cannot both pass the is_running guard and spawn duplicate scheduler tasks. Added _stop_failed unrestartable flag per the canonical pattern in docs/reference/lifecycle-sync.md. Updated callers in api/lifecycle.py and the test suite to await scheduler.start(). Refs #1733 (#7) --- src/synthorg/api/lifecycle.py | 2 +- src/synthorg/security/timeout/scheduler.py | 61 +++++++++++++------ src/synthorg/workers/worker.py | 30 ++++++--- tests/unit/security/timeout/test_scheduler.py | 8 +-- 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/src/synthorg/api/lifecycle.py b/src/synthorg/api/lifecycle.py index 6d79cbb6b4..6f5574796c 100644 --- a/src/synthorg/api/lifecycle.py +++ b/src/synthorg/api/lifecycle.py @@ -578,7 +578,7 @@ async def _safe_startup( # noqa: PLR0913, PLR0912, PLR0915, C901 app_state.set_approval_timeout_scheduler( approval_timeout_scheduler, ) - approval_timeout_scheduler.start() + await approval_timeout_scheduler.start() started_approval_timeout_scheduler = True except Exception: logger.exception( diff --git a/src/synthorg/security/timeout/scheduler.py b/src/synthorg/security/timeout/scheduler.py index 1afd71aeaf..c0e999a3ac 100644 --- a/src/synthorg/security/timeout/scheduler.py +++ b/src/synthorg/security/timeout/scheduler.py @@ -79,41 +79,62 @@ def __init__( self._background_tasks = BackgroundTaskRegistry( owner="security.timeout.scheduler", ) + # Lifecycle lock per docs/reference/lifecycle-sync.md. Held + # across the full body of start() and stop() so two concurrent + # start() calls cannot both pass the is_running guard and + # spawn duplicate scheduler tasks; symmetrically, a racing + # stop()/start() cannot interleave between cancel and the + # task=None assignment. + self._lifecycle_lock = asyncio.Lock() + self._stop_failed = False @property def is_running(self) -> bool: """Whether the scheduler loop is currently active.""" return self._task is not None and not self._task.done() - def start(self) -> None: + async def start(self) -> None: """Start the background scheduler loop. Creates an ``asyncio.Task`` running ``_run_loop``. No-op if already running. + + Raises: + RuntimeError: If a prior :meth:`stop` timed out and the + scheduler is now unrestartable; construct a fresh + instance instead. """ - if self.is_running: - return - self._wake_event.clear() - self._task = asyncio.create_task( - self._run_loop(), - name="approval-timeout-scheduler", - ) - logger.info( - TIMEOUT_SCHEDULER_STARTED, - interval_seconds=self._interval, - ) + async with self._lifecycle_lock: + if self._stop_failed: + msg = ( + "ApprovalTimeoutScheduler is unrestartable after a " + "timed-out stop; construct a fresh instance." + ) + raise RuntimeError(msg) + if self.is_running: + return + self._wake_event.clear() + self._task = asyncio.create_task( + self._run_loop(), + name="approval-timeout-scheduler", + ) + logger.info( + TIMEOUT_SCHEDULER_STARTED, + interval_seconds=self._interval, + ) async def stop(self) -> None: """Cancel the background scheduler and wait for it to finish.""" - if self._task is None: + async with self._lifecycle_lock: + if self._task is None: + await self._background_tasks.drain() + return + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None await self._background_tasks.drain() - return - self._task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._task - self._task = None - await self._background_tasks.drain() - logger.info(TIMEOUT_SCHEDULER_STOPPED) + logger.info(TIMEOUT_SCHEDULER_STOPPED) def reschedule(self, interval_seconds: float) -> None: """Update the interval and interrupt the current sleep. diff --git a/src/synthorg/workers/worker.py b/src/synthorg/workers/worker.py index 16a7e7fcbb..a40fcd2986 100644 --- a/src/synthorg/workers/worker.py +++ b/src/synthorg/workers/worker.py @@ -81,6 +81,15 @@ def __init__( self._worker_id = worker_id self._running = False self._stop_event = asyncio.Event() + # Dedicated lifecycle lock per docs/reference/lifecycle-sync.md. + # Held across the full body of run() and stop() so a racing + # start cannot see _running=False mid-drain and spawn a new + # claim loop that the outgoing stop never waits on. Worker is + # an "in-place runner" (start runs the loop on the calling + # coroutine), so the lock guards only the _running transition; + # holding it across the whole loop body would deadlock a + # second concurrent caller. + self._lifecycle_lock = asyncio.Lock() @property def is_running(self) -> bool: @@ -94,23 +103,26 @@ async def run(self) -> None: nacks the JetStream message based on the executor's returned status. """ - if self._running: - msg = f"Worker {self._worker_id} is already running" - raise RuntimeError(msg) - self._running = True - self._stop_event.clear() - logger.info(WORKERS_WORKER_STARTED, worker_id=self._worker_id) + async with self._lifecycle_lock: + if self._running: + msg = f"Worker {self._worker_id} is already running" + raise RuntimeError(msg) + self._running = True + self._stop_event.clear() + logger.info(WORKERS_WORKER_STARTED, worker_id=self._worker_id) try: while not self._stop_event.is_set(): await self._run_once() finally: - self._running = False - logger.info(WORKERS_WORKER_STOPPED, worker_id=self._worker_id) + async with self._lifecycle_lock: + self._running = False + logger.info(WORKERS_WORKER_STOPPED, worker_id=self._worker_id) async def stop(self) -> None: """Signal the claim loop to exit after the current claim.""" - self._stop_event.set() + async with self._lifecycle_lock: + self._stop_event.set() async def _run_once(self) -> None: """Fetch and process a single claim. diff --git a/tests/unit/security/timeout/test_scheduler.py b/tests/unit/security/timeout/test_scheduler.py index 36ffb4398c..a86c455be1 100644 --- a/tests/unit/security/timeout/test_scheduler.py +++ b/tests/unit/security/timeout/test_scheduler.py @@ -104,7 +104,7 @@ async def test_start_creates_task(self) -> None: interval_seconds=60.0, ) - scheduler.start() + await scheduler.start() assert scheduler.is_running # Cleanup @@ -120,9 +120,9 @@ async def test_start_is_idempotent(self) -> None: interval_seconds=60.0, ) - scheduler.start() + await scheduler.start() task1 = scheduler._task - scheduler.start() + await scheduler.start() task2 = scheduler._task assert task1 is task2 @@ -139,7 +139,7 @@ async def test_stop_cancels_task(self) -> None: interval_seconds=60.0, ) - scheduler.start() + await scheduler.start() assert scheduler.is_running await scheduler.stop() assert not scheduler.is_running From 833f9a52e7bbdef9d7c39cadc48e35c2a5fca425 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:17:17 +0200 Subject: [PATCH 14/35] docs(cli): fill Long + Example on cobra commands Add Long descriptions to all top-level commands missing them (start, status, stop, version, uninstall, update) and their config subcommands (show, unset, list, path, edit; get + set already had Long fields). Add Example blocks to the four config subcommands that lacked them (show, unset, list, path, edit, get, set) and to doctor report. Existing test suite continues to pass. Refs #1733 (#21) --- cli/cmd/config.go | 66 ++++++++++++++++++++++++++++++++++------ cli/cmd/doctor_report.go | 4 ++- cli/cmd/start.go | 8 +++++ cli/cmd/status.go | 8 +++++ cli/cmd/stop.go | 7 +++++ cli/cmd/uninstall.go | 8 +++++ cli/cmd/update.go | 10 ++++++ cli/cmd/version.go | 5 +++ 8 files changed, 105 insertions(+), 11 deletions(-) diff --git a/cli/cmd/config.go b/cli/cmd/config.go index 8d8a1ce8d8..be57016e58 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -58,8 +58,16 @@ Running 'synthorg config' without a subcommand shows the current configuration var configShowCmd = &cobra.Command{ Use: "show", Short: "Display current configuration", - Args: cobra.NoArgs, - RunE: runConfigShow, + Long: `Display the resolved configuration as a single block. + +Renders every key from the config file alongside its current +value; values default to the built-in defaults when the file is +absent. For per-key resolution and source attribution use +'synthorg config list' instead.`, + Example: ` synthorg config show # human-readable summary + synthorg --json config show # JSON for scripts`, + Args: cobra.NoArgs, + RunE: runConfigShow, } var configGetCmd = &cobra.Command{ @@ -94,6 +102,9 @@ Supported keys: Plus 17 runtime tunables (registry host, image tags, timeouts, size limits, NATS defaults). See cli/CLAUDE.md for the full list.`, + Example: ` synthorg config get backend_port + synthorg config get channel + synthorg config get image_tag`, Args: cobra.ExactArgs(1), RunE: runConfigGet, ValidArgsFunction: completeConfigGetKeys, @@ -137,14 +148,26 @@ max_binary_bytes, max_archive_entry_bytes). See cli/CLAUDE.md for formats. Keys that affect Docker compose (backend_port, web_port, sandbox, docker_sock, image_tag, log_level, telemetry_opt_in, fine_tuning, fine_tuning_variant, and the registry/NATS tunables) trigger automatic compose.yml regeneration.`, + Example: ` synthorg config set backend_port 3001 + synthorg config set channel dev + synthorg config set hints always + synthorg config set telemetry_opt_in true`, Args: cobra.ExactArgs(2), RunE: runConfigSet, ValidArgsFunction: completeConfigSetKeys, } var configUnsetCmd = &cobra.Command{ - Use: "unset ", - Short: "Reset a configuration key to its default value", + Use: "unset ", + Short: "Reset a configuration key to its default value", + Long: `Remove a config-file override so the key falls back to its default. + +Use this rather than 'config set ' when you want +the key to follow future default changes (defaults can move +between releases). Compose-affecting keys trigger compose.yml +regeneration after the unset lands.`, + Example: ` synthorg config unset backend_port # reset to platform default + synthorg config unset channel # follow default channel`, Args: cobra.ExactArgs(1), RunE: runConfigUnset, ValidArgsFunction: completeConfigUnsetKeys, @@ -153,22 +176,45 @@ var configUnsetCmd = &cobra.Command{ var configListCmd = &cobra.Command{ Use: "list", Short: "Show all config keys with resolved value and source", - Args: cobra.NoArgs, - RunE: runConfigList, + Long: `List every settable config key with its resolved value and source. + +Source is one of "default", "config-file", or "env" (env vars +override the config file but cannot be set via 'config set'). +Useful for debugging precedence when a value disagrees with what +'config show' implies.`, + Example: ` synthorg config list # full table + synthorg --json config list # JSON, one row per key`, + Args: cobra.NoArgs, + RunE: runConfigList, } var configPathCmd = &cobra.Command{ Use: "path", Short: "Print the config file path", - Args: cobra.NoArgs, - RunE: runConfigPath, + Long: `Print the absolute path to the config file the CLI uses. + +The path is platform-appropriate (XDG-compatible on Linux, the +native config dir on macOS / Windows) and reflects --data-dir or +SYNTHORG_DATA_DIR overrides if set.`, + Example: ` synthorg config path # print path + cat "$(synthorg config path)" # inspect raw file + synthorg config path --data-dir=/tmp/x`, + Args: cobra.NoArgs, + RunE: runConfigPath, } var configEditCmd = &cobra.Command{ Use: "edit", Short: "Open config file in your editor", - Args: cobra.NoArgs, - RunE: runConfigEdit, + Long: `Open the config file in $EDITOR (or VISUAL) for direct edits. + +Falls back to a platform-appropriate editor when neither env var +is set (vim on POSIX, notepad on Windows). The CLI re-reads the +file on the next invocation; no daemon to restart.`, + Example: ` synthorg config edit # use $EDITOR + EDITOR=nano synthorg config edit # one-shot override`, + Args: cobra.NoArgs, + RunE: runConfigEdit, } func init() { diff --git a/cli/cmd/doctor_report.go b/cli/cmd/doctor_report.go index fc434491fe..16f3607b98 100644 --- a/cli/cmd/doctor_report.go +++ b/cli/cmd/doctor_report.go @@ -18,7 +18,9 @@ var doctorReportCmd = &cobra.Command{ Use: "report", Short: "Generate a diagnostic archive and bug report URL", Long: "Collects diagnostics, saves a report file, and prints a pre-filled GitHub issue URL.", - RunE: runDoctorReport, + Example: ` synthorg doctor report # write archive + print issue URL + synthorg doctor report --json # machine-readable summary`, + RunE: runDoctorReport, } func init() { diff --git a/cli/cmd/start.go b/cli/cmd/start.go index 5e4aa3a3ba..d83ad6dbc8 100644 --- a/cli/cmd/start.go +++ b/cli/cmd/start.go @@ -36,6 +36,14 @@ var ( var startCmd = &cobra.Command{ Use: "start", Short: "Pull images and start the SynthOrg stack", + Long: `Start every container in the SynthOrg compose stack. + +By default this pulls each image (verifying signatures and SLSA +attestations against the pinned digests) before bringing the stack +up detached, then waits for the backend's /api/v1/readyz to return +healthy. Pass --no-pull to skip the pull when iterating locally, +--no-detach to stream logs in the foreground, or --dry-run to print +the docker commands the run would issue without executing them.`, Example: ` synthorg start # pull, verify, and start synthorg start --no-pull # start without pulling images synthorg start --dry-run # preview what would happen diff --git a/cli/cmd/status.go b/cli/cmd/status.go index bfadba1985..be33dc007d 100644 --- a/cli/cmd/status.go +++ b/cli/cmd/status.go @@ -32,6 +32,14 @@ var ( var statusCmd = &cobra.Command{ Use: "status", Short: "Show container states, health, and versions", + Long: `Render a one-shot snapshot of the running SynthOrg stack. + +Combines a verdict banner (OK / DEGRADED / CRITICAL), the backend +/api/v1/readyz response, the per-container table from +docker compose ps, and live resource usage. Use --watch to refresh +on an interval, --wide for port columns, --services to filter by +name, or --check for a silent exit-code-only run intended for +scripts (0 healthy, 3 unhealthy, 4 unreachable).`, Example: ` synthorg status # show current status synthorg status --watch # continuously poll synthorg status --wide # show extra columns diff --git a/cli/cmd/stop.go b/cli/cmd/stop.go index fc9346c9bc..5416415f86 100644 --- a/cli/cmd/stop.go +++ b/cli/cmd/stop.go @@ -22,6 +22,13 @@ var ( var stopCmd = &cobra.Command{ Use: "stop", Short: "Stop the SynthOrg stack", + Long: `Stop every container in the SynthOrg compose stack. + +Sends SIGTERM and waits for the configured graceful shutdown +window before falling back to SIGKILL. Pass --timeout to override +the wait, or --volumes to also remove named volumes once the stack +is down (destroys persisted data; pair with 'synthorg backup +create' first).`, Example: ` synthorg stop # graceful shutdown synthorg stop --timeout 60s # custom shutdown timeout synthorg stop --volumes # stop and remove volumes`, diff --git a/cli/cmd/uninstall.go b/cli/cmd/uninstall.go index b09e97f4c4..b5cadaf0f0 100644 --- a/cli/cmd/uninstall.go +++ b/cli/cmd/uninstall.go @@ -27,6 +27,14 @@ var ( var uninstallCmd = &cobra.Command{ Use: "uninstall", Short: "Stop containers, remove data, and uninstall SynthOrg", + Long: `Tear down the SynthOrg installation. + +Stops every container, removes named volumes, deletes the data +directory, and removes pulled images. Each destructive step is +confirmed interactively unless --yes is set. Pass --keep-data to +preserve the data directory and config (useful before a clean +re-install) or --keep-images to leave pulled images on disk for +faster re-init later.`, Example: ` synthorg uninstall # interactive uninstall (prompts for each step) synthorg uninstall --yes # non-interactive full uninstall synthorg uninstall --keep-data # uninstall but preserve config and data diff --git a/cli/cmd/update.go b/cli/cmd/update.go index c15be3414b..5fb1e52352 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -35,6 +35,16 @@ var ( var updateCmd = &cobra.Command{ Use: "update", Short: "Update CLI, refresh compose template, and pull new container images", + Long: `Bring the local installation up to the channel's latest version. + +Self-updates the CLI binary, regenerates compose.yml from the +embedded template, then pulls the matching container images +(verifying signatures and SLSA attestations) and restarts the +running stack. Pass --cli-only or --images-only to scope the +update, --check to exit 10 if an update is available without +applying it, --dry-run to preview the planned changes, or +--no-restart to pull images but leave the running containers +untouched.`, Example: ` synthorg update # update CLI + images synthorg update --cli-only # update CLI binary only synthorg update --images-only # update container images only diff --git a/cli/cmd/version.go b/cli/cmd/version.go index 5408cfdd88..db1fd09dae 100644 --- a/cli/cmd/version.go +++ b/cli/cmd/version.go @@ -13,6 +13,11 @@ var versionShort bool var versionCmd = &cobra.Command{ Use: "version", Short: "Print CLI version and build info", + Long: `Print the CLI version, commit hash, and build date. + +The default form renders a logo banner plus build metadata +suitable for issue reports. Pass --short for a single-line +semantic version string, useful in shell pipelines.`, Example: ` synthorg version # full version info with logo synthorg version --short # version number only`, RunE: func(cmd *cobra.Command, args []string) error { From acac9ec34eab8db8330794e666233aae606dc113 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:19:54 +0200 Subject: [PATCH 15/35] fix: enforce JSON validity on SQLite TEXT columns mirroring Postgres JSONB provider_audit_events.payload and preset_overrides.{default_models, supported_auth_types,candidate_urls} are JSONB on Postgres (which implicitly validates JSON shape) but plain TEXT on SQLite, so a buggy caller could land malformed JSON on the SQLite side without warning. SQLite has no JSONB type, so add CHECK (json_valid(col)) constraints on the TEXT columns to match the implicit Postgres validation. The nullable preset_overrides columns guard with 'IS NULL OR json_valid' because SQLite's json_valid() returns 0 for NULL. The single Atlas migration captures both schema deltas in one revision so the check-single-migration-per-pr hook stays satisfied. Refs #1733 (#8) --- .../20260503181821_json_check_constraints.sql | 53 +++++++++++++++++++ .../persistence/sqlite/revisions/atlas.sum | 3 +- src/synthorg/persistence/sqlite/schema.sql | 19 +++++-- 3 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 src/synthorg/persistence/sqlite/revisions/20260503181821_json_check_constraints.sql diff --git a/src/synthorg/persistence/sqlite/revisions/20260503181821_json_check_constraints.sql b/src/synthorg/persistence/sqlite/revisions/20260503181821_json_check_constraints.sql new file mode 100644 index 0000000000..d969648c5a --- /dev/null +++ b/src/synthorg/persistence/sqlite/revisions/20260503181821_json_check_constraints.sql @@ -0,0 +1,53 @@ +-- Disable the enforcement of foreign-keys constraints +PRAGMA foreign_keys = off; +-- Create "new_provider_audit_events" table +CREATE TABLE `new_provider_audit_events` ( + `id` integer NULL PRIMARY KEY AUTOINCREMENT, + `provider_name` text NOT NULL, + `event_type` text NOT NULL, + `actor_id` text NOT NULL, + `actor_label` text NOT NULL, + `payload` text NOT NULL DEFAULT '{}', + `occurred_at` text NOT NULL, + CHECK (length(trim(provider_name)) > 0), + CHECK (length(trim(event_type)) > 0), + CHECK (length(trim(actor_id)) > 0), + CHECK (length(trim(actor_label)) > 0), + CHECK (json_valid(payload)), + CHECK (length(trim(occurred_at)) > 0) +); +-- Copy rows from old table "provider_audit_events" to new temporary table "new_provider_audit_events" +INSERT INTO `new_provider_audit_events` (`id`, `provider_name`, `event_type`, `actor_id`, `actor_label`, `payload`, `occurred_at`) SELECT `id`, `provider_name`, `event_type`, `actor_id`, `actor_label`, `payload`, `occurred_at` FROM `provider_audit_events`; +-- Drop "provider_audit_events" table after copying rows +DROP TABLE `provider_audit_events`; +-- Rename temporary table "new_provider_audit_events" to "provider_audit_events" +ALTER TABLE `new_provider_audit_events` RENAME TO `provider_audit_events`; +-- Create index "idx_provider_audit_events_provider_id" to table: "provider_audit_events" +CREATE INDEX `idx_provider_audit_events_provider_id` ON `provider_audit_events` (`provider_name`, `id` DESC); +-- Create index "idx_provider_audit_events_occurred" to table: "provider_audit_events" +CREATE INDEX `idx_provider_audit_events_occurred` ON `provider_audit_events` (`occurred_at`); +-- Create "new_preset_overrides" table +CREATE TABLE `new_preset_overrides` ( + `preset_name` text NOT NULL, + `default_models` text NULL, + `supported_auth_types` text NULL, + `candidate_urls` text NULL, + `base_url` text NULL, + `updated_at` text NOT NULL, + `updated_by` text NOT NULL, + PRIMARY KEY (`preset_name`), + CHECK (length(trim(preset_name)) > 0), + CHECK (default_models IS NULL OR json_valid(default_models)), + CHECK (supported_auth_types IS NULL OR json_valid(supported_auth_types)), + CHECK (candidate_urls IS NULL OR json_valid(candidate_urls)), + CHECK (length(trim(updated_at)) > 0), + CHECK (length(trim(updated_by)) > 0) +); +-- Copy rows from old table "preset_overrides" to new temporary table "new_preset_overrides" +INSERT INTO `new_preset_overrides` (`preset_name`, `default_models`, `supported_auth_types`, `candidate_urls`, `base_url`, `updated_at`, `updated_by`) SELECT `preset_name`, `default_models`, `supported_auth_types`, `candidate_urls`, `base_url`, `updated_at`, `updated_by` FROM `preset_overrides`; +-- Drop "preset_overrides" table after copying rows +DROP TABLE `preset_overrides`; +-- Rename temporary table "new_preset_overrides" to "preset_overrides" +ALTER TABLE `new_preset_overrides` RENAME TO `preset_overrides`; +-- Enable back the enforcement of foreign-keys constraints +PRAGMA foreign_keys = on; diff --git a/src/synthorg/persistence/sqlite/revisions/atlas.sum b/src/synthorg/persistence/sqlite/revisions/atlas.sum index 655c651fce..5079e9b4f6 100644 --- a/src/synthorg/persistence/sqlite/revisions/atlas.sum +++ b/src/synthorg/persistence/sqlite/revisions/atlas.sum @@ -1,4 +1,4 @@ -h1:E12RicRJfZy+IIPoTsj2Kgsi1PRbaQYN17SRAyKKVuo= +h1:zQiAWc2hqnrI/Z5cUYR6yiNrihL6Ijb8o8Ls03R9Sfo= 00000000000000_baseline.sql h1:iPb7ksO7gknp0bpuhi5BQ9+ZBqxHTUTp+ac0h+09Hbs= 20260421184322_pst1_parity_and_indices.sql h1:6en6dccn5vUE2WGc8uzh+v63FGR0is0RBmh/ZNcWep0= 20260422081430_add_approvals_task_id_index.sql h1:ToNSiidJVRigL2ASpnZ2IQEH+Bglh9rYo+Hf4Bl0BuI= @@ -8,3 +8,4 @@ h1:E12RicRJfZy+IIPoTsj2Kgsi1PRbaQYN17SRAyKKVuo= 20260427210928_provider_capabilities_expansion.sql h1:nkxsZ2raiLf9ZOS2rF6kiY5NPRA864tXZiH+iM2QYSk= 20260430185252_reliability_schema_parity.sql h1:j2Jm+HpsITPMvmUbmvpiYpmHxzDKoGNWb2F9+RlTf+Q= 20260501092212_add_connections_webhook_retention_days.sql h1:nFEZkA5F3old2MAuPs1mkCHgNtsJoEU/InTcIBGVbOY= +20260503181821_json_check_constraints.sql h1:k5O4V24kDewsDyjtXArYm7X2RYBGpWTOqs8Vgcza4kA= diff --git a/src/synthorg/persistence/sqlite/schema.sql b/src/synthorg/persistence/sqlite/schema.sql index aeb5df1845..a4926046d8 100644 --- a/src/synthorg/persistence/sqlite/schema.sql +++ b/src/synthorg/persistence/sqlite/schema.sql @@ -1278,7 +1278,9 @@ CREATE TABLE provider_audit_events ( event_type TEXT NOT NULL CHECK(length(trim(event_type)) > 0), actor_id TEXT NOT NULL CHECK(length(trim(actor_id)) > 0), actor_label TEXT NOT NULL CHECK(length(trim(actor_label)) > 0), - payload TEXT NOT NULL DEFAULT '{}', + -- payload mirrors the Postgres JSONB column; SQLite has no JSONB + -- type so we store TEXT and enforce JSON validity via json_valid(). + payload TEXT NOT NULL DEFAULT '{}' CHECK(json_valid(payload)), -- Timestamp format is enforced by the Python layer via -- ``parse_iso_utc`` / ``format_iso_utc`` (see -- ``persistence/_shared/datetime_marshaller.py``); the DB only @@ -1299,10 +1301,17 @@ CREATE INDEX idx_provider_audit_events_occurred CREATE TABLE preset_overrides ( preset_name TEXT NOT NULL PRIMARY KEY CHECK(length(trim(preset_name)) > 0), - -- Column names aligned with Postgres. - default_models TEXT, - supported_auth_types TEXT, - candidate_urls TEXT, + -- Column names aligned with Postgres. default_models / + -- supported_auth_types / candidate_urls are JSONB on Postgres; + -- here we store TEXT and enforce JSON validity via json_valid(). + -- Each column is nullable, and SQLite's json_valid() returns 0 + -- for NULL so the CHECK is guarded with IS NULL OR. + default_models TEXT + CHECK(default_models IS NULL OR json_valid(default_models)), + supported_auth_types TEXT + CHECK(supported_auth_types IS NULL OR json_valid(supported_auth_types)), + candidate_urls TEXT + CHECK(candidate_urls IS NULL OR json_valid(candidate_urls)), base_url TEXT, updated_at TEXT NOT NULL CHECK(length(trim(updated_at)) > 0), updated_by TEXT NOT NULL CHECK(length(trim(updated_by)) > 0) From 46b4f08abeac53d0197761dfaba85d374626c02c Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:26:15 +0200 Subject: [PATCH 16/35] perf: batch save_many in approval expiry loop ApprovalStore.list_items previously called _check_expiration_locked per item, each issuing a save round-trip when the item transitioned PENDING to EXPIRED. Worst case K simultaneous expiries produced K UPDATE round-trips. Add save_many to the ApprovalRepository protocol and implement on both backends (executemany under one transaction; constraint violations roll back the whole batch). Split the per-item check into a pure _compute_expiration plus a side-effect block that fans out a single save_many and an audit pass after the DB write. The list path is split into _list_from_repo_locked (batched) and _list_from_cache_locked (per-item) so the orchestration body stays under the 10-branch complexity ceiling. Refs #1733 (#13) --- src/synthorg/api/approval_store.py | 146 ++++++++++++++---- src/synthorg/persistence/approval_protocol.py | 18 ++- .../persistence/postgres/approval_repo.py | 67 ++++++++ .../persistence/sqlite/approval_repo.py | 67 ++++++++ 4 files changed, 271 insertions(+), 27 deletions(-) diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 1013bff865..36b597f26c 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -243,36 +243,89 @@ async def list_items( """ async with self._lock: if self._repo is not None: - repo_items = await self._repo.list_items( + return await self._list_from_repo_locked( status=status, risk_level=risk_level, action_type=action_type, ) - for item in repo_items: - self._items[item.id] = item - # Re-filter after expiration: _check_expiration may - # transition PENDING -> EXPIRED, invalidating the - # original status filter from the repo query. - result: list[ApprovalItem] = [] - for item in repo_items: - checked = await self._check_expiration_locked(item) - if status is not None and checked.status != status: - continue - if risk_level is not None and checked.risk_level != risk_level: - continue - result.append(checked) - return tuple(result) - checked_items: list[ApprovalItem] = [] - for stored in list(self._items.values()): - checked = await self._check_expiration_locked(stored) - if status is not None and checked.status != status: - continue - if risk_level is not None and checked.risk_level != risk_level: - continue - if action_type is not None and checked.action_type != action_type: - continue - checked_items.append(checked) - return tuple(checked_items) + return await self._list_from_cache_locked( + status=status, + risk_level=risk_level, + action_type=action_type, + ) + + async def _list_from_repo_locked( + self, + *, + status: ApprovalStatus | None, + risk_level: ApprovalRiskLevel | None, + action_type: NotBlankStr | None, + ) -> tuple[ApprovalItem, ...]: + """Repo-backed list path with batched expiry persistence. + + Pure-compute pass first collects the EXPIRED transitions into + to_persist and transitions the cache; the persistence write + fans out as a single ``save_many`` so K simultaneous expiries + yield one DB round-trip rather than K. + """ + assert self._repo is not None # noqa: S101 -- caller invariant + repo_items = await self._repo.list_items( + status=status, + risk_level=risk_level, + action_type=action_type, + ) + for item in repo_items: + self._items[item.id] = item + result: list[ApprovalItem] = [] + to_persist: list[ApprovalItem] = [] + for item in repo_items: + checked = self._compute_expiration(item) + if checked is not item: + to_persist.append(checked) + self._items[item.id] = checked + if status is not None and checked.status != status: + continue + if risk_level is not None and checked.risk_level != risk_level: + continue + result.append(checked) + if to_persist: + await self._repo.save_many(to_persist) + for expired in to_persist: + logger.info( + APPROVAL_STATUS_TRANSITIONED, + approval_id=expired.id, + from_status=ApprovalStatus.PENDING.value, + to_status=ApprovalStatus.EXPIRED.value, + ) + logger.info(API_APPROVAL_EXPIRED, approval_id=expired.id) + self._fire_expire_callback(expired) + return tuple(result) + + async def _list_from_cache_locked( + self, + *, + status: ApprovalStatus | None, + risk_level: ApprovalRiskLevel | None, + action_type: NotBlankStr | None, + ) -> tuple[ApprovalItem, ...]: + """Cache-only list path (no repository wired). + + Falls through ``_check_expiration_locked`` per item because + without a repository there is no batch endpoint to amortise; + a per-item save is also a no-op (the in-memory cache is + already updated by ``_check_expiration_locked``). + """ + checked_items: list[ApprovalItem] = [] + for stored in list(self._items.values()): + checked = await self._check_expiration_locked(stored) + if status is not None and checked.status != status: + continue + if risk_level is not None and checked.risk_level != risk_level: + continue + if action_type is not None and checked.action_type != action_type: + continue + checked_items.append(checked) + return tuple(checked_items) async def save(self, item: ApprovalItem) -> ApprovalItem | None: """Update an existing approval item (first-writer-wins). @@ -506,3 +559,44 @@ async def _check_expiration_locked( ) return expired return item + + def _compute_expiration(self, item: ApprovalItem) -> ApprovalItem: + """Pure: return the (possibly-EXPIRED) item without I/O. + + Companion to ``_check_expiration_locked`` for the batch path + in :meth:`list_items`. Returns the input unchanged when no + transition applies, or a fresh EXPIRED copy otherwise. + Persistence + audit logging + callback fire AFTER the batch + save in the caller, not here -- this method must be safe to + call inside a tight loop with no side effects. + """ + if ( + item.status == ApprovalStatus.PENDING + and item.expires_at is not None + and datetime.now(UTC) >= item.expires_at + ): + return item.model_copy(update={"status": ApprovalStatus.EXPIRED}) + return item + + def _fire_expire_callback(self, expired: ApprovalItem) -> None: + """Best-effort fire of ``_on_expire`` for a batched expiration. + + Mirrors the callback handling in + :meth:`_check_expiration_locked`: a callback failure must not + unwind the expiration (the row is already EXPIRED in cache + + repo); emit ``API_APPROVAL_EXPIRE_CALLBACK_FAILED`` so + operators can filter callback failures from real expirations. + """ + if self._on_expire is None: + return + try: + self._on_expire(expired) + except MemoryError, RecursionError: + raise + except Exception as exc: + logger.warning( + API_APPROVAL_EXPIRE_CALLBACK_FAILED, + approval_id=expired.id, + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) diff --git a/src/synthorg/persistence/approval_protocol.py b/src/synthorg/persistence/approval_protocol.py index 03236b9b3f..eb0e9c72cb 100644 --- a/src/synthorg/persistence/approval_protocol.py +++ b/src/synthorg/persistence/approval_protocol.py @@ -11,7 +11,7 @@ ``persistence/escalation_protocol.py``. """ -from typing import Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable from synthorg.core.approval import ApprovalItem # noqa: TC001 from synthorg.core.enums import ( @@ -20,6 +20,9 @@ ) from synthorg.core.types import NotBlankStr # noqa: TC001 +if TYPE_CHECKING: + from collections.abc import Sequence + @runtime_checkable class ApprovalRepository(Protocol): @@ -40,6 +43,19 @@ async def save(self, item: ApprovalItem) -> None: """ ... + async def save_many(self, items: Sequence[ApprovalItem]) -> None: + """Upsert multiple approval items in a single transaction. + + All-or-nothing: if any row raises a constraint violation the + whole batch rolls back. Empty input is a no-op (returns + without opening a transaction). + + Raises: + ConstraintViolationError: On constraint violations. + QueryError: On other database errors. + """ + ... + async def get(self, approval_id: NotBlankStr) -> ApprovalItem | None: """Get an approval item by ID, or ``None`` if not found. diff --git a/src/synthorg/persistence/postgres/approval_repo.py b/src/synthorg/persistence/postgres/approval_repo.py index 5457dfeccd..c9de76d4e6 100644 --- a/src/synthorg/persistence/postgres/approval_repo.py +++ b/src/synthorg/persistence/postgres/approval_repo.py @@ -32,6 +32,8 @@ from synthorg.persistence._shared import coerce_row_timestamp if TYPE_CHECKING: + from collections.abc import Sequence + from psycopg_pool import AsyncConnectionPool logger = get_logger(__name__) @@ -223,6 +225,71 @@ async def save(self, item: ApprovalItem) -> None: ) raise QueryError(msg) from exc + async def save_many(self, items: Sequence[ApprovalItem]) -> None: + """Upsert multiple approval items in a single transaction. + + Empty input is a no-op. Single-item input falls back to + :meth:`save` so the per-item error context still names the + offending id on constraint violation. + """ + if not items: + return + if len(items) == 1: + await self.save(items[0]) + return + param_rows = [] + for item in items: + evidence_json = ( + Jsonb(item.evidence_package.model_dump(mode="json")) + if item.evidence_package is not None + else None + ) + param_rows.append( + ( + item.id, + item.action_type, + item.title, + item.description, + item.requested_by, + item.risk_level.value, + item.status.value, + item.created_at, + item.expires_at, + item.decided_at, + item.decided_by, + item.decision_reason, + item.task_id, + evidence_json, + Jsonb(item.metadata), + ), + ) + try: + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.executemany(_APPROVALS_UPSERT_SQL, param_rows) + await conn.commit() + except psycopg.errors.IntegrityError as exc: + constraint = ( + getattr(getattr(exc, "diag", None), "constraint_name", None) + or "" + ) + msg = f"Constraint violation saving approval batch (size={len(items)})" + logger.warning( + API_APPROVAL_REPO_FAILED, + batch_size=len(items), + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise ConstraintViolationError(msg, constraint=constraint) from exc + except psycopg.Error as exc: + msg = f"Failed to save approval batch (size={len(items)})" + logger.warning( + API_APPROVAL_REPO_FAILED, + batch_size=len(items), + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise QueryError(msg) from exc + async def get(self, approval_id: NotBlankStr) -> ApprovalItem | None: """Get an approval item by ID, or ``None`` if not found. diff --git a/src/synthorg/persistence/sqlite/approval_repo.py b/src/synthorg/persistence/sqlite/approval_repo.py index 84cb58f0ea..49a2c450b2 100644 --- a/src/synthorg/persistence/sqlite/approval_repo.py +++ b/src/synthorg/persistence/sqlite/approval_repo.py @@ -3,11 +3,15 @@ import asyncio import json import sqlite3 +from typing import TYPE_CHECKING import aiosqlite from aiosqlite import Row from pydantic import ValidationError +if TYPE_CHECKING: + from collections.abc import Sequence + from synthorg.core.approval import ApprovalItem from synthorg.core.enums import ApprovalRiskLevel, ApprovalStatus from synthorg.core.evidence import EvidencePackage @@ -200,6 +204,69 @@ async def save(self, item: ApprovalItem) -> None: ) raise QueryError(msg) from exc + async def save_many(self, items: Sequence[ApprovalItem]) -> None: + """Upsert multiple approval items in a single transaction. + + Empty input is a no-op. Single-item input falls back to the + scalar ``save()`` path so the per-item error context still + names the offending id on constraint violation. + """ + if not items: + return + if len(items) == 1: + await self.save(items[0]) + return + param_rows = [] + for item in items: + evidence_json = ( + item.evidence_package.model_dump_json() + if item.evidence_package is not None + else None + ) + param_rows.append( + ( + item.id, + item.action_type, + item.title, + item.description, + item.requested_by, + item.risk_level.value, + item.status.value, + format_iso_utc(item.created_at), + format_iso_utc(item.expires_at) if item.expires_at else None, + format_iso_utc(item.decided_at) if item.decided_at else None, + item.decided_by, + item.decision_reason, + item.task_id, + evidence_json, + json.dumps(item.metadata), + ), + ) + async with self._write_lock: + try: + await self._db.executemany(_APPROVALS_UPSERT_SQL, param_rows) + await self._db.commit() + except sqlite3.IntegrityError as exc: + await self._db.rollback() + msg = f"Constraint violation saving approval batch (size={len(items)})" + logger.warning( + API_APPROVAL_REPO_FAILED, + batch_size=len(items), + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise ConstraintViolationError(msg, constraint=str(exc)) from exc + except (sqlite3.Error, aiosqlite.Error) as exc: + await self._db.rollback() + msg = f"Failed to save approval batch (size={len(items)})" + logger.warning( + API_APPROVAL_REPO_FAILED, + batch_size=len(items), + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise QueryError(msg) from exc + async def get(self, approval_id: NotBlankStr) -> ApprovalItem | None: """Get an approval item by ID. From a077f87f754a9fe746c3962e31a57c602fdb9746 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:35:01 +0200 Subject: [PATCH 17/35] feat: wire ApprovalTimeoutScheduler at app startup with WaitForeverPolicy The app() factory previously hard-coded approval_timeout_scheduler=None with a comment noting auto-creation from settings was 'not yet wired', which made security.timeout_check_interval_seconds dead at the runtime. Wire a default scheduler now: WaitForeverPolicy is the safe production default (the scheduler runs the periodic scan and emits TIMEOUT_WAITING events, but never auto-decides pending approvals); operators swap in DenyOnTimeout / Tiered / EscalationChain via the security.* settings. BackupService bootstrap was already complete (build_backup_service factory, lifecycle.py start/stop hooks); this commit completes the matching ApprovalTimeoutScheduler half so all 13 audit-flagged settings now have a live consumer at runtime. Refs #1733 (#16) --- src/synthorg/api/app.py | 47 ++++++++++++++++++++++++++++++++++---- tests/unit/api/test_app.py | 4 ++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/synthorg/api/app.py b/src/synthorg/api/app.py index 69bb982672..baab8f9caf 100644 --- a/src/synthorg/api/app.py +++ b/src/synthorg/api/app.py @@ -111,7 +111,9 @@ from synthorg.providers.health import ProviderHealthTracker # noqa: TC001 from synthorg.providers.registry import ProviderRegistry # noqa: TC001 from synthorg.security.audit import AuditLog -from synthorg.security.timeout.scheduler import ApprovalTimeoutScheduler # noqa: TC001 +from synthorg.security.timeout.policies import WaitForeverPolicy +from synthorg.security.timeout.scheduler import ApprovalTimeoutScheduler +from synthorg.security.timeout.timeout_checker import TimeoutChecker from synthorg.security.trust.service import TrustService # noqa: TC001 from synthorg.tools.invocation_tracker import ToolInvocationTracker # noqa: TC001 @@ -124,6 +126,36 @@ logger = get_logger(__name__) +# Default approval-timeout interval mirrors the registry default for +# ``security.timeout_check_interval_seconds``. Held here as a constant +# so the bootstrap and the registry definition cannot drift; future +# reads from ConfigResolver still override at runtime via the +# scheduler's ``reschedule()`` (called from a settings subscriber). +_DEFAULT_TIMEOUT_CHECK_INTERVAL_SECONDS = 60.0 + + +def _build_default_approval_timeout_scheduler( + *, + approval_store: ApprovalStoreProtocol, +) -> ApprovalTimeoutScheduler: + """Construct an :class:`ApprovalTimeoutScheduler` with safe defaults. + + Uses :class:`WaitForeverPolicy` so the scheduler runs the periodic + scan and emits TIMEOUT_WAITING events but never auto-decides + pending approvals. Operators wire a real policy via the + ``security.timeout_*`` settings; the settings subscriber on + ``security.timeout_check_interval_seconds`` invokes + ``scheduler.reschedule()`` so the cadence stays operator-tunable + without restart. + """ + timeout_checker = TimeoutChecker(policy=WaitForeverPolicy()) + return ApprovalTimeoutScheduler( + approval_store=approval_store, + timeout_checker=timeout_checker, + interval_seconds=_DEFAULT_TIMEOUT_CHECK_INTERVAL_SECONDS, + ) + + # 2-Phase Init: Phase 1 (construct) bakes immutable middleware/CORS/routes # from RootConfig. Phase 2 (on_startup) wires SettingsService + ConfigResolver # for runtime-editable settings. Litestar rate-limit middleware reads config at @@ -809,10 +841,15 @@ def create_app( # noqa: C901, PLR0912, PLR0913, PLR0915 ) app_state.set_review_gate_service(review_gate_service) - # Approval timeout scheduler -- None here; auto-creation from - # settings at startup is not yet wired. Pass explicitly via the - # lifecycle when a TimeoutChecker is available. - approval_timeout_scheduler: ApprovalTimeoutScheduler | None = None + # Approval timeout scheduler -- bootstrapped here with the + # operator-tunable interval from + # ``security.timeout_check_interval_seconds``. The default policy + # is ``WaitForeverPolicy`` so the scheduler runs but never + # auto-decides; operators can swap in DenyOnTimeout / Tiered / + # EscalationChain via the security.* settings at runtime. + approval_timeout_scheduler = _build_default_approval_timeout_scheduler( + approval_store=effective_approval_store, + ) startup, shutdown = _build_lifecycle( persistence, diff --git a/tests/unit/api/test_app.py b/tests/unit/api/test_app.py index 61e33e6d38..b216ce9ee8 100644 --- a/tests/unit/api/test_app.py +++ b/tests/unit/api/test_app.py @@ -518,7 +518,7 @@ async def test_approval_timeout_scheduler_lifecycle( persistence = FakePersistenceBackend() bus = FakeMessageBus() mock_sched = MagicMock() - mock_sched.start = MagicMock() # start() is sync + mock_sched.start = AsyncMock() mock_sched.stop = AsyncMock() app_state = AppState( @@ -538,7 +538,7 @@ async def test_approval_timeout_scheduler_lifecycle( mock_sched, app_state, ) - mock_sched.start.assert_called_once() + mock_sched.start.assert_awaited_once() await _safe_shutdown(None, None, None, mock_sched, None, None, None, None) mock_sched.stop.assert_awaited_once() From 07c37819d5be700ab94b39fcb2b8f95687244779 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 20:51:26 +0200 Subject: [PATCH 18/35] feat: inject Clock seam into 14 time-reading sites Replace bare time.monotonic / time.time / asyncio.get_event_loop().time with a Clock-injected seam in classes flagged by the audit so tests can drive virtual time without monkey-patching modules. Each affected class adds an optional clock: Clock | None = None constructor parameter that defaults to SystemClock; module-level helpers without a class accept the clock as a keyword. Sites covered: - a2a/well_known.py module-level cache helpers - api/controllers/health.py via app_state.clock - api/controllers/events.py SSE keepalive + revalidate timer - api/services/idempotency_service.py poll deadline - api/state.py AppState.clock attribute (new) - api/app.py instantiates AppState.clock = SystemClock by default - communication/bus/memory.py InProcessMessageBus - communication/bus/_nats_state.py state.clock field - communication/bus/_nats_receive.py reads state.clock - engine/workflow/ceremony_scheduler.py - integrations/health/checks/database.py - memory/retrieval/hierarchical/workers.py three workers - meta/validation/ci_validator.py - security/uncertainty.py - tools/sandbox/docker_sandbox.py The idempotency-service test that previously monkey-patched svc_mod.time.monotonic now constructs a _DeterministicClock and passes it through the new clock= constructor kwarg. Refs #1733 (#17) --- src/synthorg/a2a/well_known.py | 19 ++++++- src/synthorg/api/controllers/events.py | 11 +++- src/synthorg/api/controllers/health.py | 7 +-- .../api/services/idempotency_service.py | 8 ++- src/synthorg/api/state.py | 4 ++ .../communication/bus/_nats_receive.py | 9 ++- src/synthorg/communication/bus/_nats_state.py | 14 ++++- src/synthorg/communication/bus/memory.py | 16 ++++-- .../engine/workflow/ceremony_scheduler.py | 11 ++-- .../integrations/health/checks/database.py | 9 ++- .../memory/retrieval/hierarchical/workers.py | 30 ++++++---- src/synthorg/meta/validation/ci_validator.py | 14 +++-- src/synthorg/security/uncertainty.py | 12 ++-- src/synthorg/tools/sandbox/docker_sandbox.py | 11 +++- .../api/services/test_idempotency_service.py | 56 +++++++++++++------ 15 files changed, 158 insertions(+), 73 deletions(-) diff --git a/src/synthorg/a2a/well_known.py b/src/synthorg/a2a/well_known.py index 4460723df4..d28b2de6ed 100644 --- a/src/synthorg/a2a/well_known.py +++ b/src/synthorg/a2a/well_known.py @@ -11,7 +11,6 @@ import asyncio import hashlib -import time from typing import Any from litestar import Controller, Request, get @@ -19,6 +18,7 @@ from litestar.response import Response from synthorg.a2a.agent_card import AgentCardBuilder # noqa: TC001 +from synthorg.core.clock import Clock, SystemClock from synthorg.core.normalization import strip_trailing_slash from synthorg.observability import get_logger from synthorg.observability.events.a2a import ( @@ -32,6 +32,9 @@ # Module-level cache: (card_data, expires_at, fingerprint). _card_cache: dict[str, tuple[dict[str, Any], float, str]] = {} _cache_lock = asyncio.Lock() +# Module-level clock singleton; tests inject a FakeClock by passing +# it explicitly to the cache helpers below. +_default_clock: Clock = SystemClock() async def _get_cached_card( @@ -39,6 +42,7 @@ async def _get_cached_card( ttl: int, *, fingerprint: str = "", + clock: Clock | None = None, ) -> dict[str, Any] | None: """Return cached card data if still valid. @@ -47,18 +51,22 @@ async def _get_cached_card( ttl: Cache TTL in seconds (0 disables caching). fingerprint: Identity fingerprint -- when provided, the cached entry is invalidated if the fingerprint changed. + clock: Time source override (defaults to module-level + ``_default_clock``); tests inject a FakeClock to drive + cache expiry deterministically. Returns: Cached card dict or None if expired/missing/stale. """ if ttl <= 0: return None + active_clock = clock or _default_clock async with _cache_lock: entry = _card_cache.get(cache_key) if entry is None: return None card_data, expires_at, stored_fp = entry - if time.monotonic() > expires_at: + if active_clock.monotonic() > expires_at: del _card_cache[cache_key] return None if fingerprint and stored_fp != fingerprint: @@ -73,6 +81,7 @@ async def _put_cached_card( ttl: int, *, fingerprint: str = "", + clock: Clock | None = None, ) -> None: """Store card data in cache with TTL and fingerprint. @@ -81,13 +90,17 @@ async def _put_cached_card( card_data: Serialized card dict. ttl: TTL in seconds (0 skips caching). fingerprint: Identity fingerprint for staleness detection. + clock: Time source override (defaults to module-level + ``_default_clock``); tests inject a FakeClock to control + the stored expiry deadline. """ if ttl <= 0: return + active_clock = clock or _default_clock async with _cache_lock: _card_cache[cache_key] = ( card_data, - time.monotonic() + ttl, + active_clock.monotonic() + ttl, fingerprint, ) diff --git a/src/synthorg/api/controllers/events.py b/src/synthorg/api/controllers/events.py index 9d4ba598f9..4fd2e021ed 100644 --- a/src/synthorg/api/controllers/events.py +++ b/src/synthorg/api/controllers/events.py @@ -32,6 +32,7 @@ ) from synthorg.communication.event_stream.stream import EventStreamHub # noqa: TC001 from synthorg.communication.event_stream.types import StreamEvent # noqa: TC001 +from synthorg.core.clock import SystemClock from synthorg.core.domain_errors import ( NotFoundError, UnauthorizedError, @@ -416,7 +417,11 @@ async def _sse_event_stream( # noqa: PLR0915, PLR0912, C901 ) revalidation_armed = app_state is not None and user is not None keepalive_seconds = await _resolve_sse_keepalive_seconds(app_state) - loop_now = asyncio.get_event_loop().time() + # Use ``app_state.clock.monotonic()`` so tests inject FakeClock + # rather than monkey-patching ``asyncio.get_event_loop().time``. + # The bare loop timer is still acceptable for async waits below. + clock = app_state.clock if app_state is not None else SystemClock() + loop_now = clock.monotonic() next_keepalive_ts = loop_now + keepalive_seconds # When auth context is absent (anonymous / unit-test stream), arming # the revalidation deadline at ``loop_now`` would make ``timeout`` @@ -426,7 +431,7 @@ async def _sse_event_stream( # noqa: PLR0915, PLR0912, C901 loop_now + SSE_REVALIDATE_INTERVAL_SECONDS if revalidation_armed else None ) while True: - now = asyncio.get_event_loop().time() + now = clock.monotonic() if next_revalidate_ts is None: timeout = max(0.0, next_keepalive_ts - now) else: @@ -444,7 +449,7 @@ async def _sse_event_stream( # noqa: PLR0915, PLR0912, C901 # be due simultaneously after a long-blocking event was # delivered; emit keepalive first, revalidate second. pass - now = asyncio.get_event_loop().time() + now = clock.monotonic() if now >= next_keepalive_ts: yield {"event": "keepalive", "data": "{}"} next_keepalive_ts = now + keepalive_seconds diff --git a/src/synthorg/api/controllers/health.py b/src/synthorg/api/controllers/health.py index 41c9d7b762..6a568f6a03 100644 --- a/src/synthorg/api/controllers/health.py +++ b/src/synthorg/api/controllers/health.py @@ -9,7 +9,6 @@ """ import asyncio -import time from enum import StrEnum from typing import TYPE_CHECKING, Literal @@ -145,7 +144,7 @@ def _unavailable_response( we still want to emit a well-formed envelope so operator tooling can parse it, rather than letting a 500 surface. """ - uptime = round(time.monotonic() - app_state.startup_time, 2) + uptime = round(app_state.clock.monotonic() - app_state.startup_time, 2) return Response( content=ApiResponse( data=ReadinessStatus( @@ -179,7 +178,7 @@ async def liveness( ) -> ApiResponse[LivenessStatus]: """Return a constant ``ok`` response while the process is alive.""" app_state: AppState = state.app_state - uptime = round(time.monotonic() - app_state.startup_time, 2) + uptime = round(app_state.clock.monotonic() - app_state.startup_time, 2) return ApiResponse( data=LivenessStatus( status="ok", @@ -272,7 +271,7 @@ async def readiness( ) status_code = 200 if outcome is ReadinessOutcome.OK else 503 - uptime = round(time.monotonic() - app_state.startup_time, 2) + uptime = round(app_state.clock.monotonic() - app_state.startup_time, 2) logger.debug( API_HEALTH_CHECK, diff --git a/src/synthorg/api/services/idempotency_service.py b/src/synthorg/api/services/idempotency_service.py index b0bbdb3f23..a19f31c78c 100644 --- a/src/synthorg/api/services/idempotency_service.py +++ b/src/synthorg/api/services/idempotency_service.py @@ -12,12 +12,12 @@ import asyncio import hashlib import json -import time from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum from typing import TYPE_CHECKING, Any +from synthorg.core.clock import Clock, SystemClock from synthorg.observability import get_logger, safe_error_description if TYPE_CHECKING: @@ -127,6 +127,7 @@ def __init__( repository: IdempotencyRepository, *, ttl_seconds: int = DEFAULT_IDEMPOTENCY_TTL_SECONDS, + clock: Clock | None = None, ) -> None: # Invariant: the configured TTL must outlive a polling cycle. # The leader-failed takeover path in ``_wait_for_in_flight`` @@ -149,6 +150,7 @@ def __init__( raise ValueError(msg) self._repo = repository self._ttl_seconds = ttl_seconds + self._clock = clock or SystemClock() async def run_idempotent( self, @@ -317,9 +319,9 @@ async def _wait_for_in_flight( a single ``None`` would 409 every retry after a failed leader, defeating redelivery semantics. """ - deadline = time.monotonic() + _IN_FLIGHT_POLL_TIMEOUT_SECONDS + deadline = self._clock.monotonic() + _IN_FLIGHT_POLL_TIMEOUT_SECONDS backoff = _IN_FLIGHT_POLL_INITIAL_BACKOFF_SECONDS - while time.monotonic() < deadline: + while self._clock.monotonic() < deadline: await asyncio.sleep(backoff) backoff = min(backoff * 2, _IN_FLIGHT_POLL_MAX_BACKOFF_SECONDS) record = await self._repo.get(scope=scope, key=key) diff --git a/src/synthorg/api/state.py b/src/synthorg/api/state.py index 0c03f6d2af..ec51cee564 100644 --- a/src/synthorg/api/state.py +++ b/src/synthorg/api/state.py @@ -57,6 +57,7 @@ ) from synthorg.communication.meeting.scheduler import MeetingScheduler # noqa: TC001 from synthorg.config.schema import RootConfig # noqa: TC001 +from synthorg.core.clock import Clock, SystemClock from synthorg.core.domain_errors import ServiceUnavailableError from synthorg.engine.approval_gate import ApprovalGate # noqa: TC001 from synthorg.engine.coordination.service import MultiAgentCoordinator # noqa: TC001 @@ -276,6 +277,7 @@ class AppState(AppStateServicesMixin): "_ws_revalidation_max_failures", "_ws_revalidation_window_seconds", "approval_store", + "clock", "config", "startup_time", ) @@ -320,6 +322,7 @@ def __init__( # noqa: PLR0913, PLR0915 mcp_installations_repo: McpInstallationRepository | None = None, training_service: TrainingService | None = None, startup_time: float = 0.0, + clock: Clock | None = None, ) -> None: self.config = config self.approval_store = approval_store @@ -501,6 +504,7 @@ def __init__( # noqa: PLR0913, PLR0915 # ordering invariant the controller relies on. self._request_lock_refs: dict[str, int] = {} self.startup_time = startup_time + self.clock: Clock = clock or SystemClock() def _init_derived_services( self, diff --git a/src/synthorg/communication/bus/_nats_receive.py b/src/synthorg/communication/bus/_nats_receive.py index 0ccf8c822a..201007a813 100644 --- a/src/synthorg/communication/bus/_nats_receive.py +++ b/src/synthorg/communication/bus/_nats_receive.py @@ -6,7 +6,6 @@ import asyncio import contextlib -import time from datetime import UTC, datetime from typing import Any @@ -119,7 +118,7 @@ async def _maybe_log_overflow( # noqa: C901, PLR0912, PLR0915 -- linear flow, """ cap = state.config.retention.max_subscriber_queue_size key = (channel_name, subscriber_id) - now = time.monotonic() + now = state.clock.monotonic() last = state.last_overflow_log.get(key, 0.0) if now - last < _OVERFLOW_LOG_INTERVAL_SECONDS: return @@ -417,9 +416,9 @@ async def receive_with_timeout( timeout: float, # noqa: ASYNC109 ) -> DeliveryEnvelope | None: """Wait up to ``timeout`` seconds across one or more fetch polls.""" - deadline = time.monotonic() + timeout + deadline = state.clock.monotonic() + timeout while True: - remaining = deadline - time.monotonic() + remaining = deadline - state.clock.monotonic() if remaining <= 0.0: return None if state.shutdown_event.is_set(): @@ -439,7 +438,7 @@ async def receive_with_timeout( # receive budget so ``receive(timeout=0.1)`` cannot be # extended by the full 2s probe ceiling. If the budget # is already exhausted the helper skips the probe. - budget = deadline - time.monotonic() + budget = deadline - state.clock.monotonic() await _maybe_log_overflow( state, sub, diff --git a/src/synthorg/communication/bus/_nats_state.py b/src/synthorg/communication/bus/_nats_state.py index 79b8d94d22..a0856ab7dd 100644 --- a/src/synthorg/communication/bus/_nats_state.py +++ b/src/synthorg/communication/bus/_nats_state.py @@ -14,6 +14,7 @@ MessageBusConfig, NatsConfig, ) +from synthorg.core.clock import Clock, SystemClock if TYPE_CHECKING: from nats.aio.client import Client as NatsClient @@ -61,8 +62,18 @@ class _NatsState: # in-memory bus, where every dropped envelope emits. last_overflow_log: dict[tuple[str, str], float] = field(default_factory=dict) + # Injectable time source. Submodule functions consult ``state.clock`` + # for monotonic deadlines and overflow rate-limit windows so tests + # can drive virtual time without monkey-patching ``time.monotonic`` + # at module scope. + clock: Clock = field(default_factory=SystemClock) -def create_state(config: MessageBusConfig) -> _NatsState: + +def create_state( + config: MessageBusConfig, + *, + clock: Clock | None = None, +) -> _NatsState: """Build a ``_NatsState`` from validated bus configuration. The caller (``JetStreamMessageBus.__init__``) must ensure @@ -77,4 +88,5 @@ def create_state(config: MessageBusConfig) -> _NatsState: nats_config=nats_config, stream_name=f"{nats_config.stream_name_prefix}_BUS", kv_bucket_name=f"{nats_config.stream_name_prefix}_BUS_CHANNELS", + clock=clock or SystemClock(), ) diff --git a/src/synthorg/communication/bus/memory.py b/src/synthorg/communication/bus/memory.py index 9e82534947..9dc308c2d0 100644 --- a/src/synthorg/communication/bus/memory.py +++ b/src/synthorg/communication/bus/memory.py @@ -6,7 +6,6 @@ import asyncio import contextlib -import time from collections import deque from collections.abc import Sequence # noqa: TC003 from datetime import UTC, datetime @@ -27,6 +26,7 @@ DeliveryEnvelope, Subscription, ) +from synthorg.core.clock import Clock, SystemClock from synthorg.observability import get_logger from synthorg.observability.events.communication import ( COMM_BATCH_PUBLISHED, @@ -99,8 +99,14 @@ class InMemoryMessageBus: channels and retention settings. """ - def __init__(self, *, config: MessageBusConfig) -> None: + def __init__( + self, + *, + config: MessageBusConfig, + clock: Clock | None = None, + ) -> None: self._config = config + self._clock = clock or SystemClock() self._lock = asyncio.Lock() self._channels: dict[str, Channel] = {} self._queues: dict[tuple[str, str], asyncio.Queue[DeliveryEnvelope | None]] = {} @@ -118,7 +124,7 @@ def __init__(self, *, config: MessageBusConfig) -> None: self._running = False self._shutdown_event = asyncio.Event() self._idle_poll_count: int = 0 - self._last_idle_summary: float = time.monotonic() + self._last_idle_summary: float = self._clock.monotonic() @property def is_running(self) -> bool: @@ -163,7 +169,7 @@ async def start(self) -> None: self._running = True self._shutdown_event.clear() self._idle_poll_count = 0 - self._last_idle_summary = time.monotonic() + self._last_idle_summary = self._clock.monotonic() maxlen = self._config.retention.max_messages_per_channel for name in self._config.channels: ch = Channel(name=name, type=ChannelType.TOPIC) @@ -657,7 +663,7 @@ async def _log_receive_null( ) else: self._idle_poll_count += 1 - now = time.monotonic() + now = self._clock.monotonic() if now - self._last_idle_summary >= _IDLE_SUMMARY_INTERVAL_SECONDS: logger.debug( COMM_CHANNELS_IDLE_SUMMARY, diff --git a/src/synthorg/engine/workflow/ceremony_scheduler.py b/src/synthorg/engine/workflow/ceremony_scheduler.py index 46189b0aec..3814de091a 100644 --- a/src/synthorg/engine/workflow/ceremony_scheduler.py +++ b/src/synthorg/engine/workflow/ceremony_scheduler.py @@ -9,9 +9,9 @@ """ import asyncio -import time from typing import TYPE_CHECKING, Any +from synthorg.core.clock import Clock, SystemClock from synthorg.engine.workflow.ceremony_bridge import ( build_trigger_event_name, ) @@ -86,6 +86,7 @@ class CeremonyScheduler: "_activation_time", "_active_sprint", "_active_strategy", + "_clock", "_completion_counters", "_fired_once_triggers", "_lock", @@ -100,8 +101,10 @@ def __init__( self, *, meeting_scheduler: MeetingScheduler, + clock: Clock | None = None, ) -> None: self._meeting_scheduler = meeting_scheduler + self._clock = clock or SystemClock() self._active_strategy: CeremonySchedulingStrategy | None = None self._active_sprint: Sprint | None = None self._sprint_config: SprintConfig | None = None @@ -204,7 +207,7 @@ async def activate_sprint( self._completion_counters = {c.name: 0 for c in config.ceremonies} self._fired_once_triggers = set() self._total_completions = 0 - self._activation_time = time.monotonic() + self._activation_time = self._clock.monotonic() self._running = True try: @@ -515,7 +518,7 @@ def _build_context(self, sprint: Sprint) -> CeremonyEvalContext: completions_since_last_trigger=0, total_completions_this_sprint=self._total_completions, total_tasks_in_sprint=total_tasks, - elapsed_seconds=time.monotonic() - self._activation_time, + elapsed_seconds=self._clock.monotonic() - self._activation_time, # Budget integration is a follow-up. budget_consumed_fraction=0.0, budget_remaining=0.0, @@ -541,7 +544,7 @@ def _build_ceremony_context( ), total_completions_this_sprint=self._total_completions, total_tasks_in_sprint=total_tasks, - elapsed_seconds=time.monotonic() - self._activation_time, + elapsed_seconds=self._clock.monotonic() - self._activation_time, # Budget integration is a follow-up. budget_consumed_fraction=0.0, budget_remaining=0.0, diff --git a/src/synthorg/integrations/health/checks/database.py b/src/synthorg/integrations/health/checks/database.py index 3bf4a31c22..5e912fbe3f 100644 --- a/src/synthorg/integrations/health/checks/database.py +++ b/src/synthorg/integrations/health/checks/database.py @@ -1,8 +1,8 @@ """Database health check.""" -import time from datetime import UTC, datetime +from synthorg.core.clock import Clock, SystemClock from synthorg.integrations.connections.models import ( Connection, ConnectionStatus, @@ -25,14 +25,17 @@ class DatabaseHealthCheck: that required metadata fields are present. """ + def __init__(self, *, clock: Clock | None = None) -> None: + self._clock = clock or SystemClock() + async def check(self, connection: Connection) -> HealthReport: """Verify database connection metadata is valid.""" - start = time.monotonic() + start = self._clock.monotonic() raw_dialect = connection.metadata.get("dialect") raw_database = connection.metadata.get("database") dialect = raw_dialect.strip() if isinstance(raw_dialect, str) else "" database = raw_database.strip() if isinstance(raw_database, str) else "" - elapsed = (time.monotonic() - start) * 1000 + elapsed = (self._clock.monotonic() - start) * 1000 if not dialect or not database: logger.warning( diff --git a/src/synthorg/memory/retrieval/hierarchical/workers.py b/src/synthorg/memory/retrieval/hierarchical/workers.py index 3417289fbd..6604fccd26 100644 --- a/src/synthorg/memory/retrieval/hierarchical/workers.py +++ b/src/synthorg/memory/retrieval/hierarchical/workers.py @@ -5,10 +5,10 @@ """ import builtins -import time from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING +from synthorg.core.clock import Clock, SystemClock from synthorg.core.enums import MemoryCategory from synthorg.memory import errors as memory_errors from synthorg.memory.models import MemoryQuery @@ -116,10 +116,12 @@ def __init__( backend: MemoryBackend, config: MemoryRetrievalConfig, shared_store: SharedKnowledgeStore | None = None, + clock: Clock | None = None, ) -> None: self._backend = backend self._config = config self._shared_store = shared_store + self._clock = clock or SystemClock() @property def name(self) -> str: @@ -128,7 +130,7 @@ def name(self) -> str: async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: """Execute semantic retrieval using dense + optional sparse.""" - start = time.monotonic() + start = self._clock.monotonic() logger.info( MEMORY_HIERARCHICAL_WORKER_START, worker=self.name, @@ -185,7 +187,7 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: candidates = tuple( _scored_to_candidate(s, source_worker=self.name) for s in ranked ) - elapsed_ms = int((time.monotonic() - start) * 1000) + elapsed_ms = int((self._clock.monotonic() - start) * 1000) logger.info( MEMORY_HIERARCHICAL_WORKER_COMPLETE, worker=self.name, @@ -200,7 +202,7 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: except builtins.MemoryError, RecursionError: raise except Exception as exc: - elapsed_ms = int((time.monotonic() - start) * 1000) + elapsed_ms = int((self._clock.monotonic() - start) * 1000) logger.warning( MEMORY_HIERARCHICAL_WORKER_FAILED, worker=self.name, @@ -273,6 +275,7 @@ def __init__( backend: MemoryBackend, config: MemoryRetrievalConfig, time_window_hours: int = _DEFAULT_EPISODIC_WINDOW_HOURS, + clock: Clock | None = None, ) -> None: if time_window_hours <= 0: msg = f"time_window_hours must be positive, got {time_window_hours}" @@ -280,6 +283,7 @@ def __init__( self._backend = backend self._config = config self._time_window_hours = time_window_hours + self._clock = clock or SystemClock() @property def name(self) -> str: @@ -288,7 +292,7 @@ def name(self) -> str: async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: """Retrieve recent episodic memories.""" - start = time.monotonic() + start = self._clock.monotonic() logger.info( MEMORY_HIERARCHICAL_WORKER_START, worker=self.name, @@ -300,7 +304,7 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: query.categories is not None and MemoryCategory.EPISODIC not in query.categories ): - elapsed_ms = int((time.monotonic() - start) * 1000) + elapsed_ms = int((self._clock.monotonic() - start) * 1000) return RetrievalResult( worker_name=self.name, execution_ms=elapsed_ms, @@ -362,7 +366,7 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: reverse=True, ) candidates = tuple(candidates_list[: query.max_results]) - elapsed_ms = int((time.monotonic() - start) * 1000) + elapsed_ms = int((self._clock.monotonic() - start) * 1000) logger.info( MEMORY_HIERARCHICAL_WORKER_COMPLETE, worker=self.name, @@ -377,7 +381,7 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: except builtins.MemoryError, RecursionError: raise except Exception as exc: - elapsed_ms = int((time.monotonic() - start) * 1000) + elapsed_ms = int((self._clock.monotonic() - start) * 1000) logger.warning( MEMORY_HIERARCHICAL_WORKER_FAILED, worker=self.name, @@ -408,9 +412,11 @@ def __init__( *, backend: MemoryBackend, config: MemoryRetrievalConfig, + clock: Clock | None = None, ) -> None: self._backend = backend self._config = config + self._clock = clock or SystemClock() @property def name(self) -> str: @@ -419,7 +425,7 @@ def name(self) -> str: async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: """Retrieve procedural memories.""" - start = time.monotonic() + start = self._clock.monotonic() logger.info( MEMORY_HIERARCHICAL_WORKER_START, worker=self.name, @@ -430,7 +436,7 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: query.categories is not None and MemoryCategory.PROCEDURAL not in query.categories ): - elapsed_ms = int((time.monotonic() - start) * 1000) + elapsed_ms = int((self._clock.monotonic() - start) * 1000) return RetrievalResult( worker_name=self.name, execution_ms=elapsed_ms, @@ -464,7 +470,7 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: ) ) candidates = tuple(candidates_list) - elapsed_ms = int((time.monotonic() - start) * 1000) + elapsed_ms = int((self._clock.monotonic() - start) * 1000) logger.info( MEMORY_HIERARCHICAL_WORKER_COMPLETE, worker=self.name, @@ -479,7 +485,7 @@ async def retrieve(self, query: RetrievalQuery) -> RetrievalResult: except builtins.MemoryError, RecursionError: raise except Exception as exc: - elapsed_ms = int((time.monotonic() - start) * 1000) + elapsed_ms = int((self._clock.monotonic() - start) * 1000) logger.warning( MEMORY_HIERARCHICAL_WORKER_FAILED, worker=self.name, diff --git a/src/synthorg/meta/validation/ci_validator.py b/src/synthorg/meta/validation/ci_validator.py index fcaf39bf8d..215989710a 100644 --- a/src/synthorg/meta/validation/ci_validator.py +++ b/src/synthorg/meta/validation/ci_validator.py @@ -6,9 +6,9 @@ """ import asyncio -import time from pathlib import Path +from synthorg.core.clock import Clock, SystemClock from synthorg.meta.models import CIValidationResult from synthorg.observability import get_logger from synthorg.observability.events.meta import ( @@ -32,8 +32,14 @@ class LocalCIValidator: timeout_seconds: Maximum wall-clock time for each subprocess. """ - def __init__(self, *, timeout_seconds: int = 300) -> None: + def __init__( + self, + *, + timeout_seconds: int = 300, + clock: Clock | None = None, + ) -> None: self._timeout = timeout_seconds + self._clock = clock or SystemClock() async def validate( self, @@ -54,7 +60,7 @@ async def validate( META_CI_VALIDATION_STARTED, file_count=len(changed_files), ) - start = time.monotonic() + start = self._clock.monotonic() errors: list[str] = [] # Step 1: Lint. @@ -78,7 +84,7 @@ async def validate( errors, ) - elapsed = time.monotonic() - start + elapsed = self._clock.monotonic() - start passed = lint_ok and typecheck_ok and tests_ok if passed: diff --git a/src/synthorg/security/uncertainty.py b/src/synthorg/security/uncertainty.py index a517bd6630..1ead7e04da 100644 --- a/src/synthorg/security/uncertainty.py +++ b/src/synthorg/security/uncertainty.py @@ -17,7 +17,6 @@ import asyncio import math import re -import time from collections import Counter from itertools import combinations from typing import TYPE_CHECKING, Final @@ -31,6 +30,7 @@ # they must resolve at runtime when downstream tooling evaluates # type hints (DI containers, doc generators). from synthorg.budget.tracker import CostTracker # noqa: TC001 +from synthorg.core.clock import Clock, SystemClock from synthorg.core.types import NotBlankStr from synthorg.engine.prompt_safety import ( TAG_TASK_DATA, @@ -222,11 +222,13 @@ def __init__( model_resolver: ModelResolver, config: UncertaintyCheckConfig, cost_tracker: CostTracker | None = None, + clock: Clock | None = None, ) -> None: self._registry = provider_registry self._resolver = model_resolver self._config = config self._cost_tracker = cost_tracker + self._clock = clock or SystemClock() async def check(self, prompt: str) -> UncertaintyResult: """Run cross-provider uncertainty check. @@ -238,11 +240,11 @@ async def check(self, prompt: str) -> UncertaintyResult: An ``UncertaintyResult`` with the confidence score and similarity metrics. """ - start = time.monotonic() + start = self._clock.monotonic() # Skip if no model ref configured. if self._config.model_ref is None: - duration_ms = (time.monotonic() - start) * 1000 + duration_ms = (self._clock.monotonic() - start) * 1000 logger.info( SECURITY_UNCERTAINTY_CHECK_SKIPPED, reason="no model_ref configured", @@ -266,7 +268,7 @@ async def check(self, prompt: str) -> UncertaintyResult: unique.append(c) candidates = tuple(unique) if len(candidates) < self._config.min_providers: - duration_ms = (time.monotonic() - start) * 1000 + duration_ms = (self._clock.monotonic() - start) * 1000 logger.info( SECURITY_UNCERTAINTY_CHECK_SKIPPED, reason="insufficient providers", @@ -293,7 +295,7 @@ async def check(self, prompt: str) -> UncertaintyResult: # Send prompt to all providers in parallel. responses = await self._collect_responses(prompt, candidates) - duration_ms = (time.monotonic() - start) * 1000 + duration_ms = (self._clock.monotonic() - start) * 1000 # If only one response, insufficient for comparison. if len(responses) < 2: # noqa: PLR2004 diff --git a/src/synthorg/tools/sandbox/docker_sandbox.py b/src/synthorg/tools/sandbox/docker_sandbox.py index b63703ffb5..f48bfae069 100644 --- a/src/synthorg/tools/sandbox/docker_sandbox.py +++ b/src/synthorg/tools/sandbox/docker_sandbox.py @@ -7,13 +7,13 @@ import asyncio import platform -import time from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, Final import aiodocker import aiodocker.containers +from synthorg.core.clock import Clock, SystemClock from synthorg.observability import get_logger, safe_error_description from synthorg.observability.events.docker import ( DOCKER_CONTAINER_CREATED, @@ -120,6 +120,7 @@ def __init__( config: DockerSandboxConfig | None = None, workspace: Path, log_shipping_config: ContainerLogShippingConfig | None = None, + clock: Clock | None = None, ) -> None: """Initialize the Docker sandbox. @@ -128,6 +129,9 @@ def __init__( workspace: Absolute path to the workspace root. Must exist. log_shipping_config: Container log shipping configuration. Default-constructed if not provided. + clock: Time source for execution-duration measurements. + Defaults to ``SystemClock()``; tests inject ``FakeClock`` + to drive elapsed-ms assertions deterministically. Raises: ValueError: If *workspace* is not absolute or does not exist. @@ -146,6 +150,7 @@ def __init__( self._docker: aiodocker.Docker | None = None self._tracked_containers: dict[str, str | None] = {} self._lock = asyncio.Lock() + self._clock = clock or SystemClock() self._credential_manager = SandboxCredentialManager() self._runtime_resolver: SandboxRuntimeResolver | None = None if log_shipping_config is None: @@ -696,14 +701,14 @@ async def _start_and_wait( ) raise SandboxStartError(msg) from exc - start_mono = time.monotonic() + start_mono = self._clock.monotonic() timed_out, returncode = await self._wait_for_exit( docker=docker, container_obj=container_obj, container_id=container_id, timeout=timeout, ) - elapsed_ms = int((time.monotonic() - start_mono) * 1000) + elapsed_ms = int((self._clock.monotonic() - start_mono) * 1000) stdout, stderr = await self._safe_collect_logs( container_obj, diff --git a/tests/unit/api/services/test_idempotency_service.py b/tests/unit/api/services/test_idempotency_service.py index bffe98ce66..c97fc26399 100644 --- a/tests/unit/api/services/test_idempotency_service.py +++ b/tests/unit/api/services/test_idempotency_service.py @@ -176,31 +176,51 @@ async def cb() -> dict[str, Any]: assert len(repo.completes) == 0 +class _DeterministicClock: + """Stub Clock injected via the service constructor's ``clock`` kwarg. + + Tracks a virtual-time float that advances when the service awaits + asyncio.sleep (which we also stub out so polling deadlines progress + without real wall-clock waits). + """ + + def __init__(self) -> None: + self.now_seconds = 0.0 + + def now(self) -> Any: + from datetime import UTC + from datetime import datetime as _dt + + return _dt.fromtimestamp(self.now_seconds, tz=UTC) + + def monotonic(self) -> float: + return self.now_seconds + + async def sleep(self, seconds: float) -> None: + if seconds > 0: + self.now_seconds += seconds + + def _install_deterministic_clock( monkeypatch: pytest.MonkeyPatch, svc_mod: Any, -) -> list[float]: - """Replace ``time.monotonic`` and ``asyncio.sleep`` in *svc_mod*. - - Returns a single-element ``[clock]`` list (used as a mutable cell - so the spy and the test can read/write the same float). Each - ``await asyncio.sleep(d)`` call advances the clock by *d* without - actually sleeping, so the polling loop sees deterministic elapsed - time even on slow CI workers. - """ - clock = [0.0] +) -> _DeterministicClock: + """Stub asyncio.sleep so the service's polling loop progresses + against the injected ``_DeterministicClock`` without real waits. - def _monotonic() -> float: - return clock[0] + Returns the clock instance; the test passes it via the service + constructor's ``clock`` kwarg, then reads/writes ``now_seconds`` + to verify timing-dependent behaviour. + """ + clock = _DeterministicClock() async def _fake_sleep(delay: float) -> None: # Negative or zero stays a real no-op; positive advances the # virtual clock so deadline arithmetic in the service-under- # test progresses without a real wall-clock sleep. if delay > 0: - clock[0] += delay + clock.now_seconds += delay - monkeypatch.setattr(svc_mod.time, "monotonic", _monotonic) monkeypatch.setattr(svc_mod.asyncio, "sleep", _fake_sleep) return clock @@ -216,7 +236,7 @@ async def test_run_idempotent_in_flight_returns_none_after_poll_timeout( monkeypatch.setattr(svc_mod, "_IN_FLIGHT_POLL_TIMEOUT_SECONDS", 0.05) monkeypatch.setattr(svc_mod, "_IN_FLIGHT_POLL_INITIAL_BACKOFF_SECONDS", 0.005) monkeypatch.setattr(svc_mod, "_IN_FLIGHT_POLL_MAX_BACKOFF_SECONDS", 0.01) - _install_deterministic_clock(monkeypatch, svc_mod) + clock = _install_deterministic_clock(monkeypatch, svc_mod) class _StuckRepo(_FakeRepo): async def get( @@ -235,7 +255,7 @@ async def get( ) repo = _StuckRepo(initial_outcome=IdempotencyOutcome.IN_FLIGHT) - svc = svc_mod.IdempotencyService(repo) + svc = svc_mod.IdempotencyService(repo, clock=clock) async def cb() -> dict[str, Any]: msg = "callback must not run when claim is in-flight" @@ -260,7 +280,7 @@ async def test_run_idempotent_in_flight_resolves_to_completed_via_poll( monkeypatch.setattr(svc_mod, "_IN_FLIGHT_POLL_TIMEOUT_SECONDS", 0.5) monkeypatch.setattr(svc_mod, "_IN_FLIGHT_POLL_INITIAL_BACKOFF_SECONDS", 0.005) monkeypatch.setattr(svc_mod, "_IN_FLIGHT_POLL_MAX_BACKOFF_SECONDS", 0.01) - _install_deterministic_clock(monkeypatch, svc_mod) + clock = _install_deterministic_clock(monkeypatch, svc_mod) poll_count = 0 @@ -293,7 +313,7 @@ async def get( ) repo = _ResolvingRepo(initial_outcome=IdempotencyOutcome.IN_FLIGHT) - svc = svc_mod.IdempotencyService(repo) + svc = svc_mod.IdempotencyService(repo, clock=clock) async def cb() -> dict[str, Any]: msg = "callback must not run when claim is in-flight" From 1d3ebec9953fb13a7d0ca714dad411821cff59b3 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 21:07:50 +0200 Subject: [PATCH 19/35] feat: add extra=forbid to 489 ConfigDicts across listed domains Sweep ALL ConfigDict(frozen=True, allow_inf_nan=False) instances in the listed domains (a2a, api/controllers, approval, backup, client, communication, config, engine, hr, memory, providers, security) and add extra='forbid' so unknown fields surface as ValidationError at ingestion rather than silently passing. Carve-out: 46 ConfigDict instances on 37 classes that declare @computed_field keep their lenient extra-handling, because pydantic v2's model_dump() includes computed-field values by default and the forbid contract would reject them on round-trip via ModelClass( **other.model_dump()). Net: 489 ConfigDict instances now reject extras (535 - 46 carve-outs). Test fix for the SSE-revalidate fake (which now needs an app_state.clock attribute) lands in the same commit. Refs #1733 (#18) --- src/synthorg/a2a/config.py | 8 ++--- src/synthorg/a2a/models.py | 24 ++++++------- src/synthorg/api/controllers/agents.py | 6 ++-- src/synthorg/api/controllers/analytics.py | 6 ++-- src/synthorg/api/controllers/approvals.py | 2 +- src/synthorg/api/controllers/autonomy.py | 2 +- src/synthorg/api/controllers/budget.py | 4 +-- .../api/controllers/ceremony_policy.py | 6 ++-- src/synthorg/api/controllers/clients.py | 4 +-- src/synthorg/api/controllers/collaboration.py | 2 +- src/synthorg/api/controllers/escalations.py | 2 +- src/synthorg/api/controllers/events.py | 4 +-- src/synthorg/api/controllers/health.py | 4 +-- src/synthorg/api/controllers/meetings.py | 2 +- src/synthorg/api/controllers/memory.py | 2 +- src/synthorg/api/controllers/quality.py | 2 +- src/synthorg/api/controllers/reports.py | 2 +- src/synthorg/api/controllers/requests.py | 6 ++-- src/synthorg/api/controllers/reviews.py | 4 +-- src/synthorg/api/controllers/scaling.py | 6 ++-- src/synthorg/api/controllers/settings.py | 4 +-- src/synthorg/api/controllers/setup_models.py | 14 ++++---- src/synthorg/api/controllers/simulations.py | 4 +-- src/synthorg/api/controllers/teams.py | 2 +- src/synthorg/api/controllers/users.py | 2 +- src/synthorg/approval/models.py | 4 +-- src/synthorg/backup/config.py | 4 +-- src/synthorg/backup/models.py | 8 ++--- src/synthorg/client/config.py | 14 ++++---- src/synthorg/client/human_queue.py | 4 +-- src/synthorg/client/models.py | 16 ++++----- src/synthorg/client/store.py | 2 +- .../communication/async_tasks/models.py | 6 ++-- src/synthorg/communication/channel.py | 2 +- .../communication/citation/manager.py | 2 +- src/synthorg/communication/citation/models.py | 2 +- src/synthorg/communication/config.py | 20 +++++------ .../conflict_resolution/config.py | 6 ++-- .../conflict_resolution/escalation/config.py | 2 +- .../conflict_resolution/escalation/models.py | 6 ++-- .../conflict_resolution/models.py | 8 ++--- .../communication/delegation/authority.py | 2 +- .../communication/delegation/entity_guard.py | 2 +- .../communication/delegation/models.py | 6 ++-- .../communication/event_stream/interrupt.py | 4 +-- .../communication/event_stream/types.py | 2 +- .../communication/loop_prevention/models.py | 2 +- src/synthorg/communication/meeting/config.py | 8 ++--- src/synthorg/communication/meeting/models.py | 12 +++---- src/synthorg/communication/message.py | 8 ++--- src/synthorg/communication/subscription.py | 4 +-- src/synthorg/config/provider_schema.py | 6 ++-- src/synthorg/config/schema.py | 12 +++---- src/synthorg/engine/agent_state.py | 2 +- src/synthorg/engine/assignment/models.py | 8 ++--- src/synthorg/engine/checkpoint/models.py | 6 ++-- src/synthorg/engine/classification/models.py | 2 +- .../engine/classification/protocol.py | 2 +- src/synthorg/engine/compaction/models.py | 4 +-- src/synthorg/engine/context.py | 2 +- .../engine/coordination/attribution.py | 2 +- .../engine/coordination/dispatcher_types.py | 2 +- src/synthorg/engine/coordination/models.py | 6 ++-- src/synthorg/engine/decomposition/llm.py | 2 +- src/synthorg/engine/decomposition/models.py | 8 ++--- src/synthorg/engine/evolution/config.py | 14 ++++---- .../evolution/guards/shadow_protocol.py | 2 +- src/synthorg/engine/evolution/models.py | 6 ++-- src/synthorg/engine/evolution/protocols.py | 2 +- src/synthorg/engine/health/models.py | 2 +- src/synthorg/engine/identity/diff.py | 2 +- src/synthorg/engine/identity/store/config.py | 2 +- src/synthorg/engine/intake/models.py | 2 +- .../middleware/coordination_protocol.py | 2 +- src/synthorg/engine/middleware/models.py | 12 +++---- .../engine/middleware/semantic_drift.py | 2 +- src/synthorg/engine/parallel_models.py | 2 +- src/synthorg/engine/plan_models.py | 4 +-- src/synthorg/engine/policy_validation.py | 2 +- src/synthorg/engine/prompt.py | 2 +- src/synthorg/engine/quality/models.py | 2 +- src/synthorg/engine/quality/verification.py | 8 ++--- .../engine/quality/verification_config.py | 2 +- src/synthorg/engine/review/models.py | 4 +-- src/synthorg/engine/routing/models.py | 8 ++--- src/synthorg/engine/session.py | 4 +-- src/synthorg/engine/shutdown.py | 2 +- src/synthorg/engine/stagnation/models.py | 4 +-- src/synthorg/engine/strategy/consensus.py | 2 +- src/synthorg/engine/strategy/lenses.py | 2 +- src/synthorg/engine/strategy/models.py | 34 +++++++++---------- src/synthorg/engine/strategy/premortem.py | 4 +-- src/synthorg/engine/task_engine_config.py | 2 +- src/synthorg/engine/task_engine_models.py | 16 ++++----- src/synthorg/engine/task_execution.py | 4 +-- .../engine/trajectory/efficiency_ratios.py | 4 +-- src/synthorg/engine/trajectory/models.py | 2 +- src/synthorg/engine/trajectory/pte.py | 2 +- .../engine/workflow/blueprint_models.py | 6 ++-- .../engine/workflow/ceremony_policy.py | 4 +-- src/synthorg/engine/workflow/config.py | 2 +- src/synthorg/engine/workflow/definition.py | 8 ++--- src/synthorg/engine/workflow/diff.py | 8 ++--- .../engine/workflow/execution_models.py | 6 ++-- src/synthorg/engine/workflow/kanban_board.py | 6 ++-- src/synthorg/engine/workflow/sprint_config.py | 4 +-- .../engine/workflow/sprint_lifecycle.py | 2 +- .../engine/workflow/strategy_migration.py | 2 +- .../engine/workflow/validation_types.py | 2 +- .../engine/workflow/velocity_types.py | 2 +- src/synthorg/engine/workspace/disk_quota.py | 2 +- src/synthorg/engine/workspace/models.py | 8 ++--- src/synthorg/hr/activity.py | 4 +-- src/synthorg/hr/archival_protocol.py | 2 +- src/synthorg/hr/evaluation/config.py | 14 ++++---- .../evaluation/dogfooding_dataset_builder.py | 2 +- .../evaluation/external_benchmark_models.py | 12 +++---- src/synthorg/hr/evaluation/models.py | 10 +++--- src/synthorg/hr/health/service.py | 2 +- src/synthorg/hr/models.py | 12 +++---- src/synthorg/hr/performance/config.py | 2 +- .../hr/performance/inflection_protocol.py | 2 +- src/synthorg/hr/performance/models.py | 18 +++++----- src/synthorg/hr/performance/summary.py | 2 +- src/synthorg/hr/promotion/config.py | 8 ++--- src/synthorg/hr/promotion/models.py | 6 ++-- src/synthorg/hr/pruning/models.py | 10 +++--- src/synthorg/hr/pruning/policy.py | 4 +-- src/synthorg/hr/scaling/config.py | 14 ++++---- src/synthorg/hr/scaling/models.py | 6 ++-- src/synthorg/hr/training/config.py | 2 +- src/synthorg/hr/training/models.py | 14 ++++---- .../memory/backends/composite/config.py | 2 +- src/synthorg/memory/backends/mem0/config.py | 8 ++--- src/synthorg/memory/config.py | 6 ++-- src/synthorg/memory/consolidation/config.py | 14 ++++---- .../memory/consolidation/distillation.py | 2 +- src/synthorg/memory/consolidation/models.py | 14 ++++---- .../memory/consolidation/wiki_export.py | 2 +- .../memory/embedding/fine_tune_models.py | 12 +++---- src/synthorg/memory/embedding/rankings.py | 2 +- src/synthorg/memory/fine_tune_plan.py | 4 +-- src/synthorg/memory/models.py | 8 ++--- src/synthorg/memory/org/access_control.py | 4 +-- src/synthorg/memory/org/config.py | 4 +-- src/synthorg/memory/org/models.py | 12 +++---- .../memory/procedural/capture/config.py | 2 +- .../memory/procedural/evolver_config.py | 2 +- .../memory/procedural/evolver_report.py | 2 +- src/synthorg/memory/procedural/models.py | 6 ++-- .../memory/procedural/propagation/config.py | 2 +- .../memory/procedural/pruning/config.py | 2 +- .../memory/procedural/supersession.py | 2 +- .../procedural/trajectory_aggregator.py | 4 +-- src/synthorg/memory/ranking.py | 2 +- .../memory/retrieval/hierarchical/models.py | 4 +-- src/synthorg/memory/retrieval/models.py | 8 ++--- src/synthorg/memory/retrieval_config.py | 2 +- src/synthorg/memory/self_editing.py | 2 +- src/synthorg/memory/sparse.py | 2 +- src/synthorg/providers/capabilities.py | 2 +- src/synthorg/providers/defaults_config.py | 2 +- src/synthorg/providers/discovery_policy.py | 2 +- src/synthorg/providers/health.py | 2 +- .../providers/management/capability_dtos.py | 8 ++--- src/synthorg/providers/management/dtos.py | 10 +++--- .../providers/management/local_models.py | 2 +- src/synthorg/providers/models.py | 14 ++++---- src/synthorg/providers/presets.py | 2 +- src/synthorg/providers/probing.py | 2 +- src/synthorg/providers/routing/models.py | 6 ++-- src/synthorg/security/autonomy/models.py | 12 +++---- src/synthorg/security/config.py | 12 +++---- src/synthorg/security/models.py | 8 ++--- src/synthorg/security/policy_engine/config.py | 2 +- src/synthorg/security/policy_engine/models.py | 4 +-- src/synthorg/security/risk_scorer.py | 2 +- src/synthorg/security/rules/risk_override.py | 2 +- src/synthorg/security/safety_classifier.py | 2 +- src/synthorg/security/ssrf_violation.py | 2 +- src/synthorg/security/timeout/config.py | 12 +++---- src/synthorg/security/timeout/models.py | 2 +- .../security/timeout/parked_context.py | 2 +- src/synthorg/security/trust/config.py | 12 +++---- src/synthorg/security/trust/models.py | 4 +-- src/synthorg/security/uncertainty.py | 2 +- .../api/controllers/test_sse_revalidate.py | 6 ++++ 187 files changed, 499 insertions(+), 491 deletions(-) diff --git a/src/synthorg/a2a/config.py b/src/synthorg/a2a/config.py index ad5766fd8d..590cf58548 100644 --- a/src/synthorg/a2a/config.py +++ b/src/synthorg/a2a/config.py @@ -35,7 +35,7 @@ class A2AAuthConfig(BaseModel): outbound_scheme: Default auth scheme for outbound requests. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") inbound_scheme: A2AAuthScheme = Field( default="api_key", @@ -67,7 +67,7 @@ class A2APushConfig(BaseModel): protection. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = False signature_algorithm: A2ASignatureAlgorithm = Field( @@ -98,7 +98,7 @@ class A2AAgentCardVerificationConfig(BaseModel): verification. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = False require_signatures: bool = False @@ -152,7 +152,7 @@ class A2AConfig(BaseModel): agent_card_verification: Agent Card signature verification. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = False allowed_peers: tuple[NotBlankStr, ...] = () diff --git a/src/synthorg/a2a/models.py b/src/synthorg/a2a/models.py index 61b4baf555..ec56cad802 100644 --- a/src/synthorg/a2a/models.py +++ b/src/synthorg/a2a/models.py @@ -35,7 +35,7 @@ class JsonRpcRequest(BaseModel): params: Method parameters. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") jsonrpc: Literal["2.0"] = "2.0" id: str | int = Field( @@ -64,7 +64,7 @@ class JsonRpcErrorData(BaseModel): data: Additional error data. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") code: int = Field(description="Integer error code") message: str = Field(description="Human-readable error description") @@ -93,7 +93,7 @@ class JsonRpcResponse(BaseModel): error: Error payload. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") jsonrpc: Literal["2.0"] = "2.0" id: str | int | None = None @@ -166,7 +166,7 @@ class A2ATextPart(BaseModel): text: The text content. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["text"] = "text" text: NotBlankStr = Field(description="Text content") @@ -181,7 +181,7 @@ class A2ADataPart(BaseModel): mime_type: Optional MIME type hint. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["data"] = "data" data: dict[str, Any] = Field(description="Structured JSON content") @@ -207,7 +207,7 @@ class A2AFilePart(BaseModel): name: Optional human-readable filename. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["file"] = "file" uri: NotBlankStr = Field(description="File URI or URL") @@ -247,7 +247,7 @@ class A2AMessage(BaseModel): metadata: Optional metadata key-value pairs. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") role: A2AMessageRole = Field(description="Sender role") parts: tuple[A2AMessagePart, ...] = Field( @@ -279,7 +279,7 @@ class A2ATask(BaseModel): metadata: Task-level metadata. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: str(uuid4()), @@ -320,7 +320,7 @@ class A2AAgentSkill(BaseModel): output_modes: Produced output content types. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique skill identifier") name: NotBlankStr = Field(description="Human-readable skill name") @@ -354,7 +354,7 @@ class A2AAuthSchemeInfo(BaseModel): endpoint). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") scheme: NotBlankStr = Field(description="Auth scheme identifier") service_url: str | None = Field( @@ -371,7 +371,7 @@ class A2AAgentProvider(BaseModel): url: Organization URL. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") organization: NotBlankStr = Field(description="Organization name") url: str | None = Field( @@ -396,7 +396,7 @@ class A2AAgentCard(BaseModel): version: Agent Card schema version. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Agent display name") description: str = Field( diff --git a/src/synthorg/api/controllers/agents.py b/src/synthorg/api/controllers/agents.py index 00f702ad1d..3e743eacbb 100644 --- a/src/synthorg/api/controllers/agents.py +++ b/src/synthorg/api/controllers/agents.py @@ -115,7 +115,7 @@ async def _resolve_agent_identity( class TrustSummary(BaseModel): """Trust state summary for the health endpoint.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") level: ToolAccessLevel score: float | None = Field( @@ -136,7 +136,7 @@ def _score_requires_evaluation_time(self) -> Self: class PerformanceSummary(BaseModel): """Performance snapshot summary for the health endpoint.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") quality_score: float | None = Field( default=None, @@ -165,7 +165,7 @@ def _trend_requires_at_least_one_score(self) -> Self: class AgentHealthResponse(BaseModel): """Composite health snapshot for a single agent.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr agent_name: NotBlankStr diff --git a/src/synthorg/api/controllers/analytics.py b/src/synthorg/api/controllers/analytics.py index 9bc423c12d..23cc4a674a 100644 --- a/src/synthorg/api/controllers/analytics.py +++ b/src/synthorg/api/controllers/analytics.py @@ -70,7 +70,7 @@ class OverviewMetrics(BaseModel): currency: ISO 4217 currency code. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") total_tasks: int = Field(ge=0, description="Total number of tasks") tasks_by_status: dict[str, int] = Field( @@ -118,7 +118,7 @@ class TrendsResponse(BaseModel): data_points: Bucketed time-series data. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") period: TrendPeriod = Field(description="Lookback period") metric: TrendMetric = Field(description="Metric type queried") @@ -140,7 +140,7 @@ class ForecastResponse(BaseModel): avg_daily_spend: Average daily spend used for projection. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") horizon_days: int = Field(ge=1, description="Projection horizon") projected_total: float = Field( diff --git a/src/synthorg/api/controllers/approvals.py b/src/synthorg/api/controllers/approvals.py index b8a728ed00..ab4be81e0c 100644 --- a/src/synthorg/api/controllers/approvals.py +++ b/src/synthorg/api/controllers/approvals.py @@ -183,7 +183,7 @@ class ApprovalResponse(ApprovalItem): urgency_level: Urgency classification based on time remaining. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") seconds_remaining: float | None = Field( ge=0.0, diff --git a/src/synthorg/api/controllers/autonomy.py b/src/synthorg/api/controllers/autonomy.py index b59ab3e688..b38196876f 100644 --- a/src/synthorg/api/controllers/autonomy.py +++ b/src/synthorg/api/controllers/autonomy.py @@ -41,7 +41,7 @@ class AutonomyLevelResponse(BaseModel): promotion_pending: Whether a promotion request is pending. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent identifier") level: AutonomyLevel = Field(description="Current autonomy level") diff --git a/src/synthorg/api/controllers/budget.py b/src/synthorg/api/controllers/budget.py index f8ddadc9b9..0931bf6cfc 100644 --- a/src/synthorg/api/controllers/budget.py +++ b/src/synthorg/api/controllers/budget.py @@ -42,7 +42,7 @@ class AgentSpending(BaseModel): currency: ISO 4217 currency code. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent identifier") total_cost: float = Field( @@ -69,7 +69,7 @@ class DailySummary(BaseModel): currency: ISO 4217 currency code. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") date: NotBlankStr = Field(description="ISO date (YYYY-MM-DD)") total_cost: float = Field( diff --git a/src/synthorg/api/controllers/ceremony_policy.py b/src/synthorg/api/controllers/ceremony_policy.py index a637b5762c..38d7fe5241 100644 --- a/src/synthorg/api/controllers/ceremony_policy.py +++ b/src/synthorg/api/controllers/ceremony_policy.py @@ -56,7 +56,7 @@ class ResolvedPolicyField(BaseModel): source: Which level provided this value. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") value: str | dict[str, Any] | bool | float = Field( description="Resolved field value", @@ -77,7 +77,7 @@ class ResolvedCeremonyPolicyResponse(BaseModel): transition_threshold: Resolved transition threshold with origin. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: ResolvedPolicyField = Field( description="Ceremony scheduling strategy", @@ -104,7 +104,7 @@ class ActiveCeremonyStrategyResponse(BaseModel): sprint_id: ID of the active sprint, or None. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: CeremonyStrategyType | None = Field( default=None, diff --git a/src/synthorg/api/controllers/clients.py b/src/synthorg/api/controllers/clients.py index 26c40ab31a..6c67cbce95 100644 --- a/src/synthorg/api/controllers/clients.py +++ b/src/synthorg/api/controllers/clients.py @@ -73,7 +73,7 @@ class UpdateClientRequest(BaseModel): class SatisfactionPoint(BaseModel): """A single satisfaction-history data point for a client.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") feedback_id: NotBlankStr = Field(description="Feedback identifier") task_id: NotBlankStr = Field(description="Reviewed task id") @@ -89,7 +89,7 @@ class SatisfactionPoint(BaseModel): class SatisfactionHistory(BaseModel): """Aggregated satisfaction response for a client.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") client_id: NotBlankStr = Field(description="Client identifier") total_reviews: int = Field( diff --git a/src/synthorg/api/controllers/collaboration.py b/src/synthorg/api/controllers/collaboration.py index 46b75893c4..414cdda340 100644 --- a/src/synthorg/api/controllers/collaboration.py +++ b/src/synthorg/api/controllers/collaboration.py @@ -73,7 +73,7 @@ class OverrideResponse(BaseModel): expires_at: When the override expires. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr score: float = Field(ge=0.0, le=10.0) diff --git a/src/synthorg/api/controllers/escalations.py b/src/synthorg/api/controllers/escalations.py index f2d3f5c8ed..78566e33d3 100644 --- a/src/synthorg/api/controllers/escalations.py +++ b/src/synthorg/api/controllers/escalations.py @@ -50,7 +50,7 @@ class EscalationResponse(BaseModel): """Escalation row enriched for the dashboard.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") escalation: Escalation conflict_id: NotBlankStr diff --git a/src/synthorg/api/controllers/events.py b/src/synthorg/api/controllers/events.py index 4fd2e021ed..d98c4999f8 100644 --- a/src/synthorg/api/controllers/events.py +++ b/src/synthorg/api/controllers/events.py @@ -168,7 +168,7 @@ class ResumeInterruptRequest(BaseModel): class InterruptResponse(BaseModel): """Interrupt item returned by the polling API.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr type: InterruptType @@ -332,7 +332,7 @@ class _RevalidationVerdict(BaseModel): unavailable). ``None`` when the loop should keep running. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") consecutive_failures: int revoked_event: dict[str, str] | None = None diff --git a/src/synthorg/api/controllers/health.py b/src/synthorg/api/controllers/health.py index 6a568f6a03..6f42abf089 100644 --- a/src/synthorg/api/controllers/health.py +++ b/src/synthorg/api/controllers/health.py @@ -60,7 +60,7 @@ class LivenessStatus(BaseModel): uptime_seconds: Seconds since startup. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") status: Literal["ok"] = Field( default="ok", @@ -83,7 +83,7 @@ class ReadinessStatus(BaseModel): uptime_seconds: Seconds since startup. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") status: ReadinessOutcome = Field(description="Overall readiness outcome") persistence: bool | None = Field( diff --git a/src/synthorg/api/controllers/meetings.py b/src/synthorg/api/controllers/meetings.py index 17389b5ade..91a6a23237 100644 --- a/src/synthorg/api/controllers/meetings.py +++ b/src/synthorg/api/controllers/meetings.py @@ -174,7 +174,7 @@ class MeetingResponse(MeetingRecord): minutes are present, ``None`` otherwise). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") token_usage_by_participant: dict[str, int] = Field( default_factory=dict, diff --git a/src/synthorg/api/controllers/memory.py b/src/synthorg/api/controllers/memory.py index 6761e94726..b7c1deaf31 100644 --- a/src/synthorg/api/controllers/memory.py +++ b/src/synthorg/api/controllers/memory.py @@ -148,7 +148,7 @@ def _build_memory_service( class ActiveEmbedderResponse(BaseModel): """Active embedder configuration read from settings.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") provider: NotBlankStr | None = Field( default=None, diff --git a/src/synthorg/api/controllers/quality.py b/src/synthorg/api/controllers/quality.py index f6dbf3d441..9215a26c80 100644 --- a/src/synthorg/api/controllers/quality.py +++ b/src/synthorg/api/controllers/quality.py @@ -70,7 +70,7 @@ class QualityOverrideResponse(BaseModel): expires_at: When the override expires. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field( description="Agent whose quality score is overridden", diff --git a/src/synthorg/api/controllers/reports.py b/src/synthorg/api/controllers/reports.py index efb09c729b..19b57c1c18 100644 --- a/src/synthorg/api/controllers/reports.py +++ b/src/synthorg/api/controllers/reports.py @@ -58,7 +58,7 @@ class ReportResponse(BaseModel): generated_at: Generation timestamp (ISO 8601). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") period: ReportPeriod start: AwareDatetime diff --git a/src/synthorg/api/controllers/requests.py b/src/synthorg/api/controllers/requests.py index b803dc2694..cba9023b1f 100644 --- a/src/synthorg/api/controllers/requests.py +++ b/src/synthorg/api/controllers/requests.py @@ -28,7 +28,7 @@ class CreateRequestPayload(BaseModel): """Request payload for submitting a new client request.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") client_id: NotBlankStr = Field(description="Requesting client id") requirement: TaskRequirement = Field(description="Task requirement") @@ -37,7 +37,7 @@ class CreateRequestPayload(BaseModel): class RejectionPayload(BaseModel): """Payload carrying a rejection reason.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") reason: NotBlankStr = Field(description="Reason for rejection") @@ -45,7 +45,7 @@ class RejectionPayload(BaseModel): class ScopingPayload(BaseModel): """Payload carrying scoping notes and an optional refined requirement.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") notes: NotBlankStr = Field(description="Scoping notes from the reviewer") refined_title: NotBlankStr | None = Field(default=None) diff --git a/src/synthorg/api/controllers/reviews.py b/src/synthorg/api/controllers/reviews.py index 234d3c7220..c880604085 100644 --- a/src/synthorg/api/controllers/reviews.py +++ b/src/synthorg/api/controllers/reviews.py @@ -37,7 +37,7 @@ class StageDecisionPayload(BaseModel): """Human override for a single review stage.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") verdict: ReviewVerdict = Field(description="Overriding verdict") reason: NotBlankStr | None = Field( @@ -49,7 +49,7 @@ class StageDecisionPayload(BaseModel): class StageDecisionResult(BaseModel): """Response describing an applied stage decision.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_id: NotBlankStr stage_name: NotBlankStr diff --git a/src/synthorg/api/controllers/scaling.py b/src/synthorg/api/controllers/scaling.py index 71dea515dd..9328c7900c 100644 --- a/src/synthorg/api/controllers/scaling.py +++ b/src/synthorg/api/controllers/scaling.py @@ -37,7 +37,7 @@ class ScalingStrategyResponse(BaseModel): """Strategy summary for API responses.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Strategy identifier") enabled: bool = Field(description="Whether this strategy is active") @@ -47,7 +47,7 @@ class ScalingStrategyResponse(BaseModel): class ScalingSignalResponse(BaseModel): """Signal value for API responses.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Signal name") value: float = Field(description="Current value") @@ -62,7 +62,7 @@ class ScalingSignalResponse(BaseModel): class ScalingDecisionResponse(BaseModel): """Decision summary for API responses.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Decision identifier") action_type: NotBlankStr = Field(description="Action type") diff --git a/src/synthorg/api/controllers/settings.py b/src/synthorg/api/controllers/settings.py index 420573c73f..787ce71818 100644 --- a/src/synthorg/api/controllers/settings.py +++ b/src/synthorg/api/controllers/settings.py @@ -108,7 +108,7 @@ class TestSinkConfigResponse(BaseModel): error: Validation error message (None when valid). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") valid: bool error: NotBlankStr | None = None @@ -128,7 +128,7 @@ def _check_consistency(self) -> Self: class SecurityConfigExportResponse(BaseModel): """Exported security configuration with metadata.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") config: dict[str, Any] exported_at: AwareDatetime diff --git a/src/synthorg/api/controllers/setup_models.py b/src/synthorg/api/controllers/setup_models.py index 5461ddd98c..01e4624273 100644 --- a/src/synthorg/api/controllers/setup_models.py +++ b/src/synthorg/api/controllers/setup_models.py @@ -57,7 +57,7 @@ class SetupStatusResponse(BaseModel): min_password_length: Backend-configured minimum password length. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") needs_admin: bool needs_setup: bool @@ -79,7 +79,7 @@ class TemplateVariableResponse(BaseModel): required: Whether the user must supply a value. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr description: str = "" @@ -185,7 +185,7 @@ class SetupAgentSummary(BaseModel): personality_preset: Personality preset name, if any. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr role: NotBlankStr @@ -294,7 +294,7 @@ class SetupAgentResponse(BaseModel): model_id: Model identifier. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr role: NotBlankStr @@ -417,7 +417,7 @@ class SetupNameLocalesResponse(BaseModel): locales: Stored locale codes (``["__all__"]`` if worldwide). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") locales: list[NotBlankStr] @@ -430,7 +430,7 @@ class AvailableLocalesResponse(BaseModel): display_names: Mapping of locale code to human-readable name. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") regions: dict[str, list[str]] display_names: dict[str, str] @@ -443,6 +443,6 @@ class SetupCompleteResponse(BaseModel): setup_complete: Always True on success. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") setup_complete: Literal[True] diff --git a/src/synthorg/api/controllers/simulations.py b/src/synthorg/api/controllers/simulations.py index 93c6abd519..a00393da8d 100644 --- a/src/synthorg/api/controllers/simulations.py +++ b/src/synthorg/api/controllers/simulations.py @@ -41,7 +41,7 @@ class StartSimulationPayload(BaseModel): """Request payload for starting a new simulation run.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") config: SimulationConfig = Field(description="Simulation configuration") @@ -49,7 +49,7 @@ class StartSimulationPayload(BaseModel): class SimulationStatusResponse(BaseModel): """Public view of a simulation run.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") simulation_id: NotBlankStr status: NotBlankStr diff --git a/src/synthorg/api/controllers/teams.py b/src/synthorg/api/controllers/teams.py index 4fab1bdb14..e4239a1ef2 100644 --- a/src/synthorg/api/controllers/teams.py +++ b/src/synthorg/api/controllers/teams.py @@ -80,7 +80,7 @@ class ReorderTeamsRequest(BaseModel): class TeamResponse(BaseModel): """Response body for a single team.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr lead: NotBlankStr diff --git a/src/synthorg/api/controllers/users.py b/src/synthorg/api/controllers/users.py index 83e108a99e..4a0cf9554e 100644 --- a/src/synthorg/api/controllers/users.py +++ b/src/synthorg/api/controllers/users.py @@ -98,7 +98,7 @@ class GrantOrgRoleRequest(BaseModel): class UserResponse(BaseModel): """Public user representation (no password hash).""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr username: NotBlankStr diff --git a/src/synthorg/approval/models.py b/src/synthorg/approval/models.py index 813b87160a..e6d8d705e9 100644 --- a/src/synthorg/approval/models.py +++ b/src/synthorg/approval/models.py @@ -25,7 +25,7 @@ class EscalationInfo(BaseModel): reason: Human-readable explanation of why escalation is needed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") approval_id: NotBlankStr tool_call_id: NotBlankStr @@ -45,7 +45,7 @@ class ResumePayload(BaseModel): decision_reason: Optional reason for the decision. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") approval_id: NotBlankStr approved: bool diff --git a/src/synthorg/backup/config.py b/src/synthorg/backup/config.py index 0fc07ea815..2218d40d81 100644 --- a/src/synthorg/backup/config.py +++ b/src/synthorg/backup/config.py @@ -25,7 +25,7 @@ class RetentionConfig(BaseModel): max_age_days: Maximum age in days before pruning. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_count: int = Field(default=10, ge=1, le=1000) max_age_days: int = Field(default=30, ge=1, le=365) @@ -45,7 +45,7 @@ class BackupConfig(BaseModel): include: Components to include in backups. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = False path: NotBlankStr = Field( diff --git a/src/synthorg/backup/models.py b/src/synthorg/backup/models.py index b0abdfdf45..d2ac10fde5 100644 --- a/src/synthorg/backup/models.py +++ b/src/synthorg/backup/models.py @@ -51,7 +51,7 @@ class BackupManifest(BaseModel): backup_id: Unique identifier for this backup (12-char hex). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") synthorg_version: NotBlankStr timestamp: NotBlankStr @@ -94,7 +94,7 @@ class BackupInfo(BaseModel): compressed: Whether the backup is compressed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") backup_id: NotBlankStr timestamp: NotBlankStr @@ -136,7 +136,7 @@ class RestoreRequest(BaseModel): confirm: Safety gate -- must be ``True`` to proceed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") backup_id: NotBlankStr components: tuple[BackupComponent, ...] | None = None @@ -163,7 +163,7 @@ class RestoreResponse(BaseModel): restart_required: Whether the application must be restarted. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") manifest: BackupManifest restored_components: tuple[BackupComponent, ...] diff --git a/src/synthorg/client/config.py b/src/synthorg/client/config.py index 20ac761821..1c7943e8c3 100644 --- a/src/synthorg/client/config.py +++ b/src/synthorg/client/config.py @@ -24,7 +24,7 @@ class RequirementGeneratorConfig(BaseModel): llm_model: Model identifier (for llm/hybrid strategies). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: NotBlankStr = Field( default="template", @@ -58,7 +58,7 @@ class FeedbackConfig(BaseModel): strictness_multiplier: Multiplier applied to client strictness. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: NotBlankStr = Field( default="binary", @@ -89,7 +89,7 @@ class ClientPoolConfig(BaseModel): picks from the pool. Dispatched by ``build_client_pool_strategy``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") pool_size: int = Field( default=10, @@ -144,7 +144,7 @@ class SimulationRunnerConfig(BaseModel): review_timeout_sec: Timeout for client review. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_concurrent_tasks: int = Field( default=10, @@ -172,7 +172,7 @@ class ContinuousModeConfig(BaseModel): max_concurrent_requests: Maximum parallel requests. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -199,7 +199,7 @@ class ReportConfig(BaseModel): ``json_export``, or ``metrics_only``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: NotBlankStr = Field( default="summary", @@ -221,7 +221,7 @@ class ClientSimulationConfig(BaseModel): continuous: Continuous mode configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") pool: ClientPoolConfig = Field( default_factory=ClientPoolConfig, diff --git a/src/synthorg/client/human_queue.py b/src/synthorg/client/human_queue.py index 6dff8e8f73..c563d21f04 100644 --- a/src/synthorg/client/human_queue.py +++ b/src/synthorg/client/human_queue.py @@ -29,7 +29,7 @@ class PendingRequirement(BaseModel): """A requirement request awaiting human response.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") ticket_id: NotBlankStr = Field(description="Queue ticket id") client_id: NotBlankStr = Field(description="Requesting client id") @@ -43,7 +43,7 @@ class PendingRequirement(BaseModel): class PendingReview(BaseModel): """A review request awaiting human response.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") ticket_id: NotBlankStr = Field(description="Queue ticket id") client_id: NotBlankStr = Field(description="Requesting client id") diff --git a/src/synthorg/client/models.py b/src/synthorg/client/models.py index 04d1e97a35..9c00706b2d 100644 --- a/src/synthorg/client/models.py +++ b/src/synthorg/client/models.py @@ -97,7 +97,7 @@ class ClientProfile(BaseModel): (0.0 = lenient, 1.0 = very strict). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") client_id: NotBlankStr = Field(description="Unique client identifier") name: NotBlankStr = Field(description="Human-readable client name") @@ -128,7 +128,7 @@ class TaskRequirement(BaseModel): acceptance_criteria: Criteria for task acceptance. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") title: NotBlankStr = Field(description="Short requirement title") description: NotBlankStr = Field( @@ -162,7 +162,7 @@ class GenerationContext(BaseModel): count: Number of requirements to generate. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") project_id: NotBlankStr = Field( description="Project to generate requirements for", @@ -200,7 +200,7 @@ class ReviewContext(BaseModel): prior_feedback: Previous feedback on this task (for rework). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_id: NotBlankStr = Field( description="ID of the task being reviewed", @@ -235,7 +235,7 @@ class ClientFeedback(BaseModel): created_at: Timestamp of feedback creation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") feedback_id: NotBlankStr = Field( default_factory=lambda: str(uuid4()), @@ -296,7 +296,7 @@ class ClientRequest(BaseModel): metadata: Additional request metadata. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") request_id: NotBlankStr = Field( default_factory=lambda: str(uuid4()), @@ -364,7 +364,7 @@ class PoolConstraints(BaseModel): max_clients: Maximum number of clients to select. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") min_strictness: float = Field( default=0.0, @@ -411,7 +411,7 @@ class SimulationConfig(BaseModel): requirements_per_client: Requirements each client generates. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") simulation_id: NotBlankStr = Field( default_factory=lambda: str(uuid4()), diff --git a/src/synthorg/client/store.py b/src/synthorg/client/store.py index 003232a241..aa0d1f6373 100644 --- a/src/synthorg/client/store.py +++ b/src/synthorg/client/store.py @@ -149,7 +149,7 @@ class SimulationRecord(BaseModel): and rebinds it in the store -- never mutates in place. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") simulation_id: NotBlankStr = Field(description="Run identifier") config: SimulationConfig = Field(description="Run configuration") diff --git a/src/synthorg/communication/async_tasks/models.py b/src/synthorg/communication/async_tasks/models.py index 0109d236dd..cf72bf6015 100644 --- a/src/synthorg/communication/async_tasks/models.py +++ b/src/synthorg/communication/async_tasks/models.py @@ -34,7 +34,7 @@ class AsyncTaskRecord(BaseModel): updated_at: When the status was last updated. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_id: NotBlankStr = Field(description="Task identifier") agent_name: NotBlankStr = Field(description="Executing agent name") @@ -71,7 +71,7 @@ class TaskSpec(BaseModel): metadata: Additional key-value metadata. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") title: NotBlankStr = Field(description="Task title") description: NotBlankStr = Field(description="Task description") @@ -99,7 +99,7 @@ class AsyncTaskStateChannel(BaseModel): records: Ordered tuple of tracked task records. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") records: tuple[AsyncTaskRecord, ...] = Field( default=(), diff --git a/src/synthorg/communication/channel.py b/src/synthorg/communication/channel.py index 55cfa0d91c..07cf2f5d23 100644 --- a/src/synthorg/communication/channel.py +++ b/src/synthorg/communication/channel.py @@ -20,7 +20,7 @@ class Channel(BaseModel): subscribers: Agent IDs subscribed to this channel. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Channel name") type: ChannelType = Field( diff --git a/src/synthorg/communication/citation/manager.py b/src/synthorg/communication/citation/manager.py index 8cd0d9f1ad..e6591ee6fc 100644 --- a/src/synthorg/communication/citation/manager.py +++ b/src/synthorg/communication/citation/manager.py @@ -36,7 +36,7 @@ class CitationManager(BaseModel): url_to_number: Mapping from normalized URL to citation number. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") citations: tuple[Citation, ...] = Field( default=(), diff --git a/src/synthorg/communication/citation/models.py b/src/synthorg/communication/citation/models.py index 5e16876ff1..ef585d8633 100644 --- a/src/synthorg/communication/citation/models.py +++ b/src/synthorg/communication/citation/models.py @@ -25,7 +25,7 @@ class Citation(BaseModel): accessed_via: How the source was accessed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") number: int = Field(ge=1, description="Stable citation number") url: AnyHttpUrl = Field(description="Canonical normalized URL") diff --git a/src/synthorg/communication/config.py b/src/synthorg/communication/config.py index 205fcdf4b3..d66baa45c4 100644 --- a/src/synthorg/communication/config.py +++ b/src/synthorg/communication/config.py @@ -77,7 +77,7 @@ class MessageRetentionConfig(BaseModel): exhausting memory at queue creation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_messages_per_channel: int = Field( default=1000, @@ -120,7 +120,7 @@ class NatsConfig(BaseModel): publish ack before considering the publish failed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") # Default points at a local-loopback NATS dev server. In-container # deployments override via ``SYNTHORG_NATS_URL`` (read by @@ -229,7 +229,7 @@ class MessageBusConfig(BaseModel): ``backend == NATS``, ignored otherwise). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") backend: MessageBusBackend = Field( default=MessageBusBackend.INTERNAL, @@ -277,7 +277,7 @@ class MeetingTypeConfig(BaseModel): duration_tokens: Token budget for the meeting. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Meeting type name") frequency: MeetingFrequency | None = Field( @@ -338,7 +338,7 @@ class MeetingsConfig(BaseModel): types: Configured meeting types (unique by name). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field(default=True, description="Meetings subsystem active") types: tuple[MeetingTypeConfig, ...] = Field( @@ -367,7 +367,7 @@ class HierarchyConfig(BaseModel): allow_skip_level: Whether skip-level messaging is allowed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enforce_chain_of_command: bool = Field( default=True, @@ -389,7 +389,7 @@ class RateLimitConfig(BaseModel): burst_allowance: Extra burst capacity above the rate limit. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_per_pair_per_minute: int = Field( default=10, @@ -413,7 +413,7 @@ class CircuitBreakerConfig(BaseModel): cooldown_seconds: Seconds to wait before retrying after trip. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") bounce_threshold: int = Field( default=3, @@ -454,7 +454,7 @@ class LoopPreventionConfig(BaseModel): ancestry_tracking: Must always be ``True``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_delegation_depth: int = Field( default=5, @@ -493,7 +493,7 @@ class CommunicationConfig(BaseModel): loop_prevention: Loop prevention safeguards. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") default_pattern: CommunicationPattern = Field( default=CommunicationPattern.HYBRID, diff --git a/src/synthorg/communication/conflict_resolution/config.py b/src/synthorg/communication/conflict_resolution/config.py index b85f5ebbfc..9c57f28301 100644 --- a/src/synthorg/communication/conflict_resolution/config.py +++ b/src/synthorg/communication/conflict_resolution/config.py @@ -17,7 +17,7 @@ class DebateConfig(BaseModel): manager), ``"ceo"`` (hierarchy root), or a named agent. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") judge: NotBlankStr = Field( default="shared_manager", @@ -34,7 +34,7 @@ class HybridConfig(BaseModel): when the review result is ambiguous. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") review_agent: NotBlankStr = Field( default="conflict_reviewer", @@ -56,7 +56,7 @@ class ConflictResolutionConfig(BaseModel): escalation: Configuration for the human escalation queue. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: ConflictResolutionStrategy = Field( default=ConflictResolutionStrategy.AUTHORITY, diff --git a/src/synthorg/communication/conflict_resolution/escalation/config.py b/src/synthorg/communication/conflict_resolution/escalation/config.py index c7c41b720b..8a35dbb41f 100644 --- a/src/synthorg/communication/conflict_resolution/escalation/config.py +++ b/src/synthorg/communication/conflict_resolution/escalation/config.py @@ -59,7 +59,7 @@ class EscalationQueueConfig(BaseModel): notify_channel: Postgres LISTEN/NOTIFY channel name. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") backend: Literal["memory", "sqlite", "postgres"] = "memory" decision_strategy: Literal["winner", "hybrid"] = "winner" diff --git a/src/synthorg/communication/conflict_resolution/escalation/models.py b/src/synthorg/communication/conflict_resolution/escalation/models.py index 4a1be703fc..681c82d3e3 100644 --- a/src/synthorg/communication/conflict_resolution/escalation/models.py +++ b/src/synthorg/communication/conflict_resolution/escalation/models.py @@ -42,7 +42,7 @@ class WinnerDecision(BaseModel): :class:`ConflictResolution.reasoning` field. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["winner"] = "winner" winning_agent_id: NotBlankStr @@ -57,7 +57,7 @@ class RejectDecision(BaseModel): reasoning: Operator's explanation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["reject"] = "reject" reasoning: NotBlankStr = Field(max_length=4096) @@ -95,7 +95,7 @@ class Escalation(BaseModel): decision: The decision payload (``None`` while pending). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr conflict: Conflict diff --git a/src/synthorg/communication/conflict_resolution/models.py b/src/synthorg/communication/conflict_resolution/models.py index 6126508a0e..0abdd80042 100644 --- a/src/synthorg/communication/conflict_resolution/models.py +++ b/src/synthorg/communication/conflict_resolution/models.py @@ -61,7 +61,7 @@ class ConflictPosition(BaseModel): timestamp: When the position was stated. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent taking the position") agent_department: NotBlankStr = Field(description="Agent's department") @@ -131,7 +131,7 @@ class ConflictResolution(BaseModel): resolved_at: When the resolution was produced. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") conflict_id: NotBlankStr = Field(description="Resolved conflict ID") outcome: ConflictResolutionOutcome = Field(description="Resolution outcome") @@ -192,7 +192,7 @@ class DissentRecord(BaseModel): metadata: Extra key-value metadata pairs. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique dissent record ID") conflict: Conflict = Field(description="Original conflict") @@ -265,7 +265,7 @@ class DissentPayload(BaseModel): strategy_used: Resolution strategy that was applied. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") dissent_id: NotBlankStr = Field(description="Dissent record ID") conflict_id: NotBlankStr = Field(description="Originating conflict ID") diff --git a/src/synthorg/communication/delegation/authority.py b/src/synthorg/communication/delegation/authority.py index a82a77b2bc..f13b0863ce 100644 --- a/src/synthorg/communication/delegation/authority.py +++ b/src/synthorg/communication/delegation/authority.py @@ -26,7 +26,7 @@ class AuthorityCheckResult(BaseModel): reason: Explanation (empty on success). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") allowed: bool = Field(description="Whether delegation is allowed") reason: str = Field(default="", description="Explanation") diff --git a/src/synthorg/communication/delegation/entity_guard.py b/src/synthorg/communication/delegation/entity_guard.py index f3cfa6be0a..4ec4d0086e 100644 --- a/src/synthorg/communication/delegation/entity_guard.py +++ b/src/synthorg/communication/delegation/entity_guard.py @@ -39,7 +39,7 @@ class EntityGuardOutcome(BaseModel): entity_versions: Version manifest captured during check. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") passed: bool = Field(description="Whether the delegation is allowed") mechanism: NotBlankStr = Field( diff --git a/src/synthorg/communication/delegation/models.py b/src/synthorg/communication/delegation/models.py index 004962f801..416665693f 100644 --- a/src/synthorg/communication/delegation/models.py +++ b/src/synthorg/communication/delegation/models.py @@ -20,7 +20,7 @@ class DelegationRequest(BaseModel): constraints: Extra constraints for the delegatee. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") delegator_id: NotBlankStr = Field( description="Agent ID of the delegator", @@ -61,7 +61,7 @@ class DelegationResult(BaseModel): blocked_by: Mechanism name that blocked, if applicable. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") success: bool = Field(description="Whether delegation succeeded") delegated_task: Task | None = Field( @@ -115,7 +115,7 @@ class DelegationRecord(BaseModel): entity_versions: Entity version manifest at delegation time. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") delegation_id: NotBlankStr = Field( description="Unique delegation identifier", diff --git a/src/synthorg/communication/event_stream/interrupt.py b/src/synthorg/communication/event_stream/interrupt.py index f0d978c5de..01da2ae38e 100644 --- a/src/synthorg/communication/event_stream/interrupt.py +++ b/src/synthorg/communication/event_stream/interrupt.py @@ -75,7 +75,7 @@ class Interrupt(BaseModel): context_snippet: Context for the question (INFO_REQUEST). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique interrupt identifier") type: InterruptType = Field(description="Interrupt classification") @@ -146,7 +146,7 @@ class InterruptResolution(BaseModel): resolved_by: Who provided the resolution. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") interrupt_id: NotBlankStr = Field( description="Interrupt being resolved", diff --git a/src/synthorg/communication/event_stream/types.py b/src/synthorg/communication/event_stream/types.py index fa35af5f65..989cc81bc8 100644 --- a/src/synthorg/communication/event_stream/types.py +++ b/src/synthorg/communication/event_stream/types.py @@ -81,7 +81,7 @@ class StreamEvent(BaseModel): payload: Event-specific data (deep-copied at construction). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique event identifier") type: AgUiEventType = Field(description="AG-UI event type") diff --git a/src/synthorg/communication/loop_prevention/models.py b/src/synthorg/communication/loop_prevention/models.py index 5e6e73e4f5..3373d057d6 100644 --- a/src/synthorg/communication/loop_prevention/models.py +++ b/src/synthorg/communication/loop_prevention/models.py @@ -16,7 +16,7 @@ class GuardCheckOutcome(BaseModel): message: Human-readable detail (empty on success). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") passed: bool = Field(description="Whether the check passed") mechanism: NotBlankStr = Field( diff --git a/src/synthorg/communication/meeting/config.py b/src/synthorg/communication/meeting/config.py index 801d3252fa..ee1c8efdf2 100644 --- a/src/synthorg/communication/meeting/config.py +++ b/src/synthorg/communication/meeting/config.py @@ -17,7 +17,7 @@ class RoundRobinConfig(BaseModel): for the summary phase (0.0--1.0). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_turns_per_agent: int = Field( default=2, @@ -53,7 +53,7 @@ class PositionPapersConfig(BaseModel): for the synthesis phase (0.0--1.0). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_tokens_per_position: int = Field( default=300, @@ -84,7 +84,7 @@ class StructuredPhasesConfig(BaseModel): budget reserved for the synthesis phase (0.0--1.0). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") skip_discussion_if_no_conflicts: bool = Field( default=True, @@ -120,7 +120,7 @@ class MeetingProtocolConfig(BaseModel): structured_phases: Structured-phases protocol settings. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") protocol: MeetingProtocolType = Field( default=MeetingProtocolType.ROUND_ROBIN, diff --git a/src/synthorg/communication/meeting/models.py b/src/synthorg/communication/meeting/models.py index b2ebb6d97a..bf43a42124 100644 --- a/src/synthorg/communication/meeting/models.py +++ b/src/synthorg/communication/meeting/models.py @@ -32,7 +32,7 @@ class AgentResponse(BaseModel): cost: Estimated cost of the invocation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent that responded") content: str = Field(description="Response content") @@ -62,7 +62,7 @@ class MeetingAgendaItem(BaseModel): presenter_id: Agent who presents this item (optional). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") title: NotBlankStr = Field(description="Agenda topic title") description: str = Field( @@ -84,7 +84,7 @@ class MeetingAgenda(BaseModel): items: Ordered agenda items. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") title: NotBlankStr = Field(description="Meeting title") context: str = Field( @@ -110,7 +110,7 @@ class MeetingContribution(BaseModel): timestamp: When the contribution was recorded. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Contributing agent") content: str = Field(description="Contribution content") @@ -138,7 +138,7 @@ class ActionItem(BaseModel): priority: Urgency of the action item. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") description: NotBlankStr = Field(description="What needs to be done") assignee_id: NotBlankStr | None = Field( @@ -283,7 +283,7 @@ class MeetingRecord(BaseModel): token_budget: Token budget that was allocated. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") meeting_id: NotBlankStr = Field(description="Unique meeting ID") meeting_type_name: NotBlankStr = Field( diff --git a/src/synthorg/communication/message.py b/src/synthorg/communication/message.py index 2567632cfa..19d674c08c 100644 --- a/src/synthorg/communication/message.py +++ b/src/synthorg/communication/message.py @@ -41,7 +41,7 @@ class TextPart(BaseModel): text: The text content (must not be blank). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["text"] = Field( default="text", @@ -139,7 +139,7 @@ class FilePart(BaseModel): mime_type: Optional MIME type of the file. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["file"] = Field( default="file", @@ -160,7 +160,7 @@ class UriPart(BaseModel): uri: The URI or URL. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["uri"] = Field( default="uri", @@ -197,7 +197,7 @@ class MessageMetadata(BaseModel): extra: Immutable key-value pairs for arbitrary metadata (extension). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_id: NotBlankStr | None = Field( default=None, diff --git a/src/synthorg/communication/subscription.py b/src/synthorg/communication/subscription.py index ccdabbca97..cde443ac13 100644 --- a/src/synthorg/communication/subscription.py +++ b/src/synthorg/communication/subscription.py @@ -15,7 +15,7 @@ class Subscription(BaseModel): subscribed_at: When the subscription was created. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") channel_name: NotBlankStr = Field(description="Channel name") subscriber_id: NotBlankStr = Field(description="Subscriber agent ID") @@ -34,7 +34,7 @@ class DeliveryEnvelope(BaseModel): delivered_at: When the message was delivered to this subscriber. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") message: Message = Field(description="The delivered message") channel_name: NotBlankStr = Field(description="Delivery channel") diff --git a/src/synthorg/config/provider_schema.py b/src/synthorg/config/provider_schema.py index 050c0b6e06..59af164688 100644 --- a/src/synthorg/config/provider_schema.py +++ b/src/synthorg/config/provider_schema.py @@ -26,7 +26,7 @@ class LocalModelParams(BaseModel): """Per-model launch parameters for local providers.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") num_ctx: int | None = Field(default=None, gt=0) num_gpu_layers: int | None = Field(default=None, ge=0) @@ -42,7 +42,7 @@ class LocalModelParams(BaseModel): class ProviderModelConfig(BaseModel): """Configuration for a single LLM model within a provider.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Model identifier") alias: NotBlankStr | None = Field( @@ -79,7 +79,7 @@ class ProviderModelConfig(BaseModel): class ProviderConfig(BaseModel): """Configuration for an LLM provider.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") driver: NotBlankStr = Field( default="litellm", diff --git a/src/synthorg/config/schema.py b/src/synthorg/config/schema.py index 41c6c896a2..841d6f72e3 100644 --- a/src/synthorg/config/schema.py +++ b/src/synthorg/config/schema.py @@ -88,7 +88,7 @@ class RoutingRuleConfig(BaseModel): fallback: Fallback model alias or ID. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") role_level: SeniorityLevel | None = Field( default=None, @@ -132,7 +132,7 @@ class RoutingConfig(BaseModel): fallback_chain: Ordered fallback model aliases or IDs. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: NotBlankStr = Field( default="cost_aware", @@ -174,7 +174,7 @@ class AgentConfig(BaseModel): company strategy config default. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Agent display name") role: NotBlankStr = Field(description="Role name") @@ -230,7 +230,7 @@ class GracefulShutdownConfig(BaseModel): ``"finish_tool"`` strategy (seconds). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: Literal[ "cooperative_timeout", "immediate", "finish_tool", "checkpoint" @@ -269,7 +269,7 @@ class TaskAssignmentConfig(BaseModel): that filter out agents at capacity. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") # Known strategy names -- must stay in sync with # ``STRATEGY_NAME_*`` constants in ``engine.assignment.strategies``. @@ -387,7 +387,7 @@ class RootConfig(BaseModel): (``None`` = disabled). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") company_name: NotBlankStr = Field( description="Company name", diff --git a/src/synthorg/engine/agent_state.py b/src/synthorg/engine/agent_state.py index dc6738dbde..da87ca7867 100644 --- a/src/synthorg/engine/agent_state.py +++ b/src/synthorg/engine/agent_state.py @@ -37,7 +37,7 @@ class AgentRuntimeState(BaseModel): started_at: When the current execution started (``None`` when idle). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent identifier (primary key)") execution_id: NotBlankStr | None = Field( diff --git a/src/synthorg/engine/assignment/models.py b/src/synthorg/engine/assignment/models.py index a471d323c0..d9fcf754e5 100644 --- a/src/synthorg/engine/assignment/models.py +++ b/src/synthorg/engine/assignment/models.py @@ -23,7 +23,7 @@ class AgentWorkload(BaseModel): total_cost: Total cost incurred by this agent in the configured currency. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent identifier") active_task_count: int = Field( @@ -47,7 +47,7 @@ class AssignmentCandidate(BaseModel): reason: Human-readable explanation of the score. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_identity: AgentIdentity = Field(description="Candidate agent") score: float = Field( @@ -84,7 +84,7 @@ class AssignmentRequest(BaseModel): ``TaskAssignmentConfig.max_concurrent_tasks_per_agent``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task: Task = Field(description="The task to assign") available_agents: tuple[AgentIdentity, ...] = Field( @@ -158,7 +158,7 @@ class AssignmentResult(BaseModel): reason: Human-readable explanation of the assignment decision. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_id: NotBlankStr = Field(description="Task identifier") strategy_used: NotBlankStr = Field( diff --git a/src/synthorg/engine/checkpoint/models.py b/src/synthorg/engine/checkpoint/models.py index ca2cfe955e..3a1171f6c5 100644 --- a/src/synthorg/engine/checkpoint/models.py +++ b/src/synthorg/engine/checkpoint/models.py @@ -35,7 +35,7 @@ class Checkpoint(BaseModel): created_at: Timestamp when the checkpoint was created. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: str(uuid4()), @@ -75,7 +75,7 @@ class Heartbeat(BaseModel): last_heartbeat_at: Timestamp of the last heartbeat update. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") execution_id: NotBlankStr = Field(description="Execution run identifier") agent_id: NotBlankStr = Field(description="Agent identifier") @@ -97,7 +97,7 @@ class CheckpointConfig(BaseModel): falling back to fail-and-reassign. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") persist_every_n_turns: int = Field( default=1, diff --git a/src/synthorg/engine/classification/models.py b/src/synthorg/engine/classification/models.py index 87a53d5670..75a4c7fc73 100644 --- a/src/synthorg/engine/classification/models.py +++ b/src/synthorg/engine/classification/models.py @@ -44,7 +44,7 @@ class ErrorFinding(BaseModel): is the index into the turns tuple. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") category: ErrorCategory = Field(description="Error taxonomy category") severity: ErrorSeverity = Field(description="Severity level") diff --git a/src/synthorg/engine/classification/protocol.py b/src/synthorg/engine/classification/protocol.py index 2d6918c5ba..dd911c957e 100644 --- a/src/synthorg/engine/classification/protocol.py +++ b/src/synthorg/engine/classification/protocol.py @@ -45,7 +45,7 @@ class DetectionContext(BaseModel): (TASK_TREE scope only). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") execution_result: ExecutionResult = Field( description="Completed execution to analyse", diff --git a/src/synthorg/engine/compaction/models.py b/src/synthorg/engine/compaction/models.py index 9c44f9c35f..6e0147c0f6 100644 --- a/src/synthorg/engine/compaction/models.py +++ b/src/synthorg/engine/compaction/models.py @@ -42,7 +42,7 @@ class CompactionConfig(BaseModel): markers (hedging, reconsideration, etc.) in summaries. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") fill_threshold_percent: float = Field( default=80.0, @@ -109,7 +109,7 @@ class CompressionMetadata(BaseModel): compactions_performed: Total number of compactions so far. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") compression_point: int = Field( ge=0, diff --git a/src/synthorg/engine/context.py b/src/synthorg/engine/context.py index 828096d602..9a0c7d345f 100644 --- a/src/synthorg/engine/context.py +++ b/src/synthorg/engine/context.py @@ -64,7 +64,7 @@ class AgentContextSnapshot(BaseModel): message_count: Number of messages in the conversation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") execution_id: NotBlankStr = Field(description="Unique execution identifier") agent_id: NotBlankStr = Field(description="Agent identifier") diff --git a/src/synthorg/engine/coordination/attribution.py b/src/synthorg/engine/coordination/attribution.py index a1088cd4b9..0e637999e6 100644 --- a/src/synthorg/engine/coordination/attribution.py +++ b/src/synthorg/engine/coordination/attribution.py @@ -86,7 +86,7 @@ class AgentContribution(BaseModel): (``None`` when the agent succeeded). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Contributing agent") subtask_id: NotBlankStr = Field(description="Subtask executed") diff --git a/src/synthorg/engine/coordination/dispatcher_types.py b/src/synthorg/engine/coordination/dispatcher_types.py index c7dbbd138e..f942ec76b0 100644 --- a/src/synthorg/engine/coordination/dispatcher_types.py +++ b/src/synthorg/engine/coordination/dispatcher_types.py @@ -36,7 +36,7 @@ class DispatchResult(BaseModel): phases: Phase results generated during dispatch. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") waves: tuple[CoordinationWave, ...] = Field( default=(), diff --git a/src/synthorg/engine/coordination/models.py b/src/synthorg/engine/coordination/models.py index 74265a013e..6a8d714665 100644 --- a/src/synthorg/engine/coordination/models.py +++ b/src/synthorg/engine/coordination/models.py @@ -37,7 +37,7 @@ class CoordinationContext(BaseModel): config: Coordination configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task: Task = Field(description="Parent task to coordinate") available_agents: tuple[AgentIdentity, ...] = Field( @@ -71,7 +71,7 @@ class CoordinationPhaseResult(BaseModel): error: Error description if the phase failed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") phase: NotBlankStr = Field(description="Phase name") success: bool = Field(description="Whether phase succeeded") @@ -114,7 +114,7 @@ class CoordinationWave(BaseModel): execution_result: Result from ParallelExecutor, if executed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") wave_index: int = Field(ge=0, description="Zero-based wave index") subtask_ids: tuple[NotBlankStr, ...] = Field( diff --git a/src/synthorg/engine/decomposition/llm.py b/src/synthorg/engine/decomposition/llm.py index 50f8850250..921a444200 100644 --- a/src/synthorg/engine/decomposition/llm.py +++ b/src/synthorg/engine/decomposition/llm.py @@ -64,7 +64,7 @@ class LlmDecompositionConfig(BaseModel): max_output_tokens: Maximum tokens for the LLM response. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_retries: int = Field(default=2, ge=0, le=5, description="Max retry attempts") temperature: float = Field( diff --git a/src/synthorg/engine/decomposition/models.py b/src/synthorg/engine/decomposition/models.py index 8abdc82bb1..6961206599 100644 --- a/src/synthorg/engine/decomposition/models.py +++ b/src/synthorg/engine/decomposition/models.py @@ -36,7 +36,7 @@ class SubtaskDefinition(BaseModel): required_role: Optional role name for routing. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique subtask identifier") title: NotBlankStr = Field(description="Short subtask title") @@ -86,7 +86,7 @@ class DecompositionPlan(BaseModel): coordination_topology: Selected coordination topology. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") parent_task_id: NotBlankStr = Field( description="ID of the task being decomposed", @@ -139,7 +139,7 @@ class DecompositionResult(BaseModel): dependency_edges: Directed edges (from_id, to_id) in the DAG. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") plan: DecompositionPlan = Field(description="Executed decomposition plan") created_tasks: tuple[Task, ...] = Field( @@ -282,7 +282,7 @@ class DecompositionContext(BaseModel): current_depth: Current nesting depth. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_subtasks: int = Field( default=10, diff --git a/src/synthorg/engine/evolution/config.py b/src/synthorg/engine/evolution/config.py index 01e9e1d96d..49458c89e5 100644 --- a/src/synthorg/engine/evolution/config.py +++ b/src/synthorg/engine/evolution/config.py @@ -34,7 +34,7 @@ class TriggerConfig(BaseModel): per_task_min_tasks: Min tasks between per-task triggers. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") types: tuple[Literal["batched", "inflection", "per_task"], ...] = ( "batched", @@ -60,7 +60,7 @@ class ProposerConfig(BaseModel): max_tokens: Token budget for proposer response. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["separate_analyzer", "self_report", "composite"] = "composite" model: NotBlankStr = Field( @@ -80,7 +80,7 @@ class AdapterConfig(BaseModel): prompt_template: Enable prompt injection of learned memories. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") identity: bool = False strategy_selection: bool = True @@ -114,7 +114,7 @@ class ShadowEvaluationConfig(BaseModel): real-guard verdicts in dashboards. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_provider: Literal["configured", "recent_history"] = Field( default="configured", @@ -183,7 +183,7 @@ class GuardConfig(BaseModel): the guard; set a ``ShadowEvaluationConfig`` to enable. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") review_gate: bool = True rollback: bool = True @@ -206,7 +206,7 @@ class MemoryEvolutionConfig(BaseModel): propagation: Cross-agent propagation strategy config. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") capture: CaptureConfig = Field(default_factory=CaptureConfig) pruning: PruningConfig = Field(default_factory=PruningConfig) @@ -236,7 +236,7 @@ class EvolutionConfig(BaseModel): identity_store: Identity version store configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = True triggers: TriggerConfig = Field(default_factory=TriggerConfig) diff --git a/src/synthorg/engine/evolution/guards/shadow_protocol.py b/src/synthorg/engine/evolution/guards/shadow_protocol.py index 8b3111e82b..e4451851f7 100644 --- a/src/synthorg/engine/evolution/guards/shadow_protocol.py +++ b/src/synthorg/engine/evolution/guards/shadow_protocol.py @@ -40,7 +40,7 @@ class ShadowTaskOutcome(BaseModel): error: Short error message when ``success`` is False. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") success: bool = Field(description="Task completed successfully") quality_score: float | None = Field( diff --git a/src/synthorg/engine/evolution/models.py b/src/synthorg/engine/evolution/models.py index 73a28fd2be..ac12945b6d 100644 --- a/src/synthorg/engine/evolution/models.py +++ b/src/synthorg/engine/evolution/models.py @@ -55,7 +55,7 @@ class AdaptationProposal(BaseModel): proposed_at: When the proposal was generated. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: UUID = Field(default_factory=uuid4) agent_id: NotBlankStr @@ -80,7 +80,7 @@ class AdaptationDecision(BaseModel): decided_at: When the decision was made. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") proposal_id: UUID approved: bool @@ -105,7 +105,7 @@ class EvolutionEvent(BaseModel): event_at: When the event was recorded. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: UUID = Field(default_factory=uuid4) agent_id: NotBlankStr diff --git a/src/synthorg/engine/evolution/protocols.py b/src/synthorg/engine/evolution/protocols.py index d9613447f8..6399419150 100644 --- a/src/synthorg/engine/evolution/protocols.py +++ b/src/synthorg/engine/evolution/protocols.py @@ -45,7 +45,7 @@ class EvolutionContext(BaseModel): triggered_at: When the evolution was triggered. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr identity: AgentIdentity diff --git a/src/synthorg/engine/health/models.py b/src/synthorg/engine/health/models.py index 3eec5abf88..e6f8c3f617 100644 --- a/src/synthorg/engine/health/models.py +++ b/src/synthorg/engine/health/models.py @@ -56,7 +56,7 @@ class EscalationTicket(BaseModel): metadata: Arbitrary structured context. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: str(uuid4()), diff --git a/src/synthorg/engine/identity/diff.py b/src/synthorg/engine/identity/diff.py index b59b5f4f42..2d7b0089d9 100644 --- a/src/synthorg/engine/identity/diff.py +++ b/src/synthorg/engine/identity/diff.py @@ -34,7 +34,7 @@ class IdentityFieldChange(BaseModel): - ``"modified"``: both ``old_value`` and ``new_value`` must be non-``None``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") field_path: NotBlankStr change_type: ChangeType diff --git a/src/synthorg/engine/identity/store/config.py b/src/synthorg/engine/identity/store/config.py index cd55d98977..6aa111b370 100644 --- a/src/synthorg/engine/identity/store/config.py +++ b/src/synthorg/engine/identity/store/config.py @@ -14,7 +14,7 @@ class IdentityStoreConfig(BaseModel): per agent (None = unlimited). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["append_only", "copy_on_write"] = Field( default="append_only", diff --git a/src/synthorg/engine/intake/models.py b/src/synthorg/engine/intake/models.py index ab8fde3829..4c319ffd81 100644 --- a/src/synthorg/engine/intake/models.py +++ b/src/synthorg/engine/intake/models.py @@ -22,7 +22,7 @@ class IntakeResult(BaseModel): processed_at: Timestamp of processing completion. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") request_id: NotBlankStr = Field( description="ID of the processed request", diff --git a/src/synthorg/engine/middleware/coordination_protocol.py b/src/synthorg/engine/middleware/coordination_protocol.py index 524013ac4b..2b0f400a8e 100644 --- a/src/synthorg/engine/middleware/coordination_protocol.py +++ b/src/synthorg/engine/middleware/coordination_protocol.py @@ -50,7 +50,7 @@ class CoordinationMiddlewareContext(BaseModel): metadata: Middleware-to-middleware data pass-through. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") coordination_context: CoordinationContext = Field( description="Original coordination input", diff --git a/src/synthorg/engine/middleware/models.py b/src/synthorg/engine/middleware/models.py index 0d832b2d08..7d8554bfa5 100644 --- a/src/synthorg/engine/middleware/models.py +++ b/src/synthorg/engine/middleware/models.py @@ -49,7 +49,7 @@ class AgentMiddlewareContext(BaseModel): Keyed by middleware name to avoid collisions. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_context: AgentContext = Field( description="Mutable-via-copy runtime execution state", @@ -116,7 +116,7 @@ class ModelCallResult(BaseModel): error: Error description if the call failed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") response_text: str = Field( default="", @@ -147,7 +147,7 @@ class ToolCallResult(BaseModel): (loaded tools, resources, auto-unload) persist. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") tool_name: NotBlankStr = Field( description="Name of the invoked tool", @@ -223,7 +223,7 @@ class AssumptionViolationEvent(BaseModel): turn_number: Turn in which the violation was detected. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field( description="Agent that detected the violation", @@ -264,7 +264,7 @@ class TaskLedger(BaseModel): superseded_at: When this version was replaced (None if current). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") plan_text: NotBlankStr = Field( description="Serialized decomposition plan text", @@ -322,7 +322,7 @@ class ProgressLedger(BaseModel): next_action: Recommended action (continue, replan, escalate). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") round_number: int = Field( ge=1, diff --git a/src/synthorg/engine/middleware/semantic_drift.py b/src/synthorg/engine/middleware/semantic_drift.py index c5dc087a5e..e7521591cc 100644 --- a/src/synthorg/engine/middleware/semantic_drift.py +++ b/src/synthorg/engine/middleware/semantic_drift.py @@ -44,7 +44,7 @@ class SemanticDriftConfig(BaseModel): embedding_model: Optional model name for embeddings. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, diff --git a/src/synthorg/engine/parallel_models.py b/src/synthorg/engine/parallel_models.py index 3ad9f0e32c..570523d7e4 100644 --- a/src/synthorg/engine/parallel_models.py +++ b/src/synthorg/engine/parallel_models.py @@ -99,7 +99,7 @@ class ParallelExecutionGroup(BaseModel): fail_fast: Cancel remaining assignments on first failure. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") group_id: NotBlankStr = Field( description="Unique group identifier", diff --git a/src/synthorg/engine/plan_models.py b/src/synthorg/engine/plan_models.py index 295185b72e..8037544f5a 100644 --- a/src/synthorg/engine/plan_models.py +++ b/src/synthorg/engine/plan_models.py @@ -33,7 +33,7 @@ class PlanStep(BaseModel): actual_outcome: Observed result after execution (if any). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") step_number: int = Field(gt=0, description="1-indexed step number") description: NotBlankStr = Field(description="Step description") @@ -59,7 +59,7 @@ class ExecutionPlan(BaseModel): original_task_summary: Brief summary of the task being planned. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") steps: tuple[PlanStep, ...] = Field( min_length=1, diff --git a/src/synthorg/engine/policy_validation.py b/src/synthorg/engine/policy_validation.py index aacb769919..3103d7bdf7 100644 --- a/src/synthorg/engine/policy_validation.py +++ b/src/synthorg/engine/policy_validation.py @@ -78,7 +78,7 @@ class PolicyQualityIssue(BaseModel): reserved for future stricter checks. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") policy: str = Field(description="The policy text that triggered the issue") issue: str = Field( diff --git a/src/synthorg/engine/prompt.py b/src/synthorg/engine/prompt.py index 6015b72719..b779437f04 100644 --- a/src/synthorg/engine/prompt.py +++ b/src/synthorg/engine/prompt.py @@ -108,7 +108,7 @@ class SystemPrompt(BaseModel): trimmed to fit the profile's token budget. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") content: str = Field(description="Full rendered prompt text") template_version: str = Field( diff --git a/src/synthorg/engine/quality/models.py b/src/synthorg/engine/quality/models.py index e24a60953c..6cb03e9cf5 100644 --- a/src/synthorg/engine/quality/models.py +++ b/src/synthorg/engine/quality/models.py @@ -35,7 +35,7 @@ class StepQualitySignal(BaseModel): turn_range: Inclusive (start, end) turn numbers for this step. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") quality: StepQuality = Field(description="Ternary step classification") confidence: float = Field( diff --git a/src/synthorg/engine/quality/verification.py b/src/synthorg/engine/quality/verification.py index 001276271d..78425cd84c 100644 --- a/src/synthorg/engine/quality/verification.py +++ b/src/synthorg/engine/quality/verification.py @@ -51,7 +51,7 @@ class RubricCriterion(BaseModel): grade_type: Grading scale for this criterion. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Criterion identifier") description: NotBlankStr = Field(description="What is evaluated") @@ -69,7 +69,7 @@ class CalibrationExample(BaseModel): expected_grades: Optional per-criterion expected grades. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") artifact_summary: NotBlankStr = Field( description="Condensed artifact representation", @@ -121,7 +121,7 @@ class VerificationRubric(BaseModel): the verdict is overridden to REFER. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Rubric identifier") criteria: tuple[RubricCriterion, ...] = Field( @@ -193,7 +193,7 @@ class AtomicProbe(BaseModel): this probe was derived from. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Probe identifier") probe_text: NotBlankStr = Field(description="Binary yes/no question") diff --git a/src/synthorg/engine/quality/verification_config.py b/src/synthorg/engine/quality/verification_config.py index 054a83323e..742dc5e150 100644 --- a/src/synthorg/engine/quality/verification_config.py +++ b/src/synthorg/engine/quality/verification_config.py @@ -33,7 +33,7 @@ class VerificationConfig(BaseModel): min_confidence_override: Override rubric min_confidence. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") decomposer: DecomposerVariant = Field( default=DecomposerVariant.IDENTITY, diff --git a/src/synthorg/engine/review/models.py b/src/synthorg/engine/review/models.py index 97cc1ccf14..a828040de5 100644 --- a/src/synthorg/engine/review/models.py +++ b/src/synthorg/engine/review/models.py @@ -36,7 +36,7 @@ class ReviewStageResult(BaseModel): metadata: Additional stage-specific metadata. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") stage_name: NotBlankStr = Field( description="Identifier of the review stage", @@ -68,7 +68,7 @@ class PipelineResult(BaseModel): reviewed_at: Timestamp of pipeline completion. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_id: NotBlankStr = Field( description="ID of the reviewed task", diff --git a/src/synthorg/engine/routing/models.py b/src/synthorg/engine/routing/models.py index 314505bcdb..01b2573010 100644 --- a/src/synthorg/engine/routing/models.py +++ b/src/synthorg/engine/routing/models.py @@ -30,7 +30,7 @@ class RoutingCandidate(BaseModel): reason: Human-readable explanation of the score. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_identity: AgentIdentity = Field(description="Candidate agent") score: float = Field( @@ -55,7 +55,7 @@ class RoutingDecision(BaseModel): topology: Coordination topology for this subtask. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") subtask_id: NotBlankStr = Field(description="Subtask being routed") selected_candidate: RoutingCandidate = Field( @@ -97,7 +97,7 @@ class RoutingResult(BaseModel): unroutable: IDs of subtasks with no matching agent. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") parent_task_id: NotBlankStr = Field(description="Parent task ID") decisions: tuple[RoutingDecision, ...] = Field( @@ -168,7 +168,7 @@ class AutoTopologyConfig(BaseModel): parallel tasks use decentralized topology. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") sequential_override: CoordinationTopology = Field( default=CoordinationTopology.SAS, diff --git a/src/synthorg/engine/session.py b/src/synthorg/engine/session.py index 605d7d4947..e9fa0acf26 100644 --- a/src/synthorg/engine/session.py +++ b/src/synthorg/engine/session.py @@ -65,7 +65,7 @@ class SessionEvent(BaseModel): data: Structured event payload (deep-copied at construction). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") event_name: NotBlankStr = Field(description="Dotted event constant") timestamp: AwareDatetime = Field(description="Event timestamp") @@ -95,7 +95,7 @@ class ReplayResult(BaseModel): events_total: Total events found for this execution. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") context: AgentContext = Field( description="Reconstructed agent context", diff --git a/src/synthorg/engine/shutdown.py b/src/synthorg/engine/shutdown.py index 310666c681..6538df6996 100644 --- a/src/synthorg/engine/shutdown.py +++ b/src/synthorg/engine/shutdown.py @@ -61,7 +61,7 @@ class ShutdownResult(BaseModel): duration_seconds: Wall-clock duration of the entire shutdown. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy_type: NotBlankStr = Field( description="Name of the strategy that executed the shutdown", diff --git a/src/synthorg/engine/stagnation/models.py b/src/synthorg/engine/stagnation/models.py index e815e64bdd..8cbb10111c 100644 --- a/src/synthorg/engine/stagnation/models.py +++ b/src/synthorg/engine/stagnation/models.py @@ -51,7 +51,7 @@ class StagnationConfig(BaseModel): before any check fires. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=True, @@ -109,7 +109,7 @@ class StagnationResult(BaseModel): details: Forward-compatible metadata dict. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") verdict: StagnationVerdict = Field( description="What action to take", diff --git a/src/synthorg/engine/strategy/consensus.py b/src/synthorg/engine/strategy/consensus.py index f5cb451822..d918a5856f 100644 --- a/src/synthorg/engine/strategy/consensus.py +++ b/src/synthorg/engine/strategy/consensus.py @@ -36,7 +36,7 @@ class ConsensusVelocityResult(BaseModel): disagreement_count: Number of substantially different position pairs. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") detected: bool = Field(description="Whether premature consensus was detected") action: ConsensusAction | None = Field( diff --git a/src/synthorg/engine/strategy/lenses.py b/src/synthorg/engine/strategy/lenses.py index 4687d6b3e0..72d66727e7 100644 --- a/src/synthorg/engine/strategy/lenses.py +++ b/src/synthorg/engine/strategy/lenses.py @@ -57,7 +57,7 @@ class LensDefinition(BaseModel): is_default: Whether this lens is active by default. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Human-readable lens name") description: NotBlankStr = Field(description="What this lens evaluates") diff --git a/src/synthorg/engine/strategy/models.py b/src/synthorg/engine/strategy/models.py index dfc58758d8..98a407075f 100644 --- a/src/synthorg/engine/strategy/models.py +++ b/src/synthorg/engine/strategy/models.py @@ -129,7 +129,7 @@ class ConfidenceConfig(BaseModel): format: Output format for confidence metadata. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") format: ConfidenceFormat = Field( default=ConfidenceFormat.STRUCTURED, @@ -148,7 +148,7 @@ class ConsensusVelocityConfig(BaseModel): threshold: Consensus velocity threshold (0.0-1.0). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") action: ConsensusAction = Field( default=ConsensusAction.DEVIL_ADVOCATE, @@ -169,7 +169,7 @@ class PremortemConfig(BaseModel): participants: Who participates in premortem analysis. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") participants: PremortemParticipation = Field( default=PremortemParticipation.ALL, @@ -184,7 +184,7 @@ class ConflictDetectionConfig(BaseModel): strategy: Detection strategy to use. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: ConflictDetectionStrategy = Field( default=ConflictDetectionStrategy.AUTO, @@ -205,7 +205,7 @@ class StrategicContextConfig(BaseModel): competitive_position: Market position. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") source: ContextSource = Field( default=ContextSource.CONFIG, @@ -243,7 +243,7 @@ class ProgressiveWeights(BaseModel): strategic_alignment: Weight for strategic alignment dimension. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") budget_impact: float = Field( default=0.2, @@ -337,7 +337,7 @@ class ProgressiveThresholds(BaseModel): generous: Lower threshold for generous tier. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") moderate: float = Field( default=0.4, @@ -377,7 +377,7 @@ class ProgressiveConfig(BaseModel): thresholds: Thresholds for cost tier resolution. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") weights: ProgressiveWeights = Field( default_factory=ProgressiveWeights, @@ -397,7 +397,7 @@ class ConstitutionalPrincipleConfig(BaseModel): custom: Additional custom principles appended after the pack. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") pack: NotBlankStr = Field( default="default", @@ -443,7 +443,7 @@ class StrategyConfig(BaseModel): progressive: Progressive cost tier resolution configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") output_mode: StrategicOutputMode = Field( default=StrategicOutputMode.ADVISOR, @@ -528,7 +528,7 @@ class StrategicContext(BaseModel): competitive_position: Market competitive position. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") maturity_stage: NotBlankStr = Field(description="Company maturity stage") industry: NotBlankStr = Field(description="Industry sector") @@ -547,7 +547,7 @@ class ConstitutionalPrinciple(BaseModel): severity: How strictly this principle must be followed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique principle identifier") text: NotBlankStr = Field(description="Principle rule text") @@ -571,7 +571,7 @@ class PrinciplePack(BaseModel): principles: Ordered tuple of principles in this pack. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Pack identifier") version: NotBlankStr = Field(description="Semantic version string") @@ -607,7 +607,7 @@ class RiskCard(BaseModel): time_horizon: How far into the future effects extend. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") decision_type: NotBlankStr = Field(description="Type of decision") reversibility: Reversibility = Field( @@ -633,7 +633,7 @@ class ImpactScore(BaseModel): tier: Resolved cost tier based on composite score. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") dimensions: dict[str, float] = Field( description="Per-dimension scores (0.0-1.0)", @@ -676,7 +676,7 @@ class ConfidenceMetadata(BaseModel): uncertainty_factors: Factors contributing to uncertainty. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") level: float = Field( ge=0.0, @@ -727,7 +727,7 @@ class LensAttribution(BaseModel): weight: How much this lens influenced the final recommendation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") lens: NotBlankStr = Field(description="Strategic lens name") insight: NotBlankStr = Field(description="Insight from this lens") diff --git a/src/synthorg/engine/strategy/premortem.py b/src/synthorg/engine/strategy/premortem.py index 03689a5933..a9c5ba3d1b 100644 --- a/src/synthorg/engine/strategy/premortem.py +++ b/src/synthorg/engine/strategy/premortem.py @@ -38,7 +38,7 @@ class FailureMode(BaseModel): mitigation: Proposed mitigation or preventive action. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") description: NotBlankStr = Field( description="Description of how the decision could fail" @@ -65,7 +65,7 @@ class PremortemOutput(BaseModel): assumptions: Key assumptions underlying the decision. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") failure_modes: tuple[FailureMode, ...] = Field( default=(), diff --git a/src/synthorg/engine/task_engine_config.py b/src/synthorg/engine/task_engine_config.py index fdfea53ca9..d493ec5683 100644 --- a/src/synthorg/engine/task_engine_config.py +++ b/src/synthorg/engine/task_engine_config.py @@ -21,7 +21,7 @@ class TaskEngineConfig(BaseModel): events to the message bus after each mutation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_queue_size: int = Field( default=1000, diff --git a/src/synthorg/engine/task_engine_models.py b/src/synthorg/engine/task_engine_models.py index 7a9407d947..e95436ea03 100644 --- a/src/synthorg/engine/task_engine_models.py +++ b/src/synthorg/engine/task_engine_models.py @@ -62,7 +62,7 @@ class CreateTaskData(BaseModel): displayed using configured currency formatting. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") title: NotBlankStr = Field( max_length=_MAX_TITLE_LENGTH, @@ -108,7 +108,7 @@ class CreateTaskMutation(BaseModel): task_data: Task creation payload. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") mutation_type: Literal["create"] = "create" request_id: NotBlankStr = Field(description="Unique request identifier") @@ -145,7 +145,7 @@ class UpdateTaskMutation(BaseModel): expected_version: Optional optimistic concurrency version. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") mutation_type: Literal["update"] = "update" request_id: NotBlankStr = Field(description="Unique request identifier") @@ -201,7 +201,7 @@ class TransitionTaskMutation(BaseModel): expected_version: Optional optimistic concurrency version. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") mutation_type: Literal["transition"] = "transition" request_id: NotBlankStr = Field(description="Unique request identifier") @@ -251,7 +251,7 @@ class DeleteTaskMutation(BaseModel): task_id: Target task identifier. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") mutation_type: Literal["delete"] = "delete" request_id: NotBlankStr = Field(description="Unique request identifier") @@ -270,7 +270,7 @@ class CancelTaskMutation(BaseModel): reason: Reason for cancellation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") mutation_type: Literal["cancel"] = "cancel" request_id: NotBlankStr = Field(description="Unique request identifier") @@ -307,7 +307,7 @@ class TaskMutationResult(BaseModel): dispatch (``None`` on success). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") request_id: NotBlankStr = Field(description="Echoed request identifier") success: bool = Field(description="Whether the mutation succeeded") @@ -377,7 +377,7 @@ class TaskStateChanged(BaseModel): timestamp: When the mutation was applied. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") mutation_type: MutationType = Field( description="Mutation type that triggered event", diff --git a/src/synthorg/engine/task_execution.py b/src/synthorg/engine/task_execution.py index 3c30675113..be53494ed8 100644 --- a/src/synthorg/engine/task_execution.py +++ b/src/synthorg/engine/task_execution.py @@ -44,7 +44,7 @@ class StatusTransition(BaseModel): reason: Optional human-readable reason for the transition. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") from_status: TaskStatus = Field(description="Status before transition") to_status: TaskStatus = Field(description="Status after transition") @@ -77,7 +77,7 @@ class TaskExecution(BaseModel): completed_at: When execution reached a terminal state. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task: Task = Field(description="Original frozen task definition") status: TaskStatus = Field(description="Current execution status") diff --git a/src/synthorg/engine/trajectory/efficiency_ratios.py b/src/synthorg/engine/trajectory/efficiency_ratios.py index 8c64eea072..0379efe180 100644 --- a/src/synthorg/engine/trajectory/efficiency_ratios.py +++ b/src/synthorg/engine/trajectory/efficiency_ratios.py @@ -41,7 +41,7 @@ class IdealTrajectoryBaseline(BaseModel): notes: Optional human-readable notes. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_type: NotBlankStr = Field( description="Category of task (e.g. 'research', 'code')", @@ -108,7 +108,7 @@ class EfficiencyRatios(BaseModel): baseline_version: Reference to the baseline used. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") step_ratio: float = Field( ge=0.0, diff --git a/src/synthorg/engine/trajectory/models.py b/src/synthorg/engine/trajectory/models.py index 44366b7fc8..c76f1d2348 100644 --- a/src/synthorg/engine/trajectory/models.py +++ b/src/synthorg/engine/trajectory/models.py @@ -60,7 +60,7 @@ class CandidateResult(BaseModel): trace_tokens: Total output tokens across all turns. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") candidate_index: int = Field( ge=0, diff --git a/src/synthorg/engine/trajectory/pte.py b/src/synthorg/engine/trajectory/pte.py index df182a93a2..c54737aaea 100644 --- a/src/synthorg/engine/trajectory/pte.py +++ b/src/synthorg/engine/trajectory/pte.py @@ -30,7 +30,7 @@ class PTEConfig(BaseModel): (tool responses displace more than their own tokens). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") eviction_penalty: float = Field( default=0.3, diff --git a/src/synthorg/engine/workflow/blueprint_models.py b/src/synthorg/engine/workflow/blueprint_models.py index 4791af63a2..271a4964e5 100644 --- a/src/synthorg/engine/workflow/blueprint_models.py +++ b/src/synthorg/engine/workflow/blueprint_models.py @@ -28,7 +28,7 @@ class BlueprintNodeData(BaseModel): config: Type-specific configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique node identifier") type: WorkflowNodeType = Field(description="Node type") @@ -52,7 +52,7 @@ class BlueprintEdgeData(BaseModel): label: Optional display label. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique edge identifier") source_node_id: NotBlankStr = Field(description="Source node ID") @@ -84,7 +84,7 @@ class BlueprintData(BaseModel): edges: Edges connecting nodes. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Blueprint identifier") display_name: NotBlankStr = Field(description="Human-readable name") diff --git a/src/synthorg/engine/workflow/ceremony_policy.py b/src/synthorg/engine/workflow/ceremony_policy.py index 172fe86696..7214a1bba3 100644 --- a/src/synthorg/engine/workflow/ceremony_policy.py +++ b/src/synthorg/engine/workflow/ceremony_policy.py @@ -100,7 +100,7 @@ class CeremonyPolicyConfig(BaseModel): auto-transition (0.0, 1.0] -- zero excluded. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: CeremonyStrategyType | None = Field( default=None, @@ -140,7 +140,7 @@ class ResolvedCeremonyPolicy(BaseModel): transition_threshold: Resolved transition threshold. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: CeremonyStrategyType = Field( description="Resolved scheduling strategy type", diff --git a/src/synthorg/engine/workflow/config.py b/src/synthorg/engine/workflow/config.py index c16d810bb3..ede65a579f 100644 --- a/src/synthorg/engine/workflow/config.py +++ b/src/synthorg/engine/workflow/config.py @@ -41,7 +41,7 @@ class WorkflowConfig(BaseModel): workflow_type is ``AGILE_KANBAN``). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") workflow_type: WorkflowType = Field( default=WorkflowType.AGILE_KANBAN, diff --git a/src/synthorg/engine/workflow/definition.py b/src/synthorg/engine/workflow/definition.py index 05c0ecf0c4..7f565b3613 100644 --- a/src/synthorg/engine/workflow/definition.py +++ b/src/synthorg/engine/workflow/definition.py @@ -93,7 +93,7 @@ class WorkflowIODeclaration(BaseModel): description: Free-text description for the UI. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Identifier") type: WorkflowValueType = Field(description="Typed value kind") @@ -131,7 +131,7 @@ class WorkflowNode(BaseModel): agent role, condition expression, etc.). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique node identifier") type: WorkflowNodeType = Field(description="Node type") @@ -155,7 +155,7 @@ class WorkflowEdge(BaseModel): label: Optional display label (e.g. condition text). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique edge identifier") source_node_id: NotBlankStr = Field(description="Source node ID") @@ -197,7 +197,7 @@ class WorkflowDefinition(BaseModel): revision: Optimistic concurrency counter (monotonic integer). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique workflow definition ID") name: NotBlankStr = Field(description="Workflow name") diff --git a/src/synthorg/engine/workflow/diff.py b/src/synthorg/engine/workflow/diff.py index e043ac63ee..9634d8cf7e 100644 --- a/src/synthorg/engine/workflow/diff.py +++ b/src/synthorg/engine/workflow/diff.py @@ -57,7 +57,7 @@ class NodeChange(BaseModel): new_value: New state (None for removed nodes). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") node_id: NotBlankStr change_type: Literal[ @@ -92,7 +92,7 @@ class EdgeChange(BaseModel): new_value: New state (None for removed edges). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") edge_id: NotBlankStr change_type: Literal[ @@ -125,7 +125,7 @@ class MetadataChange(BaseModel): new_value: New value. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") field: NotBlankStr old_value: str @@ -145,7 +145,7 @@ class WorkflowDiff(BaseModel): summary: Human-readable summary string. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") definition_id: NotBlankStr from_version: int = Field(ge=1) diff --git a/src/synthorg/engine/workflow/execution_models.py b/src/synthorg/engine/workflow/execution_models.py index 0aa3f23aa9..6272a92cd7 100644 --- a/src/synthorg/engine/workflow/execution_models.py +++ b/src/synthorg/engine/workflow/execution_models.py @@ -40,7 +40,7 @@ class ExecutionFrame(BaseModel): depth: Nesting depth (root frame is ``0``). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") workflow_id: NotBlankStr = Field(description="Workflow definition ID") workflow_version: NotBlankStr = Field(description="Semver version") @@ -94,7 +94,7 @@ class WorkflowNodeExecution(BaseModel): (e.g. conditional branch not taken). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") node_id: NotBlankStr = Field(description="Source node ID") node_type: WorkflowNodeType = Field(description="Node type") @@ -169,7 +169,7 @@ class WorkflowExecution(BaseModel): version: Optimistic concurrency version counter. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique execution ID") definition_id: NotBlankStr = Field(description="Source definition ID") diff --git a/src/synthorg/engine/workflow/kanban_board.py b/src/synthorg/engine/workflow/kanban_board.py index fbc43fb42e..6a07c0be35 100644 --- a/src/synthorg/engine/workflow/kanban_board.py +++ b/src/synthorg/engine/workflow/kanban_board.py @@ -31,7 +31,7 @@ class KanbanWipLimit(BaseModel): limit: Maximum number of tasks allowed in the column. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") column: KanbanColumn = Field(description="Target column") limit: int = Field( @@ -51,7 +51,7 @@ class WipCheckResult(BaseModel): limit: Configured limit (``None`` if no limit set). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") allowed: bool = Field(description="Whether the move is allowed") column: KanbanColumn = Field(description="Checked column") @@ -76,7 +76,7 @@ class KanbanConfig(BaseModel): are advisory-only (logged as warnings but ``allowed=True``). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") wip_limits: tuple[KanbanWipLimit, ...] = Field( default=( diff --git a/src/synthorg/engine/workflow/sprint_config.py b/src/synthorg/engine/workflow/sprint_config.py index 133881d25d..72431ca72d 100644 --- a/src/synthorg/engine/workflow/sprint_config.py +++ b/src/synthorg/engine/workflow/sprint_config.py @@ -43,7 +43,7 @@ class SprintCeremonyConfig(BaseModel): ceremony scheduling policy. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field( description="Ceremony identifier", @@ -95,7 +95,7 @@ class SprintConfig(BaseModel): ceremonies: Sprint ceremony definitions. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") duration_days: int = Field( default=14, diff --git a/src/synthorg/engine/workflow/sprint_lifecycle.py b/src/synthorg/engine/workflow/sprint_lifecycle.py index df5843c823..4f6225cd0c 100644 --- a/src/synthorg/engine/workflow/sprint_lifecycle.py +++ b/src/synthorg/engine/workflow/sprint_lifecycle.py @@ -114,7 +114,7 @@ class Sprint(BaseModel): story_points_completed: Story points delivered. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique sprint identifier") name: NotBlankStr = Field(description="Sprint display name") diff --git a/src/synthorg/engine/workflow/strategy_migration.py b/src/synthorg/engine/workflow/strategy_migration.py index 4a6a40827f..99c30513bc 100644 --- a/src/synthorg/engine/workflow/strategy_migration.py +++ b/src/synthorg/engine/workflow/strategy_migration.py @@ -49,7 +49,7 @@ class StrategyMigrationInfo(BaseModel): being superseded. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") sprint_id: NotBlankStr = Field( description="The sprint being activated", diff --git a/src/synthorg/engine/workflow/validation_types.py b/src/synthorg/engine/workflow/validation_types.py index 14dd89cd0c..2d0ec6a503 100644 --- a/src/synthorg/engine/workflow/validation_types.py +++ b/src/synthorg/engine/workflow/validation_types.py @@ -58,7 +58,7 @@ class ValidationErrorCode(StrEnum): class WorkflowValidationError(BaseModel): """A single validation error with optional location context.""" - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") code: ValidationErrorCode = Field(description="Error code") message: NotBlankStr = Field(description="Human-readable message") diff --git a/src/synthorg/engine/workflow/velocity_types.py b/src/synthorg/engine/workflow/velocity_types.py index 09236a14e2..db5d70b52a 100644 --- a/src/synthorg/engine/workflow/velocity_types.py +++ b/src/synthorg/engine/workflow/velocity_types.py @@ -46,7 +46,7 @@ class VelocityMetrics(BaseModel): ``{"pts_per_day": 3.2, "completion_ratio": 0.93}``). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") primary_value: float = Field( ge=0.0, diff --git a/src/synthorg/engine/workspace/disk_quota.py b/src/synthorg/engine/workspace/disk_quota.py index 7b237ae6d9..bf6f30ae69 100644 --- a/src/synthorg/engine/workspace/disk_quota.py +++ b/src/synthorg/engine/workspace/disk_quota.py @@ -36,7 +36,7 @@ class DiskQuotaStatus(BaseModel): status: One of ``ok``, ``warning``, or ``exceeded``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") path: Path = Field(description="Worktree directory path") usage_gb: float = Field(ge=0.0, description="Current usage in GB") diff --git a/src/synthorg/engine/workspace/models.py b/src/synthorg/engine/workspace/models.py index e851075cde..757a51fe21 100644 --- a/src/synthorg/engine/workspace/models.py +++ b/src/synthorg/engine/workspace/models.py @@ -19,7 +19,7 @@ class WorkspaceRequest(BaseModel): file_scope: Optional file path hints for the workspace. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_id: NotBlankStr = Field(description="Task requiring isolation") agent_id: NotBlankStr = Field(description="Agent working in workspace") @@ -46,7 +46,7 @@ class Workspace(BaseModel): created_at: Timestamp of workspace creation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") workspace_id: NotBlankStr = Field(description="Unique workspace ID") task_id: NotBlankStr = Field(description="Task this workspace serves") @@ -75,7 +75,7 @@ class MergeConflict(BaseModel): theirs_content: Content from the workspace branch side. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") file_path: NotBlankStr = Field(description="Conflicting file path") conflict_type: ConflictType = Field( @@ -117,7 +117,7 @@ class MergeResult(BaseModel): semantic_conflicts: Semantic conflicts detected after merge. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") workspace_id: NotBlankStr = Field(description="Merged workspace ID") branch_name: NotBlankStr = Field(description="Merged branch name") diff --git a/src/synthorg/hr/activity.py b/src/synthorg/hr/activity.py index a9d1b2bf18..ff3a444cca 100644 --- a/src/synthorg/hr/activity.py +++ b/src/synthorg/hr/activity.py @@ -36,7 +36,7 @@ class ActivityEvent(BaseModel): related_ids: Related entity identifiers (e.g. task_id, agent_id). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") event_type: ActivityEventType = Field(description="Event category") timestamp: AwareDatetime = Field(description="When the event occurred") @@ -62,7 +62,7 @@ class CareerEvent(BaseModel): metadata: Additional structured metadata. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") event_type: LifecycleEventType = Field(description="Lifecycle event type") timestamp: AwareDatetime = Field(description="When the event occurred") diff --git a/src/synthorg/hr/archival_protocol.py b/src/synthorg/hr/archival_protocol.py index 5aeb80e7d9..f5b438c514 100644 --- a/src/synthorg/hr/archival_protocol.py +++ b/src/synthorg/hr/archival_protocol.py @@ -26,7 +26,7 @@ class ArchivalResult(BaseModel): strategy_name: Name of the archival strategy used. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent whose memories were archived") total_archived: int = Field(ge=0, description="Memories archived") diff --git a/src/synthorg/hr/evaluation/config.py b/src/synthorg/hr/evaluation/config.py index bb2b919290..6966a22ffb 100644 --- a/src/synthorg/hr/evaluation/config.py +++ b/src/synthorg/hr/evaluation/config.py @@ -28,7 +28,7 @@ class IntelligenceConfig(BaseModel): llm_calibration_weight: Weight for LLM calibration metric. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = True weight: float = Field(default=0.2, ge=0.0, le=1.0) @@ -65,7 +65,7 @@ class EfficiencyConfig(BaseModel): reference_tokens: Reference token count for normalization. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = True weight: float = Field(default=0.2, ge=0.0, le=1.0) @@ -108,7 +108,7 @@ class ResilienceConfig(BaseModel): consistency_k: Sensitivity factor for stddev penalty. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = True weight: float = Field(default=0.2, ge=0.0, le=1.0) @@ -151,7 +151,7 @@ class GovernanceConfig(BaseModel): autonomy_compliance_weight: Weight for autonomy compliance metric. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = True weight: float = Field(default=0.2, ge=0.0, le=1.0) @@ -194,7 +194,7 @@ class ExperienceConfig(BaseModel): min_feedback_count: Minimum feedback records for meaningful scoring. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = True weight: float = Field(default=0.2, ge=0.0, le=1.0) @@ -244,7 +244,7 @@ class EvalLoopConfig(BaseModel): shipped default map. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=True, @@ -305,7 +305,7 @@ class EvaluationConfig(BaseModel): eval_loop: Closed-loop evaluation coordinator configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") intelligence: IntelligenceConfig = Field( default_factory=IntelligenceConfig, diff --git a/src/synthorg/hr/evaluation/dogfooding_dataset_builder.py b/src/synthorg/hr/evaluation/dogfooding_dataset_builder.py index 021681a684..b376f9ead2 100644 --- a/src/synthorg/hr/evaluation/dogfooding_dataset_builder.py +++ b/src/synthorg/hr/evaluation/dogfooding_dataset_builder.py @@ -29,7 +29,7 @@ class DogfoodingDatasetConfig(BaseModel): min_trace_quality: Minimum quality score to include. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") max_cases_per_tag: int = Field( default=100, diff --git a/src/synthorg/hr/evaluation/external_benchmark_models.py b/src/synthorg/hr/evaluation/external_benchmark_models.py index a0e2ef1260..70de46f4ef 100644 --- a/src/synthorg/hr/evaluation/external_benchmark_models.py +++ b/src/synthorg/hr/evaluation/external_benchmark_models.py @@ -26,7 +26,7 @@ class EvalTestCase(BaseModel): metadata: Additional benchmark-specific metadata. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique case identifier") behavior_tags: tuple[BehaviorTag, ...] = Field( @@ -61,7 +61,7 @@ class BenchmarkGrade(BaseModel): explanation: Human-readable grading rationale. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") passed: bool = Field(description="Whether the test passed") score: float = Field( @@ -87,7 +87,7 @@ class BenchmarkRunResult(BaseModel): completed_at: When the run finished. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") benchmark_name: NotBlankStr = Field( description="Which benchmark was run", @@ -127,7 +127,7 @@ class EvalDataset(BaseModel): version: Version identifier. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Dataset identifier") source: Literal["dogfooding", "external", "hand_written"] = Field( @@ -153,7 +153,7 @@ class BenchmarkRef(BaseModel): enabled: Whether this benchmark is active. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field( description="Benchmark name matching the registry key", @@ -181,7 +181,7 @@ class EvalCycleReport(BaseModel): created_at: When cycle completed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") cycle_id: NotBlankStr = Field(description="Unique cycle identifier") window_start: AwareDatetime = Field( diff --git a/src/synthorg/hr/evaluation/models.py b/src/synthorg/hr/evaluation/models.py index e4d8a79e29..14784866da 100644 --- a/src/synthorg/hr/evaluation/models.py +++ b/src/synthorg/hr/evaluation/models.py @@ -84,7 +84,7 @@ class InteractionFeedback(BaseModel): "llm_judge"). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -173,7 +173,7 @@ class ResilienceMetrics(BaseModel): (None if insufficient scored tasks). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") total_tasks: int = Field(ge=0, description="Total task count") failed_tasks: int = Field(ge=0, description="Number of failed tasks") @@ -237,7 +237,7 @@ class PillarScore(BaseModel): evaluated_at: When this score was computed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") pillar: EvaluationPillar = Field(description="Which pillar this score represents") score: float = Field(ge=0.0, le=10.0, description="Overall pillar score") @@ -276,7 +276,7 @@ class EvaluationContext(BaseModel): autonomy_downgrades_in_window: Autonomy downgrades in the window. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent being evaluated") now: AwareDatetime = Field(description="Reference timestamp") @@ -364,7 +364,7 @@ class EvaluationReport(BaseModel): pillar_weights: Applied weights as (pillar_name, weight) pairs. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), diff --git a/src/synthorg/hr/health/service.py b/src/synthorg/hr/health/service.py index 14078d7046..8e14472b3c 100644 --- a/src/synthorg/hr/health/service.py +++ b/src/synthorg/hr/health/service.py @@ -62,7 +62,7 @@ class AgentHealthReport(BaseModel): recent_failed_count: Failed-task count in ``recent_window``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent being evaluated") status: HealthStatus = Field(description="Derived health verdict") diff --git a/src/synthorg/hr/models.py b/src/synthorg/hr/models.py index e4b20a7e05..02547bd994 100644 --- a/src/synthorg/hr/models.py +++ b/src/synthorg/hr/models.py @@ -42,7 +42,7 @@ class CandidateCard(BaseModel): template_source: Template used for generation, if any. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -88,7 +88,7 @@ class HiringRequest(BaseModel): approval_id: ID of the associated approval item. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -165,7 +165,7 @@ class FiringRequest(BaseModel): completed_at: When the firing was completed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -204,7 +204,7 @@ class OnboardingStepRecord(BaseModel): notes: Optional notes from the step. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") step: OnboardingStep = Field(description="The onboarding step") completed: bool = Field(default=False, description="Whether step is complete") @@ -290,7 +290,7 @@ class OffboardingRecord(BaseModel): completed_at: When offboarding finished. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent who was offboarded") agent_name: NotBlankStr = Field(description="Agent display name") @@ -341,7 +341,7 @@ class AgentLifecycleEvent(BaseModel): metadata: Additional structured metadata. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), diff --git a/src/synthorg/hr/performance/config.py b/src/synthorg/hr/performance/config.py index ec62746ed2..1437d2fc31 100644 --- a/src/synthorg/hr/performance/config.py +++ b/src/synthorg/hr/performance/config.py @@ -33,7 +33,7 @@ class PerformanceConfig(BaseModel): score (default 0.6). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") min_data_points: int = Field( default=5, diff --git a/src/synthorg/hr/performance/inflection_protocol.py b/src/synthorg/hr/performance/inflection_protocol.py index bc3febef78..154a29abcf 100644 --- a/src/synthorg/hr/performance/inflection_protocol.py +++ b/src/synthorg/hr/performance/inflection_protocol.py @@ -30,7 +30,7 @@ class PerformanceInflection(BaseModel): detected_at: When the inflection was detected. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr metric_name: NotBlankStr diff --git a/src/synthorg/hr/performance/models.py b/src/synthorg/hr/performance/models.py index 839154f26c..467917bc59 100644 --- a/src/synthorg/hr/performance/models.py +++ b/src/synthorg/hr/performance/models.py @@ -47,7 +47,7 @@ class TaskMetricRecord(BaseModel): complexity: Estimated task complexity. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -112,7 +112,7 @@ class CollaborationMetricRecord(BaseModel): calibration (None if not available). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -170,7 +170,7 @@ class QualityScoreResult(BaseModel): confidence: Confidence in the score (0.0-1.0). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") score: float = Field(ge=0.0, le=10.0, description="Overall quality score") strategy_name: NotBlankStr = Field(description="Scoring strategy used") @@ -195,7 +195,7 @@ class CollaborationScoreResult(BaseModel): confidence: Confidence in the score (0.0-1.0). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") score: float = Field(ge=0.0, le=10.0, description="Overall collaboration score") strategy_name: NotBlankStr = Field(description="Scoring strategy used") @@ -290,7 +290,7 @@ class _BaseOverride(BaseModel): expires_at: When the override expires (None = indefinite). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -350,7 +350,7 @@ class TrendResult(BaseModel): data_point_count: Number of data points used. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") metric_name: NotBlankStr = Field(description="Metric being trended") window_size: NotBlankStr = Field(description="Time window label") @@ -381,7 +381,7 @@ class WindowMetrics(BaseModel): collaboration_score: Collaboration score, None if not computed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") window_size: NotBlankStr = Field(description="Time window label") data_point_count: int = Field(ge=0, description="Records in the window") @@ -498,7 +498,7 @@ class CollaborationCalibration(BaseModel): sample, or ``None`` when no samples exist. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent identifier") strategy_name: NotBlankStr = Field(description="Active strategy name") @@ -536,7 +536,7 @@ class AgentPerformanceSnapshot(BaseModel): overall_collaboration_score: Aggregate collaboration score. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent being evaluated") computed_at: AwareDatetime = Field(description="When this snapshot was computed") diff --git a/src/synthorg/hr/performance/summary.py b/src/synthorg/hr/performance/summary.py index 8511894f58..49fbbdf931 100644 --- a/src/synthorg/hr/performance/summary.py +++ b/src/synthorg/hr/performance/summary.py @@ -45,7 +45,7 @@ class AgentPerformanceSummary(BaseModel): trends: Trend results from snapshot. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_name: NotBlankStr = Field(description="Agent display name") tasks_completed_total: int = Field( diff --git a/src/synthorg/hr/promotion/config.py b/src/synthorg/hr/promotion/config.py index 901b5157ba..768bd90fdf 100644 --- a/src/synthorg/hr/promotion/config.py +++ b/src/synthorg/hr/promotion/config.py @@ -22,7 +22,7 @@ class PromotionCriteriaConfig(BaseModel): required_criteria: Criteria names that must always be met. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") min_criteria_met: int = Field( default=2, @@ -47,7 +47,7 @@ class PromotionApprovalConfig(BaseModel): authority-reducing demotions. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") human_approval_from_level: SeniorityLevel = Field( default=SeniorityLevel.SENIOR, @@ -71,7 +71,7 @@ class ModelMappingConfig(BaseModel): seniority_model_map: Explicit level-to-model overrides. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") model_follows_seniority: bool = Field( default=True, @@ -114,7 +114,7 @@ class PromotionConfig(BaseModel): model_mapping: Model mapping configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=True, diff --git a/src/synthorg/hr/promotion/models.py b/src/synthorg/hr/promotion/models.py index a840262e88..7e5a03d5a1 100644 --- a/src/synthorg/hr/promotion/models.py +++ b/src/synthorg/hr/promotion/models.py @@ -32,7 +32,7 @@ class CriterionResult(BaseModel): weight: Weight of this criterion (None if not weighted). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Criterion name") met: bool = Field(description="Whether the criterion was met") @@ -146,7 +146,7 @@ class PromotionRecord(BaseModel): new_model_id: New model ID (None if not changed). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -224,7 +224,7 @@ class PromotionRequest(BaseModel): approval_id: Linked approval item ID (for human approval). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), diff --git a/src/synthorg/hr/pruning/models.py b/src/synthorg/hr/pruning/models.py index ca5f0f2d54..43e69ab649 100644 --- a/src/synthorg/hr/pruning/models.py +++ b/src/synthorg/hr/pruning/models.py @@ -33,7 +33,7 @@ class PruningEvaluation(BaseModel): evaluated_at: When evaluation occurred. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent being evaluated") eligible: bool = Field(description="Whether agent should be pruned") @@ -77,7 +77,7 @@ class PruningRequest(BaseModel): decided_by: Who made the approval decision. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -152,7 +152,7 @@ class PruningRecord(BaseModel): completed_at: When process finished. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent who was pruned") agent_name: NotBlankStr = Field(description="Agent display name") @@ -193,7 +193,7 @@ class PruningJobRun(BaseModel): errors: Non-fatal errors encountered. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") job_id: NotBlankStr = Field(description="Unique cycle identifier") run_at: AwareDatetime = Field(description="When the cycle started") @@ -237,7 +237,7 @@ class PruningServiceConfig(BaseModel): approval_expiry_days: Days until pending approval expires. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") evaluation_interval_seconds: float = Field( default=3600.0, diff --git a/src/synthorg/hr/pruning/policy.py b/src/synthorg/hr/pruning/policy.py index 98cbc7c557..fba815fcd3 100644 --- a/src/synthorg/hr/pruning/policy.py +++ b/src/synthorg/hr/pruning/policy.py @@ -62,7 +62,7 @@ class ThresholdPruningPolicyConfig(BaseModel): minimum_window_data_points: Minimum records to evaluate a window. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") quality_threshold: float = Field( default=3.5, @@ -210,7 +210,7 @@ class TrendPruningPolicyConfig(BaseModel): metric_name: Which metric to track for trend evaluation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") minimum_data_points_per_window: int = Field( default=5, diff --git a/src/synthorg/hr/scaling/config.py b/src/synthorg/hr/scaling/config.py index 1798aab708..c35bc4fa9d 100644 --- a/src/synthorg/hr/scaling/config.py +++ b/src/synthorg/hr/scaling/config.py @@ -26,7 +26,7 @@ class WorkloadScalingConfig(BaseModel): prune_threshold: Utilization fraction below which to prune. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field(default=True, description="Strategy enabled") priority: int = Field(default=3, ge=0, description="Priority rank") @@ -72,7 +72,7 @@ class BudgetCapConfig(BaseModel): headroom_fraction: Burn rate below which hires are allowed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field(default=True, description="Strategy enabled") priority: int = Field(default=0, ge=0, description="Priority rank") @@ -117,7 +117,7 @@ class SkillGapConfig(BaseModel): min_missing_skills: Minimum missing skills to trigger. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -140,7 +140,7 @@ class PerformancePruningConfig(BaseModel): defer_during_evolution: Defer pruning during active evolution. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field(default=True, description="Strategy enabled") priority: int = Field(default=1, ge=0, description="Priority rank") @@ -157,7 +157,7 @@ class TriggerConfig(BaseModel): batched_interval_seconds: Interval for the batched trigger. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") batched_interval_seconds: int = Field( default=900, @@ -176,7 +176,7 @@ class GuardConfig(BaseModel): approval_expiry_days: Days until approval items expire. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") cooldown_seconds: int = Field( default=3600, @@ -214,7 +214,7 @@ class ScalingConfig(BaseModel): priority_order: Strategy priority (name list, first = highest). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field(default=True, description="Scaling service enabled") default_hire_level: NotBlankStr = Field( diff --git a/src/synthorg/hr/scaling/models.py b/src/synthorg/hr/scaling/models.py index e4bdb5afca..24549e5e85 100644 --- a/src/synthorg/hr/scaling/models.py +++ b/src/synthorg/hr/scaling/models.py @@ -40,7 +40,7 @@ class ScalingSignal(BaseModel): timestamp: When the signal was collected. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Signal identifier") value: float = Field(description="Current signal value") @@ -119,7 +119,7 @@ class ScalingDecision(BaseModel): created_at: When the decision was created. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -207,7 +207,7 @@ class ScalingActionRecord(BaseModel): executed_at: When execution occurred. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), diff --git a/src/synthorg/hr/training/config.py b/src/synthorg/hr/training/config.py index 427b6adc3f..dd71f215ac 100644 --- a/src/synthorg/hr/training/config.py +++ b/src/synthorg/hr/training/config.py @@ -41,7 +41,7 @@ class TrainingConfig(BaseModel): training_tags: Default tags applied to stored items. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=True, diff --git a/src/synthorg/hr/training/models.py b/src/synthorg/hr/training/models.py index e9e3606fed..233083fca5 100644 --- a/src/synthorg/hr/training/models.py +++ b/src/synthorg/hr/training/models.py @@ -1,7 +1,9 @@ """Training mode domain models. Frozen Pydantic models for training plans, items, guard decisions, -and results. All models use ``ConfigDict(frozen=True, allow_inf_nan=False)``. +and results. All models use the standard frozen ConfigDict with +``extra="forbid"`` so unknown payload fields are rejected at +validation time. """ from enum import StrEnum @@ -56,7 +58,7 @@ class TrainingItem(BaseModel): created_at: When the item was created. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -101,7 +103,7 @@ class TrainingGuardDecision(BaseModel): approval_item_id: ApprovalStore item ID (ReviewGateGuard only). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") approved_items: tuple[TrainingItem, ...] = Field( description="Items that passed the guard", @@ -168,7 +170,7 @@ class TrainingPlan(BaseModel): executed_at: Execution completion timestamp. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), @@ -287,7 +289,7 @@ class TrainingApprovalHandle(BaseModel): item_count: Number of items blocked by the gate. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") approval_item_id: NotBlankStr = Field( description="ApprovalStore item ID", @@ -323,7 +325,7 @@ class TrainingResult(BaseModel): completed_at: Pipeline completion timestamp. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), diff --git a/src/synthorg/memory/backends/composite/config.py b/src/synthorg/memory/backends/composite/config.py index ca55f3fd60..4bab6cb7c9 100644 --- a/src/synthorg/memory/backends/composite/config.py +++ b/src/synthorg/memory/backends/composite/config.py @@ -17,7 +17,7 @@ class CompositeBackendConfig(BaseModel): default: Backend name for unmapped namespaces. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") routes: dict[NotBlankStr, NotBlankStr] = Field( default_factory=dict, diff --git a/src/synthorg/memory/backends/mem0/config.py b/src/synthorg/memory/backends/mem0/config.py index 4fee717658..756b0bdca8 100644 --- a/src/synthorg/memory/backends/mem0/config.py +++ b/src/synthorg/memory/backends/mem0/config.py @@ -47,7 +47,7 @@ class EmbeddingFineTuneConfig(BaseModel): training step, not by checkpoint lookup. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -149,7 +149,7 @@ class Mem0EmbedderConfig(BaseModel): ``True``). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") provider: NotBlankStr = Field( description="Embedding provider name (Mem0 SDK identifier)", @@ -192,7 +192,7 @@ class EmbeddingCostConfig(BaseModel): count to token count. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -242,7 +242,7 @@ class Mem0BackendConfig(BaseModel): embedder: Embedder settings (required -- no defaults). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") data_dir: NotBlankStr = Field( default="/data/memory", diff --git a/src/synthorg/memory/config.py b/src/synthorg/memory/config.py index ea383897a1..20f2224f7e 100644 --- a/src/synthorg/memory/config.py +++ b/src/synthorg/memory/config.py @@ -35,7 +35,7 @@ class MemoryStorageConfig(BaseModel): history_store: History store backend name. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") _VALID_VECTOR_STORES: ClassVar[frozenset[str]] = frozenset( {"qdrant", "qdrant-external"}, @@ -118,7 +118,7 @@ class MemoryOptionsConfig(BaseModel): shared_knowledge_base: Whether shared knowledge is enabled. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") retention_days: int | None = Field( default=None, @@ -214,7 +214,7 @@ class CompanyMemoryConfig(BaseModel): backend is ``"composite"``). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") _VALID_BACKENDS: ClassVar[frozenset[str]] = frozenset( {"mem0", "composite", "inmemory"}, diff --git a/src/synthorg/memory/consolidation/config.py b/src/synthorg/memory/consolidation/config.py index 699017f977..f0cb015516 100644 --- a/src/synthorg/memory/consolidation/config.py +++ b/src/synthorg/memory/consolidation/config.py @@ -40,7 +40,7 @@ class RetentionConfig(BaseModel): (``None`` = keep forever). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") rules: tuple[RetentionRule, ...] = Field( default=(), @@ -96,7 +96,7 @@ class DualModeConfig(BaseModel): snippet (start/mid/end). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -156,7 +156,7 @@ class ArchivalConfig(BaseModel): dual_mode: Dual-mode archival configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -189,7 +189,7 @@ class ExperienceCompressorConfig(BaseModel): this threshold (0.0 = keep all, closer to 1.0 = stricter). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -242,7 +242,7 @@ class WikiExportConfig(BaseModel): max_entries_per_view: Maximum entries per view (``None`` = all). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -312,7 +312,7 @@ class ConsolidationConfig(BaseModel): wiki_export: Wiki filesystem export settings. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=True, @@ -381,7 +381,7 @@ class LLMConsolidationConfig(BaseModel): concatenation-fallback summaries. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") group_threshold: int = Field( default=3, diff --git a/src/synthorg/memory/consolidation/distillation.py b/src/synthorg/memory/consolidation/distillation.py index dc34a53b18..7337a116bf 100644 --- a/src/synthorg/memory/consolidation/distillation.py +++ b/src/synthorg/memory/consolidation/distillation.py @@ -75,7 +75,7 @@ class DistillationRequest(BaseModel): created_at: Capture timestamp. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent that completed the task") task_id: NotBlankStr = Field(description="Completed task identifier") diff --git a/src/synthorg/memory/consolidation/models.py b/src/synthorg/memory/consolidation/models.py index 0ad426da1b..50d5af9d44 100644 --- a/src/synthorg/memory/consolidation/models.py +++ b/src/synthorg/memory/consolidation/models.py @@ -43,7 +43,7 @@ class ArchivalModeAssignment(BaseModel): mode: Archival mode applied to this entry. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") original_id: NotBlankStr = Field( description="ID of the removed memory entry", @@ -65,7 +65,7 @@ class ArchivalIndexEntry(BaseModel): mode: Archival mode used for this entry. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") original_id: NotBlankStr = Field( description="ID of the original memory entry", @@ -104,7 +104,7 @@ class ConsolidationResult(BaseModel): (built by service after archival completes). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") + model_config = ConfigDict(frozen=True, allow_inf_nan=False) removed_ids: tuple[NotBlankStr, ...] = Field( default=(), @@ -214,7 +214,7 @@ class ArchivalEntry(BaseModel): archival_mode: How this entry was archived. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") original_id: NotBlankStr = Field(description="ID from the hot store") agent_id: NotBlankStr = Field(description="Owning agent identifier") @@ -250,7 +250,7 @@ class RetentionRule(BaseModel): retention_days: Number of days to retain memories. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") category: MemoryCategory = Field( description="Memory category this rule applies to", @@ -297,7 +297,7 @@ class DetailedExperience(BaseModel): source_task_id: Optional originating task identifier. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique identifier") agent_id: NotBlankStr = Field(description="Owning agent identifier") @@ -363,7 +363,7 @@ class CompressedExperience(BaseModel): created_at: When the compression was performed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique identifier") agent_id: NotBlankStr = Field(description="Owning agent identifier") diff --git a/src/synthorg/memory/consolidation/wiki_export.py b/src/synthorg/memory/consolidation/wiki_export.py index b03bf2a0c2..f8c82e43b3 100644 --- a/src/synthorg/memory/consolidation/wiki_export.py +++ b/src/synthorg/memory/consolidation/wiki_export.py @@ -41,7 +41,7 @@ class WikiExportResult(BaseModel): export_root: Root directory of the export. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") raw_count: int = Field(default=0, ge=0) compressed_count: int = Field(default=0, ge=0) diff --git a/src/synthorg/memory/embedding/fine_tune_models.py b/src/synthorg/memory/embedding/fine_tune_models.py index 5fb2e01ac4..1fa2f61c17 100644 --- a/src/synthorg/memory/embedding/fine_tune_models.py +++ b/src/synthorg/memory/embedding/fine_tune_models.py @@ -33,7 +33,7 @@ class FineTuneRequest(BaseModel): validation_split: Fraction of data held out for evaluation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") source_dir: NotBlankStr = Field( description="Directory containing org documents", @@ -126,7 +126,7 @@ class FineTuneStatus(BaseModel): error: Error message if the pipeline failed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") run_id: NotBlankStr | None = Field( default=None, @@ -221,7 +221,7 @@ class FineTuneRunConfig(BaseModel): validation_split: Fraction held out for evaluation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") source_dir: NotBlankStr = Field(description="Source document directory") base_model: NotBlankStr = Field(description="Base embedding model") @@ -341,7 +341,7 @@ class CheckpointRecord(BaseModel): backup_config_json: JSON backup of pre-deployment config. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique checkpoint ID") run_id: NotBlankStr = Field(description="Originating run ID") @@ -374,7 +374,7 @@ class PreflightCheck(BaseModel): detail: Optional additional detail. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Check identifier") status: Literal["pass", "warn", "fail"] = Field(description="Result") @@ -427,7 +427,7 @@ class FineTuneExecutionConfig(BaseModel): timeout_seconds: Maximum wall-clock time for a single stage. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") backend: Literal["in-process", "docker"] = "in-process" image: NotBlankStr | None = None diff --git a/src/synthorg/memory/embedding/rankings.py b/src/synthorg/memory/embedding/rankings.py index d19849e8f7..b23727b5a8 100644 --- a/src/synthorg/memory/embedding/rankings.py +++ b/src/synthorg/memory/embedding/rankings.py @@ -43,7 +43,7 @@ class EmbeddingModelRanking(BaseModel): output_dims: Output embedding vector dimensions. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") model_id: NotBlankStr = Field( description="Model identifier", diff --git a/src/synthorg/memory/fine_tune_plan.py b/src/synthorg/memory/fine_tune_plan.py index 449248512e..47835bc154 100644 --- a/src/synthorg/memory/fine_tune_plan.py +++ b/src/synthorg/memory/fine_tune_plan.py @@ -94,7 +94,7 @@ class ActiveEmbedderSnapshot(BaseModel): service is wired (values fall back to ``None``). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") provider: NotBlankStr | None = Field( default=None, @@ -138,7 +138,7 @@ class FineTunePlan(BaseModel): vs docker, gpu, memory, timeout). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") source_dir: NotBlankStr = Field( description="Directory containing org documents for training", diff --git a/src/synthorg/memory/models.py b/src/synthorg/memory/models.py index 5c1385b588..93319f89ec 100644 --- a/src/synthorg/memory/models.py +++ b/src/synthorg/memory/models.py @@ -27,7 +27,7 @@ class MemoryMetadata(BaseModel): tags: Categorization tags for filtering. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") source: NotBlankStr | None = Field( default=None, @@ -69,7 +69,7 @@ class MemoryStoreRequest(BaseModel): expires_at: Optional expiration timestamp. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") category: MemoryCategory = Field(description="Memory type category") namespace: NotBlankStr = Field( @@ -103,7 +103,7 @@ class MemoryEntry(BaseModel): relevance_score: Relevance score set by backend on retrieval. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique memory identifier") agent_id: NotBlankStr = Field(description="Owning agent identifier") @@ -180,7 +180,7 @@ class MemoryQuery(BaseModel): until: Only memories created before this timestamp. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") text: NotBlankStr | None = Field( default=None, diff --git a/src/synthorg/memory/org/access_control.py b/src/synthorg/memory/org/access_control.py index 691e1fd139..561e39a7af 100644 --- a/src/synthorg/memory/org/access_control.py +++ b/src/synthorg/memory/org/access_control.py @@ -31,7 +31,7 @@ class CategoryWriteRule(BaseModel): human_allowed: Whether human operators can write. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") allowed_seniority: SeniorityLevel | None = Field( default=None, @@ -63,7 +63,7 @@ class WriteAccessConfig(BaseModel): rules: Per-category write rules (read-only mapping). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") rules: dict[OrgFactCategory, CategoryWriteRule] = Field( default_factory=_default_rules, diff --git a/src/synthorg/memory/org/config.py b/src/synthorg/memory/org/config.py index 8a1f90fb5a..47f8831358 100644 --- a/src/synthorg/memory/org/config.py +++ b/src/synthorg/memory/org/config.py @@ -24,7 +24,7 @@ class ExtendedStoreConfig(BaseModel): max_retrieved_per_query: Maximum facts to retrieve per query. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") _VALID_BACKENDS: ClassVar[frozenset[str]] = frozenset({"sqlite"}) @@ -67,7 +67,7 @@ class OrgMemoryConfig(BaseModel): write_access: Write access control configuration. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") _VALID_BACKENDS: ClassVar[frozenset[str]] = frozenset( {"hybrid_prompt_retrieval"}, diff --git a/src/synthorg/memory/org/models.py b/src/synthorg/memory/org/models.py index 072994f418..93a8f6c619 100644 --- a/src/synthorg/memory/org/models.py +++ b/src/synthorg/memory/org/models.py @@ -38,7 +38,7 @@ class OrgFactAuthor(BaseModel): is_human: Whether the author is a human operator. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr | None = Field( default=None, @@ -123,7 +123,7 @@ class OrgFact(BaseModel): created_at: Creation timestamp. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Unique fact identifier") content: NotBlankStr = Field(description="Fact content text") @@ -145,7 +145,7 @@ class OrgFactWriteRequest(BaseModel): tags: Metadata tags for cross-cutting concerns. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") content: NotBlankStr = Field(description="Fact content text") category: OrgFactCategory = Field(description="Category classification") @@ -164,7 +164,7 @@ class OrgMemoryQuery(BaseModel): limit: Maximum number of results. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") context: NotBlankStr | None = Field( default=None, @@ -207,7 +207,7 @@ class OperationLogEntry(BaseModel): version: Per-fact version counter (starts at 1). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") operation_id: NotBlankStr = Field( description="Globally unique operation identifier", @@ -287,7 +287,7 @@ class OperationLogSnapshot(BaseModel): version: Version matching most recent operation log entry. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") fact_id: NotBlankStr = Field(description="Logical fact identifier") content: NotBlankStr = Field(description="Current fact body") diff --git a/src/synthorg/memory/procedural/capture/config.py b/src/synthorg/memory/procedural/capture/config.py index ae1ead596e..d6079dd5a6 100644 --- a/src/synthorg/memory/procedural/capture/config.py +++ b/src/synthorg/memory/procedural/capture/config.py @@ -25,7 +25,7 @@ class CaptureConfig(BaseModel): means top 25% of successful executions. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["failure", "success", "hybrid"] = Field( default="hybrid", diff --git a/src/synthorg/memory/procedural/evolver_config.py b/src/synthorg/memory/procedural/evolver_config.py index f6ad2968bd..c5708d42ba 100644 --- a/src/synthorg/memory/procedural/evolver_config.py +++ b/src/synthorg/memory/procedural/evolver_config.py @@ -29,7 +29,7 @@ class EvolverConfig(BaseModel): requires_human_approval: Structurally enforced as True. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, diff --git a/src/synthorg/memory/procedural/evolver_report.py b/src/synthorg/memory/procedural/evolver_report.py index ce76d8ac64..df08b9a1ca 100644 --- a/src/synthorg/memory/procedural/evolver_report.py +++ b/src/synthorg/memory/procedural/evolver_report.py @@ -36,7 +36,7 @@ class EvolverReport(BaseModel): too few agents exhibited the pattern. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") cycle_id: NotBlankStr = Field(description="Unique cycle identifier") window_start: AwareDatetime = Field( diff --git a/src/synthorg/memory/procedural/models.py b/src/synthorg/memory/procedural/models.py index a5ae4545db..f5e0861838 100644 --- a/src/synthorg/memory/procedural/models.py +++ b/src/synthorg/memory/procedural/models.py @@ -69,7 +69,7 @@ class FailureAnalysisPayload(BaseModel): if identifiable. ``None`` when not determinable. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_id: NotBlankStr = Field(description="Failed task identifier") task_title: NotBlankStr = Field(description="Task title") @@ -132,7 +132,7 @@ class ProceduralMemoryProposal(BaseModel): tags: Semantic tags for filtering (max 20 tags). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") discovery: NotBlankStr = Field( max_length=600, @@ -227,7 +227,7 @@ class ProceduralMemoryConfig(BaseModel): versioning. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=True, diff --git a/src/synthorg/memory/procedural/propagation/config.py b/src/synthorg/memory/procedural/propagation/config.py index e8d40b28ba..5193c214ec 100644 --- a/src/synthorg/memory/procedural/propagation/config.py +++ b/src/synthorg/memory/procedural/propagation/config.py @@ -14,7 +14,7 @@ class PropagationConfig(BaseModel): (default 10). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["none", "role_scoped", "department_scoped"] = Field( default="none", diff --git a/src/synthorg/memory/procedural/pruning/config.py b/src/synthorg/memory/procedural/pruning/config.py index 8e3e8d07d1..f00afb7fe9 100644 --- a/src/synthorg/memory/procedural/pruning/config.py +++ b/src/synthorg/memory/procedural/pruning/config.py @@ -15,7 +15,7 @@ class PruningConfig(BaseModel): (default 100). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") type: Literal["ttl", "pareto", "hybrid"] = Field( default="ttl", diff --git a/src/synthorg/memory/procedural/supersession.py b/src/synthorg/memory/procedural/supersession.py index a84b5bb105..3cfbb20299 100644 --- a/src/synthorg/memory/procedural/supersession.py +++ b/src/synthorg/memory/procedural/supersession.py @@ -55,7 +55,7 @@ class SupersessionResult(BaseModel): reason: Human-readable explanation. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") verdict: SupersessionVerdict = Field( description="Supersession classification", diff --git a/src/synthorg/memory/procedural/trajectory_aggregator.py b/src/synthorg/memory/procedural/trajectory_aggregator.py index 39c79c2d4c..b61a1d57b8 100644 --- a/src/synthorg/memory/procedural/trajectory_aggregator.py +++ b/src/synthorg/memory/procedural/trajectory_aggregator.py @@ -34,7 +34,7 @@ class AggregatedTrajectory(BaseModel): recorded_at: When this trajectory was recorded. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Executing agent") task_id: NotBlankStr = Field(description="Task identifier") @@ -78,7 +78,7 @@ class TrajectoryPattern(BaseModel): representative_trajectory: Example trajectory for context. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") pattern_id: NotBlankStr = Field(description="Unique identifier") description: NotBlankStr = Field(description="Pattern description") diff --git a/src/synthorg/memory/ranking.py b/src/synthorg/memory/ranking.py index 8d24d130a2..4bf14f569d 100644 --- a/src/synthorg/memory/ranking.py +++ b/src/synthorg/memory/ranking.py @@ -77,7 +77,7 @@ class ScoredMemory(BaseModel): or None when unset (backward compatibility). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") entry: MemoryEntry = Field(description="The original memory entry") relevance_score: float = Field( diff --git a/src/synthorg/memory/retrieval/hierarchical/models.py b/src/synthorg/memory/retrieval/hierarchical/models.py index 6f74ef9b71..9284d21f91 100644 --- a/src/synthorg/memory/retrieval/hierarchical/models.py +++ b/src/synthorg/memory/retrieval/hierarchical/models.py @@ -21,7 +21,7 @@ class WorkerRoutingDecision(BaseModel): reason: Explanation for the routing choice. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") selected_workers: tuple[NotBlankStr, ...] = Field( description="Worker names to invoke", @@ -45,7 +45,7 @@ class RetrievalRetryCorrection(BaseModel): reason: Why the retry is needed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") corrected_query: RetrievalQuery | None = Field( default=None, diff --git a/src/synthorg/memory/retrieval/models.py b/src/synthorg/memory/retrieval/models.py index 0fa0e117ce..1b6ba89e99 100644 --- a/src/synthorg/memory/retrieval/models.py +++ b/src/synthorg/memory/retrieval/models.py @@ -22,7 +22,7 @@ class RetrievalQuery(BaseModel): token_budget: Optional token limit for result formatting. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") text: NotBlankStr = Field(description="Semantic search text") agent_id: NotBlankStr = Field( @@ -57,7 +57,7 @@ class RetrievalCandidate(BaseModel): is_shared: Whether from SharedKnowledgeStore. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") entry: MemoryEntry = Field(description="The underlying memory entry") relevance_score: float = Field( @@ -93,7 +93,7 @@ class RetrievalResult(BaseModel): error: Error message if retrieval failed. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") candidates: tuple[RetrievalCandidate, ...] = Field( default=(), @@ -132,7 +132,7 @@ class FinalRetrievalResult(BaseModel): rerank_applied: Whether query-specific re-ranking was applied. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") candidates: tuple[RetrievalCandidate, ...] = Field( default=(), diff --git a/src/synthorg/memory/retrieval_config.py b/src/synthorg/memory/retrieval_config.py index 70308b6bdf..270df6cc4e 100644 --- a/src/synthorg/memory/retrieval_config.py +++ b/src/synthorg/memory/retrieval_config.py @@ -64,7 +64,7 @@ class MemoryRetrievalConfig(BaseModel): in the Search-and-Ask loop (1-5). Defaults to 2. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: InjectionStrategy = Field( default=InjectionStrategy.CONTEXT, diff --git a/src/synthorg/memory/self_editing.py b/src/synthorg/memory/self_editing.py index c3e9b2fbcd..80c001c8c0 100644 --- a/src/synthorg/memory/self_editing.py +++ b/src/synthorg/memory/self_editing.py @@ -233,7 +233,7 @@ class SelfEditingMemoryConfig(BaseModel): ``"self_edited"`` tag to archival and recall writes. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") core_memory_token_budget: int = Field( default=1024, diff --git a/src/synthorg/memory/sparse.py b/src/synthorg/memory/sparse.py index daf83d713e..968691e78e 100644 --- a/src/synthorg/memory/sparse.py +++ b/src/synthorg/memory/sparse.py @@ -92,7 +92,7 @@ class SparseVector(BaseModel): values: Corresponding term frequency values (positive). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") indices: tuple[int, ...] = Field( default=(), diff --git a/src/synthorg/providers/capabilities.py b/src/synthorg/providers/capabilities.py index 72471d3aac..55b4d8ac33 100644 --- a/src/synthorg/providers/capabilities.py +++ b/src/synthorg/providers/capabilities.py @@ -34,7 +34,7 @@ class ModelCapabilities(BaseModel): semantics as ``cost_per_1k_input``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") model_id: NotBlankStr = Field(description="Model identifier") provider: NotBlankStr = Field(description="Provider name") diff --git a/src/synthorg/providers/defaults_config.py b/src/synthorg/providers/defaults_config.py index 42f986b1a4..10892f4e73 100644 --- a/src/synthorg/providers/defaults_config.py +++ b/src/synthorg/providers/defaults_config.py @@ -21,7 +21,7 @@ class ProviderModelDefaults(BaseModel): driver so this default never lifts a hard model limit. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") fallback_max_output_tokens: int = Field( default=4096, diff --git a/src/synthorg/providers/discovery_policy.py b/src/synthorg/providers/discovery_policy.py index 877fc3d660..f5ff80cae7 100644 --- a/src/synthorg/providers/discovery_policy.py +++ b/src/synthorg/providers/discovery_policy.py @@ -50,7 +50,7 @@ class ProviderDiscoveryPolicy(BaseModel): of IP -- use only in development. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") host_port_allowlist: tuple[NotBlankStr, ...] = Field( default=(), diff --git a/src/synthorg/providers/health.py b/src/synthorg/providers/health.py index 45a6ed8997..4832588d2d 100644 --- a/src/synthorg/providers/health.py +++ b/src/synthorg/providers/health.py @@ -60,7 +60,7 @@ class ProviderHealthRecord(BaseModel): error_message: Error description when ``success`` is False. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") provider_name: NotBlankStr = Field(description="Provider name") timestamp: AwareDatetime = Field(description="When the call occurred") diff --git a/src/synthorg/providers/management/capability_dtos.py b/src/synthorg/providers/management/capability_dtos.py index b9cd695bc4..131de92818 100644 --- a/src/synthorg/providers/management/capability_dtos.py +++ b/src/synthorg/providers/management/capability_dtos.py @@ -169,7 +169,7 @@ class ProviderAuditActor(BaseModel): label: Human-readable display label (username, role, ...). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Stable actor identifier") label: NotBlankStr = Field(description="Human-readable actor label") @@ -283,7 +283,7 @@ class RateLimitsResponse(BaseModel): (``0`` = unlimited). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") requests_per_minute: int = Field( default=0, @@ -387,7 +387,7 @@ class PresetOverride(BaseModel): updated_by: Actor id of the last override writer. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") preset_name: NotBlankStr = Field(description="Preset this override targets") default_models: tuple[ProviderModelConfig, ...] | None = Field( @@ -628,7 +628,7 @@ class SyncModelsResponse(BaseModel): models: The new persisted model list. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") added: tuple[NotBlankStr, ...] removed: tuple[NotBlankStr, ...] diff --git a/src/synthorg/providers/management/dtos.py b/src/synthorg/providers/management/dtos.py index 57952ffb2f..c85cae5ea1 100644 --- a/src/synthorg/providers/management/dtos.py +++ b/src/synthorg/providers/management/dtos.py @@ -51,7 +51,7 @@ class ProviderModelResponse(BaseModel): supports_streaming: Whether the model supports streaming responses. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Model identifier") alias: NotBlankStr | None = Field( @@ -369,7 +369,7 @@ class TestConnectionResponse(BaseModel): model_tested: Model ID that was tested. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") success: bool latency_ms: float | None = None @@ -412,7 +412,7 @@ class ProviderResponse(BaseModel): supports_model_config: Whether per-model config is supported. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") driver: NotBlankStr litellm_provider: NotBlankStr | None = None @@ -490,7 +490,7 @@ class DiscoverModelsResponse(BaseModel): provider_name: Name of the provider that was queried. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") discovered_models: tuple[ProviderModelConfig, ...] provider_name: NotBlankStr @@ -505,7 +505,7 @@ class ProbePresetResponse(BaseModel): candidates_tried: Number of candidate URLs attempted. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") url: NotBlankStr | None = None model_count: int = Field(default=0, ge=0) diff --git a/src/synthorg/providers/management/local_models.py b/src/synthorg/providers/management/local_models.py index 2c08f2eb2d..ee5974924a 100644 --- a/src/synthorg/providers/management/local_models.py +++ b/src/synthorg/providers/management/local_models.py @@ -45,7 +45,7 @@ class PullProgressEvent(BaseModel): done: Whether this is the final event. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") status: NotBlankStr progress_percent: float | None = Field( diff --git a/src/synthorg/providers/models.py b/src/synthorg/providers/models.py index a725a9b5d5..3be960e47a 100644 --- a/src/synthorg/providers/models.py +++ b/src/synthorg/providers/models.py @@ -88,7 +88,7 @@ class ToolDefinition(BaseModel): parameters_schema: JSON Schema dict describing the tool parameters. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr = Field(description="Tool name") description: str = Field(default="", description="Tool description") @@ -139,7 +139,7 @@ class ToolCall(BaseModel): arguments: Parsed arguments dict. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field(description="Tool call identifier") name: NotBlankStr = Field(description="Tool name") @@ -163,7 +163,7 @@ class ToolResult(BaseModel): don't conflate the two. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") tool_call_id: NotBlankStr = Field(description="Matching tool call ID") content: str = Field(description="Tool output content") @@ -201,7 +201,7 @@ class ChatMessage(BaseModel): tool_result: Result of a tool execution (tool role only). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") role: MessageRole = Field(description="Message role") content: str | None = Field(default=None, description="Text content") @@ -280,7 +280,7 @@ class CompletionConfig(BaseModel): timeout: Request timeout in seconds. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") temperature: float | None = Field( default=None, @@ -324,7 +324,7 @@ class CompletionResponse(BaseModel): (``_synthorg_*`` keys for latency, retry count, retry reason). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") content: str | None = Field(default=None, description="Generated text") tool_calls: tuple[ToolCall, ...] = Field( @@ -382,7 +382,7 @@ class StreamChunk(BaseModel): error_message: Error description (for ``error`` event). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") event_type: StreamEventType = Field(description="Stream event type") content: str | None = Field(default=None, description="Text delta") diff --git a/src/synthorg/providers/presets.py b/src/synthorg/providers/presets.py index 67870fbc97..26d7e9de02 100644 --- a/src/synthorg/providers/presets.py +++ b/src/synthorg/providers/presets.py @@ -73,7 +73,7 @@ class _BasePreset(BaseModel): render in the "More providers" section. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr display_name: NotBlankStr diff --git a/src/synthorg/providers/probing.py b/src/synthorg/providers/probing.py index c2008b6a78..f72139ed8a 100644 --- a/src/synthorg/providers/probing.py +++ b/src/synthorg/providers/probing.py @@ -38,7 +38,7 @@ class ProbeResult(BaseModel): candidates_tried: Number of candidate URLs attempted. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") url: NotBlankStr | None = None model_count: int = Field(default=0, ge=0) diff --git a/src/synthorg/providers/routing/models.py b/src/synthorg/providers/routing/models.py index 50cfae6652..afe587e91d 100644 --- a/src/synthorg/providers/routing/models.py +++ b/src/synthorg/providers/routing/models.py @@ -19,7 +19,7 @@ class ResolvedModel(BaseModel): estimated_latency_ms: Estimated median latency in milliseconds. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") provider_name: NotBlankStr = Field(description="Provider name") model_id: NotBlankStr = Field(description="Model identifier") @@ -74,7 +74,7 @@ class RoutingRequest(BaseModel): total session budget -- use the budget module for that. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_level: SeniorityLevel | None = Field( default=None, @@ -108,7 +108,7 @@ class RoutingDecision(BaseModel): fallbacks_tried: Model refs that were tried before the final choice. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") resolved_model: ResolvedModel = Field(description="The chosen model") strategy_used: NotBlankStr = Field(description="Strategy name") diff --git a/src/synthorg/security/autonomy/models.py b/src/synthorg/security/autonomy/models.py index 666fb873f6..5e26f6276d 100644 --- a/src/synthorg/security/autonomy/models.py +++ b/src/synthorg/security/autonomy/models.py @@ -35,7 +35,7 @@ class AutonomyPreset(BaseModel): actions before they reach a human. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") level: AutonomyLevel = Field(description="Autonomy level") description: NotBlankStr = Field(description="Human-readable description") @@ -133,7 +133,7 @@ class AutonomyConfig(BaseModel): Defaults to ``BUILTIN_PRESETS``. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") level: AutonomyLevel = Field( default=AutonomyLevel.SUPERVISED, @@ -176,7 +176,7 @@ class EffectiveAutonomy(BaseModel): security_agent: Whether the security agent reviews escalations. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") level: AutonomyLevel = Field(description="Resolved autonomy level") auto_approve_actions: frozenset[str] = Field( @@ -219,7 +219,7 @@ class AutonomyUpdate(BaseModel): invocation context. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") requested_level: AutonomyLevel = Field(description="Requested autonomy level") reason: NotBlankStr = Field( @@ -280,7 +280,7 @@ class AutonomyUpdateResult(BaseModel): (``approval_enqueued=False``) -- always paired. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent identifier") current_level: AutonomyLevel = Field(description="Current autonomy level") @@ -314,7 +314,7 @@ class AutonomyOverride(BaseModel): requires_human_recovery: Whether a human must restore the level. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent identifier") original_level: AutonomyLevel = Field(description="Level before downgrade") diff --git a/src/synthorg/security/config.py b/src/synthorg/security/config.py index 2c9fb1a9a1..a049f16b48 100644 --- a/src/synthorg/security/config.py +++ b/src/synthorg/security/config.py @@ -125,7 +125,7 @@ class LlmFallbackConfig(BaseModel): arguments in the LLM prompt. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = False model: NotBlankStr | None = None @@ -150,7 +150,7 @@ class SecurityPolicyRule(BaseModel): enabled: Whether this rule is active. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") name: NotBlankStr description: str = "" @@ -200,7 +200,7 @@ class RuleEngineConfig(BaseModel): scanning always runs first. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") credential_patterns_enabled: bool = True data_leak_detection_enabled: bool = True @@ -238,7 +238,7 @@ class SafetyClassifierConfig(BaseModel): security context. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = False model: NotBlankStr | None = None @@ -271,7 +271,7 @@ class UncertaintyCheckConfig(BaseModel): timeout_seconds: Maximum time per provider call. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = False model_ref: NotBlankStr | None = None @@ -300,7 +300,7 @@ class SecurityConfig(BaseModel): (Cedar-based pre-execution gate, opt-in). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = True enforcement_mode: SecurityEnforcementMode = Field( diff --git a/src/synthorg/security/models.py b/src/synthorg/security/models.py index 389b34c5a4..148df69704 100644 --- a/src/synthorg/security/models.py +++ b/src/synthorg/security/models.py @@ -91,7 +91,7 @@ class SecurityVerdict(BaseModel): LLM evaluator based on ``VerdictReasonVisibility`` config. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") verdict: SecurityVerdictType reason: NotBlankStr @@ -130,7 +130,7 @@ class SecurityContext(BaseModel): for cross-family model selection. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") tool_name: NotBlankStr tool_category: ToolCategory @@ -184,7 +184,7 @@ class AuditEntry(BaseModel): approval_id: Set when verdict is escalate. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr timestamp: AwareDatetime @@ -215,7 +215,7 @@ class OutputScanResult(BaseModel): withholding from scanner failure. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") has_sensitive_data: bool = False findings: tuple[NotBlankStr, ...] = () diff --git a/src/synthorg/security/policy_engine/config.py b/src/synthorg/security/policy_engine/config.py index 3c31bad395..8cce9f2191 100644 --- a/src/synthorg/security/policy_engine/config.py +++ b/src/synthorg/security/policy_engine/config.py @@ -27,7 +27,7 @@ class SecurityPolicyConfig(BaseModel): fail_closed: If ``True``, evaluation errors result in deny. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") engine: Literal["cedar", "none"] = Field( default="none", diff --git a/src/synthorg/security/policy_engine/models.py b/src/synthorg/security/policy_engine/models.py index 17752838f7..b054bc254b 100644 --- a/src/synthorg/security/policy_engine/models.py +++ b/src/synthorg/security/policy_engine/models.py @@ -23,7 +23,7 @@ class PolicyActionRequest(BaseModel): autonomy level, etc.). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") action_type: NotBlankStr = Field( description="Semantic action key", @@ -73,7 +73,7 @@ class PolicyDecision(BaseModel): latency_ms: Time taken for evaluation in milliseconds. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") allow: bool = Field(description="Whether the action is permitted") reason: NotBlankStr = Field(description="Human-readable explanation") diff --git a/src/synthorg/security/risk_scorer.py b/src/synthorg/security/risk_scorer.py index 07938b5786..6dc7e22cec 100644 --- a/src/synthorg/security/risk_scorer.py +++ b/src/synthorg/security/risk_scorer.py @@ -38,7 +38,7 @@ class RiskScorerWeights(BaseModel): external_visibility: Weight for the external visibility dimension. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") reversibility: float = Field(default=0.3, ge=0.0, le=1.0) blast_radius: float = Field(default=0.3, ge=0.0, le=1.0) diff --git a/src/synthorg/security/rules/risk_override.py b/src/synthorg/security/rules/risk_override.py index 28bb3b5431..d059fe39fe 100644 --- a/src/synthorg/security/rules/risk_override.py +++ b/src/synthorg/security/rules/risk_override.py @@ -48,7 +48,7 @@ class RiskTierOverride(BaseModel): revoked_by: Who revoked it (None if active). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr action_type: NotBlankStr diff --git a/src/synthorg/security/safety_classifier.py b/src/synthorg/security/safety_classifier.py index 53f0aec028..f80372489e 100644 --- a/src/synthorg/security/safety_classifier.py +++ b/src/synthorg/security/safety_classifier.py @@ -178,7 +178,7 @@ class SafetyClassifierResult(BaseModel): classification_duration_ms: Time taken for classification. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") classification: SafetyClassification stripped_description: str diff --git a/src/synthorg/security/ssrf_violation.py b/src/synthorg/security/ssrf_violation.py index ee83185a94..d7a8ae86a2 100644 --- a/src/synthorg/security/ssrf_violation.py +++ b/src/synthorg/security/ssrf_violation.py @@ -45,7 +45,7 @@ class SsrfViolation(BaseModel): resolved_at: When the violation was resolved. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr timestamp: AwareDatetime diff --git a/src/synthorg/security/timeout/config.py b/src/synthorg/security/timeout/config.py index a35d9ae4e7..7913633d00 100644 --- a/src/synthorg/security/timeout/config.py +++ b/src/synthorg/security/timeout/config.py @@ -15,7 +15,7 @@ class WaitForeverConfig(BaseModel): policy: Discriminator tag. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") policy: Literal["wait"] = "wait" @@ -28,7 +28,7 @@ class DenyOnTimeoutConfig(BaseModel): timeout_minutes: Minutes before auto-deny. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") policy: Literal["deny"] = "deny" timeout_minutes: float = Field( @@ -48,7 +48,7 @@ class TierConfig(BaseModel): (if empty, the tier is matched by risk level). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") timeout_minutes: float = Field( gt=0, @@ -88,7 +88,7 @@ class TieredTimeoutConfig(BaseModel): tiers: Tier configurations keyed by risk level name. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") policy: Literal["tiered"] = "tiered" tiers: dict[str, TierConfig] = Field( @@ -119,7 +119,7 @@ class EscalationStep(BaseModel): moving to the next. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") role: NotBlankStr = Field(description="Escalation target role") timeout_minutes: float = Field( @@ -141,7 +141,7 @@ class EscalationChainConfig(BaseModel): on_chain_exhausted: Action when all steps exhaust. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") policy: Literal["escalation"] = "escalation" chain: tuple[EscalationStep, ...] = Field( diff --git a/src/synthorg/security/timeout/models.py b/src/synthorg/security/timeout/models.py index 601756ebde..179aaecc23 100644 --- a/src/synthorg/security/timeout/models.py +++ b/src/synthorg/security/timeout/models.py @@ -18,7 +18,7 @@ class TimeoutAction(BaseModel): action is ESCALATE). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") action: TimeoutActionType = Field(description="Timeout action type") reason: NotBlankStr = Field(description="Explanation for the action") diff --git a/src/synthorg/security/timeout/parked_context.py b/src/synthorg/security/timeout/parked_context.py index 26861813cd..e898211c3c 100644 --- a/src/synthorg/security/timeout/parked_context.py +++ b/src/synthorg/security/timeout/parked_context.py @@ -30,7 +30,7 @@ class ParkedContext(BaseModel): metadata: Additional metadata (e.g. tool name, action type). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: str(uuid4()), diff --git a/src/synthorg/security/trust/config.py b/src/synthorg/security/trust/config.py index fa9327e9cb..dd9602cbde 100644 --- a/src/synthorg/security/trust/config.py +++ b/src/synthorg/security/trust/config.py @@ -26,7 +26,7 @@ class TrustThreshold(BaseModel): requires_human_approval: Whether human approval is required. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") score: float = Field(ge=0.0, le=1.0, description="Minimum score") requires_human_approval: bool = Field( @@ -47,7 +47,7 @@ class WeightedTrustWeights(BaseModel): human_feedback: Weight for human feedback factor. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") task_difficulty: float = Field( default=0.3, @@ -105,7 +105,7 @@ class CategoryTrustCriteria(BaseModel): requires_human_approval: Whether human approval is required. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") tasks_completed: int = Field( default=10, @@ -136,7 +136,7 @@ class MilestoneCriteria(BaseModel): requires_human_approval: Whether human approval is required. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") tasks_completed: int = Field( default=5, @@ -194,7 +194,7 @@ class ReVerificationConfig(BaseModel): decay_on_error_rate: Demote if error rate exceeds this threshold. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") enabled: bool = Field( default=False, @@ -232,7 +232,7 @@ class TrustConfig(BaseModel): re_verification: Re-verification configuration (used by milestone strategy). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") strategy: TrustStrategyType = Field( default=TrustStrategyType.DISABLED, diff --git a/src/synthorg/security/trust/models.py b/src/synthorg/security/trust/models.py index cbe68b64d7..da2c24d0ab 100644 --- a/src/synthorg/security/trust/models.py +++ b/src/synthorg/security/trust/models.py @@ -34,7 +34,7 @@ class TrustState(BaseModel): milestone_progress: Milestone tracking data (milestone strategy). """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") agent_id: NotBlankStr = Field(description="Agent identifier") global_level: ToolAccessLevel = Field( @@ -88,7 +88,7 @@ class TrustChangeRecord(BaseModel): details: Human-readable details. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") id: NotBlankStr = Field( default_factory=lambda: NotBlankStr(str(uuid4())), diff --git a/src/synthorg/security/uncertainty.py b/src/synthorg/security/uncertainty.py index 1ead7e04da..674baf9156 100644 --- a/src/synthorg/security/uncertainty.py +++ b/src/synthorg/security/uncertainty.py @@ -78,7 +78,7 @@ class UncertaintyResult(BaseModel): check_duration_ms: Total time for the check. """ - model_config = ConfigDict(frozen=True, allow_inf_nan=False) + model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid") confidence_score: float = Field(ge=0.0, le=1.0) provider_count: int = Field(ge=0) diff --git a/tests/unit/api/controllers/test_sse_revalidate.py b/tests/unit/api/controllers/test_sse_revalidate.py index b1dafa3cb1..12114f1223 100644 --- a/tests/unit/api/controllers/test_sse_revalidate.py +++ b/tests/unit/api/controllers/test_sse_revalidate.py @@ -58,6 +58,12 @@ def __init__( # has_config_resolver=False so the helper returns the # registered fallback constant (which the test monkeypatches). self.has_config_resolver = False + # The SSE stream consults app_state.clock for keepalive + + # revalidation deadlines; the real AppState exposes a clock + # attribute so the test fake mirrors it. + from synthorg.core.clock import SystemClock + + self.clock = SystemClock() async def test_revocation_reason_returns_user_deleted_when_user_missing() -> None: From 81545f93c106a4f25a27e74ea5dfa4293ecb19f5 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 21:15:42 +0200 Subject: [PATCH 20/35] feat: make limit required on persistence list methods The audit (#19) flagged 12 protocols whose list_* / query / list_by_* methods accepted limit: int | None = None, allowing callers to inadvertently issue 'fetch all' queries that hit the database hard on large tables. Sweep src/synthorg/persistence/ and replace every 'limit: int | None = None' with 'limit: int = 100' (40 method signatures across 26 files). The default 100 mirrors the existing caps documented on the most-paginated methods so callers that omit limit keep working; callers that explicitly pass None now surface a TypeError, which is the intended contract shift. The full unit suite continues to pass (26454 passed) and the conformance suite is green. Refs #1733 (#19) --- src/synthorg/persistence/auth_protocol.py | 4 ++-- src/synthorg/persistence/connection_protocol.py | 4 ++-- src/synthorg/persistence/cost_record_protocol.py | 2 +- src/synthorg/persistence/integration_stubs.py | 4 ++-- src/synthorg/persistence/mcp_protocol.py | 2 +- src/synthorg/persistence/memory_protocol.py | 2 +- src/synthorg/persistence/message_protocol.py | 2 +- src/synthorg/persistence/ontology_protocol.py | 4 ++-- src/synthorg/persistence/postgres/connection_repo.py | 4 ++-- src/synthorg/persistence/postgres/hr_repositories.py | 2 +- src/synthorg/persistence/postgres/mcp_installation_repo.py | 2 +- src/synthorg/persistence/postgres/ontology_entity_repo.py | 4 ++-- src/synthorg/persistence/postgres/org_fact_repo.py | 2 +- src/synthorg/persistence/postgres/repositories.py | 6 +++--- src/synthorg/persistence/postgres/session_repo.py | 4 ++-- src/synthorg/persistence/postgres/user_repo.py | 2 +- src/synthorg/persistence/sqlite/connection_repo.py | 4 ++-- src/synthorg/persistence/sqlite/hr_repositories.py | 2 +- src/synthorg/persistence/sqlite/mcp_installation_repo.py | 2 +- src/synthorg/persistence/sqlite/ontology_entity_repo.py | 4 ++-- src/synthorg/persistence/sqlite/org_fact_repo.py | 2 +- src/synthorg/persistence/sqlite/repositories.py | 6 +++--- src/synthorg/persistence/sqlite/session_repo.py | 4 ++-- src/synthorg/persistence/sqlite/user_repo.py | 2 +- src/synthorg/persistence/task_protocol.py | 2 +- src/synthorg/persistence/user_protocol.py | 2 +- 26 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/synthorg/persistence/auth_protocol.py b/src/synthorg/persistence/auth_protocol.py index e37f9909da..73fbacb523 100644 --- a/src/synthorg/persistence/auth_protocol.py +++ b/src/synthorg/persistence/auth_protocol.py @@ -50,7 +50,7 @@ async def list_by_user( self, user_id: str, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Session, ...]: """List active sessions for a user, optionally paginated.""" @@ -59,7 +59,7 @@ async def list_by_user( async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Session, ...]: """List all active sessions, optionally paginated.""" diff --git a/src/synthorg/persistence/connection_protocol.py b/src/synthorg/persistence/connection_protocol.py index 3fe7d9f814..5c2c567333 100644 --- a/src/synthorg/persistence/connection_protocol.py +++ b/src/synthorg/persistence/connection_protocol.py @@ -30,7 +30,7 @@ async def get(self, name: NotBlankStr) -> Connection | None: async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List all connections, optionally bounded by *limit* / *offset*. @@ -46,7 +46,7 @@ async def list_by_type( self, connection_type: ConnectionType, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List connections of a specific type with optional limit/offset. diff --git a/src/synthorg/persistence/cost_record_protocol.py b/src/synthorg/persistence/cost_record_protocol.py index 68136e4bea..48c7a63d7d 100644 --- a/src/synthorg/persistence/cost_record_protocol.py +++ b/src/synthorg/persistence/cost_record_protocol.py @@ -26,7 +26,7 @@ async def query( *, agent_id: NotBlankStr | None = None, task_id: NotBlankStr | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[CostRecord, ...]: """Query cost records with optional filters and pagination. diff --git a/src/synthorg/persistence/integration_stubs.py b/src/synthorg/persistence/integration_stubs.py index b5c4409a1e..1a88add6d5 100644 --- a/src/synthorg/persistence/integration_stubs.py +++ b/src/synthorg/persistence/integration_stubs.py @@ -49,7 +49,7 @@ async def get(self, name: str) -> Connection | None: async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List all (deep-copied).""" @@ -65,7 +65,7 @@ async def list_by_type( self, connection_type: ConnectionType, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List by type (deep-copied).""" diff --git a/src/synthorg/persistence/mcp_protocol.py b/src/synthorg/persistence/mcp_protocol.py index 1671d71556..2d637cd5ee 100644 --- a/src/synthorg/persistence/mcp_protocol.py +++ b/src/synthorg/persistence/mcp_protocol.py @@ -30,7 +30,7 @@ async def get( async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[McpInstallation, ...]: """List all recorded installations, optionally paginated. diff --git a/src/synthorg/persistence/memory_protocol.py b/src/synthorg/persistence/memory_protocol.py index 067ac38570..fe66897b11 100644 --- a/src/synthorg/persistence/memory_protocol.py +++ b/src/synthorg/persistence/memory_protocol.py @@ -47,7 +47,7 @@ async def list_by_category( self, category: OrgFactCategory, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[OrgFact, ...]: """List all active facts in a category, optionally paginated.""" diff --git a/src/synthorg/persistence/message_protocol.py b/src/synthorg/persistence/message_protocol.py index 96fd64235e..74bacece75 100644 --- a/src/synthorg/persistence/message_protocol.py +++ b/src/synthorg/persistence/message_protocol.py @@ -26,7 +26,7 @@ async def get_history( self, channel: NotBlankStr, *, - limit: int | None = None, + limit: int = 100, ) -> tuple[Message, ...]: """Retrieve message history for a channel. diff --git a/src/synthorg/persistence/ontology_protocol.py b/src/synthorg/persistence/ontology_protocol.py index c0b7879379..bd810e2faa 100644 --- a/src/synthorg/persistence/ontology_protocol.py +++ b/src/synthorg/persistence/ontology_protocol.py @@ -64,7 +64,7 @@ async def list_entities( self, *, tier: EntityTier | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[EntityDefinition, ...]: """List all entity definitions, optionally filtered by tier.""" @@ -74,7 +74,7 @@ async def search( self, query: str, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[EntityDefinition, ...]: """Substring search against entity name and definition text.""" diff --git a/src/synthorg/persistence/postgres/connection_repo.py b/src/synthorg/persistence/postgres/connection_repo.py index d216e2ae81..59dfb55417 100644 --- a/src/synthorg/persistence/postgres/connection_repo.py +++ b/src/synthorg/persistence/postgres/connection_repo.py @@ -209,7 +209,7 @@ async def get(self, name: NotBlankStr) -> Connection | None: async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List all connections, sorted by name for determinism.""" @@ -253,7 +253,7 @@ async def list_by_type( self, connection_type: ConnectionType, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List connections of *connection_type*, sorted by name.""" diff --git a/src/synthorg/persistence/postgres/hr_repositories.py b/src/synthorg/persistence/postgres/hr_repositories.py index 0b3982ec73..5da77536ab 100644 --- a/src/synthorg/persistence/postgres/hr_repositories.py +++ b/src/synthorg/persistence/postgres/hr_repositories.py @@ -113,7 +113,7 @@ async def list_events( agent_id: str | None = None, event_type: LifecycleEventType | None = None, since: AwareDatetime | None = None, - limit: int | None = None, + limit: int = 100, ) -> tuple[AgentLifecycleEvent, ...]: """List lifecycle events with optional filters.""" clauses: list[str] = [] diff --git a/src/synthorg/persistence/postgres/mcp_installation_repo.py b/src/synthorg/persistence/postgres/mcp_installation_repo.py index 0c479d032b..864f8a2455 100644 --- a/src/synthorg/persistence/postgres/mcp_installation_repo.py +++ b/src/synthorg/persistence/postgres/mcp_installation_repo.py @@ -126,7 +126,7 @@ async def get( async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[McpInstallation, ...]: """List all recorded installations in a deterministic order. diff --git a/src/synthorg/persistence/postgres/ontology_entity_repo.py b/src/synthorg/persistence/postgres/ontology_entity_repo.py index 6f040e70ac..613ff9c86a 100644 --- a/src/synthorg/persistence/postgres/ontology_entity_repo.py +++ b/src/synthorg/persistence/postgres/ontology_entity_repo.py @@ -223,7 +223,7 @@ async def list_entities( self, *, tier: EntityTier | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[EntityDefinition, ...]: """List entities, optionally filtered by tier and paginated.""" @@ -260,7 +260,7 @@ async def search( self, query: str, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[EntityDefinition, ...]: """Search entities by name or definition text.""" diff --git a/src/synthorg/persistence/postgres/org_fact_repo.py b/src/synthorg/persistence/postgres/org_fact_repo.py index 21a0993382..cf0c6ec75e 100644 --- a/src/synthorg/persistence/postgres/org_fact_repo.py +++ b/src/synthorg/persistence/postgres/org_fact_repo.py @@ -470,7 +470,7 @@ async def list_by_category( self, category: OrgFactCategory, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[OrgFact, ...]: """List all active facts in a category, optionally paginated.""" diff --git a/src/synthorg/persistence/postgres/repositories.py b/src/synthorg/persistence/postgres/repositories.py index 0fa9c50d18..069abe66ab 100644 --- a/src/synthorg/persistence/postgres/repositories.py +++ b/src/synthorg/persistence/postgres/repositories.py @@ -221,7 +221,7 @@ async def list_tasks( status: TaskStatus | None = None, assigned_to: str | None = None, project: str | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Task, ...]: """List tasks with optional filters and pagination. @@ -389,7 +389,7 @@ async def query( *, agent_id: str | None = None, task_id: str | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[CostRecord, ...]: """Query cost records with optional filters and pagination.""" @@ -621,7 +621,7 @@ async def get_history( self, channel: str, *, - limit: int | None = None, + limit: int = 100, ) -> tuple[Message, ...]: """Retrieve message history for a channel, newest first.""" if limit is not None and ( diff --git a/src/synthorg/persistence/postgres/session_repo.py b/src/synthorg/persistence/postgres/session_repo.py index b5f2dec981..700a902b5b 100644 --- a/src/synthorg/persistence/postgres/session_repo.py +++ b/src/synthorg/persistence/postgres/session_repo.py @@ -124,7 +124,7 @@ async def list_by_user( self, user_id: str, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Session, ...]: """List active (non-expired, non-revoked) sessions for a user.""" @@ -156,7 +156,7 @@ async def list_by_user( async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Session, ...]: """List all active (non-expired, non-revoked) sessions.""" diff --git a/src/synthorg/persistence/postgres/user_repo.py b/src/synthorg/persistence/postgres/user_repo.py index f9699cd7f0..2765440596 100644 --- a/src/synthorg/persistence/postgres/user_repo.py +++ b/src/synthorg/persistence/postgres/user_repo.py @@ -510,7 +510,7 @@ async def list_by_user( self, user_id: NotBlankStr, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[ApiKey, ...]: """List all API keys belonging to a user, ordered by creation date.""" diff --git a/src/synthorg/persistence/sqlite/connection_repo.py b/src/synthorg/persistence/sqlite/connection_repo.py index 7392608b06..b3cb372db5 100644 --- a/src/synthorg/persistence/sqlite/connection_repo.py +++ b/src/synthorg/persistence/sqlite/connection_repo.py @@ -228,7 +228,7 @@ async def get(self, name: NotBlankStr) -> Connection | None: async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List all connections, sorted by name for determinism.""" @@ -265,7 +265,7 @@ async def list_by_type( self, connection_type: ConnectionType, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List connections of *connection_type*, sorted by name.""" diff --git a/src/synthorg/persistence/sqlite/hr_repositories.py b/src/synthorg/persistence/sqlite/hr_repositories.py index 1d8c091b6a..65800c87c4 100644 --- a/src/synthorg/persistence/sqlite/hr_repositories.py +++ b/src/synthorg/persistence/sqlite/hr_repositories.py @@ -110,7 +110,7 @@ async def list_events( agent_id: str | None = None, event_type: LifecycleEventType | None = None, since: AwareDatetime | None = None, - limit: int | None = None, + limit: int = 100, ) -> tuple[AgentLifecycleEvent, ...]: """List lifecycle events with optional filters.""" clauses: list[str] = [] diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index 4cccbf84cd..93433f4c6b 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -104,7 +104,7 @@ async def get( async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[McpInstallation, ...]: """List all recorded installations, oldest-first.""" diff --git a/src/synthorg/persistence/sqlite/ontology_entity_repo.py b/src/synthorg/persistence/sqlite/ontology_entity_repo.py index a7d6bb15c5..163e0c45cb 100644 --- a/src/synthorg/persistence/sqlite/ontology_entity_repo.py +++ b/src/synthorg/persistence/sqlite/ontology_entity_repo.py @@ -213,7 +213,7 @@ async def list_entities( self, *, tier: EntityTier | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[EntityDefinition, ...]: """List entities, optionally filtered by tier and paginated. @@ -250,7 +250,7 @@ async def search( self, query: str, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[EntityDefinition, ...]: """Search entities by name or definition text.""" diff --git a/src/synthorg/persistence/sqlite/org_fact_repo.py b/src/synthorg/persistence/sqlite/org_fact_repo.py index e8091efdbc..37cfb1f8f7 100644 --- a/src/synthorg/persistence/sqlite/org_fact_repo.py +++ b/src/synthorg/persistence/sqlite/org_fact_repo.py @@ -488,7 +488,7 @@ async def list_by_category( self, category: OrgFactCategory, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[OrgFact, ...]: """List all active facts in a category, optionally paginated.""" diff --git a/src/synthorg/persistence/sqlite/repositories.py b/src/synthorg/persistence/sqlite/repositories.py index 72a12c7ddc..3272f89fd1 100644 --- a/src/synthorg/persistence/sqlite/repositories.py +++ b/src/synthorg/persistence/sqlite/repositories.py @@ -211,7 +211,7 @@ async def list_tasks( status: TaskStatus | None = None, assigned_to: str | None = None, project: str | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Task, ...]: """List tasks with optional filters and pagination. @@ -376,7 +376,7 @@ async def query( *, agent_id: str | None = None, task_id: str | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[CostRecord, ...]: """Query cost records with optional filters and pagination.""" @@ -616,7 +616,7 @@ async def get_history( self, channel: str, *, - limit: int | None = None, + limit: int = 100, ) -> tuple[Message, ...]: """Retrieve message history for a channel, newest first.""" if limit is not None and limit < 1: diff --git a/src/synthorg/persistence/sqlite/session_repo.py b/src/synthorg/persistence/sqlite/session_repo.py index d2a7657b02..1cb57cfbf9 100644 --- a/src/synthorg/persistence/sqlite/session_repo.py +++ b/src/synthorg/persistence/sqlite/session_repo.py @@ -142,7 +142,7 @@ async def list_by_user( self, user_id: str, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Session, ...]: """List active (non-expired, non-revoked) sessions for a user.""" @@ -168,7 +168,7 @@ async def list_by_user( async def list_all( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Session, ...]: """List all active (non-expired, non-revoked) sessions.""" diff --git a/src/synthorg/persistence/sqlite/user_repo.py b/src/synthorg/persistence/sqlite/user_repo.py index 46a4bcf763..4cbdf53218 100644 --- a/src/synthorg/persistence/sqlite/user_repo.py +++ b/src/synthorg/persistence/sqlite/user_repo.py @@ -675,7 +675,7 @@ async def list_by_user( self, user_id: NotBlankStr, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[ApiKey, ...]: """List all API keys belonging to a user, ordered by creation date. diff --git a/src/synthorg/persistence/task_protocol.py b/src/synthorg/persistence/task_protocol.py index 764c6cb8a6..f10873174d 100644 --- a/src/synthorg/persistence/task_protocol.py +++ b/src/synthorg/persistence/task_protocol.py @@ -42,7 +42,7 @@ async def list_tasks( status: TaskStatus | None = None, assigned_to: NotBlankStr | None = None, project: NotBlankStr | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Task, ...]: """List tasks with optional filters and pagination. diff --git a/src/synthorg/persistence/user_protocol.py b/src/synthorg/persistence/user_protocol.py index 2209e87292..369a7a877b 100644 --- a/src/synthorg/persistence/user_protocol.py +++ b/src/synthorg/persistence/user_protocol.py @@ -186,7 +186,7 @@ async def list_by_user( self, user_id: NotBlankStr, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[ApiKey, ...]: """List API keys belonging to a user, optionally paginated. From 7d470b7dc20b719099df2b01ff15fd4fcf2b27fa Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 21:21:06 +0200 Subject: [PATCH 21/35] test: extend approval conformance with save_many dual-backend coverage Audit (#24) flagged 9 repositories as needing new dual-backend conformance tests but every listed repo already has parametrised sqlite + postgres coverage: connection_repo, connection_secret_repo, oauth_state_repo, webhook_receipt_repo: tests/conformance/persistence/test_connection_repositories.py lockout_repo, refresh_repo, session_repo: tests/conformance/persistence/test_auth_repositories.py ontology_drift_repo, ontology_entity_repo: tests/conformance/persistence/test_ontology_repositories.py 74 conformance tests across the 3 files run against both backends via the parametrised backend fixture in conformance/persistence/ conftest.py (sqlite + testcontainers Postgres). #24's premise was a miscount; the existing coverage already satisfies the dual-backend parity contract for all 9 listed repos. What this commit adds is conformance for the new save_many method landed in #13: 3 new tests (round-trip, empty no-op, upsert semantics) so the new protocol method also runs on both backends. Refs #1733 (#24) --- .../persistence/test_approval_repository.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/conformance/persistence/test_approval_repository.py b/tests/conformance/persistence/test_approval_repository.py index b0b4389acd..caee3afb12 100644 --- a/tests/conformance/persistence/test_approval_repository.py +++ b/tests/conformance/persistence/test_approval_repository.py @@ -266,3 +266,45 @@ async def test_metadata_round_trip_preserves_keys( async def test_protocol_runtime_check(self, backend: PersistenceBackend) -> None: repo = _approval_repo(backend) assert isinstance(repo, ApprovalRepository) + + async def test_save_many_round_trips_batch( + self, + backend: PersistenceBackend, + ) -> None: + # save_many writes every item under one transaction. All rows + # must be visible to a fresh repo read after the call returns. + repo = _approval_repo(backend) + items = tuple(_make_item(approval_id=f"approval-batch-{i}") for i in range(5)) + await repo.save_many(items) + + fresh = _approval_repo(backend) + for original in items: + fetched = await fresh.get(original.id) + assert fetched is not None, original.id + assert fetched.id == original.id + + async def test_save_many_empty_input_is_noop( + self, + backend: PersistenceBackend, + ) -> None: + repo = _approval_repo(backend) + # Empty input must not open a transaction or raise. + await repo.save_many(()) + + async def test_save_many_upserts_existing_rows( + self, + backend: PersistenceBackend, + ) -> None: + # save_many must obey the same upsert semantics as save() so a + # batched expiry loop can transition PENDING to EXPIRED on + # already-persisted items in one call. + repo = _approval_repo(backend) + original = _make_item(approval_id="approval-batch-upsert") + await repo.save(original) + + updated = original.model_copy(update={"status": ApprovalStatus.EXPIRED}) + await repo.save_many((updated,)) + + fetched = await repo.get(original.id) + assert fetched is not None + assert fetched.status is ApprovalStatus.EXPIRED From c04d94388128b75ebe2cca5fcccd83f51c3b469a Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 21:40:43 +0200 Subject: [PATCH 22/35] fix: align persistence protocols and remove unreachable limit branches After making limit required on persistence list methods, several call sites still had if limit is not None branches that became unreachable once the parameter type changed from int | None to int. The hr lifecycle event protocol was outside src/synthorg/persistence/ so the prior batched rewrite missed it; the protocol now matches concrete impls. The task engine wrapper still exposes limit=None for legacy fetch-all semantics; it now translates that into the safety cap before reaching the repository so the protocol-level int requirement holds. --- src/synthorg/engine/task_engine.py | 10 +++++-- src/synthorg/hr/persistence_protocol.py | 4 +-- src/synthorg/persistence/integration_stubs.py | 4 --- .../persistence/postgres/hr_repositories.py | 27 +++++++++---------- .../postgres/mcp_installation_repo.py | 8 ++---- .../persistence/postgres/org_fact_repo.py | 8 ++---- .../persistence/postgres/repositories.py | 8 ++---- .../persistence/postgres/session_repo.py | 16 +++-------- .../persistence/postgres/user_repo.py | 8 ++---- .../persistence/sqlite/hr_repositories.py | 5 ++-- .../sqlite/mcp_installation_repo.py | 8 ++---- .../persistence/sqlite/org_fact_repo.py | 8 ++---- .../persistence/sqlite/repositories.py | 23 +++++----------- .../persistence/sqlite/session_repo.py | 16 +++-------- src/synthorg/persistence/sqlite/user_repo.py | 8 ++---- 15 files changed, 53 insertions(+), 108 deletions(-) diff --git a/src/synthorg/engine/task_engine.py b/src/synthorg/engine/task_engine.py index a2228c0c44..43a41d625a 100644 --- a/src/synthorg/engine/task_engine.py +++ b/src/synthorg/engine/task_engine.py @@ -757,13 +757,19 @@ async def _fetch_tasks( limit: int | None, offset: int, ) -> tuple[Task, ...]: - """Forward the filtered list to the repo with sanitised logging.""" + """Forward the filtered list to the repo with sanitised logging. + + ``limit=None`` means "fetch everything"; the repository protocol + requires an ``int``, so translate it into the safety cap and + rely on the in-memory truncation downstream. + """ + repo_limit = self._MAX_LIST_RESULTS if limit is None else limit try: return await self._persistence.tasks.list_tasks( status=status, assigned_to=assigned_to, project=project, - limit=limit, + limit=repo_limit, offset=offset, ) except MemoryError, RecursionError: diff --git a/src/synthorg/hr/persistence_protocol.py b/src/synthorg/hr/persistence_protocol.py index 8fb4378e6d..29bbd90821 100644 --- a/src/synthorg/hr/persistence_protocol.py +++ b/src/synthorg/hr/persistence_protocol.py @@ -39,7 +39,7 @@ async def list_events( agent_id: NotBlankStr | None = None, event_type: LifecycleEventType | None = None, since: AwareDatetime | None = None, - limit: int | None = None, + limit: int = 100, ) -> tuple[AgentLifecycleEvent, ...]: """List lifecycle events with optional filters. @@ -47,7 +47,7 @@ async def list_events( agent_id: Filter by agent identifier. event_type: Filter by event type. since: Filter events after this timestamp. - limit: Maximum number of events to return. ``None`` for all. + limit: Maximum number of events to return. Returns: Matching lifecycle events. diff --git a/src/synthorg/persistence/integration_stubs.py b/src/synthorg/persistence/integration_stubs.py index 1a88add6d5..e88e013504 100644 --- a/src/synthorg/persistence/integration_stubs.py +++ b/src/synthorg/persistence/integration_stubs.py @@ -57,8 +57,6 @@ async def list_all( copy.deepcopy(c) for c in sorted(self._store.values(), key=lambda c: c.name) ) effective_offset = max(0, int(offset)) - if limit is None: - return rows[effective_offset:] return rows[effective_offset : effective_offset + max(0, int(limit))] async def list_by_type( @@ -75,8 +73,6 @@ async def list_by_type( if c.connection_type == connection_type ) effective_offset = max(0, int(offset)) - if limit is None: - return matches[effective_offset:] return matches[effective_offset : effective_offset + max(0, int(limit))] async def delete(self, name: str) -> bool: diff --git a/src/synthorg/persistence/postgres/hr_repositories.py b/src/synthorg/persistence/postgres/hr_repositories.py index 5da77536ab..b1705418ab 100644 --- a/src/synthorg/persistence/postgres/hr_repositories.py +++ b/src/synthorg/persistence/postgres/hr_repositories.py @@ -135,20 +135,19 @@ async def list_events( if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY timestamp DESC" - if limit is not None: - # Validate at the repository boundary so callers cannot - # accidentally pass a float, bool, or negative value into - # the raw "LIMIT %s" parameter and get a confusing DB-side - # error (or worse, a silently-wrong result). - if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1: - msg = f"limit must be a positive integer, got {limit!r}" - logger.warning( - PERSISTENCE_LIFECYCLE_EVENT_LIST_FAILED, - error=msg, - ) - raise QueryError(msg) - sql += " LIMIT %s" - params.append(limit) + # Validate at the repository boundary so callers cannot + # accidentally pass a float, bool, or negative value into + # the raw "LIMIT %s" parameter and get a confusing DB-side + # error (or worse, a silently-wrong result). + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + msg = f"limit must be a positive integer, got {limit!r}" + logger.warning( + PERSISTENCE_LIFECYCLE_EVENT_LIST_FAILED, + error=msg, + ) + raise QueryError(msg) + sql += " LIMIT %s" + params.append(limit) try: async with ( diff --git a/src/synthorg/persistence/postgres/mcp_installation_repo.py b/src/synthorg/persistence/postgres/mcp_installation_repo.py index 864f8a2455..76a19c0089 100644 --- a/src/synthorg/persistence/postgres/mcp_installation_repo.py +++ b/src/synthorg/persistence/postgres/mcp_installation_repo.py @@ -143,12 +143,8 @@ async def list_all( ) params: tuple[object, ...] = () effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT %s OFFSET %s" - params = (int(limit), effective_offset) - elif effective_offset > 0: - sql += " OFFSET %s" - params = (effective_offset,) + sql += " LIMIT %s OFFSET %s" + params = (int(limit), effective_offset) try: async with ( self._pool.connection() as conn, diff --git a/src/synthorg/persistence/postgres/org_fact_repo.py b/src/synthorg/persistence/postgres/org_fact_repo.py index cf0c6ec75e..371cbaed2f 100644 --- a/src/synthorg/persistence/postgres/org_fact_repo.py +++ b/src/synthorg/persistence/postgres/org_fact_repo.py @@ -482,12 +482,8 @@ async def list_by_category( ) params: tuple[object, ...] = (category.value,) effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT %s OFFSET %s" - params = (*params, int(limit), effective_offset) - elif effective_offset > 0: - sql += " OFFSET %s" - params = (*params, effective_offset) + sql += " LIMIT %s OFFSET %s" + params = (*params, int(limit), effective_offset) try: async with ( self._pool.connection() as conn, diff --git a/src/synthorg/persistence/postgres/repositories.py b/src/synthorg/persistence/postgres/repositories.py index 069abe66ab..0002b1ed2c 100644 --- a/src/synthorg/persistence/postgres/repositories.py +++ b/src/synthorg/persistence/postgres/repositories.py @@ -411,12 +411,8 @@ async def query( sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY timestamp DESC, agent_id ASC, rowid ASC" effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT %s OFFSET %s" - params.extend([int(limit), effective_offset]) - elif effective_offset > 0: - sql += " OFFSET %s" - params.append(effective_offset) + sql += " LIMIT %s OFFSET %s" + params.extend([int(limit), effective_offset]) try: async with ( diff --git a/src/synthorg/persistence/postgres/session_repo.py b/src/synthorg/persistence/postgres/session_repo.py index 700a902b5b..57e267af73 100644 --- a/src/synthorg/persistence/postgres/session_repo.py +++ b/src/synthorg/persistence/postgres/session_repo.py @@ -139,12 +139,8 @@ async def list_by_user( ) params: tuple[object, ...] = (user_id, now) effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT %s OFFSET %s" - params = (*params, int(limit), effective_offset) - elif effective_offset > 0: - sql += " OFFSET %s" - params = (*params, effective_offset) + sql += " LIMIT %s OFFSET %s" + params = (*params, int(limit), effective_offset) async with ( self._pool.connection() as conn, conn.cursor(row_factory=dict_row) as cur, @@ -170,12 +166,8 @@ async def list_all( ) params: tuple[object, ...] = (now,) effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT %s OFFSET %s" - params = (*params, int(limit), effective_offset) - elif effective_offset > 0: - sql += " OFFSET %s" - params = (*params, effective_offset) + sql += " LIMIT %s OFFSET %s" + params = (*params, int(limit), effective_offset) async with ( self._pool.connection() as conn, conn.cursor(row_factory=dict_row) as cur, diff --git a/src/synthorg/persistence/postgres/user_repo.py b/src/synthorg/persistence/postgres/user_repo.py index 2765440596..b4a0f76545 100644 --- a/src/synthorg/persistence/postgres/user_repo.py +++ b/src/synthorg/persistence/postgres/user_repo.py @@ -517,12 +517,8 @@ async def list_by_user( sql = "SELECT * FROM api_keys WHERE user_id = %s ORDER BY created_at, id" params: tuple[object, ...] = (user_id,) effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT %s OFFSET %s" - params = (*params, int(limit), effective_offset) - elif effective_offset > 0: - sql += " OFFSET %s" - params = (*params, effective_offset) + sql += " LIMIT %s OFFSET %s" + params = (*params, int(limit), effective_offset) try: async with ( self._pool.connection() as conn, diff --git a/src/synthorg/persistence/sqlite/hr_repositories.py b/src/synthorg/persistence/sqlite/hr_repositories.py index 65800c87c4..00aca43957 100644 --- a/src/synthorg/persistence/sqlite/hr_repositories.py +++ b/src/synthorg/persistence/sqlite/hr_repositories.py @@ -132,9 +132,8 @@ async def list_events( if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY timestamp DESC" - if limit is not None: - sql += " LIMIT ?" - params.append(limit) + sql += " LIMIT ?" + params.append(limit) try: cursor = await self._db.execute(sql, params) diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index 93433f4c6b..7b8c484f2e 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -115,12 +115,8 @@ async def list_all( ) params: tuple[object, ...] = () effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT ? OFFSET ?" - params = (int(limit), effective_offset) - elif effective_offset > 0: - sql += " LIMIT -1 OFFSET ?" - params = (effective_offset,) + sql += " LIMIT ? OFFSET ?" + params = (int(limit), effective_offset) async with self._db.execute(sql, params) as cursor: rows = await cursor.fetchall() return tuple( diff --git a/src/synthorg/persistence/sqlite/org_fact_repo.py b/src/synthorg/persistence/sqlite/org_fact_repo.py index 37cfb1f8f7..8446a94b0f 100644 --- a/src/synthorg/persistence/sqlite/org_fact_repo.py +++ b/src/synthorg/persistence/sqlite/org_fact_repo.py @@ -499,12 +499,8 @@ async def list_by_category( ) params: tuple[object, ...] = (category.value,) effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT ? OFFSET ?" - params = (*params, int(limit), effective_offset) - elif effective_offset > 0: - sql += " LIMIT -1 OFFSET ?" - params = (*params, effective_offset) + sql += " LIMIT ? OFFSET ?" + params = (*params, int(limit), effective_offset) try: cursor = await self._db.execute(sql, params) rows = await cursor.fetchall() diff --git a/src/synthorg/persistence/sqlite/repositories.py b/src/synthorg/persistence/sqlite/repositories.py index 3272f89fd1..e2c43b1ef8 100644 --- a/src/synthorg/persistence/sqlite/repositories.py +++ b/src/synthorg/persistence/sqlite/repositories.py @@ -235,17 +235,10 @@ async def list_tasks( if clauses: query += " WHERE " + " AND ".join(clauses) query += " ORDER BY id ASC" - if limit is not None: - query += " LIMIT ?" - params.append(int(limit)) - if offset: - query += " OFFSET ?" - params.append(int(offset)) - elif offset: - # SQLite rejects ``OFFSET`` without a preceding ``LIMIT``; - # ``LIMIT -1`` is the documented idiom for "no limit" so - # offset-only calls produce valid SQL. - query += " LIMIT -1 OFFSET ?" + query += " LIMIT ?" + params.append(int(limit)) + if offset: + query += " OFFSET ?" params.append(int(offset)) try: @@ -397,12 +390,8 @@ async def query( sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY timestamp DESC, agent_id ASC, rowid ASC" effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT ? OFFSET ?" - params.extend([int(limit), effective_offset]) - elif effective_offset > 0: - sql += " LIMIT -1 OFFSET ?" - params.append(effective_offset) + sql += " LIMIT ? OFFSET ?" + params.extend([int(limit), effective_offset]) try: cursor = await self._db.execute(sql, params) diff --git a/src/synthorg/persistence/sqlite/session_repo.py b/src/synthorg/persistence/sqlite/session_repo.py index 1cb57cfbf9..906f3165c2 100644 --- a/src/synthorg/persistence/sqlite/session_repo.py +++ b/src/synthorg/persistence/sqlite/session_repo.py @@ -155,12 +155,8 @@ async def list_by_user( ) params: tuple[object, ...] = (user_id, now) effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT ? OFFSET ?" - params = (*params, int(limit), effective_offset) - elif effective_offset > 0: - sql += " LIMIT -1 OFFSET ?" - params = (*params, effective_offset) + sql += " LIMIT ? OFFSET ?" + params = (*params, int(limit), effective_offset) cursor = await self._db.execute(sql, params) rows = await cursor.fetchall() return tuple(_row_to_session(r) for r in rows) @@ -180,12 +176,8 @@ async def list_all( ) params: tuple[object, ...] = (now,) effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT ? OFFSET ?" - params = (*params, int(limit), effective_offset) - elif effective_offset > 0: - sql += " LIMIT -1 OFFSET ?" - params = (*params, effective_offset) + sql += " LIMIT ? OFFSET ?" + params = (*params, int(limit), effective_offset) cursor = await self._db.execute(sql, params) rows = await cursor.fetchall() return tuple(_row_to_session(r) for r in rows) diff --git a/src/synthorg/persistence/sqlite/user_repo.py b/src/synthorg/persistence/sqlite/user_repo.py index 4cbdf53218..20f8a1472a 100644 --- a/src/synthorg/persistence/sqlite/user_repo.py +++ b/src/synthorg/persistence/sqlite/user_repo.py @@ -696,12 +696,8 @@ async def list_by_user( sql = "SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at, id" params: tuple[object, ...] = (user_id,) effective_offset = max(0, int(offset)) - if limit is not None: - sql += " LIMIT ? OFFSET ?" - params = (*params, int(limit), effective_offset) - elif effective_offset > 0: - sql += " LIMIT -1 OFFSET ?" - params = (*params, effective_offset) + sql += " LIMIT ? OFFSET ?" + params = (*params, int(limit), effective_offset) try: cursor = await self._db.execute(sql, params) rows = await cursor.fetchall() From 00f456c0619893dd8ab7a3fdb642fff853d4f0bb Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 22:38:29 +0200 Subject: [PATCH 23/35] fix: address pre-PR review findings on the audit-bucket PR Critical: * ApprovalTimeoutScheduler.stop() now accepts a timeout argument and sets the unrestartable _stop_failed flag on drain timeout. The outer lifecycle wait_for budget exceeds the inner so the unrestartable guard fires before the outer cancellation. * DelegationCircuitBreaker.check() folds the entire OPEN-branch decision into a single _state_lock acquisition; the previous split between get_state and the post-hoc cooldown read was a TOCTOU window against record_delegation. * SsrfViolationService error paths now log under a dedicated SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED event so SIEM filters cannot misclassify a failed resolution as an actual decision. Major: * TrustService.apply_trust_change holds _state_lock across the read through to the change-history append. * _fire_expire_callback logs at ERROR with structured fields so a downstream side-effect failure is alertable. * Mock-spec gate enforced on the lifecycle-test scheduler mocks. * Prometheus label snapshot fixture promoted to session scope so cross-file ordering under xdist loadfile cannot leave an empty snapshot in place between files. * SSRF service module docstring updated to mention the new failure event constant. Medium: * prometheus_collector._fetch_tool_names narrows the swallow to AttributeError / TypeError / ValueError and logs at ERROR via logger.exception. * Backup manifest extraction (service_archive + retention) splits catches into archive_corrupt / json_parse_failed / schema_validation_failed / io_error categories. * Lifecycle startup distinguishes RuntimeError from generic startup failures so the unrestartable diagnostic is surfaced cleanly. * SSE revalidate test iteration cap raised to 200 with rationale comment for slow-CI variance. * save_many empty-input conformance test asserts the post-condition. * approval_store _list_from_repo_locked docstring documents the callback / audit-event side effects. Documentation: * CLAUDE.md and conventions.md describe the broad extra=forbid sweep and the @computed_field carve-out; the protocol example uses limit: int = 100 to match the audit-imposed default. Tests: * New scheduler lifecycle-lock + _stop_failed transition tests. * New circuit-breaker TOCTOU regression tests. * New SQLite JSON CHECK-constraint conformance tests. * New duplicate-id batch test for save_many. * Mock-spec baseline refreshed after the audit-bucket import shifts. --- CLAUDE.md | 2 +- docs/reference/conventions.md | 25 ++-- scripts/mock_spec_baseline.txt | 31 ++--- src/synthorg/api/app.py | 10 +- src/synthorg/api/approval_store.py | 15 ++- src/synthorg/api/lifecycle.py | 27 +++- .../api/services/ssrf_violation_service.py | 22 ++-- src/synthorg/api/state.py | 5 + src/synthorg/backup/retention.py | 61 ++++++++- src/synthorg/backup/service_archive.py | 37 +++++- .../loop_prevention/circuit_breaker.py | 62 +++++---- src/synthorg/observability/events/security.py | 3 + .../observability/prometheus_collector.py | 11 +- src/synthorg/security/timeout/scheduler.py | 52 +++++++- src/synthorg/security/trust/service.py | 71 +++++----- .../persistence/test_approval_repository.py | 26 ++++ .../test_json_constraints_sqlite.py | 116 +++++++++++++++++ .../api/controllers/test_sse_revalidate.py | 12 +- .../services/test_ssrf_violation_service.py | 34 +++-- tests/unit/api/test_app.py | 10 +- .../loop_prevention/test_circuit_breaker.py | 90 +++++++++++++ tests/unit/observability/conftest.py | 28 ++++ .../timeout/test_scheduler_lifecycle_locks.py | 123 ++++++++++++++++++ 23 files changed, 741 insertions(+), 132 deletions(-) create mode 100644 tests/conformance/persistence/test_json_constraints_sqlite.py create mode 100644 tests/unit/security/timeout/test_scheduler_lifecycle_locks.py diff --git a/CLAUDE.md b/CLAUDE.md index 391cf4cefe..b79c8b49f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,7 @@ See [docs/reference/configuration-precedence.md](docs/reference/configuration-pr - **Docstrings**: Google style, required on public classes / functions (ruff D rules). - **Immutability**: create new objects, never mutate existing ones. Frozen Pydantic models for config/identity; for non-Pydantic registries use `copy.deepcopy()` at construction + `MappingProxyType` wrapping; deepcopy at system boundaries (tool execution, provider serialization, persistence). - **Config vs runtime state**: frozen models for config/identity; separate mutable-via-copy models (`model_copy(update=...)`) for runtime state that evolves. Never mix static config and mutable runtime fields in one model. -- **Pydantic v2 conventions**: `ConfigDict(frozen=True, allow_inf_nan=False)` everywhere; `extra="forbid"` on request DTOs; `@computed_field` for derived values; `NotBlankStr` from `core.types` for identifier / name fields. See [docs/reference/conventions.md](docs/reference/conventions.md) §10. +- **Pydantic v2 conventions**: `ConfigDict(frozen=True, allow_inf_nan=False)` everywhere; `extra="forbid"` on every model that does not need to round-trip through `model_dump()` (request DTOs always; ~489 ConfigDicts total today; carve-out is the ~46 classes carrying a `@computed_field`); `@computed_field` for derived values; `NotBlankStr` from `core.types` for identifier / name fields. See [docs/reference/conventions.md](docs/reference/conventions.md) §10. - **Args models at every system boundary (#1611)**: every `BaseTool` subclass, MCP tool registration, A2A RPC method, and WebSocket event declares a typed Pydantic args model and is validated before dispatch. See [docs/reference/conventions.md](docs/reference/conventions.md) §9 for the inventory and [docs/reference/mcp-handler-contract.md](docs/reference/mcp-handler-contract.md) for the MCP-specific contract. - **Typed-boundary helper**: every entry-point that ingests a dict payload from an external source (MCP handler args, JWT decode, WebSocket control message, audit-chain payload, A2A JSON-RPC params, settings security import) calls `parse_typed()` from `synthorg.api.boundary`. The helper accepts either a Pydantic model class or a `TypeAdapter` (for discriminated unions); it validates, emits `API_BOUNDARY_VALIDATION_FAILED` on failure with the boundary name + redacted error description + first 5 field locations + truncated flag, and re-raises `ValidationError` for the caller to translate into the appropriate HTTP / RPC / envelope response. The `boundary` label MUST be a hardcoded `LiteralString` -- never user-controlled. Phase 3 lint guard `scripts/check_boundary_typed.py` enforces the contract: a regression at any of the six registered (file, function) pairs fails pre-push and CI. See [docs/reference/typed-boundaries.md](docs/reference/typed-boundaries.md) for the full per-boundary inventory and the "Adding a new boundary" recipe. - **Async concurrency**: prefer `asyncio.TaskGroup` for fan-out / fan-in. Wrap independent task bodies in `async def` helpers that catch `Exception` (re-raise only `MemoryError` / `RecursionError`) so one failure doesn't unwind the group. See [docs/reference/conventions.md](docs/reference/conventions.md) §11. diff --git a/docs/reference/conventions.md b/docs/reference/conventions.md index d0a6f15f1b..5c318b9b67 100644 --- a/docs/reference/conventions.md +++ b/docs/reference/conventions.md @@ -24,7 +24,7 @@ class ApprovalRepository(Protocol): self, *, status: ApprovalStatus | None = None, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[ApprovalItem, ...]: ... async def delete(self, approval_id: NotBlankStr) -> bool: ... @@ -145,13 +145,22 @@ inline with the consumer. Examples: ## 8. Frozen `ConfigDict` pattern Every Pydantic model declares -`model_config = ConfigDict(frozen=True, allow_inf_nan=False)`. Request -DTOs additionally set `extra="forbid"` so unknown keys are rejected -instead of silently ignored. Combined with the framework's `frozen` -guarantee this gives us the "create new objects, never mutate -existing ones" property the immutability covenant relies on. - -References: 30+ occurrences across `src/synthorg/`. Canonical example: +`model_config = ConfigDict(frozen=True, allow_inf_nan=False)`. The +project standard is to add `extra="forbid"` on every model that does +not need to round-trip through `model_dump()` -- which is most of +them. Around 489 ConfigDicts across `src/synthorg/` carry the strict +form today; the carve-out is the ~46 classes that declare a +`@computed_field`, where Pydantic v2 includes the computed value in +`model_dump()` output and a strict-extra reconstruction would reject +that key on the round trip. Request DTOs are always strict because +the caller-side reject-unknown-keys property is what `extra="forbid"` +exists for. + +Combined with the framework's `frozen` guarantee this gives us the +"create new objects, never mutate existing ones" property the +immutability covenant relies on. + +References: 489+ occurrences across `src/synthorg/`. Canonical example: `src/synthorg/approval/models.py:28`. ## 9. Typed args models at system boundaries (#1611) diff --git a/scripts/mock_spec_baseline.txt b/scripts/mock_spec_baseline.txt index 4fc7510251..aee7217ada 100644 --- a/scripts/mock_spec_baseline.txt +++ b/scripts/mock_spec_baseline.txt @@ -427,21 +427,19 @@ tests/unit/api/test_app.py:420:32 tests/unit/api/test_app.py:423:31 tests/unit/api/test_app.py:454:18 tests/unit/api/test_app.py:455:23 -tests/unit/api/test_app.py:477:21 -tests/unit/api/test_app.py:478:27 -tests/unit/api/test_app.py:479:26 -tests/unit/api/test_app.py:520:21 -tests/unit/api/test_app.py:521:27 -tests/unit/api/test_app.py:522:26 -tests/unit/api/test_app.py:943:24 -tests/unit/api/test_app.py:1004:28 -tests/unit/api/test_app.py:1062:30 -tests/unit/api/test_app.py:1063:36 -tests/unit/api/test_app.py:1066:35 -tests/unit/api/test_app.py:1447:18 -tests/unit/api/test_app.py:1448:15 -tests/unit/api/test_app.py:1458:18 -tests/unit/api/test_app.py:1471:18 +tests/unit/api/test_app.py:481:27 +tests/unit/api/test_app.py:482:26 +tests/unit/api/test_app.py:527:27 +tests/unit/api/test_app.py:528:26 +tests/unit/api/test_app.py:949:24 +tests/unit/api/test_app.py:1010:28 +tests/unit/api/test_app.py:1068:30 +tests/unit/api/test_app.py:1069:36 +tests/unit/api/test_app.py:1072:35 +tests/unit/api/test_app.py:1453:18 +tests/unit/api/test_app.py:1454:15 +tests/unit/api/test_app.py:1464:18 +tests/unit/api/test_app.py:1477:18 tests/unit/api/test_approval_store.py:309:19 tests/unit/api/test_approval_store.py:329:19 tests/unit/api/test_auto_wire_meetings.py:24:11 @@ -2814,6 +2812,9 @@ tests/unit/security/timeout/test_scheduler.py:430:27 tests/unit/security/timeout/test_scheduler.py:453:36 tests/unit/security/timeout/test_scheduler.py:474:32 tests/unit/security/timeout/test_scheduler.py:497:19 +tests/unit/security/timeout/test_scheduler_lifecycle_locks.py:29:23 +tests/unit/security/timeout/test_scheduler_lifecycle_locks.py:30:28 +tests/unit/security/timeout/test_scheduler_lifecycle_locks.py:48:32 tests/unit/security/timeout/test_timeout_checker.py:38:18 tests/unit/security/timeout/test_timeout_checker.py:147:22 tests/unit/settings/test_bridge_config_wiring.py:184:40 diff --git a/src/synthorg/api/app.py b/src/synthorg/api/app.py index baab8f9caf..9a721a3c39 100644 --- a/src/synthorg/api/app.py +++ b/src/synthorg/api/app.py @@ -127,10 +127,14 @@ # Default approval-timeout interval mirrors the registry default for -# ``security.timeout_check_interval_seconds``. Held here as a constant -# so the bootstrap and the registry definition cannot drift; future -# reads from ConfigResolver still override at runtime via the +# ``security.timeout_check_interval_seconds`` defined in +# ``src/synthorg/settings/definitions/security.py``. Held here as a +# constant so the bootstrap and the registry definition cannot drift; +# future reads from ConfigResolver still override at runtime via the # scheduler's ``reschedule()`` (called from a settings subscriber). +# Update both sites together if the default ever changes; otherwise a +# bootstrap value will silently disagree with operator-editable +# overrides resolved through ``ConfigResolver``. _DEFAULT_TIMEOUT_CHECK_INTERVAL_SECONDS = 60.0 diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 36b597f26c..e193ef6b3a 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -267,6 +267,14 @@ async def _list_from_repo_locked( to_persist and transitions the cache; the persistence write fans out as a single ``save_many`` so K simultaneous expiries yield one DB round-trip rather than K. + + Side effects after the batch save: + + * Emits one ``APPROVAL_STATUS_TRANSITIONED`` + one + ``API_APPROVAL_EXPIRED`` audit event per newly-expired item. + * Fires the optional ``on_expire`` callback for each item via + :meth:`_fire_expire_callback` (best-effort; failures are + logged at ERROR but do not unwind the expiration). """ assert self._repo is not None # noqa: S101 -- caller invariant repo_items = await self._repo.list_items( @@ -594,7 +602,12 @@ def _fire_expire_callback(self, expired: ApprovalItem) -> None: except MemoryError, RecursionError: raise except Exception as exc: - logger.warning( + # ERROR rather than WARNING: the approval is already + # EXPIRED in cache + repo, so the callback can't + # propagate, but a failed downstream side effect (webhook, + # audit dispatch, workflow resume) is operationally + # meaningful and operators must be able to alert on it. + logger.error( # noqa: TRY400 API_APPROVAL_EXPIRE_CALLBACK_FAILED, approval_id=expired.id, error_type=type(exc).__name__, diff --git a/src/synthorg/api/lifecycle.py b/src/synthorg/api/lifecycle.py index 6f5574796c..cd91540688 100644 --- a/src/synthorg/api/lifecycle.py +++ b/src/synthorg/api/lifecycle.py @@ -12,7 +12,7 @@ from synthorg.api.auth.service import AuthService from synthorg.api.auth.system_user import ensure_system_user from synthorg.backup.models import BackupTrigger -from synthorg.observability import get_logger +from synthorg.observability import get_logger, safe_error_description from synthorg.observability.events.api import API_APP_SHUTDOWN, API_APP_STARTUP from synthorg.persistence.auth_protocol import ( LockoutRepository, # noqa: TC001 @@ -580,6 +580,20 @@ async def _safe_startup( # noqa: PLR0913, PLR0912, PLR0915, C901 ) await approval_timeout_scheduler.start() started_approval_timeout_scheduler = True + except RuntimeError as exc: + # ``ApprovalTimeoutScheduler.start()`` raises + # ``RuntimeError`` when a prior ``stop()`` timed out + # and the scheduler is now unrestartable. The fresh + # instance rule applies: log without the stack trace + # (the underlying cause was already logged at stop + # time) and propagate so startup fails closed. + logger.error( # noqa: TRY400 + API_APP_STARTUP, + error="Approval timeout scheduler is unrestartable", + error_type=type(exc).__name__, + error_detail=safe_error_description(exc), + ) + raise except Exception: logger.exception( API_APP_STARTUP, @@ -634,11 +648,18 @@ async def _safe_shutdown( # noqa: PLR0913, PLR0912, C901 disconnect so shutdown backup can still access the DB. """ if approval_timeout_scheduler is not None: + # Inner timeout sets the scheduler's ``_stop_failed`` flag on + # drain timeout so a subsequent ``start()`` raises rather than + # spawning a duplicate task on top of an in-flight cancelled + # one. The outer ``_try_stop`` budget exceeds the inner so the + # unrestartable guard actually fires before cancellation. await _try_stop( - approval_timeout_scheduler.stop(), + approval_timeout_scheduler.stop( + timeout=_APPROVAL_TIMEOUT_SHUTDOWN_SECONDS, + ), API_APP_SHUTDOWN, "Failed to stop approval timeout scheduler", - timeout=_APPROVAL_TIMEOUT_SHUTDOWN_SECONDS, + timeout=_APPROVAL_TIMEOUT_SHUTDOWN_SECONDS * 2.0 + 1.0, service="approval_timeout_scheduler", ) if meeting_scheduler is not None: diff --git a/src/synthorg/api/services/ssrf_violation_service.py b/src/synthorg/api/services/ssrf_violation_service.py index 62fa820495..0719758c80 100644 --- a/src/synthorg/api/services/ssrf_violation_service.py +++ b/src/synthorg/api/services/ssrf_violation_service.py @@ -11,9 +11,13 @@ operator allowing or denying a previously-blocked URL is captured at this layer via :data:`SECURITY_SSRF_VIOLATION_ALLOWED` / :data:`SECURITY_SSRF_VIOLATION_DENIED` so the entries land on the -signed audit chain alongside other security mutations. Read-side -fetch / list events stay on the API namespace because they carry no -audit-chain implication. +signed audit chain alongside other security mutations. Failed +resolution attempts emit +:data:`SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED` rather than the +success verb so SIEM readers can distinguish a failed resolution +from an actual allow / deny decision. Read-side fetch / list events +stay on the API namespace because they carry no audit-chain +implication. """ from typing import TYPE_CHECKING @@ -28,6 +32,7 @@ SECURITY_SSRF_VIOLATION_ALLOWED, SECURITY_SSRF_VIOLATION_DENIED, SECURITY_SSRF_VIOLATION_RECORDED, + SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED, ) from synthorg.security.ssrf_violation import ( SsrfViolation, @@ -226,11 +231,12 @@ async def update_status( raise except ValueError as exc: # Invalid status transition (e.g. PENDING) is a caller bug - # but still a security-relevant audit signal -- log it at - # WARNING with full context before propagating per - # CLAUDE.md `## Logging`. + # and a security-relevant audit signal: log under the + # dedicated failure event (NOT the success allowed/denied + # verb) so SIEM filters can distinguish a failed resolution + # from an actual decision. logger.warning( - success_event, + SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED, violation_id=violation_id, status=status.value, error_type=type(exc).__name__, @@ -239,7 +245,7 @@ async def update_status( raise except Exception as exc: logger.warning( - success_event, + SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED, violation_id=violation_id, status=status.value, error_type=type(exc).__name__, diff --git a/src/synthorg/api/state.py b/src/synthorg/api/state.py index ec51cee564..b575c72300 100644 --- a/src/synthorg/api/state.py +++ b/src/synthorg/api/state.py @@ -504,6 +504,11 @@ def __init__( # noqa: PLR0913, PLR0915 # ordering invariant the controller relies on. self._request_lock_refs: dict[str, int] = {} self.startup_time = startup_time + # Test seam: controllers and services that read time go + # through ``app_state.clock`` so unit tests can inject a + # ``FakeClock`` without monkey-patching ``time.monotonic`` + # at the module level. ``SystemClock`` is the production + # default; see CLAUDE.md ``## Code Conventions`` (Clock seam). self.clock: Clock = clock or SystemClock() def _init_derived_services( diff --git a/src/synthorg/backup/retention.py b/src/synthorg/backup/retention.py index 709b04e092..daa00b4775 100644 --- a/src/synthorg/backup/retention.py +++ b/src/synthorg/backup/retention.py @@ -7,6 +7,8 @@ from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING +from pydantic import ValidationError + from synthorg.backup.errors import RetentionError from synthorg.backup.models import BackupManifest, BackupTrigger from synthorg.observability import get_logger, safe_error_description @@ -171,16 +173,36 @@ def _load_dir_manifest(entry: Path) -> BackupManifest | None: try: data = json.loads(manifest_path.read_text(encoding="utf-8")) return BackupManifest.model_validate(data) - except Exception: + except json.JSONDecodeError as exc: + logger.warning( + BACKUP_MANIFEST_INVALID, + path=str(manifest_path), + category="json_parse_failed", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + return None + except ValidationError as exc: + logger.warning( + BACKUP_MANIFEST_INVALID, + path=str(manifest_path), + category="schema_validation_failed", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + return None + except OSError as exc: logger.warning( BACKUP_MANIFEST_INVALID, path=str(manifest_path), - exc_info=True, + category="io_error", + error_type=type(exc).__name__, + error=safe_error_description(exc), ) return None @staticmethod - def _load_archive_manifest(entry: Path) -> BackupManifest | None: + def _load_archive_manifest(entry: Path) -> BackupManifest | None: # noqa: PLR0911 """Load a manifest from a compressed tar.gz archive.""" try: with tarfile.open(entry, "r:gz") as tar: @@ -197,11 +219,40 @@ def _load_archive_manifest(entry: Path) -> BackupManifest | None: return BackupManifest.model_validate(data) except MemoryError, RecursionError: raise - except Exception: + except tarfile.TarError as exc: + logger.warning( + BACKUP_MANIFEST_INVALID, + path=str(entry), + category="archive_corrupt", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + return None + except json.JSONDecodeError as exc: + logger.warning( + BACKUP_MANIFEST_INVALID, + path=str(entry), + category="json_parse_failed", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + return None + except ValidationError as exc: + logger.warning( + BACKUP_MANIFEST_INVALID, + path=str(entry), + category="schema_validation_failed", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + return None + except OSError as exc: logger.warning( BACKUP_MANIFEST_INVALID, path=str(entry), - exc_info=True, + category="io_error", + error_type=type(exc).__name__, + error=safe_error_description(exc), ) return None diff --git a/src/synthorg/backup/service_archive.py b/src/synthorg/backup/service_archive.py index d4d653ac98..e69f6de76e 100644 --- a/src/synthorg/backup/service_archive.py +++ b/src/synthorg/backup/service_archive.py @@ -12,6 +12,8 @@ from pathlib import Path from typing import TYPE_CHECKING +from pydantic import ValidationError + from synthorg.backup.errors import ( BackupInProgressError, BackupNotFoundError, @@ -407,7 +409,7 @@ def _extract_tar(archive_path: Path, target_dir: Path) -> None: tar.extractall(target_dir, filter="data") @staticmethod - def _read_manifest_from_archive( + def _read_manifest_from_archive( # noqa: PLR0911 archive_path: Path, ) -> BackupManifest | None: """Read manifest.json from a tar.gz archive.""" @@ -436,10 +438,39 @@ def _read_manifest_from_archive( return BackupManifest.model_validate(data) except MemoryError, RecursionError: raise - except Exception: + except tarfile.TarError as exc: logger.warning( BACKUP_MANIFEST_INVALID, path=str(archive_path), - exc_info=True, + category="archive_corrupt", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + return None + except json.JSONDecodeError as exc: + logger.warning( + BACKUP_MANIFEST_INVALID, + path=str(archive_path), + category="json_parse_failed", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + return None + except ValidationError as exc: + logger.warning( + BACKUP_MANIFEST_INVALID, + path=str(archive_path), + category="schema_validation_failed", + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + return None + except OSError as exc: + logger.warning( + BACKUP_MANIFEST_INVALID, + path=str(archive_path), + category="io_error", + error_type=type(exc).__name__, + error=safe_error_description(exc), ) return None diff --git a/src/synthorg/communication/loop_prevention/circuit_breaker.py b/src/synthorg/communication/loop_prevention/circuit_breaker.py index 8aaaf17a88..06fd75b7d7 100644 --- a/src/synthorg/communication/loop_prevention/circuit_breaker.py +++ b/src/synthorg/communication/loop_prevention/circuit_breaker.py @@ -187,30 +187,46 @@ def check( Returns: Outcome with passed=False if circuit is open. """ - state = self.get_state(delegator_id, delegatee_id) - if state is CircuitBreakerState.OPEN: + # Hold the lock across both the state evaluation and the + # cooldown read. Splitting them lets a concurrent + # ``record_delegation`` reset or mutate the pair between + # ``get_state`` and the post-hoc ``_get_pair`` lookup, which + # would surface as a stale cooldown value or a missing pair + # in the OPEN branch. + with self._state_lock: pair = self._get_pair(delegator_id, delegatee_id) - cooldown = ( - self._compute_cooldown(pair.trip_count) - if pair is not None - else float(self._config.cooldown_seconds) - ) - logger.info( - DELEGATION_LOOP_CIRCUIT_OPEN, - delegator=delegator_id, - delegatee=delegatee_id, - cooldown_seconds=cooldown, - ) - return GuardCheckOutcome( - passed=False, - mechanism=_MECHANISM, - message=( - f"Circuit breaker open for pair " - f"({delegator_id!r}, {delegatee_id!r}); " - f"cooldown {cooldown}s" - ), - ) - return GuardCheckOutcome(passed=True, mechanism=_MECHANISM) + if pair is None or pair.opened_at is None: + return GuardCheckOutcome(passed=True, mechanism=_MECHANISM) + elapsed = self._clock() - pair.opened_at + cooldown = self._compute_cooldown(pair.trip_count) + if elapsed >= cooldown: + key = pair_key(delegator_id, delegatee_id) + pair.bounce_count = 0 + pair.opened_at = None + self._dirty.add(key) + logger.info( + DELEGATION_LOOP_CIRCUIT_RESET, + delegator=delegator_id, + delegatee=delegatee_id, + cooldown_seconds=cooldown, + trip_count=pair.trip_count, + ) + return GuardCheckOutcome(passed=True, mechanism=_MECHANISM) + logger.info( + DELEGATION_LOOP_CIRCUIT_OPEN, + delegator=delegator_id, + delegatee=delegatee_id, + cooldown_seconds=cooldown, + ) + return GuardCheckOutcome( + passed=False, + mechanism=_MECHANISM, + message=( + f"Circuit breaker open for pair " + f"({delegator_id!r}, {delegatee_id!r}); " + f"cooldown {cooldown}s" + ), + ) def record_delegation( self, diff --git a/src/synthorg/observability/events/security.py b/src/synthorg/observability/events/security.py index cd358bf284..0bd1fd5d33 100644 --- a/src/synthorg/observability/events/security.py +++ b/src/synthorg/observability/events/security.py @@ -119,6 +119,9 @@ SECURITY_SSRF_VIOLATION_RECORDED: Final[str] = "security.ssrf_violation.recorded" SECURITY_SSRF_VIOLATION_ALLOWED: Final[str] = "security.ssrf_violation.allowed" SECURITY_SSRF_VIOLATION_DENIED: Final[str] = "security.ssrf_violation.denied" +SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED: Final[str] = ( + "security.ssrf_violation.resolution_failed" +) SECURITY_ALLOWLIST_UPDATED: Final[str] = "security.allowlist.updated" SECURITY_ALLOWLIST_UPDATE_FAILED: Final[str] = "security.allowlist.update_failed" diff --git a/src/synthorg/observability/prometheus_collector.py b/src/synthorg/observability/prometheus_collector.py index 36003b3b08..5c3221a32b 100644 --- a/src/synthorg/observability/prometheus_collector.py +++ b/src/synthorg/observability/prometheus_collector.py @@ -132,11 +132,16 @@ async def _fetch_tool_names(app_state: AppState) -> frozenset[str] | None: return frozenset(registry.list_tools()) except MemoryError, RecursionError: raise - except Exception: - logger.warning( + except AttributeError, TypeError, ValueError: + # Narrow the swallow to the specific shapes a malformed or + # partially-initialised registry can produce. ERROR rather + # than WARNING so operators can alert on stale snapshots -- + # if this fires, the previous allowlist is preserved silently + # and tool_name labels keep using stale values until the + # registry recovers. + logger.exception( METRICS_SCRAPE_FAILED, component="tool_registry", - exc_info=True, ) return None diff --git a/src/synthorg/security/timeout/scheduler.py b/src/synthorg/security/timeout/scheduler.py index c0e999a3ac..b3de982b2f 100644 --- a/src/synthorg/security/timeout/scheduler.py +++ b/src/synthorg/security/timeout/scheduler.py @@ -123,18 +123,60 @@ async def start(self) -> None: interval_seconds=self._interval, ) - async def stop(self) -> None: - """Cancel the background scheduler and wait for it to finish.""" + async def stop(self, *, timeout: float | None = None) -> None: # noqa: ASYNC109 + """Cancel the background scheduler and wait for it to finish. + + Holds ``_lifecycle_lock`` across the full body so a racing + ``start()`` cannot interleave between cancel and the + ``self._task = None`` assignment. + + Args: + timeout: Seconds to wait for cancellation + drain. ``None`` + means "wait indefinitely". Must be positive when set. + + Raises: + ValueError: If ``timeout`` is non-positive. + TimeoutError: If cancellation + drain do not complete within + ``timeout``. The scheduler is marked unrestartable so + a subsequent :meth:`start` raises ``RuntimeError``; + operators must construct a fresh instance because the + prior task may still be in flight finishing its cleanup + and a new task spawned alongside it would break the + single-writer invariant. + """ + if timeout is not None and timeout <= 0: + msg = f"stop() timeout must be > 0, got {timeout!r}" + raise ValueError(msg) async with self._lifecycle_lock: if self._task is None: await self._background_tasks.drain() return + try: + if timeout is None: + await self._cancel_and_drain() + else: + await asyncio.wait_for( + self._cancel_and_drain(), + timeout=timeout, + ) + except TimeoutError: + self._stop_failed = True + logger.error( # noqa: TRY400 + TIMEOUT_SCHEDULER_ERROR, + error="stop drain timed out", + timeout_seconds=timeout, + ) + raise + self._task = None + logger.info(TIMEOUT_SCHEDULER_STOPPED) + + async def _cancel_and_drain(self) -> None: + """Cancel the scheduler task and drain background callbacks.""" + if self._task is not None: self._task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._task - self._task = None - await self._background_tasks.drain() - logger.info(TIMEOUT_SCHEDULER_STOPPED) + await self._background_tasks.drain() def reschedule(self, interval_seconds: float) -> None: """Update the interval and interrupt the current sleep. diff --git a/src/synthorg/security/trust/service.py b/src/synthorg/security/trust/service.py index 2060b864af..b5aede8673 100644 --- a/src/synthorg/security/trust/service.py +++ b/src/synthorg/security/trust/service.py @@ -181,17 +181,6 @@ async def apply_trust_change( if not result.should_change: return None - key = str(agent_id) - state = self._trust_states.get(key) - if state is None: - msg = f"Agent {agent_id!r} not initialized for trust tracking" - logger.warning( - TRUST_EVALUATE_FAILED, - agent_id=agent_id, - error=msg, - ) - raise TrustEvaluationError(msg) - # Defense-in-depth: re-enforce elevated gate on the result # to prevent crafted TrustEvaluationResults from bypassing # the mandatory human approval gate. @@ -201,39 +190,49 @@ async def apply_trust_change( await self._create_approval(agent_id, result) return None - # Apply the change + key = str(agent_id) now = datetime.now(UTC) reason = self._infer_reason(result) - record = TrustChangeRecord( - agent_id=agent_id, - old_level=state.global_level, - new_level=result.recommended_level, - reason=reason, - timestamp=now, - details=result.details, - ) - - # Update state -- only set last_promoted_at on actual promotions from synthorg.security.trust.levels import ( # noqa: PLC0415 TRUST_LEVEL_RANK, ) - is_promotion = TRUST_LEVEL_RANK.get( - result.recommended_level, 0 - ) > TRUST_LEVEL_RANK.get(state.global_level, 0) - state_update: dict[str, object] = { - "global_level": result.recommended_level, - "trust_score": result.score, - } - if is_promotion: - state_update["last_promoted_at"] = now - # Lock both writes so a concurrent apply_trust_change / - # evaluate_agent for the same agent cannot interleave between - # the dict assign and the change_history append (both must - # land or neither, otherwise log readers see a phantom - # transition that no state row backs). + # Hold the lock across the whole read-modify-write so a + # concurrent ``apply_trust_change`` for the same agent cannot + # observe stale state at the read and overwrite a peer's update + # at the write. Building the change record under the lock also + # closes the gap where an evaluation can race past initialisation + # and produce a record against a removed state row. async with self._state_lock: + state = self._trust_states.get(key) + if state is None: + msg = f"Agent {agent_id!r} not initialized for trust tracking" + logger.warning( + TRUST_EVALUATE_FAILED, + agent_id=agent_id, + error=msg, + ) + raise TrustEvaluationError(msg) + + record = TrustChangeRecord( + agent_id=agent_id, + old_level=state.global_level, + new_level=result.recommended_level, + reason=reason, + timestamp=now, + details=result.details, + ) + + is_promotion = TRUST_LEVEL_RANK.get( + result.recommended_level, 0 + ) > TRUST_LEVEL_RANK.get(state.global_level, 0) + state_update: dict[str, object] = { + "global_level": result.recommended_level, + "trust_score": result.score, + } + if is_promotion: + state_update["last_promoted_at"] = now updated = state.model_copy(update=state_update) self._trust_states[key] = updated self._change_history.setdefault(key, []).append(record) diff --git a/tests/conformance/persistence/test_approval_repository.py b/tests/conformance/persistence/test_approval_repository.py index caee3afb12..467cdbeb2c 100644 --- a/tests/conformance/persistence/test_approval_repository.py +++ b/tests/conformance/persistence/test_approval_repository.py @@ -290,6 +290,12 @@ async def test_save_many_empty_input_is_noop( repo = _approval_repo(backend) # Empty input must not open a transaction or raise. await repo.save_many(()) + # Confirm no rows were written: a fresh repo on the same + # connection sees an empty list. Without this post-condition + # the test would pass even if save_many silently opened and + # committed an empty transaction. + fresh = _approval_repo(backend) + assert await fresh.list_items() == () async def test_save_many_upserts_existing_rows( self, @@ -308,3 +314,23 @@ async def test_save_many_upserts_existing_rows( fetched = await repo.get(original.id) assert fetched is not None assert fetched.status is ApprovalStatus.EXPIRED + + async def test_save_many_duplicate_ids_within_batch_settle_to_last( + self, + backend: PersistenceBackend, + ) -> None: + # The protocol contract is upsert per id. When the same id + # appears twice in a batch the repository must converge on the + # last value rather than open a half-applied state where a + # concurrent reader could observe the intermediate version. + repo = _approval_repo(backend) + first = _make_item( + approval_id="approval-batch-dup", + status=ApprovalStatus.PENDING, + ) + second = first.model_copy(update={"status": ApprovalStatus.EXPIRED}) + await repo.save_many((first, second)) + + fetched = await repo.get(first.id) + assert fetched is not None + assert fetched.status is ApprovalStatus.EXPIRED diff --git a/tests/conformance/persistence/test_json_constraints_sqlite.py b/tests/conformance/persistence/test_json_constraints_sqlite.py new file mode 100644 index 0000000000..ceaf8856c8 --- /dev/null +++ b/tests/conformance/persistence/test_json_constraints_sqlite.py @@ -0,0 +1,116 @@ +"""SQLite ``CHECK (json_valid(...))`` constraint conformance tests. + +The Postgres side stores these columns as ``JSONB`` which validates +implicitly; SQLite stores them as ``TEXT`` and the audit migration +``20260503181821_json_check_constraints.sql`` adds CHECK constraints +to bring the same shape guarantee. This module asserts the integrity +error fires on bad input -- a parity floor between the two backends. + +Postgres is skipped via the ``backend_name == "sqlite"`` guard so the +parametrised dual-backend fixture stays usable; the JSONB side is +already exercised by the existing repository conformance tests. +""" + +from typing import cast + +import aiosqlite +import pytest + +from synthorg.persistence.protocol import PersistenceBackend + +pytestmark = pytest.mark.integration + + +class TestSqliteJsonValidConstraints: + """``CHECK (json_valid(...))`` integrity coverage on SQLite TEXT columns.""" + + async def test_provider_audit_payload_rejects_non_json( + self, + backend: PersistenceBackend, + ) -> None: + """``provider_audit_events.payload`` rejects malformed JSON.""" + if backend.backend_name != "sqlite": + pytest.skip("SQLite-only constraint") + conn = cast("aiosqlite.Connection", backend.get_db()) + + async def _attempt_insert() -> None: + await conn.execute( + "INSERT INTO provider_audit_events " + "(provider_name, event_type, actor_id, actor_label, " + "payload, occurred_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + "example-provider", + "credential.rotated", + "actor-1", + "Operator", + "{not-valid-json", + "2026-05-03T12:00:00+00:00", + ), + ) + + with pytest.raises(aiosqlite.IntegrityError): + await _attempt_insert() + + async def test_preset_overrides_default_models_rejects_non_json( + self, + backend: PersistenceBackend, + ) -> None: + """``preset_overrides.default_models`` accepts NULL but rejects non-JSON.""" + if backend.backend_name != "sqlite": + pytest.skip("SQLite-only constraint") + conn = cast("aiosqlite.Connection", backend.get_db()) + + async def _attempt_insert() -> None: + await conn.execute( + "INSERT INTO preset_overrides " + "(preset_name, base_url, default_models, " + "supported_auth_types, candidate_urls, " + "updated_at, updated_by) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + "example-preset", + "https://example.invalid", + "not-json", + None, + None, + "2026-05-03T12:00:00+00:00", + "operator-1", + ), + ) + + with pytest.raises(aiosqlite.IntegrityError): + await _attempt_insert() + + async def test_preset_overrides_nullable_columns_accept_null( + self, + backend: PersistenceBackend, + ) -> None: + """The ``IS NULL OR json_valid()`` form admits NULL for the nullable + JSON columns so existing rows that omit overrides keep working.""" + if backend.backend_name != "sqlite": + pytest.skip("SQLite-only constraint") + conn = cast("aiosqlite.Connection", backend.get_db()) + await conn.execute( + "INSERT INTO preset_overrides " + "(preset_name, base_url, default_models, " + "supported_auth_types, candidate_urls, " + "updated_at, updated_by) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + "example-preset-nulls", + "https://example.invalid", + None, + None, + None, + "2026-05-03T12:00:00+00:00", + "operator-1", + ), + ) + await conn.commit() + cur = await conn.execute( + "SELECT preset_name FROM preset_overrides WHERE preset_name = ?", + ("example-preset-nulls",), + ) + row = await cur.fetchone() + assert row is not None diff --git a/tests/unit/api/controllers/test_sse_revalidate.py b/tests/unit/api/controllers/test_sse_revalidate.py index 12114f1223..94996346a8 100644 --- a/tests/unit/api/controllers/test_sse_revalidate.py +++ b/tests/unit/api/controllers/test_sse_revalidate.py @@ -161,6 +161,13 @@ async def unsubscribe(self, _session_id: str, _queue: _FakeQueue) -> None: ) saw_revoked = False iterations = 0 + # The loop body sleeps via real asyncio.wait_for, not the injected + # clock seam, so the iteration cap is a wall-clock safety net. Set + # the cap to handle slow-CI variance without masking a genuine + # regression: the role-demoted check fires once per + # SSE_REVALIDATE_INTERVAL_SECONDS, and 200 iterations at 20ms + # gives 4s of headroom. + iteration_cap = 200 async for event in gen: iterations += 1 if event.get("event") == "revoked": @@ -168,8 +175,5 @@ async def unsubscribe(self, _session_id: str, _queue: _FakeQueue) -> None: assert payload["reason"] == "role_demoted" saw_revoked = True break - # Safety net: at the configured cadence we should hit revoked - # within a handful of keepalive ticks (>= 1 keepalive_count - # required by the loop math). 50 is generous. - assert iterations < 50 + assert iterations < iteration_cap assert saw_revoked, "SSE stream never emitted the revoked event" diff --git a/tests/unit/api/services/test_ssrf_violation_service.py b/tests/unit/api/services/test_ssrf_violation_service.py index 0224dae153..462b95ff3a 100644 --- a/tests/unit/api/services/test_ssrf_violation_service.py +++ b/tests/unit/api/services/test_ssrf_violation_service.py @@ -27,6 +27,7 @@ SECURITY_SSRF_VIOLATION_ALLOWED, SECURITY_SSRF_VIOLATION_DENIED, SECURITY_SSRF_VIOLATION_RECORDED, + SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED, ) from synthorg.security.ssrf_violation import SsrfViolation, SsrfViolationStatus @@ -346,10 +347,12 @@ async def test_update_status_no_audit_when_row_missing() -> None: async def test_update_status_rejects_pending_target() -> None: """Transitioning back to PENDING is invalid; one WARNING audit fires. - The success-shape INFO event must NOT fire (no actual transition - happened) but a WARNING with ``error_type`` is required by - CLAUDE.md `## Logging` so incident triage can correlate the - invalid call. + The success-shape allow / deny event must NOT fire (no actual + transition happened); a dedicated + ``SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED`` WARNING with + ``error_type`` fires instead so SIEM dashboards keyed on the + success verbs cannot misclassify a failed resolution as an + actual decision. """ repo = _FakeSsrfViolationRepo() service = SsrfViolationService(repo=repo) @@ -368,15 +371,22 @@ async def test_update_status_rejects_pending_target() -> None: resolved_at=resolved_at, ) - # PENDING is not ALLOWED, so the service routes the warning event - # to SECURITY_SSRF_VIOLATION_DENIED (the non-allowed branch). - audits = [log for log in logs if log["event"] == SECURITY_SSRF_VIOLATION_DENIED] - info_audits = [log for log in audits if log.get("log_level") == "info"] - warning_audits = [log for log in audits if log.get("log_level") == "warning"] - assert info_audits == [], ( - f"the success-shape INFO event must NOT fire on invalid transition " - f"-- got {info_audits}" + success_audits = [ + log + for log in logs + if log["event"] + in {SECURITY_SSRF_VIOLATION_ALLOWED, SECURITY_SSRF_VIOLATION_DENIED} + ] + assert success_audits == [], ( + f"success-shape events must NOT fire on invalid transition " + f"-- got {success_audits}" ) + failure_audits = [ + log for log in logs if log["event"] == SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED + ] + warning_audits = [ + log for log in failure_audits if log.get("log_level") == "warning" + ] assert len(warning_audits) == 1 assert warning_audits[0]["error_type"] == "ValueError" assert warning_audits[0]["violation_id"] == violation.id diff --git a/tests/unit/api/test_app.py b/tests/unit/api/test_app.py index b216ce9ee8..e680a93a84 100644 --- a/tests/unit/api/test_app.py +++ b/tests/unit/api/test_app.py @@ -467,6 +467,9 @@ async def test_meeting_scheduler_lifecycle( from synthorg.api.approval_store import ApprovalStore from synthorg.api.lifecycle import _safe_shutdown, _safe_startup from synthorg.api.state import AppState + from synthorg.communication.meeting.scheduler import ( + MeetingScheduler, + ) from tests.unit.api.conftest import ( FakeMessageBus, FakePersistenceBackend, @@ -474,7 +477,7 @@ async def test_meeting_scheduler_lifecycle( persistence = FakePersistenceBackend() bus = FakeMessageBus() - mock_sched = MagicMock() + mock_sched = MagicMock(spec=MeetingScheduler) mock_sched.start = AsyncMock() mock_sched.stop = AsyncMock() @@ -510,6 +513,9 @@ async def test_approval_timeout_scheduler_lifecycle( from synthorg.api.approval_store import ApprovalStore from synthorg.api.lifecycle import _safe_shutdown, _safe_startup from synthorg.api.state import AppState + from synthorg.security.timeout.scheduler import ( + ApprovalTimeoutScheduler, + ) from tests.unit.api.conftest import ( FakeMessageBus, FakePersistenceBackend, @@ -517,7 +523,7 @@ async def test_approval_timeout_scheduler_lifecycle( persistence = FakePersistenceBackend() bus = FakeMessageBus() - mock_sched = MagicMock() + mock_sched = MagicMock(spec=ApprovalTimeoutScheduler) mock_sched.start = AsyncMock() mock_sched.stop = AsyncMock() diff --git a/tests/unit/communication/loop_prevention/test_circuit_breaker.py b/tests/unit/communication/loop_prevention/test_circuit_breaker.py index 0260a6d507..92014d03ae 100644 --- a/tests/unit/communication/loop_prevention/test_circuit_breaker.py +++ b/tests/unit/communication/loop_prevention/test_circuit_breaker.py @@ -324,3 +324,93 @@ async def test_load_state_restores_pairs(self) -> None: assert pair.bounce_count == 1 assert pair.trip_count == 2 assert pair.opened_at == 50.0 + + +@pytest.mark.unit +class TestCheckAtomicity: + """Regression coverage for the ``check()`` TOCTOU race. + + The previous implementation called ``get_state()`` (which released + the lock on return), then re-acquired the pair via a second + ``_get_pair`` lookup outside the lock to compute the cooldown for + the OPEN-branch log message. A concurrent ``record_delegation`` + on the same pair could mutate the dict between those reads, + surfacing a stale cooldown value or a missing pair. + """ + + def test_check_open_branch_runs_under_state_lock(self) -> None: + """The whole OPEN-branch decision (state + cooldown read) + runs while holding ``_state_lock``.""" + config = CircuitBreakerConfig(bounce_threshold=1, cooldown_seconds=10) + clock_time = 0.0 + + def clock() -> float: + return clock_time + + cb = DelegationCircuitBreaker(config, clock=clock) + cb.record_delegation("a", "b") + + # Wrap the lock so we can observe whether the protected + # region was held across the OPEN-branch reads. Substituting + # a tracking RLock keeps the API identical -- both + # acquire/release pairs delegate to the underlying lock so + # threading semantics are preserved. + underlying = cb._state_lock + acquired_during_check: list[bool] = [] + + class _TrackingLock: + def __enter__(self) -> _TrackingLock: + underlying.acquire() + acquired_during_check.append(True) + return self + + def __exit__( + self, + exc_type: object, + exc: object, + tb: object, + ) -> None: + acquired_during_check.append(False) + underlying.release() + + def acquire(self, *args: object, **kwargs: object) -> bool: + return underlying.acquire() + + def release(self) -> None: + underlying.release() + + cb._state_lock = _TrackingLock() # type: ignore[assignment] + result = cb.check("a", "b") + assert result.passed is False + # Exactly one acquire/release pair across the check, meaning + # the entire OPEN branch decision was inside the critical + # section (no second unlocked read). + assert acquired_during_check == [True, False] + + def test_record_delegation_after_get_state_does_not_drop_pair( + self, + ) -> None: + """A concurrent ``record_delegation`` between ``get_state`` and + the cooldown read cannot leave ``check`` reading a missing pair. + + The fix folds both reads under one lock so the resetting + branch in ``get_state`` and the OPEN-branch read in ``check`` + cannot interleave with a sibling mutation. + """ + config = CircuitBreakerConfig(bounce_threshold=2, cooldown_seconds=10) + clock_time = 0.0 + + def clock() -> float: + return clock_time + + cb = DelegationCircuitBreaker(config, clock=clock) + cb.record_delegation("a", "b") + cb.record_delegation("a", "b") + # Pair is OPEN; cooldown elapsed mid-check would historically + # leave the post-get_state lookup observing a freshly-reset + # pair with opened_at=None. Under the new locking it doesn't + # matter -- the entire branch is atomic. + clock_time = 5.0 + result = cb.check("a", "b") + assert result.passed is False + assert "cooldown" in (result.message or "") diff --git a/tests/unit/observability/conftest.py b/tests/unit/observability/conftest.py index cf74919aa9..cc403f253e 100644 --- a/tests/unit/observability/conftest.py +++ b/tests/unit/observability/conftest.py @@ -19,6 +19,11 @@ SyslogFacility, SyslogProtocol, ) +from synthorg.observability.prometheus_labels import ( + _LabelSnapshot, + _reset_label_snapshot_for_tests, + update_label_snapshot, +) from tests.conftest import clear_logging_state # -- Factories -------------------------------------------------------------- @@ -82,6 +87,29 @@ def _reset_logging() -> Iterator[None]: clear_logging_state() +@pytest.fixture(autouse=True, scope="session") +def _seed_prometheus_label_snapshot() -> Iterator[None]: + """Seed the Prometheus label snapshot for all observability tests. + + ``record_tool_invocation`` validates the ``tool_name`` label + against the snapshot maintained by ``PrometheusCollector.refresh()``. + Unit tests that never invoke ``refresh()`` would otherwise see an + empty snapshot and reject every recording call. Session-scoping + keeps the snapshot populated even when individual files install + their own (now redundant) seed-and-reset fixtures, so cross-file + test ordering under xdist ``loadfile`` can no longer leave an + empty snapshot in place between files on the same worker. + """ + update_label_snapshot( + _LabelSnapshot( + tool_names=frozenset({"web_search", "calculator", "t"}), + tool_names_seeded=True, + ), + ) + yield + _reset_label_snapshot_for_tests() + + @pytest.fixture def handler_cleanup() -> Iterator[list[logging.Handler]]: """Collect handlers and close them after the test.""" diff --git a/tests/unit/security/timeout/test_scheduler_lifecycle_locks.py b/tests/unit/security/timeout/test_scheduler_lifecycle_locks.py new file mode 100644 index 0000000000..72951bf0e8 --- /dev/null +++ b/tests/unit/security/timeout/test_scheduler_lifecycle_locks.py @@ -0,0 +1,123 @@ +"""Lifecycle-lock and unrestartable-flag tests for ApprovalTimeoutScheduler. + +Covers the canonical lifecycle pattern from +``docs/reference/lifecycle-sync.md``: + +* ``start()`` is idempotent under concurrent callers (the lifecycle + lock prevents duplicate task spawning). +* ``stop(timeout=...)`` sets the unrestartable ``_stop_failed`` flag + on drain timeout so a subsequent ``start()`` raises ``RuntimeError``. +""" + +import asyncio +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from synthorg.core.approval import ApprovalItem +from synthorg.core.enums import ApprovalRiskLevel +from synthorg.security.timeout.scheduler import ApprovalTimeoutScheduler + +pytestmark = pytest.mark.unit + + +def _make_store() -> MagicMock: + from synthorg.approval.protocol import ApprovalStoreProtocol + + store = MagicMock(spec=ApprovalStoreProtocol) + store.list_items = AsyncMock(return_value=()) + store.save_if_pending = AsyncMock( + side_effect=lambda item: ApprovalItem( + id=item.id, + action_type=item.action_type, + title=item.title, + description=item.description, + requested_by=item.requested_by, + risk_level=ApprovalRiskLevel.LOW, + created_at=datetime.now(UTC), + ), + ) + return store + + +def _make_checker() -> MagicMock: + from synthorg.security.timeout.timeout_checker import TimeoutChecker + + checker = MagicMock(spec=TimeoutChecker) + checker.check_and_resolve = AsyncMock(side_effect=lambda item: (item, None)) + return checker + + +class TestSchedulerLifecycleLock: + async def test_concurrent_start_calls_spawn_only_one_task(self) -> None: + """asyncio.gather of two start() calls produces a single task. + + Without the lifecycle lock, both callers could pass the + ``is_running`` guard before either reaches the + ``asyncio.create_task`` line, spawning duplicate scheduler + loops on the same store. + """ + scheduler = ApprovalTimeoutScheduler( + approval_store=_make_store(), + timeout_checker=_make_checker(), + interval_seconds=60.0, + ) + try: + await asyncio.gather(scheduler.start(), scheduler.start()) + assert scheduler.is_running + # Both calls converged on the same task instance. + first_task = scheduler._task + assert first_task is not None + finally: + await scheduler.stop() + + +class TestSchedulerStopFailedFlag: + async def test_stop_timeout_marks_unrestartable(self) -> None: + """A drain that exceeds ``timeout`` sets ``_stop_failed`` and raises. + + Construction of a fresh scheduler is the documented recovery + path because the prior task may still be in flight finishing + its cleanup; a new task spawned alongside it would break the + single-writer invariant. + """ + scheduler = ApprovalTimeoutScheduler( + approval_store=_make_store(), + timeout_checker=_make_checker(), + interval_seconds=60.0, + ) + await scheduler.start() + + # Patch the inner cancel-and-drain to hang so the wait_for + # bound trips. ``asyncio.Event().wait()`` blocks indefinitely + # but is cancellation-safe, so the wait_for can still fire its + # TimeoutError without leaking the helper coroutine. + async def _hang() -> None: + await asyncio.Event().wait() + + scheduler._cancel_and_drain = _hang # type: ignore[method-assign] + with pytest.raises(TimeoutError): + await scheduler.stop(timeout=0.05) + assert scheduler._stop_failed is True + + async def test_start_after_stop_failed_raises_runtime_error(self) -> None: + """Once ``_stop_failed`` is set, ``start()`` refuses to spawn.""" + scheduler = ApprovalTimeoutScheduler( + approval_store=_make_store(), + timeout_checker=_make_checker(), + interval_seconds=60.0, + ) + scheduler._stop_failed = True + with pytest.raises(RuntimeError, match="unrestartable"): + await scheduler.start() + + async def test_stop_negative_timeout_raises_value_error(self) -> None: + """Bounds-check the timeout argument at the system boundary.""" + scheduler = ApprovalTimeoutScheduler( + approval_store=_make_store(), + timeout_checker=_make_checker(), + interval_seconds=60.0, + ) + with pytest.raises(ValueError, match="must be > 0"): + await scheduler.stop(timeout=0) From 966cb2046315bc05ee16144fa49f93e2054483e5 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 3 May 2026 23:33:31 +0200 Subject: [PATCH 24/35] fix: babysit round 2, address CI failures and CodeRabbit/Gemini findings CI failures (3 integration tests): * AgentConfig was sweep-hardened with extra=forbid in the audit-bucket PR but the company-agents setting payload that setup_agents writes back at first-run carries a personality_preset key. The model now declares the field so the round-trip survives without weakening the forbid contract. * test_decide_stage_missing_task_returns_4xx posted decided_by which is not a StageDecisionPayload field; under extra=forbid Litestar rejects the request with 400 before the controller runs. Drop the stale field from the test body so the original missing-task path still gets exercised. * test_initiate_requires_connection_name now wires a real per-op rate-limit store + config into the Litestar test state and pins has_per_op_rate_limit_config=False on the AppState mock so the guard hits the State-dict fallback path instead of unpacking a MagicMock as the live config. CodeRabbit / Gemini findings: * approval_store: drop the repo-level pre-filter on status so lazy expirations the caller asked to see (status=EXPIRED) still surface; stage cache promotions in a side dict and only commit them after save_many succeeds; thread Clock through both _check_expiration_locked and _compute_expiration so tests drive expiry through the seam. * approval_store / pruning test: the test that previously patched approval_store.datetime swaps approval_store._clock.now instead. * prometheus_collector: broaden the registry-fetch swallow to Exception so a TaskGroup sibling failure cannot cancel workflow / department fetchers. logger.exception preserves the traceback. * backup retention + service_archive: catch UnicodeDecodeError alongside JSONDecodeError so a non-UTF-8 manifest does not abort the whole prune. * persistence: validate / clamp limit at the org_fact_repo (sqlite + postgres) and hr_repositories sqlite list paths so negative or oversized values cannot reach LIMIT and produce parity drift. * tests: spec the AsyncMock method bindings on the lifecycle test schedulers; rebuild the scheduler concurrent-start test on an asyncio.create_task call-count assertion; multi-item batch in the save_many upsert test so the batched executemany path is actually exercised; parametrise the SQLite JSON-CHECK test across all three nullable override columns; restore per-test snapshot seeding for prometheus_collector_new_metrics now that the session-scoped seed conflicted with the top-level autouse reset. * docs: fix the cobra config show / config list help text to match runtime; update model_matcher comment to use the actual constant names; update mcp_installation_repo docstring to reflect the default limit. * mock-spec baseline refreshed after the line-number shifts. Findings filtered out as INVALID: * The Gemini except A, B: -- syntax-error claims (4 sites) and the CodeRabbit / Gemini ConsolidationResult extra=forbid regression claim. PEP 758 makes the unparen except form valid in Python 3.14 (mypy + 26460 unit tests pass either way) and the consolidation carve-out is mandatory because the model declares a @computed_field that breaks the model_dump round-trip under strict extra. --- cli/cmd/config.go | 9 +++-- scripts/mock_spec_baseline.txt | 29 ++++++-------- src/synthorg/api/approval_store.py | 39 +++++++++++++++---- src/synthorg/backup/retention.py | 4 +- src/synthorg/backup/service_archive.py | 2 +- src/synthorg/config/schema.py | 9 +++++ .../observability/prometheus_collector.py | 15 +++---- .../persistence/postgres/org_fact_repo.py | 8 +++- .../persistence/sqlite/hr_repositories.py | 11 ++++++ .../sqlite/mcp_installation_repo.py | 7 +++- .../persistence/sqlite/org_fact_repo.py | 9 ++++- src/synthorg/templates/model_matcher.py | 10 ++--- .../persistence/test_approval_repository.py | 12 +++++- .../test_json_constraints_sqlite.py | 31 ++++++++++----- .../api/controllers/test_client_simulation.py | 6 ++- .../integrations/test_controllers.py | 29 +++++++++++++- tests/unit/api/test_app.py | 8 ++-- tests/unit/hr/pruning/test_service.py | 20 +++++----- tests/unit/observability/conftest.py | 32 +++------------ .../test_prometheus_collector_new_metrics.py | 24 ++++++------ .../timeout/test_scheduler_lifecycle_locks.py | 27 ++++++++++--- 21 files changed, 224 insertions(+), 117 deletions(-) diff --git a/cli/cmd/config.go b/cli/cmd/config.go index be57016e58..0132ab618e 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -61,9 +61,10 @@ var configShowCmd = &cobra.Command{ Long: `Display the resolved configuration as a single block. Renders every key from the config file alongside its current -value; values default to the built-in defaults when the file is -absent. For per-key resolution and source attribution use -'synthorg config list' instead.`, +value. If the config file is missing the command reports +"Not initialized" rather than rendering built-in defaults; use +'synthorg config list' for per-key resolution and source +attribution that still surfaces the default-value column.`, Example: ` synthorg config show # human-readable summary synthorg --json config show # JSON for scripts`, Args: cobra.NoArgs, @@ -178,7 +179,7 @@ var configListCmd = &cobra.Command{ Short: "Show all config keys with resolved value and source", Long: `List every settable config key with its resolved value and source. -Source is one of "default", "config-file", or "env" (env vars +Source is one of "default", "config", or "env" (env vars override the config file but cannot be set via 'config set'). Useful for debugging precedence when a value disagrees with what 'config show' implies.`, diff --git a/scripts/mock_spec_baseline.txt b/scripts/mock_spec_baseline.txt index aee7217ada..11f8eeb1ee 100644 --- a/scripts/mock_spec_baseline.txt +++ b/scripts/mock_spec_baseline.txt @@ -111,15 +111,15 @@ tests/integration/integrations/test_controllers.py:877:30 tests/integration/integrations/test_controllers.py:885:17 tests/integration/integrations/test_controllers.py:886:25 tests/integration/integrations/test_controllers.py:887:30 -tests/integration/integrations/test_controllers.py:969:18 -tests/integration/integrations/test_controllers.py:970:31 -tests/integration/integrations/test_controllers.py:971:34 -tests/integration/integrations/test_controllers.py:972:30 -tests/integration/integrations/test_controllers.py:1009:25 -tests/integration/integrations/test_controllers.py:1060:18 -tests/integration/integrations/test_controllers.py:1061:27 -tests/integration/integrations/test_controllers.py:1078:18 -tests/integration/integrations/test_controllers.py:1079:31 +tests/integration/integrations/test_controllers.py:996:18 +tests/integration/integrations/test_controllers.py:997:31 +tests/integration/integrations/test_controllers.py:998:34 +tests/integration/integrations/test_controllers.py:999:30 +tests/integration/integrations/test_controllers.py:1036:25 +tests/integration/integrations/test_controllers.py:1087:18 +tests/integration/integrations/test_controllers.py:1088:27 +tests/integration/integrations/test_controllers.py:1105:18 +tests/integration/integrations/test_controllers.py:1106:31 tests/integration/integrations/test_oauth_flows.py:51:11 tests/integration/integrations/test_oauth_flows.py:54:28 tests/integration/integrations/test_oauth_flows.py:84:22 @@ -427,10 +427,6 @@ tests/unit/api/test_app.py:420:32 tests/unit/api/test_app.py:423:31 tests/unit/api/test_app.py:454:18 tests/unit/api/test_app.py:455:23 -tests/unit/api/test_app.py:481:27 -tests/unit/api/test_app.py:482:26 -tests/unit/api/test_app.py:527:27 -tests/unit/api/test_app.py:528:26 tests/unit/api/test_app.py:949:24 tests/unit/api/test_app.py:1010:28 tests/unit/api/test_app.py:1068:30 @@ -1370,8 +1366,8 @@ tests/unit/hr/performance/test_tracker_enhancements.py:232:24 tests/unit/hr/performance/test_tracker_enhancements.py:233:30 tests/unit/hr/performance/test_tracker_enhancements.py:237:23 tests/unit/hr/performance/test_tracker_enhancements.py:239:30 -tests/unit/hr/pruning/test_service.py:566:19 -tests/unit/hr/pruning/test_service.py:600:19 +tests/unit/hr/pruning/test_service.py:568:19 +tests/unit/hr/pruning/test_service.py:602:19 tests/unit/hr/test_activity_list_recent.py:50:11 tests/unit/hr/test_activity_list_recent.py:57:14 tests/unit/hr/test_activity_list_recent.py:58:31 @@ -2812,9 +2808,6 @@ tests/unit/security/timeout/test_scheduler.py:430:27 tests/unit/security/timeout/test_scheduler.py:453:36 tests/unit/security/timeout/test_scheduler.py:474:32 tests/unit/security/timeout/test_scheduler.py:497:19 -tests/unit/security/timeout/test_scheduler_lifecycle_locks.py:29:23 -tests/unit/security/timeout/test_scheduler_lifecycle_locks.py:30:28 -tests/unit/security/timeout/test_scheduler_lifecycle_locks.py:48:32 tests/unit/security/timeout/test_timeout_checker.py:38:18 tests/unit/security/timeout/test_timeout_checker.py:147:22 tests/unit/settings/test_bridge_config_wiring.py:184:40 diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index e193ef6b3a..48c7469204 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -35,10 +35,10 @@ import asyncio from collections.abc import Callable # noqa: TC003 -from datetime import UTC, datetime from typing import TYPE_CHECKING from synthorg.core.approval import ApprovalItem # noqa: TC001 +from synthorg.core.clock import Clock, SystemClock from synthorg.core.domain_errors import ConflictError from synthorg.core.enums import ( ApprovalRiskLevel, @@ -86,10 +86,17 @@ def __init__( *, on_expire: Callable[[ApprovalItem], None] | None = None, repo: ApprovalRepository | None = None, + clock: Clock | None = None, ) -> None: self._items: dict[str, ApprovalItem] = {} self._on_expire = on_expire self._repo = repo + # Clock seam: lazy-expiration checks on both the scalar + # ``_check_expiration_locked`` and the batch ``_compute_expiration`` + # paths read time through ``self._clock`` so tests can drive + # expiry deterministically with ``FakeClock`` instead of + # patching ``datetime.now`` globally. + self._clock: Clock = clock if clock is not None else SystemClock() self._lock = asyncio.Lock() # Approval ids whose ``save()`` is currently mid-flight. A # second concurrent ``save(same_id)`` observes the marker and @@ -264,9 +271,16 @@ async def _list_from_repo_locked( """Repo-backed list path with batched expiry persistence. Pure-compute pass first collects the EXPIRED transitions into - to_persist and transitions the cache; the persistence write - fans out as a single ``save_many`` so K simultaneous expiries - yield one DB round-trip rather than K. + ``to_persist`` and stages cache updates in a side dict; the + persistence write fans out as a single ``save_many`` so K + simultaneous expiries yield one DB round-trip rather than K. + Cache promotions only land *after* ``save_many`` succeeds so a + failed batch cannot leave an EXPIRED row in the cache that + contradicts the still-PENDING repo state, and the in-memory + ``status`` filter is applied here rather than pushed into the + repo query so a caller asking for ``ApprovalStatus.EXPIRED`` + still observes lazily-promoted items that the repo persists as + PENDING. Side effects after the batch save: @@ -277,8 +291,11 @@ async def _list_from_repo_locked( logged at ERROR but do not unwind the expiration). """ assert self._repo is not None # noqa: S101 -- caller invariant + # Pull every candidate row regardless of the caller's status + # filter; the lazy expiration pass below may promote PENDING + # items into the requested status (typically EXPIRED) and a + # repo-level pre-filter on ``status`` would hide them. repo_items = await self._repo.list_items( - status=status, risk_level=risk_level, action_type=action_type, ) @@ -286,18 +303,24 @@ async def _list_from_repo_locked( self._items[item.id] = item result: list[ApprovalItem] = [] to_persist: list[ApprovalItem] = [] + cache_updates: dict[str, ApprovalItem] = {} for item in repo_items: checked = self._compute_expiration(item) if checked is not item: to_persist.append(checked) - self._items[item.id] = checked + cache_updates[item.id] = checked if status is not None and checked.status != status: continue if risk_level is not None and checked.risk_level != risk_level: continue result.append(checked) if to_persist: + # Durable write first; only mutate the cache once the + # batch commits. Any callback or audit-event emission + # follows the cache update so observers cannot see a + # cached EXPIRED state that the repo never accepted. await self._repo.save_many(to_persist) + self._items.update(cache_updates) for expired in to_persist: logger.info( APPROVAL_STATUS_TRANSITIONED, @@ -522,7 +545,7 @@ async def _check_expiration_locked( if ( item.status == ApprovalStatus.PENDING and item.expires_at is not None - and datetime.now(UTC) >= item.expires_at + and self._clock.now() >= item.expires_at ): expired = item.model_copy( update={"status": ApprovalStatus.EXPIRED}, @@ -581,7 +604,7 @@ def _compute_expiration(self, item: ApprovalItem) -> ApprovalItem: if ( item.status == ApprovalStatus.PENDING and item.expires_at is not None - and datetime.now(UTC) >= item.expires_at + and self._clock.now() >= item.expires_at ): return item.model_copy(update={"status": ApprovalStatus.EXPIRED}) return item diff --git a/src/synthorg/backup/retention.py b/src/synthorg/backup/retention.py index daa00b4775..714bab8801 100644 --- a/src/synthorg/backup/retention.py +++ b/src/synthorg/backup/retention.py @@ -173,7 +173,7 @@ def _load_dir_manifest(entry: Path) -> BackupManifest | None: try: data = json.loads(manifest_path.read_text(encoding="utf-8")) return BackupManifest.model_validate(data) - except json.JSONDecodeError as exc: + except (json.JSONDecodeError, UnicodeDecodeError) as exc: logger.warning( BACKUP_MANIFEST_INVALID, path=str(manifest_path), @@ -228,7 +228,7 @@ def _load_archive_manifest(entry: Path) -> BackupManifest | None: # noqa: PLR09 error=safe_error_description(exc), ) return None - except json.JSONDecodeError as exc: + except (json.JSONDecodeError, UnicodeDecodeError) as exc: logger.warning( BACKUP_MANIFEST_INVALID, path=str(entry), diff --git a/src/synthorg/backup/service_archive.py b/src/synthorg/backup/service_archive.py index e69f6de76e..14306fc7e0 100644 --- a/src/synthorg/backup/service_archive.py +++ b/src/synthorg/backup/service_archive.py @@ -447,7 +447,7 @@ def _read_manifest_from_archive( # noqa: PLR0911 error=safe_error_description(exc), ) return None - except json.JSONDecodeError as exc: + except (json.JSONDecodeError, UnicodeDecodeError) as exc: logger.warning( BACKUP_MANIFEST_INVALID, path=str(archive_path), diff --git a/src/synthorg/config/schema.py b/src/synthorg/config/schema.py index 841d6f72e3..c1db4ac378 100644 --- a/src/synthorg/config/schema.py +++ b/src/synthorg/config/schema.py @@ -183,6 +183,15 @@ class AgentConfig(BaseModel): default=SeniorityLevel.MID, description="Seniority level", ) + personality_preset: NotBlankStr | None = Field( + default=None, + description=( + "Named personality preset. ``setup_agents`` writes the " + "resolved preset name back when bootstrapping from a " + "template, so the company-agents setting must round-trip " + "the field rather than reject it under ``extra=forbid``." + ), + ) personality: dict[str, Any] = Field( default_factory=dict, description="Raw personality config", diff --git a/src/synthorg/observability/prometheus_collector.py b/src/synthorg/observability/prometheus_collector.py index 5c3221a32b..a3387af6da 100644 --- a/src/synthorg/observability/prometheus_collector.py +++ b/src/synthorg/observability/prometheus_collector.py @@ -132,13 +132,14 @@ async def _fetch_tool_names(app_state: AppState) -> frozenset[str] | None: return frozenset(registry.list_tools()) except MemoryError, RecursionError: raise - except AttributeError, TypeError, ValueError: - # Narrow the swallow to the specific shapes a malformed or - # partially-initialised registry can produce. ERROR rather - # than WARNING so operators can alert on stale snapshots -- - # if this fires, the previous allowlist is preserved silently - # and tool_name labels keep using stale values until the - # registry recovers. + except Exception: + # ``_fetch_tool_names`` runs inside a ``TaskGroup`` alongside + # the workflow / department fetchers; an uncaught exception + # here would cancel its siblings via the structured-concurrency + # contract and lose their snapshot updates too. Catch broadly, + # log via ``logger.exception`` so the traceback survives, and + # fall back to ``None`` so the merge step preserves the prior + # tool-name allowlist. logger.exception( METRICS_SCRAPE_FAILED, component="tool_registry", diff --git a/src/synthorg/persistence/postgres/org_fact_repo.py b/src/synthorg/persistence/postgres/org_fact_repo.py index 371cbaed2f..868d6858e9 100644 --- a/src/synthorg/persistence/postgres/org_fact_repo.py +++ b/src/synthorg/persistence/postgres/org_fact_repo.py @@ -481,9 +481,15 @@ async def list_by_category( "ORDER BY created_at DESC, fact_id ASC" ) params: tuple[object, ...] = (category.value,) + # Clamp ``limit`` at the repository boundary: PostgreSQL + # rejects negative LIMIT (SQLSTATE 2201W) and an unbounded + # value would defeat the page-size invariant the protocol + # documents. Mirrors the clamp on the sibling ``query`` + # method so both paths share the same bounded contract. + effective_limit = max(1, min(int(limit), 100)) effective_offset = max(0, int(offset)) sql += " LIMIT %s OFFSET %s" - params = (*params, int(limit), effective_offset) + params = (*params, effective_limit, effective_offset) try: async with ( self._pool.connection() as conn, diff --git a/src/synthorg/persistence/sqlite/hr_repositories.py b/src/synthorg/persistence/sqlite/hr_repositories.py index 00aca43957..5cd2e04f6b 100644 --- a/src/synthorg/persistence/sqlite/hr_repositories.py +++ b/src/synthorg/persistence/sqlite/hr_repositories.py @@ -132,6 +132,17 @@ async def list_events( if clauses: sql += " WHERE " + " AND ".join(clauses) sql += " ORDER BY timestamp DESC" + # Validate ``limit`` at the boundary: SQLite's ``LIMIT -1`` + # idiom would silently lift the cap, and Postgres rejects + # negative LIMIT outright. Match the Postgres sibling's + # validation so a bad caller fails loud on both backends. + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + msg = f"limit must be a positive integer, got {limit!r}" + logger.warning( + PERSISTENCE_LIFECYCLE_EVENT_LIST_FAILED, + error=msg, + ) + raise QueryError(msg) sql += " LIMIT ?" params.append(limit) diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index 7b8c484f2e..84bf96734d 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -107,7 +107,12 @@ async def list_all( limit: int = 100, offset: int = 0, ) -> tuple[McpInstallation, ...]: - """List all recorded installations, oldest-first.""" + """Return up to ``limit`` recorded installations, oldest-first. + + ``limit`` defaults to 100 (matches the protocol-wide pagination + floor); callers needing more must loop with ``offset`` rather + than passing a larger ``limit``. + """ sql = ( "SELECT catalog_entry_id, connection_name, installed_at " "FROM mcp_installations " diff --git a/src/synthorg/persistence/sqlite/org_fact_repo.py b/src/synthorg/persistence/sqlite/org_fact_repo.py index 8446a94b0f..6de69a89be 100644 --- a/src/synthorg/persistence/sqlite/org_fact_repo.py +++ b/src/synthorg/persistence/sqlite/org_fact_repo.py @@ -498,9 +498,16 @@ async def list_by_category( "ORDER BY created_at DESC, fact_id ASC" ) params: tuple[object, ...] = (category.value,) + # Clamp ``limit`` to a sane positive range at the boundary so + # SQLite's ``LIMIT -1`` "unlimited" semantics cannot leak in + # via a caller passing a negative or oversized value, and so + # the SQLite path agrees with Postgres on the bounded contract + # (Postgres rejects negative LIMIT outright; SQLite would + # silently drop the cap). + effective_limit = max(1, min(int(limit), 100)) effective_offset = max(0, int(offset)) sql += " LIMIT ? OFFSET ?" - params = (*params, int(limit), effective_offset) + params = (*params, effective_limit, effective_offset) try: cursor = await self._db.execute(sql, params) rows = await cursor.fetchall() diff --git a/src/synthorg/templates/model_matcher.py b/src/synthorg/templates/model_matcher.py index 4a43a5ca5a..2a5f7bcae3 100644 --- a/src/synthorg/templates/model_matcher.py +++ b/src/synthorg/templates/model_matcher.py @@ -358,11 +358,11 @@ def _rank_by_priority( return min(models, key=lambda m: abs(m.cost_per_1k_input - mid)) -# Three score components, each contributing up to ``_TIER_BASE`` / -# ``_HEADROOM_MAX`` / ``_PRIORITY_MAX``. Sum is capped at 1.0 by -# ``_compute_score``. ``_HEADROOM_RATIO_CAP`` clamps the headroom curve -# so a model with 10x the requested context does not displace a tighter -# fit on the priority axis. +# Three score components, each contributing up to ``_TIER_BASE_SCORE`` / +# ``_HEADROOM_MAX_BONUS`` / ``_PRIORITY_MAX_BONUS``. Sum is capped at +# 1.0 by ``_compute_score``. ``_HEADROOM_RATIO_CAP`` clamps the +# headroom curve so a model with 10x the requested context does not +# displace a tighter fit on the priority axis. _TIER_BASE_SCORE = 0.5 _HEADROOM_MAX_BONUS = 0.25 _PRIORITY_MAX_BONUS = 0.25 diff --git a/tests/conformance/persistence/test_approval_repository.py b/tests/conformance/persistence/test_approval_repository.py index 467cdbeb2c..2f371e70e5 100644 --- a/tests/conformance/persistence/test_approval_repository.py +++ b/tests/conformance/persistence/test_approval_repository.py @@ -303,17 +303,25 @@ async def test_save_many_upserts_existing_rows( ) -> None: # save_many must obey the same upsert semantics as save() so a # batched expiry loop can transition PENDING to EXPIRED on - # already-persisted items in one call. + # already-persisted items in one call. Use a multi-item batch + # (a peer fresh insert + the upsert under test) so the repo + # actually exercises its executemany / batched-upsert path + # rather than delegating to the single-item ``save()`` + # fast-path that both backends short-circuit on len(items)==1. repo = _approval_repo(backend) original = _make_item(approval_id="approval-batch-upsert") await repo.save(original) updated = original.model_copy(update={"status": ApprovalStatus.EXPIRED}) - await repo.save_many((updated,)) + peer = _make_item(approval_id="approval-batch-upsert-peer") + await repo.save_many((updated, peer)) fetched = await repo.get(original.id) assert fetched is not None assert fetched.status is ApprovalStatus.EXPIRED + peer_fetched = await repo.get(peer.id) + assert peer_fetched is not None + assert peer_fetched.status is ApprovalStatus.PENDING async def test_save_many_duplicate_ids_within_batch_settle_to_last( self, diff --git a/tests/conformance/persistence/test_json_constraints_sqlite.py b/tests/conformance/persistence/test_json_constraints_sqlite.py index ceaf8856c8..9552354dde 100644 --- a/tests/conformance/persistence/test_json_constraints_sqlite.py +++ b/tests/conformance/persistence/test_json_constraints_sqlite.py @@ -52,28 +52,41 @@ async def _attempt_insert() -> None: with pytest.raises(aiosqlite.IntegrityError): await _attempt_insert() - async def test_preset_overrides_default_models_rejects_non_json( + @pytest.mark.parametrize( + "column_name", + ["default_models", "supported_auth_types", "candidate_urls"], + ) + async def test_preset_overrides_nullable_json_columns_reject_non_json( self, backend: PersistenceBackend, + column_name: str, ) -> None: - """``preset_overrides.default_models`` accepts NULL but rejects non-JSON.""" + """Each nullable JSON override column rejects malformed JSON. + + The migration installs a separate ``CHECK (col IS NULL OR + json_valid(col))`` clause per column. Parametrising across + all three guards against a typo or missed column in the + schema/migration that would otherwise pass green if only one + column was exercised. + """ if backend.backend_name != "sqlite": pytest.skip("SQLite-only constraint") conn = cast("aiosqlite.Connection", backend.get_db()) async def _attempt_insert() -> None: + # ``column_name`` is a closed parametrise enum, never user + # input, so the f-string is safe; suppress S608 to keep + # parametrise readable rather than building per-column + # branches. await conn.execute( - "INSERT INTO preset_overrides " - "(preset_name, base_url, default_models, " - "supported_auth_types, candidate_urls, " + "INSERT INTO preset_overrides " # noqa: S608 + f"(preset_name, base_url, {column_name}, " "updated_at, updated_by) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", + "VALUES (?, ?, ?, ?, ?)", ( - "example-preset", + f"example-preset-{column_name}", "https://example.invalid", "not-json", - None, - None, "2026-05-03T12:00:00+00:00", "operator-1", ), diff --git a/tests/integration/api/controllers/test_client_simulation.py b/tests/integration/api/controllers/test_client_simulation.py index 8f4d85a401..d5b31e13d9 100644 --- a/tests/integration/api/controllers/test_client_simulation.py +++ b/tests/integration/api/controllers/test_client_simulation.py @@ -371,13 +371,17 @@ async def test_decide_stage_missing_task_returns_4xx( json={ "verdict": "pass", "reason": "manual override", - "decided_by": "ceo", }, ) # Without a task_engine the lookup may 404/503 instead # of producing a decision. Accept any 4xx/5xx defensive # response here; the happy path is exercised via # dedicated unit tests for the review controller. + # ``StageDecisionPayload`` enforces ``extra="forbid"``, so + # the body sent above only carries the fields the model + # declares; a Pydantic-level rejection would otherwise + # surface as 400 and mask the missing-task path that this + # smoke test exists to assert. assert resp.status_code in {404, 409, 503} diff --git a/tests/integration/integrations/test_controllers.py b/tests/integration/integrations/test_controllers.py index 633d3738e1..8e40734e06 100644 --- a/tests/integration/integrations/test_controllers.py +++ b/tests/integration/integrations/test_controllers.py @@ -933,16 +933,43 @@ async def handle( scope["user"] = _TestUser() await next_app(scope, receive, send) + from synthorg.api.rate_limits import InMemorySlidingWindowStore + from synthorg.api.rate_limits._subject import ( + STATE_KEY_CONFIG, + STATE_KEY_STORE, + ) + from synthorg.api.rate_limits.config import PerOpRateLimitConfig from synthorg.api.state import AppState app_state_stub = MagicMock(spec=AppState) + # ``MagicMock(spec=AppState)`` would otherwise satisfy the + # ``has_per_op_rate_limit_config`` getattr probe and pass back + # another MagicMock as the live config; unpacking + # ``mock.overrides.get(...)`` into ``(limit_max, limit_window)`` + # then explodes with "expected 2, got 0". Force the rate-limit + # guard down its Litestar-state-dict fallback path so it picks + # up the real config installed below. + app_state_stub.has_per_op_rate_limit_config = False + # The route applies a per-op rate-limit guard which runs ahead + # of body validation. Without a wired store + config the guard + # raises ``ServiceUnavailableError`` (503) and masks the + # body-bind 400 this test exists to assert. Install a real + # in-memory store and the registry-default config so the + # guard returns success and the request flows into Litestar's + # validation layer. api_router = Router( path="/api/v1", route_handlers=[OAuthController], ) app = Litestar( route_handlers=[api_router], - state=LitestarState({"app_state": app_state_stub}), + state=LitestarState( + { + "app_state": app_state_stub, + STATE_KEY_STORE: InMemorySlidingWindowStore(), + STATE_KEY_CONFIG: PerOpRateLimitConfig(), + }, + ), middleware=[_InjectUserMiddleware()], exception_handlers=dict(EXCEPTION_HANDLERS), # type: ignore[arg-type] ) diff --git a/tests/unit/api/test_app.py b/tests/unit/api/test_app.py index e680a93a84..971a2d8a9e 100644 --- a/tests/unit/api/test_app.py +++ b/tests/unit/api/test_app.py @@ -478,8 +478,8 @@ async def test_meeting_scheduler_lifecycle( persistence = FakePersistenceBackend() bus = FakeMessageBus() mock_sched = MagicMock(spec=MeetingScheduler) - mock_sched.start = AsyncMock() - mock_sched.stop = AsyncMock() + mock_sched.start = AsyncMock(spec=MeetingScheduler.start) + mock_sched.stop = AsyncMock(spec=MeetingScheduler.stop) app_state = AppState( config=root_config, @@ -524,8 +524,8 @@ async def test_approval_timeout_scheduler_lifecycle( persistence = FakePersistenceBackend() bus = FakeMessageBus() mock_sched = MagicMock(spec=ApprovalTimeoutScheduler) - mock_sched.start = AsyncMock() - mock_sched.stop = AsyncMock() + mock_sched.start = AsyncMock(spec=ApprovalTimeoutScheduler.start) + mock_sched.stop = AsyncMock(spec=ApprovalTimeoutScheduler.stop) app_state = AppState( config=root_config, diff --git a/tests/unit/hr/pruning/test_service.py b/tests/unit/hr/pruning/test_service.py index 5f302fd74e..5918bd6af7 100644 --- a/tests/unit/hr/pruning/test_service.py +++ b/tests/unit/hr/pruning/test_service.py @@ -1,7 +1,7 @@ """Tests for PruningService.""" from datetime import UTC, datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest @@ -320,14 +320,14 @@ async def test_cycle_deduplicates_pending_approvals( # Pin wall-clock to NOW so the lazy-expiration check inside # ApprovalStore._check_expiration_locked sees the same time as - # the pruning cycle. Without this, real datetime.now(UTC) may - # exceed expires_at and silently expire the first approval. - with patch( - "synthorg.api.approval_store.datetime", - wraps=datetime, - ) as mock_dt: - mock_dt.now.return_value = NOW - + # the pruning cycle. Without this, real wall-clock may exceed + # expires_at and silently expire the first approval. The + # store reads time through its injected Clock seam, so swap + # ``approval_store._clock.now`` for a fixed-NOW callable for + # the duration of the test. + original_now = approval_store._clock.now + approval_store._clock.now = lambda: NOW # type: ignore[method-assign] + try: # First cycle creates approval. job1 = await service.run_pruning_cycle(now=NOW) assert job1.approval_requests_created == 1 @@ -338,6 +338,8 @@ async def test_cycle_deduplicates_pending_approvals( items = await approval_store.list_items(action_type="hr:prune") assert len(items) == 1 + finally: + approval_store._clock.now = original_now # type: ignore[method-assign] async def test_cycle_aggregates_errors_without_stopping( self, diff --git a/tests/unit/observability/conftest.py b/tests/unit/observability/conftest.py index cc403f253e..0a9ea29180 100644 --- a/tests/unit/observability/conftest.py +++ b/tests/unit/observability/conftest.py @@ -19,11 +19,6 @@ SyslogFacility, SyslogProtocol, ) -from synthorg.observability.prometheus_labels import ( - _LabelSnapshot, - _reset_label_snapshot_for_tests, - update_label_snapshot, -) from tests.conftest import clear_logging_state # -- Factories -------------------------------------------------------------- @@ -87,27 +82,12 @@ def _reset_logging() -> Iterator[None]: clear_logging_state() -@pytest.fixture(autouse=True, scope="session") -def _seed_prometheus_label_snapshot() -> Iterator[None]: - """Seed the Prometheus label snapshot for all observability tests. - - ``record_tool_invocation`` validates the ``tool_name`` label - against the snapshot maintained by ``PrometheusCollector.refresh()``. - Unit tests that never invoke ``refresh()`` would otherwise see an - empty snapshot and reject every recording call. Session-scoping - keeps the snapshot populated even when individual files install - their own (now redundant) seed-and-reset fixtures, so cross-file - test ordering under xdist ``loadfile`` can no longer leave an - empty snapshot in place between files on the same worker. - """ - update_label_snapshot( - _LabelSnapshot( - tool_names=frozenset({"web_search", "calculator", "t"}), - tool_names_seeded=True, - ), - ) - yield - _reset_label_snapshot_for_tests() +# Per-test snapshot seeding lives in the individual file fixtures +# (e.g. test_prometheus_collector_new_metrics.py). A session-scoped +# autouse seed here would be wiped immediately by the top-level +# ``tests/conftest.py`` autouse reset that runs before each test, so +# the seeding has to be function-scoped to be observable inside the +# test body. @pytest.fixture diff --git a/tests/unit/observability/test_prometheus_collector_new_metrics.py b/tests/unit/observability/test_prometheus_collector_new_metrics.py index 996bedc45a..f80504369b 100644 --- a/tests/unit/observability/test_prometheus_collector_new_metrics.py +++ b/tests/unit/observability/test_prometheus_collector_new_metrics.py @@ -7,8 +7,6 @@ instead of silently polluting the metric family. """ -from collections.abc import Generator - import pytest from prometheus_client import generate_latest from prometheus_client.parser import text_string_to_metric_families @@ -16,7 +14,6 @@ from synthorg.observability.prometheus_collector import PrometheusCollector from synthorg.observability.prometheus_labels import ( _LabelSnapshot, - _reset_label_snapshot_for_tests, status_class, update_label_snapshot, ) @@ -25,13 +22,18 @@ @pytest.fixture(autouse=True) -def _seed_tool_name_snapshot() -> Generator[None]: - """Seed the prometheus label snapshot with bounded tool-name values. - - record_tool_invocation now bounds ``tool_name`` against the - snapshot maintained by PrometheusCollector.refresh(); these unit - tests never invoke refresh() so we seed manually. Reset on - teardown so cross-file tests start from a clean snapshot. +def _seed_tool_name_snapshot() -> None: + """Seed the prometheus label snapshot per test. + + ``record_tool_invocation`` validates ``tool_name`` against the + snapshot maintained by ``PrometheusCollector.refresh()``; these + unit tests never invoke ``refresh`` so we seed manually. The + top-level ``tests/conftest.py`` autouse fixture resets the + snapshot before AND after every test, so seeding here is + function-scoped (a session-scoped seed would be wiped + immediately) and a per-test teardown reset is redundant -- the + top-level fixture already handles cleanup, so this fixture + cannot leave a populated snapshot leaking into unrelated files. """ update_label_snapshot( _LabelSnapshot( @@ -39,8 +41,6 @@ def _seed_tool_name_snapshot() -> Generator[None]: tool_names_seeded=True, ), ) - yield - _reset_label_snapshot_for_tests() def _parse( diff --git a/tests/unit/security/timeout/test_scheduler_lifecycle_locks.py b/tests/unit/security/timeout/test_scheduler_lifecycle_locks.py index 72951bf0e8..0d671b403e 100644 --- a/tests/unit/security/timeout/test_scheduler_lifecycle_locks.py +++ b/tests/unit/security/timeout/test_scheduler_lifecycle_locks.py @@ -11,7 +11,7 @@ import asyncio from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -26,8 +26,12 @@ def _make_store() -> MagicMock: from synthorg.approval.protocol import ApprovalStoreProtocol store = MagicMock(spec=ApprovalStoreProtocol) - store.list_items = AsyncMock(return_value=()) + store.list_items = AsyncMock( + spec=ApprovalStoreProtocol.list_items, + return_value=(), + ) store.save_if_pending = AsyncMock( + spec=ApprovalStoreProtocol.save_if_pending, side_effect=lambda item: ApprovalItem( id=item.id, action_type=item.action_type, @@ -45,7 +49,10 @@ def _make_checker() -> MagicMock: from synthorg.security.timeout.timeout_checker import TimeoutChecker checker = MagicMock(spec=TimeoutChecker) - checker.check_and_resolve = AsyncMock(side_effect=lambda item: (item, None)) + checker.check_and_resolve = AsyncMock( + spec=TimeoutChecker.check_and_resolve, + side_effect=lambda item: (item, None), + ) return checker @@ -64,9 +71,19 @@ async def test_concurrent_start_calls_spawn_only_one_task(self) -> None: interval_seconds=60.0, ) try: - await asyncio.gather(scheduler.start(), scheduler.start()) + # Patch the underlying task spawn so the test asserts on + # call count, not just on the post-condition snapshot. + # ``is_running`` and a non-None ``_task`` could both be + # true even if a brief duplicate task got created and + # immediately collapsed; counting spawns is the only way + # to prove the lifecycle lock actually serialised. + with patch( + "asyncio.create_task", + wraps=asyncio.create_task, + ) as create_task: + await asyncio.gather(scheduler.start(), scheduler.start()) + assert create_task.call_count == 1 assert scheduler.is_running - # Both calls converged on the same task instance. first_task = scheduler._task assert first_task is not None finally: From e5e88d23c8bc2852f299a5cd8a52a03686f4047b Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 00:00:18 +0200 Subject: [PATCH 25/35] test: drop security.timeout_check_interval_seconds from ghost-wired baseline The ghost-wired-settings trace lint correctly stopped flagging security.timeout_check_interval_seconds the moment ApprovalTimeoutScheduler got wired into the lifespan startup. The real-repo smoke test still expected the violation, so move the setting from expected_positives into must_not_flag to lock in the new wired contract. --- .../scripts/test_check_setting_to_startup_trace.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/unit/scripts/test_check_setting_to_startup_trace.py b/tests/unit/scripts/test_check_setting_to_startup_trace.py index f3cebca31c..13d5a9d176 100644 --- a/tests/unit/scripts/test_check_setting_to_startup_trace.py +++ b/tests/unit/scripts/test_check_setting_to_startup_trace.py @@ -166,11 +166,13 @@ def test_inventory_rejects_empty_suppression_justification(tmp_path: Path) -> No def test_real_repo_violations_match_expected() -> None: """Lint against the actual src/synthorg/ tree. - Asserts exactly the 8 expected ghost-wired violations: - - - 7 ``backup.*`` settings (BackupService factory-gated by default). - - ``security.timeout_check_interval_seconds`` (ApprovalTimeoutScheduler - hardcoded to None in app.py). + Asserts exactly the 7 expected ghost-wired violations: the + ``backup.*`` settings the BackupService factory still resolves + only when the operator opts in. ``security.timeout_check_interval_seconds`` + used to ghost-wire here as well; the audit-bucket PR wired + ``ApprovalTimeoutScheduler`` into the lifespan startup so the + setting is now resolved on every cold start, and the lint should + no longer flag it. Asserts zero false positives on the negative ``security.*`` settings (audit_enabled, post_tool_scanning_enabled, ...) and on @@ -194,7 +196,6 @@ def test_real_repo_violations_match_expected() -> None: "backup.path", "backup.retention_days", "backup.schedule_hours", - "security.timeout_check_interval_seconds", } assert expected_positives.issubset(flagged), ( f"missing expected positives: {expected_positives - flagged}" @@ -208,6 +209,7 @@ def test_real_repo_violations_match_expected() -> None: "security.audit_retention_days", "security.retention_cleanup_paused", "security.auth_token_bytes", + "security.timeout_check_interval_seconds", "engine.timeout_enforcement_enabled", } leaked = flagged & must_not_flag From 0d1692456f7e487241bf6e981e32730c7212eceb Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 00:32:34 +0200 Subject: [PATCH 26/35] fix: babysit round 4, address CodeRabbit findings on the new head * approval_store: paginate the repo list_items call instead of relying on a single 100-row page so older PENDING rows still reach the lazy-expiration pass. * circuit_breaker: record_delegation now reads pair state under _state_lock (the prior get_state read happened outside the lock, letting two threads both see CLOSED and double-bump trip_count past intended backoff levels). load_state and persist_dirty also acquire the lock; persist_dirty snapshots dirty pairs under the lock and only clears the dirty marker after confirming the cached pair has not been mutated since the snapshot, so a concurrent record_delegation cannot lose its dirty state behind a stale save. * lifecycle.py: rename error_detail to the standard error key on the unrestartable-scheduler diagnostic so log queries keying on error / error_type still match. * engine/health/models.py: EscalationTicket.metadata is now wrapped in MappingProxyType at construction so the documented frozen-model contract holds for nested dict mutations, not just attribute rebinding. * task_engine.list_tasks: limit=None still pre-clamps the repo call to MAX_LIST_RESULTS (the safety cap), but true_total now takes the maximum of the persistence count and the observed list length so the legacy fetch-all path keeps reporting accurate totals AND the safety-cap warning still fires when a non-clamping repo returns more than the cap. * mcp_installation_repo (sqlite + postgres): clamp limit at the repository boundary so negative or oversized values cannot reach the LIMIT clause; mirrors the org_fact_repo and hr_repositories clamps from round 2. * user_repo (sqlite + postgres): docstrings updated to describe the bounded page contract that limit=100 now imposes. * test_circuit_breaker: TOCTOU regression now drives a real concurrent mutation through a sibling thread that races check()'s critical section, so the assertion only passes when the OPEN-branch decision actually runs under _state_lock. * mock-spec baseline refreshed after the line-number shifts. All 10 new CodeRabbit findings on the round-2 head addressed. Full unit suite: 26498 passed. --- scripts/mock_spec_baseline.txt | 8 +- src/synthorg/api/approval_store.py | 24 ++++- src/synthorg/api/lifecycle.py | 4 +- .../loop_prevention/circuit_breaker.py | 102 ++++++++++++++---- src/synthorg/engine/health/models.py | 25 +++-- src/synthorg/engine/task_engine.py | 44 +++++--- .../postgres/mcp_installation_repo.py | 7 +- .../persistence/postgres/user_repo.py | 6 +- .../sqlite/mcp_installation_repo.py | 7 +- src/synthorg/persistence/sqlite/user_repo.py | 11 +- .../loop_prevention/test_circuit_breaker.py | 59 +++++++++- 11 files changed, 234 insertions(+), 63 deletions(-) diff --git a/scripts/mock_spec_baseline.txt b/scripts/mock_spec_baseline.txt index 11f8eeb1ee..97a8a9d122 100644 --- a/scripts/mock_spec_baseline.txt +++ b/scripts/mock_spec_baseline.txt @@ -618,10 +618,10 @@ tests/unit/communication/bus/test_nats_consumer_config.py:31:9 tests/unit/communication/bus/test_nats_consumer_config.py:33:24 tests/unit/communication/bus/test_nats_consumer_config.py:33:47 tests/unit/communication/bus/test_nats_consumer_config.py:44:12 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:293:15 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:294:20 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:316:15 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:317:24 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:296:15 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:297:20 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:319:15 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:320:24 tests/unit/communication/meeting/test_agent_caller.py:102:25 tests/unit/communication/meeting/test_agent_caller.py:104:15 tests/unit/communication/meeting/test_agent_caller.py:106:28 diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 48c7469204..39a721cdf7 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -295,10 +295,26 @@ async def _list_from_repo_locked( # filter; the lazy expiration pass below may promote PENDING # items into the requested status (typically EXPIRED) and a # repo-level pre-filter on ``status`` would hide them. - repo_items = await self._repo.list_items( - risk_level=risk_level, - action_type=action_type, - ) + # ``ApprovalRepository.list_items`` defaults to ``limit=100`` + # since the audit-bucket pagination sweep, so we explicitly + # page until exhausted otherwise older PENDING rows that + # should lazily flip to EXPIRED here would never be visited + # once there are >100 newer non-expired rows in the table. + page_size = 100 + repo_pages: list[ApprovalItem] = [] + offset = 0 + while True: + page = await self._repo.list_items( + risk_level=risk_level, + action_type=action_type, + limit=page_size, + offset=offset, + ) + repo_pages.extend(page) + if len(page) < page_size: + break + offset += page_size + repo_items: tuple[ApprovalItem, ...] = tuple(repo_pages) for item in repo_items: self._items[item.id] = item result: list[ApprovalItem] = [] diff --git a/src/synthorg/api/lifecycle.py b/src/synthorg/api/lifecycle.py index cd91540688..05aeb2952b 100644 --- a/src/synthorg/api/lifecycle.py +++ b/src/synthorg/api/lifecycle.py @@ -589,9 +589,9 @@ async def _safe_startup( # noqa: PLR0913, PLR0912, PLR0915, C901 # time) and propagate so startup fails closed. logger.error( # noqa: TRY400 API_APP_STARTUP, - error="Approval timeout scheduler is unrestartable", error_type=type(exc).__name__, - error_detail=safe_error_description(exc), + error=safe_error_description(exc), + note="Approval timeout scheduler is unrestartable", ) raise except Exception: diff --git a/src/synthorg/communication/loop_prevention/circuit_breaker.py b/src/synthorg/communication/loop_prevention/circuit_breaker.py index 06fd75b7d7..b928a95c3a 100644 --- a/src/synthorg/communication/loop_prevention/circuit_breaker.py +++ b/src/synthorg/communication/loop_prevention/circuit_breaker.py @@ -246,11 +246,32 @@ def record_delegation( delegator_id: First agent ID. delegatee_id: Second agent ID. """ - state = self.get_state(delegator_id, delegatee_id) - if state is CircuitBreakerState.OPEN: - return + # Single critical section: the OPEN-state check, the bounce + # increment, and the threshold transition all run under the + # same lock so two concurrent callers cannot both observe + # CLOSED, both bump ``trip_count`` / ``opened_at``, and skip + # backoff levels. with self._state_lock: pair = self._get_or_create_pair(delegator_id, delegatee_id) + if pair.opened_at is not None: + elapsed = self._clock() - pair.opened_at + cooldown = self._compute_cooldown(pair.trip_count) + if elapsed < cooldown: + return + # Cooldown expired between calls -- reset bounce state + # under the same lock so the bump below counts toward + # a fresh post-cooldown window. + key = pair_key(delegator_id, delegatee_id) + pair.bounce_count = 0 + pair.opened_at = None + self._dirty.add(key) + logger.info( + DELEGATION_LOOP_CIRCUIT_RESET, + delegator=delegator_id, + delegatee=delegatee_id, + cooldown_seconds=cooldown, + trip_count=pair.trip_count, + ) pair.bounce_count += 1 if pair.bounce_count >= self._config.bounce_threshold: pair.trip_count += 1 @@ -293,13 +314,18 @@ async def load_state(self) -> None: note="load_state failed; circuit breaker starting with empty state", ) raise - for rec in records: - key = (rec.pair_key_a, rec.pair_key_b) - ps = _PairState() - ps.bounce_count = rec.bounce_count - ps.trip_count = rec.trip_count - ps.opened_at = rec.opened_at - self._pairs[key] = ps + # Hot-path may already be running by the time persistence + # finishes; take the lock for the bulk install so a concurrent + # ``record_delegation`` cannot observe a half-restored + # ``_pairs`` dict mid-iteration. + with self._state_lock: + for rec in records: + key = (rec.pair_key_a, rec.pair_key_b) + ps = _PairState() + ps.bounce_count = rec.bounce_count + ps.trip_count = rec.trip_count + ps.opened_at = rec.opened_at + self._pairs[key] = ps async def persist_dirty(self) -> None: """Flush dirty pair state to the repository. @@ -308,31 +334,63 @@ async def persist_dirty(self) -> None: No-op if no repository is configured. """ if self._state_repo is None: - self._dirty.clear() + with self._state_lock: + self._dirty.clear() return - dirty = tuple(self._dirty) - for key in dirty: - pair = self._pairs.get(key) - if pair is None: - self._dirty.discard(key) - continue + # Snapshot dirty keys + their pair state under the lock so a + # concurrent ``record_delegation`` cannot mutate a pair after + # the snapshot but before the save records what was observed. + # The save itself runs unlocked (I/O), and the dirty discard + # only fires when the snapshot value still matches the + # currently-cached state (no newer in-memory update has + # arrived in the meantime). + with self._state_lock: + dirty = tuple(self._dirty) + snapshot: dict[ + tuple[str, str], + tuple[int, int, float | None], + ] = {} + for key in dirty: + pair = self._pairs.get(key) + if pair is None: + self._dirty.discard(key) + continue + snapshot[key] = ( + pair.bounce_count, + pair.trip_count, + pair.opened_at, + ) + + for key, (bounce, trip, opened) in snapshot.items(): try: record = CircuitBreakerStateRecord( pair_key_a=key[0], pair_key_b=key[1], - bounce_count=pair.bounce_count, - trip_count=pair.trip_count, - opened_at=pair.opened_at, + bounce_count=bounce, + trip_count=trip, + opened_at=opened, ) await self._state_repo.save(record) - self._dirty.discard(key) except MemoryError, RecursionError: raise except Exception: - # Key stays in _dirty for retry on next persist cycle + # Key stays in _dirty for retry on next persist cycle. logger.exception( DELEGATION_LOOP_CIRCUIT_PERSIST_FAILED, delegator=key[0], delegatee=key[1], ) + continue + with self._state_lock: + # Only clear the dirty marker if the cached pair has + # not been updated since we snapshotted it. A newer + # update would otherwise lose its dirty state and the + # next persist cycle would skip it. + live = self._pairs.get(key) + if live is not None and ( + live.bounce_count == bounce + and live.trip_count == trip + and live.opened_at == opened + ): + self._dirty.discard(key) diff --git a/src/synthorg/engine/health/models.py b/src/synthorg/engine/health/models.py index e6f8c3f617..935f7c9286 100644 --- a/src/synthorg/engine/health/models.py +++ b/src/synthorg/engine/health/models.py @@ -5,8 +5,10 @@ """ import copy +from collections.abc import Mapping # noqa: TC003 from datetime import UTC, datetime from enum import StrEnum +from types import MappingProxyType from uuid import uuid4 from pydantic import AwareDatetime, BaseModel, ConfigDict, Field @@ -95,13 +97,24 @@ class EscalationTicket(BaseModel): default_factory=lambda: datetime.now(UTC), description="Ticket creation timestamp (UTC)", ) - metadata: dict[str, object] = Field( - default_factory=dict, - description="Arbitrary structured context", + metadata: Mapping[str, object] = Field( + default_factory=lambda: MappingProxyType({}), + description="Arbitrary structured context (read-only at runtime)", ) def __init__(self, **data: object) -> None: - """Deep-copy metadata dict at construction boundary.""" - if "metadata" in data and isinstance(data["metadata"], dict): - data["metadata"] = copy.deepcopy(data["metadata"]) + """Deep-copy metadata dict and wrap as a read-only mapping. + + ``ConfigDict(frozen=True)`` only blocks attribute rebinding; + without the ``MappingProxyType`` wrap a caller could still + mutate ``ticket.metadata['key'] = value`` after construction + and break the documented immutability contract per + CLAUDE.md ``## Code Conventions`` (immutability covenant). + """ + if "metadata" in data: + raw = data["metadata"] + if isinstance(raw, MappingProxyType): + data["metadata"] = raw + elif isinstance(raw, dict): + data["metadata"] = MappingProxyType(copy.deepcopy(raw)) super().__init__(**data) diff --git a/src/synthorg/engine/task_engine.py b/src/synthorg/engine/task_engine.py index 43a41d625a..f92fa52e78 100644 --- a/src/synthorg/engine/task_engine.py +++ b/src/synthorg/engine/task_engine.py @@ -861,25 +861,43 @@ async def list_tasks( # noqa: PLR0913 ) # When the caller paginates at the repo layer, ``tasks`` is - # already bounded; the safety cap only fires on unpaginated - # "fetch all" calls. Capture the true pre-cap size so the - # returned ``total`` still reflects real cardinality even when - # the tuple is truncated. - true_total = len(tasks) - if limit is None and true_total > self._MAX_LIST_RESULTS: - logger.warning( - TASK_ENGINE_LIST_CAPPED, - actual_total=true_total, - cap=self._MAX_LIST_RESULTS, + # already bounded by the repo's safety cap. For the legacy + # ``limit=None`` (fetch-all) path, ``_fetch_tasks`` pre-clamps + # to ``_MAX_LIST_RESULTS`` at the repo layer so ``len(tasks)`` + # is the post-cap count, not the true total -- issuing an + # extra ``count_tasks`` call here gives us the authoritative + # pre-cap cardinality so ``TASK_ENGINE_LIST_CAPPED`` fires + # AND the returned ``total`` reflects real cardinality even + # when the tuple is truncated. We also still apply the + # in-memory truncation against the returned list so a + # mis-mocked or non-clamping repo cannot bypass the safety + # cap downstream; ``true_total`` then takes the maximum of + # the count and the observed list length so a pathological + # repo (e.g. test fixture returning more rows than count + # reports) still surfaces the real pre-cap size. + if limit is None: + count_total = await self._count_tasks_filtered( + status=status, + assigned_to=assigned_to, + project=project, ) - tasks = tasks[: self._MAX_LIST_RESULTS] + true_total = max(count_total, len(tasks)) + if true_total > self._MAX_LIST_RESULTS: + logger.warning( + TASK_ENGINE_LIST_CAPPED, + actual_total=true_total, + cap=self._MAX_LIST_RESULTS, + ) + tasks = tasks[: self._MAX_LIST_RESULTS] + else: + true_total = len(tasks) if not include_total: return tasks, None if limit is None: - # Full-fetch path: the pre-truncation count is authoritative - # so callers keep accurate totals even after the safety cap. + # Full-fetch path: ``true_total`` is the authoritative + # pre-truncation count from ``count_tasks``. return tasks, true_total total = await self._count_tasks_filtered( diff --git a/src/synthorg/persistence/postgres/mcp_installation_repo.py b/src/synthorg/persistence/postgres/mcp_installation_repo.py index 76a19c0089..2151575072 100644 --- a/src/synthorg/persistence/postgres/mcp_installation_repo.py +++ b/src/synthorg/persistence/postgres/mcp_installation_repo.py @@ -142,9 +142,14 @@ async def list_all( "ORDER BY installed_at ASC, catalog_entry_id ASC" ) params: tuple[object, ...] = () + # Clamp at the boundary: Postgres rejects negative ``LIMIT`` + # outright (SQLSTATE 2201W), and an oversized value would + # defeat the page-size contract. Mirror the sqlite sibling's + # clamp. + effective_limit = max(1, min(int(limit), 100)) effective_offset = max(0, int(offset)) sql += " LIMIT %s OFFSET %s" - params = (int(limit), effective_offset) + params = (effective_limit, effective_offset) try: async with ( self._pool.connection() as conn, diff --git a/src/synthorg/persistence/postgres/user_repo.py b/src/synthorg/persistence/postgres/user_repo.py index b4a0f76545..9f520701d1 100644 --- a/src/synthorg/persistence/postgres/user_repo.py +++ b/src/synthorg/persistence/postgres/user_repo.py @@ -513,7 +513,11 @@ async def list_by_user( limit: int = 100, offset: int = 0, ) -> tuple[ApiKey, ...]: - """List all API keys belonging to a user, ordered by creation date.""" + """List up to ``limit`` API keys for a user, ordered by creation date. + + Defaults to a 100-key page; callers needing more must paginate + with ``offset``. + """ sql = "SELECT * FROM api_keys WHERE user_id = %s ORDER BY created_at, id" params: tuple[object, ...] = (user_id,) effective_offset = max(0, int(offset)) diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index 84bf96734d..d30ed92478 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -119,9 +119,14 @@ async def list_all( "ORDER BY installed_at ASC, catalog_entry_id ASC" ) params: tuple[object, ...] = () + # Clamp at the boundary: SQLite treats negative ``LIMIT`` + # as unbounded, so a misbehaving caller passing -1 would + # bypass the page contract entirely. Mirror the postgres + # sibling's clamp. + effective_limit = max(1, min(int(limit), 100)) effective_offset = max(0, int(offset)) sql += " LIMIT ? OFFSET ?" - params = (int(limit), effective_offset) + params = (effective_limit, effective_offset) async with self._db.execute(sql, params) as cursor: rows = await cursor.fetchall() return tuple( diff --git a/src/synthorg/persistence/sqlite/user_repo.py b/src/synthorg/persistence/sqlite/user_repo.py index 20f8a1472a..62e1585b09 100644 --- a/src/synthorg/persistence/sqlite/user_repo.py +++ b/src/synthorg/persistence/sqlite/user_repo.py @@ -678,14 +678,15 @@ async def list_by_user( limit: int = 100, offset: int = 0, ) -> tuple[ApiKey, ...]: - """List all API keys belonging to a user, ordered by creation date. + """List up to ``limit`` API keys for a user, ordered by creation date. + + Defaults to a 100-key page; callers needing more must paginate + with ``offset``. Args: user_id: Owner user identifier. - limit: Maximum keys to return; ``None`` (default) preserves - fetch-all semantics. - offset: Keys to skip before applying *limit*; ignored when - *limit* is ``None``. + limit: Maximum keys to return (must be >= 1). + offset: Keys to skip before applying *limit* (must be >= 0). Returns: Tuple of ``ApiKey`` records, oldest first. diff --git a/tests/unit/communication/loop_prevention/test_circuit_breaker.py b/tests/unit/communication/loop_prevention/test_circuit_breaker.py index 92014d03ae..2800ed6ed1 100644 --- a/tests/unit/communication/loop_prevention/test_circuit_breaker.py +++ b/tests/unit/communication/loop_prevention/test_circuit_breaker.py @@ -1,5 +1,8 @@ """Tests for delegation circuit breaker.""" +import threading +import time +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -406,11 +409,59 @@ def clock() -> float: cb = DelegationCircuitBreaker(config, clock=clock) cb.record_delegation("a", "b") cb.record_delegation("a", "b") - # Pair is OPEN; cooldown elapsed mid-check would historically - # leave the post-get_state lookup observing a freshly-reset - # pair with opened_at=None. Under the new locking it doesn't - # matter -- the entire branch is atomic. + # Pair is OPEN. Wrap ``_state_lock`` with a tracking proxy + # that fires a sibling thread's mutation while ``check`` + # holds the lock. Under the fix, ``check`` reads + # ``opened_at`` and ``trip_count`` while holding + # ``_state_lock``; the sibling thread blocks on the lock + # until ``check`` exits, so its mutation cannot influence + # the OPEN-branch verdict. Without the fix, the sibling + # would race the post-``get_state`` re-read and the test + # would observe ``passed=True``. + from threading import Thread + + underlying = cb._state_lock + injection_done = threading.Event() + + def _mutate_in_sibling() -> None: + with underlying: + pair = cb._pairs.get(("a", "b")) + if pair is not None: + pair.opened_at = None + injection_done.set() + + recorded_during_check: list[bool] = [] + + class _TrackingLock: + def __enter__(self) -> Any: + result = underlying.__enter__() + if not recorded_during_check: + recorded_during_check.append(True) + t = Thread(target=_mutate_in_sibling, daemon=True) + t.start() + # Yield to the sibling so it observes the lock + # held; it blocks on us until __exit__ fires. + time.sleep(0.05) + return result + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + underlying.__exit__(exc_type, exc, tb) + + def acquire(self, *args: Any, **kwargs: Any) -> Any: + return underlying.acquire(*args, **kwargs) + + def release(self) -> None: + underlying.release() + + cb._state_lock = _TrackingLock() # type: ignore[assignment] clock_time = 5.0 result = cb.check("a", "b") + cb._state_lock = underlying # type: ignore[assignment] + injection_done.wait(timeout=1.0) + assert recorded_during_check, ( + "check() never acquired _state_lock through the tracked " + "wrapper; the OPEN-branch decision is NOT covered by " + "the regression." + ) assert result.passed is False assert "cooldown" in (result.message or "") From ee674e3b1b90e52f66986bb18563237167a16cc2 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 00:40:15 +0200 Subject: [PATCH 27/35] fix: drop unused type-ignore on lock-restore in TOCTOU regression test --- .../unit/communication/loop_prevention/test_circuit_breaker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/communication/loop_prevention/test_circuit_breaker.py b/tests/unit/communication/loop_prevention/test_circuit_breaker.py index 2800ed6ed1..572e124fd8 100644 --- a/tests/unit/communication/loop_prevention/test_circuit_breaker.py +++ b/tests/unit/communication/loop_prevention/test_circuit_breaker.py @@ -456,7 +456,7 @@ def release(self) -> None: cb._state_lock = _TrackingLock() # type: ignore[assignment] clock_time = 5.0 result = cb.check("a", "b") - cb._state_lock = underlying # type: ignore[assignment] + cb._state_lock = underlying injection_done.wait(timeout=1.0) assert recorded_during_check, ( "check() never acquired _state_lock through the tracked " From 2ceba7820d8da44eae90a981062cf8db60f47ccf Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 01:08:49 +0200 Subject: [PATCH 28/35] fix: babysit round 5, address CodeRabbit review on the round-4 head * approval_store: drop audit-history wording from the paging comment; wrap save_many in try/except that logs the affected ids + batch size before re-raising so failed batched expirations are diagnosable in production. * circuit_breaker.load_state: drop persisted opened_at on restore (a different process's monotonic clock cannot be compared against the current self._clock() reading); preserve trip_count history so backoff escalation survives across restarts. Use setdefault so a hot-path record_delegation that fired before the persistence load completes is not silently overwritten by the stale snapshot. * engine/health/models.py: EscalationTicket.metadata always deep-copies before wrapping in MappingProxyType. Reusing an incoming proxy directly would alias its backing dict. * user_repo (sqlite + postgres): replace silent limit/offset clamping with explicit QueryError validation, matching the approval / artifact / audit / settings / provider_audit repo patterns. Same change in mcp_installation_repo (sqlite + postgres). * Added API_APPROVAL_EXPIRE_BATCH_FAILED event constant for the batched-expiry diagnostic. Tests: * test_load_state_restores_pairs now asserts opened_at is None on restore (cooldown reset behavior). * New test_load_state_does_not_overwrite_newer_in_memory locks in the setdefault contract. * mock-spec baseline refreshed after the line-number shifts. --- scripts/mock_spec_baseline.txt | 2 + src/synthorg/api/approval_store.py | 32 ++++++++++---- .../loop_prevention/circuit_breaker.py | 19 +++++++-- src/synthorg/engine/health/models.py | 12 +++--- src/synthorg/observability/events/api.py | 1 + .../postgres/mcp_installation_repo.py | 15 +++---- .../persistence/postgres/user_repo.py | 9 +++- .../sqlite/mcp_installation_repo.py | 14 +++---- src/synthorg/persistence/sqlite/user_repo.py | 9 +++- .../loop_prevention/test_circuit_breaker.py | 42 ++++++++++++++++++- 10 files changed, 121 insertions(+), 34 deletions(-) diff --git a/scripts/mock_spec_baseline.txt b/scripts/mock_spec_baseline.txt index 97a8a9d122..1b230b763d 100644 --- a/scripts/mock_spec_baseline.txt +++ b/scripts/mock_spec_baseline.txt @@ -622,6 +622,8 @@ tests/unit/communication/loop_prevention/test_circuit_breaker.py:296:15 tests/unit/communication/loop_prevention/test_circuit_breaker.py:297:20 tests/unit/communication/loop_prevention/test_circuit_breaker.py:319:15 tests/unit/communication/loop_prevention/test_circuit_breaker.py:320:24 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:356:15 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:357:24 tests/unit/communication/meeting/test_agent_caller.py:102:25 tests/unit/communication/meeting/test_agent_caller.py:104:15 tests/unit/communication/meeting/test_agent_caller.py:106:28 diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 39a721cdf7..8f779ddd9d 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -49,6 +49,7 @@ from synthorg.observability import get_logger, safe_error_description from synthorg.observability.events.api import ( API_APPROVAL_CONFLICT, + API_APPROVAL_EXPIRE_BATCH_FAILED, API_APPROVAL_EXPIRE_CALLBACK_FAILED, API_APPROVAL_EXPIRED, API_APPROVAL_STORE_CLEARED, @@ -261,7 +262,7 @@ async def list_items( action_type=action_type, ) - async def _list_from_repo_locked( + async def _list_from_repo_locked( # noqa: C901 self, *, status: ApprovalStatus | None, @@ -295,11 +296,11 @@ async def _list_from_repo_locked( # filter; the lazy expiration pass below may promote PENDING # items into the requested status (typically EXPIRED) and a # repo-level pre-filter on ``status`` would hide them. - # ``ApprovalRepository.list_items`` defaults to ``limit=100`` - # since the audit-bucket pagination sweep, so we explicitly - # page until exhausted otherwise older PENDING rows that - # should lazily flip to EXPIRED here would never be visited - # once there are >100 newer non-expired rows in the table. + # ``ApprovalRepository.list_items`` is bounded to 100 rows + # per call, so we page until exhausted -- otherwise older + # PENDING rows that should lazily flip to EXPIRED here would + # never be visited once there are >100 newer non-expired + # rows in the table. page_size = 100 repo_pages: list[ApprovalItem] = [] offset = 0 @@ -335,7 +336,24 @@ async def _list_from_repo_locked( # batch commits. Any callback or audit-event emission # follows the cache update so observers cannot see a # cached EXPIRED state that the repo never accepted. - await self._repo.save_many(to_persist) + try: + await self._repo.save_many(to_persist) + except MemoryError, RecursionError: + raise + except Exception as exc: + # Log the affected ids before re-raising so a + # production failure on the batched expiry path is + # diagnosable -- otherwise the caller sees the + # ``QueryError`` and has no record of which lazy + # expirations were attempted. + logger.warning( + API_APPROVAL_EXPIRE_BATCH_FAILED, + batch_size=len(to_persist), + approval_ids=tuple(item.id for item in to_persist), + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise self._items.update(cache_updates) for expired in to_persist: logger.info( diff --git a/src/synthorg/communication/loop_prevention/circuit_breaker.py b/src/synthorg/communication/loop_prevention/circuit_breaker.py index b928a95c3a..2ca91a5b3a 100644 --- a/src/synthorg/communication/loop_prevention/circuit_breaker.py +++ b/src/synthorg/communication/loop_prevention/circuit_breaker.py @@ -317,15 +317,28 @@ async def load_state(self) -> None: # Hot-path may already be running by the time persistence # finishes; take the lock for the bulk install so a concurrent # ``record_delegation`` cannot observe a half-restored - # ``_pairs`` dict mid-iteration. + # ``_pairs`` dict mid-iteration. Use ``setdefault`` so newer + # in-memory state created by ``record_delegation`` between + # process start and ``load_state`` completing is not silently + # overwritten by the persisted snapshot. with self._state_lock: for rec in records: key = (rec.pair_key_a, rec.pair_key_b) ps = _PairState() ps.bounce_count = rec.bounce_count ps.trip_count = rec.trip_count - ps.opened_at = rec.opened_at - self._pairs[key] = ps + # ``opened_at`` is a monotonic value captured by the + # original process; another process's monotonic + # reference point is undefined so a persisted value + # cannot be safely compared against a fresh + # ``self._clock()`` call. Drop ``opened_at`` on + # restore so the breaker re-opens cleanly under the + # current process's clock the next time + # ``record_delegation`` trips it; the trip-count + # history is preserved (so backoff escalation + # survives), only the in-flight cooldown is reset. + ps.opened_at = None + self._pairs.setdefault(key, ps) async def persist_dirty(self) -> None: """Flush dirty pair state to the repository. diff --git a/src/synthorg/engine/health/models.py b/src/synthorg/engine/health/models.py index 935f7c9286..7f9165d481 100644 --- a/src/synthorg/engine/health/models.py +++ b/src/synthorg/engine/health/models.py @@ -5,7 +5,7 @@ """ import copy -from collections.abc import Mapping # noqa: TC003 +from collections.abc import Mapping from datetime import UTC, datetime from enum import StrEnum from types import MappingProxyType @@ -113,8 +113,10 @@ def __init__(self, **data: object) -> None: """ if "metadata" in data: raw = data["metadata"] - if isinstance(raw, MappingProxyType): - data["metadata"] = raw - elif isinstance(raw, dict): - data["metadata"] = MappingProxyType(copy.deepcopy(raw)) + if isinstance(raw, Mapping): + # Always deep-copy before wrapping; reusing an + # incoming ``MappingProxyType`` directly would alias + # its backing dict and let the caller mutate + # ``ticket.metadata`` after construction. + data["metadata"] = MappingProxyType(copy.deepcopy(dict(raw))) super().__init__(**data) diff --git a/src/synthorg/observability/events/api.py b/src/synthorg/observability/events/api.py index c9c2f2402a..a97cb0cb69 100644 --- a/src/synthorg/observability/events/api.py +++ b/src/synthorg/observability/events/api.py @@ -36,6 +36,7 @@ # (SECURITY_APPROVAL_APPROVED / SECURITY_APPROVAL_REJECTED) so the audit # chain signs the human decision. API_APPROVAL_EXPIRED: Final[str] = "api.approval.expired" +API_APPROVAL_EXPIRE_BATCH_FAILED: Final[str] = "api.approval.expire_batch_failed" API_APPROVAL_EXPIRE_CALLBACK_FAILED: Final[str] = "api.approval.expire_callback_failed" API_APPROVAL_PUBLISH_FAILED: Final[str] = "api.approval.publish_failed" API_APPROVAL_CONFLICT: Final[str] = "api.approval.conflict" diff --git a/src/synthorg/persistence/postgres/mcp_installation_repo.py b/src/synthorg/persistence/postgres/mcp_installation_repo.py index 2151575072..444c662ad2 100644 --- a/src/synthorg/persistence/postgres/mcp_installation_repo.py +++ b/src/synthorg/persistence/postgres/mcp_installation_repo.py @@ -13,6 +13,7 @@ from psycopg.rows import dict_row +from synthorg.core.persistence_errors import QueryError from synthorg.core.types import NotBlankStr from synthorg.integrations.mcp_catalog.installations import McpInstallation from synthorg.observability import get_logger, safe_error_description @@ -141,15 +142,15 @@ async def list_all( "FROM mcp_installations " "ORDER BY installed_at ASC, catalog_entry_id ASC" ) + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + msg = f"limit must be a positive integer, got {limit!r}" + raise QueryError(msg) + if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: + msg = f"offset must be a non-negative integer, got {offset!r}" + raise QueryError(msg) params: tuple[object, ...] = () - # Clamp at the boundary: Postgres rejects negative ``LIMIT`` - # outright (SQLSTATE 2201W), and an oversized value would - # defeat the page-size contract. Mirror the sqlite sibling's - # clamp. - effective_limit = max(1, min(int(limit), 100)) - effective_offset = max(0, int(offset)) sql += " LIMIT %s OFFSET %s" - params = (effective_limit, effective_offset) + params = (limit, offset) try: async with ( self._pool.connection() as conn, diff --git a/src/synthorg/persistence/postgres/user_repo.py b/src/synthorg/persistence/postgres/user_repo.py index 9f520701d1..636e351888 100644 --- a/src/synthorg/persistence/postgres/user_repo.py +++ b/src/synthorg/persistence/postgres/user_repo.py @@ -518,11 +518,16 @@ async def list_by_user( Defaults to a 100-key page; callers needing more must paginate with ``offset``. """ + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + msg = f"limit must be a positive integer, got {limit!r}" + raise QueryError(msg) + if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: + msg = f"offset must be a non-negative integer, got {offset!r}" + raise QueryError(msg) sql = "SELECT * FROM api_keys WHERE user_id = %s ORDER BY created_at, id" params: tuple[object, ...] = (user_id,) - effective_offset = max(0, int(offset)) sql += " LIMIT %s OFFSET %s" - params = (*params, int(limit), effective_offset) + params = (*params, limit, offset) try: async with ( self._pool.connection() as conn, diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index d30ed92478..d4fc95b5ad 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -118,15 +118,15 @@ async def list_all( "FROM mcp_installations " "ORDER BY installed_at ASC, catalog_entry_id ASC" ) + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + msg = f"limit must be a positive integer, got {limit!r}" + raise QueryError(msg) + if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: + msg = f"offset must be a non-negative integer, got {offset!r}" + raise QueryError(msg) params: tuple[object, ...] = () - # Clamp at the boundary: SQLite treats negative ``LIMIT`` - # as unbounded, so a misbehaving caller passing -1 would - # bypass the page contract entirely. Mirror the postgres - # sibling's clamp. - effective_limit = max(1, min(int(limit), 100)) - effective_offset = max(0, int(offset)) sql += " LIMIT ? OFFSET ?" - params = (effective_limit, effective_offset) + params = (limit, offset) async with self._db.execute(sql, params) as cursor: rows = await cursor.fetchall() return tuple( diff --git a/src/synthorg/persistence/sqlite/user_repo.py b/src/synthorg/persistence/sqlite/user_repo.py index 62e1585b09..7c982d8d48 100644 --- a/src/synthorg/persistence/sqlite/user_repo.py +++ b/src/synthorg/persistence/sqlite/user_repo.py @@ -694,11 +694,16 @@ async def list_by_user( Raises: QueryError: If the database query or deserialization fails. """ + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + msg = f"limit must be a positive integer, got {limit!r}" + raise QueryError(msg) + if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: + msg = f"offset must be a non-negative integer, got {offset!r}" + raise QueryError(msg) sql = "SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at, id" params: tuple[object, ...] = (user_id,) - effective_offset = max(0, int(offset)) sql += " LIMIT ? OFFSET ?" - params = (*params, int(limit), effective_offset) + params = (*params, limit, offset) try: cursor = await self._db.execute(sql, params) rows = await cursor.fetchall() diff --git a/tests/unit/communication/loop_prevention/test_circuit_breaker.py b/tests/unit/communication/loop_prevention/test_circuit_breaker.py index 572e124fd8..01a3e43a90 100644 --- a/tests/unit/communication/loop_prevention/test_circuit_breaker.py +++ b/tests/unit/communication/loop_prevention/test_circuit_breaker.py @@ -326,7 +326,47 @@ async def test_load_state_restores_pairs(self) -> None: assert pair is not None assert pair.bounce_count == 1 assert pair.trip_count == 2 - assert pair.opened_at == 50.0 + # ``opened_at`` is dropped on restore: the persisted value + # was captured by a different process's monotonic clock and + # cannot be safely compared against ``self._clock()`` here. + # Trip-count history survives so the next backoff escalation + # fires at the correct level; in-flight cooldown is reset. + assert pair.opened_at is None + + async def test_load_state_does_not_overwrite_newer_in_memory( + self, + ) -> None: + """``load_state`` preserves entries that ``record_delegation`` + seeded between process start and the persistence load + completing. Without this, a hot-path trip recorded at + startup gets clobbered by the stale persisted snapshot. + """ + from synthorg.persistence.circuit_breaker_repo import ( + CircuitBreakerStateRecord, + ) + + config = CircuitBreakerConfig(bounce_threshold=3, cooldown_seconds=300) + record = CircuitBreakerStateRecord( + pair_key_a="a", + pair_key_b="b", + bounce_count=1, + trip_count=1, + opened_at=None, + ) + repo = MagicMock() + repo.load_all = AsyncMock(return_value=(record,)) + + cb = DelegationCircuitBreaker(config, state_repo=repo) + # Pre-populate with a "live" entry that record_delegation + # produced before load_state ran. + cb.record_delegation("a", "b") + cb.record_delegation("a", "b") + live_bounce = cb._pairs[("a", "b")].bounce_count + + await cb.load_state() + + # Live entry survives; persisted snapshot does not overwrite. + assert cb._pairs[("a", "b")].bounce_count == live_bounce @pytest.mark.unit From 4f8a47ef6d32064ab06cea541b8d9ddcaa19e1f7 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 01:39:12 +0200 Subject: [PATCH 29/35] fix: address round-6 CodeRabbit findings on PR #1744 - approval_store: push status filter down to repo for non-EXPIRED queries (only EXPIRED + None need broad read for lazy promotion visibility) - health/models: drop CLAUDE.md citation from __init__ docstring; explain the deep-copy + MappingProxyType combination in terms the reader can verify against the code - mcp_installation_repo (sqlite + postgres): replace duplicated manual pagination validation with shared validate_pagination_args helper - user_repo (sqlite + postgres): same migration to shared validator - test_circuit_breaker: add spec=CircuitBreakerStateRepository to three MagicMock() sites so renames propagate to test failures - observability/events/persistence: add PERSISTENCE_MCP_INSTALLATION_LIST_FAILED constant --- scripts/mock_spec_baseline.txt | 9 +++---- src/synthorg/api/approval_store.py | 27 ++++++++++++------- src/synthorg/engine/health/models.py | 7 ++--- .../observability/events/persistence.py | 3 +++ .../postgres/mcp_installation_repo.py | 24 ++++++++--------- .../persistence/postgres/user_repo.py | 13 ++++----- .../sqlite/mcp_installation_repo.py | 21 +++++++-------- src/synthorg/persistence/sqlite/user_repo.py | 13 ++++----- .../loop_prevention/test_circuit_breaker.py | 12 ++++++--- 9 files changed, 73 insertions(+), 56 deletions(-) diff --git a/scripts/mock_spec_baseline.txt b/scripts/mock_spec_baseline.txt index 1b230b763d..faf691fccc 100644 --- a/scripts/mock_spec_baseline.txt +++ b/scripts/mock_spec_baseline.txt @@ -618,12 +618,9 @@ tests/unit/communication/bus/test_nats_consumer_config.py:31:9 tests/unit/communication/bus/test_nats_consumer_config.py:33:24 tests/unit/communication/bus/test_nats_consumer_config.py:33:47 tests/unit/communication/bus/test_nats_consumer_config.py:44:12 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:296:15 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:297:20 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:319:15 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:320:24 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:356:15 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:357:24 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:301:20 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:325:24 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:363:24 tests/unit/communication/meeting/test_agent_caller.py:102:25 tests/unit/communication/meeting/test_agent_caller.py:104:15 tests/unit/communication/meeting/test_agent_caller.py:106:28 diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 8f779ddd9d..77fc5c9fd3 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -277,11 +277,17 @@ async def _list_from_repo_locked( # noqa: C901 simultaneous expiries yield one DB round-trip rather than K. Cache promotions only land *after* ``save_many`` succeeds so a failed batch cannot leave an EXPIRED row in the cache that - contradicts the still-PENDING repo state, and the in-memory - ``status`` filter is applied here rather than pushed into the - repo query so a caller asking for ``ApprovalStatus.EXPIRED`` - still observes lazily-promoted items that the repo persists as - PENDING. + contradicts the still-PENDING repo state. + + Status filtering: + + * When ``status`` is ``EXPIRED`` (or ``None``), the repo query + omits the status filter so PENDING rows that should lazily + flip to EXPIRED still surface and get persisted. + * When ``status`` is any other terminal value (APPROVED, + REJECTED, CANCELLED), the repo authoritatively persists that + status and lazy expiration cannot promote into it -- the + filter is pushed down so the DB only returns matching rows. Side effects after the batch save: @@ -292,20 +298,23 @@ async def _list_from_repo_locked( # noqa: C901 logged at ERROR but do not unwind the expiration). """ assert self._repo is not None # noqa: S101 -- caller invariant - # Pull every candidate row regardless of the caller's status - # filter; the lazy expiration pass below may promote PENDING - # items into the requested status (typically EXPIRED) and a - # repo-level pre-filter on ``status`` would hide them. + # Push the status filter down for non-EXPIRED queries so the + # DB doesn't have to scan the whole table; only EXPIRED (and + # the unfiltered ``None`` case) need the broad read so PENDING + # rows that should lazily flip to EXPIRED are visible to the + # expiration pass below. # ``ApprovalRepository.list_items`` is bounded to 100 rows # per call, so we page until exhausted -- otherwise older # PENDING rows that should lazily flip to EXPIRED here would # never be visited once there are >100 newer non-expired # rows in the table. + repo_status = None if status in {None, ApprovalStatus.EXPIRED} else status page_size = 100 repo_pages: list[ApprovalItem] = [] offset = 0 while True: page = await self._repo.list_items( + status=repo_status, risk_level=risk_level, action_type=action_type, limit=page_size, diff --git a/src/synthorg/engine/health/models.py b/src/synthorg/engine/health/models.py index 7f9165d481..b3a618a3ce 100644 --- a/src/synthorg/engine/health/models.py +++ b/src/synthorg/engine/health/models.py @@ -107,9 +107,10 @@ def __init__(self, **data: object) -> None: ``ConfigDict(frozen=True)`` only blocks attribute rebinding; without the ``MappingProxyType`` wrap a caller could still - mutate ``ticket.metadata['key'] = value`` after construction - and break the documented immutability contract per - CLAUDE.md ``## Code Conventions`` (immutability covenant). + mutate ``ticket.metadata['key'] = value`` after construction. + The deep copy guards against the caller retaining a reference + to the original dict and mutating it post-construction; the + proxy guards against direct item assignment on the field. """ if "metadata" in data: raw = data["metadata"] diff --git a/src/synthorg/observability/events/persistence.py b/src/synthorg/observability/events/persistence.py index 6fcc7d1433..73c1400065 100644 --- a/src/synthorg/observability/events/persistence.py +++ b/src/synthorg/observability/events/persistence.py @@ -117,6 +117,9 @@ PERSISTENCE_MCP_INSTALLATION_DELETE_FAILED: Final[str] = ( "persistence.mcp_installation.delete_failed" ) +PERSISTENCE_MCP_INSTALLATION_LIST_FAILED: Final[str] = ( + "persistence.mcp_installation.list_failed" +) PERSISTENCE_PARKED_CONTEXT_DELETED: Final[str] = "persistence.parked_context.deleted" PERSISTENCE_PARKED_CONTEXT_DELETE_FAILED: Final[str] = ( "persistence.parked_context.delete_failed" diff --git a/src/synthorg/persistence/postgres/mcp_installation_repo.py b/src/synthorg/persistence/postgres/mcp_installation_repo.py index 444c662ad2..861c88ac3e 100644 --- a/src/synthorg/persistence/postgres/mcp_installation_repo.py +++ b/src/synthorg/persistence/postgres/mcp_installation_repo.py @@ -13,7 +13,6 @@ from psycopg.rows import dict_row -from synthorg.core.persistence_errors import QueryError from synthorg.core.types import NotBlankStr from synthorg.integrations.mcp_catalog.installations import McpInstallation from synthorg.observability import get_logger, safe_error_description @@ -22,7 +21,11 @@ MCP_SERVER_INSTALLED, MCP_SERVER_UNINSTALLED, ) +from synthorg.observability.events.persistence import ( + PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, +) from synthorg.persistence._shared import coerce_row_timestamp, normalize_utc +from synthorg.persistence._shared.pagination import validate_pagination_args if TYPE_CHECKING: from psycopg_pool import AsyncConnectionPool @@ -137,26 +140,23 @@ async def list_all( (restores, backfills, clock skew) are always returned in the same order across calls. """ + validate_pagination_args( + limit, + offset, + event=PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, + ) sql = ( "SELECT catalog_entry_id, connection_name, installed_at " "FROM mcp_installations " - "ORDER BY installed_at ASC, catalog_entry_id ASC" + "ORDER BY installed_at ASC, catalog_entry_id ASC " + "LIMIT %s OFFSET %s" ) - if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: - msg = f"limit must be a positive integer, got {limit!r}" - raise QueryError(msg) - if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: - msg = f"offset must be a non-negative integer, got {offset!r}" - raise QueryError(msg) - params: tuple[object, ...] = () - sql += " LIMIT %s OFFSET %s" - params = (limit, offset) try: async with ( self._pool.connection() as conn, conn.cursor(row_factory=dict_row) as cur, ): - await cur.execute(sql, params) + await cur.execute(sql, (limit, offset)) rows = await cur.fetchall() except MemoryError, RecursionError: raise diff --git a/src/synthorg/persistence/postgres/user_repo.py b/src/synthorg/persistence/postgres/user_repo.py index 636e351888..8c66d09e2e 100644 --- a/src/synthorg/persistence/postgres/user_repo.py +++ b/src/synthorg/persistence/postgres/user_repo.py @@ -39,6 +39,7 @@ PERSISTENCE_USER_LISTED, PERSISTENCE_USER_SAVE_FAILED, ) +from synthorg.persistence._shared.pagination import validate_pagination_args from synthorg.persistence.constraint_tokens import ( IDX_SINGLE_CEO, LAST_CEO_TRIGGER, @@ -518,12 +519,12 @@ async def list_by_user( Defaults to a 100-key page; callers needing more must paginate with ``offset``. """ - if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: - msg = f"limit must be a positive integer, got {limit!r}" - raise QueryError(msg) - if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: - msg = f"offset must be a non-negative integer, got {offset!r}" - raise QueryError(msg) + validate_pagination_args( + limit, + offset, + event=PERSISTENCE_API_KEY_LIST_FAILED, + user_id=user_id, + ) sql = "SELECT * FROM api_keys WHERE user_id = %s ORDER BY created_at, id" params: tuple[object, ...] = (user_id,) sql += " LIMIT %s OFFSET %s" diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index d4fc95b5ad..dd97af00ae 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -17,9 +17,11 @@ from synthorg.observability import get_logger, safe_error_description from synthorg.observability.events.persistence import ( PERSISTENCE_MCP_INSTALLATION_DELETE_FAILED, + PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, PERSISTENCE_MCP_INSTALLATION_SAVE_FAILED, ) from synthorg.persistence._shared import coerce_row_timestamp, format_iso_utc +from synthorg.persistence._shared.pagination import validate_pagination_args logger = get_logger(__name__) @@ -113,21 +115,18 @@ async def list_all( floor); callers needing more must loop with ``offset`` rather than passing a larger ``limit``. """ + validate_pagination_args( + limit, + offset, + event=PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, + ) sql = ( "SELECT catalog_entry_id, connection_name, installed_at " "FROM mcp_installations " - "ORDER BY installed_at ASC, catalog_entry_id ASC" + "ORDER BY installed_at ASC, catalog_entry_id ASC " + "LIMIT ? OFFSET ?" ) - if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: - msg = f"limit must be a positive integer, got {limit!r}" - raise QueryError(msg) - if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: - msg = f"offset must be a non-negative integer, got {offset!r}" - raise QueryError(msg) - params: tuple[object, ...] = () - sql += " LIMIT ? OFFSET ?" - params = (limit, offset) - async with self._db.execute(sql, params) as cursor: + async with self._db.execute(sql, (limit, offset)) as cursor: rows = await cursor.fetchall() return tuple( McpInstallation( diff --git a/src/synthorg/persistence/sqlite/user_repo.py b/src/synthorg/persistence/sqlite/user_repo.py index 7c982d8d48..8eca92c004 100644 --- a/src/synthorg/persistence/sqlite/user_repo.py +++ b/src/synthorg/persistence/sqlite/user_repo.py @@ -38,6 +38,7 @@ PERSISTENCE_USER_LISTED, PERSISTENCE_USER_SAVE_FAILED, ) +from synthorg.persistence._shared.pagination import validate_pagination_args from synthorg.persistence.constraint_tokens import ( IDX_SINGLE_CEO, LAST_CEO_TRIGGER, @@ -694,12 +695,12 @@ async def list_by_user( Raises: QueryError: If the database query or deserialization fails. """ - if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: - msg = f"limit must be a positive integer, got {limit!r}" - raise QueryError(msg) - if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0: - msg = f"offset must be a non-negative integer, got {offset!r}" - raise QueryError(msg) + validate_pagination_args( + limit, + offset, + event=PERSISTENCE_API_KEY_LIST_FAILED, + user_id=user_id, + ) sql = "SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at, id" params: tuple[object, ...] = (user_id,) sql += " LIMIT ? OFFSET ?" diff --git a/tests/unit/communication/loop_prevention/test_circuit_breaker.py b/tests/unit/communication/loop_prevention/test_circuit_breaker.py index 01a3e43a90..ff6b0174c0 100644 --- a/tests/unit/communication/loop_prevention/test_circuit_breaker.py +++ b/tests/unit/communication/loop_prevention/test_circuit_breaker.py @@ -292,8 +292,12 @@ def clock() -> float: assert ("a", "b") in cb._dirty async def test_persist_dirty_clears_set(self) -> None: + from synthorg.persistence.circuit_breaker_repo import ( + CircuitBreakerStateRepository, + ) + config = CircuitBreakerConfig(bounce_threshold=1, cooldown_seconds=10) - repo = MagicMock() + repo = MagicMock(spec=CircuitBreakerStateRepository) repo.save = AsyncMock() cb = DelegationCircuitBreaker(config, state_repo=repo) cb.record_delegation("a", "b") @@ -306,6 +310,7 @@ async def test_persist_dirty_clears_set(self) -> None: async def test_load_state_restores_pairs(self) -> None: from synthorg.persistence.circuit_breaker_repo import ( CircuitBreakerStateRecord, + CircuitBreakerStateRepository, ) config = CircuitBreakerConfig(bounce_threshold=3, cooldown_seconds=300) @@ -316,7 +321,7 @@ async def test_load_state_restores_pairs(self) -> None: trip_count=2, opened_at=50.0, ) - repo = MagicMock() + repo = MagicMock(spec=CircuitBreakerStateRepository) repo.load_all = AsyncMock(return_value=(record,)) cb = DelegationCircuitBreaker(config, state_repo=repo) @@ -343,6 +348,7 @@ async def test_load_state_does_not_overwrite_newer_in_memory( """ from synthorg.persistence.circuit_breaker_repo import ( CircuitBreakerStateRecord, + CircuitBreakerStateRepository, ) config = CircuitBreakerConfig(bounce_threshold=3, cooldown_seconds=300) @@ -353,7 +359,7 @@ async def test_load_state_does_not_overwrite_newer_in_memory( trip_count=1, opened_at=None, ) - repo = MagicMock() + repo = MagicMock(spec=CircuitBreakerStateRepository) repo.load_all = AsyncMock(return_value=(record,)) cb = DelegationCircuitBreaker(config, state_repo=repo) From c56b1d9fa4f28011ce676dced88c8a2c6fe930ae Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 02:07:00 +0200 Subject: [PATCH 30/35] fix: address round-7 CodeRabbit findings on PR #1744 - approval_store: per-page chunked list path releases the store lock during repo I/O, save_many, and callback dispatch. Concurrent get() / save() callers no longer block on an unbounded scan. Pure compute factored into _compute_page helper. - approval_store: align _check_expiration_locked callback failure to ERROR severity matching _fire_expire_callback (operationally meaningful side-effect failures must alert regardless of which expiration path fired). - engine/health/models: replace __init__ pre-validation wrap with @field_validator(mode='after'). Pydantic 2.x unwraps generic Mapping annotations into a plain dict during validation, silently discarding the pre-validation MappingProxyType wrap. - sqlite/mcp_installation_repo: docstring no longer claims callers must loop with offset; reflects actual behavior (no max enforced). - test_circuit_breaker: replace time.sleep(0.05) handshake with a deterministic threading.Event; the sibling sets the event when its non-blocking acquire fails (i.e. once it has observed the contended acquire), so the regression cannot pass without exercising the interleaving. --- scripts/mock_spec_baseline.txt | 6 +- src/synthorg/api/approval_store.py | 172 +++++++++++------- src/synthorg/engine/health/models.py | 34 ++-- .../sqlite/mcp_installation_repo.py | 5 +- .../loop_prevention/test_circuit_breaker.py | 31 +++- 5 files changed, 150 insertions(+), 98 deletions(-) diff --git a/scripts/mock_spec_baseline.txt b/scripts/mock_spec_baseline.txt index faf691fccc..42369aad9e 100644 --- a/scripts/mock_spec_baseline.txt +++ b/scripts/mock_spec_baseline.txt @@ -618,9 +618,9 @@ tests/unit/communication/bus/test_nats_consumer_config.py:31:9 tests/unit/communication/bus/test_nats_consumer_config.py:33:24 tests/unit/communication/bus/test_nats_consumer_config.py:33:47 tests/unit/communication/bus/test_nats_consumer_config.py:44:12 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:301:20 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:325:24 -tests/unit/communication/loop_prevention/test_circuit_breaker.py:363:24 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:300:20 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:324:24 +tests/unit/communication/loop_prevention/test_circuit_breaker.py:362:24 tests/unit/communication/meeting/test_agent_caller.py:102:25 tests/unit/communication/meeting/test_agent_caller.py:104:15 tests/unit/communication/meeting/test_agent_caller.py:106:28 diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 77fc5c9fd3..6aa2d56b8b 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -249,20 +249,20 @@ async def list_items( Returns: Tuple of matching approval items. """ + if self._repo is not None: + return await self._list_from_repo( + status=status, + risk_level=risk_level, + action_type=action_type, + ) async with self._lock: - if self._repo is not None: - return await self._list_from_repo_locked( - status=status, - risk_level=risk_level, - action_type=action_type, - ) return await self._list_from_cache_locked( status=status, risk_level=risk_level, action_type=action_type, ) - async def _list_from_repo_locked( # noqa: C901 + async def _list_from_repo( self, *, status: ApprovalStatus | None, @@ -271,13 +271,17 @@ async def _list_from_repo_locked( # noqa: C901 ) -> tuple[ApprovalItem, ...]: """Repo-backed list path with batched expiry persistence. - Pure-compute pass first collects the EXPIRED transitions into - ``to_persist`` and stages cache updates in a side dict; the - persistence write fans out as a single ``save_many`` so K - simultaneous expiries yield one DB round-trip rather than K. - Cache promotions only land *after* ``save_many`` succeeds so a - failed batch cannot leave an EXPIRED row in the cache that - contradicts the still-PENDING repo state. + Per-page chunked so the store lock is held only for short + cache-mutation critical sections, never across repo I/O, + ``save_many``, or callback dispatch. A long unbounded scan + cannot stall concurrent ``get()`` / ``save()`` callers that + serialize on the same lock. + + Per-page protocol: read page (no lock) -> compute expirations + (pure, no lock) -> ``save_many`` (no lock) -> brief lock for + cache update -> emit audit events + fire callbacks (no lock). + Each page is independent so a failure on one page does not + leave a half-applied state on a later page. Status filtering: @@ -289,7 +293,7 @@ async def _list_from_repo_locked( # noqa: C901 status and lazy expiration cannot promote into it -- the filter is pushed down so the DB only returns matching rows. - Side effects after the batch save: + Side effects after each per-page batch save: * Emits one ``APPROVAL_STATUS_TRANSITIONED`` + one ``API_APPROVAL_EXPIRED`` audit event per newly-expired item. @@ -303,16 +307,13 @@ async def _list_from_repo_locked( # noqa: C901 # the unfiltered ``None`` case) need the broad read so PENDING # rows that should lazily flip to EXPIRED are visible to the # expiration pass below. - # ``ApprovalRepository.list_items`` is bounded to 100 rows - # per call, so we page until exhausted -- otherwise older - # PENDING rows that should lazily flip to EXPIRED here would - # never be visited once there are >100 newer non-expired - # rows in the table. repo_status = None if status in {None, ApprovalStatus.EXPIRED} else status page_size = 100 - repo_pages: list[ApprovalItem] = [] + result: list[ApprovalItem] = [] offset = 0 while True: + # Repo I/O outside the store lock so concurrent get() / + # save() callers are never blocked by a long scan. page = await self._repo.list_items( status=repo_status, risk_level=risk_level, @@ -320,17 +321,82 @@ async def _list_from_repo_locked( # noqa: C901 limit=page_size, offset=offset, ) - repo_pages.extend(page) + if not page: + break + page_result, to_persist, cache_updates = self._compute_page( + page, + status=status, + risk_level=risk_level, + ) + if to_persist: + # Durable write outside the lock; only re-acquire to + # apply the cache delta once ``save_many`` succeeds. + # Audit events + callbacks fire outside the lock too. + try: + await self._repo.save_many(to_persist) + except MemoryError, RecursionError: + raise + except Exception as exc: + # Log the affected ids before re-raising so a + # production failure on the batched expiry path + # is diagnosable -- otherwise the caller sees the + # ``QueryError`` and has no record of which lazy + # expirations were attempted. + logger.warning( + API_APPROVAL_EXPIRE_BATCH_FAILED, + batch_size=len(to_persist), + approval_ids=tuple(item.id for item in to_persist), + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise + async with self._lock: + self._items.update(cache_updates) + for expired in to_persist: + logger.info( + APPROVAL_STATUS_TRANSITIONED, + approval_id=expired.id, + from_status=ApprovalStatus.PENDING.value, + to_status=ApprovalStatus.EXPIRED.value, + ) + logger.info(API_APPROVAL_EXPIRED, approval_id=expired.id) + self._fire_expire_callback(expired) + else: + # No expirations on this page; still need a brief + # lock to populate the cache from the repo read. + async with self._lock: + for item in page: + self._items[item.id] = item + result.extend(page_result) if len(page) < page_size: break offset += page_size - repo_items: tuple[ApprovalItem, ...] = tuple(repo_pages) - for item in repo_items: - self._items[item.id] = item - result: list[ApprovalItem] = [] + return tuple(result) + + def _compute_page( + self, + page: tuple[ApprovalItem, ...], + *, + status: ApprovalStatus | None, + risk_level: ApprovalRiskLevel | None, + ) -> tuple[ + list[ApprovalItem], + list[ApprovalItem], + dict[str, ApprovalItem], + ]: + """Pure: classify a repo page into (filtered, to_persist, cache_updates). + + Companion to :meth:`_list_from_repo`. Walks ``page`` once, + computing lazy expiration via :meth:`_compute_expiration` and + applying caller-supplied filters. No I/O, no lock acquisition. + Splitting this out keeps the per-page pipeline in + ``_list_from_repo`` short and lets the lock-released chunked + flow remain readable. + """ + page_result: list[ApprovalItem] = [] to_persist: list[ApprovalItem] = [] cache_updates: dict[str, ApprovalItem] = {} - for item in repo_items: + for item in page: checked = self._compute_expiration(item) if checked is not item: to_persist.append(checked) @@ -339,41 +405,8 @@ async def _list_from_repo_locked( # noqa: C901 continue if risk_level is not None and checked.risk_level != risk_level: continue - result.append(checked) - if to_persist: - # Durable write first; only mutate the cache once the - # batch commits. Any callback or audit-event emission - # follows the cache update so observers cannot see a - # cached EXPIRED state that the repo never accepted. - try: - await self._repo.save_many(to_persist) - except MemoryError, RecursionError: - raise - except Exception as exc: - # Log the affected ids before re-raising so a - # production failure on the batched expiry path is - # diagnosable -- otherwise the caller sees the - # ``QueryError`` and has no record of which lazy - # expirations were attempted. - logger.warning( - API_APPROVAL_EXPIRE_BATCH_FAILED, - batch_size=len(to_persist), - approval_ids=tuple(item.id for item in to_persist), - error_type=type(exc).__name__, - error=safe_error_description(exc), - ) - raise - self._items.update(cache_updates) - for expired in to_persist: - logger.info( - APPROVAL_STATUS_TRANSITIONED, - approval_id=expired.id, - from_status=ApprovalStatus.PENDING.value, - to_status=ApprovalStatus.EXPIRED.value, - ) - logger.info(API_APPROVAL_EXPIRED, approval_id=expired.id) - self._fire_expire_callback(expired) - return tuple(result) + page_result.append(checked) + return page_result, to_persist, cache_updates async def _list_from_cache_locked( self, @@ -620,12 +653,15 @@ async def _check_expiration_locked( except MemoryError, RecursionError: raise except Exception as exc: - # Best-effort: the approval is already transitioned - # to EXPIRED in cache + repo at this point; callback - # failure must not unwind the expiration itself. - # Emit a dedicated event so operators can filter - # callback failures from successful expirations. - logger.warning( + # ERROR (matching ``_fire_expire_callback``): the + # approval is already EXPIRED in cache + repo, so + # the callback failure can't unwind the expiration, + # but a dropped downstream side effect (webhook, + # audit dispatch, workflow resume) is operationally + # meaningful and operators must be able to alert + # on it. Both paths emit at ERROR so alerting is + # not sensitive to which expiration path fired. + logger.error( # noqa: TRY400 API_APPROVAL_EXPIRE_CALLBACK_FAILED, approval_id=item.id, error_type=type(exc).__name__, diff --git a/src/synthorg/engine/health/models.py b/src/synthorg/engine/health/models.py index b3a618a3ce..1891cf90af 100644 --- a/src/synthorg/engine/health/models.py +++ b/src/synthorg/engine/health/models.py @@ -5,13 +5,13 @@ """ import copy -from collections.abc import Mapping +from collections.abc import Mapping # noqa: TC003 -- runtime Pydantic field annotation from datetime import UTC, datetime from enum import StrEnum from types import MappingProxyType from uuid import uuid4 -from pydantic import AwareDatetime, BaseModel, ConfigDict, Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator from synthorg.core.types import NotBlankStr # noqa: TC001 from synthorg.engine.quality.models import StepQualitySignal # noqa: TC001 @@ -102,22 +102,18 @@ class EscalationTicket(BaseModel): description="Arbitrary structured context (read-only at runtime)", ) - def __init__(self, **data: object) -> None: - """Deep-copy metadata dict and wrap as a read-only mapping. - - ``ConfigDict(frozen=True)`` only blocks attribute rebinding; - without the ``MappingProxyType`` wrap a caller could still - mutate ``ticket.metadata['key'] = value`` after construction. - The deep copy guards against the caller retaining a reference - to the original dict and mutating it post-construction; the + @field_validator("metadata", mode="after") + @classmethod + def _freeze_metadata(cls, value: Mapping[str, object]) -> Mapping[str, object]: + """Deep-copy and wrap metadata as a read-only mapping. + + Pydantic 2.x unwraps generic ``Mapping[...]`` annotations into + a plain ``dict`` during validation, so a pre-validation wrap + in ``__init__`` would silently be discarded and the caller + would still receive a mutable dict on the field. The freeze + runs ``mode="after"`` to act on the validated value. The + deep copy guards against the caller retaining a reference to + the original dict and mutating it post-construction; the proxy guards against direct item assignment on the field. """ - if "metadata" in data: - raw = data["metadata"] - if isinstance(raw, Mapping): - # Always deep-copy before wrapping; reusing an - # incoming ``MappingProxyType`` directly would alias - # its backing dict and let the caller mutate - # ``ticket.metadata`` after construction. - data["metadata"] = MappingProxyType(copy.deepcopy(dict(raw))) - super().__init__(**data) + return MappingProxyType(copy.deepcopy(dict(value))) diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index dd97af00ae..433f126638 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -112,8 +112,9 @@ async def list_all( """Return up to ``limit`` recorded installations, oldest-first. ``limit`` defaults to 100 (matches the protocol-wide pagination - floor); callers needing more must loop with ``offset`` rather - than passing a larger ``limit``. + floor) and accepts any positive integer; no upper bound is + enforced. Callers may either pass a larger ``limit`` or loop + with ``offset`` for cursor-style pagination. """ validate_pagination_args( limit, diff --git a/tests/unit/communication/loop_prevention/test_circuit_breaker.py b/tests/unit/communication/loop_prevention/test_circuit_breaker.py index ff6b0174c0..1b92329faf 100644 --- a/tests/unit/communication/loop_prevention/test_circuit_breaker.py +++ b/tests/unit/communication/loop_prevention/test_circuit_breaker.py @@ -1,7 +1,6 @@ """Tests for delegation circuit breaker.""" import threading -import time from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -467,14 +466,27 @@ def clock() -> float: from threading import Thread underlying = cb._state_lock + # Deterministic handshake: the sibling sets ``blocked_on_lock`` + # exactly when its non-blocking acquire fails (i.e. once it has + # observed that ``check`` holds the lock); the main thread waits + # on that signal instead of sleeping a fixed 50ms. Sleeping does + # NOT prove the sibling reached a contended acquire before + # ``check`` finished, so the regression could pass without + # exercising the interleaving it claims to cover. + blocked_on_lock = threading.Event() injection_done = threading.Event() def _mutate_in_sibling() -> None: - with underlying: + if not underlying.acquire(blocking=False): + blocked_on_lock.set() + underlying.acquire() + try: pair = cb._pairs.get(("a", "b")) if pair is not None: pair.opened_at = None injection_done.set() + finally: + underlying.release() recorded_during_check: list[bool] = [] @@ -485,9 +497,14 @@ def __enter__(self) -> Any: recorded_during_check.append(True) t = Thread(target=_mutate_in_sibling, daemon=True) t.start() - # Yield to the sibling so it observes the lock - # held; it blocks on us until __exit__ fires. - time.sleep(0.05) + # Wait until the sibling proves it observed the + # contended acquire; only then can ``check`` proceed + # to its OPEN-branch read of ``opened_at``. + assert blocked_on_lock.wait(timeout=1.0), ( + "sibling thread did not reach contended " + "acquire within 1s; the test cannot prove " + "the OPEN-branch race is closed." + ) return result def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: @@ -503,7 +520,9 @@ def release(self) -> None: clock_time = 5.0 result = cb.check("a", "b") cb._state_lock = underlying - injection_done.wait(timeout=1.0) + assert injection_done.wait(timeout=1.0), ( + "sibling mutation never completed; the deterministic handshake stalled." + ) assert recorded_during_check, ( "check() never acquired _state_lock through the tracked " "wrapper; the OPEN-branch decision is NOT covered by " From e155e084ea6adf8a7ce712a4374bf808eff6f569 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 02:35:47 +0200 Subject: [PATCH 31/35] fix: address round-8 CodeRabbit findings on PR #1744 - approval_store: capture _generation under the lock before any repo I/O in _list_from_repo; per-page cache update now skips when the generation no longer matches (concurrent clear() landed mid-scan). Mirrors the same guard save() already applies; without it an in-flight scan could repopulate _items after a clear. - approval_store: refresh the entire page slice in _items, not just the EXPIRED transitions. _compute_page now returns page_cache (every row) so non-expired siblings whose authoritative repo state has drifted from the cache get refreshed, preventing stale cached copies from leaking into later get()/save_if_pending() decisions. - sqlite/mcp_installation_repo: list_all wraps the read in try/except to log PERSISTENCE_MCP_INSTALLATION_LIST_FAILED and raise QueryError on sqlite/aiosqlite errors, matching save/delete error pattern. - postgres/mcp_installation_repo: list_all read failures normalize to QueryError and use PERSISTENCE_MCP_INSTALLATION_LIST_FAILED for parity with sqlite (was using MCP_SERVER_INSTALL_FAILED with bare re-raise). --- src/synthorg/api/approval_store.py | 82 ++++++++++++------- .../postgres/mcp_installation_repo.py | 9 +- .../sqlite/mcp_installation_repo.py | 15 +++- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 6aa2d56b8b..3bb5d42d57 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -283,6 +283,22 @@ async def _list_from_repo( Each page is independent so a failure on one page does not leave a half-applied state on a later page. + Generation guard: captures ``self._generation`` under the lock + before any repo I/O, then skips the cache-update step on a + per-page basis if the captured generation no longer matches + ``self._generation`` (i.e. a concurrent ``clear()`` landed + between the capture and the cache write). Without this guard + an in-flight scan could repopulate ``_items`` after a clear + finished, undoing the post-clear empty-cache invariant the + ``save()`` path already protects via the same generation check. + + Cache refresh scope: every row in a fetched page is written + into ``_items`` (not just the EXPIRED transitions), so a + non-expired sibling whose authoritative repo state has drifted + from the cache still gets refreshed. Otherwise a stale cached + copy could survive a repo read and leak into a later ``get()`` + / ``save_if_pending()`` decision. + Status filtering: * When ``status`` is ``EXPIRED`` (or ``None``), the repo query @@ -302,6 +318,12 @@ async def _list_from_repo( logged at ERROR but do not unwind the expiration). """ assert self._repo is not None # noqa: S101 -- caller invariant + # Capture generation under the lock before any repo I/O so a + # concurrent ``clear()`` landing mid-scan can be detected and + # prevent a post-clear cache resurrection. Mirrors the same + # guard ``save()`` already applies. + async with self._lock: + captured_generation = self._generation # Push the status filter down for non-EXPIRED queries so the # DB doesn't have to scan the whole table; only EXPIRED (and # the unfiltered ``None`` case) need the broad read so PENDING @@ -323,15 +345,15 @@ async def _list_from_repo( ) if not page: break - page_result, to_persist, cache_updates = self._compute_page( + page_result, to_persist, page_cache = self._compute_page( page, status=status, risk_level=risk_level, ) if to_persist: - # Durable write outside the lock; only re-acquire to - # apply the cache delta once ``save_many`` succeeds. - # Audit events + callbacks fire outside the lock too. + # Durable write outside the lock; cache refresh + audit + # events + callbacks all fire below regardless of + # whether expirations landed on this page. try: await self._repo.save_many(to_persist) except MemoryError, RecursionError: @@ -350,23 +372,24 @@ async def _list_from_repo( error=safe_error_description(exc), ) raise - async with self._lock: - self._items.update(cache_updates) - for expired in to_persist: - logger.info( - APPROVAL_STATUS_TRANSITIONED, - approval_id=expired.id, - from_status=ApprovalStatus.PENDING.value, - to_status=ApprovalStatus.EXPIRED.value, - ) - logger.info(API_APPROVAL_EXPIRED, approval_id=expired.id) - self._fire_expire_callback(expired) - else: - # No expirations on this page; still need a brief - # lock to populate the cache from the repo read. - async with self._lock: - for item in page: - self._items[item.id] = item + # Refresh the entire page slice in the cache (not just the + # EXPIRED transitions) so stale non-expired siblings can't + # outlive a fresh repo read. Generation guard: a concurrent + # ``clear()`` between the I/O and this critical section + # bumps ``_generation``; skip the cache write so the + # post-clear empty-cache invariant survives. + async with self._lock: + if self._generation == captured_generation: + self._items.update(page_cache) + for expired in to_persist: + logger.info( + APPROVAL_STATUS_TRANSITIONED, + approval_id=expired.id, + from_status=ApprovalStatus.PENDING.value, + to_status=ApprovalStatus.EXPIRED.value, + ) + logger.info(API_APPROVAL_EXPIRED, approval_id=expired.id) + self._fire_expire_callback(expired) result.extend(page_result) if len(page) < page_size: break @@ -384,29 +407,32 @@ def _compute_page( list[ApprovalItem], dict[str, ApprovalItem], ]: - """Pure: classify a repo page into (filtered, to_persist, cache_updates). + """Pure: classify a repo page into (filtered, to_persist, page_cache). Companion to :meth:`_list_from_repo`. Walks ``page`` once, computing lazy expiration via :meth:`_compute_expiration` and applying caller-supplied filters. No I/O, no lock acquisition. - Splitting this out keeps the per-page pipeline in - ``_list_from_repo`` short and lets the lock-released chunked - flow remain readable. + + ``page_cache`` carries every row from the page (with the + possibly-EXPIRED replacement substituted in) so the caller + can refresh the entire page slice in ``_items``, not just the + EXPIRED transitions. ``to_persist`` carries only the rows that + actually flipped, which is what ``save_many`` writes. """ page_result: list[ApprovalItem] = [] to_persist: list[ApprovalItem] = [] - cache_updates: dict[str, ApprovalItem] = {} + page_cache: dict[str, ApprovalItem] = {} for item in page: checked = self._compute_expiration(item) + page_cache[item.id] = checked if checked is not item: to_persist.append(checked) - cache_updates[item.id] = checked if status is not None and checked.status != status: continue if risk_level is not None and checked.risk_level != risk_level: continue page_result.append(checked) - return page_result, to_persist, cache_updates + return page_result, to_persist, page_cache async def _list_from_cache_locked( self, diff --git a/src/synthorg/persistence/postgres/mcp_installation_repo.py b/src/synthorg/persistence/postgres/mcp_installation_repo.py index 861c88ac3e..2c917606ab 100644 --- a/src/synthorg/persistence/postgres/mcp_installation_repo.py +++ b/src/synthorg/persistence/postgres/mcp_installation_repo.py @@ -13,6 +13,7 @@ from psycopg.rows import dict_row +from synthorg.core.persistence_errors import QueryError from synthorg.core.types import NotBlankStr from synthorg.integrations.mcp_catalog.installations import McpInstallation from synthorg.observability import get_logger, safe_error_description @@ -161,14 +162,16 @@ async def list_all( except MemoryError, RecursionError: raise except Exception as exc: + msg = "Failed to list mcp installations" logger.warning( - MCP_SERVER_INSTALL_FAILED, - operation="list_all", + PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, + limit=limit, + offset=offset, error_type=type(exc).__name__, error=safe_error_description(exc), backend="postgres", ) - raise + raise QueryError(msg) from exc return tuple(_row_to_installation(row) for row in rows) async def delete(self, catalog_entry_id: NotBlankStr) -> bool: diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index 433f126638..ab1be56d52 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -127,8 +127,19 @@ async def list_all( "ORDER BY installed_at ASC, catalog_entry_id ASC " "LIMIT ? OFFSET ?" ) - async with self._db.execute(sql, (limit, offset)) as cursor: - rows = await cursor.fetchall() + try: + async with self._db.execute(sql, (limit, offset)) as cursor: + rows = await cursor.fetchall() + except (sqlite3.Error, aiosqlite.Error) as exc: + msg = "Failed to list mcp installations" + logger.warning( + PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, + limit=limit, + offset=offset, + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise QueryError(msg) from exc return tuple( McpInstallation( catalog_entry_id=NotBlankStr(row[0]), From 9ed706f439671b3b211e4eda2e1ba18a38b8ea3c Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 03:07:32 +0200 Subject: [PATCH 32/35] fix: address round-9 CodeRabbit findings on PR #1744 - approval_repo (sqlite + postgres): add expire_if_pending(ids) -> tuple[NotBlankStr, ...] compare-and-set method that flips rows still PENDING to EXPIRED via UPDATE ... WHERE id IN (...) AND status='pending' RETURNING id, returning the ids actually updated. Replaces the blind save_many upsert in the lazy-expire path so a concurrent save() that wrote a newer terminal status (APPROVED, REJECTED, CANCELLED) between page read and persist cannot be clobbered. - approval_store: _list_from_repo now uses expire_if_pending and filters cache writes, audit events, callbacks, and the response to actually-transitioned ids only. Lost-race rows are evicted from cache (next get() refetches authoritative state) and dropped from the response (surfacing them as EXPIRED would leak stale data). - approval_store: drop status=PENDING from the repo-side pushdown. PENDING cannot be pushed down because per-page expiration removes rows from the filtered set as the iterator advances; offset += 100 would then skip rows that should still be visible. - mcp_installation_repo (sqlite + postgres): pull row deserialization inside the same try/except as execute/fetchall so malformed persisted rows surface under PERSISTENCE_MCP_INSTALLATION_LIST_FAILED + QueryError envelope, not as raw exceptions escaping the persistence boundary. - conformance/persistence/test_approval_repository: add tests for expire_if_pending (compare-and-set semantics, empty input no-op, unknown ids no-op). --- src/synthorg/api/approval_store.py | 82 ++++++++++++++----- src/synthorg/persistence/approval_protocol.py | 22 +++++ .../persistence/postgres/approval_repo.py | 35 +++++++- .../postgres/mcp_installation_repo.py | 7 +- .../persistence/sqlite/approval_repo.py | 40 ++++++++- .../sqlite/mcp_installation_repo.py | 21 +++-- .../persistence/test_approval_repository.py | 52 ++++++++++++ 7 files changed, 228 insertions(+), 31 deletions(-) diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 3bb5d42d57..8814d1b1dc 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -262,7 +262,7 @@ async def list_items( action_type=action_type, ) - async def _list_from_repo( + async def _list_from_repo( # noqa: C901 self, *, status: ApprovalStatus | None, @@ -278,7 +278,8 @@ async def _list_from_repo( serialize on the same lock. Per-page protocol: read page (no lock) -> compute expirations - (pure, no lock) -> ``save_many`` (no lock) -> brief lock for + (pure, no lock) -> ``expire_if_pending`` (no lock; compare-and- + set so concurrent saves can't be clobbered) -> brief lock for cache update -> emit audit events + fire callbacks (no lock). Each page is independent so a failure on one page does not leave a half-applied state on a later page. @@ -301,9 +302,16 @@ async def _list_from_repo( Status filtering: - * When ``status`` is ``EXPIRED`` (or ``None``), the repo query - omits the status filter so PENDING rows that should lazily - flip to EXPIRED still surface and get persisted. + * When ``status`` is ``EXPIRED``, ``PENDING``, or ``None``, + the repo query omits the status filter. ``PENDING`` cannot + be pushed down because :meth:`_compute_page` flips PENDING + rows to EXPIRED between pages; a repo-side ``status=pending`` + filter would shrink the result set under the iterator, so + ``offset += 100`` would skip rows that were still PENDING + when the previous page was read but should remain visible + to the caller. ``EXPIRED`` also stays unfiltered so PENDING + rows that should lazily flip to EXPIRED surface and get + persisted. * When ``status`` is any other terminal value (APPROVED, REJECTED, CANCELLED), the repo authoritatively persists that status and lazy expiration cannot promote into it -- the @@ -324,12 +332,18 @@ async def _list_from_repo( # guard ``save()`` already applies. async with self._lock: captured_generation = self._generation - # Push the status filter down for non-EXPIRED queries so the - # DB doesn't have to scan the whole table; only EXPIRED (and - # the unfiltered ``None`` case) need the broad read so PENDING - # rows that should lazily flip to EXPIRED are visible to the - # expiration pass below. - repo_status = None if status in {None, ApprovalStatus.EXPIRED} else status + # Push the status filter down only for terminal non-EXPIRED + # queries (APPROVED / REJECTED / CANCELLED). PENDING cannot + # be pushed down because the per-page expiration flip removes + # rows from the filtered set as the iterator advances -- + # ``offset += 100`` would then skip PENDING rows that should + # have been visible. EXPIRED also stays unfiltered so the + # lazy-expire pass can promote the PENDING rows. + repo_status = ( + None + if status in {None, ApprovalStatus.PENDING, ApprovalStatus.EXPIRED} + else status + ) page_size = 100 result: list[ApprovalItem] = [] offset = 0 @@ -350,16 +364,26 @@ async def _list_from_repo( status=status, risk_level=risk_level, ) + actually_expired_ids: set[str] = set() if to_persist: - # Durable write outside the lock; cache refresh + audit - # events + callbacks all fire below regardless of - # whether expirations landed on this page. + # Compare-and-set at the repo boundary: only flip rows + # still PENDING. A concurrent save() that landed a + # newer terminal status (APPROVED / REJECTED / + # CANCELLED) between our page read and this call wins + # the race; ``expire_if_pending`` returns only the ids + # that actually transitioned, so audit events, + # callbacks, and cache writes don't fire for rows we + # never persisted. try: - await self._repo.save_many(to_persist) + actually_expired_ids = set( + await self._repo.expire_if_pending( + tuple(item.id for item in to_persist), + ), + ) except MemoryError, RecursionError: raise except Exception as exc: - # Log the affected ids before re-raising so a + # Log the attempted ids before re-raising so a # production failure on the batched expiry path # is diagnosable -- otherwise the caller sees the # ``QueryError`` and has no record of which lazy @@ -372,6 +396,13 @@ async def _list_from_repo( error=safe_error_description(exc), ) raise + # Lost-race rows: rows we tried to flip but the repo + # already had a newer terminal status. Drop them from the + # cache (next get() refetches authoritative state) and + # from the response (surfacing them as EXPIRED would + # leak stale data). + attempted_ids = {item.id for item in to_persist} + lost_race_ids = attempted_ids - actually_expired_ids # Refresh the entire page slice in the cache (not just the # EXPIRED transitions) so stale non-expired siblings can't # outlive a fresh repo read. Generation guard: a concurrent @@ -380,8 +411,18 @@ async def _list_from_repo( # post-clear empty-cache invariant survives. async with self._lock: if self._generation == captured_generation: - self._items.update(page_cache) + for item_id, cached in page_cache.items(): + if item_id in lost_race_ids: + # Stale snapshot; evict so the next get() + # refetches the authoritative row from + # repo rather than returning our local + # EXPIRED guess. + self._items.pop(item_id, None) + else: + self._items[item_id] = cached for expired in to_persist: + if expired.id not in actually_expired_ids: + continue logger.info( APPROVAL_STATUS_TRANSITIONED, approval_id=expired.id, @@ -390,7 +431,7 @@ async def _list_from_repo( ) logger.info(API_APPROVAL_EXPIRED, approval_id=expired.id) self._fire_expire_callback(expired) - result.extend(page_result) + result.extend(item for item in page_result if item.id not in lost_race_ids) if len(page) < page_size: break offset += page_size @@ -416,8 +457,9 @@ def _compute_page( ``page_cache`` carries every row from the page (with the possibly-EXPIRED replacement substituted in) so the caller can refresh the entire page slice in ``_items``, not just the - EXPIRED transitions. ``to_persist`` carries only the rows that - actually flipped, which is what ``save_many`` writes. + EXPIRED transitions. ``to_persist`` carries only the rows + that flipped locally, which is the candidate set the caller + feeds to ``expire_if_pending`` for the compare-and-set. """ page_result: list[ApprovalItem] = [] to_persist: list[ApprovalItem] = [] diff --git a/src/synthorg/persistence/approval_protocol.py b/src/synthorg/persistence/approval_protocol.py index eb0e9c72cb..17bf664d36 100644 --- a/src/synthorg/persistence/approval_protocol.py +++ b/src/synthorg/persistence/approval_protocol.py @@ -56,6 +56,28 @@ async def save_many(self, items: Sequence[ApprovalItem]) -> None: """ ... + async def expire_if_pending( + self, ids: Sequence[NotBlankStr] + ) -> tuple[NotBlankStr, ...]: + """Compare-and-set: flip rows still ``PENDING`` to ``EXPIRED``. + + Updates only rows whose current persisted status is still + ``PENDING``; rows that have transitioned to a terminal status + (APPROVED, REJECTED, CANCELLED) since the caller's snapshot + are silently skipped. Returns the ids actually updated, so + the lazy-expire path in :class:`ApprovalStore` can drive + cache refresh, audit events, and ``on_expire`` callbacks + only for rows that truly transitioned -- without this + compare-and-set a blind upsert would clobber a concurrent + ``save()`` decision back to ``EXPIRED``. + + Empty input is a no-op (returns ``()``). + + Raises: + QueryError: On database errors. + """ + ... + async def get(self, approval_id: NotBlankStr) -> ApprovalItem | None: """Get an approval item by ID, or ``None`` if not found. diff --git a/src/synthorg/persistence/postgres/approval_repo.py b/src/synthorg/persistence/postgres/approval_repo.py index c9de76d4e6..f3b7c9117c 100644 --- a/src/synthorg/persistence/postgres/approval_repo.py +++ b/src/synthorg/persistence/postgres/approval_repo.py @@ -22,7 +22,7 @@ from synthorg.core.enums import ApprovalRiskLevel, ApprovalStatus from synthorg.core.evidence import EvidencePackage from synthorg.core.persistence_errors import ConstraintViolationError, QueryError -from synthorg.core.types import NotBlankStr # noqa: TC001 +from synthorg.core.types import NotBlankStr from synthorg.observability import get_logger, safe_error_description from synthorg.observability.events.api import ( API_APPROVAL_REPO_FAILED, @@ -290,6 +290,39 @@ async def save_many(self, items: Sequence[ApprovalItem]) -> None: ) raise QueryError(msg) from exc + async def expire_if_pending( + self, ids: Sequence[NotBlankStr] + ) -> tuple[NotBlankStr, ...]: + """Compare-and-set: flip rows still PENDING to EXPIRED. + + Uses ``UPDATE ... WHERE id = ANY(%s) AND status='pending' + RETURNING id`` so the compare-and-set is atomic at the row + level and the returned ids reflect what actually transitioned. + """ + if not ids: + return () + sql = ( + f"UPDATE approvals SET status = '{ApprovalStatus.EXPIRED.value}' " # noqa: S608 + "WHERE id = ANY(%s) " + f"AND status = '{ApprovalStatus.PENDING.value}' " + "RETURNING id" + ) + try: + async with self._pool.connection() as conn, conn.cursor() as cur: + await cur.execute(sql, (list(ids),)) + rows = await cur.fetchall() + await conn.commit() + except psycopg.Error as exc: + msg = f"Failed to expire approval batch (size={len(ids)})" + logger.warning( + API_APPROVAL_REPO_FAILED, + batch_size=len(ids), + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise QueryError(msg) from exc + return tuple(NotBlankStr(row[0]) for row in rows) + async def get(self, approval_id: NotBlankStr) -> ApprovalItem | None: """Get an approval item by ID, or ``None`` if not found. diff --git a/src/synthorg/persistence/postgres/mcp_installation_repo.py b/src/synthorg/persistence/postgres/mcp_installation_repo.py index 2c917606ab..9c172ccf1f 100644 --- a/src/synthorg/persistence/postgres/mcp_installation_repo.py +++ b/src/synthorg/persistence/postgres/mcp_installation_repo.py @@ -159,6 +159,12 @@ async def list_all( ): await cur.execute(sql, (limit, offset)) rows = await cur.fetchall() + # Deserialization runs inside the same try/except so a + # malformed persisted row surfaces under the same + # ``PERSISTENCE_MCP_INSTALLATION_LIST_FAILED`` event + + # ``QueryError`` envelope as a DB failure, not as a raw + # exception that escapes the persistence boundary. + return tuple(_row_to_installation(row) for row in rows) except MemoryError, RecursionError: raise except Exception as exc: @@ -172,7 +178,6 @@ async def list_all( backend="postgres", ) raise QueryError(msg) from exc - return tuple(_row_to_installation(row) for row in rows) async def delete(self, catalog_entry_id: NotBlankStr) -> bool: """Delete an installation. Returns ``True`` if a row was removed.""" diff --git a/src/synthorg/persistence/sqlite/approval_repo.py b/src/synthorg/persistence/sqlite/approval_repo.py index 49a2c450b2..7c89511507 100644 --- a/src/synthorg/persistence/sqlite/approval_repo.py +++ b/src/synthorg/persistence/sqlite/approval_repo.py @@ -1,6 +1,7 @@ """SQLite repository implementation for approval items.""" import asyncio +import contextlib import json import sqlite3 from typing import TYPE_CHECKING @@ -16,7 +17,7 @@ from synthorg.core.enums import ApprovalRiskLevel, ApprovalStatus from synthorg.core.evidence import EvidencePackage from synthorg.core.persistence_errors import ConstraintViolationError, QueryError -from synthorg.core.types import NotBlankStr # noqa: TC001 +from synthorg.core.types import NotBlankStr from synthorg.observability import get_logger, safe_error_description from synthorg.observability.events.api import ( API_APPROVAL_REPO_FAILED, @@ -267,6 +268,43 @@ async def save_many(self, items: Sequence[ApprovalItem]) -> None: ) raise QueryError(msg) from exc + async def expire_if_pending( + self, ids: Sequence[NotBlankStr] + ) -> tuple[NotBlankStr, ...]: + """Compare-and-set: flip rows still PENDING to EXPIRED. + + Uses ``UPDATE ... WHERE id IN (?,...) AND status='pending' + RETURNING id`` (SQLite >= 3.35) so the compare-and-set is + atomic at the row level and the returned ids reflect what + actually transitioned. + """ + if not ids: + return () + placeholders = ",".join(["?"] * len(ids)) + sql = ( + f"UPDATE approvals SET status = '{ApprovalStatus.EXPIRED.value}' " # noqa: S608 + f"WHERE id IN ({placeholders}) " + f"AND status = '{ApprovalStatus.PENDING.value}' " + "RETURNING id" + ) + async with self._write_lock: + try: + async with self._db.execute(sql, tuple(ids)) as cursor: + rows = await cursor.fetchall() + await self._db.commit() + except (sqlite3.Error, aiosqlite.Error) as exc: + with contextlib.suppress(sqlite3.Error, aiosqlite.Error): + await self._db.rollback() + msg = f"Failed to expire approval batch (size={len(ids)})" + logger.warning( + API_APPROVAL_REPO_FAILED, + batch_size=len(ids), + error_type=type(exc).__name__, + error=safe_error_description(exc), + ) + raise QueryError(msg) from exc + return tuple(NotBlankStr(row[0]) for row in rows) + async def get(self, approval_id: NotBlankStr) -> ApprovalItem | None: """Get an approval item by ID. diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index ab1be56d52..be79ab8856 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -130,6 +130,19 @@ async def list_all( try: async with self._db.execute(sql, (limit, offset)) as cursor: rows = await cursor.fetchall() + # Deserialization runs inside the same try/except so a + # malformed persisted row surfaces under the same + # ``PERSISTENCE_MCP_INSTALLATION_LIST_FAILED`` event + + # ``QueryError`` envelope as a DB failure, not as a raw + # exception that escapes the persistence boundary. + return tuple( + McpInstallation( + catalog_entry_id=NotBlankStr(row[0]), + connection_name=(NotBlankStr(row[1]) if row[1] else None), + installed_at=coerce_row_timestamp(row[2]), + ) + for row in rows + ) except (sqlite3.Error, aiosqlite.Error) as exc: msg = "Failed to list mcp installations" logger.warning( @@ -140,14 +153,6 @@ async def list_all( error=safe_error_description(exc), ) raise QueryError(msg) from exc - return tuple( - McpInstallation( - catalog_entry_id=NotBlankStr(row[0]), - connection_name=(NotBlankStr(row[1]) if row[1] else None), - installed_at=coerce_row_timestamp(row[2]), - ) - for row in rows - ) async def delete(self, catalog_entry_id: NotBlankStr) -> bool: """Delete an installation. Returns ``True`` if a row was removed.""" diff --git a/tests/conformance/persistence/test_approval_repository.py b/tests/conformance/persistence/test_approval_repository.py index 2f371e70e5..3088bdbe04 100644 --- a/tests/conformance/persistence/test_approval_repository.py +++ b/tests/conformance/persistence/test_approval_repository.py @@ -17,6 +17,7 @@ from synthorg.core.approval import ApprovalItem from synthorg.core.enums import ApprovalRiskLevel, ApprovalStatus +from synthorg.core.types import NotBlankStr from synthorg.persistence.approval_protocol import ApprovalRepository from synthorg.persistence.postgres.approval_repo import ( PostgresApprovalRepository, @@ -342,3 +343,54 @@ async def test_save_many_duplicate_ids_within_batch_settle_to_last( fetched = await repo.get(first.id) assert fetched is not None assert fetched.status is ApprovalStatus.EXPIRED + + async def test_expire_if_pending_flips_pending_rows_only( + self, + backend: PersistenceBackend, + ) -> None: + # Compare-and-set contract: rows still PENDING transition to + # EXPIRED; rows already in a terminal status are silently + # skipped. Returned ids reflect what actually changed. + repo = _approval_repo(backend) + pending = _make_item( + approval_id="approval-expire-pending", + status=ApprovalStatus.PENDING, + ) + approved = _make_item( + approval_id="approval-expire-approved", + status=ApprovalStatus.APPROVED, + ) + rejected = _make_item( + approval_id="approval-expire-rejected", + status=ApprovalStatus.REJECTED, + ) + await repo.save_many((pending, approved, rejected)) + + updated = await repo.expire_if_pending( + (pending.id, approved.id, rejected.id), + ) + assert set(updated) == {pending.id} + assert (await repo.get(pending.id)).status is ApprovalStatus.EXPIRED # type: ignore[union-attr] + assert (await repo.get(approved.id)).status is ApprovalStatus.APPROVED # type: ignore[union-attr] + assert (await repo.get(rejected.id)).status is ApprovalStatus.REJECTED # type: ignore[union-attr] + + async def test_expire_if_pending_empty_input_is_noop( + self, + backend: PersistenceBackend, + ) -> None: + repo = _approval_repo(backend) + result = await repo.expire_if_pending(()) + assert result == () + + async def test_expire_if_pending_unknown_ids_returned_empty( + self, + backend: PersistenceBackend, + ) -> None: + # Ids that don't exist in the table are silently skipped, same + # as a row that's already terminal -- the compare-and-set + # WHERE clause matches no row, so no row is returned. + repo = _approval_repo(backend) + updated = await repo.expire_if_pending( + (NotBlankStr("approval-expire-missing"),), + ) + assert updated == () From 0cfa0dfa922dbce22205fb48a9a43ac08e252dfb Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 03:32:55 +0200 Subject: [PATCH 33/35] fix: address round-10 CodeRabbit findings on PR #1744 - approval_store: refetch lost-race rows from the repo and apply the caller's filters before extending the result. Previously dropped them entirely, which under-reported rows for unfiltered (status= None) listings when a concurrent save() raced our compare-and-set. Refetched rows also land in the cache so the next get() returns authoritative state. - sqlite/approval_repo.expire_if_pending: replace contextlib.suppress on the rollback path with explicit try/except that logs the rollback failure as a separate structured event before re-raising the original. Suppressing rollback diagnostics left the shared aiosqlite.Connection in an unknown state with no trace of why subsequent writes might start failing. - sqlite/mcp_installation_repo.list_all: broaden the read-path except from (sqlite3.Error, aiosqlite.Error) to Exception (with MemoryError/RecursionError carve-out) so NotBlankStr / coerce_row_ timestamp failures on a malformed persisted row also surface under PERSISTENCE_MCP_INSTALLATION_LIST_FAILED + QueryError, matching the postgres impl's pattern. --- src/synthorg/api/approval_store.py | 45 ++++++++++++++----- .../persistence/sqlite/approval_repo.py | 22 ++++++++- .../sqlite/mcp_installation_repo.py | 8 +++- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 8814d1b1dc..687040535b 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -45,7 +45,7 @@ ApprovalStatus, ) from synthorg.core.persistence_errors import ConstraintViolationError -from synthorg.core.types import NotBlankStr # noqa: TC001 +from synthorg.core.types import NotBlankStr from synthorg.observability import get_logger, safe_error_description from synthorg.observability.events.api import ( API_APPROVAL_CONFLICT, @@ -262,7 +262,7 @@ async def list_items( action_type=action_type, ) - async def _list_from_repo( # noqa: C901 + async def _list_from_repo( # noqa: C901, PLR0912, PLR0915 self, *, status: ApprovalStatus | None, @@ -397,15 +397,33 @@ async def _list_from_repo( # noqa: C901 ) raise # Lost-race rows: rows we tried to flip but the repo - # already had a newer terminal status. Drop them from the - # cache (next get() refetches authoritative state) and - # from the response (surfacing them as EXPIRED would - # leak stale data). + # already had a newer terminal status. Refetch them so + # the response reflects the authoritative state instead + # of either our stale EXPIRED guess or silent omission + # (an unfiltered ``list_items()`` must not under-report + # rows just because a concurrent save() raced with the + # expire pass). Apply the caller's filters to each + # refetched row; rows where the repo returns ``None`` + # (deleted between page read and refetch) drop out. attempted_ids = {item.id for item in to_persist} lost_race_ids = attempted_ids - actually_expired_ids + refetched_rows: list[ApprovalItem] = [] + for lost_id in lost_race_ids: + refetched = await self._repo.get(NotBlankStr(lost_id)) + if refetched is None: + continue + if status is not None and refetched.status != status: + continue + if risk_level is not None and refetched.risk_level != risk_level: + continue + if action_type is not None and refetched.action_type != action_type: + continue + refetched_rows.append(refetched) # Refresh the entire page slice in the cache (not just the # EXPIRED transitions) so stale non-expired siblings can't - # outlive a fresh repo read. Generation guard: a concurrent + # outlive a fresh repo read; refetched lost-race rows + # land alongside so subsequent ``get()`` returns the + # authoritative state. Generation guard: a concurrent # ``clear()`` between the I/O and this critical section # bumps ``_generation``; skip the cache write so the # post-clear empty-cache invariant survives. @@ -413,13 +431,17 @@ async def _list_from_repo( # noqa: C901 if self._generation == captured_generation: for item_id, cached in page_cache.items(): if item_id in lost_race_ids: - # Stale snapshot; evict so the next get() - # refetches the authoritative row from - # repo rather than returning our local - # EXPIRED guess. + # Stale local guess; either the refetch + # below provides the authoritative copy + # (and overwrites this slot a few lines + # down) or the row no longer matches + # filters and we evict so the next + # ``get()`` refetches. self._items.pop(item_id, None) else: self._items[item_id] = cached + for refetched in refetched_rows: + self._items[refetched.id] = refetched for expired in to_persist: if expired.id not in actually_expired_ids: continue @@ -432,6 +454,7 @@ async def _list_from_repo( # noqa: C901 logger.info(API_APPROVAL_EXPIRED, approval_id=expired.id) self._fire_expire_callback(expired) result.extend(item for item in page_result if item.id not in lost_race_ids) + result.extend(refetched_rows) if len(page) < page_size: break offset += page_size diff --git a/src/synthorg/persistence/sqlite/approval_repo.py b/src/synthorg/persistence/sqlite/approval_repo.py index 7c89511507..dcd5052bd5 100644 --- a/src/synthorg/persistence/sqlite/approval_repo.py +++ b/src/synthorg/persistence/sqlite/approval_repo.py @@ -1,7 +1,6 @@ """SQLite repository implementation for approval items.""" import asyncio -import contextlib import json import sqlite3 from typing import TYPE_CHECKING @@ -293,8 +292,27 @@ async def expire_if_pending( rows = await cursor.fetchall() await self._db.commit() except (sqlite3.Error, aiosqlite.Error) as exc: - with contextlib.suppress(sqlite3.Error, aiosqlite.Error): + # Log the rollback failure separately rather than + # suppressing it -- a silent rollback failure leaves + # the shared aiosqlite.Connection in an unknown state + # and the only diagnostic of why subsequent writes + # may start failing is then lost. Original ``exc`` is + # still chained on the QueryError so the caller sees + # the root cause. + try: await self._db.rollback() + except (sqlite3.Error, aiosqlite.Error) as rollback_exc: + # ``logger.error`` (not ``logger.exception``): + # the rollback failure is a structured event, not + # a stack-trace dump. ``rollback_exc`` is captured + # in ``error_type`` + ``error`` already. + logger.error( # noqa: TRY400 + API_APPROVAL_REPO_FAILED, + batch_size=len(ids), + phase="rollback", + error_type=type(rollback_exc).__name__, + error=safe_error_description(rollback_exc), + ) msg = f"Failed to expire approval batch (size={len(ids)})" logger.warning( API_APPROVAL_REPO_FAILED, diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index be79ab8856..1ae29e160c 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -135,6 +135,10 @@ async def list_all( # ``PERSISTENCE_MCP_INSTALLATION_LIST_FAILED`` event + # ``QueryError`` envelope as a DB failure, not as a raw # exception that escapes the persistence boundary. + # ``NotBlankStr`` raises ``ValueError`` on blank strings + # and ``coerce_row_timestamp`` raises ``ValueError`` / + # ``TypeError`` on malformed timestamps, both of which + # would slip past a ``sqlite3.Error``-only except. return tuple( McpInstallation( catalog_entry_id=NotBlankStr(row[0]), @@ -143,7 +147,9 @@ async def list_all( ) for row in rows ) - except (sqlite3.Error, aiosqlite.Error) as exc: + except MemoryError, RecursionError: + raise + except Exception as exc: msg = "Failed to list mcp installations" logger.warning( PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, From cc7b04dd574e3c3e0446966d909f5ccc191d2788 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 04:00:30 +0200 Subject: [PATCH 34/35] fix: align McpInstallationRepository CRUD vocab to list_items Round-10 head review: rename list_all to list_items on the McpInstallationRepository protocol and all four implementations (sqlite, postgres, in_memory, plus the mcp_protocol.py mirror) so the persistence CRUD vocabulary matches the canonical contract spelled out in CLAUDE.md (save, get, delete, list_items, query). Aligns the in_memory impl's limit=int|None=None outlier to the standard limit=int=100 signature used by the durable backends; the protocol body itself previously had no pagination params at all and now matches. Updated callers: conformance test (4 sites plus 2 test method renames), unit test (1 site), integration test (1 site). 26499 unit tests pass. Plus a transient CLA workflow flake on the prior head (action verified the signature, then died on a GitHub API socket-hang-up post-success); reran the failed job, now green. --- .../mcp_catalog/in_memory_installations.py | 12 +++++++----- .../integrations/mcp_catalog/installations.py | 16 ++++++++++++++-- src/synthorg/persistence/mcp_protocol.py | 4 ++-- .../postgres/mcp_installation_repo.py | 4 ++-- .../persistence/sqlite/mcp_installation_repo.py | 2 +- .../test_mcp_installations_repository.py | 12 ++++++------ .../integration/integrations/test_controllers.py | 2 +- tests/unit/integrations/test_mcp_catalog.py | 2 +- 8 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/synthorg/integrations/mcp_catalog/in_memory_installations.py b/src/synthorg/integrations/mcp_catalog/in_memory_installations.py index 36fc233099..3e9dd4e3b7 100644 --- a/src/synthorg/integrations/mcp_catalog/in_memory_installations.py +++ b/src/synthorg/integrations/mcp_catalog/in_memory_installations.py @@ -55,17 +55,21 @@ async def get( """Fetch by catalog entry id.""" return self._store.get(catalog_entry_id) - async def list_all( + async def list_items( self, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[McpInstallation, ...]: - """List all installations ordered by ``installed_at, catalog_entry_id`` ASC. + """List installations ordered by ``installed_at, catalog_entry_id`` ASC. Tiebreaker on ``catalog_entry_id`` matches the durable backends so the in-memory shim produces identical pagination windows for rows that share an ``installed_at`` instant. + + ``limit`` defaults to the protocol-wide pagination floor; + callers needing more must loop with ``offset`` or pass a + larger ``limit`` explicitly. """ rows = tuple( sorted( @@ -74,8 +78,6 @@ async def list_all( ), ) effective_offset = max(0, int(offset)) - if limit is None: - return rows[effective_offset:] return rows[effective_offset : effective_offset + max(0, int(limit))] async def delete(self, catalog_entry_id: NotBlankStr) -> bool: diff --git a/src/synthorg/integrations/mcp_catalog/installations.py b/src/synthorg/integrations/mcp_catalog/installations.py index 7865cad8d8..6991ce0f7f 100644 --- a/src/synthorg/integrations/mcp_catalog/installations.py +++ b/src/synthorg/integrations/mcp_catalog/installations.py @@ -47,8 +47,20 @@ async def get(self, catalog_entry_id: NotBlankStr) -> McpInstallation | None: """Fetch an installation by catalog entry id.""" ... - async def list_all(self) -> tuple[McpInstallation, ...]: - """List all recorded installations.""" + async def list_items( + self, + *, + limit: int = 100, + offset: int = 0, + ) -> tuple[McpInstallation, ...]: + """List recorded installations. + + ``limit`` defaults to the protocol-wide pagination floor; pass + a larger ``limit`` or loop with ``offset`` for cursor-style + pagination. Implementations enforce ``limit >= 1`` / + ``offset >= 0`` via the shared ``validate_pagination_args`` + helper and raise ``QueryError`` on invalid inputs. + """ ... async def delete(self, catalog_entry_id: NotBlankStr) -> bool: diff --git a/src/synthorg/persistence/mcp_protocol.py b/src/synthorg/persistence/mcp_protocol.py index 2d637cd5ee..f6c64bbcc4 100644 --- a/src/synthorg/persistence/mcp_protocol.py +++ b/src/synthorg/persistence/mcp_protocol.py @@ -27,13 +27,13 @@ async def get( """Fetch an installation by catalog entry id.""" ... - async def list_all( + async def list_items( self, *, limit: int = 100, offset: int = 0, ) -> tuple[McpInstallation, ...]: - """List all recorded installations, optionally paginated. + """List recorded installations, optionally paginated. Implementations MUST return rows ordered by ``installed_at ASC, catalog_entry_id ASC`` so callers paging diff --git a/src/synthorg/persistence/postgres/mcp_installation_repo.py b/src/synthorg/persistence/postgres/mcp_installation_repo.py index 9c172ccf1f..ac26e15139 100644 --- a/src/synthorg/persistence/postgres/mcp_installation_repo.py +++ b/src/synthorg/persistence/postgres/mcp_installation_repo.py @@ -128,13 +128,13 @@ async def get( return None return _row_to_installation(row) - async def list_all( + async def list_items( self, *, limit: int = 100, offset: int = 0, ) -> tuple[McpInstallation, ...]: - """List all recorded installations in a deterministic order. + """List recorded installations in a deterministic order. Sorted by ``installed_at`` ascending with ``catalog_entry_id`` as a stable tiebreaker so rows with identical timestamps diff --git a/src/synthorg/persistence/sqlite/mcp_installation_repo.py b/src/synthorg/persistence/sqlite/mcp_installation_repo.py index 1ae29e160c..f36c181016 100644 --- a/src/synthorg/persistence/sqlite/mcp_installation_repo.py +++ b/src/synthorg/persistence/sqlite/mcp_installation_repo.py @@ -103,7 +103,7 @@ async def get( installed_at=coerce_row_timestamp(row[2]), ) - async def list_all( + async def list_items( self, *, limit: int = 100, diff --git a/tests/conformance/persistence/test_mcp_installations_repository.py b/tests/conformance/persistence/test_mcp_installations_repository.py index 314e76ac00..65c6a55c32 100644 --- a/tests/conformance/persistence/test_mcp_installations_repository.py +++ b/tests/conformance/persistence/test_mcp_installations_repository.py @@ -91,14 +91,14 @@ async def test_save_with_null_connection_name( assert fetched is not None assert fetched.connection_name is None - async def test_list_all(self, backend: PersistenceBackend) -> None: + async def test_list_items(self, backend: PersistenceBackend) -> None: await backend.mcp_installations.save(_installation("cat_a")) await backend.mcp_installations.save(_installation("cat_b")) - rows = await backend.mcp_installations.list_all() + rows = await backend.mcp_installations.list_items() ids = {r.catalog_entry_id for r in rows} assert {"cat_a", "cat_b"} <= ids - async def test_list_all_pagination(self, backend: PersistenceBackend) -> None: + async def test_list_items_pagination(self, backend: PersistenceBackend) -> None: # Insert with monotonically increasing installed_at so the # deterministic ORDER BY installed_at, catalog_entry_id places # the rows in a known order. @@ -111,9 +111,9 @@ async def test_list_all_pagination(self, backend: PersistenceBackend) -> None: ), ) - page_one = await backend.mcp_installations.list_all(limit=2, offset=0) - page_two = await backend.mcp_installations.list_all(limit=2, offset=2) - page_three = await backend.mcp_installations.list_all(limit=2, offset=4) + page_one = await backend.mcp_installations.list_items(limit=2, offset=0) + page_two = await backend.mcp_installations.list_items(limit=2, offset=2) + page_three = await backend.mcp_installations.list_items(limit=2, offset=4) assert len(page_one) == 2 assert len(page_two) == 2 diff --git a/tests/integration/integrations/test_controllers.py b/tests/integration/integrations/test_controllers.py index 8e40734e06..fc138dff4d 100644 --- a/tests/integration/integrations/test_controllers.py +++ b/tests/integration/integrations/test_controllers.py @@ -748,7 +748,7 @@ async def test_install_connectionless_entry(self) -> None: data=InstallEntryRequest(catalog_entry_id="filesystem-mcp"), ) assert second.data == response.data - assert len(await repo.list_all()) == 1 + assert len(await repo.list_items()) == 1 async def test_install_missing_entry_raises_404(self) -> None: from synthorg.api.controllers.mcp_catalog import ( diff --git a/tests/unit/integrations/test_mcp_catalog.py b/tests/unit/integrations/test_mcp_catalog.py index 8bda0b891f..1e531ffd65 100644 --- a/tests/unit/integrations/test_mcp_catalog.py +++ b/tests/unit/integrations/test_mcp_catalog.py @@ -155,7 +155,7 @@ async def test_install_idempotent(self) -> None: ) assert first.catalog_entry_id == second.catalog_entry_id # Only one row remains after the re-install. - all_rows = await repo.list_all() + all_rows = await repo.list_items() assert len(all_rows) == 1 async def test_install_missing_entry(self) -> None: From 1c4bb1fcc4757552e4c7d660ea754522fae27bc9 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Mon, 4 May 2026 04:24:47 +0200 Subject: [PATCH 35/35] fix: align in_memory MCP installations validation + add conformance gate Round-11 head review (outside-diff-range): - in_memory_installations.list_items: replace silent max(0, int(...)) coercion with the shared validate_pagination_args helper so invalid pagination inputs (limit=0, offset<0, non-int, bool) raise QueryError. Aligns the in-memory shim with the sqlite/postgres impls; previously a no-persistence test or headless dev app could mask a real bug that the durable backends catch. - conformance test: add parametrized test_list_items_rejects_invalid_pagination covering limit=0, offset=-1, limit=-1, limit=True (bool subtype), offset=False; runs against every backend on PersistenceBackend so the QueryError contract stays locked across sqlite + postgres + future backends. 26499 unit tests pass; 29 MCP-install tests pass. --- .../mcp_catalog/in_memory_installations.py | 19 ++++++++++--- .../test_mcp_installations_repository.py | 27 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/synthorg/integrations/mcp_catalog/in_memory_installations.py b/src/synthorg/integrations/mcp_catalog/in_memory_installations.py index 3e9dd4e3b7..b297660ca4 100644 --- a/src/synthorg/integrations/mcp_catalog/in_memory_installations.py +++ b/src/synthorg/integrations/mcp_catalog/in_memory_installations.py @@ -15,6 +15,10 @@ MCP_SERVER_INSTALLED, MCP_SERVER_UNINSTALLED, ) +from synthorg.observability.events.persistence import ( + PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, +) +from synthorg.persistence._shared.pagination import validate_pagination_args logger = get_logger(__name__) @@ -69,16 +73,25 @@ async def list_items( ``limit`` defaults to the protocol-wide pagination floor; callers needing more must loop with ``offset`` or pass a - larger ``limit`` explicitly. + larger ``limit`` explicitly. Invalid inputs (``limit < 1``, + ``offset < 0``, non-int, or ``bool``) raise ``QueryError`` to + match the sqlite/postgres contract -- silently coercing them + would let bugs that the durable backends catch slip through + in tests and no-persistence deployments. """ + validate_pagination_args( + limit, + offset, + event=PERSISTENCE_MCP_INSTALLATION_LIST_FAILED, + backend="in_memory", + ) rows = tuple( sorted( self._store.values(), key=lambda i: (i.installed_at, i.catalog_entry_id), ), ) - effective_offset = max(0, int(offset)) - return rows[effective_offset : effective_offset + max(0, int(limit))] + return rows[offset : offset + limit] async def delete(self, catalog_entry_id: NotBlankStr) -> bool: """Delete by catalog entry id.""" diff --git a/tests/conformance/persistence/test_mcp_installations_repository.py b/tests/conformance/persistence/test_mcp_installations_repository.py index 65c6a55c32..a75b76c7da 100644 --- a/tests/conformance/persistence/test_mcp_installations_repository.py +++ b/tests/conformance/persistence/test_mcp_installations_repository.py @@ -11,6 +11,7 @@ import pytest +from synthorg.core.persistence_errors import QueryError from synthorg.core.types import NotBlankStr from synthorg.integrations.mcp_catalog.installations import McpInstallation from synthorg.persistence.protocol import PersistenceBackend @@ -122,6 +123,32 @@ async def test_list_items_pagination(self, backend: PersistenceBackend) -> None: assert [r.catalog_entry_id for r in page_two] == ["cat_pag_2", "cat_pag_3"] assert [r.catalog_entry_id for r in page_three] == ["cat_pag_4"] + @pytest.mark.parametrize( + ("limit", "offset"), + [ + (0, 0), + (-1, 0), + (1, -1), + (True, 0), + (1, False), + ], + ) + async def test_list_items_rejects_invalid_pagination( + self, + backend: PersistenceBackend, + limit: object, + offset: object, + ) -> None: + # Lock the QueryError contract across every backend so a + # silently-coercing impl can't drift away from the durable + # ones (sqlite + postgres reject these via + # ``validate_pagination_args``; the in-memory shim must too). + with pytest.raises(QueryError): + await backend.mcp_installations.list_items( + limit=limit, # type: ignore[arg-type] + offset=offset, # type: ignore[arg-type] + ) + async def test_delete_returns_true_when_present( self, backend: PersistenceBackend ) -> None: