Add design docs; update README/PLAN

This commit is contained in:
vjrj 2026-07-07 01:28:54 +02:00
parent ce3a3da81f
commit 86b0c4d251
5 changed files with 553 additions and 0 deletions

207
docs/design/data-model.md Normal file
View file

@ -0,0 +1,207 @@
# 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` (the 2023 batch and the 2024 batch are different lots).
| 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 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.
| 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.)