# Sprint 18.1: Integration Cache Resilience

| Field | Value |
|-------|-------|
| **Sprint** | 18.1 |
| **Size** | M (3-5 days) |
| **Phase** | Implementation-Ready |
| **Prerequisites** | Sprint 16 (Identity), Sprint 9 (Calendar Sync) |

---

## Summary

The client caches integration IDs in localStorage but has no mechanism to handle stale IDs when they no longer exist on the server. This causes infinite retry loops, confusing error states, and cross-device inconsistencies.

## Problem Statement

**Scenario:** Client has a cached integration ID that no longer exists in the backend database (e.g., deleted, workspace mismatch, or cross-device sync).

**Current Behavior:**
1. Client caches integration ID in localStorage
2. Server returns "Integration not found" (gRPC `NOT_FOUND`)
3. Client catches error generically, shows toast
4. Cache is NOT cleared
5. Client retries with stale ID → infinite error loop

---

## Open Issues

| Issue | Status | Resolution |
|-------|--------|------------|
| Missing `IntegrationNotFound` in Rust error enum | **BLOCKING** | Add to `client/src-tauri/src/error.rs` |
| No `GetUserIntegrations` RPC in proto | **BLOCKING** | Add RPC to `noteflow.proto` |
| gRPC status code not extracted in Rust client | **BLOCKING** | Update `sync.rs` to parse `tonic::Status` code |
| `workspace_id` not in `Integration` type | **GAP** | Add field to `client/src/api/types/index.ts` |

---

## Validation Status

### What Already Exists

| Asset | Location | Reusable? |
|-------|----------|-----------|
| `abort_not_found()` helper | `grpc/_mixins/errors.py:44` | Yes |
| `ENTITY_INTEGRATION` constant | `grpc/_mixins/errors.py:27` | Yes |
| `removeIntegration()` method | `client/src/lib/preferences.ts:332` | Yes |
| `ErrorKind` serialized enum | `client/src-tauri/src/error.rs:78` | Extend |
| `extractErrorMessage()` helper | `client/src/api/helpers.ts:53` | Extend |
| `extractGrpcStatusMessage()` | `client/src/api/helpers.ts:89` | Extend |
| Workspace context | `client/src/contexts/workspace-context.tsx` | Yes |

### What Needs Implementation

| Component | Sprint Assumption | Actual Status |
|-----------|-------------------|---------------|
| `client/src/api/errors.ts` | Create new file | **NOT NEEDED** - extend `helpers.ts` |
| `IntegrationNotFoundError` class | TypeScript class | Can be discriminated union in `ErrorKind` |
| `GetUserIntegrations` RPC | New proto RPC | **REQUIRED** - not present |
| `validateCachedIntegrations()` | New function | **REQUIRED** |
| Rust status code extraction | Assumed present | **MISSING** - `sync.rs` uses `?` operator |

---

## Risk Assessment

| Aspect | Status | Severity |
|--------|--------|----------|
| Cache invalidation on "Not Found" | ❌ Missing | **CRITICAL** |
| Server validation at startup | ❌ Missing | **HIGH** |
| Retry logic for stale IDs | ⚠️ Retries indefinitely | **HIGH** |
| Error differentiation | ⚠️ All errors treated same | **MEDIUM** |
| Workspace validation | ❌ Client ignores workspace_id | **MEDIUM** |
| Cross-device consistency | ❌ No sync mechanism | **MEDIUM** |

## Root Cause Analysis

### 1. Client-Side Caching Locations

| Location | What's Cached | Persistence |
|----------|---------------|-------------|
| `preferences.integrations` | Full `Integration` objects | localStorage |
| `useIntegrationSync` hook | `syncStates` by ID | In-memory |
| `useOAuthFlow` hook | Connection status only | React state |

### 2. Missing Error Handling

**Calendar Service** (`calendar_service.py`):
- Returns generic "Provider X not connected" for missing integrations
- No explicit "integration not found" error code

**Rust Client** (`sync.rs`, `calendar.rs`):
- Uses `?` operator, propagates all errors identically
- No cache invalidation on failures

**TypeScript Hooks** (`use-integration-sync.ts`, `use-calendar-sync.ts`):
- Catch errors generically
- Never clear cached integration on "Not Found"
- Continue polling/retrying indefinitely

### 3. Missing Startup Validation

- `preferences.initialize()` hydrates from Tauri storage blindly
- No RPC to validate cached integration IDs against server
- `useIntegrationSync.startScheduler()` assumes cache is accurate

---

## Key Decisions

