diff --git a/CONFORMANCE.md b/CONFORMANCE.md new file mode 100644 index 0000000..1bfc0bf --- /dev/null +++ b/CONFORMANCE.md @@ -0,0 +1,22 @@ +# Agrinet (Brazil fork) — Conformance self-description + +*Registry IDs: `JFA/jfa-conformance-suite.py`. Agrinet is the built instance here; several rows below are implemented and testable today — the next step is tests that cite these IDs so they stop being self-attested. `docs/mycelium-spec.md` carries the authoritative implementation-status table for the record layer.* + +**Shape:** phase-one mutual-credit bootstrap (Part III, "Switching credit on"): fiat escrow marketplace with the witnessed-ledger discipline running underneath, building visible integrity. + +| ID | Invariant | Status | +|---|---|---| +| REC-1 | Append-only | **Implemented** — dialog transmissions, immutable units (mycelium-spec) | +| REC-4 | Dialog seals complete-and-quiescent | **Implemented** — `sealIfComplete`: ratings given, no open dispute; −1 holds the seal | +| REC-10 | Marked default on rating-window expiry | **Implemented** — system `+2` timeout default, distribution-distinguishable | +| REC-3 | No PII anchored/federated | **Implemented** — refs only in `data`; narratives operator-local (relocation open, OQ-3) | +| REC-5 | Per-operator log | **Implemented** — federation sync of listings/blocks, no global chain | +| Witnessing (REC-6/7, problem 2) | Independent witnesses | **Stand-in, labeled** — single-witness; conduct evidence self-attested (OQ-2) | +| COV-1 | No averaging | **Implemented via LBTAS** — distribution read; fraud score must stay operational, never reputational (OQ-6) | +| ECO-1 | Balances from the sealed record | **Partial** — wallet ledger with audit; derivation-from-dialogs to verify at test time | +| ECO-4/5 | Escrow now; governed switch; earned-never-bought | **Met** — Stripe escrow phase one; switch ungoverned-in-code but unscheduled (OQ-4) | +| SUB-1 | No hosting chokepoint | **Partial** — fully self-hostable compose stack; single-VPS production is a labeled interim (OQ-5) | +| GOV-9 | Living open-questions document | **Met** — `OPEN-QUESTIONS.md` now ships here | +| GOV-10 | Copyleft commons | **Met** — AGPL-3.0; fork provenance recorded (OQ-1) | + +**Honesty line:** the record core runs, but with one witness — every conduct claim is self-attested until federation is real (architecture problem 2). "Implemented" rows become "bound" only when tests citing these IDs land in CI. diff --git a/OPEN-QUESTIONS.md b/OPEN-QUESTIONS.md new file mode 100644 index 0000000..7c9068c --- /dev/null +++ b/OPEN-QUESTIONS.md @@ -0,0 +1,35 @@ +# Agrinet (Brazil fork) — Open Questions + +*The living open-questions deliverable required by Janus-Facing Architecture (standard check 7). Reviewed and re-shipped with each release; staleness is nonconformance. An entry leaves only by resolution or by an explicit values call made by the people who live with the outcome. Invariant IDs cite the registry in `JFA/jfa-conformance-suite.py`.* + +Agrinet is the **built** instance in this folder: a phase-one escrow marketplace (fiat on-ramp, witnessed-ledger discipline underneath) with the dialog-ledger record implemented per `docs/mycelium-spec.md`. + +## 1. Fork relationship with upstream +**Problem.** This tree is the Brazil adaptation (Carlos Zamboni) of NTARI-RAND/Agrinet. Divergence cadence, what flows upstream, and who reviews cross-flow are undefined. +**Constraints inherited.** Provenance is inbound = outbound under AGPL-3.0 (GOV-10): fork freedom is the point, and improvements can flow both ways without assignment to a center. The fork is exit working as designed — keep it legible by recording the relationship, not by restricting it. +**Status.** Open — define a sync/contribution cadence with upstream. + +## 2. Witness federation (architecture problem 2, directly) +**Problem.** The record core is implemented (per-operator log, seal-on-complete-and-quiescent, marked timeout defaults, PII never anchored — see the status table in `docs/mycelium-spec.md`), but it runs single-witness: every conduct read is self-attested. +**Constraints inherited.** A single-witness deployment carries the stand-in label and cannot present itself as federated; an independent witness joins by appending a countersignature, no protocol change. Until two or more independent, long-lived witnesses exist, dispositions must name their evidence as self-attested. +**Status.** Open — inherits network problem 2; Agrinet is a natural first federation site. + +## 3. Narrative store relocation (already named in mycelium-spec) +**Problem.** Rating narratives live in the backend's `rating_narratives` store; the spec's own note says relocate to the front end when one exists, and federation excludes it. +**Constraints inherited.** PII and free text never anchor and never federate (REC-3); the narrative belongs in the erasable, operator-local layer. +**Status.** Open — tracked here so it survives the spec's table. + +## 4. The escrow-to-credit switch +**Problem.** Agrinet settles in fiat escrow via Stripe (phase one). Whether and when member-issued credit enters is undecided. +**Constraints inherited.** The switch is a governed configuration change, never a rebuild (ECO-5), gated by Board approval, membership notice, and a completed regulatory review (bylaws §5.7); credit would be earned, never bought, never redeemable (ECO-4); issuance covenant-gated and capped by a separate limit never derived from harms (ECO-2). Get the regulatory read before any phase-two move — the Brazilian read is its own work, distinct from any US read. +**Status.** Open; escrow phase holding, no switch scheduled. + +## 5. Production hosting posture +**Problem.** Production runs on one VPS behind duckdns, with images on Docker Hub and media on Cloudflare R2 — single-operator, single-host. +**Constraints inherited.** No unremovable hosting chokepoint (SUB-1). The compose stack is fully self-hostable, so the *mechanical* exit exists; the single-VPS production is a labeled stand-in, not an end-state, and the R2/Docker Hub dependencies deserve exit notes (S3-compatible and registry-portable respectively). +**Status.** Open — acceptable interim, label it in ops docs. + +## 6. Fraud scoring vs. the covenant +**Problem.** The antifraude layer computes a fraud score and trust levels — numeric composites adjacent to reputation. +**Constraints inherited.** A score may gate *operational* risk controls (velocity, moderation queue) but must never become the member's reputation: reputation stays the LBTAS distribution, never a number (COV-1); an automated flag is an input to the moderation queue, never an auto-finding (flag-never-finding — the queue already exists, keep the human in it). +**Status.** Open — audit the read paths so fraud score and reputation never merge in any display or gate. diff --git a/backend/migrations/20260709_mycelium_operator_id.js b/backend/migrations/20260709_mycelium_operator_id.js new file mode 100644 index 0000000..c07578d --- /dev/null +++ b/backend/migrations/20260709_mycelium_operator_id.js @@ -0,0 +1,31 @@ +/** + * Mycelium: scope the log per operator (P3-013 §5). There is no global chain — each + * operator keeps its own append-only log. Adds `operator_id` to both tables; existing + * single-backend history adopts this node's operator id (so it becomes that + * operator's log). Cross-operator non-equivocation is by witnessing (§5.1), which + * remains intended-not-built. + */ +const OPERATOR_ID = process.env.OPERATOR_ID || 'agrinet'; + +exports.up = async function up(knex) { + const hasLogCol = await knex.schema.hasColumn('mycelium_log', 'operator_id'); + if (!hasLogCol) { + await knex.schema.alterTable('mycelium_log', (t) => { + t.string('operator_id', 64).notNullable().defaultTo(OPERATOR_ID).after('dialog_id'); + }); + } + const hasAnchorCol = await knex.schema.hasColumn('mycelium_anchors', 'operator_id'); + if (!hasAnchorCol) { + await knex.schema.alterTable('mycelium_anchors', (t) => { + t.string('operator_id', 64).notNullable().defaultTo(OPERATOR_ID).after('dialog_id'); + }); + } + // Existing rows adopt this node's operator id. + await knex('mycelium_log').update({ operator_id: OPERATOR_ID }); + await knex('mycelium_anchors').update({ operator_id: OPERATOR_ID }); +}; + +exports.down = async function down(knex) { + await knex.schema.alterTable('mycelium_log', (t) => t.dropColumn('operator_id')); + await knex.schema.alterTable('mycelium_anchors', (t) => t.dropColumn('operator_id')); +}; diff --git a/backend/services/myceliumService.js b/backend/services/myceliumService.js index 85f6226..1fa7bc4 100644 --- a/backend/services/myceliumService.js +++ b/backend/services/myceliumService.js @@ -4,25 +4,28 @@ * Two append-only hash chains: * - intra-dialog (mycelium_log): each transmission folds into a running head * hash, so an open dialog is tamper-evident as it is built. - * - inter-dialog (mycelium_anchors): sealed dialogs (and post-seal annotations) - * are chained globally, so the set of completed exchanges can't be rewritten. + * - per-operator (mycelium_anchors): sealed dialogs (and post-seal annotations) + * are chained WITHIN THIS OPERATOR'S log (§5) — there is no global chain and no + * consensus. Cross-operator non-equivocation is by witnessing (§5.1, `checkpoint`), + * which is intended-not-built. * - * append() serializes on the dialog tail; seal()/annotate() serialize on the global - * anchor tail. Only references + structural facts are hashed — never PII (P3-013 §6). + * append() serializes on the dialog tail; seal()/annotate() serialize on this + * operator's anchor tail. Only references + structural facts are hashed — never PII. */ const crypto = require('crypto'); const { randomUUID } = require('crypto'); const pool = require('../lib/db'); +const OPERATOR_ID = process.env.OPERATOR_ID || 'agrinet'; const H = (s) => crypto.createHash('sha256').update(s).digest('hex'); // Canonical bytes of one transmission (header-led; refs only). function txnCore({ dialogId, seq, type, actorId, actorRole, dataText }) { return `${dialogId}|${seq}|${type}|${actorId || ''}|${actorRole || ''}|${dataText || ''}`; } -// Canonical bytes of one anchor. -function anchorCore({ kind, dialogId, fileHash, sealedSeq }) { - return `${kind}|${dialogId}|${fileHash}|${sealedSeq}`; +// Canonical bytes of one anchor — bound to the operator so logs cannot be spliced (§5). +function anchorCore({ operatorId, kind, dialogId, fileHash, sealedSeq }) { + return `${operatorId}|${kind}|${dialogId}|${fileHash}|${sealedSeq}`; } // Append a transmission to a dialog's open file. Returns { seq, head }. @@ -39,9 +42,9 @@ async function append(dialogId, { type, actorId = null, actorRole = null, data = const prevHead = tail.length ? tail[0].head_hash : null; const head = H((prevHead || '') + txnCore({ dialogId, seq, type, actorId, actorRole, dataText })); await conn.query( - `INSERT INTO mycelium_log (id, dialog_id, seq, type, actor_id, actor_role, data, head_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [randomUUID(), dialogId, seq, type, actorId, actorRole, dataText, head] + `INSERT INTO mycelium_log (id, dialog_id, operator_id, seq, type, actor_id, actor_role, data, head_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [randomUUID(), dialogId, OPERATOR_ID, seq, type, actorId, actorRole, dataText, head] ); await conn.commit(); return { seq, head }; @@ -58,7 +61,7 @@ async function isSealed(dialogId) { return !!a; } -// Anchor the dialog's current head on the global chain (kind = 'seal' | 'annotation'). +// Anchor the dialog's current head on THIS operator's chain (kind = 'seal' | 'annotation'). async function _writeAnchor(dialogId, kind) { const conn = await pool.getConnection(); try { @@ -67,13 +70,14 @@ async function _writeAnchor(dialogId, kind) { if (!head.length) { await conn.rollback(); return null; } const fileHash = head[0].head_hash; const sealedSeq = head[0].seq; - const [gtail] = await conn.query('SELECT hash FROM mycelium_anchors ORDER BY n DESC LIMIT 1 FOR UPDATE'); + // per-operator tail (not a global chain — §5) + const [gtail] = await conn.query('SELECT hash FROM mycelium_anchors WHERE operator_id = ? ORDER BY n DESC LIMIT 1 FOR UPDATE', [OPERATOR_ID]); const prevHash = gtail.length ? gtail[0].hash : null; - const hash = H((prevHash || '') + anchorCore({ kind, dialogId, fileHash, sealedSeq })); + const hash = H((prevHash || '') + anchorCore({ operatorId: OPERATOR_ID, kind, dialogId, fileHash, sealedSeq })); await conn.query( - `INSERT INTO mycelium_anchors (id, dialog_id, kind, file_hash, sealed_seq, prev_hash, hash) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [randomUUID(), dialogId, kind, fileHash, sealedSeq, prevHash, hash] + `INSERT INTO mycelium_anchors (id, dialog_id, operator_id, kind, file_hash, sealed_seq, prev_hash, hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [randomUUID(), dialogId, OPERATOR_ID, kind, fileHash, sealedSeq, prevHash, hash] ); await conn.commit(); return { kind, file_hash: fileHash, hash, sealed_seq: sealedSeq }; @@ -107,16 +111,17 @@ async function record(dialogId, transmission) { return { appended: true }; } -// Verify both chains: every anchor links and hashes correctly, and each anchor's -// file_hash equals the recomputed intra-dialog head at sealed_seq. -async function verifyChain() { +// Verify THIS operator's log (§8): every anchor links and hashes correctly, and each +// anchor's file_hash equals the recomputed intra-dialog head at sealed_seq. +async function verifyChain(operatorId = OPERATOR_ID) { const [anchors] = await pool.query( - 'SELECT n, dialog_id, kind, file_hash, sealed_seq, prev_hash, hash FROM mycelium_anchors ORDER BY n ASC' + 'SELECT n, dialog_id, kind, file_hash, sealed_seq, prev_hash, hash FROM mycelium_anchors WHERE operator_id = ? ORDER BY n ASC', + [operatorId] ); let prev = null; for (const a of anchors) { if ((a.prev_hash || null) !== prev) return { ok: false, broken_at: `anchor ${a.n}`, reason: 'anchor linkage broken' }; - const expected = H((prev || '') + anchorCore({ kind: a.kind, dialogId: a.dialog_id, fileHash: a.file_hash, sealedSeq: a.sealed_seq })); + const expected = H((prev || '') + anchorCore({ operatorId, kind: a.kind, dialogId: a.dialog_id, fileHash: a.file_hash, sealedSeq: a.sealed_seq })); if (a.hash !== expected) return { ok: false, broken_at: `anchor ${a.n}`, reason: 'anchor hash mismatch' }; const [rows] = await pool.query( @@ -131,7 +136,7 @@ async function verifyChain() { if (h !== a.file_hash) return { ok: false, broken_at: `anchor ${a.n}`, reason: 'file_hash does not match dialog' }; prev = a.hash; } - return { ok: true, anchors: anchors.length }; + return { ok: true, anchors: anchors.length, operator_id: operatorId }; } // The full record for one dialog: its transmissions + its anchors (sealed/annotation). @@ -153,12 +158,38 @@ async function forDialog(dialogId) { async function listAnchors(limit = 50) { const [rows] = await pool.query( - 'SELECT n, dialog_id, kind, file_hash, hash, created_at FROM mycelium_anchors ORDER BY n DESC LIMIT ?', - [Number(limit) || 50] + 'SELECT n, dialog_id, kind, file_hash, hash, created_at FROM mycelium_anchors WHERE operator_id = ? ORDER BY n DESC LIMIT ?', + [OPERATOR_ID, Number(limit) || 50] ); return rows; } +// Produce a signed, monotonic checkpoint of this operator's log head — the unit an +// independent witness co-signs so equivocation becomes self-evident (§5.1, +// Certificate Transparency / RFC 6962). +// +// STATUS: INTENDED, NOT BUILT. Returns the checkpoint BODY only. Signing the body +// with the operator key, publishing to >= 1 independent witness, obtaining a signed +// witness receipt, and serving inclusion proofs on read are the highest-leverage +// federation step and are not implemented here — until they exist, non-equivocation +// rests on there being a single backend. +async function checkpoint(operatorId = OPERATOR_ID) { + const [[row]] = await pool.query( + 'SELECT COUNT(*) AS tree_size, MAX(n) AS max_n FROM mycelium_anchors WHERE operator_id = ?', + [operatorId] + ); + let headHash = null; + if (row.max_n) { + const [[h]] = await pool.query('SELECT hash FROM mycelium_anchors WHERE operator_id = ? ORDER BY n DESC LIMIT 1', [operatorId]); + headHash = h ? h.hash : null; + } + return { + body: { operator_id: operatorId, tree_size: Number(row.tree_size), head_hash: headHash }, + signature: null, // no operator signer wired yet + witnessed: false, // no witness receipt — witnessing not built (§5.1) + }; +} + function safeParse(s) { try { return JSON.parse(s); } catch { return s; } } -module.exports = { append, seal, annotate, record, isSealed, verifyChain, forDialog, listAnchors }; +module.exports = { append, seal, annotate, record, isSealed, verifyChain, forDialog, listAnchors, checkpoint, OPERATOR_ID }; diff --git a/docs/mycelium-spec.md b/docs/mycelium-spec.md index 4bad28a..745272e 100644 --- a/docs/mycelium-spec.md +++ b/docs/mycelium-spec.md @@ -290,8 +290,8 @@ contains the dispute and its dismissal — the forgiveness is on the record, not | Per-transmission operator signing of intra-dialog events | **Not yet** | Sign messages/events (rides with operator onboarding / Cloudflare). | | PII never anchored; narrative operator-local | **Implemented** (only refs in `data`; narrative in `rating_narratives`) | Relocate the narrative store to the front end when Fruitful gains one; federation excludes it. | | Annotate-not-erase; post-seal annotation | **Implemented** (post-seal `record` → `annotation` anchor) | — | -| Per-operator log scoping (`operator_id`) | **Not conformant** (single global anchor chain) | Add `operator_id` to `mycelium_log` + `mycelium_anchors`; scope the chain per operator. | -| Witnessing — signed checkpoints, independent witnesses, inclusion proofs (§5.1) | **Not built** (intended) | Certificate-Transparency checkpointing across operators. Highest-leverage federation step. | +| Per-operator log scoping (`operator_id`) | **Implemented** (both `mycelium_log` + `mycelium_anchors` scoped; chain + verify per operator) | — | +| Witnessing — signed checkpoints, independent witnesses, inclusion proofs (§5.1) | **Not built** (intended; `checkpoint()` body stub present) | Sign + publish checkpoints to independent witnesses; serve inclusion proofs (Certificate Transparency). Highest-leverage federation step. | --- diff --git a/docs/protocol-spec.md b/docs/protocol-spec.md index ddde4ef..55cca71 100644 --- a/docs/protocol-spec.md +++ b/docs/protocol-spec.md @@ -522,8 +522,8 @@ informative, not normative. | §7 escrow gate / maturity / tranches | **Implemented** (`transactionService`) | — | | §9 market/reputation in the protocol, portable | **Implemented** (Agrinet backend holds posts/transactions/ratings/escrow/ledger; Fruitful is swappable UI) | Cross-node reputation federation (multi-backend) not built; single-protocol-many-frontends holds today. | | §1 substrate / no hosting chokepoint | **Partial** (runs on SoHoLINK-capable infra; usually a shared backend) | Package for participant-owned deploy; document the self-host path. | -| §5.1 per-operator log | **Implemented** (single backend) | — | -| §5.2 witnessing / cross-operator non-equivocation | **Not built** (intended) | Signed checkpoints, independent witnesses, inclusion proofs (Certificate Transparency). Highest-leverage federation step. | +| §5.1 per-operator log | **Implemented** (`operator_id`-scoped log + anchor chain; verify per operator) | — | +| §5.2 witnessing / cross-operator non-equivocation | **Not built** (intended; `checkpoint()` body stub present) | Sign checkpoints, publish to independent witnesses, serve inclusion proofs (Certificate Transparency). Highest-leverage federation step. | | §7.2 net-zero balances from the record | **Not conformant** | Balances are a mutable `wallets.balance` (direct `UPDATE`); `wallet_history` is an audit trail, not the source of truth. Delta: append-only wallet-entries ledger, balance = derived sum, each exchange nets to zero. | | §7.3 non-redeemable / non-purchasable / denominated-not-backed | **Flag — purchasability** | No cash-out path exists (conformant on redemption). But Stripe payments credit a *general spendable wallet* (`webhook.js` "Stripe deposit") — fiat buying in-network balance. Delta: bind each payment to a specific transaction's escrow; retire the general fiat top-up (§10, tension protocol). | | §7.4 covenant gate + separate limit | **Not built** (credit off) | Add a limit instrument separate from the harm distribution, gated by the covenant. |