Files
StarPunk/docs/decisions/ADR-013-expose-deleted-at-in-note-model.md
Phil Skentelbery 0cca8169ce feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation

Implemented complete web interface with public and admin routes,
templates, CSS, and development authentication.

### Core Features

**Public Routes**:
- Homepage with recent published notes
- Note permalinks with microformats2
- Server-side rendering (Jinja2)

**Admin Routes**:
- Login via IndieLogin
- Dashboard with note management
- Create, edit, delete notes
- Protected with @require_auth decorator

**Development Authentication**:
- Dev login bypass for local testing (DEV_MODE only)
- Security safeguards per ADR-011
- Returns 404 when disabled

**Templates & Frontend**:
- Base layouts (public + admin)
- 8 HTML templates with microformats2
- Custom responsive CSS (114 lines)
- Error pages (404, 500)

### Bugfixes (v0.5.1 → v0.5.2)

1. **Cookie collision fix (v0.5.1)**:
   - Renamed auth cookie from "session" to "starpunk_session"
   - Fixed redirect loop between dev login and admin dashboard
   - Flask's session cookie no longer conflicts with auth

2. **HTTP 404 error handling (v0.5.1)**:
   - Update route now returns 404 for nonexistent notes
   - Delete route now returns 404 for nonexistent notes
   - Follows ADR-012 HTTP Error Handling Policy
   - Pattern consistency across all admin routes

3. **Note model enhancement (v0.5.2)**:
   - Exposed deleted_at field from database schema
   - Enables soft deletion verification in tests
   - Follows ADR-013 transparency principle

### Architecture

**New ADRs**:
- ADR-011: Development Authentication Mechanism
- ADR-012: HTTP Error Handling Policy
- ADR-013: Expose deleted_at Field in Note Model

**Standards Compliance**:
- Uses uv for Python environment
- Black formatted, Flake8 clean
- Follows git branching strategy
- Version incremented per versioning strategy

### Test Results

- 405/406 tests passing (99.75%)
- 87% code coverage
- All security tests passing
- Manual testing confirmed working

### Documentation

- Complete implementation reports in docs/reports/
- Architecture reviews in docs/reviews/
- Design documents in docs/design/
- CHANGELOG updated for v0.5.2

### Files Changed

**New Modules**:
- starpunk/dev_auth.py
- starpunk/routes/ (public, admin, auth, dev_auth)

**Templates**: 10 files (base, pages, admin, errors)
**Static**: CSS and optional JavaScript
**Tests**: 4 test files for routes and templates
**Docs**: 20+ architectural and implementation documents

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 23:01:53 -07:00

384 lines
11 KiB
Markdown

# 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