Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ jobs:
- name: Cost-recording chokepoint gate
run: uv run python scripts/check_provider_complete_chokepoint.py

- name: Typed-boundary contract gate (RFC #1711)
run: uv run python scripts/check_boundary_typed.py

# ── Docs: Doc-claim drift gate ──
# Runs on python OR doc_claims so a docs-only PR that bumps a guarded
# claim (eg "100+ event constant modules") still triggers the gate.
Expand Down
20 changes: 18 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ci:
# too slow / toolchain-heavy for the cloud runner.
# - no-em-dashes / no-redundant-timeout / forbidden-literals /
# persistence-boundary / no-new-logger-exception-str-exc /
# orphan-fixtures:
# orphan-fixtures / boundary-typed:
# scoped gates that need ``uv`` + repo source -- redundant given
# the `Lint` + `Forbidden literals gate` jobs in ci.yml.
# - check-push-rebased / check-single-migration-per-pr /
Expand All @@ -24,7 +24,7 @@ ci:
# scripts with no CI counterpart, and letting pre-commit.ci enforce
# them closes the gap where a PR from a contributor who skipped local
# hooks would otherwise introduce a regression unchecked.
skip: [commitizen, gitleaks, hadolint-docker, caddy-validate, no-em-dashes, no-redundant-timeout, mypy, pytest-unit, golangci-lint, go-vet, go-test, eslint-web, check-push-rebased, check-single-migration-per-pr, check-no-modify-migration, forbidden-literals, persistence-boundary, provider-complete-chokepoint, no-new-logger-exception-str-exc, orphan-fixtures, doc-drift-counts]
skip: [commitizen, gitleaks, hadolint-docker, caddy-validate, no-em-dashes, no-redundant-timeout, mypy, pytest-unit, golangci-lint, go-vet, go-test, eslint-web, check-push-rebased, check-single-migration-per-pr, check-no-modify-migration, forbidden-literals, persistence-boundary, provider-complete-chokepoint, no-new-logger-exception-str-exc, orphan-fixtures, doc-drift-counts, boundary-typed]

default_install_hook_types: [pre-commit, commit-msg, pre-push]

Expand Down Expand Up @@ -273,6 +273,22 @@ repos:
pass_filenames: false
stages: [pre-push]

- id: boundary-typed
name: typed-boundary contract gate (RFC #1711 Phase 3)
entry: uv run python scripts/check_boundary_typed.py
language: system
# The script's ``_REGISTERED_BOUNDARIES`` tuple is the single
# source of truth for which (file, function) pairs the gate
# enforces. Keep this trigger broad rather than duplicating
# the boundary list here, so a future boundary added only to
# the script does not silently stop running pre-push because
# this regex was forgotten. The check is sub-second when
# there are no violations, so the broader trigger costs
# nothing per push.
files: ^(src/synthorg/.*\.py|scripts/check_boundary_typed\.py|\.pre-commit-config\.yaml)$
pass_filenames: false
stages: [pre-push]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- id: provider-complete-chokepoint
name: cost-recording chokepoint gate
entry: uv run python scripts/check_provider_complete_chokepoint.py
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ See [docs/reference/configuration-precedence.md](docs/reference/configuration-pr
- **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.
- **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**: when migrating an entry-point from raw `dict[str, Any]` to typed Pydantic validation (MCP handler args, JWT decode, WebSocket control message, audit-chain payload, A2A JSON-RPC params, settings security export), call `parse_typed()` from `synthorg.api.boundary` rather than inventing per-call-site validation. The helper validates against the boundary's typed model, emits `API_BOUNDARY_VALIDATION_FAILED` on failure with the boundary name + redacted error description + 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 literal -- never user-controlled.
- **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.
- **Time injection (Clock seam)**: classes that read time or sleep cooperatively take `clock: Clock | None = None` defaulting to `SystemClock()` (`synthorg.core.clock`); tests inject `FakeClock`. See [docs/reference/conventions.md](docs/reference/conventions.md) §12 for the replacement table and the legacy-callable carve-outs.
- **Lifecycle synchronization**: async `start()` / `stop()` services own a dedicated `self._lifecycle_lock`; timed-out stops mark the service unrestartable. See [docs/reference/lifecycle-sync.md](docs/reference/lifecycle-sync.md).
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/mcp-handler-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ SynthOrg exposes 200+ tools across the 15 domain modules under `src/synthorg/met

## Argument validation (typed-args path, #1611 Phase 4)

Each builder accepts an optional `args_model: type[BaseModel]` kwarg that flows through to `MCPToolDef.args_model`. When set, the invoker validates the raw `arguments` dict against the Pydantic model **before** dispatching to the handler:
Each builder accepts an optional `args_model: type[BaseModel]` kwarg that flows through to `MCPToolDef.args_model`. When set, the invoker validates the raw `arguments` dict against the Pydantic model **before** dispatching to the handler. The validation call routes through the canonical typed-boundary helper (`synthorg.api.boundary.parse_typed("mcp.tool", arguments, args_model)`) so a malformed payload emits the cross-boundary `api.boundary.validation_failed` warning alongside the existing `mcp.server.invoke.failed` event -- see [typed-boundaries.md](typed-boundaries.md) for the full contract.

- Validation success: the invoker takes the validated model's `model_dump(mode="python")` and passes that dict to the handler. Every key matches the model's declared fields with no extras (because args models use `extra="forbid"`); enum/datetime/etc. values are already coerced.
- Validation failure: the invoker short-circuits with a `{"status": "error", "error_type": "ArgumentValidationError", "domain_code": "invalid_argument", "message": "...", "tool": ...}` envelope. The handler is never invoked.
Expand Down
182 changes: 182 additions & 0 deletions docs/reference/typed-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Typed Boundaries

The six security-sensitive API entry points listed below validate
inbound payloads through a single helper,
`synthorg.api.boundary.parse_typed`. The helper replaces the legacy
`dict[str, Any]` contract that let a typo or rename slip silently
through dict access at the auth, agent tool plane, audit trail, RPC,
and settings surfaces.

## The helper

```python
from synthorg.api.boundary import parse_typed

claims = parse_typed("jwt", raw_payload, JwtClaims)
user_id = claims.sub
```

Two overloads are accepted:

- `parse_typed[T: BaseModel](boundary, raw, model: type[T]) -> T` for
single-shape boundaries (JWT, settings, audit chain).
- `parse_typed[T](boundary, raw, model: TypeAdapter[T]) -> T` for
discriminated-union boundaries (A2A JSON-RPC params, WebSocket
control messages).

Behaviour:

- The `boundary` label is typed `LiteralString`; passing a
runtime-derived label fails the static type check, so the
operator-search log key cannot be operator-influenced.
- A `None` raw payload is normalised to `{}` so callers do not branch
on optional / nullable wire fields; Pydantic still raises loudly for
required fields.
- On validation failure the helper logs `api.boundary.validation_failed`
at warning with the boundary label, exception class, error count,
redacted error description (`safe_error_description`), the first
five field locations, and a `truncated` flag, then re-raises the
underlying `ValidationError`. Each boundary translates the re-raised
exception into its native error envelope or event (HTTP 422 for
settings-import; MCP envelope `err()` with
`domain_code=invalid_argument`; WebSocket
`{"error": "Invalid control message"}` envelope on the open socket
(no close-code escalation); A2A JSON-RPC `-32602 Invalid params`;
audit-chain `audit_chain.emit_validation_failed`).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Registered boundaries

| Boundary label | File | Function | Model |
| -------------------- | ------------------------------------------------- | ------------------------- | ------------------------------------------------------- |
| `jwt` | `src/synthorg/api/auth/service.py` | `decode_token` | `synthorg.api.auth.claims.JwtClaims` |
| `settings.security` | `src/synthorg/api/controllers/settings.py` | `import_security_config` | `synthorg.security.config.SecurityConfig` |
| `ws.control` | `src/synthorg/api/controllers/ws_protocol.py` | `handle_message` | `synthorg.api.ws_control_models.WsControlMessage` |
| `audit_chain` | `src/synthorg/observability/audit_chain/sink.py` | `emit` | `synthorg.observability.audit_chain.payloads.AuditChainEventPayload` |
| `a2a.jsonrpc` | `src/synthorg/a2a/rpc_params.py` | `parse_rpc_params` | `synthorg.a2a.rpc_params.A2ARpcParams` (TypeAdapter) |
| `mcp.tool` | `src/synthorg/meta/mcp/invoker.py` | `invoke` | Per-tool `MCPToolDef.args_model` |

## Per-boundary notes

### JWT (`jwt`)

`AuthService.create_token(user)` builds a `JwtClaims` instance
internally, then `model_dump(mode="json")` for the JWT library.
`AuthService.decode_token(token)` returns a `JwtClaims` instance so
the middleware accesses `claims.sub`, `claims.jti`, `claims.iss`,
`claims.aud`, `claims.pwd_sig` instead of `dict.get(...)`. User-only
fields (`username`, `role`, `must_change_password`, `pwd_sig`) are
optional so the same model serves both user tokens and CLI-minted
system tokens. `iat` and `exp` are `int` (epoch seconds); a `before`
validator coerces datetime values from the encode side.

A malformed token surfaces as a 401 through the middleware's existing
`_try_jwt_auth` failure path, with an additional
`SECURITY_AUTH_FAILED` log carrying `reason="jwt_claims_malformed"`
alongside the boundary helper's warning.

### Settings security config (`settings.security`)

The `import_security_config` controller routes the inbound
`data.config` dict through `parse_typed("settings.security", ...,
SecurityConfig)`. The export side already round-trips through
`SecurityConfig.model_dump` and never accepts external dict input.
`SecurityConfig` does not declare `extra="forbid"`; reject paths are
still the model's existing field validators (range checks, enum
coercion, cross-field constraints).

### WebSocket control messages (`ws.control`)

`WsControlMessage` is a `Discriminator("action")` union of four
variants:

- `WsAuthMessage{action: "auth", ticket: str}`: first-message ticket
handshake.
- `WsSubscribeMessage{action: "subscribe", channels: tuple[str, ...],
filters: dict[str, str] | None}` where `filters=None` leaves
existing filters, `{}` clears them, and `{...}` replaces them.
- `WsUnsubscribeMessage{action: "unsubscribe", channels: tuple[str,
...]}`.
- `WsPingMessage{action: "ping"}`.

The shape mirrors the typed contract in
`web/src/api/types/websocket.ts` (PR #1718); bump
`WS_PROTOCOL_VERSION` on both sides together for breaking payload
changes. Malformed control frames return the generic `Invalid control
message` envelope and the connection stays open; the legacy
per-error strings (`Unknown action`, `filters must be an object`)
are gone.

### Audit-chain payload (`audit_chain`)

`AuditChainEventPayload` mirrors the field set
`AuditChainSink.emit()` extracts from each `LogRecord`. The model is
called for validation only; the helper never replaces the dict that
goes into `json.dumps(payload, sort_keys=True, ensure_ascii=True,
default=str)`, so the chain hash is byte-stable across the migration.
Two pinning tests in `tests/unit/observability/test_audit_chain_boundary.py`
guard the byte layout against future drift:

- `test_golden_json_byte_stable` compares the `json.dumps` output
against a hard-coded byte string.
- `test_golden_hash_matches` pins the SHA-256 of the same bytes.

Regenerating either constant requires explicit reviewer sign-off
because a chain-hash change invalidates every previously-signed
audit entry.

### A2A JSON-RPC (`a2a.jsonrpc`)

`parse_rpc_params(rpc_request)` merges the envelope `method` into the
params dict (envelope wins on conflict, blocking peers that smuggle a
`method` key inside `params`) and routes through `parse_typed` against
the `A2ARpcParams` `TypeAdapter`. Variants:

- `A2AMessageSendParams` for `message/send`
- `A2ATaskGetParams` for `tasks/get`
- `A2ATaskCancelParams` for `tasks/cancel`

The wire shape is unchanged; the gateway still maps the re-raised
`ValidationError` to `JsonRpcErrorData(-32602, "Invalid params")`.

### MCP tool args (`mcp.tool`)

The MCP invoker validates `arguments` against each tool's declared
`args_model` through `parse_typed("mcp.tool", arguments, args_model)`
before dispatch. A malformed payload returns the
`ArgumentValidationError` / `domain_code=invalid_argument` envelope
on the wire. Tools without an `args_model` fall through to the
deepcopy path and continue to validate via `common_args` helpers in
the handler; this gate fires whenever a tool opts into typed args.

## Lint guard (Phase 3)

`scripts/check_boundary_typed.py` walks the six registered functions
above and confirms each one still calls `parse_typed`. A regression
that drops the call (refactor, rename, accidental removal) fails the
gate before push.

The guard is wired into `.pre-commit-config.yaml` at the pre-push
stage and into the CI `Lint` job. Per-line opt-out is `# lint-allow:
boundary-typed -- <reason>` on the function def line.

## Adding a new boundary

1. Define a frozen Pydantic model (or a `TypeAdapter` for a
discriminated union) under the relevant module.
2. Call `parse_typed("<dotted.label>", raw, Model)` at the entry
point. The boundary label MUST be a string literal; the
`LiteralString` type erases any runtime-derived value.
3. Translate the re-raised `ValidationError` into your boundary's
native error envelope (HTTP, MCP, WS close code, JSON-RPC error,
audit log). Do not swallow.
4. Add a `(file, function, label)` tuple to
`_REGISTERED_BOUNDARIES` in `scripts/check_boundary_typed.py` and
widen the `files:` pattern in the `boundary-typed` hook of
`.pre-commit-config.yaml` to include the new file.
5. Add a row to the table above; add a per-boundary subsection
below explaining wire shape, error envelope translation, and any
stability constraints.
6. Cover the boundary with a `tests/unit/<area>/test_*_boundary.py`
file asserting (a) accepted typed input, (b) rejected extra keys
or missing-required, (c) wire-shape round-trip where applicable,
and (d) `api.boundary.validation_failed` log emission.
Loading
Loading