| Decision | Options Considered | Chosen | Rationale |
|----------|-------------------|--------|-----------|
| Error type propagation | New error class vs discriminated union | **Discriminated union** | Aligns with existing Rust `ErrorKind` pattern |
| Validation trigger | App startup vs lazy validation | **App startup** | Prevents stale cache from causing errors |
| Workspace filtering | Client-side vs server-side | **Server-side** | `GetUserIntegrations` already workspace-scoped |
| Cache invalidation scope | Single integration vs all stale | **Single** | Surgical removal, minimal disruption |

---

## Execution Checklist

### Phase 1: Error Differentiation (M)

| Task | Effort | Files |
|------|--------|-------|
| Add `IntegrationNotFound` to Rust `ErrorKind` | S | `error.rs` |
| Extract gRPC status code in Rust sync client | M | `sync.rs` |
| Add `extractGrpcStatusCode()` TypeScript helper | S | `helpers.ts` |
| Add `isNotFoundError()` type guard | S | `helpers.ts` |

### Phase 2: Cache Invalidation on "Not Found" (M)

| Task | Effort | Files |
|------|--------|-------|
| Update `performSync()` to detect NOT_FOUND | S | `use-integration-sync.ts` |
| Call `preferences.removeIntegration()` on NOT_FOUND | S | `use-integration-sync.ts` |
| Update `useCalendarSync` error handling | S | `use-calendar-sync.ts` |
| Update `useOAuthFlow` to reset on NOT_FOUND | S | `use-oauth-flow.ts` |

### Phase 3: Startup Validation (L)

| Task | Effort | Files |
|------|--------|-------|
| Add `GetUserIntegrations` RPC to proto | M | `noteflow.proto` |
| Implement server-side handler | M | `_mixins/sync.py` or new mixin |
| Add Rust client method | S | `sync.rs` |
| Add Tauri command | S | `commands/calendar.rs` |
| Add TypeScript adapter method | S | `tauri-adapter.ts` |
| Implement `validateCachedIntegrations()` | M | `preferences.ts` |
| Call during `preferences.initialize()` | S | `preferences.ts` |

### Phase 4: Workspace-Aware Caching (S) - Optional

| Task | Effort | Files |
|------|--------|-------|
| Add `workspace_id` to `Integration` type | S | `api/types/index.ts` |
| Filter by current workspace in preferences | S | `preferences.ts` |

### Phase 5: Health Check (Optional)

- [ ] Add periodic `GetOAuthConnectionStatus()` calls for connected integrations
- [ ] Auto-clear if status becomes `DISCONNECTED` unexpectedly
- [ ] Show warning in UI if stale state detected

---

## Files to Modify

| File | Changes |
|------|---------|
| `client/src-tauri/src/error.rs` | Add `IntegrationNotFound` variant to `Error` and `ErrorKind` |
| `client/src-tauri/src/grpc/client/sync.rs` | Extract gRPC status codes, map NOT_FOUND to new error |
| `client/src/api/helpers.ts` | Add `extractGrpcStatusCode()`, `isNotFoundError()` |
| `client/src/hooks/use-integration-sync.ts` | Cache invalidation on NOT_FOUND in `performSync()` |
| `client/src/hooks/use-calendar-sync.ts` | Cache invalidation on NOT_FOUND |
| `client/src/hooks/use-oauth-flow.ts` | Reset state on NOT_FOUND |
| `client/src/lib/preferences.ts` | Add `validateCachedIntegrations()`, call in `initialize()` |
| `client/src/api/tauri-adapter.ts` | Add `getUserIntegrations()` method |
| `src/noteflow/grpc/proto/noteflow.proto` | Add `GetUserIntegrations` RPC |
| `src/noteflow/grpc/_mixins/sync.py` | Implement `GetUserIntegrations` handler |

---

## Domain Model

### Error Type Extension (Rust)

```rust
// client/src-tauri/src/error.rs - add variant
#[derive(Debug, Error)]
pub enum Error {
    // ... existing variants ...

    #[error("Integration not found: {0}")]
    IntegrationNotFound(String),
}

#[derive(Debug, Serialize)]
#[serde(tag = "kind", content = "message")]
pub enum ErrorKind {
    // ... existing variants ...

    IntegrationNotFound(String),
}
```

### Status Code Extraction (Rust)

```rust
// client/src-tauri/src/grpc/client/sync.rs
use tonic::Code;

impl GrpcClient {
    pub async fn start_integration_sync(&self, integration_id: &str) -> Result<...> {
        let mut client = self.get_client()?;
        let response = client
            .start_integration_sync(...)
            .await
            .map_err(|status| {
                if status.code() == Code::NotFound {
                    Error::IntegrationNotFound(integration_id.to_string())
                } else {
                    Error::Grpc(Box::new(status))
                }
            })?
            .into_inner();
        // ...
    }
}
```

