---
phase: 09-storage-abstraction-layer
plan: 02
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
  - crates/pmcp-tasks/src/store/generic.rs
  - crates/pmcp-tasks/src/store/mod.rs
  - crates/pmcp-tasks/src/lib.rs
autonomous: true
requirements:
  - ABST-02
  - ABST-04

must_haves:
  truths:
    - "GenericTaskStore<B: StorageBackend> implements all domain logic (state machine, owner isolation, variable merge, TTL, size limits)"
    - "All mutations use put_if_version (CAS) — VersionConflict maps to TaskError::ConcurrentModification"
    - "Owner mismatch returns TaskError::NotFound (never reveals task exists for another owner)"
    - "Canonical JSON serialization happens in GenericTaskStore, not in backends"
    - "Variable schema validation (depth + string length) is enforced before serialization"
    - "TaskStore trait is redesigned as a thin interface with blanket impl for GenericTaskStore"
    - "Existing code (TaskContext, TaskRouterImpl) compiles with Arc<dyn TaskStore> unchanged"
  artifacts:
    - path: "crates/pmcp-tasks/src/store/generic.rs"
      provides: "GenericTaskStore<B> with all domain operations"
      exports: ["GenericTaskStore"]
      min_lines: 200
    - path: "crates/pmcp-tasks/src/store/mod.rs"
      provides: "Redesigned TaskStore trait with blanket impl for GenericTaskStore"
      contains: "impl<B: StorageBackend"
  key_links:
    - from: "crates/pmcp-tasks/src/store/generic.rs"
      to: "crates/pmcp-tasks/src/store/backend.rs"
      via: "GenericTaskStore delegates all storage to B: StorageBackend"
      pattern: "self\\.backend\\."
    - from: "crates/pmcp-tasks/src/store/generic.rs"
      to: "crates/pmcp-tasks/src/domain/record.rs"
      via: "serde_json serialization/deserialization of TaskRecord"
      pattern: "serde_json::(to_vec|from_slice)"
    - from: "crates/pmcp-tasks/src/store/mod.rs"
      to: "crates/pmcp-tasks/src/store/generic.rs"
      via: "blanket TaskStore impl for GenericTaskStore"
      pattern: "impl<B.*StorageBackend.*TaskStore.*for.*GenericTaskStore"
    - from: "crates/pmcp-tasks/src/context.rs"
      to: "crates/pmcp-tasks/src/store/mod.rs"
      via: "TaskContext uses Arc<dyn TaskStore> — must still compile"
      pattern: "Arc<dyn TaskStore>"
    - from: "crates/pmcp-tasks/src/router.rs"
      to: "crates/pmcp-tasks/src/store/mod.rs"
      via: "TaskRouterImpl uses Arc<dyn TaskStore> — must still compile"
      pattern: "Arc<dyn TaskStore>"
---

<objective>
Implement GenericTaskStore with all domain logic and redesign the TaskStore trait as a thin interface.

Purpose: Extract all domain logic (state machine transitions, owner isolation, variable merge with null-deletion, size limit enforcement, TTL validation, CAS-based mutations, canonical serialization) into GenericTaskStore<B: StorageBackend>. Redesign the existing TaskStore trait from 11 methods to a thin interface that GenericTaskStore implements via blanket impl, preserving Arc<dyn TaskStore> compatibility for TaskContext and TaskRouterImpl.

Output: `generic.rs` with complete GenericTaskStore implementation, updated `mod.rs` with redesigned TaskStore trait + blanket impl, updated `lib.rs` with re-exports.
</objective>

<execution_context>
@./.claude/get-shit-done/workflows/execute-plan.md
@./.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-storage-abstraction-layer/09-CONTEXT.md
@.planning/phases/09-storage-abstraction-layer/09-RESEARCH.md
@.planning/phases/09-storage-abstraction-layer/09-01-SUMMARY.md
@crates/pmcp-tasks/src/store/mod.rs
@crates/pmcp-tasks/src/store/backend.rs
@crates/pmcp-tasks/src/store/memory.rs
@crates/pmcp-tasks/src/domain/record.rs
@crates/pmcp-tasks/src/error.rs
@crates/pmcp-tasks/src/context.rs
@crates/pmcp-tasks/src/router.rs
@crates/pmcp-tasks/src/lib.rs
</context>

<tasks>

