# S3-Compatible Storage Backend Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add S3-compatible storage backend and refactor Supabase to use pure file-based storage (no database table).

**Architecture:** All storage backends use the same file pair pattern: `{session_name}/session.pickle` + `{session_name}/metadata.json`. S3 backend uses boto3. Supabase backend drops the `session_cache` database table and uses only Supabase Storage. This standardizes storage across local, S3, and Supabase.

**Tech Stack:** boto3, botocore (S3); supabase-py storage3 (Supabase refactor)

---

## Task 1: Create S3SessionStorage Class

**Files:**
- Create: `src/graftpunk/storage/s3.py`
- Test: `tests/unit/test_storage_s3.py`

### Step 1: Write the core test file structure

Create test file with fixtures and first test:

```python
"""Tests for S3 storage backend.

Note: These tests mock the boto3 client to avoid requiring actual
S3/R2 credentials in unit tests.
"""

from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch

import pytest

# Skip all tests in this module if boto3 is not installed
pytest.importorskip("boto3")

from graftpunk.exceptions import SessionNotFoundError, StorageError
from graftpunk.storage.base import SessionMetadata


@pytest.fixture
def sample_metadata():
    """Create sample session metadata."""
    now = datetime.now(UTC)
    return SessionMetadata(
        name="test-session",
        checksum="abc123",
        created_at=now,
        modified_at=now,
        expires_at=now + timedelta(hours=24),
        domain="example.com",
        current_url="https://example.com/dashboard",
        cookie_count=5,
        cookie_domains=["example.com", ".example.com"],
        status="active",
    )


@pytest.fixture
def mock_s3_client():
    """Create a mock boto3 S3 client."""
    return MagicMock()


@pytest.fixture
def storage(mock_s3_client):
    """Create S3SessionStorage with mocked client."""
    from graftpunk.storage.s3 import S3SessionStorage

    return S3SessionStorage(
        bucket="test-bucket",
        region="us-east-1",
        endpoint_url="https://test.r2.example.com",
        client=mock_s3_client,
    )


class TestS3SessionStorageInit:
    """Tests for S3SessionStorage initialization."""

    def test_storage_initialization_with_injected_client(self, mock_s3_client):
        """Test that storage initializes correctly with injected client."""
        from graftpunk.storage.s3 import S3SessionStorage

        storage = S3SessionStorage(
            bucket="test-bucket",
            endpoint_url="https://test.r2.example.com",
            client=mock_s3_client,
        )

        assert storage.bucket == "test-bucket"
        assert storage.endpoint_url == "https://test.r2.example.com"

    @patch("boto3.client")
    def test_storage_creates_client_when_not_injected(self, mock_boto_client):
        """Test that storage creates boto3 client when not injected."""
        from graftpunk.storage.s3 import S3SessionStorage

        mock_boto_client.return_value = MagicMock()

        storage = S3SessionStorage(
            bucket="test-bucket",
            region="us-west-2",
            endpoint_url="https://r2.example.com",
        )

        mock_boto_client.assert_called_once_with(
            "s3",
            region_name="us-west-2",
            endpoint_url="https://r2.example.com",
        )
        assert storage.bucket == "test-bucket"

    @patch("boto3.client")
    def test_storage_skips_region_when_auto(self, mock_boto_client):
        """Test that region='auto' is treated as no region (for R2)."""
        from graftpunk.storage.s3 import S3SessionStorage

        mock_boto_client.return_value = MagicMock()

        S3SessionStorage(
            bucket="test-bucket",
            region="auto",
            endpoint_url="https://r2.example.com",
        )

        mock_boto_client.assert_called_once_with(
            "s3",
            endpoint_url="https://r2.example.com",
        )
```

### Step 2: Run test to verify it fails

Run: `uv run pytest tests/unit/test_storage_s3.py -v -x`
Expected: FAIL with "No module named 'graftpunk.storage.s3'"

### Step 3: Write minimal S3SessionStorage implementation

