Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions .opencode/plugins/synthorg-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* PreToolUse (Bash): scripts/check_bash_no_write.sh
* PreToolUse (Bash): scripts/check_git_c_cwd.sh
* PreToolUse (Bash): scripts/check_no_pr_create.sh
* PreToolUse (Bash): scripts/check_no_git_no_verify.sh
* PreToolUse (Bash): scripts/check_no_cd_prefix.sh
* PreToolUse (Bash): scripts/check_no_local_coverage.sh
* PreToolUse (Bash): scripts/check_enforce_parallel_tests.sh
Expand Down Expand Up @@ -342,6 +343,7 @@ export const SynthOrgHooks: Plugin = async ({ client, $, app }) => {
// -n=8 --dist=loadfile).
for (const script of [
"scripts/check_no_pr_create.sh",
"scripts/check_no_git_no_verify.sh",
"scripts/check_no_cd_prefix.sh",
"scripts/check_no_local_coverage.sh",
"scripts/check_enforce_parallel_tests.sh",
Expand Down
13 changes: 7 additions & 6 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -570,14 +570,15 @@ repos:
pass_filenames: false
stages: [pre-push]

- id: dto-forbid-extra
name: DTO extra="forbid" gate (src/synthorg/api)
entry: uv run python scripts/check_dto_forbid_extra.py
- id: frozen-extra-forbid
name: Frozen model extra="forbid" gate (src/synthorg)
entry: uv run python scripts/check_frozen_model_extra_forbid.py
language: system
# Trigger on any change to the scanned tree, the checker
# Project-wide successor to the old api-only dto-forbid-extra
# gate. Trigger on any change to the scanned tree, the checker
# itself, or this config so a PR that weakens the gate cannot
# bypass the check by not touching api Python files.
files: ^(src/synthorg/api/.*\.py|scripts/check_dto_forbid_extra\.py|\.pre-commit-config\.yaml)$
# bypass it by not touching scanned Python files.
files: ^(src/synthorg/.*\.py|scripts/check_frozen_model_extra_forbid\.py|\.pre-commit-config\.yaml)$
pass_filenames: false
stages: [pre-push]

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ PYTHONPATH=. uv run zensical build # docs
- No `from __future__ import annotations` (3.14 has PEP 649). PEP 758 except: `except A, B:` no parens unless binding.
- Type hints on public functions; mypy strict. Google-style docstrings. Line length 88; functions <50 lines; files <800 lines.
- Errors: `<Domain><Condition>Error` from `DomainError`; never inherit `Exception`/`RuntimeError`/etc directly. Enforced by `check_domain_error_hierarchy.py`.
- Pydantic v2 frozen + `extra="forbid"` on API DTOs (Request/Response/Snapshot/Result/Envelope/Status/Info/Summary suffixes); `@computed_field` for derived; `NotBlankStr` for identifiers.
- Pydantic v2 frozen + `extra="forbid"` on every frozen model project-wide (gate `check_frozen_model_extra_forbid.py`; `@computed_field` auto-exempt, per-line `# lint-allow: frozen-extra-forbid -- <reason>` for `extra="allow"`/`"ignore"` boundaries); `@computed_field` for derived; `NotBlankStr` for identifiers.
- Args models at every system boundary; `parse_typed()` for every external dict ingestion. Enforced by `check_boundary_typed.py`.
- Immutability: `model_copy(update=...)` or `copy.deepcopy()`; deepcopy at system boundaries.
- Async: `asyncio.TaskGroup` for fan-out/fan-in; helpers catch `Exception` (re-raise `MemoryError`/`RecursionError`).
Expand Down
4 changes: 2 additions & 2 deletions docs/design/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ from synthorg.core.types import NotBlankStr
class Skill(BaseModel):
"""Structured capability description, A2A AgentSkill-aligned."""

model_config = ConfigDict(frozen=True, allow_inf_nan=False)
model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid")

id: NotBlankStr # e.g. "code-review"
name: NotBlankStr # e.g. "Code Review"
Expand All @@ -88,7 +88,7 @@ class Skill(BaseModel):
class SkillSet(BaseModel):
"""Agent skill inventory, split into primary and secondary."""

model_config = ConfigDict(frozen=True, allow_inf_nan=False)
model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid")

primary: tuple[Skill, ...] = ()
secondary: tuple[Skill, ...] = ()
Expand Down
2 changes: 1 addition & 1 deletion docs/design/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ for non-Docker deployments where torch is installed directly.

```python
class EmbeddingFineTuneConfig(BaseModel):
model_config = ConfigDict(frozen=True, allow_inf_nan=False)
model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid")

enabled: bool = False
checkpoint_path: NotBlankStr | None = None
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/audit-category-gate-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The four resolution paths are:
| Typed boundary (`parse_typed` at every external dict ingestion) | Standing gate | `scripts/check_boundary_typed.py` |
| Vendor-name leakage (Anthropic / OpenAI / Claude / GPT) | Standing gate | `scripts/check_forbidden_literals.py` |
| Regional-default hardcoding (currency / locale / timezone / language) | Standing gate | `scripts/check_backend_regional_defaults.py` + `scripts/check_web_design_system.py` |
| API DTO `extra="forbid"` (request / response / snapshot / result / envelope / status / info / summary suffixes) | Standing gate | `scripts/check_dto_forbid_extra.py` |
| Frozen model `extra="forbid"` (project-wide: every frozen `ConfigDict` model under `src/synthorg/`, `@computed_field` auto-exempt) | Standing gate | `scripts/check_frozen_model_extra_forbid.py` |
| Em-dashes (U+2014) in source | Standing gate | `scripts/check_no_em_dashes.py` |
| Redundant per-test `pytest.mark.timeout(30)` | Standing gate | `scripts/check_no_redundant_timeout.py` |
| Bulk edits without explicit user approval | Standing gate | `scripts/check_no_bulk_edit.py` |
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/convention-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ All under `scripts/`. The list is generated by `ls scripts/check_*.py`; if an en
- `check_doc_drift_counts.py`
- `check_doc_numeric_macros.py`
- `check_domain_error_hierarchy.py`
- `check_dto_forbid_extra.py`
- `check_dto_types_ts_in_sync.py`
- `check_dual_backend_test_parity.py`
- `check_error_codes_ts_in_sync.py`
- `check_forbidden_literals.py`
- `check_frozen_model_extra_forbid.py`
- `check_image_signatures.py`
- `check_list_pagination.py`
- `check_logger_exception_str_exc.py`
Expand Down
44 changes: 29 additions & 15 deletions docs/reference/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,23 +183,36 @@ inline with the consumer. Examples:
## 8. Frozen `ConfigDict` pattern

Every Pydantic model declares
`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.

`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/` whose own `model_config` is a `ConfigDict` (or dict
literal) with `frozen=True` MUST also set `extra="forbid"`.

Two carve-outs:

* **`@computed_field` (automatic).** Classes declaring a
`@computed_field` are exempt without annotation: Pydantic v2
includes the computed value in `model_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 using `extra="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.

References: 489+ occurrences across `src/synthorg/`. Canonical example:
`src/synthorg/approval/models.py:28`.
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 (#1611)

Expand Down Expand Up @@ -754,8 +767,9 @@ API boundary. The naming suffix encodes its role:
* `*Info`: derived metadata (e.g. `ProviderInfo`).
* `*Summary`: aggregate / rollup view (e.g. `BudgetSummary`).

The `dto-forbid-extra` gate scans for any DTO carrying one of these
suffixes and verifies it sets `extra="forbid"`.
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

Expand Down
19 changes: 18 additions & 1 deletion docs/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ Clients should dispatch on `error_code` (most specific) and fall back to `error_
| 2002 | `ARTIFACT_TOO_LARGE` | Upload exceeds `artifact.max_bytes` |
| 2003 | `TOOL_PARAMETER_ERROR` | Tool parameters failed schema validation |
| 2004 | `PROVIDER_TIER_COVERAGE_INSUFFICIENT` | Setup wizard cannot apply a template because no configured provider exposes any models |
| 2005 | `IMMUTABLE_FIELD_MISMATCH` | A restore/rollback would change an immutable field (e.g. agent id/name/department) |
| 2006 | `CHECKPOINT_ROLLBACK_UNAVAILABLE` | Fine-tune checkpoint rollback target is missing or unusable |
| 2007 | `CHECKPOINT_ROLLBACK_CORRUPT` | Fine-tune checkpoint rollback backup data is corrupt |

## Not Found (3xxx)

Expand All @@ -70,8 +73,12 @@ The NotFound hierarchy is driven by a single `NotFoundError` class with domain-s
| 3010 | `CONNECTION_NOT_FOUND` | Integration connection |
| 3011 | `MODEL_NOT_FOUND` | Provider model |
| 3012 | `ESCALATION_NOT_FOUND` | Escalation queue entry |
| 3013 | `WORKFLOW_DEFINITION_NOT_FOUND` | Workflow definition record |
| 3014 | `AB_TEST_NOT_FOUND` | A/B test record for a proposal |
| 3015 | `BACKUP_NOT_FOUND` | Backup archive |
| 3016 | `MEMORY_ENTRY_NOT_FOUND` | Agent memory entry |

All 13 share the same `type` URI; the numeric code is the discriminator.
All share the same `type` URI; the numeric code is the discriminator.

## Conflict (4xxx)

Expand All @@ -86,6 +93,11 @@ All 13 share the same `type` URI; the numeric code is the discriminator.
| 4006 | `ESCALATION_ALREADY_DECIDED` | Late decision on a closed escalation |
| 4007 | `MIXED_CURRENCY_AGGREGATION` | Cross-currency aggregation attempted |
| 4008 | `WORKFLOW_EXECUTION_ALREADY_TERMINAL` | Cancel hit an execution already in a terminal status (no retry will succeed) |
| 4009 | `BACKUP_IN_PROGRESS` | A backup/restore operation is already running |
| 4010 | `CHECKPOINT_OPERATION_CONFLICT` | Checkpoint deploy/delete rejected (e.g. active checkpoint) |
| 4011 | `FINE_TUNE_RUN_ACTIVE` | A fine-tune run is already active (start/resume blocked) |
| 4012 | `TRAINING_PLAN_NOT_MODIFIABLE` | Training plan cannot be modified after execution or failure |
| 4013 | `BACKUP_UNRESTARTABLE` | Backup service stopped in an unrestartable state |

## Rate Limit (5xxx)

Expand Down Expand Up @@ -135,6 +147,11 @@ All 13 share the same `type` URI; the numeric code is the discriminator.
| 8008 | `TOOL_EXECUTION_ERROR` | Tool runtime failure (subclass of `TOOL_ERROR`) |
| 8009 | `FEATURE_NOT_IMPLEMENTED` | Active backend or deployment fundamentally does not implement the requested operation (501) |
| 8010 | `ARTIFACT_NO_STORAGE_BACKEND` | Artifact service was constructed without a storage backend; controller-helper misconfiguration |
| 8011 | `AGENT_IDENTITY_ROLLBACK_FAILED` | Unexpected server failure during agent-identity rollback |
| 8012 | `BACKUP_RESTORE_FAILED` | Restore operation failed (non-recoverable backend error) |
| 8013 | `BACKUP_MANIFEST_ERROR` | Backup manifest could not be parsed or validated |
| 8014 | `SETTINGS_ENCRYPTION_ERROR` | Internal error processing a sensitive (encrypted) setting |
| 8015 | `SINK_CONFIG_VALIDATION_ERROR` | Internal error validating an observability sink configuration |

## Content negotiation

Expand Down
Loading
Loading