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
611 changes: 304 additions & 307 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/open-api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: The Agent's user-facing API
description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.)
version: 5.19.0
version: 5.19.1
license:
name: MIT
url: https://opensource.org/licenses/MIT
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-14
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
## Context

`chat_configs` is one of the remaining persistence areas using the legacy `db/schema` + `db/crud` pattern. `ChatConfigCRUD` returns `ChatConfigDB` rows, and callers convert those rows with `ChatConfig.model_validate(...)`. That persistence shape leaks into `AuthorizationService`, `SettingsController`, Telegram/WhatsApp data resolvers, integration helpers, SDK code, announcements, and tests.

Newer persistence areas in this codebase use a different boundary:

```
caller
repository ─────▶ SQLAlchemy DB model
│ ▲
▼ │
domain dataclass ◀──── mapper
```

`UsageRecordRepository`, `PurchaseRecordRepository`, and `ChatMembershipRepository` are the local precedents. They keep SQLAlchemy models at the persistence edge and return feature-level domain dataclasses to callers.

The most sensitive current behavior is in Telegram and WhatsApp chat resolution. Platform updates arrive as partial chat snapshots. When an existing chat is found, remote-owned fields refresh from the snapshot while DB-owned settings such as language, reply chance, release notifications, and media mode stay intact. `is_private` is treated as a platform fact and updates existing chats when the snapshot provides a non-null value. When no chat exists, the resolver persists a new chat from remote data with explicit mapper defaults, including release notifications based on privacy.

## Goals / Non-Goals

**Goals:**

- Introduce a chat config domain dataclass, mapper, and repository that match the repository pattern used by newer persistence units.
- Keep `ChatConfigDB` as the only SQLAlchemy model for the existing `chat_configs` table.
- Preserve all current external API behavior and database schema behavior.
- Make creation defaults and remote-owned overrides explicit in code rather than relying on accidental DTO defaults.
- Add repository tests beside legacy CRUD tests, then migrate production callers through the new repository/domain boundary.
- Leave old CRUD/schema files in place until their remaining DB CRUD tests are intentionally retired.

**Non-Goals:**

- No database migration or schema change.
- No endpoint, payload, response, or OpenAPI contract change.
- No enum relocation in the first pass; `ChatConfigDB.ChatType`, `MediaMode`, and `ReleaseNotifications` remain where they are to avoid a whole-system enum migration.
- No immediate removal of `db/crud/chat_config.py` or `db/schema/chat_config.py`.

## Decisions

### 1. Keep One SQLAlchemy Model

`ChatConfigDB` remains the only DB model and table definition for `chat_configs`.

The new model introduced by this change is a feature-level domain dataclass, not a second SQLAlchemy model or table. The repository maps between the dataclass and `ChatConfigDB`.

**Rationale**: This matches `usage_record` and `purchase_record`: one DB model, one domain model, one mapper, one repository. A second DB model would create competing table definitions and migration risk without solving the persistence boundary problem.

**Alternative considered**: Create a parallel SQLAlchemy model. Rejected because it duplicates table ownership and makes Alembic behavior harder to reason about.

### 2. Use a Nullable Domain ID for Create Parity

The chat config domain dataclass may carry `chat_id: UUID | None = None` during create flows. The repository lets SQLAlchemy generate `chat_id` using the existing `ChatConfigDB.chat_id` default when no ID is present, then returns the persisted domain object with a concrete ID.

**Rationale**: `chat_configs` currently generates IDs in the DB model. Preserving that avoids changing identity ownership during this refactor. It also keeps the first milestone close to existing `ChatConfigSave` behavior while moving persistence behind a repository.

**Alternative considered**: Require callers to generate UUIDs before saving. Rejected for the first pass because it changes current object lifecycle semantics without a clear benefit.

### 3. Repository Saves Full Domain Objects and Remote Snapshots

The repository provides persistence operations:

- `get(chat_id) -> ChatConfig | None`
- `get_all(skip, limit) -> list[ChatConfig]`
- `get_by_external_identifiers(external_id, chat_type) -> ChatConfig | None`
- `save(chat_config: ChatConfig | ChatConfigRemoteData) -> ChatConfig`
- `delete(chat_id) -> ChatConfig | None`

`save(ChatConfig)` treats the domain object as a complete persisted object. If `chat_id` points at an existing row, the row is updated with all domain fields. If `chat_id` is absent or no row exists for it, a new row is inserted and SQLAlchemy generates the ID when needed.