```python
"""S3-compatible storage backend for graftpunk sessions.

Supports AWS S3, Cloudflare R2, MinIO, and other S3-compatible services.

Environment Variables:
    GRAFTPUNK_STORAGE_BACKEND: Set to "s3" to enable
    GRAFTPUNK_S3_BUCKET: Bucket name (required)
    GRAFTPUNK_S3_REGION: AWS region or "auto" for R2 (optional)
    GRAFTPUNK_S3_ENDPOINT_URL: Custom endpoint for R2/MinIO (optional)
    AWS_ACCESS_KEY_ID: S3 credentials (standard boto3)
    AWS_SECRET_ACCESS_KEY: S3 credentials (standard boto3)

Example R2 Configuration:
    export GRAFTPUNK_STORAGE_BACKEND=s3
    export GRAFTPUNK_S3_BUCKET=graftpunk-sessions
    export GRAFTPUNK_S3_ENDPOINT_URL=https://<account-id>.r2.cloudflarestorage.com
    export AWS_ACCESS_KEY_ID=<r2-access-key>
    export AWS_SECRET_ACCESS_KEY=<r2-secret-key>
"""

from __future__ import annotations

import json
import random
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from graftpunk.exceptions import SessionExpiredError, SessionNotFoundError, StorageError
from graftpunk.logging import get_logger
from graftpunk.storage.base import SessionMetadata, parse_datetime_iso

if TYPE_CHECKING:
    from mypy_boto3_s3 import S3Client

LOG = get_logger(__name__)


class S3SessionStorage:
    """S3-compatible storage backend for session persistence.

    Stores sessions as:
        {bucket}/{session_name}/session.pickle  (encrypted binary)
        {bucket}/{session_name}/metadata.json   (JSON metadata)

    Args:
        bucket: S3 bucket name
        region: AWS region (optional, use "auto" or None for R2)
        endpoint_url: Custom endpoint URL for R2/MinIO (optional)
        max_retries: Maximum retry attempts for transient failures
        base_delay: Base delay in seconds for exponential backoff
        client: Optional pre-configured boto3 S3 client (for testing)
    """

    def __init__(
        self,
        bucket: str,
        region: str | None = None,
        endpoint_url: str | None = None,
        max_retries: int = 5,
        base_delay: float = 1.0,
        client: "S3Client | None" = None,
    ) -> None:
        self.bucket = bucket
        self.region = region
        self.endpoint_url = endpoint_url
        self.max_retries = max_retries
        self.base_delay = base_delay

        if client is not None:
            self._client = client
        else:
            self._client = self._create_client()

        LOG.info(
            "s3_storage_initialized",
            bucket=bucket,
            region=region,
            endpoint_url=endpoint_url,
        )

    def _create_client(self) -> "S3Client":
        """Create boto3 S3 client with configuration."""
        try:
            import boto3
        except ImportError as e:
            raise StorageError(
                "boto3 is required for S3 storage backend. "
                "Install with: pip install graftpunk[s3]"
            ) from e

        client_kwargs: dict[str, Any] = {}
        if self.region and self.region != "auto":
            client_kwargs["region_name"] = self.region
        if self.endpoint_url:
            client_kwargs["endpoint_url"] = self.endpoint_url

        return boto3.client("s3", **client_kwargs)

    def _session_key(self, name: str) -> str:
        return f"{name}/session.pickle"

    def _metadata_key(self, name: str) -> str:
        return f"{name}/metadata.json"
```

### Step 4: Run test to verify it passes

Run: `uv run pytest tests/unit/test_storage_s3.py::TestS3SessionStorageInit -v`
Expected: PASS

### Step 5: Commit

```bash
git add src/graftpunk/storage/s3.py tests/unit/test_storage_s3.py
git commit -m "feat(storage): add S3SessionStorage class skeleton

Add initial S3-compatible storage backend with:
- Constructor accepting bucket, region, endpoint_url
- Client injection for testing
- Region='auto' handling for Cloudflare R2

Co-authored-by: stavxyz <hi@stav.xyz>
Co-authored-by: Claude <noreply@anthropic.com>"
```

---

## Task 2: Implement save_session and load_session

**Files:**
- Modify: `src/graftpunk/storage/s3.py`
- Modify: `tests/unit/test_storage_s3.py`

### Step 1: Add tests for save_session

Add to `tests/unit/test_storage_s3.py`:

