15 KiB
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 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. In short:Variety,Lot,Movement,Speciesand 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 onMovement(§2.4) andPlantare(§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.labelis 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.
Movementrows 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
LedgerEntryofcommons_core(see core-domain-boundary.md §2, §4.3);Movementis its seeds-domain shape. At v0 it lives whole inapp_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 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.
Plantareis the seeds-domain name for the genericPledge(return promise) ofcommons_core— see core-domain-boundary.md §2, §5. The generalization usesPledge.return_kind = similarfor seeds (return something alike) vs.same_itemfor 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.
| 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.
Movementlog → 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:
schemaVersioninteger on the database; bump on every schema change.- Step-by-step migrations:
dart run drift_dev schema steps drift_schemas/ lib/db/schema_versions.dartgeneratesfrom1To2,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 generatebuilds old-version DBs;validateDatabaseSchemaasserts 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_versionper 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)
quantitymodelled once and reused on Lot and Movement, or duplicated? (Lean: a shared embedded type.)- Is
offer_statusonLotenough, or does aVariety-level default help? (Lean: Lot only.) - Do we need
Variety-level provenance, or is per-Lot/Movement provenance sufficient? (Lean: Movement DAG is enough.) categoryas free enum vs linked toSpecies.familywhen a species is set. (Lean: free, prefilled from family when available.)