Data model & spec v0.1 draft
This is a design sketch, not an implemented specification. Field names, canonicalisation and the token-family construction are all provisional. Do not build against it expecting stability.
Contents
1. Key hierarchy
All keys are EdDSA (Ed25519), matching Taler's signature scheme.
Registro de Atestadores // signed mirror of SENASA's public registry
├─ issuer key: sgp-arequipa // SGP council
├─ issuer key: oc-controlunion-pe // registered certification body
├─ issuer key: senasa-bpa // state, for Sello BPA
└─ …
├─ attestation → subject_pub // the producer's key
├─ token issue keys (per family, per season, per denomination)
└─ revocation signing key
Producer
└─ merchant instance key = subject_pub // signs contract terms
Distributor / retailer
└─ distributor key // countersigns, never re-signs
The root is deliberately not ours. It is a signed mirror of a list the Peruvian state already publishes[1]. We do not decide who may certify; we transcribe a decision SENASA already made and sign the transcription so it can travel offline. If SENASA ever publishes the registry in a signed machine-readable form, our mirror should be deleted, not kept.
2. Attestation object
| Field | Type | Req. | Definition |
|---|---|---|---|
v | integer | yes | Format version. 1. |
standard | string | yes | Registered standard identifier, e.g. pe.rtpo.organico.sgp. See
mapping table. |
issuer | string | yes | Slug of the issuing body as it appears in the registry mirror. |
issuer_pub | EdDSA pub | yes | Key used for issuer_sig. Must match the registry entry. |
subject_pub | EdDSA pub | yes | The producer's merchant-instance key. The binding that makes the claim non-transferable. |
subject_label | string | yes | Human-readable producer or association name, for display only. Never trusted for verification. |
scope.crops[] | string[] | yes | Crop identifiers the attestation covers. ["*"] permitted for
whole-operation certification. |
scope.parcels[] | string[] | no | Parcel or cadastral references. Omitted where publishing them would expose a producer to risk. |
scope.market | enum | yes | domestic | export | any. An SGP
attestation is domestic, per Ley 30983. |
scope.region | string | no | INEI region/province code, for display and for regional-market rules. |
valid_from | date | yes | ISO 8601. |
valid_until | date | yes | ISO 8601. Short windows are preferred over long ones plus revocation. |
families[] | string[] | no | Token family slugs opened under this attestation. Absent for attestations that carry no quantity ceiling (e.g. BPA). |
audit_ref | string | no | Opaque reference to the inspection or council minute. Resolvable by the issuer, meaningless to anyone else. |
issuer_sig | EdDSA sig | yes | Signature over the canonical form of all other fields. |
Canonicalisation
Signing is over a deterministic serialisation: keys sorted lexicographically, no insignificant whitespace, UTF-8, no floating-point values anywhere. We intend to follow whatever Taler already uses for contract-term hashing rather than introducing a second canonicalisation — two canonical forms in one system is a bug generator. Confirming that is a task, not a decision we have made.
3. Token family slug grammar
<standard> "/" <crop> "/" <season> "/" <issuer>
pe.rtpo.organico.sgp/papa-nativa/2026-A/sgp-arequipa
pe.senasa.bpa/palta-hass/2026/senasa-bpa
pe.libre-pesticidas/lechuga/2026-W31/sgp-lima-norte
Slugs are opaque to Taler, which only uses them as map keys in
token_families.[2] We impose
structure so that a wallet can cross-check a family against an attestation
without a lookup table, and so that a human reading a log can tell what a family
is. Rules:
- Lowercase, ASCII, hyphen-separated segments;
/separates the four components; no empty components. seasonis a year, a year plus campaign letter (2026-A), or a year plus ISO week for short-cycle crops (2026-W31).- A family is append-only within a season: supplementary issuance adds to the same slug rather than creating a parallel one, so the ceiling stays legible as a single number.
- One unit = one kilogram unless the family metadata declares otherwise.
Crate- and bunch-denominated families are permitted and must say so in the
description.
4. The contract extra block
Taler's contract terms carry an extra field for merchant-defined
data that needs to live on the contract without separate
storage.[3] Because it is inside the
signed and hashed contract, anything we put there is covered by
merchant_sig for free.
{
"order_id": "AQP-2026-A-00417",
"summary": "Papa nativa 3 kg — orgánico certificado",
"summary_i18n": {
"es": "Papa nativa 3 kg — orgánico certificado (SGP Arequipa)",
"qu": "Papa 3 kg — allin puquy"
},
"merchant_pub": "J7QK…", // = attestation.subject_pub
"choices": [{
"amount": "PEN:25.00",
"inputs": [{
"type": "token",
"token_family_slug": "pe.rtpo.organico.sgp/papa-nativa/2026-A/sgp-arequipa",
"number": 3 // 3 kg burned from the season ceiling
}],
"outputs": []
}],
"extra": {
"agro_v": 1,
"attestations": [ /* attestation objects, §2 */ ],
"lot": {
"crop": "papa-nativa",
"variety": "huayro",
"net_kg": 3.0,
"harvest_date": "2026-07-11",
"parcel": "UC-04812-A"
},
"split": [
{ "role": "producer", "label": "Chacra Alta", "amount": "PEN:18.60" },
{ "role": "transport", "label": "Corredor Sur", "amount": "PEN:2.10" },
{ "role": "distributor", "label": "Mercado #4 p.112", "amount": "PEN:4.30" }
],
"countersigs": [ /* §5 */ ]
}
}
inputs entry is what burns the kilo-tokens; extra
carries everything a wallet needs to explain the claim to a human.Invariants a verifier must enforce
extra.attestations[*].subject_pub==merchant_pub.extra.lot.crop∈scope.crops(or scope is["*"]).- Every token input's family slug is listed in the matching attestation's
families[], and itsstandardandissuercomponents agree with that attestation. input.number==ceil(extra.lot.net_kg)for kilogram-denominated families. Rounding is up: the system may burn more certified quantity than sold, never less.- Σ
extra.split[*].amount==choices[i].amount, same currency throughout. - Contract
timestamp∈ [valid_from,valid_until].
5. Distributor countersignature
A distributor must be able to add its margin and its identity without touching what the producer signed. The countersignature covers the producer's contract hash plus the distributor's own additions:
{
"role": "distributor",
"pub": "R2MW…",
"label": "Mercado #4, puesto 112",
"h_producer_terms": "…SHA-512 of the producer's contract terms…",
"adds": [{ "amount": "PEN:4.30", "reason": "comision-puesto" }],
"sig": "…EdDSA…"
}
Because h_producer_terms is a hash of the producer's already-signed
object, a distributor can append but never rewrite. Altering the farmgate
figure breaks the hash, which breaks the attestation chain, which removes the
certified claim — the exact thing the distributor is selling. The incentive
and the cryptography point the same way, which is the only kind of rule that
holds up in a market.
Multiple countersignatures chain: transport countersigns the producer, the stall countersigns transport. Each link is verifiable independently.
6. Verification algorithm
verify(contract, registry_mirror, now) → Result
1. h ← SHA-512(canonical(contract))
2. require verify_eddsa(contract.merchant_sig, h, contract.merchant_pub)
3. att ← contract.extra.attestations
require att non-empty and att[*].v == 1
4. for each a in att:
require verify_eddsa(a.issuer_sig, canonical(a minus sig), a.issuer_pub)
require a.subject_pub == contract.merchant_pub
entry ← registry_mirror.lookup(a.issuer)
require entry exists and entry.pub == a.issuer_pub
require entry.status == ACTIVE // see §7
require a.valid_from ≤ now ≤ a.valid_until
require contract.extra.lot.crop in a.scope.crops
5. for each input of type "token" in the selected choice:
require slug in (union of a.families for matching a)
require input.number ≥ ceil(lot.net_kg)
6. require sum(extra.split[*].amount) == choice.amount
7. for each cs in extra.countersigs:
require verify_eddsa(cs.sig, canonical(cs minus sig), cs.pub)
require cs.h_producer_terms == h
8. return Verified {
standard, issuer_label, scope, valid_until,
producer_share = split[role=producer] / total,
mirror_age = now - registry_mirror.fetched_at // surface this
}
Steps 1–7 are pure local computation. mirror_age is returned
rather than hidden because a wallet whose registry is four months stale should
say so, and a UI that renders a confident tick over stale trust data is worse
than one that shows nothing.
7. Revocation
Three independent mechanisms, in order of how much we rely on them:
- Short validity windows. Attestations expire at the end of a season. This is the primary mechanism because it needs no infrastructure and fails closed.
- Token exhaustion. A ceiling reached is a claim ended, with no revocation step at all.
- Explicit revocation list, signed by the issuer and by the registry mirror, fetched opportunistically and cached. Necessary for the case that matters most — a certifier suspended by SENASA mid-season — and weakest exactly there, because it depends on the wallet having refreshed.
We deliberately do not require an online status check per sale. An OCSP-style
round trip would leak purchase patterns to the certifier and would fail in the
markets we care about. The cost is a revocation window bounded by mirror
staleness; the mitigation is short validity and honest display of
mirror_age.
8. Key custody open
The hardest unsolved problem in the design, and the one most likely to quietly undo it.
The entire premise is that the producer holds the signing key. In practice that key lives on a phone that may be shared, sold, lost, or held by whichever family member is most comfortable with technology. Constraints:
- Losing a key must not lose the certification. Rotation has to be a
routine, cheap operation at the issuer — a re-attestation to a new
subject_pub, not a re-audit. - Custody by an aggregator recreates the problem. A cooperative that holds members' keys "for convenience" is the retailer relationship in new clothing. Threshold or co-signature schemes are a possible answer, and they add operational complexity to a system that already asks a lot of its users.
- Shared phones are the norm, not the exception. Designing for one-person-one-device is designing for someone else's countryside.
No decision. Tracked as OQ-5.
9. Privacy properties
| Party | Learns | Does not learn |
|---|---|---|
| Certifier / SGP council | How much of its issued quota was consumed, over time | Which retailer sold it, which shopper bought it, at what price — blind signatures make the tokens unlinkable to issuance |
| Producer | Its own sales | Shopper identity |
| Distributor | Its own sales and the producer's declared farmgate price | Shopper identity |
| Exchange | That coins and tokens were spent; settlement to merchants | Which withdrawal a coin came from — payer anonymity is Taler's core property |
| Shopper's wallet | Everything on the contract | — and it reports none of it anywhere |
| agro.yaya.cash operators | Nothing. We publish a registry mirror and a spec. We are not in the transaction path and must never be | Everything |
A dataset of who buys certified produce, where, at what premium, is commercially valuable and would compromise the reason for the design. The protocol is built so that no party is in a position to assemble it — not because we promise not to, but because the blind signatures make it unavailable. Any future proposal to aggregate and sell transaction data has to answer to Ley 29733 on personal data protection and, before that, to this paragraph.
See also: Protocol overview · GNU Taler primer · Open questions
References
- SENASA registry of certification bodies and SGPs. gob.pe
- GNU Taler, DD 46: Contract Format v1 —
token_families, inputs and outputs. docs.taler.net - GNU Taler, Merchant Backend RESTful API —
extra,h_contract_terms,merchant_sig. docs.taler.net