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
835 changes: 404 additions & 431 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.1
version: 5.19.2
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-18
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
## Context

`sponsorships` still uses the legacy `db/schema` + `db/crud` persistence pattern. `SponsorshipCRUD` accepts `SponsorshipSave`, returns `SponsorshipDB`, and callers convert rows with `Sponsorship.model_validate(...)`. This leaks persistence-specific Pydantic and SQLAlchemy shapes into `SponsorshipService`, `SponsorshipsController`, transfer validation, cleanup, settings checks, and tests.

The repository pattern now exists in nearby persistence areas:

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

`UsageRecordRepository`, `PurchaseRecordRepository`, `ChatMembershipRepository`, and the newer chat config repository keep SQLAlchemy models at the persistence edge and return feature-level domain dataclasses to callers.

Sponsorship differs from chat config in one important way: it does not receive partial external platform snapshots. Sponsorship rows are internal state created, accepted, and deleted by application services. `accepted_at` is the only nullable domain state:

```
sponsor_id / receiver_id
required composite identity, never generated

sponsored_at
persisted timestamp, defaulted by the application domain model
existing DB default remains only as a database fallback

accepted_at
nullable business state
None means "pending sponsorship"
non-null means "accepted sponsorship"
```

## Goals / Non-Goals

**Goals:**

- Introduce a sponsorship domain dataclass, mapper, and repository that match the repository pattern used by newer persistence units.
- Keep `SponsorshipDB` as the only SQLAlchemy model for the existing `sponsorships` table.
- Preserve all current external API behavior, internal sponsorship business rules, and database schema behavior.
- Make `sponsored_at` defaulting and `accepted_at` null semantics explicit in mapper/repository tests.
- Add repository tests beside legacy CRUD tests, then migrate production callers through the new repository/domain boundary.
- Remove legacy `db/crud/sponsorship.py` and `db/schema/sponsorship.py` only after production and test references are gone.

**Non-Goals:**

- No database migration or schema change.
- No endpoint, payload, response, or OpenAPI contract change.
- No change to sponsorship eligibility rules, transitive sponsorship restrictions, waitlist behavior, transfer restrictions, or cleanup retention policy.
- No user repository migration in this change, even though sponsorship flows still depend on legacy `user_crud`.

## Decisions

### 1. Keep One SQLAlchemy Model

`SponsorshipDB` remains the only DB model and table definition for `sponsorships`.

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 `SponsorshipDB`.

**Rationale**: This matches the existing repository pattern and avoids competing table definitions. The database schema already represents the desired persistence shape.

**Alternative considered**: Create a parallel SQLAlchemy model. Rejected because it duplicates table ownership and increases Alembic risk without improving the domain boundary.

### 2. Use One Domain Model for New and Persisted Sponsorships

The sponsorship domain dataclass should have required composite identity fields, a non-null sponsorship timestamp default, and nullable acceptance state:

```
Sponsorship(
sponsor_id: UUID,
receiver_id: UUID,
sponsored_at: datetime = field(default_factory=datetime.now),
accepted_at: datetime | None = None,
)
```

`sponsor_id` and `receiver_id` are always required before saving. `sponsored_at` is created by the domain model and is written to the database explicitly. `accepted_at` is intentionally nullable business state.

**Rationale**: A separate `SponsorshipSave` equivalent is unnecessary. Unlike chat config, sponsorship data is internal and does not arrive as a partial remote snapshot. Letting the domain model default `sponsored_at` keeps the app and DB representations aligned without carrying a fake nullable state.

**Alternative considered**: Keep separate create/save and persisted domain models. Rejected because it retains the old split without adding meaningful safety.

### 3. Write Domain State Exactly

Repository save behavior should be explicit:

- Insert:
- require `sponsor_id` and `receiver_id`;
- write the domain `sponsored_at` value;
- include `accepted_at` exactly as provided, including `None`.
- Update:
- locate the row by `(sponsor_id, receiver_id)`;
- write the domain `sponsored_at` value;
- write `accepted_at` exactly as provided, including `None`.