`save(ChatConfigRemoteData)` treats the input as a remote/platform snapshot. The repository looks up by `(external_id, chat_type)`. If a row exists, mapper-owned merge logic applies only remote-owned updates. If no row exists, mapper-owned creation logic builds a full `ChatConfig` from the snapshot plus explicit defaults.

**Rationale**: The repository stays responsible for persistence orchestration while the mapper owns cross-shape conversion. Callers can save full domain objects when they own all fields, or save remote data when they only have a platform snapshot.

**Alternative considered**: Keep separate `create`, `update`, and `get_by_external_identifiers_or_create` methods. Rejected because it forced platform callers to duplicate the existing-vs-missing branch and made the new repo less aligned with the current `save` usage.

### 4. Resolve Platform Snapshots with Explicit Ownership

Telegram and WhatsApp chat config resolution should follow this shape after their migration milestone:

```
remote_data = ChatConfigRemoteData(
external_id = snapshot.external_id,
chat_type = snapshot.chat_type,
title = snapshot.title,
is_private = snapshot.is_private,
language_iso_code = snapshot.language_iso_code,
)

return repo.save(remote_data)
```

For existing chats, `language_iso_code`, `language_name`, `reply_chance_percent`, `release_notifications`, and `media_mode` remain DB-owned. `title` and non-null `is_private` refresh from remote data. For new chats, `from_remote_data` uses explicit defaults: private defaults to `True`, release notifications are `major` for private chats and `none` for public chats, and media mode defaults to `photo`.

**Rationale**: The resolver stops mutating the incoming mapped object and instead sends a typed remote snapshot to the repository. The mapper makes field ownership clear in one conversion boundary.

**Alternative considered**: Keep mutating a `ChatConfigSave`-like object before saving. Rejected because the object still conflates partial platform snapshots with full persistence data.

### 5. Migrate Callers Through Domain Boundaries

The new repository is added to DI immediately but remains inert until a caller uses it. The original migration plan used small review gates:

1. Add domain/mapper/repo/DI and tests only.
2. Migrate `SettingsController` direct chat config reads/writes.
3. Migrate Telegram and WhatsApp chat config resolution.
4. Migrate lower-risk read paths such as announcements, SDK lookup code, and integration helpers.
5. Migrate `AuthorizationService` after the lower-risk paths have been reviewed.
6. Remove legacy DI access after production imports are gone.
7. Remove legacy CRUD/schema files only when their remaining tests are retired.

During implementation, review feedback broadened the migration to all domain-layer callers including authorization. The production boundary now uses `chat_config_repo` and the new domain `ChatConfig` across DI, settings, authorization, membership, platform resolvers, integrations, announcements, SDK lookup code, responders, and prompt-resolver paths.

**Rationale**: Once settings and repository behavior were reviewed, keeping mixed legacy/domain model types in the domain layer became more risky than migrating the rest of the domain boundary together. Removing `chat_config_crud` from DI prevents new production code from drifting back to the legacy path.

**Alternative considered**: Big-bang replace every `db.schema.chat_config` and `chat_config_crud` import. Rejected due to broad blast radius and poor reviewability.

## Risks / Trade-offs

- **Repository and CRUD temporarily coexist** -> Mitigation: keep both test suites green and remove CRUD only after `rg "chat_config_crud|db.schema.chat_config"` shows no production imports.
- **Partial migration causes mixed model types in callers** -> Mitigation: migrate one owner boundary at a time and adjust mocks/tests per milestone.
- **Existing resolver behavior changes accidentally** -> Mitigation: use Telegram/WhatsApp data resolver tests as canaries, especially existing-chat remote-field updates, DB-owned field preservation, and new-chat default tests.
- **Enum migration expands scope** -> Mitigation: do not move `ChatConfigDB` nested enums in this change.
- **Authorization regression** -> Mitigation: migrate `AuthorizationService` only after repository behavior is covered, then run authorization/settings tests and the full offline suite.

## Migration Plan

