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/cli/cmd/config.go b/cli/cmd/config.go index 8d8a1ce8d8..0132ab618e 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -58,8 +58,17 @@ 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. 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, + RunE: runConfigShow, } var configGetCmd = &cobra.Command{ @@ -94,6 +103,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 +149,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 +177,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", 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 { 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..42369aad9e 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,21 +427,15 @@ 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: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 @@ -624,10 +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: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: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 @@ -1372,8 +1365,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 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/a2a/well_known.py b/src/synthorg/a2a/well_known.py index 279e1c6ac0..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,8 @@ 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 ( A2A_AGENT_CARD_CACHE_HIT, @@ -31,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( @@ -38,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. @@ -46,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: @@ -72,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. @@ -80,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, ) @@ -115,7 +129,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 +157,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 +224,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/app.py b/src/synthorg/api/app.py index 69bb982672..9a721a3c39 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,40 @@ logger = get_logger(__name__) +# Default approval-timeout interval mirrors the registry default for +# ``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 + + +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 +845,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/src/synthorg/api/approval_store.py b/src/synthorg/api/approval_store.py index 1013bff865..687040535b 100644 --- a/src/synthorg/api/approval_store.py +++ b/src/synthorg/api/approval_store.py @@ -35,20 +35,21 @@ 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, 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, + API_APPROVAL_EXPIRE_BATCH_FAILED, API_APPROVAL_EXPIRE_CALLBACK_FAILED, API_APPROVAL_EXPIRED, API_APPROVAL_STORE_CLEARED, @@ -86,10 +87,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 @@ -241,38 +249,281 @@ 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: - 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 - # 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: + return await self._list_from_cache_locked( + status=status, + risk_level=risk_level, + action_type=action_type, + ) + + async def _list_from_repo( # noqa: C901, PLR0912, PLR0915 + self, + *, + status: ApprovalStatus | None, + risk_level: ApprovalRiskLevel | None, + action_type: NotBlankStr | None, + ) -> tuple[ApprovalItem, ...]: + """Repo-backed list path with batched expiry persistence. + + 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) -> ``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. + + 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``, ``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 + filter is pushed down so the DB only returns matching rows. + + Side effects after each per-page 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 + # 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 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 + 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, + action_type=action_type, + limit=page_size, + offset=offset, + ) + if not page: + break + page_result, to_persist, page_cache = self._compute_page( + page, + status=status, + risk_level=risk_level, + ) + actually_expired_ids: set[str] = set() + if to_persist: + # 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: + 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 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 + # 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 + # Lost-race rows: rows we tried to flip but the repo + # 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 checked.risk_level != risk_level: + if risk_level is not None and refetched.risk_level != risk_level: continue - if action_type is not None and checked.action_type != action_type: + if action_type is not None and refetched.action_type != action_type: continue - checked_items.append(checked) - return tuple(checked_items) + 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; 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. + async with self._lock: + if self._generation == captured_generation: + for item_id, cached in page_cache.items(): + if item_id in lost_race_ids: + # 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 + 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(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 + 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, 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. + + ``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 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] = [] + 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) + 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, page_cache + + 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). @@ -461,7 +712,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}, @@ -493,12 +744,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__, @@ -506,3 +760,49 @@ 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 self._clock.now() >= 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: + # 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__, + error=safe_error_description(exc), + ) 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/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 9d4ba598f9..d98c4999f8 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, @@ -167,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 @@ -331,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 @@ -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..6f42abf089 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 @@ -61,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", @@ -84,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( @@ -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/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/oauth.py b/src/synthorg/api/controllers/oauth.py index 2176d5b3d6..334530d0a9 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 ) @@ -72,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( @@ -111,7 +115,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/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 a5879b9702..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 @@ -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/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/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/api/lifecycle.py b/src/synthorg/api/lifecycle.py index 6d79cbb6b4..05aeb2952b 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 @@ -578,8 +578,22 @@ 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 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_type=type(exc).__name__, + error=safe_error_description(exc), + note="Approval timeout scheduler is unrestartable", + ) + 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/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/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/services/ssrf_violation_service.py b/src/synthorg/api/services/ssrf_violation_service.py index fdc0cba825..0719758c80 100644 --- a/src/synthorg/api/services/ssrf_violation_service.py +++ b/src/synthorg/api/services/ssrf_violation_service.py @@ -9,7 +9,15 @@ 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. 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 @@ -19,12 +27,16 @@ 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, + SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED, ) from synthorg.security.ssrf_violation import ( - SsrfViolation, # noqa: TC001 - SsrfViolationStatus, # noqa: TC001 + SsrfViolation, + SsrfViolationStatus, ) if TYPE_CHECKING: @@ -75,14 +87,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 +187,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 +212,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, @@ -209,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( - API_SSRF_VIOLATION_STATUS_UPDATED, + SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED, violation_id=violation_id, status=status.value, error_type=type(exc).__name__, @@ -222,7 +245,7 @@ async def update_status( raise except Exception as exc: logger.warning( - API_SSRF_VIOLATION_STATUS_UPDATED, + SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED, violation_id=violation_id, status=status.value, error_type=type(exc).__name__, @@ -231,7 +254,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/api/state.py b/src/synthorg/api/state.py index 0c03f6d2af..b575c72300 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,12 @@ 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( self, 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/backup/retention.py b/src/synthorg/backup/retention.py index e20bd71c08..714bab8801 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, UnicodeDecodeError) 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: @@ -188,18 +210,49 @@ 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 - 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, UnicodeDecodeError) 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 3a9126f1f9..14306fc7e0 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.""" @@ -417,10 +419,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, @@ -435,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, UnicodeDecodeError) 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/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) 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/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/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/circuit_breaker.py b/src/synthorg/communication/loop_prevention/circuit_breaker.py index 25299b23f8..2ca91a5b3a 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( @@ -172,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, @@ -215,26 +246,48 @@ 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 - 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, - ) + # 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 + 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) @@ -261,13 +314,31 @@ 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. 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 + # ``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. @@ -276,31 +347,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/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..c1db4ac378 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") @@ -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", @@ -230,7 +239,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 +278,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 +396,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/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/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..1891cf90af 100644 --- a/src/synthorg/engine/health/models.py +++ b/src/synthorg/engine/health/models.py @@ -5,11 +5,13 @@ """ import copy +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 @@ -56,7 +58,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()), @@ -95,13 +97,23 @@ 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"]) - super().__init__(**data) + @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. + """ + return MappingProxyType(copy.deepcopy(dict(value))) 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/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/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/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/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.py b/src/synthorg/engine/task_engine.py index a2228c0c44..f92fa52e78 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: @@ -855,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/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/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/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/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/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/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/integrations/mcp_catalog/in_memory_installations.py b/src/synthorg/integrations/mcp_catalog/in_memory_installations.py index 36fc233099..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__) @@ -55,28 +59,39 @@ 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. 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)) - if limit is None: - return rows[effective_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/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/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/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/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 2b0806c6dd..93319f89ec 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 @@ -26,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, @@ -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 @@ -68,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( @@ -102,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") @@ -179,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, @@ -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/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 9d4d0e89f6..f5e0861838 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__) @@ -68,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") @@ -131,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, @@ -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 @@ -226,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/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/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/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/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/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/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/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/observability/events/api.py b/src/synthorg/observability/events/api.py index 71037c9bf9..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" @@ -217,10 +218,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/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/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/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/observability/prometheus_collector.py b/src/synthorg/observability/prometheus_collector.py index 6f81e9c599..a3387af6da 100644 --- a/src/synthorg/observability/prometheus_collector.py +++ b/src/synthorg/observability/prometheus_collector.py @@ -114,6 +114,39 @@ 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: + # ``_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", + ) + return None + + class PrometheusCollector(RecordingMixin): """Collects business metrics from SynthOrg services for Prometheus. @@ -330,10 +363,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 +377,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 +404,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 +420,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/src/synthorg/persistence/approval_protocol.py b/src/synthorg/persistence/approval_protocol.py index 03236b9b3f..17bf664d36 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,41 @@ 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 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/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..e88e013504 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).""" @@ -57,15 +57,13 @@ 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( self, connection_type: ConnectionType, *, - limit: int | None = None, + limit: int = 100, offset: int = 0, ) -> tuple[Connection, ...]: """List by type (deep-copied).""" @@ -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/mcp_protocol.py b/src/synthorg/persistence/mcp_protocol.py index 1671d71556..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 | None = None, + 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/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/approval_repo.py b/src/synthorg/persistence/postgres/approval_repo.py index 5457dfeccd..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, @@ -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,104 @@ 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 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/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..b1705418ab 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] = [] @@ -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 0c479d032b..ac26e15139 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 @@ -21,7 +22,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 @@ -123,51 +128,56 @@ async def get( return None return _row_to_installation(row) - async def list_all( + async def list_items( self, *, - limit: int | None = None, + 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 (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" ) - 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,) 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() + # 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: + 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 - return tuple(_row_to_installation(row) for row in rows) + raise QueryError(msg) from exc 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/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..868d6858e9 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.""" @@ -481,13 +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)) - 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, effective_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 0fa9c50d18..0002b1ed2c 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.""" @@ -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 ( @@ -621,7 +617,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..57e267af73 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.""" @@ -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, @@ -156,7 +152,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.""" @@ -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 f9699cd7f0..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, @@ -510,19 +511,24 @@ 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.""" + """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``. + """ + 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,) - 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, limit, offset) try: async with ( self._pool.connection() as conn, diff --git a/src/synthorg/persistence/sqlite/approval_repo.py b/src/synthorg/persistence/sqlite/approval_repo.py index 84cb58f0ea..dcd5052bd5 100644 --- a/src/synthorg/persistence/sqlite/approval_repo.py +++ b/src/synthorg/persistence/sqlite/approval_repo.py @@ -3,16 +3,20 @@ 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 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, @@ -200,6 +204,125 @@ 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 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: + # 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, + 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/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..5cd2e04f6b 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] = [] @@ -132,9 +132,19 @@ 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) + # 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) 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 4cccbf84cd..f36c181016 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__) @@ -101,36 +103,62 @@ async def get( installed_at=coerce_row_timestamp(row[2]), ) - async def list_all( + async def list_items( self, *, - limit: int | None = None, + 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) 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, + 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 ?" ) - 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,) - async with self._db.execute(sql, params) as cursor: - rows = await cursor.fetchall() - 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]), + 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. + # ``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]), + connection_name=(NotBlankStr(row[1]) if row[1] else None), + installed_at=coerce_row_timestamp(row[2]), + ) + for row in rows ) - for row in rows - ) + except MemoryError, RecursionError: + raise + except Exception 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 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/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..6de69a89be 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.""" @@ -498,13 +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)) - 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, effective_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 72a12c7ddc..e2c43b1ef8 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. @@ -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: @@ -376,7 +369,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.""" @@ -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) @@ -616,7 +605,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/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) diff --git a/src/synthorg/persistence/sqlite/session_repo.py b/src/synthorg/persistence/sqlite/session_repo.py index d2a7657b02..906f3165c2 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.""" @@ -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) @@ -168,7 +164,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.""" @@ -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 46a4bcf763..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, @@ -675,17 +676,18 @@ 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. + """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. @@ -693,15 +695,16 @@ async def list_by_user( Raises: QueryError: If the database query or deserialization fails. """ + 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,) - 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, limit, offset) try: cursor = await self._db.execute(sql, params) rows = await cursor.fetchall() 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. 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/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/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 5afa0e1edd..ee5974924a 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 ( @@ -44,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( @@ -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/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 68a1dc12f1..f72139ed8a 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 ( @@ -37,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) @@ -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/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/timeout/scheduler.py b/src/synthorg/security/timeout/scheduler.py index 1afd71aeaf..b3de982b2f 100644 --- a/src/synthorg/security/timeout/scheduler.py +++ b/src/synthorg/security/timeout/scheduler.py @@ -79,41 +79,104 @@ 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: - await self._background_tasks.drain() - return - self._task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._task - self._task = None + 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 await self._background_tasks.drain() - 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/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/trust/service.py b/src/synthorg/security/trust/service.py index 2463c85300..b5aede8673 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, @@ -168,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. @@ -188,36 +190,52 @@ 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 - updated = state.model_copy(update=state_update) - self._trust_states[key] = updated - self._change_history.setdefault(key, []).append(record) + # 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) logger.info( TRUST_LEVEL_CHANGED, diff --git a/src/synthorg/security/uncertainty.py b/src/synthorg/security/uncertainty.py index a517bd6630..674baf9156 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, @@ -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) @@ -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/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/src/synthorg/templates/model_matcher.py b/src/synthorg/templates/model_matcher.py index 8fcc2c59e3..2a5f7bcae3 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_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 +_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 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/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/conformance/persistence/test_approval_repository.py b/tests/conformance/persistence/test_approval_repository.py index b0b4389acd..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, @@ -266,3 +267,130 @@ 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(()) + # 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, + 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. 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}) + 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, + 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 + + 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 == () 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..9552354dde --- /dev/null +++ b/tests/conformance/persistence/test_json_constraints_sqlite.py @@ -0,0 +1,129 @@ +"""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() + + @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: + """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 " # noqa: S608 + f"(preset_name, base_url, {column_name}, " + "updated_at, updated_by) " + "VALUES (?, ?, ?, ?, ?)", + ( + f"example-preset-{column_name}", + "https://example.invalid", + "not-json", + "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/conformance/persistence/test_mcp_installations_repository.py b/tests/conformance/persistence/test_mcp_installations_repository.py index 314e76ac00..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 @@ -91,14 +92,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 +112,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 @@ -122,6 +123,32 @@ async def test_list_all_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: 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..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 ( @@ -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/controllers/test_sse_revalidate.py b/tests/unit/api/controllers/test_sse_revalidate.py index b1dafa3cb1..94996346a8 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: @@ -155,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": @@ -162,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/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), } 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" diff --git a/tests/unit/api/services/test_ssrf_violation_service.py b/tests/unit/api/services/test_ssrf_violation_service.py index d0816c0405..462b95ff3a 100644 --- a/tests/unit/api/services/test_ssrf_violation_service.py +++ b/tests/unit/api/services/test_ssrf_violation_service.py @@ -22,8 +22,12 @@ 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, + SECURITY_SSRF_VIOLATION_RESOLUTION_FAILED, ) from synthorg.security.ssrf_violation import SsrfViolation, SsrfViolationStatus @@ -105,7 +109,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 +125,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 +154,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 +313,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,17 +340,19 @@ 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 == [] 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) @@ -365,13 +371,22 @@ 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] - 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 @@ -402,5 +417,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 == [] diff --git a/tests/unit/api/test_app.py b/tests/unit/api/test_app.py index 61e33e6d38..971a2d8a9e 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,9 +477,9 @@ async def test_meeting_scheduler_lifecycle( persistence = FakePersistenceBackend() bus = FakeMessageBus() - mock_sched = MagicMock() - mock_sched.start = AsyncMock() - mock_sched.stop = AsyncMock() + mock_sched = MagicMock(spec=MeetingScheduler) + mock_sched.start = AsyncMock(spec=MeetingScheduler.start) + mock_sched.stop = AsyncMock(spec=MeetingScheduler.stop) app_state = AppState( config=root_config, @@ -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,9 +523,9 @@ async def test_approval_timeout_scheduler_lifecycle( persistence = FakePersistenceBackend() bus = FakeMessageBus() - mock_sched = MagicMock() - mock_sched.start = MagicMock() # start() is sync - mock_sched.stop = AsyncMock() + mock_sched = MagicMock(spec=ApprovalTimeoutScheduler) + mock_sched.start = AsyncMock(spec=ApprovalTimeoutScheduler.start) + mock_sched.stop = AsyncMock(spec=ApprovalTimeoutScheduler.stop) app_state = AppState( config=root_config, @@ -538,7 +544,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() diff --git a/tests/unit/communication/loop_prevention/test_circuit_breaker.py b/tests/unit/communication/loop_prevention/test_circuit_breaker.py index 0260a6d507..1b92329faf 100644 --- a/tests/unit/communication/loop_prevention/test_circuit_breaker.py +++ b/tests/unit/communication/loop_prevention/test_circuit_breaker.py @@ -1,5 +1,7 @@ """Tests for delegation circuit breaker.""" +import threading +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -289,8 +291,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") @@ -303,6 +309,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) @@ -313,7 +320,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) @@ -323,4 +330,203 @@ 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, + CircuitBreakerStateRepository, + ) + + 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(spec=CircuitBreakerStateRepository) + 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 +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. 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 + # 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: + 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] = [] + + 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() + # 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: + 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 + 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 " + "the regression." + ) + assert result.passed is False + assert "cooldown" in (result.message or "") 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 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/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: 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) 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/observability/conftest.py b/tests/unit/observability/conftest.py index cf74919aa9..0a9ea29180 100644 --- a/tests/unit/observability/conftest.py +++ b/tests/unit/observability/conftest.py @@ -82,6 +82,14 @@ def _reset_logging() -> Iterator[None]: clear_logging_state() +# 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 def handler_cleanup() -> Iterator[list[logging.Handler]]: """Collect handlers and close them after the test.""" diff --git a/tests/unit/observability/test_prometheus_collector_new_metrics.py b/tests/unit/observability/test_prometheus_collector_new_metrics.py index b9829661fa..f80504369b 100644 --- a/tests/unit/observability/test_prometheus_collector_new_metrics.py +++ b/tests/unit/observability/test_prometheus_collector_new_metrics.py @@ -12,11 +12,37 @@ 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, + status_class, + update_label_snapshot, +) pytestmark = pytest.mark.unit +@pytest.fixture(autouse=True) +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( + tool_names=frozenset({"web_search", "calculator", "t"}), + tool_names_seeded=True, + ), + ) + + def _parse( collector: PrometheusCollector, ) -> dict[str, list[tuple[dict[str, str], float]]]: 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 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 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..0d671b403e --- /dev/null +++ b/tests/unit/security/timeout/test_scheduler_lifecycle_locks.py @@ -0,0 +1,140 @@ +"""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, patch + +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( + 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, + 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( + spec=TimeoutChecker.check_and_resolve, + 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: + # 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 + 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) 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", ), ( 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.' 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, })