```python
class TestSaveSession:
    """Tests for save_session method."""

    def test_save_session_uploads_pickle_and_metadata(
        self, storage, mock_s3_client, sample_metadata
    ):
        """Test that save uploads both pickle and metadata files."""
        encrypted_data = b"encrypted-pickle-data"

        location = storage.save_session("test-session", encrypted_data, sample_metadata)

        assert location == "s3://test-bucket/test-session/session.pickle"
        assert mock_s3_client.put_object.call_count == 2

        # Verify pickle upload
        pickle_call = mock_s3_client.put_object.call_args_list[0]
        assert pickle_call.kwargs["Bucket"] == "test-bucket"
        assert pickle_call.kwargs["Key"] == "test-session/session.pickle"
        assert pickle_call.kwargs["Body"] == encrypted_data
        assert pickle_call.kwargs["ContentType"] == "application/octet-stream"

        # Verify metadata upload
        metadata_call = mock_s3_client.put_object.call_args_list[1]
        assert metadata_call.kwargs["Key"] == "test-session/metadata.json"
        assert metadata_call.kwargs["ContentType"] == "application/json"


class TestLoadSession:
    """Tests for load_session method."""

    def test_load_session_success(self, storage, mock_s3_client, sample_metadata):
        """Test successful session load."""
        # Mock metadata response
        metadata_dict = {
            "name": "test-session",
            "checksum": "abc123",
            "created_at": sample_metadata.created_at.isoformat(),
            "modified_at": sample_metadata.modified_at.isoformat(),
            "expires_at": sample_metadata.expires_at.isoformat(),
            "domain": "example.com",
            "current_url": "https://example.com/dashboard",
            "cookie_count": 5,
            "cookie_domains": ["example.com", ".example.com"],
            "status": "active",
        }
        mock_metadata_body = MagicMock()
        mock_metadata_body.read.return_value = json.dumps(metadata_dict).encode()

        mock_session_body = MagicMock()
        mock_session_body.read.return_value = b"encrypted-data"

        def get_object_side_effect(**kwargs):
            if kwargs["Key"].endswith("metadata.json"):
                return {"Body": mock_metadata_body}
            return {"Body": mock_session_body}

        mock_s3_client.get_object.side_effect = get_object_side_effect

        data, metadata = storage.load_session("test-session")

        assert data == b"encrypted-data"
        assert metadata.name == "test-session"
        assert metadata.domain == "example.com"

    def test_load_session_not_found(self, storage, mock_s3_client):
        """Test load raises SessionNotFoundError when metadata not found."""
        from botocore.exceptions import ClientError

        error_response = {"Error": {"Code": "NoSuchKey"}, "ResponseMetadata": {"HTTPStatusCode": 404}}
        mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject")

        with pytest.raises(SessionNotFoundError, match="not found"):
            storage.load_session("nonexistent")

    def test_load_session_expired_ttl(self, storage, mock_s3_client):
        """Test load raises SessionExpiredError when TTL has passed."""
        now = datetime.now(UTC)
        past = now - timedelta(hours=24)

        metadata_dict = {
            "name": "expired-session",
            "checksum": "abc",
            "created_at": (now - timedelta(hours=48)).isoformat(),
            "modified_at": (now - timedelta(hours=48)).isoformat(),
            "expires_at": past.isoformat(),
            "domain": "example.com",
            "current_url": None,
            "cookie_count": 0,
            "cookie_domains": [],
            "status": "active",
        }
        mock_body = MagicMock()
        mock_body.read.return_value = json.dumps(metadata_dict).encode()
        mock_s3_client.get_object.return_value = {"Body": mock_body}

        with pytest.raises(SessionExpiredError, match="expired"):
            storage.load_session("expired-session")
```

### Step 2: Run tests to verify they fail

Run: `uv run pytest tests/unit/test_storage_s3.py::TestSaveSession -v -x`
Expected: FAIL with "AttributeError: 'S3SessionStorage' object has no attribute 'save_session'"

### Step 3: Implement save_session and load_session

Add to `src/graftpunk/storage/s3.py`:

```python
    def save_session(
        self,
        name: str,
        encrypted_data: bytes,
        metadata: SessionMetadata,
    ) -> str:
        """Save encrypted session data and metadata to S3."""
        session_key = self._session_key(name)
        metadata_key = self._metadata_key(name)

        # Save encrypted session data
        self._client.put_object(
            Bucket=self.bucket,
            Key=session_key,
            Body=encrypted_data,
            ContentType="application/octet-stream",
        )

        # Save metadata as JSON
        metadata_json = json.dumps(self._metadata_to_dict(metadata), indent=2)
        self._client.put_object(
            Bucket=self.bucket,
            Key=metadata_key,
            Body=metadata_json.encode("utf-8"),
            ContentType="application/json",
        )

        location = f"s3://{self.bucket}/{session_key}"
        LOG.info("session_saved", name=name, location=location)
        return location

    def load_session(self, name: str) -> tuple[bytes, SessionMetadata]:
        """Load encrypted session data and metadata from S3."""
        from botocore.exceptions import ClientError

        metadata_key = self._metadata_key(name)
        session_key = self._session_key(name)

        # Load metadata first (to check TTL before downloading large pickle)
        try:
            metadata_response = self._client.get_object(
                Bucket=self.bucket,
                Key=metadata_key,
            )
            metadata_json = metadata_response["Body"].read().decode("utf-8")
            metadata = self._dict_to_metadata(json.loads(metadata_json))
        except ClientError as e:
            error_code = e.response.get("Error", {}).get("Code", "")
            if error_code in ("NoSuchKey", "404"):
                raise SessionNotFoundError(f"Session '{name}' not found") from e
            raise StorageError(f"Failed to load session '{name}': {e}") from e

        # Check TTL
        if metadata.expires_at and datetime.now(UTC) > metadata.expires_at:
            raise SessionExpiredError(
                f"Session '{name}' expired at {metadata.expires_at.isoformat()}"
            )

        # Load encrypted session data
        try:
            session_response = self._client.get_object(
                Bucket=self.bucket,
                Key=session_key,
            )
            encrypted_data = session_response["Body"].read()
        except ClientError as e:
            error_code = e.response.get("Error", {}).get("Code", "")
            if error_code in ("NoSuchKey", "404"):
                raise SessionNotFoundError(
                    f"Session '{name}' data not found (metadata exists)"
                ) from e
            raise StorageError(f"Failed to load session '{name}': {e}") from e

        LOG.info("session_loaded", name=name, size=len(encrypted_data))
        return encrypted_data, metadata

    def _metadata_to_dict(self, metadata: SessionMetadata) -> dict[str, Any]:
        """Convert SessionMetadata to JSON-serializable dict."""
        return {
            "name": metadata.name,
            "checksum": metadata.checksum,
            "created_at": metadata.created_at.isoformat(),
            "modified_at": metadata.modified_at.isoformat(),
            "expires_at": metadata.expires_at.isoformat() if metadata.expires_at else None,
            "domain": metadata.domain,
            "current_url": metadata.current_url,
            "cookie_count": metadata.cookie_count,
            "cookie_domains": metadata.cookie_domains,
            "status": metadata.status,
        }

    def _dict_to_metadata(self, data: dict[str, Any]) -> SessionMetadata:
        """Convert dict to SessionMetadata."""
        return SessionMetadata(
            name=data.get("name", ""),
            checksum=data.get("checksum", ""),
            created_at=parse_datetime_iso(data.get("created_at")) or datetime.now(UTC),
            modified_at=parse_datetime_iso(data.get("modified_at")) or datetime.now(UTC),
            expires_at=parse_datetime_iso(data.get("expires_at")),
            domain=data.get("domain"),
            current_url=data.get("current_url"),
            cookie_count=data.get("cookie_count", 0),
            cookie_domains=data.get("cookie_domains", []),
            status=data.get("status", "active"),
        )
```

