Implicit Conventions¶
CLAUDE.md codifies the rules we enforce with hooks and review agents.
This page captures the implicit conventions: patterns followed
across the codebase by precedent rather than enforcement. New code
should follow them; deviations should be justified in the diff.
1. Repository CRUD signature pattern¶
Every repository protocol exposes the same five-method shape, returning
bool from delete so callers can distinguish "removed one row" from
"id did not exist" without raising.
class ApprovalRepository(Protocol):
async def save(self, item: ApprovalItem) -> None: ...
async def get(self, approval_id: NotBlankStr) -> ApprovalItem | None: ...
async def list_items(
self,
*,
status: ApprovalStatus | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[ApprovalItem, ...]: ...
async def delete(self, approval_id: NotBlankStr) -> bool: ...
Reference: src/synthorg/persistence/approval_protocol.py:34-73.
2. Service lifecycle method symmetry¶
Long-lived services own a private asyncio.Lock named
_lifecycle_lock (separate from any hot-path lock) and expose
symmetric async start() / async stop() methods. Both must be
held across the full body of their respective method per
lifecycle-sync.md. Examples:
src/synthorg/workers/worker.py,
src/synthorg/integrations/health/prober.py.
3. API response wrapping¶
Default: return ApiResponse[T] (or PaginatedResponse[T] for list
endpoints). Wrap in Response[ApiResponse[T]] only when the
controller must set a custom status code or response header (Location
on 201, Retry-After on 429, WWW-Authenticate on 401). Bare
ApiResponse[T] is preferred everywhere else because Litestar's
exception handler already maps domain errors to the right status codes;
wrapping in Response[...] for a successful call adds noise without
changing the wire envelope.
Controllers return one of three shapes:
ApiResponse[T]: success-only path with no header or status-code customisation. Litestar serialises it as{"data": ..., "error": null, "success": true}.PaginatedResponse[T]: list endpoints that return a page of items plus pagination metadata. WrapsApiResponse[T]and adds apaginationenvelope ({limit,next_cursor,has_more}). Required for any controller method whose return type is a collection. Opaque HMAC-signed cursor pagination is the project default; clients walk pages via thenext_cursortoken rather than offset arithmetic. There is nototalcount on the wire.Response[ApiResponse[T]]: only when status code or response headers must be customised (e.g. setting aLocationheader on a 201, attaching aRetry-Afterheader on a 429).
Reference: src/synthorg/api/dto.py (ApiResponse,
PaginatedResponse, PaginationMeta); almost every controller under
src/synthorg/api/controllers/.
4. @model_validator(mode="after") is the default¶
mode="after" runs against the constructed model and is the default
choice. mode="before" is reserved for normalising inputs the caller
might pass in non-canonical shape (lists vs tuples, dirty strings,
missing aliases). When using mode="before", never mutate the
input dict in place; return a new dict via {**data, key: value}.
All mode="before" validators in the codebase return new dicts; the
immutability tests under tests/unit/{api,tools}/... lock in the
pattern.
Validator declaration order (mode="before")¶
Pydantic v2 runs multiple mode="before" validators on a class in
reverse declaration order: the validator declared LAST in source
runs FIRST. When a class pairs a settings-mirror validator
(_apply_mirrors, populating a field from the env via
apply_settings_mirrors) with a shape validator that must inspect the
populated value, declare the shape validator BEFORE _apply_mirrors
in source so it runs AFTER the mirror has populated the field.
Reference: PerOpRateLimitConfig._validate_override_tuples and
PerOpConcurrencyConfig._validate_override_values in
src/synthorg/config/rate_limits.py are declared before
_apply_mirrors precisely so the env-populated overrides dict is
shape-checked. Getting the order wrong means the shape validator runs
against an empty default and never sees the env override.
Validator method naming¶
@model_validator and @field_validator methods are named
_validate_<constraint> (leading underscore, verb validate, snake-case
description of the invariant), e.g. _validate_temporal_order,
_validate_weights_sum. The one sanctioned exception is _apply_mirrors,
reserved for the settings-mirror mode="before" validators described above.
This is a review-enforced convention rather than a gated one: there is no
check_* script asserting the pattern, so new validators are kept consistent
in code review (like the route-access-guard placement convention below).
5. Event constant module imports¶
Every observability event is defined as a Final[str] constant under
src/synthorg/observability/events/<domain>.py (or, for the persistence
domain, the per-entity sub-module events/persistence/<entity>.py) and
imported by name (never by string literal) from the consumer.
from synthorg.observability.events.workers import (
WORKERS_DISPATCHER_CLAIM_ENQUEUED,
WORKERS_DISPATCHER_PUBLISH_EXHAUSTED,
WORKERS_DISPATCHER_PUBLISH_FAILED,
WORKERS_DISPATCHER_PUBLISH_RETRYING,
WORKERS_DISPATCHER_QUEUE_NOT_RUNNING,
)
# Persistence events import from their entity sub-module, not a re-export bag:
from synthorg.observability.events.persistence.task import PERSISTENCE_TASK_SAVED
Reference: src/synthorg/workers/dispatcher.py:19-25.
6. Domain error hierarchies¶
Each domain owns errors.py with a base error class carrying
status_code / error_code / error_category / retryable as
ClassVars; subclasses inherit and override only the fields that
change. The HTTP exception handler keys off the base class so a new
subclass automatically inherits the correct status mapping.
Enforced at pre-push by scripts/check_domain_error_hierarchy.py,
which AST-walks every class .* definition under src/synthorg/ and
fails the build if a class inherits directly from Exception /
RuntimeError / LookupError / PermissionError / ValueError /
TypeError / KeyError / IndexError / AttributeError / OSError
/ IOError without reaching DomainError via another base. Per-line
opt-out: # lint-allow: domain-error-hierarchy -- <reason>. See
errors.md for the full gate
contract.
References:
src/synthorg/budget/errors.py:BudgetExhaustedErrorfamily.src/synthorg/communication/errors.py:CommunicationErrorfamily.src/synthorg/engine/errors.py:EngineErrorfamily.
Two-step raise form¶
Every raise in src/synthorg/ assigns the message to a local variable first,
then raises: msg = f"..."; raise SomeError(msg). Inline string or f-string
literals in an exception constructor (raise SomeError(f"...")) are blocked by
ruff EM101 / EM102 (both in the extend-select block in pyproject.toml),
so a new contributor who inlines the message gets a ruff failure. Use the
two-step form throughout.
7. Module file structure¶
Every business-logic module follows the same top-down ordering:
- Module docstring.
- Imports: stdlib, then third-party, then internal, alphabetical within each group.
logger = get_logger(__name__)immediately after imports.- Module-level
Finalconstants (private prefixed with_). - Public types (Pydantic models, dataclasses, enums).
- Public functions / classes.
- Private helpers (prefixed with
_).
Reference: src/synthorg/communication/bus/memory.py.
For tool / handler argument models (Pydantic), the convention is to
co-locate them in a single domain-scoped _args.py module rather than
inline with the consumer. Examples:
src/synthorg/tools/<domain>/_args.pyforBaseToolsubclasses (file_system/_args.py,web/_args.py,database/_args.py,communication/_args.py,analytics/_args.py,design/_args.py); smaller domains share an aggregator module (tools/_git_args.py,tools/_misc_args.py).src/synthorg/meta/mcp/domains/_*_args.pyfor MCP tool registrations (_common_args.py,_tasks_args.py,_agents_args.py,_simple_args.py,_workflows_org_args.py, and the_remaining_args/package, whose__init__.pyre-exports the_communication,_integrations,_infrastructure, and_memory_finetunesub-modules).src/synthorg/memory/self_editing_args.pyfor the six self-editing-memory tools.src/synthorg/api/ws_payloads/for the WebSocket payload discriminated union (split across_lifecycle.pyand_domain.pywith the union exported from__init__.py).src/synthorg/a2a/rpc_params.pyfor the A2A JSON-RPC param discriminated union.
8. Frozen ConfigDict pattern¶
Every Pydantic model declares
model_config = ConfigDict(frozen=True, allow_inf_nan=False) with
extra="forbid". This is enforced project-wide (not API-DTO-only)
by scripts/check_frozen_model_extra_forbid.py: every class under
src/synthorg/ and tests/ whose own model_config is a
ConfigDict (or dict literal) with frozen=True MUST also set
extra="forbid". The rule applies equally to test fixtures: a
fixture model that silently absorbs unknown construction keys masks
the same class of caller typos the gate catches in production code.
The same gate also enforces allow_inf_nan=False on every frozen
model, but scoped to src/synthorg/ only (test fixtures are exempt
from the inf/nan assertion). The @computed_field carve-out below
applies to the extra="forbid" check only; a @computed_field-bearing
model still must carry allow_inf_nan=False. Genuine inf/nan-accepting
boundaries opt out per-line with
# lint-allow: frozen-allow-inf-nan -- <reason>.
Two carve-outs (for the extra="forbid" check):
@computed_field(automatic). Classes declaring a@computed_fieldare exempt without annotation: Pydantic v2 includes the computed value inmodel_dump()output and a strict-extra reconstruction would reject that key on the round trip. The gate detects the decorator via AST so the ~68 such classes carry no per-line noise.- Per-line opt-out. Genuine exceptions (an
extra="allow"envelope that must accept arbitrary provider keys, a validator-gated boundary usingextra="ignore"for forward-compat) declare# lint-allow: frozen-extra-forbid -- <reason>on the class definition line. Bare opt-outs without a reason are violations.
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.
Canonical example: src/synthorg/approval/models.py:28. Gate:
scripts/check_frozen_model_extra_forbid.py (pre-push +
.pre-commit-config.yaml frozen-extra-forbid).
9. Typed args models at system boundaries¶
Every system boundary that accepted a raw dict[str, Any] now
validates against a frozen Pydantic args model:
- A2A JSON-RPC (
src/synthorg/a2a/rpc_params.py): one model per RPC method, joined intoA2ARpcParamsdiscriminated union via themethodliteral. The gateway callsparse_rpc_params(rpc_request)before dispatching. - WebSocket events (
src/synthorg/api/ws_payloads/): one model perWsEventTypevalue, joined intoWsEventPayloaddiscriminated union viaevent_type.WsEventruns every constructed payload through the union adapter so shape drift is rejected at construction. - Self-editing memory (
src/synthorg/memory/self_editing_args.py): six args models discriminated by thetoolliteral.parse_self_editing_args(tool_name, arguments)validates before dispatch; the dispatcher matches on the typed variant. - Tool ecosystem (
src/synthorg/tools/): every concreteBaseToolsubclass declaresargs_model: ClassVar[type[BaseModel] | None] = <Args>. TheToolInvokercallsargs_model.model_validate(arguments)before invokingexecute; failures surface as a typedToolParameterErrorenvelope. - MCP handlers (
src/synthorg/meta/mcp/): everyread_tool/write_tool/admin_toolregistration may passargs_model=<Args>through toMCPToolDef.args_model; when set, the invoker validates ahead of dispatch and failures surface as the standardArgumentValidationErrorenvelope without ever calling the handler.args_modelis optional (typedtype[BaseModel] | NoneonMCPToolDef); registrations withargs_model=None-- e.g.MCPBridgeToolwhose shape mirrors a remote MCP server'stools/listresponse and is not known until runtime -- keep the legacycommon_argsvalidators inside the handler body. The bulk of in-tree tools declare a concreteargs_model; theNoneexit is reserved for genuinely dynamically-shaped tools.
All args models share the convention from §8 (frozen, no NaN/Inf,
extra=forbid) and reuse the _ArgsBase / PaginationFields /
AdminGuardrailFields mixins under
src/synthorg/meta/mcp/domains/_common_args.py where applicable.
10. Pydantic v2 model conventions¶
Three rules apply on top of §8's frozen ConfigDict:
UUIDfor entity primary keys,NotBlankStrfor names and string references. An entity's primary-key.idfield is typedUUID = Field(default_factory=uuid4). Every name, string foreign-key reference, or other required-non-empty string field usesNotBlankStr(import fromsynthorg.core.types), including theNotBlankStr | Noneoptional andtuple[NotBlankStr, ...]tuple variants.NotBlankStrreplaces the manual whitespace validators that several models used to carry. Repository lookup-key types stayNotBlankStr(callers passstr(entity.id)); the persistence layer (de)serialises theUUIDagainst aTEXTid column viastr(uuid)/UUID(...).@computed_fieldfor derived values. Never store + validate a redundant field; let it derive. Canonical example:TokenUsage.total_tokensis a computed field overprompt_tokensandcompletion_tokens.- Declared field +
mode="before"validator for derived values that must survive a settings round-trip. A@computed_fieldis rejected byextra="forbid"when a model that is persisted via the settings layer is reconstructed (the computed key appears inmodel_dump()output and is then an unexpected key onmodel_validate). For those models, declare the field normally and force its value from a@model_validator(mode="before")that re-derives it on every validation (so a stored value round-trips and a stale one is repaired). Canonical example:AgentConfig.idis derived fromnameviastable_agent_id(name)in_derive_stable_id; thedefault_factory=uuid4is a vestigial placeholder that keeps the field non-required for mypy and is overwritten on every name-bearing construction. allow_inf_nan=Falseeverywhere. Already part of the standardConfigDictfrom §8. The point is that numeric fields rejectNaNandInfat validation time rather than producing silent garbage downstream.
Reference: 30+ occurrences across src/synthorg/. The
tests/unit/api/test_response_models.py and
tests/unit/persistence/test_token_usage.py suites pin the
computed_field and NotBlankStr patterns.
11. Async concurrency: asyncio.TaskGroup and structured concurrency¶
New code uses asyncio.TaskGroup for fan-out / fan-in parallel work
(multiple tool invocations, parallel agent calls). Bare
asyncio.create_task is reserved for genuinely fire-and-forget paths
that escape the current scope (rare; prefer structured concurrency).
When running multiple tasks inside a TaskGroup where one task's
failure should NOT cancel the others -- independent workers,
classification detectors, notification sinks -- wrap each task body
in a small async def helper that catches Exception and returns a
safe default. Only MemoryError / RecursionError propagate (those
indicate the interpreter itself is in trouble and the group should
unwind). Propagation is delegated to
synthorg.core.critical_errors.reraise_critical so the broad handler
stays a single except clause and ruff DOC501 does not demand that
every helper docstring document MemoryError / RecursionError:
from synthorg.core.critical_errors import reraise_critical
async def _safe_dispatch(sink: NotificationSink, payload: Payload) -> None:
try:
await sink.dispatch(payload)
except Exception as exc:
reraise_critical(exc)
logger.warning(SINK_DISPATCH_FAILED, sink=sink.name, exc_info=False)
async with asyncio.TaskGroup() as tg:
for sink in self._sinks:
tg.create_task(_safe_dispatch(sink, payload))
The legacy two-clause form (except (MemoryError, RecursionError): raise
followed by except Exception:) is equivalent and remains acceptable
in sites where the critical-error branch needs additional cleanup
before propagation; see persistence/postgres/backend_connection.py
for an example. asyncio.CancelledError is not routed through
reraise_critical: it is a BaseException, not an Exception, so a
broad except Exception: never catches it.
Do not preemptively rewrite gather(..., return_exceptions=True) sites in
unrelated modules. Convert each site to the structured-error pattern when
touching its surrounding code.
Interpreter-critical exception propagation¶
The same rule applies to any broad except Exception: handler, async or
not. MemoryError and RecursionError are subclasses of Exception, so
a broad handler silently swallows them unless it propagates them first.
Call reraise_critical(exc) as the handler's first statement, then log
and re-raise a DomainError:
from synthorg.core.critical_errors import reraise_critical
try:
...
except Exception as exc:
reraise_critical(exc)
logger.warning(EVENT, error_type=type(exc).__name__, ...)
raise QueryError(msg) from exc
asyncio.CancelledError is not routed through this helper because it
is a BaseException, not an Exception; a broad except Exception:
block never catches it.
12. Time injection: the Clock seam¶
Any class that reads wall-clock time, monotonic time, or sleeps
cooperatively MUST take an optional clock: Clock | None = None
constructor parameter that defaults to SystemClock() (both from
synthorg.core.clock):
| Replace ... | with ... |
|---|---|
datetime.now(UTC) |
self._clock.now() |
time.monotonic() |
self._clock.monotonic() |
await asyncio.sleep(...) |
await self._clock.sleep(...) |
time.time() (epoch float) |
self._clock.now().timestamp() |
The wall-clock-epoch case matters for sites that compare against an
attacker-supplied timestamp header (webhook freshness checks):
self._clock.now().timestamp() produces the same epoch float
without bypassing the seam.
Tests inject FakeClock from tests/_shared/fake_clock.py and drive
virtual time deterministically via clock.advance(seconds),
await clock.advance_async(seconds), or await clock.sleep(seconds)
(which advances and yields once so awaiters wake up the same way they
would under SystemClock).
Grandfathered callable shape¶
loop_prevention/{circuit_breaker,dedup,rate_limit}.py and
communication/meeting/scheduler.py deliberately use the
clock: Callable[[], float] = time.monotonic shape rather than the Clock
Protocol: the churn of converting ~30 test sites passing callables
outweighs the testability win, so this is a permanent carve-out. New code uses
the Clock Protocol; do not add new modules to this list without justification.
12.1. Test-double ladder¶
The test-double ladder (Protocol fake, mock_of[T] / create_autospec, SimpleNamespace, and why a bare MagicMock is forbidden at a typed boundary) has its own focused reference: Test-Double Ladder. It covers the four rungs, a need-to-tool table, and the scripts/check_mock_spec.py gate.
13. Observability event-name inventory¶
Every observability event is a Final[str] constant in a
domain-scoped module under src/synthorg/observability/events/.
Import by name from the domain module; never use a string literal in
a logger.*(...) call.
Domains currently exposing constants (the
src/synthorg/observability/events/ package tree is the authoritative
inventory; the names below are an illustrative subset, not the full
set of top-level and nested domain modules):
api, tool, workflow_execution, approval_gate, hr,
workers, meeting, engine, escalation, settings,
memory, persistence, mcp, metrics, tracing, telemetry,
classification, verification, rollout, chief_of_staff,
analytics, integrations, a2a, budget, quota, coordination,
security, red_team, audit_chain, provider, eval_loop,
prompt_purpose, model_pins.
The security domain is special: every constant whose value starts
with security. (or tool.registry.integrity.) is signed and
appended to the audit chain by AuditChainSink. See
docs/design/observability.md
for the opt-in rule and the sink's record-shape extraction logic.
events/telemetry.py namespace split¶
events/telemetry.py carries two name-spaced groups:
TELEMETRY_*constants are observability log events emitted vialogger.*(...).TELEMETRY_EVENT_*constants are payload event types that go insideTelemetryEvent.event_typeand ride through the privacy scrubber.
Pick the right namespace when adding constants. Mixing them is the typical cause of "the scrubber rejected my new field" surprises.
*_STATUS_TRANSITIONED constants¶
Every status enum hop (including non-terminal ones like
PENDING -> RUNNING) MUST log at INFO using a domain-scoped
*_STATUS_TRANSITIONED constant carrying from_status,
to_status, and the domain identifier. Examples:
WORKFLOW_EXEC_STATUS_TRANSITIONED,
APPROVAL_STATUS_TRANSITIONED,
PRUNING_REQUEST_STATUS_TRANSITIONED.
Subsystems that already have terminal-state events
(MEETING_COMPLETED, WORKFLOW_EXEC_FAILED, ...) keep those for
final-hop summaries. The transition log fires AFTER the persistence
write succeeds, so the audit trail captures only transitions that
actually landed; if pre-decision visibility is needed, emit a
separate DEBUG "attempting transition" log alongside.
14. Repository CRUD method names¶
Persistence repositories share a CRUD vocabulary that's uniform
across 100+ implementations. This section expands on §1 with the
extra semantic detail (return-value contracts, immutability of
collection returns, where NotFoundError belongs).
| Method | Signature | Semantics |
|---|---|---|
save |
async def save(entity) -> None |
Insert or update; idempotent. One persist verb (no separate create / update). |
get |
async def get(id) -> Entity \| None |
Single-entity fetch. Returns None on miss, never raises. |
delete |
async def delete(id) -> bool |
Removal. True if a row was removed, False if the id did not exist; same return type used in §1. |
list_items |
async def list_items(...) -> tuple[Entity, ...] |
Full scan / paginated list. Some older repositories use list_all(); new repositories prefer list_items(*, limit, offset, **filters) so callers can paginate without defensive slicing. |
query |
async def query(...) -> tuple[Entity, ...] |
Filtered query when the filter set diverges from a single canonical list_items. |
get_by_<key> |
async def get_by_<key>(value) -> Entity \| None |
Single-entity fetch keyed on a non-primary unique column (e.g. get_by_username, get_by_name, get_by_content_hash). Same Entity \| None-on-miss contract as get. The get_by_<key> form is the canonical alternate-key lookup name; find_by_<key> is not used (the lone surviving find_by_task_id is a pre-convention deviation, not a sanctioned variant). |
Query methods always return tuple[T, ...], never list[T]. This
matches the immutability default for collection returns and lets
callers safely share results without defensive copies.
A handful of older repositories (notably OntologyEntityRepository
and ProjectRepository) currently raise OntologyNotFoundError /
RecordNotFoundError directly from get() instead of returning
None; this predates the canonical pattern and is tracked as a
follow-up migration. New repositories follow the
Entity | None shape so the service layer owns the
NotFoundError raise (with the logger.warning(...) + raise
audit trail).
15. MCP handler logging centralisation¶
Every MCP handler error path uses one of three centralised helpers
from src/synthorg/meta/mcp/handlers/common_logging.py:
log_handler_argument_invalid(tool, exc)forArgumentValidationErrorlog_handler_invoke_failed(tool, exc, **context)for any other service-layer exceptionlog_handler_guardrail_violated(tool, exc)forGuardrailViolationError
Success paths emit logger.info(MCP_HANDLER_INVOKE_SUCCESS,
tool_name=...). Do NOT emit custom logger.error() /
logger.warning() calls from handlers -- these three helpers are
the single source of truth so an event-name change touches one
file, not 245 handler methods.
16. Repository file structure¶
- Repository protocols live in
src/synthorg/persistence/<domain>_protocol.pyas@runtime_checkable Protocolclasses. - Concrete implementations live in
src/synthorg/persistence/sqlite/<domain>_repo.pyandsrc/synthorg/persistence/postgres/<domain>_repo.py. - Both backends MUST conform to the same protocol; dual-backend
conformance is enforced via parametrised tests in
tests/conformance/persistence/(the sharedbackendfixture inconftest.pyruns each test against SQLite and Postgres) and policed byscripts/check_dual_backend_test_parity.py, which checks signature, body, and coverage in three passes (pre-push hook + CI Lint job). - Every new repository MUST be exposed on
PersistenceBackend(src/synthorg/persistence/protocol.py) as a property so controllers and services can resolve it through the same backend handle they already hold; concrete backends (SQLitePersistenceBackend,PostgresPersistenceBackend) fill in the property by constructing the per-backend repo with the shared connection pool. Without this exposure, the new repo is unreachable through the canonical service-layer access path and must be hand-wired at every call site. - The naming consistency lets
glob-based test discovery and contributor onboarding find the right files without grepping.
17. Registering a new MANDATORY rule¶
Every paragraph marked (MANDATORY) in the canonical doc set
(CLAUDE.md, web/CLAUDE.md, cli/CLAUDE.md, docs/reference/*.md,
docs/design/*.md) must be registered in
scripts/convention_gate_map.yaml in the same PR that introduces it.
The meta-gate scripts/check_convention_gate_inventory.py runs at
pre-push and fails the build if a paragraph is unregistered, an entry
is stale, or a referenced gate path is missing on disk.
Each registration takes one of two shapes:
- Gate-backed (the default; the goal for every new rule):
- id: <file-slug>::<header-slug>
file: <repo-relative path>
header: <exact header text without the "(MANDATORY)" suffix>
gate: scripts/check_<your-rule>.py
The gate path can point at any file whose presence and correctness
enforce the rule (a scripts/check_*.py AST gate, an ESLint config,
a CI ceiling file). The path is verified to exist on disk; broken
references fail the gate.
- Exempt (reserved for rules that are genuinely not script- enforceable -- process rules requiring user approval, workflow rules enforced by hookify or skills):
- id: <file-slug>::<header-slug>
file: <repo-relative path>
header: <exact header text>
exempt:
reason: |
<one or more sentences explaining why no script can enforce
this rule and what does (peer review, /pre-pr-review skill,
runtime test guard, etc.)>
Exempt entries are technical debt. The convention-rollout policy aims to drive the exempt list toward zero by promoting each exemption into a real gate as enforcement options become tractable.
Generating the rule id¶
The id is <file-slug>::<header-slug>, both halves lowercase ASCII
slugs (alphanumeric runs separated by -, with the file path's /
treated as whitespace). Examples:
| File | Header | id |
|---|---|---|
CLAUDE.md |
Persistence Boundary |
claude-md::persistence-boundary |
web/CLAUDE.md |
MSW handlers |
web-claude-md::msw-handlers |
docs/reference/foo.md |
Async-Leak Ceiling |
docs-reference-foo-md::async-leak-ceiling |
If you rename a header, update both header: and id: in the same
edit. The gate's stale-entry check surfaces orphans automatically.
18. activate_* / deactivate_* lifecycle method naming¶
Domain services that flip an entity into / out of an "active" runtime
state expose paired async methods named activate_<entity> and
deactivate_<entity> (or the bare verbs when the receiver name
already disambiguates). These verbs are reserved for state transitions
that affect downstream scheduling, eligibility, or visibility,
distinct from save (persist) and delete (remove).
| Method | Where | Notes |
|---|---|---|
activate_workflow |
WorkflowExecutionController.activate_workflow (src/synthorg/api/controllers/workflow_executions.py) |
Spawns a workflow execution loop. The corresponding teardown verb in this controller is WorkflowExecutionController.cancel_execution: workflow runtimes are cancelled rather than "deactivated" because cancel_* is the lifecycle-end verb when an entity carries an in-flight execution that may need to surface a cancellation outcome. |
activate_sprint / deactivate_sprint |
CeremonyScheduler.activate_sprint / CeremonyScheduler.deactivate_sprint (src/synthorg/engine/workflow/ceremony_scheduler.py) |
Sprint window open / close. |
deactivate_client |
ClientController.deactivate_client (src/synthorg/api/controllers/clients.py), ClientFacadeService.deactivate_client (src/synthorg/integrations/mcp_services.py) |
Disables an integration client without deletion. |
deactivate_all |
FineTuneCheckpointRepository.deactivate_all (src/synthorg/persistence/fine_tune_protocol.py, both backends) |
Bulk deactivate of fine-tune jobs. |
Prefer these verbs for any new "becomes runnable / no longer runnable"
transition. enable_* / disable_* are reserved for boolean feature
flags read from settings, not for domain-entity lifecycle, so avoid
those as synonyms for the lifecycle pair documented here.
19. Factory module naming¶
Pluggable subsystems expose their construction surface through a
sibling factory.py: backup/factory.py, client/factory.py,
engine/evolution/factory.py, hr/scaling/factory.py,
memory/factory.py, notifications/factory.py, plus equivalents
under engine/coordination/, engine/identity/store/,
engine/middleware/, integrations/webhooks/verifiers/, and
memory/org/.
Narrow / module-private factories that build one specific collaborator
(not the subsystem's full assembly graph) use a scoped suffix instead:
engine/coordination/dispatcher_factory.py,
engine/checkpoint/callback_factory.py,
engine/quality/verification_factory.py,
api/rate_limits/inflight_factory.py, api/middleware_factory.py.
Rule: a subsystem's canonical assembly entry point is factory.py;
collaborator-specific helpers within the same subsystem use
<scope>_factory.py. The single engine/agent_engine_factories.py
(plural) carries the top-level engine assembly surface and is the
deliberate exception.
20. Args-model file naming at the MCP boundary¶
Args models for MCP tool / domain registrations live in
src/synthorg/meta/mcp/domains/*_args.py. One file per logical domain
(_workflows_org_args.py, _workflows_engine_args.py, etc.). Types
inside follow <Verb><Noun>Args and extend _ArgsBase (which is
itself the per-domain frozen + extra="forbid" base).
See mcp-handler-contract.md for the full boundary contract; §20 documents the naming so a new domain lands its args in the canonical place.
21. Subpackage _shared.py pattern¶
Helpers needed by multiple siblings within a single subpackage and not
intended for external import live in a leading-underscore
_shared.py at the subpackage root. Current sites:
engine/assignment/_shared.py, hr/evaluation/extractors/_shared.py,
memory/tools/_shared.py, persistence/sqlite/_shared.py.
The leading underscore signals "private to this subpackage"; callers
outside the subpackage import from the subpackage's public surface
(engine/assignment/__init__.py etc.) instead. Use this pattern any
time three or more sibling files in a subpackage want the same
helper.
22. When a subpackage gets its own errors.py¶
Section 6 covers the <Domain><Condition>Error(DomainError) hierarchy
itself. The orthogonal question of file location follows this rule:
a subpackage gets its own errors.py when it owns at least one
bounded-context-specific error meaningful only inside that subpackage.
The 30+ instances of <package>/errors.py under src/synthorg/
(backup/errors.py, budget/errors.py,
communication/meeting/errors.py, engine/middleware/errors.py,
hr/scaling/errors.py, memory/org/errors.py, etc.) all follow this
rule. Subpackages without their own bounded-context errors raise from
the parent package's errors.py instead.
23. Service vs Repository naming¶
XService types hold orchestration / business logic: they depend on
repositories, other services, and protocol-typed collaborators, and
they live next to the domain they orchestrate (backup/service.py,
hr/training/service.py).
XRepository types implement the per-backend persistence protocol
and live under persistence/<backend>/ (one module per repository per
backend: a single agent_identity_repo.py file for smaller repos,
or a same-named subpackage agent_identity_repo/ when the
module-size policy requires decomposition, e.g.
persistence/postgres/approval_repo/__init__.py assembling read/write
mixins). The protocol
definition (the XProtocol / XRepository Protocol class) lives
under persistence/<entity>_protocol.py and is shared by both
backends.
24. conftest.py scoping¶
tests/conftest.py (one file at the top level) hosts cross-suite
fixtures: Hypothesis profile selection, the FakeClock factory, the
repo-root resolver. Per-domain tests/<area>/conftest.py files host
fixtures local to that suite (controller fixtures under tests/unit/api/,
persistence-conformance fixtures under tests/conformance/persistence/,
etc.). Per-tier loop selection on Windows uses the
pytest_asyncio_loop_factories pluggy hook in each tier's
conftest.py rather than a process-wide policy override: the unit
tier hook returns SelectorEventLoop, and deeper conftest files
(tests/unit/tools/, tests/unit/engine/workspace/git_backend/,
tests/integration/engine/workspace/, tests/e2e/) shadow it with
ProactorEventLoop for subprocess-driving tests. Pluggy's
reverse-order invocation under firstresult=True lets the deeper
conftest's hook win.
tests/_shared/ is not a pytest suite and carries no conftest.py;
it exposes the test-double ladder (FakeClock, mock_of,
SimpleNamespace-bag helpers) plus LoopAsyncClient (the portal-free
async API test client backing the async_test_client fixture) as
importable utilities consumed by fixtures declared elsewhere.
25. Settings-definitions structure¶
Every settings registration lives in
src/synthorg/settings/definitions/<area>.py (api.py, budget.py,
security.py, ...). Each module imports the per-area registrar
(typically aliased _r) and calls _r.register(SettingDefinition(...))
once per setting.
New settings consumed by a service that starts at boot must also be
wired into the src/synthorg/api/lifecycle_helpers/ package (one of
bootstrap.py, config_apply.py, settings_dispatcher.py,
audit_retention.py, ticket_cleanup.py) so the value is read from
the resolver at startup. The
setting-to-startup-trace gate enforces this trace; ghost-wired
settings (defined but never read at startup) fail the gate.
26. Boundary parse_typed() helper¶
Every external dict ingestion at a system boundary (HTTP body, MCP
tool args, WebSocket control frame, CLI argument bag) parses through
parse_typed() (defined in synthorg.core.boundary). Direct
Model.model_validate(payload) at a boundary is blocked by
scripts/check_boundary_typed.py.
parse_typed() provides a single error-translation path (Pydantic
ValidationError becomes the appropriate <Domain><Condition>Error
or RFC 9457 problem detail at the boundary), structured logging of
the rejected payload shape, and uniform handling of extra="forbid"
violations. Internal-only model construction (a service handing a
known-shape dict to a repository) does not need parse_typed().
27. Module __all__ usage¶
Packages that re-export a public surface declare __all__ to pin the
exported names: the top-level synthorg/__init__.py, each
subsystem's <subsystem>/__init__.py, persistence/__init__.py.
Single-purpose internal modules do not declare __all__.
Rule: declare __all__ only when the module exists to re-export
across a package boundary; do not declare it as a substitute for
module-level access control inside a single implementation file. A
new public re-export point should land in the matching
__init__.py, not in a sibling implementation module.
28. Controller method naming¶
Litestar controllers in src/synthorg/api/controllers/ name handler
methods <resource>_<action> (agents_list, agents_get,
agents_create, agents_update, agents_delete). Handlers are
async def; each takes a typed *Request DTO from
synthorg.api.dto or a domain-specific dto_*.py. Response shapes
are typed *Response DTOs returned through the standard envelope
wrapper.
<resource> is the persistence-entity noun, not the URL segment, so
search-as-a-shape stays consistent (workflow_versions_list, not
workflow_versions_index).
Route access guards¶
Controller routes are access-guarded by their effect: read-only routes
(@get, @head) use require_read_access; mutating routes (@post,
@patch, @put, @delete) use require_write_access. Declare the guard
either per route (guards=[...]) or once at the controller class level
(guards = [...], which covers every route on that controller); both import
from synthorg.api.guards. This is a review-enforced convention rather than a
gated one: apply the matching guard to any route that exposes or mutates
tenant-scoped state.
29. Request / Response / Snapshot suffix taxonomy¶
The frozen + extra="forbid" rule (§8) applies to every DTO at an
API boundary. The naming suffix encodes its role:
*Request: inbound payload (HTTP body, WebSocket control frame, MCP tool args). Validated throughparse_typed()(§26).*Response: outbound payload, wrapped in the standard envelope before serialisation.*Snapshot: point-in-time projection of mutable state (e.g.AgentSnapshot,WorkflowSnapshot). Suitable for caching and diffing; not used for mutation inputs.*Result: outcome of a discrete operation that does not have a natural "request" / "response" pair (e.g.RestoreResult,TrainingResult). Carries a status discriminator plus the operation-specific payload.*Envelope: typed error wrapper or generic transport container.*Status: read-only state projection (e.g.BackupStatus).*Info: derived metadata (e.g.ProviderInfo).*Summary: aggregate / rollup view (e.g.BudgetSummary).
The project-wide frozen-extra-forbid gate (section 8) covers every
DTO carrying one of these suffixes along with every other frozen
model, verifying each sets extra="forbid".
30. Import order¶
stdlib imports, blank line, third-party imports, blank line,
synthorg.* imports. Within each group, lines are alphabetical by
module path. Enforced by ruff rule I (isort).
Re-exports through synthorg.observability (e.g.
from synthorg.observability import get_logger) and through other
synthorg.* top-level facades count as project-internal imports for
ordering purposes, even though they wrap a third-party logger
under the hood. The package facade is the convention boundary; what
it wraps is an implementation detail.
31. Ruff lint preview rules¶
The project opts into individual ruff preview rules via three coupled
keys in [tool.ruff.lint]:
preview = trueenables ruff's preview-rule machinery.explicit-preview-rules = truerestricts activation to preview rules that are explicitly listed inselect/extend-select. Without this flag,preview = trueactivates every preview rule and would surface hundreds of unintended violations across the codebase.extend-selectlists each preview rule the project opts into.
Active preview opt-ins:
TID255(lazy-import-immediately-resolved): pre-emptive gate for PEP 690 lazy imports. Currently inert on Python 3.14 (nolazysyntax in the language yet); becomes meaningful when 3.15 lands.
When adding a new preview rule, list it in extend-select and keep
the explicit-preview-rules = true flag; never remove that flag.
32. <service>_of(app_state) slice accessors¶
Each feature package exposes module-level <service>_of(app_state)
helpers (e.g. cost_tracker_of in budget/state.py) that resolve a
wired collaborator off its BaseFeatureStateSlice or raise a 503 when
it is absent. Controllers and MCP handlers call these accessors rather
than reaching into app_state.slice(XStateSlice).field directly: the
accessor centralises the "not wired yet" failure into one typed 503 and
keeps the slice's internal field shape private to its owning package.
Name the accessor <noun>_of after the service it returns.
33. _wire_* vs _try_wire_* startup wiring naming¶
On-startup wiring helpers follow a two-name contract:
_wire_<x>MUST succeed: a failure is a wiring bug and propagates, failing the boot._try_wire_<x>is best-effort and idempotent: it attaches durable collaborators that only exist once a backend is connected (e.g._try_wire_performance_persistenceattaches metric repos to the construction-phase tracker), logs anAPI_APP_STARTUPwarning and returns cleanly when its dependency is absent so a persistence-less boot (tests / dev) degrades instead of crashing.
Pick the prefix by whether a missing dependency is a defect (_wire_)
or an expected degraded mode (_try_wire_).
The MUST-succeed contract binds the functions registered directly as
startup hooks. An inner builder called only from a _try_wire_* wrapper
may itself carry degradation guards even when it keeps the _wire_
prefix (e.g. _wire_cockpit_services, _wire_knowledge_engine return
early when their backend is absent); the wrapping _try_wire_* owns the
degraded-mode contract for that chain.
34. set_slice / swap_slice / wire semantics¶
AppState composes feature state slices under one lock with three
distinct write seams:
set_slice(slice)-- install a slice once at boot; raises if a slice of that type is already composed. Use for the initial construction-phase install.swap_slice(slice)-- hot-replace a whole slice atomically (the nextslice()read returns the new one; readers holding the old reference keep it). Use when an entire slice is rebuilt at runtime.wire(SliceType, field=service)-- field-level swap: read-copy-update a single field on the (possibly absent) slice atomically. Use to install one service into its owning slice without disturbing siblings already wired onto it.set_field_once/wire_if_field_absentare the once-only and if-absent variants for race-sensitive single fields.swap_field_returning_previous(SliceType, field=service)-- likewire, but returns the previous field value under the same lock acquisition. Use when the caller must stop or close the service it is replacing before the swap takes effect.
See also¶
- persistence-boundary.md: repository /
service / controller layering, plus the datetime-marshalling
helpers (
parse_iso_utc,format_iso_utc,normalize_utc,coerce_row_timestamp). - lifecycle-sync.md:
_lifecycle_lockrule. - pluggable-subsystems.md: protocol + strategy + factory + config discriminator pattern.
- sec-prompt-safety.md: SEC-1 untrusted-content
fences, HTML parsing guard, secret-log redaction (the
error=str(exc)ban). - errors.md: RFC 9457 problem details, error-code ranges, HTTP exception handler registration recipe.
- mcp-handler-contract.md: the Args models contract at the MCP boundary (#1611).