kanban-persistence-json 0.7.1

JSON file storage backend for the kanban project management tool
Documentation
# kanban-persistence-json

JSON file storage backend for the kanban workspace. Implements `StoreFactory` and `PersistenceStore` from `kanban-persistence`.

## `JsonFileStore`

```rust
pub struct JsonFileStore {
    path: PathBuf,
    instance_id: Uuid,
    last_known_metadata: Mutex<Option<FileMetadata>>,
}

impl JsonFileStore {
    pub fn new(path: impl AsRef<Path>) -> Self;
    pub fn with_instance_id(path: impl AsRef<Path>, instance_id: Uuid) -> Self;
}
```

`instance_id` is a random UUID generated per process; used for conflict detection.

---

## Envelope Format

```json
{
  "version": 6,
  "metadata": {
    "instance_id": "550e8400-e29b-41d4-a716-446655440000",
    "saved_at": "2024-06-15T10:30:00Z"
  },
  "data": { /* kanban_domain::Snapshot */ }
}
```

V6 is the current on-disk format and is what `save` writes. The reader accepts envelopes with `version` in the range `2..=6` and migrates anything below 6 up to 6 before returning the snapshot. Anything below 2 or above 6 is rejected with `PersistenceError::Serialization`.

V1 was a bare `Snapshot` JSON object with no envelope (flat, no `version` field).

`#[serde(deny_unknown_fields)]` is deliberately NOT applied to the envelope: pre-KAN-405 builds wrote top-level `commands` / `undo_cursor` / `baseline_data` / `command_schema_version` fields, and tolerant deserialisation lets those files still load. The load path actively scrubs those legacy fields from disk on the next load, rather than leaving "dust" until the next mutation.

---

## Save Flow

1. Check for conflict: compare current file metadata (size + mtime) against the last-seen value
2. Stamp `PersistenceMetadata` with this store's `instance_id` and the current time
3. Wrap the snapshot in a V6 envelope
4. Pretty-print to JSON bytes
5. Write atomically via `AtomicWriter` (temp file + rename) for crash safety
6. Update the cached `FileMetadata` after a successful write

The atomic rename means a crash at any point leaves either the old file or the new file intact, always a complete consistent file on disk.

---

## Load Flow

1. Detect the current on-disk format version via `Migrator::detect_version`
2. If `< V7`, run the migration chain in order, each step writing back atomically:
   - **V1 -> V2**: wrap the bare V1 `Snapshot` in an envelope, side-stepping through a `<path>.v1.backup` file that is removed once the new file is written
   - **V2 -> V3**: in-place transform via `transform_v2_to_v3_value`
   - **V3 -> V4** and **V4 -> V5**: version bump only, no shape change on disk (the reader simply accepts these versions and the chain hands them on)
   - **V5 -> V6 (split-graph)**: see below
   - **V6 -> V7 (spawns-bucket rename)**: see below
3. Read the bytes, parse as a `JsonEnvelope`, and validate `2 <= version <= 7`
4. Detect any pre-KAN-405 legacy fields on the raw value and rewrite the file with a clean envelope if any are present (errors are logged but non-fatal; the in-memory load still succeeds)
5. Extract `envelope.data` as the `StoreSnapshot`

A `load_sync` variant performs the same chain through synchronous helpers (`migrate_v1_to_v2_sync`, `migrate_v2_to_v3_sync`, `split_graph_sync`, `v6_to_v7_rename_sync`) so non-async callers see the same migration semantics.

A clean V7 file with no legacy fields is read but not rewritten, so mtimes stay stable and version-controlled kanban files don't churn.

### V6 split-graph migration

V6 splits the single `data.graph.cards.edges` list (used by V3, V4 and V5 on disk) into three sub-graphs keyed by the original `edge_type`:

```json
"data": {
  "graph": {
    "parent_child": { "edges": [...] },
    "blocks":       { "edges": [...] },
    "relates":      { "edges": [...] }
  }
}
```

Each migrated edge has its `edge_type`, `direction`, and `weight` keys removed (the sub-graph encodes the kind, and the new per-kind edge structs no longer carry those fields). `source`, `target`, `created_at`, and `archived_at` are preserved. Migrated `Blocks` edges get `severity: "Medium"` and `RelatesTo` edges get `kind: "General"` as defaults. Unknown or missing `edge_type` values and non-object entries in the legacy edge list are rejected with a clear diagnostic.

`transform_to_v6_split_graph_value` is idempotent: invoked on an already-V6 envelope it returns without touching `data.graph`, so re-running the migration cannot silently wipe a populated split graph.

### V7 spawns-bucket rename

V7 renames the spawns sub-graph key from `parent_child` to `spawns` so the wire format matches the `SpawnsEdge` struct, the `spawns_edges()` accessor on `DependencyGraph`, and the SQLite `spawns_edges` table:

```json
"data": {
  "graph": {
    "spawns":  { "edges": [...] },
    "blocks":  { "edges": [...] },
    "relates": { "edges": [...] }
  }
}
```

Pure key rename: edge contents are untouched. The transform writes a `<path>.v6.backup` before rewriting and removes it on success. `transform_v6_to_v7_value` is idempotent and tolerates a missing `data.graph` (only the version field is bumped).

---

## Conflict Detection

`FileMetadata` captures the file's size and modification time at last load/save. On the next save the current file metadata is compared:

- If they match: file unchanged since last save, safe to write
- If they differ: another instance wrote the file, return `PersistenceError::ConflictDetected`

---

## `JsonStoreFactory`

The actual `StoreFactory` trait (defined in `kanban-persistence/src/registry.rs`) has only three methods:

```rust
pub trait StoreFactory: Send + Sync {
    fn name(&self) -> &str;
    fn matches_content(&self, header: &[u8]) -> bool { false }
    fn create(&self, locator: &str)
        -> Result<Arc<dyn PersistenceStore + Send + Sync>, PersistenceError>;
}
```

`JsonStoreFactory` implements it as:

```rust
impl StoreFactory for JsonStoreFactory {
    fn name(&self) -> &str { "json" }
    fn matches_content(&self, header: &[u8]) -> bool { /* content sniff */ }
    fn create(&self, locator: &str) -> Result<...> {
        Ok(Arc::new(JsonFileStore::new(locator)))
    }
}
```

**Backend selection is by content sniffing, not by file extension.** The registry reads the first 32 bytes of the file and asks each registered factory whether the header looks like its format. `JsonStoreFactory::matches_content` skips an optional UTF-8 BOM and any leading ASCII whitespace, then accepts the header if the first significant byte is `{` or `[`. This lets a `.json` file with SQLite contents be routed to the SQLite backend (and vice versa), and means a misleading file extension cannot trick the registry.

---

## Dependencies

| Crate | Purpose |
|-------|---------|
| `kanban-persistence` | `PersistenceStore`, `StoreFactory` traits, `FormatVersion` |
| `kanban-domain` | `Snapshot` type |
| `serde_json` | JSON parsing |
| `tokio` | Async I/O |
| `tempfile` | Temp file for atomic writes |