### Step 4: Run tests to verify they pass

Run: `uv run pytest tests/unit/test_storage_s3.py::TestSaveSession tests/unit/test_storage_s3.py::TestLoadSession -v`
Expected: PASS

### Step 5: Commit

```bash
git add src/graftpunk/storage/s3.py tests/unit/test_storage_s3.py
git commit -m "feat(storage): implement S3 save_session and load_session

Add core session persistence methods:
- save_session: uploads pickle + metadata.json to S3
- load_session: downloads and validates TTL before returning data
- Metadata serialization helpers

Co-authored-by: stavxyz <hi@stav.xyz>
Co-authored-by: Claude <noreply@anthropic.com>"
```

---

## Task 3: Implement list_sessions, delete_session, metadata methods

**Files:**
- Modify: `src/graftpunk/storage/s3.py`
- Modify: `tests/unit/test_storage_s3.py`

### Step 1: Add tests for remaining methods

Add to `tests/unit/test_storage_s3.py`:

```python
class TestListSessions:
    """Tests for list_sessions method."""

    def test_list_sessions_empty(self, storage, mock_s3_client):
        """Test listing sessions when none exist."""
        mock_paginator = MagicMock()
        mock_paginator.paginate.return_value = [{"Contents": []}]
        mock_s3_client.get_paginator.return_value = mock_paginator

        sessions = storage.list_sessions()
        assert sessions == []

    def test_list_sessions_with_results(self, storage, mock_s3_client):
        """Test listing sessions with results."""
        mock_paginator = MagicMock()
        mock_paginator.paginate.return_value = [
            {
                "Contents": [
                    {"Key": "session-b/session.pickle"},
                    {"Key": "session-b/metadata.json"},
                    {"Key": "session-a/session.pickle"},
                    {"Key": "session-a/metadata.json"},
                ]
            }
        ]
        mock_s3_client.get_paginator.return_value = mock_paginator

        sessions = storage.list_sessions()
        assert sessions == ["session-a", "session-b"]


class TestDeleteSession:
    """Tests for delete_session method."""

    def test_delete_session_success(self, storage, mock_s3_client):
        """Test successful session deletion."""
        mock_s3_client.delete_object.return_value = {}

        result = storage.delete_session("test-session")

        assert result is True
        assert mock_s3_client.delete_object.call_count == 2


class TestGetSessionMetadata:
    """Tests for get_session_metadata method."""

    def test_get_session_metadata_not_found(self, storage, mock_s3_client):
        """Test getting metadata for non-existent session."""
        from botocore.exceptions import ClientError

        error_response = {"Error": {"Code": "NoSuchKey"}, "ResponseMetadata": {"HTTPStatusCode": 404}}
        mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject")

        metadata = storage.get_session_metadata("non-existent")
        assert metadata is None

    def test_get_session_metadata_success(self, storage, mock_s3_client, sample_metadata):
        """Test getting metadata for existing session."""
        metadata_dict = {
            "name": "my-session",
            "checksum": "sha256abc",
            "created_at": sample_metadata.created_at.isoformat(),
            "modified_at": sample_metadata.modified_at.isoformat(),
            "expires_at": None,
            "domain": "example.com",
            "current_url": "https://example.com",
            "cookie_count": 3,
            "cookie_domains": ["example.com"],
            "status": "active",
        }
        mock_body = MagicMock()
        mock_body.read.return_value = json.dumps(metadata_dict).encode()
        mock_s3_client.get_object.return_value = {"Body": mock_body}

        metadata = storage.get_session_metadata("my-session")
        assert metadata is not None
        assert metadata.name == "my-session"
        assert metadata.domain == "example.com"


class TestUpdateSessionMetadata:
    """Tests for update_session_metadata method."""

    def test_update_session_metadata_invalid_status(self, storage):
        """Test that invalid status raises ValueError."""
        with pytest.raises(ValueError, match="Invalid status"):
            storage.update_session_metadata("test", status="invalid-status")

    def test_update_session_metadata_success(self, storage, mock_s3_client, sample_metadata):
        """Test successful metadata update."""
        # Mock get_object for reading existing metadata
        metadata_dict = {
            "name": "test",
            "checksum": "abc",
            "created_at": sample_metadata.created_at.isoformat(),
            "modified_at": sample_metadata.modified_at.isoformat(),
            "expires_at": None,
            "domain": None,
            "current_url": None,
            "cookie_count": 0,
            "cookie_domains": [],
            "status": "active",
        }
        mock_body = MagicMock()
        mock_body.read.return_value = json.dumps(metadata_dict).encode()
        mock_s3_client.get_object.return_value = {"Body": mock_body}

        result = storage.update_session_metadata("test", status="logged_out")
        assert result is True
        mock_s3_client.put_object.assert_called_once()

    def test_update_session_metadata_not_found(self, storage, mock_s3_client):
        """Test update returns False when session not found."""
        from botocore.exceptions import ClientError

        error_response = {"Error": {"Code": "NoSuchKey"}, "ResponseMetadata": {"HTTPStatusCode": 404}}
        mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject")

        result = storage.update_session_metadata("nonexistent", status="active")
        assert result is False
```