### TypeScript Error Helpers

```typescript
// client/src/api/helpers.ts - add functions

/** gRPC status code mapping */
const GRPC_STATUS_CODES = {
  NOT_FOUND: 5,
} as const;

/** Extract gRPC status code from Tauri error */
export function extractGrpcStatusCode(error: unknown): number | null {
  if (!isRecord(error) || error.kind !== 'grpc') return null;
  const message = error.message;
  if (typeof message !== 'string') return null;

  const match = message.match(/status:\s*(\w+)/i);
  if (!match?.[1]) return null;

  const statusName = match[1].toUpperCase();
  if (statusName === 'NOTFOUND' || statusName === 'NOT_FOUND') {
    return GRPC_STATUS_CODES.NOT_FOUND;
  }
  return null;
}

/** Type guard for NOT_FOUND errors */
export function isNotFoundError(error: unknown): boolean {
  if (isRecord(error) && error.kind === 'integrationNotFound') {
    return true;
  }
  return extractGrpcStatusCode(error) === GRPC_STATUS_CODES.NOT_FOUND;
}
```

---

## API Schema

### New RPC: GetUserIntegrations

```protobuf
// noteflow.proto - add to NoteFlow service

message GetUserIntegrationsRequest {
  // Empty - uses identity context for user/workspace filtering
}

message IntegrationInfo {
  string id = 1;
  string name = 2;
  string type = 3;  // "calendar", "pkm", etc.
  string status = 4;  // "connected", "error", etc.
  string workspace_id = 5;
}

message GetUserIntegrationsResponse {
  repeated IntegrationInfo integrations = 1;
}

rpc GetUserIntegrations(GetUserIntegrationsRequest) returns (GetUserIntegrationsResponse);
```

### Startup Validation Flow

```
App Startup
    │
    ▼
preferences.initialize()
    │
    ▼
validateCachedIntegrations()
    │
    ├──► Call GetUserIntegrations RPC
    │
    ▼
Compare cached IDs vs server IDs
    │
    ├──► Remove stale IDs from cache
    │
    ▼
Continue with validated cache
```

---

## Migration Strategy

| Phase | Risk | Mitigation |
|-------|------|------------|
| Phase 1 | Low | Error handling is additive, no breaking changes |
| Phase 2 | Medium | Cache clearing is destructive; add logging |
| Phase 3 | Medium | New RPC requires coordinated deployment |
| Phase 4 | Low | Optional, can defer |

**Deployment Order:**
1. Deploy backend with new RPC + error codes
2. Deploy client with error handling + validation
3. Existing stale caches auto-clear on next sync attempt

---

## Test Strategy

### Unit Tests

```typescript
// helpers.test.ts
describe('isNotFoundError', () => {
  it.each([
    [{ kind: 'integrationNotFound', message: 'Integration x not found' }, true],
    [{ kind: 'grpc', message: 'status: NotFound, message: "..."' }, true],
    [{ kind: 'grpc', message: 'status: Internal, message: "..."' }, false],
    [new Error('random error'), false],
  ])('returns %p for %p', (error, expected) => {
    expect(isNotFoundError(error)).toBe(expected);
  });
});
```

### Integration Test Scenario

```
Given: Client has cached integration ID "int-123" in localStorage
And: Integration "int-123" does not exist on server
When: Client calls startIntegrationSync("int-123")
Then: Server returns NOT_FOUND
And: Client calls preferences.removeIntegration("int-123")
And: Integration "int-123" is no longer in localStorage
And: No retry is attempted
```

---

## Success Criteria

1. ✅ Client clears cached integration when server returns "Not Found"
2. ✅ No infinite retry loops for stale integration IDs
3. ✅ Startup validation removes orphaned integrations
4. ✅ Workspace switch clears other workspace's integrations
5. ✅ User sees clear error message with action to reconnect

## Quality Gates

- [ ] All `NOT_FOUND` errors result in cache invalidation (no infinite loops)
- [ ] Startup validation completes within 2s
- [ ] No regression in sync success rate for valid integrations
- [ ] Pre-commit hooks pass
- [ ] Type checking passes (`npm run type-check`)
- [ ] Test suite passes (`pytest`, `npm test`)

---

## Related Issues

- Workspace ID mismatch bug (fixed in identity.rs, fixtures.ts, calendar_service.py)
- OAuth browser popup fix (use-oauth-flow.ts shell plugin)