```
┌──────────────────────┐
│ Sponsorship domain │
│ sponsor_id required │
│ receiver_id required │
│ sponsored_at default │
│ accepted_at optional │
└──────────┬───────────┘
┌──────────────┴──────────────┐
▼ ▼
no existing row existing row found
│ │
▼ ▼
write sponsored_at write sponsored_at
write accepted_at write accepted_at
even when None even when None
```

**Rationale**: `accepted_at = None` means pending and must not be treated like an absent update value. `sponsored_at` is ordinary domain state; callers that update acceptance should preserve it by copying/replacing the existing domain model.

**Alternative considered**: Keep `sponsored_at` nullable and let the DB generate it. Rejected because this creates ambiguity in the domain model.

### 4. Keep Query Methods Aligned with Current CRUD Names First

The first repository should preserve the current operation surface:

- `get(sponsor_id, receiver_id) -> Sponsorship | None`
- `get_all_by_sponsor(sponsor_id, skip, limit) -> list[Sponsorship]`
- `get_all_by_receiver(receiver_id, skip, limit) -> list[Sponsorship]`
- `get_all(skip, limit) -> list[Sponsorship]`
- `save(sponsorship) -> Sponsorship`
- `delete(sponsor_id, receiver_id) -> Sponsorship | None`
- `delete_all_by_receiver(receiver_id) -> int`
- `delete_unaccepted_older_than(cutoff) -> int`

**Rationale**: Matching method names keeps production migration focused on type boundaries rather than business flow redesign.

**Alternative considered**: Rename methods around domain language, such as `find_received_by_user` or `delete_pending_before`. Rejected for the first pass because it expands review scope.

### 5. Migrate Callers Through Service Boundaries

The new repository should be added to DI first, while legacy CRUD remains available. Then production callers can migrate in bounded steps:

1. Repository/domain/mapper/DI and tests only.
2. `SponsorshipService`, where creation, acceptance, and deletion state transitions are centralized.
3. `SponsorshipsController`, preserving response shape and post-create lookup behavior.
4. Secondary read/delete callers: settings checks, transfer validation, cleanup service, responder/support paths.
5. Remove legacy DI access after production callers are gone.
6. Remove legacy CRUD/schema/tests after remaining test fixtures and mocks are migrated.

**Rationale**: Sponsorship service is the semantic owner of sponsorship state. Migrating it early lets the rest of the application consume the new domain shape without changing business rules.

**Alternative considered**: Big-bang replace every `sponsorship_crud` and `db.schema.sponsorship` import. Rejected because controller/service tests are numerous and the boundary is easier to review in milestones.

## Risks / Trade-offs

- **`accepted_at=None` is mistaken for "do not update"** -> Mitigation: mapper and repository tests must explicitly cover clearing/pending behavior and accepting behavior.
- **Fresh update objects can accidentally change `sponsored_at`** -> Mitigation: service migration should fetch the existing sponsorship and use dataclass replacement for acceptance changes.
- **Mixed DB/domain sponsorship types during migration** -> Mitigation: migrate one owner boundary at a time and keep focused tests green after each milestone.
- **Controller response shape changes accidentally** -> Mitigation: use sponsorship controller tests as API behavior canaries.
- **Cleanup or transfer restrictions regress** -> Mitigation: run focused cleanup and credit transfer tests after those callers migrate.

## Migration Plan

1. Add the sponsorship domain model, mapper, repository, DI property, and SQL test helper.
2. Add mapper and repository tests that mirror the existing CRUD behavior and specifically cover timestamp null semantics.
3. Migrate `SponsorshipService` to the repository and domain model.
4. Migrate `SponsorshipsController` and preserve exact response behavior.
5. Migrate secondary callers in settings, transfers, cleanup, and chat responder/support paths.
6. Remove legacy DI access to `sponsorship_crud` once production callers use the repository.
7. Replace remaining test fixtures/mocks that import `SponsorshipCRUD` or `db.schema.sponsorship`.
8. Remove legacy sponsorship CRUD/schema files and obsolete CRUD tests.
9. 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. Sponsorship state is internal-only, and `accepted_at` remains an intentional nullable domain field.
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
## Why