### Step 2: Run tests to verify they fail

Run: `uv run pytest tests/unit/test_storage_s3.py::TestListSessions -v -x`
Expected: FAIL with "AttributeError: 'S3SessionStorage' object has no attribute 'list_sessions'"

### Step 3: Implement remaining methods

Add to `src/graftpunk/storage/s3.py`:

```python
    def list_sessions(self) -> list[str]:
        """List all session names in the bucket."""
        sessions: set[str] = set()
        paginator = self._client.get_paginator("list_objects_v2")

        try:
            for page in paginator.paginate(Bucket=self.bucket):
                for obj in page.get("Contents", []):
                    key = obj["Key"]
                    # Extract session name from path: {name}/session.pickle or {name}/metadata.json
                    if "/" in key:
                        session_name = key.split("/")[0]
                        sessions.add(session_name)
        except Exception as e:
            LOG.warning("list_sessions_failed", error=str(e))
            return []

        return sorted(sessions)

    def delete_session(self, name: str) -> bool:
        """Delete session data and metadata from S3."""
        session_key = self._session_key(name)
        metadata_key = self._metadata_key(name)

        deleted = False
        for key in (session_key, metadata_key):
            try:
                self._client.delete_object(Bucket=self.bucket, Key=key)
                deleted = True
            except Exception as e:
                LOG.debug("delete_object_failed", key=key, error=str(e))

        if deleted:
            LOG.info("session_deleted", name=name)
        return deleted

    def get_session_metadata(self, name: str) -> SessionMetadata | None:
        """Get session metadata without loading session data."""
        from botocore.exceptions import ClientError

        metadata_key = self._metadata_key(name)

        try:
            response = self._client.get_object(Bucket=self.bucket, Key=metadata_key)
            metadata_json = response["Body"].read().decode("utf-8")
            return self._dict_to_metadata(json.loads(metadata_json))
        except ClientError:
            return None
        except Exception as e:
            LOG.warning("get_session_metadata_failed", name=name, error=str(e))
            return None

    def update_session_metadata(
        self,
        name: str,
        status: str | None = None,
    ) -> bool:
        """Update session metadata (currently only status field)."""
        if status is not None and status not in ("active", "logged_out"):
            raise ValueError(f"Invalid status: {status}. Must be 'active' or 'logged_out'")

        metadata = self.get_session_metadata(name)
        if metadata is None:
            return False

        from dataclasses import replace

        updates: dict[str, Any] = {"modified_at": datetime.now(UTC)}
        if status is not None:
            updates["status"] = status

        new_metadata = replace(metadata, **updates)
        metadata_key = self._metadata_key(name)
        metadata_json = json.dumps(self._metadata_to_dict(new_metadata), indent=2)

        try:
            self._client.put_object(
                Bucket=self.bucket,
                Key=metadata_key,
                Body=metadata_json.encode("utf-8"),
                ContentType="application/json",
            )
            LOG.info("metadata_updated", name=name, status=status)
            return True
        except Exception as e:
            LOG.error("metadata_update_failed", name=name, error=str(e))
            return False
```

