Typed Boundaries¶
The security-sensitive API entry points listed below split into two
groups. The parse_typed-enforced boundaries (jwt,
settings.security, ws.control, audit_chain, a2a.jsonrpc,
mcp.tool: the original six) validate inbound payloads through a
single helper, synthorg.api.boundary.parse_typed. The
informational/lenient entries (provider.tool_call,
webhook.payload, mcp.tool.dual_path) are documented in the same
table for discoverability but are NOT gated by parse_typed; the
table's Model column marks them explicitly (no Pydantic,
extra="allow", dual-path helpers). 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¶
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]) -> Tfor single-shape boundaries (JWT, settings, audit chain).parse_typed[T](boundary, raw, model: TypeAdapter[T]) -> Tfor discriminated-union boundaries (A2A JSON-RPC params, WebSocket control messages).
Behaviour:
- The
boundarylabel is typedLiteralString; passing a runtime-derived label fails the static type check, so the operator-search log key cannot be operator-influenced. - A
Noneraw 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_failedat warning with the boundary label, exception class, error count, redacted error description (safe_error_description), the first five field locations, and atruncatedflag, then re-raises the underlyingValidationError. Each boundary translates the re-raised exception into its native error envelope or event (HTTP 422 for settings-import; MCP envelopeerr()withdomain_code=invalid_argument; WebSocket{"error": "Invalid control message"}envelope on the open socket (no close-code escalation); A2A JSON-RPC-32602 Invalid params; audit-chainaudit_chain.emit_validation_failed).
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 |
provider.tool_call |
src/synthorg/providers/drivers/mappers.py |
extract_tool_calls |
(no Pydantic; lenient dict/object extraction) |
webhook.payload |
src/synthorg/api/controllers/_webhooks_wiring.py |
parse_payload |
WebhookEventPayload (extra="allow") |
mcp.tool.dual_path |
src/synthorg/meta/mcp/invoker.py |
invoke |
Per-tool args_model OR common_args handler helpers |
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}wherefilters=Noneleaves 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_stablecompares thejson.dumpsoutput against a hard-coded byte string.test_golden_hash_matchespins 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:
A2AMessageSendParamsformessage/sendA2ATaskGetParamsfortasks/getA2ATaskCancelParamsfortasks/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.
Provider tool-call extraction (provider.tool_call)¶
LiteLLM provider drivers return tool-call payloads in heterogeneous
shapes: some completions parse to plain dicts, others surface objects
with attribute access (item.function.arguments). The provider layer
has no control over the upstream payload shape, so this boundary is
deliberately lenient: it does NOT run parse_typed. Instead,
extract_tool_calls (src/synthorg/providers/drivers/mappers.py:131)
walks the raw list and rescues whatever it can.
- Wire shape:
list[dict] | list[object](orNonefor completions that emitted no tool calls). - Field access:
_get(item, "id"/"function", ...)usesdict.getfor mappings andgetattrfor objects; the helper centralises the lenient access so each call site does not branch. - Failure modes:
- Missing
functionblock: skip the entry; emitprovider.tool_call.missing_functionwarning withitem_type=type(item).__name__. - Empty
idorname: skip the entry; emitprovider.tool_call.incompletewarning carrying whatever fields were recoverable. - Malformed
argumentsJSON: fall back to{}so the handler can apply its own validation (the alternative, rejecting the entire completion, would discard one good tool call because a sibling arrived malformed). - Why lenient: provider variability dominates. A strict
parse_typedhere would surface a hard 5xx on every novel upstream shape, blocking the whole completion path. The warning logs preserve observability without coupling the wire contract to any single provider.
Each skipped entry is logged so a provider regression surfaces in
the event stream rather than disappearing silently. The handler that
consumes the returned tuple[ToolCall, ...] re-validates field
shape via the typed Pydantic ToolCall model.
Webhook payload envelope (webhook.payload)¶
External webhook providers send arbitrary JSON keys (each integration
has its own schema). The boundary uses a Pydantic model with
ConfigDict(extra="allow") to enforce envelope shape only:
(src/synthorg/api/controllers/_webhooks_wiring.py:39).
- Wire shape: any JSON object. Arrays, scalars, and non-JSON bodies
are rejected at
parse_typed("webhook.payload", ...)time and surface as HTTP 400. - Provider keys flow through unchanged via
extra="allow". The controller routes the typed envelope to the integration-specific handler, which validates the inner payload against its own schema. - Why
extra="allow"and NOTextra="forbid": flipping the config would break every integration the moment a provider added a new optional field.frozen=Truestill prevents mutation; the only relaxation is on unknown-key rejection.
The envelope-only validation closes the silent {"raw": ...}
fallback the controller carried before typed boundaries existed: a
non-object payload now fails fast instead of routing as a
single-key dict.
MCP tool-execution dual paths (mcp.tool.dual_path)¶
The MCP invoker (src/synthorg/meta/mcp/invoker.py:149) routes tool
arguments through one of two validation paths depending on whether
the tool declares an args_model:
if tool_def.args_model is not None:
# Typed-args path.
validated = parse_typed("mcp.tool", arguments, tool_def.args_model)
handler_arguments = deepcopy(validated.model_dump(mode="python"))
else:
# Per-field path.
handler_arguments = deepcopy(arguments)
- Typed-args path (
args_modeldeclared): the raw dict goes throughparse_typed, which emitsapi.boundary.validation_failedon rejection and re-raises. The invoker catches thePydanticValidationError, recordsrecord_mcp_handler_outcome(outcome="validation_error", ...), and returns anArgumentValidationErrorenvelope withdomain_code=invalid_argumentto the client. The validatedmodel_dump(mode="python")is deep-copied so handlers receive a fresh mutable dict. - Per-field path (no
args_model): the raw dict is deep-copied unchanged and handed to the handler. The handler validates each field throughcommon_argshelpers (require_arg,require_non_blank,require_dict, etc. insrc/synthorg/meta/mcp/handlers/common_args.py), which raiseArgumentValidationErrordirectly on missing or malformed inputs.
Both paths converge on the same wire envelope
(domain_code=invalid_argument on validation failure) and the same
observability surface (MCP_SERVER_INVOKE_FAILED warning,
record_mcp_handler_outcome with the validation_error outcome).
The typed-args path provides typed validation at construction time;
the per-field path provides field-level validation at call time.
Validation surface area is equivalent; opting a tool into
args_model is a code-quality refactor, not a security upgrade.
Pre-mapping shape check: the typed-args path rejects non-mapping
payloads (isinstance(raw_arguments, dict)) before invoking
parse_typed. A JSON array would otherwise survive dict(...)
coercion and reach Pydantic, broadening the contract beyond what
MCP expects.
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¶
- Define a frozen Pydantic model (or a
TypeAdapterfor a discriminated union) under the relevant module. - Call
parse_typed("<dotted.label>", raw, Model)at the entry point. The boundary label MUST be a string literal; theLiteralStringtype erases any runtime-derived value. - Translate the re-raised
ValidationErrorinto your boundary's native error envelope (HTTP, MCP, WS close code, JSON-RPC error, audit log). Do not swallow. - Add a
(file, function, label)tuple to_REGISTERED_BOUNDARIESinscripts/check_boundary_typed.pyand widen thefiles:pattern in theboundary-typedhook of.pre-commit-config.yamlto include the new file. - Add a row to the table above; add a per-boundary subsection below explaining wire shape, error envelope translation, and any stability constraints.
- Cover the boundary with a
tests/unit/<area>/test_*_boundary.pyfile asserting (a) accepted typed input, (b) rejected extra keys or missing-required, (c) wire-shape round-trip where applicable, and (d)api.boundary.validation_failedlog emission.