Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions OPEN-QUESTIONS.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions backend/migrations/20260709_mycelium_operator_id.js
Original file line number Diff line number Diff line change
@@ -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'));
};
81 changes: 56 additions & 25 deletions backend/services/myceliumService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }.
Expand All @@ -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 };
Expand All @@ -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 {
Expand All @@ -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 };
Expand Down Expand Up @@ -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(
Expand All @@ -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).
Expand All @@ -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 };
4 changes: 2 additions & 2 deletions docs/mycelium-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down
4 changes: 2 additions & 2 deletions docs/protocol-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading