# Test Failure Analysis: Missing `deleted_at` Attribute on Note Model

**Date**: 2025-11-18
**Status**: Issue Identified - Architectural Guidance Provided
**Test**: `test_delete_without_confirmation_cancels` (tests/test_routes_admin.py:441)
**Error**: `AttributeError: 'Note' object has no attribute 'deleted_at'`

---

## Executive Summary

A test is failing because it expects the `Note` model to expose a `deleted_at` attribute, but this field is **not included in the Note dataclass definition** despite being present in the database schema. This is a **model-schema mismatch** issue.

**Root Cause**: The `deleted_at` column exists in the database (`starpunk/database.py:20`) but is not mapped to the `Note` dataclass (`starpunk/models.py:44-121`).

**Impact**:
- Test suite failure prevents CI/CD pipeline success
- Soft deletion feature is partially implemented but not fully exposed through the model layer
- Code that attempts to check deletion status will fail at runtime

**Recommended Fix**: Add `deleted_at` field to the Note dataclass definition

---

## Analysis

### 1. Database Schema Review

**File**: `starpunk/database.py:11-27`

The database schema **includes** a `deleted_at` column:

```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,           -- ← THIS FIELD EXISTS
    content_hash TEXT
);

CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
```

**Key Findings**:
- `deleted_at` is defined as a nullable TIMESTAMP column
- An index exists on `deleted_at` for query performance
- The schema supports soft deletion architecture

### 2. Note Model Review

**File**: `starpunk/models.py:44-121`

The Note dataclass **does not include** `deleted_at`:

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

    # Internal fields (not from database)
    _data_dir: Path = field(repr=False, compare=False)

    # Optional fields
    content_hash: Optional[str] = None
    # ← MISSING: deleted_at field
```

**Key Findings**:
- The model lists 6 "core fields from database" but only includes 6 of the 7 columns
- `deleted_at` is completely absent from the dataclass definition
- The `from_row()` class method (line 123-162) does not extract `deleted_at` from database rows

### 3. Notes Module Review

**File**: `starpunk/notes.py`

The notes module **uses** `deleted_at` in queries but **never exposes** it:

```python
# Line 358-364: get_note() filters by deleted_at
row = db.execute(
    "SELECT * FROM notes WHERE slug = ? AND deleted_at IS NULL", (slug,)
).fetchone()

# Line 494: list_notes() filters by deleted_at
query = "SELECT * FROM notes WHERE deleted_at IS NULL"

# Line 800-804: delete_note() sets deleted_at for soft deletes
db.execute(
    "UPDATE notes SET deleted_at = ? WHERE id = ?",
    (deleted_at, existing_note.id),
)
```

**Key Findings**:
- The application logic **knows about** `deleted_at`
- Queries correctly filter out soft-deleted notes (`deleted_at IS NULL`)
- Soft deletion is implemented by setting `deleted_at` to current timestamp
- However, the model layer **never reads this value back** from the database
- This creates a **semantic gap**: the database has the data, but the model can't access it

### 4. Failing Test Review

**File**: `tests/test_routes_admin.py:441`

The test expects to verify deletion status:

```python
def test_delete_without_confirmation_cancels(self, authenticated_client, sample_notes):
    """Test that delete without confirmation cancels operation"""

    # ... test logic ...

    # Verify note was NOT deleted (still exists)
    with authenticated_client.application.app_context():
        from starpunk.notes import get_note

        note = get_note(id=note_id)
        assert note is not None  # Note should still exist
        assert note.deleted_at is None  # NOT soft-deleted  ← FAILS HERE
```

**Key Findings**:
- Test wants to **explicitly verify** that a note is not soft-deleted
- This is a reasonable test - it validates business logic
- The test assumes `deleted_at` is accessible on the Note model
- Without the field, the test cannot verify soft-deletion status

---

## Architectural Assessment

### Why This Is a Problem

1. **Model-Schema Mismatch**: The fundamental rule of data models is that they should accurately represent the database schema. Currently, `Note` is incomplete.

2. **Information Hiding**: The application knows about soft deletion (it uses it), but the model layer hides this information from consumers. This violates the **principle of least surprise**.

3. **Testing Limitation**: Tests cannot verify soft-deletion behavior without accessing the field. This creates a testing blind spot.

4. **Future Maintenance**: Any code that needs to check deletion status (admin UI, API responses, debugging tools) will face the same issue.

### Why `deleted_at` Was Omitted

Looking at the git history and design patterns, I can infer the reasoning:

1. **Query-Level Filtering**: The developer chose to filter soft-deleted notes at the **query level** (`WHERE deleted_at IS NULL`), making `deleted_at` invisible to consumers.

2. **Encapsulation**: This follows a pattern of "consumers shouldn't need to know about deletion mechanics" - they just get active notes.

3. **Simplicity**: By excluding `deleted_at`, the model is simpler and consumers don't need to remember to filter it.

This is a **defensible design choice** for application code, but it creates problems for:
- Testing
- Admin interfaces (where you might want to show soft-deleted items)
- Debugging
- Data export/backup tools

### Design Principles at Stake

1. **Transparency vs Encapsulation**:
   - Encapsulation says: "Hide implementation details (soft deletion) from consumers"
   - Transparency says: "Expose database state accurately"
   - **Verdict**: For data models, transparency should win

2. **Data Integrity**:
   - The model should be a **faithful representation** of the database
   - Hiding fields creates a semantic mismatch
   - **Verdict**: Add the field

3. **Testability**:
   - Tests need to verify deletion behavior
   - Current design makes this impossible
   - **Verdict**: Add the field

---

## Architectural Decision

**Decision**: Add `deleted_at: Optional[datetime]` to the Note dataclass

**Rationale**:

1. **Principle of Least Surprise**: If a database column exists, developers expect to access it through the model

2. **Testability**: Tests must be able to verify soft-deletion state

3. **Transparency**: Data models should accurately reflect database schema

4. **Future Flexibility**: Admin UIs, backup tools, and debugging features will need this field

5. **Low Complexity Cost**: Adding one optional field is minimal complexity

6. **Backwards Compatibility**: The field is optional (nullable), so existing code won't break

**Trade-offs Accepted**:

- **Loss of Encapsulation**: Consumers now see "deleted_at" and must understand soft deletion
  - **Mitigation**: Document the field clearly; provide helper properties if needed

- **Slight Complexity Increase**: Model has one more field
  - **Impact**: Minimal - one line of code

---

## Implementation Plan

### Changes Required

**File**: `starpunk/models.py`

1. Add `deleted_at` field to Note dataclass (line ~109):

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

    # Internal fields (not from database)
    _data_dir: Path = field(repr=False, compare=False)

    # Optional fields
    content_hash: Optional[str] = None
```

2. Update `from_row()` class method to extract `deleted_at` (line ~145-162):

```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
        _data_dir=data_dir,
        content_hash=data.get("content_hash"),
    )
```

3. (Optional) Update `to_dict()` method to include `deleted_at` when serializing (line ~354-406):

```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, for API consistency)
    if self.deleted_at is not None:
        data["deleted_at"] = self.deleted_at.strftime("%Y-%m-%dT%H:%M:%SZ")

    # ... rest of method ...
```

4. Update docstring to document the field (line ~44-100):

```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
        content_hash: SHA-256 hash of content (for integrity checking)
        # ... rest of docstring ...
    """
```

### Testing Strategy

**Unit Tests**:

1. Verify `Note.from_row()` correctly parses `deleted_at` from database rows
2. Verify `deleted_at` defaults to `None` for active notes
3. Verify `deleted_at` is set to timestamp for soft-deleted notes
4. Verify `to_dict()` includes `deleted_at` when present

**Integration Tests**:

1. The failing test should pass: `test_delete_without_confirmation_cancels`
2. Verify soft-deleted notes have `deleted_at` set after `delete_note(soft=True)`
3. Verify `get_note()` returns `None` for soft-deleted notes (existing behavior)
4. Verify hard-deleted notes are removed entirely (existing behavior)

**Regression Tests**:

1. Run full test suite to ensure no existing tests break
2. Verify `list_notes()` still excludes soft-deleted notes
3. Verify `get_note()` still excludes soft-deleted notes

