tane/docs/design/data-model.md

213 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`).
> **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). |
| `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_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.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. Nostr/keypair identity (Layer 3+). |
| `kind` | enum | `person` / `collective`. |
| `note` | TEXT | |
### 2.6 `SeedBank` — a collective bank (optional, Layer 23)
`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 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.)