# ADR-013: Expose deleted_at Field in Note Model ## Status Accepted ## Context The StarPunk application implements soft deletion for notes, using a `deleted_at` timestamp in the database to mark notes as deleted without physically removing them. However, there is a **model-schema mismatch**: the `deleted_at` column exists in the database schema but is not exposed as a field in the `Note` dataclass. ### Current State **Database Schema** (`starpunk/database.py`): ```sql CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, file_path TEXT UNIQUE NOT NULL, published BOOLEAN DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP, -- Column exists content_hash TEXT ); ``` **Note Model** (`starpunk/models.py`): ```python @dataclass(frozen=True) class Note: # Core fields from database id: int slug: str file_path: str published: bool created_at: datetime updated_at: datetime # deleted_at: MISSING ``` **Notes Module** (`starpunk/notes.py`): - Uses `deleted_at` in queries (`WHERE deleted_at IS NULL`) - Sets `deleted_at` during soft deletion (`UPDATE notes SET deleted_at = ?`) - Never exposes the value through the model layer ### Problem This architecture creates several issues: 1. **Testability Gap**: Tests cannot verify soft-deletion status because `note.deleted_at` doesn't exist 2. **Information Hiding**: The model hides database state from consumers 3. **Principle Violation**: Data models should faithfully represent database schema 4. **Future Limitations**: Admin UIs, debugging tools, and backup utilities cannot access deletion timestamps ### Immediate Trigger Test `test_delete_without_confirmation_cancels` fails with: ``` AttributeError: 'Note' object has no attribute 'deleted_at' ``` The test attempts to verify that a cancelled deletion does NOT set `deleted_at`: ```python note = get_note(id=note_id) assert note is not None assert note.deleted_at is None # ← Fails here ``` ## Decision **We will add `deleted_at: Optional[datetime]` as a field in the Note dataclass.** The field will be: - **Nullable**: `Optional[datetime] = None` - **Extracted** from database rows in `Note.from_row()` - **Documented** in the Note docstring - **Optionally serialized** in `Note.to_dict()` when present ## Rationale ### Why Add the Field 1. **Transparency Over Encapsulation** - For data models, transparency should win - Developers expect to access any database column through the model - Hiding fields creates semantic mismatches 2. **Testability** - Tests must be able to verify soft-deletion behavior - Current design makes deletion status verification impossible - Exposing the field enables proper test coverage 3. **Principle of Least Surprise** - If a database column exists, it should be accessible - Other models (Session, Token, AuthState) expose all their fields - Consistency across the codebase 4. **Future Flexibility** - Admin interfaces may need to show when notes were deleted - Data export/backup tools need complete state - Debugging requires visibility into deletion status 5. **Low Complexity Cost** - Adding one optional field is minimal complexity - No performance impact (no additional queries) - Backwards compatible (existing code won't break) ### Why NOT Use Alternative Approaches **Alternative 1: Fix the Test Only** - Weakens test coverage (can't verify deletion status) - Doesn't solve root problem (future code will hit same issue) - Rejected **Alternative 2: Add Helper Property (`is_deleted`)** - Loses information (can't see deletion timestamp) - Adds complexity (two fields instead of one) - Inconsistent with other models - Rejected **Alternative 3: Separate Model Class for Deleted Notes** - Massive complexity increase - Violates simplicity principle - Breaks existing code - Rejected ## Consequences ### Positive Consequences 1. **Test Suite Passes**: `test_delete_without_confirmation_cancels` will pass 2. **Complete Model**: Note model accurately reflects database schema 3. **Better Testability**: All tests can verify soft-deletion state 4. **Future-Proof**: Admin UIs and debugging tools have access to deletion data 5. **Consistency**: All models expose their database fields ### Negative Consequences 1. **Loss of Encapsulation**: Consumers now see `deleted_at` and must understand soft deletion - **Mitigation**: Document the field clearly in docstring - **Impact**: Minimal - developers working with notes should understand deletion 2. **Slight Complexity Increase**: Model has one more field - **Impact**: One line of code, negligible complexity ### Breaking Changes **None** - The field is optional and nullable, so: - Existing code that doesn't use `deleted_at` continues to work - `Note.from_row()` sets it to `None` for active notes - Serialization is optional ## Implementation Guidance ### File: `starpunk/models.py` #### Change 1: Add Field to Dataclass ```python @dataclass(frozen=True) class Note: """Represents a note/post""" # Core fields from database id: int slug: str file_path: str published: bool created_at: datetime updated_at: datetime deleted_at: Optional[datetime] = None # ← ADD THIS LINE # Internal fields (not from database) _data_dir: Path = field(repr=False, compare=False) # Optional fields content_hash: Optional[str] = None ``` #### Change 2: Update from_row() Method Add timestamp conversion for `deleted_at`: ```python @classmethod def from_row(cls, row: sqlite3.Row | dict[str, Any], data_dir: Path) -> "Note": # ... existing code ... # Convert timestamps if they are strings created_at = data["created_at"] if isinstance(created_at, str): created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) updated_at = data["updated_at"] if isinstance(updated_at, str): updated_at = datetime.fromisoformat(updated_at.replace("Z", "+00:00")) # ← ADD THIS BLOCK deleted_at = data.get("deleted_at") if deleted_at and isinstance(deleted_at, str): deleted_at = datetime.fromisoformat(deleted_at.replace("Z", "+00:00")) return cls( id=data["id"], slug=data["slug"], file_path=data["file_path"], published=bool(data["published"]), created_at=created_at, updated_at=updated_at, deleted_at=deleted_at, # ← ADD THIS LINE _data_dir=data_dir, content_hash=data.get("content_hash"), ) ``` #### Change 3: Update Docstring Add documentation for `deleted_at`: ```python @dataclass(frozen=True) class Note: """ Represents a note/post Attributes: id: Database ID (primary key) slug: URL-safe slug (unique) file_path: Path to markdown file (relative to data directory) published: Whether note is published (visible publicly) created_at: Creation timestamp (UTC) updated_at: Last update timestamp (UTC) deleted_at: Soft deletion timestamp (UTC, None if not deleted) # ← ADD THIS LINE content_hash: SHA-256 hash of content (for integrity checking) # ... rest of docstring ... """ ``` #### Change 4 (Optional): Update to_dict() Method Add `deleted_at` to serialization when present: ```python def to_dict( self, include_content: bool = False, include_html: bool = False ) -> dict[str, Any]: data = { "id": self.id, "slug": self.slug, "title": self.title, "published": self.published, "created_at": self.created_at.strftime("%Y-%m-%dT%H:%M:%SZ"), "updated_at": self.updated_at.strftime("%Y-%m-%dT%H:%M:%SZ"), "permalink": self.permalink, "excerpt": self.excerpt, } # ← ADD THIS BLOCK (optional) if self.deleted_at is not None: data["deleted_at"] = self.deleted_at.strftime("%Y-%m-%dT%H:%M:%SZ") if include_content: data["content"] = self.content if include_html: data["html"] = self.html return data ``` ### Testing Strategy #### Verification Steps 1. **Run Failing Test**: ```bash uv run pytest tests/test_routes_admin.py::TestDeleteRoute::test_delete_without_confirmation_cancels -v ``` Should pass after changes. 2. **Run Full Test Suite**: ```bash uv run pytest ``` Should pass with no regressions. 3. **Manual Verification**: ```python # Active note should have deleted_at = None note = get_note(slug="active-note") assert note.deleted_at is None # Soft-deleted note should have deleted_at set delete_note(slug="test-note", soft=True) # Note: get_note() filters out soft-deleted notes # To verify, query database directly or use admin interface ``` #### Expected Test Coverage - `deleted_at` is `None` for active notes - `deleted_at` is `None` for newly created notes - `deleted_at` is set after soft deletion (verify via database query) - `get_note()` returns `None` for soft-deleted notes (existing behavior) - `list_notes()` excludes soft-deleted notes (existing behavior) ### Acceptance Criteria - [ ] `deleted_at` field added to Note dataclass - [ ] `from_row()` extracts and parses `deleted_at` from database rows - [ ] `from_row()` handles `deleted_at` as ISO string - [ ] `from_row()` handles `deleted_at` as None (active notes) - [ ] Docstring updated to document `deleted_at` - [ ] Test `test_delete_without_confirmation_cancels` passes - [ ] Full test suite passes with no regressions - [ ] Optional: `to_dict()` includes `deleted_at` when present ## Alternatives Considered ### 1. Update Test to Remove deleted_at Check **Approach**: Modify test to not verify deletion status **Pros**: - One line change - Maintains current encapsulation **Cons**: - Weakens test coverage - Doesn't solve root problem - Violates test intent **Decision**: Rejected - Band-aid solution ### 2. Add Helper Property Instead of Raw Field **Approach**: Expose `is_deleted` boolean property, hide timestamp **Pros**: - Encapsulates implementation - Simple boolean interface **Cons**: - Loses deletion timestamp information - Inconsistent with other models - More complex than exposing field directly **Decision**: Rejected - Adds complexity without clear benefit ### 3. Create Separate SoftDeletedNote Model **Approach**: Use different classes for active vs deleted notes **Pros**: - Type safety - Clear separation **Cons**: - Massive complexity increase - Violates simplicity principle - Breaks existing code **Decision**: Rejected - Over-engineered for V1 ## References - **Test Failure Analysis**: `/home/phil/Projects/starpunk/docs/reports/test-failure-analysis-deleted-at-attribute.md` - **Database Schema**: `starpunk/database.py:11-27` - **Note Model**: `starpunk/models.py:44-440` - **Notes Module**: `starpunk/notes.py:685-849` - **Failing Test**: `tests/test_routes_admin.py:435-441` - **ADR-004**: File-Based Note Storage (discusses soft deletion design) ## Related Standards - **Data Model Design**: Models should faithfully represent database schema - **Testability Principle**: All business logic must be testable - **Principle of Least Surprise**: Developers expect database columns to be accessible - **Transparency vs Encapsulation**: For data models, transparency wins --- **Date**: 2025-11-18 **Author**: StarPunk Architect Agent **Status**: Accepted