### Acceptance Criteria

- [ ] `deleted_at` field added to Note dataclass
- [ ] `from_row()` extracts `deleted_at` from database rows
- [ ] `from_row()` handles `deleted_at` as string (ISO format)
- [ ] `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
- [ ] No regression in existing functionality

---

## Alternative Approaches Considered

### Alternative 1: Update Test to Remove `deleted_at` Check

**Approach**: Change the test to not check `deleted_at`

```python
# Instead of:
assert note.deleted_at is None

# Use:
# (No check - just verify note exists)
assert note is not None
```

**Pros**:
- Minimal code change (one line)
- Maintains current encapsulation

**Cons**:
- **Weakens test coverage**: Can't verify note is truly not soft-deleted
- **Doesn't solve root problem**: Future code will hit the same issue
- **Violates test intent**: Test specifically wants to verify deletion status

**Verdict**: REJECTED - This is a band-aid, not a fix

### Alternative 2: Add Helper Property Instead of Raw Field

**Approach**: Keep `deleted_at` hidden, add `is_deleted` property

```python
@dataclass(frozen=True)
class Note:
    # ... existing fields ...
    _deleted_at: Optional[datetime] = field(default=None, repr=False)

    @property
    def is_deleted(self) -> bool:
        """Check if note is soft-deleted"""
        return self._deleted_at is not None
```

**Pros**:
- Provides boolean flag for deletion status
- Hides timestamp implementation detail
- Encapsulates deletion logic

**Cons**:
- **Information loss**: Tests/admin UIs can't see when note was deleted
- **Inconsistent with other models**: Session, Token, AuthState all expose timestamps
- **More complex**: Two fields instead of one
- **Harder to serialize**: Can't include deletion timestamp in API responses

**Verdict**: REJECTED - Adds complexity without clear benefit

### Alternative 3: Create Separate SoftDeletedNote Model

**Approach**: Use different model classes for active vs deleted notes

**Pros**:
- Type safety: Can't accidentally mix active and deleted notes
- Clear separation of concerns

**Cons**:
- **Massive complexity increase**: Two model classes, complex query logic
- **Violates simplicity principle**: Way over-engineered for the problem
- **Breaks existing code**: Would require rewriting note operations

**Verdict**: REJECTED - Far too complex for V1

---

## Risk Assessment

**Risk Level**: LOW

**Implementation Risks**:
- **Breaking Changes**: None - field is optional and nullable
- **Performance Impact**: None - no additional queries or processing
- **Security Impact**: None - field is read-only from model perspective

**Migration Risks**:
- **Database Migration**: None needed - column already exists
- **Data Backfill**: None needed - existing notes have NULL by default
- **API Compatibility**: Potential change if `to_dict()` includes `deleted_at`
  - **Mitigation**: Make inclusion optional or conditional

---

## Summary for Developer

**What to do**:
1. Add `deleted_at: Optional[datetime] = None` to Note dataclass
2. Update `from_row()` to extract and parse `deleted_at`
3. Update docstring to document the field
4. Run test suite to verify fix

**Why**:
- Database has `deleted_at` column but model doesn't expose it
- Tests need to verify soft-deletion status
- Models should accurately reflect database schema

**Complexity**: LOW (3 lines of code change)

**Time Estimate**: 5 minutes implementation + 2 minutes testing

**Files to modify**:
- `starpunk/models.py` (primary change)
- No migration needed (database already has column)
- No test changes needed (test is already correct)

---

## References

- Database Schema: `/home/phil/Projects/starpunk/starpunk/database.py:11-27`
- Note Model: `/home/phil/Projects/starpunk/starpunk/models.py:44-440`
- Notes Module: `/home/phil/Projects/starpunk/starpunk/notes.py:685-849`
- Failing Test: `/home/phil/Projects/starpunk/tests/test_routes_admin.py:435-441`

---

**Next Steps**:
1. Review this analysis with development team
2. Get approval for recommended fix
3. Implement changes to `starpunk/models.py`
4. Verify test passes
5. Document decision in ADR if desired