1. Add the new domain model, mapper, repository, DI property, and SQL test helper.
2. Add mapper and repository tests that mirror the existing CRUD behavior.
3. Migrate production callers through API/service boundaries, platform resolvers, integrations, announcements, SDK lookup code, responders, and authorization.
4. After each milestone, run the focused tests for the migrated boundary.
5. When all production callers use the repository, remove legacy DI access to `chat_config_crud`.
6. Remove legacy chat config CRUD/schema and their now-obsolete tests in a final cleanup after review.
7. Run the full offline test suite and pre-commit before closing implementation.

Rollback during the transition is straightforward because the database schema does not change and legacy CRUD remains available until the final removal milestone.

## Open Questions

None. `is_private` is intentionally treated as a remote-owned platform fact when the snapshot provides it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## Why

Chat config persistence still uses the legacy `db/schema` + `db/crud` pattern, which leaks SQLAlchemy/Pydantic persistence types into API controllers, authorization, and platform data resolvers. Newer persistence areas such as usage records and chat memberships use feature-level domain dataclasses, explicit DB/domain mappers, and repositories, making persistence behavior easier to test and refactor safely.

## What Changes

- Add a feature-level chat config domain model, mapper, and repository beside the existing `ChatConfigDB` SQLAlchemy model.
- Keep `ChatConfigDB` as the single database model and preserve the existing `chat_configs` table, columns, indexes, defaults, and uniqueness behavior.
- Add the new repository to DI immediately, then remove DI access to the legacy CRUD after production callers are migrated.
- Preserve current chat config behavior while migrating callers gradually:
- direct domain saves persist the full chat config object;
- remote/platform snapshot saves update only remote-owned fields for existing chats;
- newly discovered remote/platform chats receive explicit defaults in the mapper.
- Migrate production callers from API/service boundaries through platform resolvers, integrations, announcements, SDK lookup code, and authorization.
- Keep legacy CRUD tests until the CRUD file is no longer used by production code; add repository/mapper tests first and maintain both test sets during the transition.

## Capabilities

### New Capabilities

- `chat-config-persistence`: Domain-model and repository behavior for creating, reading, updating, and resolving chat configurations without exposing SQLAlchemy DB models or legacy Pydantic persistence schemas to callers.

### Modified Capabilities

_(None — no existing chat config persistence spec.)_

## Impact

**Code**
- New feature-level chat config files under `src/features/chat/config/` or an equivalent feature-local package.
- `src/di/di.py` gains `chat_config_repo`; `chat_config_crud` is removed from DI once production callers no longer use it.
- `test/db/sql_util.py` gains a `chat_config_repo()` helper for focused repository tests.
- Production callers are migrated from legacy CRUD/schema use to the new repository/domain model.

**Database**
- No table, column, index, enum, or migration changes are intended.
- `src/db/model/chat_config.py` remains the only SQLAlchemy representation for `chat_configs`.

**API**
- No external API route, payload, response, or OpenAPI behavior changes are intended.
- Settings endpoints should continue returning the same externally visible data while their internal persistence dependency changes.

**Tests**
- New mapper tests verify DB/domain round trips, remote snapshot conversion, merge behavior, enum fields, generated-ID create behavior, and `None` handling.
- New repository tests mirror existing `test/db/crud/test_chat_config.py` behavior and cover `ChatConfigRemoteData` save semantics.
- Existing settings, authorization, Telegram, WhatsApp, integration, announcement, SDK, and responder tests remain behavior canaries through migration.
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## ADDED Requirements

### Requirement: Chat config repository returns domain models
The system SHALL provide a chat config repository that persists through the existing `ChatConfigDB` table model while accepting and returning feature-level chat config domain dataclasses.

#### Scenario: Fetch by chat ID returns domain model
- **WHEN** a chat config exists for a chat ID
- **THEN** the chat config repository returns a chat config domain dataclass with the persisted field values

#### Scenario: Missing chat ID returns none
- **WHEN** no chat config exists for a chat ID
- **THEN** the chat config repository returns `None`

#### Scenario: Save inserts pure chat config
- **WHEN** the chat config repository saves a domain dataclass without a persisted `chat_id`
- **THEN** the repository inserts a `chat_configs` row using the existing database model defaults
- **THEN** the repository returns a domain dataclass with a concrete persisted `chat_id`

#### Scenario: Save updates pure chat config
- **WHEN** the chat config repository saves a domain dataclass with an existing `chat_id`
- **THEN** the repository updates that row
- **THEN** subsequent reads return the updated domain field values

### Requirement: External identifier lookup is preserved
The system SHALL preserve current chat config lookup behavior by `(external_id, chat_type)`.