<task type="auto">
  <name>Task 1: Implement GenericTaskStore with all domain logic in generic.rs</name>
  <files>crates/pmcp-tasks/src/store/generic.rs, crates/pmcp-tasks/src/store/mod.rs</files>
  <action>
Create `crates/pmcp-tasks/src/store/generic.rs` with `GenericTaskStore<B: StorageBackend>`.

**Structure:**

```rust
pub struct GenericTaskStore<B: StorageBackend> {
    backend: B,
    config: StoreConfig,
    security: TaskSecurityConfig,
    default_poll_interval: u64,
}
```

**Construction (builder pattern, matching existing InMemoryTaskStore):**
- `pub fn new(backend: B) -> Self` — default StoreConfig, default TaskSecurityConfig, default_poll_interval = 500
- `pub fn with_config(mut self, config: StoreConfig) -> Self`
- `pub fn with_security(mut self, security: TaskSecurityConfig) -> Self`
- `pub fn with_poll_interval(mut self, ms: u64) -> Self`

**Serialization helpers (private):**
- `fn serialize_record(record: &TaskRecord) -> Result<Vec<u8>, TaskError>` — `serde_json::to_vec(record)` mapped to `TaskError::StoreError`
- `fn deserialize_record(data: &[u8]) -> Result<TaskRecord, TaskError>` — `serde_json::from_slice(data)` mapped to `TaskError::StoreError`
- `fn map_storage_error(err: StorageError, task_id: &str) -> TaskError` — maps StorageError variants to TaskError variants:
  - `NotFound` -> `TaskError::NotFound`
  - `VersionConflict` -> `TaskError::ConcurrentModification`
  - `CapacityExceeded` -> `TaskError::StorageFull`
  - `Backend` -> `TaskError::StoreError`

**Domain operations (public methods — these become the TaskStore impl):**

All methods extract domain logic from the current `InMemoryTaskStore` (in `memory.rs`). Reference that file for the exact logic. The key change: instead of DashMap operations, use `self.backend.get/put/put_if_version/delete/list_by_prefix`.

1. **`create`** — check anonymous access (using security config), count owner tasks via `list_by_prefix("{owner_id}:")` + `.len()`, validate TTL against `config.max_ttl_ms` (reject if exceeded, not clamp — per locked decision), apply default TTL if None, create `TaskRecord::new(...)`, serialize to canonical JSON, `backend.put(key, bytes)`, return record.

2. **`get`** — construct key via `make_key(owner_id, task_id)`, `backend.get(key)`, deserialize, verify `record.owner_id == owner_id` (defense in depth — return NotFound on mismatch, NEVER reveal task exists for another owner per locked security decision), return record (even if expired — per existing decision "expired tasks readable on get()").

3. **`update_status`** — get -> deserialize -> owner check -> expiry check on mutations -> state machine validate_transition -> apply mutation (update status, status_message, last_updated_at) -> serialize -> `put_if_version` (CAS). Map VersionConflict to ConcurrentModification.

4. **`set_variables`** — get -> deserialize -> owner check -> expiry check -> apply merge (upsert non-null, delete null keys) -> **validate_variables** (depth + string length from StoreConfig) -> check total size against `config.max_variable_size_bytes` (serialize variables, check byte length) -> serialize full record -> `put_if_version`. This is the clone-check-commit pattern from InMemoryTaskStore.

5. **`set_result`** — get -> deserialize -> owner check -> expiry check -> set result -> serialize -> `put_if_version`.

6. **`get_result`** — get -> deserialize -> owner check -> expiry check -> check terminal status (NotReady if not terminal) -> return result (StoreError if None).

7. **`complete_with_result`** — get -> deserialize -> owner check -> expiry check -> validate_transition -> apply status + status_message + result atomically -> serialize -> `put_if_version`. The atomicity comes from the CAS — either the whole record writes or it fails. This is the key improvement over the DashMap approach: CAS gives us atomic read-modify-write at the backend level.

8. **`list`** — `list_by_prefix("{owner_id}:")` -> deserialize each -> sort by created_at descending -> apply cursor-based pagination (cursor = task_id of last item, skip until past cursor) -> apply limit -> construct TaskPage.

9. **`cancel`** — delegate to `update_status(task_id, owner_id, TaskStatus::Cancelled, None)`.

10. **`cleanup_expired`** — delegate directly to `backend.cleanup_expired()`. Map StorageError to TaskError.

