tane/docs/design/plantare-bilateral.md
vjrj adf396d49d feat(plantare): surface product link + dates; spec the bilateral record
The Plantaré screen stored madeOn/dueBy/varietyId but showed none of them,
so it felt half-built. Surface what's already there (no schema change):

- watchPlantareViews() joins each commitment with its variety label
- tile shows the made-on date, a "Return by {date}" line (amber + "overdue"
  when an open promise is past due, via new seedWarning colour), the variety
  name, and taps through to /variety/:id
- add sheet gains an optional return-by date picker (createPlantare already
  took dueBy)
- variety detail shows a read-only commitments section (hidden when empty,
  no add button — honours the single hand-over door)
- i18n en/es/pt/ast; tests for the join, dueBy round-trip, tile, and section

Also specs the deferred two-sided record in docs/design/plantare-bilateral.md
(Nostr pubkey counterparty, PlantareTransport propose->accept->counter-sign,
deferred key/signature/movement columns, provenance DAG, reputation anchor),
with a faithful transcription of the paper Plantaré v0.4 (BAH-Semillero 2009,
CC-BY-SA). Cross-linked from data-model §2.7 and sharing-model §6.
2026-07-13 18:06:33 +02:00

9 KiB
Raw Blame History

Plantaré — the bilateral signed record (design, not yet built)

Status: design locked, implementation deferred. This doc specifies the two-sided Plantaré so a later session can build it without re-deciding. It is the "formulario bilateral firmado" that other docs wait on:

  • data-model.md §2.7 — 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 — 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 — the paper original (BAH-Semillero, 2009): "partida doble repartida entre dos bolsillos", device-to-device, provenance DAG.
  • 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.

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:

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) — "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 / network-trust.md item.

Open questions (for the build session)

  • Conflict / divergence: if A and B end up with mismatched payloads (edited after propose), which wins? Likely: accept is over an immutable hash; any change re-proposes.
  • Revocation / forgiveness sync: forgiven is one-sided today; should it propagate to the counterparty's copy, or stay each party's own view?
  • Expiry: does dueBy passing change remoteState, or only the UI nudge (as in Phase 1)?
  • Paper ingest: signature model for a Plantaré where one side has no key (photo/QR of a paper stub) — store as creditorSignature null, counterparty free text.
  • Group/collective party: a seed bank (not a person) as creditor — ties to the collective-identity question in sharing-model.md §6.