# Tane — Data model (v0 draft)

*Technical spec feeding the Flutter/Drift implementation. Written in English (project convention). Implements the concepts discussed in [data-notes.md](data-notes.md) and PLAN §3 / §5-bis.*

> Status: draft for discussion. Nothing here is frozen; it is the starting point for the first Drift schema (`schemaVersion = 1`).

> **Core/domain split.** This model is presented *flat* for readability. Which of these tables live in the generic engine (`commons_core`) vs. the seeds domain (`app_seeds`) is decided in [core-domain-boundary.md](core-domain-boundary.md). In short: `Variety`, `Lot`, `Movement`, `Species` and the seed-specific fields are **domain**; `Offer`, `Party`, `SeedBank`→`Group`, trust, identity and the signed-return promise are **core**. Two entities below are the seeds view of a generalized core concept — see the notes on `Movement` (§2.4) and `Plantare` (§2.7).

## 0. Design constraints (why the model looks like this)

- **Local-first & offline.** The whole model lives in local SQLite. No server is required. Online only enriches.
- **CRDT-ready from day one.** Every mutable row carries the metadata needed to merge across devices/peers later, even before sync is built. See §4.
- **Progressive disclosure.** Only `Variety.label` is mandatory. Everything else is optional. The schema must never force a field the UI hides.
- **Three levels: identity / batch / event.** `Variety` (what it is) → `Lot` (a batch you hold) → `Movement` (append-only log). This makes year, in/out, dates and germination fall into place.
- **Append-only ledger.** `Movement` rows are immutable events. This is both the bank history (mockup HISTORY tab) and the provenance/Plantare chain (§5-bis).

## 1. Conventions for every table

All identifiers, column names, comments and commit messages are in **English**. Shared columns on every mutable entity:

| Column | Type | Purpose |
|---|---|---|
| `id` | TEXT (UUIDv7) | **Client-generated** primary key. Never auto-increment (would collide across peers). UUIDv7 sorts by time. |
| `created_at` | INTEGER (ms) | Creation timestamp. |
| `updated_at` | TEXT (HLC) | Hybrid Logical Clock stamp of last change — drives last-writer-wins merge. |
| `last_author` | TEXT | Public key / device id of who last wrote (for CRDT + trust). |
| `is_deleted` | BOOLEAN | **Soft delete only.** Never `DELETE FROM`; tombstones must survive to merge correctly. |
| `schema_row_version` | INTEGER | Per-record format version for distributed migration (§5). |

`Movement` is the exception: it is append-only and immutable, so it needs no `updated_at`/`is_deleted` (a correction is a new compensating event).

## 2. Entities

### 2.1 `Variety` — the identity (accession)
What you saved; one row per distinct thing in your inventory.

| Column | Type | Notes |
|---|---|---|
| `id` | UUID | |
| `label` | TEXT **(required)** | Your own name: "el tomate de la abuela". The only mandatory field. |
| `species_id` | UUID → `Species` | Nullable. Links to the bundled catalog; unlocks scientific name + Wikidata. |
| `cultivar_name` | TEXT | Nullable. 'Marmande', 'Cherokee Purple'. Free text — landraces aren't in any authority. |
| `category` | TEXT/enum | Nullable. Botanical family or user group, for the inventory sections. |
| `notes` | TEXT (markdown) | Nullable. Free personal notes. |
| *(+ common columns)* | | |

Related (separate tables so concurrent edits merge as sets):
- `VarietyVernacularName(id, variety_id, name, language, region)` — the multiple common names.
- `Attachment(id, parent_type, parent_id, uri, kind)` — photos/docs (kind: photo, doc). Polymorphic parent.
- `ExternalLink(id, parent_type, parent_id, url, title)` — any pasted URL (Wikipedia, forum…).

### 2.2 `Species` — bundled name catalog (mostly read-only)
The lightweight offline catalog (Wikidata CC0 + GBIF CC-BY). Users may add local entries.