11. **`config`** — return `&self.config`.

**Critical rules (from locked decisions):**
- Owner mismatch ALWAYS returns `TaskError::NotFound`, never `OwnerMismatch` to external callers (security)
- No auto-retry on ConcurrentModification — surface immediately
- TTL rejection (not clamping) for over-max values
- Variable size check uses clone-check-commit: clone variables, apply merge to clone, check size of clone, if OK apply to real record
- All mutations use `put_if_version` (CAS), never plain `put` (except `create` which uses plain `put` since it is a new key)

Add `pub mod generic;` to `store/mod.rs`.

**Unit tests in generic.rs** — these require a StorageBackend implementation. Create a minimal `MockBackend` (or `TestBackend`) using `DashMap` that implements `StorageBackend` for testing purposes (NOT the full InMemoryBackend from Phase 10 — just enough to test GenericTaskStore logic). Tests:
- Create a task and retrieve it
- Owner isolation: create as owner-A, get as owner-B returns NotFound
- State machine: create -> update to Completed succeeds, Completed -> Working fails
- Variable merge: set variables, verify upsert and null-deletion
- Variable size exceeded: set variables exceeding limit -> VariableSizeExceeded error
- Variable depth bomb: deeply nested JSON -> error
- Variable long string: extremely long string -> error
- TTL rejection: create with TTL > max -> error
- CAS conflict: simulate concurrent modification via version mismatch
- Complete with result: atomic status + result via put_if_version
- List pagination: create 3 tasks, list with limit 2, verify next_cursor, list page 2
- Cleanup expired: delegate to backend
- Serialization round-trip: create, serialize, deserialize, verify fields match
  </action>
  <verify>
Run `cargo check -p pmcp-tasks` to verify compilation. Run `cargo test -p pmcp-tasks -- generic::tests` for GenericTaskStore unit tests. Run `cargo test -p pmcp-tasks` to verify ALL existing tests still pass. Run `cargo clippy -p pmcp-tasks -- -D warnings` for zero warnings.
  </verify>
  <done>
`generic.rs` exists with GenericTaskStore<B: StorageBackend> implementing all 11 domain operations. Builder pattern construction. Canonical JSON serialization at the storage boundary. All mutations use CAS (put_if_version). Owner isolation enforced structurally. Variable validation enforced before serialization. Unit tests pass with a test backend. All existing tests still pass.
  </done>
</task>

<task type="auto">
  <name>Task 2: Redesign TaskStore trait as thin interface with blanket impl for GenericTaskStore</name>
  <files>crates/pmcp-tasks/src/store/mod.rs, crates/pmcp-tasks/src/lib.rs</files>
  <action>
Redesign the `TaskStore` trait in `crates/pmcp-tasks/src/store/mod.rs` to be a thin interface that `GenericTaskStore` implements via blanket impl.

**Per locked decision:** "Clean break from the current 11-method trait — redesign from scratch." But per research recommendation: "Keep TaskStore trait as thin interface for type erasure — `TaskContext` and `TaskRouterImpl` both use `Arc<dyn TaskStore>`."

**The redesign:**

1. **Keep the same method signatures** on `TaskStore` trait — the 11 methods (create, get, update_status, set_variables, set_result, get_result, complete_with_result, list, cancel, cleanup_expired, config) remain the same. The "clean break" is that domain logic is NO LONGER expected to live in trait implementations — it lives in GenericTaskStore. The trait is now purely a type-erasure interface.

2. **Add blanket impl** for `GenericTaskStore<B>`:

   ```rust
   #[async_trait]
   impl<B: StorageBackend + 'static> TaskStore for GenericTaskStore<B> {
       async fn create(&self, owner_id: &str, request_method: &str, ttl: Option<u64>)
           -> Result<TaskRecord, TaskError> {
           self.create(owner_id, request_method, ttl).await
       }
       // ... all methods delegate to self (GenericTaskStore already has them)
   }
   ```

   The `+ 'static` bound is needed for `Arc<dyn TaskStore>` compatibility.

3. **Update trait documentation** to reflect the new role: "This trait serves as a type-erasure interface for `GenericTaskStore<B>`. Domain logic lives in `GenericTaskStore`, not in trait implementations. Use `GenericTaskStore::new(backend)` to create a store, then wrap in `Arc<dyn TaskStore>` for use with `TaskContext` and `TaskRouterImpl`."

