tane/docs/design/plantare-bilateral.md
vjrj 4ab146b157 docs(plantare): mark bilateral signed record shipped
Flip plantare-bilateral.md from "design, not yet built" to "v1 shipped",
summarize what landed (transport / schema v12 / service / UI), resolve
the open questions inline with the decisions made, and list the honest
follow-ups (durable outbox, accept-after-restart, rating anchor,
provenance DAG, paper/group/forgiveness sync).
2026-07-14 11:23:09 +02:00

201 lines
12 KiB
Markdown
Raw Permalink 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.

# Plantaré — the bilateral signed record
Status: **v1 shipped (2026-07-14).** The two-sided signed Plantaré — the "formulario
bilateral firmado" other docs waited on — is now built end-to-end: transport in
`commons_core`, schema v12 + repo, `PlantareService`, and UI (propose from a chat,
accept/decline on the ledger). What's left is noted under "Deferred to a follow-up"
below. This doc keeps the original design for reference; the "Open questions" are now
resolved inline.
Built (commit series `feat(plantare): …`, 2026-07-14):
- **Transport** — `PlantarePledge` (canonical, byte-stable core + two BIP-340 Schnorr
stubs), `PlantareCrypto` (sign/verify over SHA-256 of the core), `PlantareTransport` +
`NostrPlantareTransport` (propose→accept→counter-sign / decline over NIP-17 gift wrap
carrying a tagged JSON payload; the message inbox skips these and vice-versa).
- **Schema v12** — the deferred columns on `Plantares` (`pledgeId`, `debtorKey`,
`creditorKey`, `debtorSignature`, `creditorSignature`, `movementId`, `remoteState`,
`returnKind`, `workHours`), all nullable/defaulted so v1 local rows are untouched;
guarded migration + schema dump + migration test.
- **`PlantareService`** — signs my stub, sends, and reconciles incoming moves into the
ledger (verifies stubs before storing; pending proposals held in memory keyed by
pledge id, relay redelivers on reconnect).
- **UI** — propose sheet reached from the chat overflow menu; accept/decline + handshake
badges (awaiting signature / signed by both / declined) on the Plantares screen.
This doc originally specified the two-sided Plantaré so a later session could build it
without re-deciding. It is the "formulario bilateral firmado" that other docs wait on:
- [`data-model.md` §2.7](data-model.md) — the `Plantare` entity, whose `debtor_key`,
`creditor_key`, `debtor_signature`/`creditor_signature` and `movement_id` columns are
specified there but **deferred** in the shipped Drift table.
- [`sharing-model.md` §1, §6](sharing-model.md) — the Plantaré as the *private bilateral
close* of an exchange; and the open question of anchoring reputation to a closed deal.
- [`PLAN.md` §5-bis](../../PLAN.md) — the paper original (BAH-Semillero, 2009): "partida
doble repartida entre dos bolsillos", device-to-device, provenance DAG.
- [`open-decisions.md`](open-decisions.md) — strong rating anchoring "espera al formulario
bilateral firmado".
## What ships today (v1, local-only)
A private local ledger only. `apps/app_seeds` `Plantares` table stores `varietyId`,
`direction` (`iReturn` / `owedToMe`), `counterparty` (**free text**), `owedDescription`,
`madeOn`, `dueBy`, `status`, `note`. No identity keys, no signatures, no transport — the
other party gets **no record**. The UI now surfaces the linked variety and the dates
(Phase 1), but it is still one-sided.
## The paper original (v0.4, transcribed)
Source: BAH-Semillero, *Plantaré — moneda comunitaria de intercambio de semilla*, v4.0,
© 20092010, CC-BY-SA 3.0 (`plantare.ourproject.org`). The printed form is **two matching
stubs** ("partida doble") on one sheet:
**Stub A — "CORTAR Y ENTREGAR A CAMBIO DE LAS SEMILLAS"** (cut off and handed over with the
seeds; the **giver keeps it** as proof of what they're owed):
- **Yo** ______ (your name)
- **localizable en** ______ (*email, tel., etc.* — contact info)
- **recibo** ______ (*cantidad de semillas o lo que sea que recibo*)
- **y me comprometo a devolver antes de (mes/año):** __ / 20__
- return options (*choose those that apply*):
- `[ ]` una **cantidad similar\*** de la recibida — *(\*) similar = no hibridada, no
transgénica, cultivada en ecológico*
- `[ ]` ó un **trabajo de ___ horas** equivalente
- `[ ]` ó ______ (free text — anything else)
- **Firmado,** ______ **el** __ / __ / 20__ (signature + date)
**Stub B — "RESGUARDO QUE ME QUEDO"** (the receipt the **receiver keeps** to remember what
they owe): person/collective owed, quantity received, and "lo devolveré antes de __/20__".
What the paper captures that v1 does **not** yet:
- **Contact** of the party ("localizable en") — v1 stores only a free-text name. Digitally
this becomes the **Nostr pubkey** (reachable by DM); keep a free-text contact for the
offline/paper case.
- **Structured return options** instead of one free-text line: *similar quantity* (default),
*equivalent work in N hours*, or *other*. The work-hours option is time-as-currency — it
overlaps Tane's hand-over payment model, which already allows a time/`tiempo` currency
([project-sales]; €/Ğ1/time/none).
- **"Similar" is a defined term** — open-pollinated, non-GMO, organically grown — which ties
to Tane's existing organic/eco self-declaration flag. The default return preset should
carry this meaning, not just the word.
- **Return-by is month/year**, matched to cultivation cycles (v1 stores a full day; Phase 1's
picker is day-precision — display can round to month/year).
## The goal
Turn the one-sided note into the paper Plantaré's **two matching signed stubs**: when two
people close an exchange, both devices end up holding the *same* commitment, each signed by
both, so either can prove "recibí X, devolveré una cantidad similar antes de la fecha Y".
Non-goals: interest, enforcement, debt framing (microcopy stays "promise, not debt");
a central server or global ledger (it stays device-to-device / relay-as-enrichment).
## Identity — counterparty becomes a real key
Replace (or rather, *augment*) the free-text `counterparty` with the counterparty's **Nostr
pubkey / `Party`**, reusing the identity already present in the social layer (offers, chat).
The flow starts from an existing conversation or a closed offer, so the pubkey is in hand.
Keep the free-text field as the offline/paper fallback (a Plantaré made face-to-face with
someone who has no app, or ingested from a paper form / QR).
## Schema delta (versioned Drift migration)
Add the deferred columns to `Plantares`, nullable so v1 rows coexist untouched:
- `debtorKey` TEXT — pubkey of who received and owes a return.
- `creditorKey` TEXT — pubkey of who gave.
- `debtorSignature` / `creditorSignature` TEXT — signatures over the canonical payload
(both "stubs of the paper").
- `movementId` TEXT → `Movement` — the shared `given` event this accompanies (the
`Movement.plantareId` link already exists on the other side).
- `remoteState` enum — `proposed` / `accepted` / `declined`, distinct from `status`
(`open`/`returned`/`forgiven`), which tracks the *promise*, not the *handshake*.
From the paper form:
- ~~`contact` (the paper's "localizable en", email/phone)~~ — **decided NOT needed**
(2026-07-13). It's a paper-era artifact: digitally the counterparty is a Nostr pubkey,
reachable by DM, so a manual contact field is redundant. Offline/paper Plantarés keep it
only inside the free-text `counterparty`.
- `returnKind` enum — `similar` / `workHours` / `other`, mirroring the form's checkboxes
(default `similar`, meaning open-pollinated · non-GMO · organically grown — surface that
definition, not just the word). `workHours` REAL for the "trabajo de N horas" option;
`owedDescription` still backs `other`. Note the work-hours option overlaps Tane's hand-over
time/`tiempo` currency — reconcile the two rather than duplicating. Kept for the Phase 2
build; not added to v1.
A v1 local row = both keys/signatures null, `remoteState` null. Migration test in CI per
[`data-model.md` §5](data-model.md).
## Transport — `PlantareTransport` in `commons_core`
A new interface alongside `OfferTransport` / `MessageTransport` / `TrustTransport` /
`RatingTransport` / `SyncTransport` / `ReportTransport`
(`packages/commons_core/lib/src/social/`), on the shared `NostrConnection`. Unlike ratings
(public signed events), a Plantaré is **private to the two parties**, so it is *encrypted*,
like DMs/sync. Sketch, mirroring the existing `*Transport` shape:
```dart
abstract interface class PlantareTransport {
/// Send a signed proposal (my signature only) to [counterpartyPubkey].
Future<void> propose(PlantarePledge pledge);
/// Counter-sign a received proposal and send the fully-signed copy back.
Future<void> accept(PlantarePledge pledge);
/// Decline a received proposal.
Future<void> decline({required String pledgeId, String reason});
/// Incoming proposals / accepts / declines addressed to this identity.
Stream<PlantareEnvelope> incoming();
Future<void> close();
}
```
**Handshake:** propose → accept → counter-sign. A proposes (signs, encrypts to B); B reviews,
accepts (adds B's signature), sends the doubly-signed copy back; both persist the identical
row (`remoteState = accepted`). Decline leaves A's row `declined`.
**Channel:** prefer **NIP-17 encrypted DM** (already used for messaging) carrying a canonical
JSON payload — least new surface, rides the existing connection. A dedicated event kind is an
alternative if we want first-class filtering. **Offline QR/NFC** device-to-device (PLAN.md's
favoured, most-decentralized path) is the same payload exchanged without a relay — the
transport abstracts channel from payload. Relays are enrichment only; offline must fully work.
## Provenance DAG
Each accepted Plantaré links parent→child (this lot came from that exchange), forming the
variety's distributed **procedence DAG** ([PLAN.md §5-bis](../../PLAN.md)) — "un grafo de
commits, pero de semillas". `movementId` + the counterparty's prior movement give the edge.
## Reputation anchor
An `accepted` Plantaré is the **strong anchor** for a rating (both parties provably closed a
real deal), upgrading the current soft, per-conversation anchoring — closing the
[`open-decisions.md`](open-decisions.md) / [`network-trust.md`](network-trust.md) item.
## Resolved in the v1 build (2026-07-14)
- **Conflict / divergence:** RESOLVED as anticipated — the two signatures cover the
canonical core (everything except the signatures), and the `pledgeId` is part of that
core. Any edit changes the signing hash, so an accept always closes the *exact* payload
proposed; a changed pledge is a different id and must be re-proposed. `PlantareCrypto`
enforces this; the "editing after signing invalidates the stub" test pins it.
- **Revocation / forgiveness sync:** kept **one-sided** for v1 — `status`
(`open`/`returned`/`forgiven`) stays each party's own view, distinct from the shared
`remoteState` handshake. Propagating a forgive to the counterparty is a follow-up (it'd
need a signed "released" move).
- **Expiry:** `dueBy` passing is a **UI nudge only** (overdue styling), as in Phase 1 — it
does not change `remoteState`.
- **Paper ingest:** the schema already supports it (nullable keys/signatures + free-text
`counterparty`); no UI path yet — deferred.
- **Group/collective party:** deferred — still tied to the collective-identity question in
`sharing-model.md` §6.
## Deferred to a follow-up
- **Durable send outbox.** Propose/accept send best-effort over the current session; if
offline the local row is recorded but the move isn't queued for retry. A durable outbox
(like `offer_outbox`) should back it so a proposal made offline goes out on reconnect.
- **Accept after restart before redelivery.** Pending proposals live in memory (refilled by
NIP-17 redelivery on reconnect); accepting one the relay hasn't redelivered yet shows a
gentle "offline / reconnecting" nudge rather than reconstructing the pledge from the row.
- **Rating anchor.** An `accepted` Plantaré is now a provable closed deal, but the rating
flow isn't yet wired to require/prefer it (the "strong anchor" upgrade).
- **Provenance DAG.** `movementId` is stored; building the distributed procedence graph
from accepted pledges is not done.
- **Forgiveness/paper/group** as above.