#### Scenario: Existing external identifier returns domain model
- **WHEN** a chat config exists with a matching `external_id` and `chat_type`
- **THEN** the chat config repository returns that chat config as a domain dataclass

#### Scenario: Missing external identifier returns none
- **WHEN** no chat config exists with a matching `external_id` and `chat_type`
- **THEN** the chat config repository returns `None`

#### Scenario: External identifiers remain unique by chat type
- **WHEN** a chat config is created or updated
- **THEN** the existing unique database constraint for non-null `(external_id, chat_type)` remains the persistence constraint

### Requirement: Remote chat snapshots use explicit conversion
The system SHALL provide a non-null remote snapshot save flow for platform chat resolution that applies remote-owned updates when an existing chat config is found and creates from explicit defaults when no chat config is found.

#### Scenario: Existing chat merges remote-owned fields
- **WHEN** `save(ChatConfigRemoteData)` finds an existing chat config by `(external_id, chat_type)`
- **THEN** it updates remote-owned fields from non-null snapshot values
- **THEN** it preserves DB-owned fields that are not part of the remote update set

#### Scenario: Missing chat creates from remote data
- **WHEN** `save(ChatConfigRemoteData)` does not find an existing chat config by `(external_id, chat_type)`
- **THEN** it creates a new chat config from the remote snapshot and mapper defaults
- **THEN** it returns the persisted domain dataclass

#### Scenario: Missing privacy defaults to private
- **WHEN** `save(ChatConfigRemoteData)` creates a new chat config and the snapshot privacy value is `None`
- **THEN** the new chat config defaults `is_private` to `True`
- **THEN** release notifications default according to the resolved privacy value

### Requirement: Platform chat resolution preserves existing behavior
The system SHALL preserve current Telegram and WhatsApp chat config resolution behavior while moving persistence to the repository.

#### Scenario: Existing Telegram chat preserves DB-owned settings
- **WHEN** a Telegram update resolves a chat config that already exists
- **THEN** the persisted chat config keeps its existing language, reply chance, release notifications, and media mode
- **THEN** remote-owned fields such as title and non-null `is_private` refresh from the platform snapshot

#### Scenario: New Telegram chat uses explicit defaults
- **WHEN** a Telegram update resolves a chat config that does not exist
- **THEN** the new persisted chat config uses explicit defaults matching current Telegram resolver behavior

#### Scenario: Existing WhatsApp chat preserves DB-owned settings
- **WHEN** a WhatsApp update resolves a chat config that already exists
- **THEN** the persisted chat config keeps its existing language, reply chance, release notifications, and media mode
- **THEN** remote-owned fields such as title and non-null `is_private` refresh from the platform snapshot

#### Scenario: New WhatsApp chat uses explicit defaults
- **WHEN** a WhatsApp update resolves a chat config that does not exist
- **THEN** the new persisted chat config uses explicit defaults matching current WhatsApp resolver behavior

### Requirement: Repository migration preserves production behavior
The system SHALL migrate production chat config callers from legacy chat config CRUD/schema usage to the repository without changing external API behavior.

#### Scenario: Repository is added before production migration
- **WHEN** the chat config repository is added to DI
- **THEN** existing legacy CRUD callers continue to work until they are explicitly migrated

#### Scenario: Production callers use repository domain models
- **WHEN** production chat config callers are migrated
- **THEN** API controllers, authorization, domain services, platform resolvers, integrations, announcements, SDK lookup code, and responders use `chat_config_repo` and the feature-level chat config domain model
- **THEN** they do not import `db.schema.chat_config` for production chat config behavior

#### Scenario: Settings controller migration preserves API behavior
- **WHEN** settings controller chat config persistence is migrated to the repository
- **THEN** settings API routes, payloads, responses, validation behavior, and sorting remain externally unchanged

#### Scenario: DI exposes only repository access for production chat config persistence
- **WHEN** production callers no longer use legacy chat config CRUD
- **THEN** DI exposes `chat_config_repo` for chat config persistence
- **THEN** DI no longer exposes `chat_config_crud`

#### Scenario: Legacy CRUD removed only after production imports are gone
- **WHEN** no production code imports `chat_config_crud` or `db.schema.chat_config`
- **THEN** the legacy CRUD/schema files and their obsolete tests may be removed
Loading
Loading