4. **Update `crates/pmcp-tasks/src/lib.rs`** — Add re-exports:
   ```rust
   pub use store::generic::GenericTaskStore;
   ```
   Keep existing re-exports of `StorageBackend`, `StorageError`, `VersionedRecord` from Plan 01.

5. **Verify compilation of downstream code** — `TaskContext` (uses `Arc<dyn TaskStore>`), `TaskRouterImpl` (uses `Arc<dyn TaskStore>`), and `InMemoryTaskStore` (implements `TaskStore`) must still compile. The existing `InMemoryTaskStore` in `memory.rs` continues to implement `TaskStore` directly — it will be refactored in Phase 10 to use `GenericTaskStore<InMemoryBackend>`. Both can coexist for now.

6. **Do NOT touch `memory.rs`, `context.rs`, or `router.rs`** — these files must compile unchanged. The only changes are to `mod.rs` (blanket impl + updated docs) and `lib.rs` (re-exports).

**Compilation verification tests** — Add a compile-time test or integration test that creates a `GenericTaskStore<TestBackend>`, wraps it in `Arc<dyn TaskStore>`, and uses it through the trait interface. This proves the blanket impl works for type erasure:

```rust
#[tokio::test]
async fn generic_task_store_as_dyn_task_store() {
    let backend = TestBackend::new();
    let store = GenericTaskStore::new(backend);
    let dyn_store: Arc<dyn TaskStore> = Arc::new(store);
    // Use through trait interface
    let record = dyn_store.create("owner", "tools/call", Some(60_000)).await.unwrap();
    let fetched = dyn_store.get(&record.task.task_id, "owner").await.unwrap();
    assert_eq!(fetched.task.task_id, record.task.task_id);
}
```
  </action>
  <verify>
Run `cargo check -p pmcp-tasks` to verify the blanket impl compiles. Run `cargo test -p pmcp-tasks` to verify ALL existing tests still pass (InMemoryTaskStore, TaskContext, TaskRouterImpl unchanged). Run `cargo test -p pmcp-tasks -- generic` for the type-erasure test. Run `cargo clippy -p pmcp-tasks -- -D warnings` for zero warnings. Run `cargo test -p pmcp-tasks --doc` for doctests. Run `cargo check` (full workspace) to verify no downstream breakage.
  </verify>
  <done>
TaskStore trait has blanket impl for GenericTaskStore<B: StorageBackend>. GenericTaskStore can be used as `Arc<dyn TaskStore>`. TaskContext and TaskRouterImpl compile unchanged. InMemoryTaskStore continues to work via direct TaskStore impl. `GenericTaskStore` is re-exported from crate root. All existing tests pass. Full workspace compiles. Zero clippy warnings.
  </done>
</task>

</tasks>

<verification>
1. `cargo check -p pmcp-tasks` — new types compile
2. `cargo test -p pmcp-tasks` — ALL 200+ existing tests pass (critical regression check)
3. `cargo test -p pmcp-tasks -- generic` — GenericTaskStore unit tests + type-erasure test pass
4. `cargo clippy -p pmcp-tasks -- -D warnings` — zero warnings
5. `cargo test -p pmcp-tasks --doc` — all doctests pass
6. `cargo check` — full workspace compiles (no downstream breakage)
7. Verify GenericTaskStore can be wrapped in Arc<dyn TaskStore> and used through the trait interface
8. Verify owner isolation: owner-B cannot access owner-A's tasks through GenericTaskStore
9. Verify CAS: concurrent modification via put_if_version returns ConcurrentModification error
</verification>

<success_criteria>
- GenericTaskStore<B: StorageBackend> exists with all 11 domain operations
- All mutations use put_if_version (CAS) for optimistic concurrency
- Owner mismatch returns NotFound (never leaks task existence)
- Canonical JSON serialization happens in GenericTaskStore only
- Variable validation (depth + string length) enforced before serialization
- TaskStore trait serves as type-erasure interface with blanket impl
- GenericTaskStore works as Arc<dyn TaskStore>
- TaskContext, TaskRouterImpl, InMemoryTaskStore compile unchanged
- All 200+ existing tests pass
- Zero clippy warnings
- Full workspace compiles
</success_criteria>

<output>
After completion, create `.planning/phases/09-storage-abstraction-layer/09-02-SUMMARY.md`
</output>