Sponsorship persistence still uses the legacy `db/schema` + `db/crud` pattern, which leaks SQLAlchemy/Pydantic persistence types into controllers, services, cleanup, transfer validation, and tests. After the chat config repository migration, sponsorships are a good next candidate because the domain is small, internal-only, and has clear state transitions.

## What Changes

- Add a feature-level sponsorship domain model, mapper, and repository beside the existing `SponsorshipDB` SQLAlchemy model.
- Keep `SponsorshipDB` as the single database model and preserve the existing `sponsorships` table, composite primary key, foreign keys, nullable `accepted_at`, and `sponsored_at` database default as a fallback.
- Model sponsorship state explicitly as a domain dataclass:
- `sponsor_id` and `receiver_id` are always required;
- `sponsored_at` defaults to the current application time and is non-null in domain code;
- `accepted_at` remains intentionally nullable and is written exactly as domain state.
- Add the new repository to DI, migrate production callers from `sponsorship_crud` / `db.schema.sponsorship`, then remove legacy CRUD/schema access after callers and tests are migrated.
- Preserve current sponsorship behavior for sponsoring, accepting, unsponsoring, fetch responses, sponsored-user transfer restrictions, stale cleanup, and settings/sponsorship checks.
- Keep legacy CRUD tests until repository behavior is covered and production callers are migrated.

## Capabilities

### New Capabilities

- `sponsorship-persistence`: Domain-model and repository behavior for creating, reading, updating, deleting, accepting, and cleaning up sponsorships without exposing SQLAlchemy DB models or legacy Pydantic persistence schemas to callers.

### Modified Capabilities

_(None — no existing sponsorship persistence spec.)_

## Impact

