# Tanemaki — 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`). ## 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). | | `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). | | `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_label`/`quantity_precise`. 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.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. | 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. Nostr/keypair identity (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. | 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 Tanemaki 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.)