### Step 4: Run tests to verify they pass

Run: `uv run pytest tests/unit/test_storage_s3.py -v`
Expected: PASS

### Step 5: Commit

```bash
git add src/graftpunk/storage/s3.py tests/unit/test_storage_s3.py
git commit -m "feat(storage): complete S3SessionStorage implementation

Add remaining SessionStorageBackend protocol methods:
- list_sessions: paginated S3 listing
- delete_session: removes both pickle and metadata
- get_session_metadata: lightweight metadata fetch
- update_session_metadata: status updates

Co-authored-by: stavxyz <hi@stav.xyz>
Co-authored-by: Claude <noreply@anthropic.com>"
```

---

## Task 4: Add retry logic with exponential backoff

**Files:**
- Modify: `src/graftpunk/storage/s3.py`
- Modify: `tests/unit/test_storage_s3.py`

### Step 1: Add retry tests

Add to `tests/unit/test_storage_s3.py`:

```python
class TestRetryLogic:
    """Tests for retry logic in S3 storage."""

    @patch("graftpunk.storage.s3.time.sleep")
    def test_save_session_retries_on_server_error(
        self, mock_sleep, storage, mock_s3_client, sample_metadata
    ):
        """Test that 5xx errors trigger retries."""
        from botocore.exceptions import ClientError

        storage.max_retries = 3
        storage.base_delay = 0.01

        error_response = {"Error": {"Code": "500"}, "ResponseMetadata": {"HTTPStatusCode": 500}}
        mock_s3_client.put_object.side_effect = ClientError(error_response, "PutObject")

        with pytest.raises(StorageError, match="after 3 attempts"):
            storage.save_session("test", b"data", sample_metadata)

        # Should have retried
        assert mock_s3_client.put_object.call_count == 3
        assert mock_sleep.call_count == 2  # (max_retries - 1) sleeps

    def test_save_session_no_retry_on_client_error(
        self, storage, mock_s3_client, sample_metadata
    ):
        """Test that 4xx errors don't trigger retries."""
        from botocore.exceptions import ClientError

        error_response = {"Error": {"Code": "400"}, "ResponseMetadata": {"HTTPStatusCode": 400}}
        mock_s3_client.put_object.side_effect = ClientError(error_response, "PutObject")

        with pytest.raises(StorageError):
            storage.save_session("test", b"data", sample_metadata)

        # Should not have retried
        assert mock_s3_client.put_object.call_count == 1

    @patch("graftpunk.storage.s3.time.sleep")
    def test_retry_on_throttling(self, mock_sleep, storage, mock_s3_client, sample_metadata):
        """Test retry on SlowDown/Throttling errors."""
        from botocore.exceptions import ClientError

        storage.max_retries = 2

        # First call fails with throttling, second succeeds
        error_response = {"Error": {"Code": "SlowDown"}, "ResponseMetadata": {"HTTPStatusCode": 503}}
        mock_s3_client.put_object.side_effect = [
            ClientError(error_response, "PutObject"),
            {},  # Success
            {},  # Metadata upload
        ]

        storage.save_session("test", b"data", sample_metadata)
        assert mock_s3_client.put_object.call_count == 3  # retry + 2 uploads
```

### Step 2: Run tests to verify they fail