| Column | Type | Notes |
|---|---|---|
| `id` | UUID | |
| `scientific_name` | TEXT | *Solanum lycopersicum*. |
| `wikidata_qid` | TEXT | e.g. `Q23501`. Anchor to derive Wikipedia/image/GBIF online. |
| `gbif_key` | INTEGER | Nullable. GBIF taxon key. |
| `family` | TEXT | Nullable. |
| `is_bundled` | BOOLEAN | True = shipped with app (don't sync); false = user-added (syncs). |

Localized common names for the catalog live in `SpeciesCommonName(species_id, name, language)` and are bundled too.

### 2.3 `Lot` — a batch you hold
Zero or more per `Variety`, each with its **own year and its own unit** — and units may differ between lots of the same variety. A single maize variety can hold, at once: `[2 cobs · 2024]`, `[a few loose seeds · 2023]`, `[a cup · 2022]`. Even the same variety+year in two forms (2 cobs *and* loose kernels from 2024) is just two lots. A `Lot` = one homogeneous batch. This is exactly why quantity and unit live here, not on `Variety`.

| Column | Type | Notes |
|---|---|---|
| `id` | UUID | |
| `variety_id` | UUID → `Variety` | |
| `harvest_year` | INTEGER | Nullable. **This is where "year" lives** (germination decays with lot age). |
| `type` | enum `LotType` | The **form** held — see §2.3.2. |
| `quantity_label` | TEXT + `quantity_kind` | Qualitative by default. **Not only kitchen units** — see §2.3.1. |
| `quantity_precise` | REAL + `quantity_unit` | Optional precise amount (grams / seed count). |
| `presentation` | enum `Presentation` | Nullable. How living material is packaged — see §2.3.2. |
| `storage_location` | TEXT | Nullable. Free text with suggestions ("fridge", "village bank"). |
| `offer_status` | enum | `private` / `shared` / `exchange` / `sell` — the visibility from PLAN §3 Layer 2. Per lot. |
| `seedbank_id` | UUID → `SeedBank` | Nullable. If this lot belongs to a collective bank. |
| *(+ common columns)* | | |

`GerminationTest(id, lot_id, date, sample_size, germinated_count, notes)` — optional; `percent` is derived (`germinated_count / sample_size`). One-to-many so history of tests is kept.

#### 2.3.1 Quantity units — qualitative and plant-aware (design note)

Quantity is deliberately *rough and human*, not precise. But the informal vocabulary must go **beyond kitchen measures** ("cup", "pinch", "packet") — those feel too "andar por casa" and don't fit many seeds. Seeds are naturally counted in the **forms the plant gives them**. So `quantity_kind` is an **extensible, i18n, grouped list**, and the app *suggests* the relevant units from the linked `Species`/`category` (never forces them):

- **Informal / generic:** a few, some, plenty, a handful, a pinch, a jar, a **packet/envelope** ("sobre").
- **Plant-form natural units:** a **cob** (mazorca, maize), a **flower head** (cabezuela, sunflower), a **pod** (vaina, legumes), an **ear/spike** (espiga, cereals), a **fruit's worth** (tomato, pepper, squash), a **bulb**, a **tuber**, a **seed head**, a **bunch**.
- **Precise (optional):** grams or seed count (`quantity_precise` + `quantity_unit`).

Modelling: store `quantity_kind` (a stable enum key like `pod`, `cob`, `head`, `packet`, `handful`, `grams`, `count`) + an optional numeric `quantity_precise` (and a free-text `quantity_label` for a rough amount when no key fits). The **display label is localized** from the key, so "pod"→"vaina"/"beina"… Units are *labels for a rough amount*, generally **not convertible** between each other or to grams — and that's fine. Suggestion logic (e.g. Poaceae → ear/cob, Fabaceae → pod, Helianthus → flower head) lives in the catalog mapping, not hardcoded, so it stays extensible and translatable. **To think through further:** the full starter list per family, and whether users can add their own unit keys (probably yes, as free `quantity_label` text when no key fits).

#### 2.3.2 Lot form (`LotType`) and packaging (`Presentation`) — design note

A lot isn't always dry seed. Reviewing the vocabulary of real seed shops and nurseries (viveros) — where "plantel" is only the *seedbed/batch*, not a unit — the **form** a lot is held in is a small, append-only enum `LotType` (stored by name, data-model §5):

- `seed` — dry seed. **The only form germination tests apply to.**
- `seedling` — a young plant grown from seed, to transplant (*plantón* / plug).
- `plant` — an established plant (potted or bare-root).
- `tree` — a woody plant: tree or shrub.
- `bulb` — a storage organ: bulb, corm, tuber or rhizome.
- `cutting` — vegetative propagation material (cutting, scion).

**Form drives the offered units**, and only `seed` gets the rich plant-form scale of §2.3.1; living forms are counted as **whole individuals** (`seedling`, `plant`, `tree`, `cutting`; `bulb` also allows grams). The *packaging* is deliberately a **separate optional attribute** `Presentation` (`pot`, `tray`, `plug`, `bareRoot`, `rootBall`) so "how many" is never conflated with "in what format" — that conflation was the original flaw (the old `pot`/`tray` "amounts", now retained only for old rows). Presentation is offered only for `seedling`/`plant`/`tree`. **Left open:** grafts/divisions/forestry seedlings (nicho) fold into `cutting`/`plant` for now, or a free note; revisit if real exchanges demand their own key.

### 2.4 `Movement` — the append-only event log
Immutable events on a `Lot`. Entries/exits are just types. This *is* the history tab and the provenance chain.

> The append-only *mechanic* is the generic `LedgerEntry` of `commons_core` (see [core-domain-boundary.md](core-domain-boundary.md) §2, §4.3); `Movement` is its seeds-domain shape. At v0 it lives whole in `app_seeds` (per that doc's §8 conservative start); it may be split later.

| Column | Type | Notes |
|---|---|---|
| `id` | UUID | |
| `lot_id` | UUID → `Lot` | |
| `type` | enum | `received`, `given`, `sown`, `harvested`, `germination_test`, `split`, `discarded`… |
| `occurred_on` | DATE | The date (in/out/sowing…). |
| `counterparty_id` | UUID → `Party` | Nullable. From/to whom (for received/given). |
| `quantity_label` / `quantity_precise` | | How much moved. |
| `parent_movement_id` | UUID → `Movement` | Nullable. Links a lot to the exchange it came from → **provenance DAG**. |
| `plantare_id` | UUID → `Plantare` | Nullable. A `given` may carry a Plantare. |
| `notes` | TEXT | |
| `created_at`, `last_author`, `schema_row_version` | | (no update/delete — append-only) |

### 2.5 `Party` — a person or collective you exchange with
| Column | Type | Notes |
|---|---|---|
| `id` | UUID | |
| `display_name` | TEXT | |
| `public_key` | TEXT | Nullable. The person's single identity key (Duniter/Ğ1-style; a derived subkey serves Nostr transport). Layer 3+. |
| `kind` | enum | `person` / `collective`. |
| `note` | TEXT | |

### 2.6 `SeedBank` — a collective bank (optional, Layer 2–3)
`SeedBank(id, name, note)` + `SeedBankMember(seedbank_id, party_id, role)`. A `Lot` may point to a `SeedBank`. Kept minimal now; expands with sharing.

### 2.7 `Plantare` — the signed seed-IOU (§5-bis)
The digital version of the paper Plantare. Bilateral, signed, held by both parties.

> `Plantare` is the seeds-domain name for the generic `Pledge` (return promise) of `commons_core` — see [core-domain-boundary.md](core-domain-boundary.md) §2, §5. The generalization uses `Pledge.return_kind = similar` for seeds (return *something alike*) vs. `same_item` for a lent tool. The columns below map onto that core entity.

| Column | Type | Notes |
|---|---|---|
| `id` | UUID | |
| `movement_id` | UUID → `Movement` | The `given` event it accompanies. |
| `debtor_key` | TEXT | Who received and owes a return. |
| `creditor_key` | TEXT | Who gave. |
| `owed_description` | TEXT | "a similar amount of free seed". |
| `due_date` | DATE | Return-by date. |
| `debtor_signature` / `creditor_signature` | TEXT | Cryptographic signatures (both stubs of the paper). |
| `status` | enum | `open` / `returned` / `forgiven`. Framed as a promise, not a debt (microcopy). |

### 2.8 `Offer` — a published, discoverable listing
The *shop window*: a signed statement published to the network so others can discover what you share. Distinct from `Lot` on purpose — publishing reveals only what you choose (privacy). Serializes to Nostr NIP-99 (`kind:30402`) or ActivityPub. Full rationale in [sharing-model.md](sharing-model.md).

| Column | Type | Notes |
|---|---|---|
| `id` | UUID | |
| `lot_id` | UUID → `Lot` | Nullable (offer without exposing a lot; `wanted` needs none). |
| `variety_summary` | TEXT/JSON | Only the name + photo ref you choose to publish. |
| `offer_type` | enum | `gift` / `exchange` / `sale` / `wanted`. |
| `price_amount` | DECIMAL | Nullable (only `sale`). **No in-app payment** — price is informational; money changes hands off-platform. |
| `price_currency` | TEXT | Nullable. ISO 4217 or a local/community currency. |
| `price_negotiable` | BOOLEAN | |
| `exchange_terms` | TEXT | Nullable (for `exchange`). |
| `approx_geohash` | TEXT | Low precision only. |
| `radius_km` | INTEGER | |
| `status` | enum | `active` / `reserved` / `closed`. |
| `published_at` / `expires_at` | | Seasonality / freshness. |
| `transport_ref` | TEXT | Relay event id (NIP-99) / AP object id. |
| `author_key` | TEXT | Publishing public key. |
| *(+ common columns)* | | |

A closed deal may produce a `Movement` (`given`) and, if it is an exchange with a return promise, a `Plantare`. The app never processes payments, takes commissions, or holds funds (keeps Tane neutral infrastructure, not a central market operator).

## 3. Entity map

```
Species (bundled) ──< Variety ──< Lot ──< Movement (append-only) >── Party
                         │           │  │       │
              VernacularName         │  Offer   └── Plantare
              Attachment/Link   GerminationTest
                                     │
                                 SeedBank
```
`Movement.parent_movement_id` forms the cross-device provenance DAG. `Offer` is published (NIP-99/AP) and, when a deal closes, spawns a `Movement` (and optionally a `Plantare`).

## 4. CRDT strategy (merge semantics)

Chosen libraries (PLAN §3): `crdt_lf` / `crdt_sync` on top of Drift. Merge rules by data shape:

- **Scalar fields** (label, harvest_year, offer_status…) → **LWW register** keyed by HLC `updated_at`. Simple, good enough for personal edits.
- **Collections** (vernacular names, attachments, links, seedbank members) → **OR-Set** (observed-remove set) so concurrent adds on two phones both survive.
- **`Movement` log** → **grow-only set** (append-only). No conflicts possible; corrections are new events. This is why the ledger is the safest part to sync first.
- **Identity:** client-generated UUIDv7; **deletes are tombstones** (`is_deleted`), never physical.

Adoption order (avoid CRDT-everything at once): sync `Movement` first (append-only, trivial), then `Lot`/`Variety` (LWW), then the set-typed relations.

## 5. Migration — two axes (this is the Liquibase/Flyway question)

### 5.1 Local schema migration → Drift, natively (the Flyway/Liquibase equivalent)
Drift ships versioned, testable migrations — exactly the requested capability:

- `schemaVersion` integer on the database; bump on every schema change.
- **Step-by-step migrations**: `dart run drift_dev schema steps drift_schemas/ lib/db/schema_versions.dart` generates `from1To2`, `from2To3`… functions.
- **Versioned schema exports** stored in `drift_schemas/` (`drift_schema_v1.json`, `v2.json`…) — the migration history, like Flyway's versioned scripts, committed to git.
- **Migration tests**: `dart run drift_dev schema generate` builds old-version DBs; `validateDatabaseSchema` asserts the migrated schema matches a fresh one. Run in CI.

Rule: never edit a released schema version; always add a new one with a forward migration. Keep every `drift_schema_vN.json` in the repo.

### 5.2 Distributed / wire migration → compatibility discipline (no tool can do this for you)
Because peers run **different app versions**, the sync payload must stay backward+forward compatible. This is the harder axis and is a *convention*:

- **Additive, optional-only changes** to synced records → both forward and backward compatible. Never repurpose a field's meaning.
- **`schema_row_version` per record**; readers **ignore unknown fields** and fill missing ones with defaults.
- **Soft deletes / tombstones** always (already in the model).
- **Never reuse or renumber** enum values; only append new ones, and tolerate unknown enum values on read (map to a safe default).
- A new required concept ships as a **new optional table/field**, promoted to "expected" only after most peers have upgraded.

Together: Drift handles the *local database* migration; the compatibility rules handle the *distributed data* migration. Both are needed for a P2P local-first app.

## 6. Open questions (decide before freezing `schemaVersion = 1`)

- `quantity` modelled once and reused on Lot and Movement, or duplicated? (Lean: a shared embedded type.)
- Is `offer_status` on `Lot` enough, or does a `Variety`-level default help? (Lean: Lot only.)
- Do we need `Variety`-level provenance, or is per-Lot/Movement provenance sufficient? (Lean: Movement DAG is enough.)
- `category` as free enum vs linked to `Species.family` when a species is set. (Lean: free, prefilled from family when available.)

## 7. Interchange export/import (Phase 1) — decided & implemented

The user-triggered **interchange export** (Settings → Backup). Distinct from the future encrypted full backup (`.tane`, [backup-and-recovery.md](backup-and-recovery.md) mechanism 1): the interchange files are the *only* plaintext the app ever writes, and only because the user explicitly asks for them. Import reads the picked file straight into memory — no temp copies.

Two formats, one canonical:

- **JSON** — canonical, versioned, importable; a precursor of the future sync payload. Envelope: `formatVersion` (currently 1), `schemaVersion` (the writer's DB generation), `appId: "tane"`, then one array per table: `varieties`, `lots`, `vernacularNames`, `externalLinks`, `germinationTests`, `movements`, `parties`, `attachments`. Every row carries its sync metadata verbatim (`id`, `createdAt`, packed-HLC `updatedAt`, `lastAuthor`, `schemaRowVersion`); enums serialize by name. **Photos/docs are embedded as base64** (`bytesBase64`) — the JSON export is the complete copy of the data (~33% size overhead accepted). **Tombstones are excluded**: this is a user-facing export of the live inventory, not the sync wire format (real sync, Block 2, will carry tombstones on its own channel).
- **CSV** — export-only, for spreadsheets. One row per Lot (a variety with no lots gets one row); vernacular names and links joined into single columns; latest germination rate derived; ISO-8601 dates; never any photo bytes. Not importable — the JSON is.

**Species are not exported.** The bundled catalog rows get per-install ids, so `species_id` is not portable. Each exported variety carries a denormalized `speciesScientificName`; the importer re-resolves it against its own catalog by scientific name (no match → `species_id` stays null).

**Import = merge by id (UUIDv7), never duplicating.** Mutable rows: unknown id → insert preserving the original metadata (no re-stamping); known id → last-writer-wins on the packed HLC `updatedAt` (§4), incoming wins only if strictly newer — so re-importing the same file is a no-op, and a locally-deleted row only resurrects if the incoming version is newer than the tombstone. `movements` are append-only: insert-if-unknown, never updated. Everything runs in one transaction; afterwards the local HLC clock `receiveEvent`s the newest imported stamp so the next local write out-stamps everything imported.

**Reader compatibility (per §5.2):** unknown JSON fields are ignored; unknown enum values map to a safe default (`lot.type → seed`, `offer_status → private`) or drop the row when none exists (an unknown `movement.type`); a `formatVersion` newer than the reader supports fails with a clear "update the app" error. `formatVersion` changes must be additive and documented here.

Implementation: `apps/app_seeds/lib/data/export_import/` (codecs + reconciler), `VarietyRepository.exportInventory()/importInventory()`, `lib/services/export_import_service.dart`.