**Code**
- New feature-level sponsorship files under `src/features/sponsorships/` or an equivalent feature-local package.
- `src/di/di.py` gains `sponsorship_repo`; `sponsorship_crud` is removed from DI once production callers no longer use it.
- `test/db/sql_util.py` gains a `sponsorship_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, primary key, foreign key, default, or migration changes are intended.
- `src/db/model/sponsorship.py` remains the only SQLAlchemy representation for `sponsorships`.

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

**Tests**
- New mapper tests verify DB/domain round trips, `sponsored_at` domain default behavior, nullable `accepted_at` handling, and composite-key identity.
- New repository tests mirror existing `test/db/crud/test_sponsorship.py` behavior and cover create/update/save/delete/bulk cleanup semantics.
- Existing sponsorship controller/service, settings, transfer, cleanup, and responder tests remain behavior canaries through migration.
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
## ADDED Requirements

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

#### Scenario: Fetch by composite key returns domain model
- **WHEN** a sponsorship exists for a sponsor ID and receiver ID
- **THEN** the sponsorship repository returns a sponsorship domain dataclass with the persisted field values

#### Scenario: Missing composite key returns none
- **WHEN** no sponsorship exists for a sponsor ID and receiver ID
- **THEN** the sponsorship repository returns `None`

#### Scenario: Fetch by sponsor returns domain models
- **WHEN** one or more sponsorships exist for a sponsor ID
- **THEN** the sponsorship repository returns sponsorship domain dataclasses for that sponsor

#### Scenario: Fetch by receiver returns domain models
- **WHEN** one or more sponsorships exist for a receiver ID
- **THEN** the sponsorship repository returns sponsorship domain dataclasses for that receiver

### Requirement: Sponsorship save preserves timestamp semantics
The system SHALL preserve sponsorship timestamp behavior while replacing legacy `SponsorshipSave` persistence with a domain dataclass.

#### Scenario: Save inserts pending sponsorship
- **WHEN** the sponsorship repository saves a domain dataclass with sponsor ID, receiver ID, app-defaulted `sponsored_at`, and `accepted_at = None`
- **THEN** the repository inserts a `sponsorships` row with that `sponsored_at`
- **THEN** the repository returns a domain dataclass with the persisted `sponsored_at` and null `accepted_at`

#### Scenario: Save inserts accepted sponsorship
- **WHEN** the sponsorship repository saves a domain dataclass with a non-null `accepted_at`
- **THEN** the repository inserts or updates the row with that `accepted_at`
- **THEN** the repository returns a domain dataclass with the persisted `accepted_at`

#### Scenario: Save updates acceptance without changing sponsorship time
- **WHEN** the sponsorship repository saves a domain dataclass copied from an existing sponsorship with a changed `accepted_at`
- **THEN** the repository updates `accepted_at`
- **THEN** the repository preserves the copied `sponsored_at`

#### Scenario: Save can clear acceptance
- **WHEN** the sponsorship repository saves a domain dataclass for an existing sponsorship with `accepted_at = None`
- **THEN** the repository persists null `accepted_at`
- **THEN** the sponsorship is represented as pending

### Requirement: Sponsorship deletion behavior is preserved
The system SHALL preserve existing sponsorship deletion behavior while moving persistence behind the repository.

#### Scenario: Delete existing sponsorship returns deleted snapshot
- **WHEN** the sponsorship repository deletes an existing sponsorship by sponsor ID and receiver ID
- **THEN** it removes the row
- **THEN** it returns the deleted sponsorship as a domain dataclass

#### Scenario: Delete missing sponsorship returns none
- **WHEN** the sponsorship repository deletes a missing sponsorship by sponsor ID and receiver ID
- **THEN** it returns `None`

#### Scenario: Delete all by receiver returns deleted count
- **WHEN** the sponsorship repository deletes all sponsorships for a receiver ID
- **THEN** it removes those rows
- **THEN** it returns the number of rows deleted

#### Scenario: Delete stale pending sponsorships returns deleted count
- **WHEN** the sponsorship repository deletes unaccepted sponsorships older than a cutoff
- **THEN** it removes only pending sponsorships older than the cutoff
- **THEN** it keeps accepted sponsorships and fresh pending sponsorships
- **THEN** it returns the number of rows deleted

### Requirement: Production sponsorship callers use repository domain models
The system SHALL migrate production sponsorship callers from legacy sponsorship CRUD/schema usage to the repository without changing external behavior.

#### Scenario: Sponsorship service preserves business rules
- **WHEN** sponsorship creation, acceptance, unsponsoring, self-unsponsoring, or eligibility checks run through `SponsorshipService`
- **THEN** current sponsorship business rules and result messages remain unchanged
- **THEN** sponsorship persistence uses the repository and sponsorship domain dataclass

#### Scenario: Sponsorship controller preserves API behavior
- **WHEN** sponsorship API routes fetch, create, or delete sponsorships
- **THEN** route behavior, response shape, error behavior, and authorization behavior remain externally unchanged
- **THEN** sponsorship persistence uses repository domain models internally

#### Scenario: Secondary callers preserve behavior
- **WHEN** settings, transfer validation, cleanup, chat responder, or support paths check or delete sponsorships
- **THEN** their externally visible behavior remains unchanged
- **THEN** they use repository domain models instead of legacy sponsorship CRUD/schema objects

#### Scenario: DI exposes only repository access for production sponsorship persistence
- **WHEN** production callers no longer use legacy sponsorship CRUD
- **THEN** DI exposes `sponsorship_repo` for sponsorship persistence
- **THEN** DI no longer exposes `sponsorship_crud`

#### Scenario: Legacy CRUD removed only after references are gone
- **WHEN** no production or test code imports `sponsorship_crud`, `SponsorshipCRUD`, `db.schema.sponsorship`, or `SponsorshipSave`
- **THEN** the legacy sponsorship CRUD/schema files and obsolete CRUD tests may be removed
Loading
Loading