Run: `uv run pytest tests/unit/test_storage_s3.py::TestRetryLogic -v -x`
Expected: FAIL (current implementation doesn't have retry logic)

### Step 3: Add retry wrapper to save_session

Update `src/graftpunk/storage/s3.py` - wrap save_session with retry:

```python
    def _with_retry(self, operation: str, func, *args, **kwargs):
        """Execute function with exponential backoff retry."""
        from botocore.exceptions import ClientError

        last_exception = None
        for attempt in range(self.max_retries):
            try:
                return func(*args, **kwargs)
            except ClientError as e:
                last_exception = e
                error_code = e.response.get("Error", {}).get("Code", "")
                status_code = e.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 0)

                # Non-retryable errors (4xx except throttling)
                if status_code < 500 and error_code not in ("Throttling", "SlowDown"):
                    raise StorageError(f"{operation} failed: {e}") from e

                # Retryable - exponential backoff with jitter
                delay = min(self.base_delay * (2**attempt), 30.0)
                delay = delay * (0.5 + random.random() * 0.5)  # noqa: S311

                LOG.warning(
                    "s3_operation_retry",
                    operation=operation,
                    attempt=attempt + 1,
                    max_attempts=self.max_retries,
                    delay=delay,
                    error=str(e),
                )

                if attempt < self.max_retries - 1:
                    time.sleep(delay)
            except (ConnectionError, TimeoutError, OSError) as e:
                last_exception = e
                delay = min(self.base_delay * (2**attempt), 30.0)
                delay = delay * (0.5 + random.random() * 0.5)  # noqa: S311

                LOG.warning(
                    "s3_connection_retry",
                    operation=operation,
                    attempt=attempt + 1,
                    delay=delay,
                    error=str(e),
                )

                if attempt < self.max_retries - 1:
                    time.sleep(delay)

        raise StorageError(
            f"{operation} failed after {self.max_retries} attempts"
        ) from last_exception

    def save_session(
        self,
        name: str,
        encrypted_data: bytes,
        metadata: SessionMetadata,
    ) -> str:
        """Save encrypted session data and metadata to S3."""
        session_key = self._session_key(name)
        metadata_key = self._metadata_key(name)

        # Save encrypted session data with retry
        self._with_retry(
            "save_session_data",
            self._client.put_object,
            Bucket=self.bucket,
            Key=session_key,
            Body=encrypted_data,
            ContentType="application/octet-stream",
        )

        # Save metadata as JSON with retry
        metadata_json = json.dumps(self._metadata_to_dict(metadata), indent=2)
        self._with_retry(
            "save_session_metadata",
            self._client.put_object,
            Bucket=self.bucket,
            Key=metadata_key,
            Body=metadata_json.encode("utf-8"),
            ContentType="application/json",
        )

        location = f"s3://{self.bucket}/{session_key}"
        LOG.info("session_saved", name=name, location=location)
        return location
```

### Step 4: Run tests to verify they pass

Run: `uv run pytest tests/unit/test_storage_s3.py::TestRetryLogic -v`
Expected: PASS

### Step 5: Commit

```bash
git add src/graftpunk/storage/s3.py tests/unit/test_storage_s3.py
git commit -m "feat(storage): add retry logic with exponential backoff to S3

Add _with_retry wrapper for transient failure handling:
- Retries on 5xx errors, SlowDown, Throttling
- No retry on 4xx client errors
- Exponential backoff with jitter (max 30s)
- Configurable max_retries and base_delay

Co-authored-by: stavxyz <hi@stav.xyz>
Co-authored-by: Claude <noreply@anthropic.com>"
```

---

## Task 5: Wire up S3 backend in cache.py

**Files:**
- Modify: `src/graftpunk/cache.py:82-112`

### Step 1: Add test for S3 backend selection

Add to an existing cache test file or create inline test:

```python
# In tests/unit/test_cache.py or inline verification
def test_s3_backend_selection():
    """Verify S3 backend is wired up correctly."""
    # This would require mocking settings, tested via integration
    pass
```

### Step 2: Update _get_session_storage_backend

Modify `src/graftpunk/cache.py` line ~96 to add S3 case:

```python
def _get_session_storage_backend() -> "SessionStorageBackend":
    """Get or create the session storage backend.

    Returns the appropriate backend based on GRAFTPUNK_STORAGE_BACKEND env var:
    - "local" (default): Returns LocalSessionStorage
    - "supabase": Returns SupabaseSessionStorage
    - "s3": Returns S3SessionStorage
    """
    global _session_storage_backend

    if _session_storage_backend is not None:
        return _session_storage_backend

    settings = get_settings()

    if settings.storage_backend == "supabase":
        from graftpunk.storage.supabase import SupabaseSessionStorage

        config = settings.get_storage_config()
        _session_storage_backend = SupabaseSessionStorage(
            url=config["url"],
            service_key=config["service_key"],
            bucket_name=config.get("bucket_name", "sessions"),
        )
    elif settings.storage_backend == "s3":
        from graftpunk.storage.s3 import S3SessionStorage

        config = settings.get_storage_config()
        _session_storage_backend = S3SessionStorage(
            bucket=config["bucket"],
            region=config.get("region"),
            endpoint_url=config.get("endpoint_url"),
            max_retries=config.get("retry_max_attempts", 5),
            base_delay=config.get("retry_base_delay", 1.0),
        )
    else:
        # Default: local filesystem backend
        from graftpunk.storage.local import LocalSessionStorage

        _session_storage_backend = LocalSessionStorage(base_dir=settings.sessions_dir)

    return _session_storage_backend
```

### Step 3: Update module docstring

Update the docstring at the top of cache.py:

```python
"""Session caching and persistence using dill (enhanced pickle).

This module provides session storage functionality with pluggable backends:
- Local filesystem (default): ~/.config/graftpunk/sessions/
- Supabase (GRAFTPUNK_STORAGE_BACKEND=supabase): Supabase Storage
- S3 (GRAFTPUNK_STORAGE_BACKEND=s3): S3-compatible storage (AWS, R2, MinIO)

Backend selection is automatic based on GRAFTPUNK_STORAGE_BACKEND environment variable.
...
"""
```

### Step 4: Run full test suite

Run: `uv run pytest tests/ -v --tb=short`
Expected: PASS

### Step 5: Commit

```bash
git add src/graftpunk/cache.py
git commit -m "feat(cache): wire up S3 storage backend selection

Add S3 backend to _get_session_storage_backend():
- Lazy-loads S3SessionStorage when GRAFTPUNK_STORAGE_BACKEND=s3
- Passes bucket, region, endpoint_url, retry config
- Update docstring to document S3 option

Co-authored-by: stavxyz <hi@stav.xyz>
Co-authored-by: Claude <noreply@anthropic.com>"
```

---

## Task 6: Update pyproject.toml with S3 optional dependency

**Files:**
- Modify: `pyproject.toml`

### Step 1: Add s3 optional dependency

Add to `[project.optional-dependencies]` section:

```toml
s3 = [
    "boto3>=1.34.0",
]
```

Update the `all` dependency to include s3:

```toml
all = [
    "graftpunk[supabase,s3]",
]
```

### Step 2: Verify installation works

Run: `uv sync --all-extras`
Expected: boto3 installed

### Step 3: Commit

```bash
git add pyproject.toml
git commit -m "build: add boto3 as optional s3 dependency

Add [s3] optional dependency group for S3-compatible storage:
- boto3>=1.34.0
- Include in [all] extras

Install with: pip install graftpunk[s3]

Co-authored-by: stavxyz <hi@stav.xyz>
Co-authored-by: Claude <noreply@anthropic.com>"
```

---

## Task 7: Refactor Supabase to pure file-based storage (breaking change)

**Files:**
- Modify: `src/graftpunk/storage/supabase.py`
- Modify: `tests/unit/test_storage_supabase.py`

### Step 1: Simplify SupabaseSessionStorage

Rewrite to use only Supabase Storage (no database table). The new implementation will:
- Store `{session_name}/session.pickle` and `{session_name}/metadata.json` in the bucket
- Remove all `client.table()` calls
- Remove `_upsert_metadata`, `_row_to_metadata` (replaced with file-based equivalents)
- Keep the same pattern as S3 and local backends

This is a breaking change but acceptable for pre-release.

### Step 2: Update tests to match new implementation

Remove all database-related tests:
- Remove `_make_table_chain` helper
- Remove tests for `_upsert_metadata`, `_row_to_metadata`
- Update save/load tests to use Storage-only mocks

### Step 3: Run tests

Run: `uv run pytest tests/unit/test_storage_supabase.py -v`
Expected: PASS

### Step 4: Commit

```bash
git add src/graftpunk/storage/supabase.py tests/unit/test_storage_supabase.py
git commit -m "refactor(storage)!: Supabase uses pure file-based storage

BREAKING CHANGE: Supabase backend no longer uses session_cache database table.

Migrate to file-pair pattern matching local and S3 backends:
- {session_name}/session.pickle + metadata.json in Storage bucket
- Remove all database table operations
- Simplify implementation to match other backends

Users with existing Supabase sessions will need to re-login.

Co-authored-by: stavxyz <hi@stav.xyz>
Co-authored-by: Claude <noreply@anthropic.com>"
```

---

## Task 8: Documentation and final verification

**Files:**
- Modify: `docs/HOW_IT_WORKS.md` (if storage section exists)
- Create: Update README or add storage docs

### Step 1: Run full test suite

Run: `uv run pytest tests/ -v --tb=short`
Expected: All tests pass

### Step 2: Run quality checks

Run: `uvx ruff check . && uvx ruff format . && uvx ty check src/`
Expected: No errors

### Step 3: Final commit with any doc updates

```bash
git add .
git commit -m "docs: update storage backend documentation

Document S3-compatible storage backend configuration:
- Environment variables (GRAFTPUNK_S3_BUCKET, etc.)
- R2/MinIO endpoint configuration
- Note about Supabase breaking change

Co-authored-by: stavxyz <hi@stav.xyz>
Co-authored-by: Claude <noreply@anthropic.com>"
```

---

## Summary

| Task | Description | Est. Time |
|------|-------------|-----------|
| 1 | Create S3SessionStorage skeleton | 10 min |
| 2 | Implement save_session, load_session | 15 min |
| 3 | Implement list, delete, metadata methods | 15 min |
| 4 | Add retry logic with exponential backoff | 10 min |
| 5 | Wire up S3 backend in cache.py | 5 min |
| 6 | Update pyproject.toml dependencies | 5 min |
| 7 | Refactor Supabase to pure file storage | 20 min |
| 8 | Documentation and verification | 10 min |

**Total estimated time: ~90 minutes**
