diff --git a/README.md b/README.md index 7441841..eb0a6a7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,110 @@ # Mycelium -Mycelium β€” the per-operator, witnessed, append-only record layer for Janus-Facing Architecture networks (NTARI). Spec + JS reference implementation. AGPL-3.0. + +**The per-operator, witnessed, append-only record layer for Janus-Facing Architecture networks.** + +Mycelium is the *record* layer of a mutual-credit network built to the [Janus-Facing +Architecture](https://github.com/NTARI-RAND) standard: the immutable, tamper-evident +account of *what happened* between participants β€” transactions, ratings, and +adjudications β€” that the reputation layer (LBTAS) reads from and the economy layer +settles against. Its one job is to make the record of an exchange **legible, portable, +and impossible to quietly rewrite**. + +Its design philosophy is **detection, not prevention**: tampering is not made +impossible, it is made *evident*. + +- πŸ“„ **[SPEC.md](SPEC.md)** β€” the normative specification (P3-013). This repository is its canonical home. +- 🧩 **[mycelium.js](mycelium.js)** β€” a storage-agnostic JavaScript reference implementation. +- ▢️ **[example.js](example.js)** β€” a runnable demo + smoke test of the guarantees. + +## The shape of it + +- **The dialog is the atomic unit.** One append-only file per exchange, accumulating the + whole story in order: opening, messages, the money lifecycle, ratings, any audit. +- **Two hash chains.** *Intra-dialog* β€” each transmission folds into a running head hash + (`h_i = H(h_{i-1} β€– Cα΅’)`), so an open dialog is tamper-evident as it is built. + *Per-operator* β€” sealed dialogs chain within one operator's log + (`anchor.hash = H(prev β€– canonical(anchor))`). +- **Seal only when complete and quiescent.** Complete: every party owed a rating has one + (a non-rater gets a *marked* `+2` default, so silence is never read as praise). + Quiescent: no open audit β€” a harm claim (`βˆ’1`) holds the seal open, in both directions, + until it is answered. +- **Annotate, never erase.** A front end may forgive a harm; it can never hide one. A + dismissal is a new, separately-anchored record, not an edit. +- **No PII in the commons.** Only structural facts and references are ever hashed. The + free-text narrative and anything identifying stay operator-local β€” which is what lets an + immutable record coexist with a right to be forgotten. +- **Per-operator, not global.** There is **no** global chain and **no** consensus layer. + Each operator keeps its own log; cross-operator non-equivocation comes from + **witnessing** (signed monotonic checkpoints to independent witnesses β€” the + [Certificate Transparency](https://www.rfc-editor.org/rfc/rfc6962) model). + +## Quick start + +```bash +node example.js +``` + +```js +const { Mycelium } = require('./mycelium'); + +// One instance == one operator's log (there is no global chain). +const myc = new Mycelium({ operatorId: 'op-01' }); + +myc.append('dialog-1', { type: 'dialog_open', actorId: 'buyerA', actorRole: 'buyer', data: { post: 'post-7' } }); +myc.append('dialog-1', { type: 'paid', actorId: 'buyerA', actorRole: 'buyer', data: { ref: 'pay-1' } }); +myc.append('dialog-1', { type: 'rating', actorId: 'buyerA', actorRole: 'buyer', data: { rated_role: 'market_seller', value: 3 } }); + +myc.seal('dialog-1'); // fix the file hash, anchor it on this operator's chain +myc.verify(); // => { ok: true, anchors: 1 } +``` + +`data` MUST carry **references only** β€” never free text or PII (SPEC Β§6). + +### API + +| Method | Purpose | +|---|---| +| `append(dialogId, txn)` | Add a transmission to an open dialog (SPEC Β§3). | +| `seal(dialogId)` | Anchor a complete + quiescent dialog; idempotent (SPEC Β§4). | +| `annotate(dialogId, txn)` / `record(dialogId, txn)` | Post-seal annotation β€” reference the sealed dialog, never edit it (SPEC Β§7). | +| `verify()` | Recompute this operator's chains; report the first break (SPEC Β§8). | +| `forDialog(dialogId)` / `listAnchors(limit)` | Read the record. | +| `checkpoint()` | Produce a checkpoint body for witnessing β€” **stub; not built** (SPEC Β§5.1). | + +The store is pluggable: implement the same append-only interface as `InMemoryStore` +(SQL, KV, or files) and pass it as `store`. The hash and clock are injectable too. + +## Honest status + +Per the JFA method, this repo ships its status plainly rather than performing the +absence of a gap. + +**Built.** The dialog-file model, both hash chains, seal / annotate / verify, tamper +detection, PII-free hashing, post-seal annotation. + +**Not built (intended).** + +- **Witnessing** β€” signed checkpoint publication, independent witnesses, and + cross-operator inclusion proofs. Until this exists, non-equivocation rests on there + being a **single backend**, and a log is effectively one operator's. This is the single + highest-leverage step toward real federation (SPEC Β§5.1). +- **Per-transmission operator signatures** β€” the reference impl records structure and + order; the Agrinet protocol (P3-012 Β§2) defines the rotating-key signing that rides + with operator onboarding. + +**Out of scope.** Mycelium attests the *sequence and integrity* of the record β€” not the +honesty of the *computation inside* a transmission (a rigged match or price). That is +checked by legibility and cheap exit (SPEC Β§9). + +## Related + +- **Janus-Facing Architecture** β€” the builder's guide and the operating brief (NTARI). +- **[Agrinet](https://github.com/NTARI-RAND/Agrinet)** β€” the protocol (P3-012) and the first operator; Mycelium is its record layer. +- **[LBTAS](https://github.com/NTARI-RAND/Leveson-Based-Trade-Assessment-Scale)** β€” the reputation covenant whose rating events are Mycelium records. + +## License + +[GNU AGPL-3.0](LICENSE) β€” free documentation and software, meant to be read, +reimplemented, and contested. + +*Network Theory Applied Research Institute, Inc. β€” 501(c)(3) β€” info@ntari.org* diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..b2b11aa --- /dev/null +++ b/SPEC.md @@ -0,0 +1,299 @@ +# Mycelium: The Immutable Transaction-Record Ledger + +**Network Theory Applied Research Institute** +Document ID: P3-013 Β· Version: 0.3 (Draft) Β· June 2026 +*Companion to P3-011 v2 (Janus Facing Applications), P3-012 (Agrinet Protocol Specification), and the LBTAS specification. Held to* Janus-Facing Architecture *(builder's guide, v2.0) and* Building JFA Software *(v1.0). This repository is the canonical home of this specification; the reference implementation in [`mycelium.js`](mycelium.js) tracks it.* + +--- + +## 0. Purpose + +Mycelium is the **immutable, tamper-evident record of what happened** between +participants β€” the transactions, ratings, and adjudications that make up an exchange. +P3-011 v2 calls a reputation record a *sensor*: a thermometer that surfaces harm +rather than a thermostat that acts on it. Mycelium is the substrate that sensor reads +from. Its single job is to make the record of an exchange **legible, portable, and +impossible to quietly rewrite**, so that the contest the orchestration software runs +(P3-011 Β§2) is run over facts no party can later edit in their own favor. + +Two design commitments follow directly from the JFA argument and govern everything +below: + +1. **Annotate, never erase (P3-011 Β§3.1).** A front end may forgive harm; it must + never be able to hide one. Mycelium has no update and no delete. A dismissal is a + new record, not an edit. +2. **No PII in the commons (P3-011 Β§3.1, Β§3.2).** The ledger anchors structural facts + and references β€” never the free-text narrative or any personally identifying + information. The non-erasable record is, by construction, PII-free; the erasable + PII lives at the front end. This is what lets an immutable record coexist with + privacy law and a right to be forgotten (P3-011 Β§6, research agenda). +3. **Per-operator logs, witnessed β€” detection, not prevention (P3-011 Β§3.1).** There is + **no global chain and no consensus layer** over unrelated exchanges. Each operator + keeps its **own** append-only log and publishes **signed, monotonic checkpoints** to + independent witnesses; equivocation (showing two histories) is thereby made + **evident**, not impossible. This is the Certificate Transparency model (RFC 6962). + Detection is only as strong as a verifier's reach to independent witnesses (Β§5, Β§9). + +Mycelium does **not** attest the honesty of a computation *inside* a record (Β§9). It +attests the **sequence and integrity** of the record itself. + +--- + +## 1. The unit of record: the dialog + +The atomic record is not a single event but a **dialog**: one append-only file per +exchange β€” a transaction, a contract, or a negotiation β€” between users (and, where an +adjudicator intervenes, them too). A dialog is always **anchored to a post** (of any +type); there are no free-floating records. + +A dialog accumulates the *whole story* of one exchange in order: the opening, the +messages, the transaction lifecycle (payment, tranche releases, settlement, +transfer), the PING progress reports, the ratings, and any audit. The immutable unit +is therefore the complete exchange, not a scattering of disconnected events. + +A dialog has two phases: **open** (accumulating) and **sealed** (frozen and anchored). + +--- + +## 2. Transmissions and per-transmission integrity + +A dialog is built from **transmissions** β€” the atomic, operator-signed messages +defined by P3-012 Β§3. Each transmission carries a header-first canonical form +(`v Β· operator_id Β· dialog_id Β· seq Β· type Β· actor_id Β· actor_role Β· ts Β· nonce`, +then body, then two operator signatures). Only protocol fields and **references** +appear in a transmission's canonical bytes; free text (message bodies, the βˆ’1 +narrative) is operator-local and is referenced, never carried (Β§6, P3-012 Β§9). + +Let `C_i` be the canonical bytes of the `i`-th transmission in a dialog. Each +transmission is signed by the operator under the rotating 7-key / 2-signature scheme +(P3-012 Β§2), giving **authenticity** (who said it) and, via the bound +`dialog_id`/`seq`, **position** (where it sits). + +--- + +## 3. The intra-dialog chain (bits recorded as received) + +As each transmission arrives and validates, it is appended to the open dialog file and +folded into a running **intra-dialog hash**, so the record is tamper-evident +incrementally β€” not only at the end: + +``` +h_0 = H(C_0) +h_i = H(h_{i-1} β€– C_i) for i β‰₯ 1 +``` + +`h_i` is the dialog's head after the `i`-th transmission. Because each transmission +chains to the prior one and is independently signed, altering, reordering, dropping, +or injecting any transmission in an open dialog is detectable immediately β€” the head +no longer recomputes, or a signature fails (P3-012 Β§8, out of protocol). This is the +integrity of "bits recorded as they are received." + +`H` is SHA-256 in this version (algorithm-agile; the construction is unchanged if the +hash is upgraded). + +--- + +## 4. Sealing: complete and quiescent + +A dialog seals β€” closes, hashes, and anchors β€” only when it is **both complete and +quiescent**. Money finality and record finality are distinct: a ready-market purchase +settles funds quickly but its dialog seals **late**, when the conditions below hold. + +### 4.1 Complete β€” both parties have a rating + +A dialog is complete when **every party owed a rating has one**, in both directions +(buyer↔seller). A party that does not rate within its **rating window** does not block +completion: on window expiry the protocol emits a **system default rating of `+2`** +("Basic Satisfaction"). Silence is read as an exchange that completed without +complaint β€” not as praise, not as harm. + +A timeout default is a `rating` transmission with `actor_role = system`, **marked as a +timeout default**. It is distribution-distinguishable from an affirmed party rating in +every read (as a dismissal is), so a reputation built partly from silence is never +mistaken for one built from affirmation. (`+2` is the conservative default β€” it does +not inflate reputation; `+3` is the documented alternative. This is the only tunable +constant in the seal rule.) + +### 4.2 Quiescent β€” no open audit on either side + +A `βˆ’1` ("No Trust") from **either** party opens an `audit_open` and **holds the seal +open**. The dialog cannot seal until a matching `audit_resolved` (upheld, or +dismissed-with-annotation) is appended. Sealing therefore waits for adjudication to +finish, in both directions β€” the record is never frozen mid-dispute, and a harm claim +is never sealed away before it has been answered (P3-011 Β§3.1, harm is contestable +both ways). + +### 4.3 The seal + +When complete and quiescent, a final `dialog_seal` transmission: + +1. fixes the **file hash** `F = h_{n-1}` (the intra-dialog head after the last content + transmission); +2. records `F` together with the previous sealed dialog's anchor hash; +3. **anchors** the seal on the global chain (Β§5). + +After sealing, the file is immutable. + +--- + +## 5. The per-operator log and witnessing (the ledger) + +Sealed dialogs are linked into a **per-operator**, append-only chain β€” **not one global +chain, and not a consensus layer**. The *form* of the log is shared (the construction +is domain-independent); each operator runs a **separate** log over that form, scoped by +`operator_id`. Each **anchor** binds a sealed dialog's file hash to the previous anchor +in that operator's log: + +``` +anchor = { operator_id, dialog_id, file_hash F, sealed_at, prev_anchor_hash } +anchor.hash = H(prev_anchor_hash β€– canonical(anchor)) +``` + +Editing or removing any anchored dialog breaks the chain from that point forward, so +the log is tamper-evident as a whole. Anchors are append-only; there is no update and +no delete. A `verify` walks the log, recomputes every link, and reports the first break. + +### 5.1 Non-equivocation by witnessing + +Integrity *across* operators comes from **witnessing**, not from a shared authority. An +operator publishes **signed, monotonic checkpoints** of its log head to independent +witnesses. An operator that shows two different histories thereby emits two +validly-signed, mutually-inconsistent checkpoints β€” a self-evident, attributable, +portable proof that it lied (Certificate Transparency, RFC 6962). A verifier SHOULD +check inclusion proofs cheaply and automatically on read. + +> **Status (honest):** the witness layer β€” checkpoint publication, independent +> witnesses, cross-operator inclusion proofs β€” is **intended, not yet built**. Until it +> exists, non-equivocation rests on there being a **single backend**, and the current +> anchor chain is effectively one operator's log. Building this is the single +> highest-leverage step toward real federation. + +Two chains, two scopes: the **intra-dialog** chain (Β§3) protects the order and content +of one exchange as it is built; the **per-operator** chain (Β§5) protects that operator's +set of completed exchanges against later rewriting, with witnessing (Β§5.1) extending +non-equivocation across operators. + +--- + +## 6. What is anchored β€” and what is not + +Anchored: structural facts and references only β€” transmission types, sequence, +operator and actor identifiers (pseudonymous `user_id`s), amounts, rating *levels*, +hashes, timestamps. + +**Never anchored, never federated, never in the commons:** the free-text rating +narrative and any personally identifying information. These are **operator-local** +(P3-012 Β§9): held at the front end, referenced by id from a transmission, and read +only by a record's own parties and the operator's adjudicator. Because the hashed +canonical transmissions contain references rather than text, **no PII enters the hash +at any level** β€” the intra-dialog head, the file hash, and the chain anchors are all +PII-free. + +Consequence (directly addressing P3-011 Β§6): the immutable layer carries no personal +data, so it can be permanent without colliding with privacy law; the personal data +lives in the erasable, front-end-local layer. "Data sovereignty" here is the right to +carry, answer, and annotate your record β€” not the right to delete another party's +attestation about a shared exchange. + +--- + +## 7. Post-seal adjudication: annotate, never erase + +A dismissal or finding that arrives **after** a dialog has sealed MUST NOT touch the +sealed file. It is recorded as a **new, separately anchored record that references the +sealed dialog by its file hash** β€” an annotation, not an edit. The original sealed +record and its later annotation are both permanent and both visible downstream. A +reputation read computes the *active* distribution (excluding dismissed events) while +still surfacing every dismissed event, annotated with who dismissed it and why +(LBTAS; P3-011 Β§3.1). Forgiveness is expressible; concealment is not. + +--- + +## 8. Verification and portability + +- **Chain integrity:** recompute every anchor from `prev_anchor_hash β€– canonical(anchor)`; + the first mismatch localizes tampering to a dialog. +- **Record integrity:** recompute a sealed dialog's intra-dialog chain from its + transmissions and compare to the anchored `file_hash`; recompute each transmission's + operator signatures. +- **Portability:** because the ledger and the reputation events live in the protocol + (not in any front end), a participant's record is portable across front ends β€” + leaving a front end costs the front end and nothing else (P3-011 Β§3.2). Verification + requires only the protocol's records and the operators' registered public keys, both + of which any participant or reimplementation can read. + +--- + +## 9. Guarantees and non-guarantees + +**Guarantees.** Given the chains and signatures above, Mycelium proves: no transmission +in a dialog was altered, reordered, dropped, or injected; no sealed dialog was changed +after sealing; no anchored dialog was removed or re-ordered in the ledger; and every +transmission was issued by a registered operator. The *sequence and integrity of the +record* are sound. + +**Non-guarantees.** Mycelium does **not** prove that the computation *inside* a +transmission was honest. An operator running modified logic can emit a well-formed, +correctly-ordered, correctly-signed, faithfully-anchored transmission whose *contents* +are a rigged match or a manipulated price. Closing that gap needs reproducible +computation, zero-knowledge proofs, or trusted execution, and is out of scope for +v0.1. The checks that remain real against it are the JFA checks: legibility and cheap +exit β€” a front end whose outputs are suspect can be left, and a reimplementation built +from P3-012 can verify the record independently and stay on the network. + +--- + +## 10. Relationship to the other layers + +- **LBTAS (peer layer):** rating events are Mycelium records; the distribution is read + from them. Immutability + annotate-not-erase + both-ways contestation (P3-011 Β§3.1) + are enforced here. +- **Escrow / settlement:** `paid`, `tranche_released`, `escrow_settled`, `refunded`, + `contract_transferred` are dialog transmissions; the money lifecycle is part of the + sealed record. +- **Operator keys (P3-012 Β§2):** supply per-transmission authenticity; Mycelium supplies + order and immutability. +- **Federation & witnessing:** each operator keeps its own log and publishes signed + checkpoints to independent witnesses (Β§5.1); anchors and structural records MAY + federate across nodes, operator-local narratives MUST NOT. Non-equivocation is by + witnessing, never by a global chain or consensus. + +--- + +## 11. Worked example + +*Ready-market purchase.* `dialog_open` (post anchor) β†’ `transaction_created` β†’ +`paid`. Buyer confirms with a `+3` `rating` β†’ `escrow_settled` to the producer +(money finalizes). The producer has not yet rated the buyer; the dialog stays **open** +through the producer's window. The window expires β†’ a marked `+2` system `rating` is +emitted. No audit is open β†’ the dialog is now complete and quiescent β†’ `dialog_seal` +fixes the file hash and anchors it. *Settlement was fast; the seal was not.* + +*Disputed contract.* A `plan_producer` contract runs through PINGs and tranche +releases. At delivery the buyer rates `βˆ’1` with a narrative (operator-local) β†’ +`audit_open`; the seal is held. An adjudicator dismisses it as bad-faith β†’ +`audit_resolved(dismissed)`; the dismissed `βˆ’1` remains visible, annotated. The +producer rates the buyer `+2`. Complete and quiescent β†’ seal + anchor. The sealed file +contains the dispute and its dismissal β€” the forgiveness is on the record, not hidden. + +--- + +## 12. Implementation status (informative) + +| Element | Status | Delta | +|---|---|---| +| Dialog-file ledger (`mycelium_log` + `mycelium_anchors`); intra- + inter-dialog chains; `append`/`seal`/`annotate`/`verify` | **Implemented** (`services/myceliumService.js`) | β€” | +| Money-lifecycle + ratings recorded as dialog transmissions | **Implemented** (after-commit hooks in `transactionService` / `contractService`) | β€” | +| Dialog openβ†’seal lifecycle (Β§1–§5) | **Implemented** | β€” | +| Seal = complete **and** quiescent; `βˆ’1` holds the seal | **Implemented** (`sealIfComplete`: `rating_given` + no open dispute + no active contest; contest/uphold/dismiss resolve the audit) | β€” | +| Rating-window timeout β†’ marked `+2` default (Β§4.1) | **Implemented** (`applyRatingTimeouts`; seeded `system` rater; per-role `defaulted` count) | β€” | +| 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. | + +--- + +*Network Theory Applied Research Institute, Inc. β€” 501(c)(3) β€” info@ntari.org* +*Free documentation under the project's AGPL-3.0 commons β€” meant to be read, reimplemented, and contested.* diff --git a/example.js b/example.js new file mode 100644 index 0000000..173c63b --- /dev/null +++ b/example.js @@ -0,0 +1,89 @@ +/** + * Mycelium reference implementation β€” runnable demo + smoke test. + * + * node example.js + * + * Walks the worked examples from SPEC Β§11 and asserts the guarantees of Β§9: + * append-in-order, seal, chain verification, tamper detection, and post-seal + * annotation (annotate-never-erase). + */ +'use strict'; + +const assert = require('assert'); +const { Mycelium, InMemoryStore } = require('./mycelium'); + +let n = 0; +const ok = (label) => { n++; console.log(` βœ“ ${label}`); }; + +// Deterministic clock so runs are reproducible. +let t = 1_700_000_000_000; +const clock = () => (t += 1000); + +const myc = new Mycelium({ operatorId: 'op-agrinet-01', store: new InMemoryStore(), now: clock }); + +console.log('Mycelium reference implementation β€” demo\n'); + +// ---- 1. Ready-market purchase: fast settlement, late seal (SPEC Β§11) ---- +console.log('1) ready-market purchase'); +const d1 = 'dialog-0001'; +myc.append(d1, { type: 'dialog_open', actorId: 'buyerA', actorRole: 'buyer', data: { post: 'post-77' } }); +myc.append(d1, { type: 'transaction_created', actorId: 'buyerA', actorRole: 'buyer', data: { amount: 100, qty: 1 } }); +myc.append(d1, { type: 'paid', actorId: 'buyerA', actorRole: 'buyer', data: { ref: 'pay-1', escrow_locked: true } }); +myc.append(d1, { type: 'rating', actorId: 'buyerA', actorRole: 'buyer', data: { rated_role: 'market_seller', value: 3 } }); +myc.append(d1, { type: 'escrow_settled', actorId: 'system', actorRole: 'system', data: { beneficiary: 'sellerB', amount: 100 } }); +// producer's window expires -> marked +2 system default (SPEC Β§4.1) +myc.append(d1, { type: 'rating', actorId: 'system', actorRole: 'system', data: { rated_role: 'market_buyer', value: 2, timeout_default: true } }); +const seal1 = myc.seal(d1); +assert.ok(seal1 && seal1.hash, 'dialog 1 sealed'); +assert.strictEqual(myc.forDialog(d1).sealed, true); +ok('appended 6 transmissions in order, then sealed'); + +// ---- 2. Disputed contract: -1 holds the seal; dismissal is annotated, not erased ---- +console.log('2) disputed contract'); +const d2 = 'dialog-0002'; +myc.append(d2, { type: 'dialog_open', actorId: 'backerC', actorRole: 'plan_backer', data: { post: 'post-88' } }); +myc.append(d2, { type: 'contract_created', actorId: 'backerC', actorRole: 'plan_backer', data: { amount: 500, settlement: 'tranches' } }); +myc.append(d2, { type: 'ping', actorId: 'producerD', actorRole: 'plan_producer', data: { ref: 'ping-1' } }); +myc.append(d2, { type: 'rating', actorId: 'backerC', actorRole: 'plan_backer', data: { rated_role: 'plan_producer', value: -1, narrative_ref: 'nar-9' } }); +myc.append(d2, { type: 'audit_open', actorId: 'backerC', actorRole: 'plan_backer', data: { target: 'rating-x' } }); +myc.append(d2, { type: 'audit_resolved', actorId: 'adminE', actorRole: 'adjudicator', data: { outcome: 'dismissed' } }); +myc.append(d2, { type: 'rating', actorId: 'producerD', actorRole: 'plan_producer', data: { rated_role: 'plan_backer', value: 2 } }); +const seal2 = myc.seal(d2); +assert.ok(seal2 && seal2.hash, 'dialog 2 sealed after audit resolved'); +ok('the -1 and its dismissal are both on the sealed record (annotate, never erase)'); + +// ---- 3. The per-operator chain verifies ---- +console.log('3) verification'); +let v = myc.verify(); +assert.deepStrictEqual(v, { ok: true, anchors: 2 }, 'chain verifies with 2 anchors'); +ok('verify() ok, 2 anchors'); + +// ---- 4. Tamper detection: mutate a sealed transmission's stored bytes ---- +console.log('4) tamper detection'); +const victim = myc.store.log.find((r) => r.dialogId === d1 && r.type === 'escrow_settled'); +victim.dataText = victim.dataText.replace('100', '9999'); // silently change the settled amount +v = myc.verify(); +assert.strictEqual(v.ok, false, 'tamper is detected'); +assert.match(v.broken_at, /dialog-0001/, 'tamper localized to the altered dialog'); +ok(`verify() caught it: ${v.reason} @ ${v.broken_at}`); +victim.dataText = victim.dataText.replace('9999', '100'); // repair for the next step +assert.strictEqual(myc.verify().ok, true, 'repaired'); + +// ---- 5. Post-seal annotation: a late dismissal references the sealed dialog, never edits it ---- +console.log('5) post-seal annotation'); +const beforeSeq = myc.forDialog(d1).transmissions.length; +myc.record(d1, { type: 'audit_resolved', actorId: 'adminE', actorRole: 'adjudicator', data: { outcome: 'dismissed', note_ref: 'nar-late' } }); +const after = myc.forDialog(d1); +assert.strictEqual(after.transmissions.length, beforeSeq + 1, 'annotation appended'); +assert.ok(after.anchors.some((a) => a.kind === 'annotation'), 'annotation anchored separately'); +assert.strictEqual(myc.verify().ok, true, 'chain still verifies after annotation'); +ok('late dismissal recorded as a new, separately-anchored transmission'); + +// ---- 6. Witness checkpoint stub (SPEC Β§5.1 β€” intended, not built) ---- +console.log('6) witness checkpoint (stub)'); +const cp = myc.checkpoint(); +assert.strictEqual(cp.witnessed, false, 'checkpoint is unwitnessed (not built)'); +assert.strictEqual(typeof cp.body.tree_size, 'number', 'checkpoint has a monotonic tree_size'); +ok(`checkpoint tree_size=${cp.body.tree_size}, witnessed=${cp.witnessed} (witness layer not built)`); + +console.log(`\nAll ${n} checks passed.`); diff --git a/mycelium.js b/mycelium.js new file mode 100644 index 0000000..c23cd28 --- /dev/null +++ b/mycelium.js @@ -0,0 +1,260 @@ +/** + * Mycelium β€” reference implementation (P3-013). + * + * The per-operator, witnessed, append-only record layer for Janus-Facing + * Architecture networks. This file is the canonical *reference* implementation: + * storage-agnostic, dependency-light, and written to be read. It tracks SPEC.md + * clause-for-clause; where the two disagree, the spec wins. + * + * Two append-only hash chains (SPEC Β§3, Β§5): + * - intra-dialog: each transmission folds into a running head hash, so an open + * dialog is tamper-evident as it is built β€” h_0 = H(C_0), h_i = H(h_{i-1} β€– C_i) + * - per-operator: sealed dialogs (and post-seal annotations) chain within THIS + * operator's log β€” anchor.hash = H(prev_anchor.hash β€– canonical(anchor)) + * + * One Mycelium instance == one operator's log (SPEC Β§5: there is no global chain + * and no consensus). Cross-operator non-equivocation comes from witnessing + * (SPEC Β§5.1) β€” signed, monotonic checkpoints to independent witnesses β€” which is + * INTENDED, NOT BUILT here; see `checkpoint()`. + * + * Only references and structural facts are ever hashed β€” never PII (SPEC Β§6). + * + * Copyright (C) 2026 Network Theory Applied Research Institute + * Licensed under the GNU Affero General Public License v3.0. + */ + +'use strict'; + +const nodeCrypto = require('crypto'); + +const SPEC_VERSION = '0.3'; + +/** Default hash: SHA-256 hex. Algorithm-agile β€” inject your own to upgrade (SPEC Β§3). */ +const sha256Hex = (s) => nodeCrypto.createHash('sha256').update(s).digest('hex'); + +/** Canonical bytes of one transmission (header-led; refs only β€” never PII). SPEC Β§2, Β§6. */ +function txnCore({ dialogId, seq, type, actorId, actorRole, dataText }) { + return `${dialogId}|${seq}|${type}|${actorId || ''}|${actorRole || ''}|${dataText || ''}`; +} + +/** Canonical bytes of one anchor. Bound to the operator so logs cannot be spliced. SPEC Β§5. */ +function anchorCore({ operatorId, kind, dialogId, fileHash, sealedSeq }) { + return `${operatorId}|${kind}|${dialogId}|${fileHash}|${sealedSeq}`; +} + +function stableStringify(data) { + if (data === null || typeof data !== 'object') return JSON.stringify(data); + if (Array.isArray(data)) return `[${data.map(stableStringify).join(',')}]`; + const keys = Object.keys(data).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(data[k])}`).join(',')}}`; +} + +/** + * In-memory append-only store. Swap for a durable one (SQL, KV, files) by + * implementing the same nine methods; all are append-only or read β€” never update + * or delete (SPEC Β§5, "there is no update and no delete"). + */ +class InMemoryStore { + constructor() { + this.log = []; // transmissions: { dialogId, seq, type, actorId, actorRole, dataText, head, at } + this.anchors = []; // anchors (this operator): { n, operatorId, dialogId, kind, fileHash, sealedSeq, prevHash, hash, at } + } + appendTransmission(row) { this.log.push({ ...row, at: row.at }); } + dialogTail(dialogId) { + const rows = this.log.filter((r) => r.dialogId === dialogId); + return rows.length ? rows[rows.length - 1] : null; + } + dialogTransmissions(dialogId, uptoSeq = Infinity) { + return this.log + .filter((r) => r.dialogId === dialogId && r.seq <= uptoSeq) + .sort((a, b) => a.seq - b.seq); + } + appendAnchor(row) { this.anchors.push({ ...row, n: this.anchors.length + 1 }); } + anchorTail(operatorId) { + const rows = this.anchors.filter((a) => a.operatorId === operatorId); + return rows.length ? rows[rows.length - 1] : null; + } + anchorsFor(operatorId) { + return this.anchors.filter((a) => a.operatorId === operatorId).sort((a, b) => a.n - b.n); + } + dialogAnchors(operatorId, dialogId) { + return this.anchorsFor(operatorId).filter((a) => a.dialogId === dialogId); + } + hasSeal(operatorId, dialogId) { + return this.anchorsFor(operatorId).some((a) => a.dialogId === dialogId && a.kind === 'seal'); + } +} + +class Mycelium { + /** + * @param {object} opts + * @param {string} opts.operatorId This operator's id. One instance == one log (SPEC Β§5). + * @param {object} [opts.store] Append-only store (default: InMemoryStore). + * @param {(s:string)=>string} [opts.hash] Hash function (default: SHA-256 hex). + * @param {()=>number} [opts.now] Clock (default: Date.now) β€” injectable for tests. + * @param {(bytes:string)=>string} [opts.sign] Operator signer for checkpoints (optional; witnessing not built). + */ + constructor({ operatorId, store = new InMemoryStore(), hash = sha256Hex, now = () => Date.now(), sign = null } = {}) { + if (!operatorId) throw new Error('Mycelium: operatorId is required (each operator keeps its own log β€” SPEC Β§5).'); + this.operatorId = operatorId; + this.store = store; + this.H = hash; + this.now = now; + this._sign = sign; + } + + /** + * Append a transmission to a dialog's OPEN file (SPEC Β§3). Returns { seq, head }. + * `data` MUST contain references only β€” never free text or PII (SPEC Β§6). + */ + append(dialogId, { type, actorId = null, actorRole = null, data = {} } = {}) { + if (this.store.hasSeal(this.operatorId, dialogId)) { + throw new Error(`Mycelium: dialog ${dialogId} is sealed; use annotate()/record() (SPEC Β§7).`); + } + const dataText = stableStringify(data); + const tail = this.store.dialogTail(dialogId); + const seq = tail ? tail.seq + 1 : 0; + const prevHead = tail ? tail.head : null; + const head = this.H((prevHead || '') + txnCore({ dialogId, seq, type, actorId, actorRole, dataText })); + this.store.appendTransmission({ dialogId, seq, type, actorId, actorRole, dataText, head, at: this.now() }); + return { seq, head }; + } + + isSealed(dialogId) { return this.store.hasSeal(this.operatorId, dialogId); } + + /** Anchor the dialog's current head on THIS operator's chain (SPEC Β§5). */ + _writeAnchor(dialogId, kind) { + const tail = this.store.dialogTail(dialogId); + if (!tail) return null; + const fileHash = tail.head; + const sealedSeq = tail.seq; + const gtail = this.store.anchorTail(this.operatorId); + const prevHash = gtail ? gtail.hash : null; + const hash = this.H((prevHash || '') + anchorCore({ operatorId: this.operatorId, kind, dialogId, fileHash, sealedSeq })); + this.store.appendAnchor({ operatorId: this.operatorId, dialogId, kind, fileHash, sealedSeq, prevHash, hash, at: this.now() }); + return { kind, file_hash: fileHash, hash, sealed_seq: sealedSeq }; + } + + /** + * Seal a COMPLETE and QUIESCENT dialog (SPEC Β§4). Completeness/quiescence is the + * caller's decision (it depends on ratings and open audits β€” see the Agrinet + * protocol, P3-012 Β§4.4); Mycelium fixes the file hash and anchors it. Idempotent. + */ + seal(dialogId) { + if (this.isSealed(dialogId)) return { already: true }; + return this._writeAnchor(dialogId, 'seal'); + } + + /** Post-seal annotation: append, then anchor referencing the sealed dialog β€” never edit it (SPEC Β§7). */ + annotate(dialogId, transmission) { + if (!this.isSealed(dialogId)) throw new Error(`Mycelium: dialog ${dialogId} is not sealed; use append().`); + // annotate bypasses the sealed-guard in append() deliberately (it adds to the tail, then re-anchors) + const dataText = stableStringify(transmission.data || {}); + const tail = this.store.dialogTail(dialogId); + const seq = tail ? tail.seq + 1 : 0; + const prevHead = tail ? tail.head : null; + const head = this.H((prevHead || '') + txnCore({ + dialogId, seq, type: transmission.type, + actorId: transmission.actorId || null, actorRole: transmission.actorRole || null, dataText, + })); + this.store.appendTransmission({ + dialogId, seq, type: transmission.type, + actorId: transmission.actorId || null, actorRole: transmission.actorRole || null, + dataText, head, at: this.now(), + }); + return this._writeAnchor(dialogId, 'annotation'); + } + + /** Append while open; once sealed, record as a post-seal annotation (SPEC Β§7). */ + record(dialogId, transmission) { + if (this.isSealed(dialogId)) return this.annotate(dialogId, transmission); + this.append(dialogId, transmission); + return { appended: true }; + } + + /** + * Verify THIS operator's log (SPEC Β§8): every anchor links and hashes correctly, + * and each anchor's file_hash equals the recomputed intra-dialog head at sealed_seq. + * Returns { ok, anchors } or { ok:false, broken_at, reason }. + */ + verify() { + const anchors = this.store.anchorsFor(this.operatorId); + let prev = null; + for (const a of anchors) { + if ((a.prevHash || null) !== prev) return { ok: false, broken_at: `anchor ${a.n}`, reason: 'anchor linkage broken' }; + const expected = this.H((prev || '') + anchorCore({ + operatorId: this.operatorId, kind: a.kind, dialogId: a.dialogId, fileHash: a.fileHash, sealedSeq: a.sealedSeq, + })); + if (a.hash !== expected) return { ok: false, broken_at: `anchor ${a.n}`, reason: 'anchor hash mismatch' }; + + let h = null; + for (const r of this.store.dialogTransmissions(a.dialogId, a.sealedSeq)) { + h = this.H((h || '') + txnCore(r)); + if (r.head !== h) return { ok: false, broken_at: `${a.dialogId} seq ${r.seq}`, reason: 'dialog transmission altered' }; + } + if (h !== a.fileHash) return { ok: false, broken_at: `anchor ${a.n}`, reason: 'file_hash does not match dialog' }; + prev = a.hash; + } + return { ok: true, anchors: anchors.length }; + } + + /** The full record for one dialog: transmissions + anchors + sealed flag. */ + forDialog(dialogId) { + const transmissions = this.store.dialogTransmissions(dialogId).map((r) => ({ + seq: r.seq, type: r.type, actor_id: r.actorId, actor_role: r.actorRole, + data: safeParse(r.dataText), head: r.head, at: r.at, + })); + const anchors = this.store.dialogAnchors(this.operatorId, dialogId).map((a) => ({ + kind: a.kind, file_hash: a.fileHash, sealed_seq: a.sealedSeq, hash: a.hash, at: a.at, + })); + return { transmissions, anchors, sealed: anchors.some((a) => a.kind === 'seal') }; + } + + listAnchors(limit = 50) { + return this.store.anchorsFor(this.operatorId).slice(-limit).reverse().map((a) => ({ + n: a.n, dialog_id: a.dialogId, kind: a.kind, file_hash: a.fileHash, hash: a.hash, at: a.at, + })); + } + + /** + * Produce a signed, monotonic checkpoint of this operator's log head β€” the unit an + * independent witness co-signs so equivocation becomes self-evident (SPEC Β§5.1, + * Certificate Transparency / RFC 6962). + * + * STATUS: INTENDED, NOT BUILT. This returns the checkpoint *body* only. Witnessing + * proper β€” signing the body with the operator key, publishing to >= 1 independent + * witness, obtaining a signed witness receipt, and serving inclusion proofs on read β€” + * is the highest-leverage federation step and is not implemented here. Until it + * exists, non-equivocation rests on there being a single backend (SPEC Β§5.1). + */ + checkpoint() { + const anchors = this.store.anchorsFor(this.operatorId); + const tail = anchors.length ? anchors[anchors.length - 1] : null; + const body = { + operator_id: this.operatorId, + tree_size: anchors.length, // monotonic: only ever grows + head_hash: tail ? tail.hash : null, + spec_version: SPEC_VERSION, + ts: this.now(), + }; + const signature = this._sign ? this._sign(stableStringify(body)) : null; + return { + body, + signature, // null until an operator signer is wired + witnessed: false, // no witness receipt β€” witnessing not built (SPEC Β§5.1) + }; + } +} + +function safeParse(s) { try { return JSON.parse(s); } catch { return s; } } + +module.exports = { + Mycelium, + InMemoryStore, + SPEC_VERSION, + // exported for reuse / conformance tests: + sha256Hex, + txnCore, + anchorCore, + stableStringify, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..cdff048 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@ntari/mycelium", + "version": "0.3.0", + "description": "The per-operator, witnessed, append-only record layer for Janus-Facing Architecture networks. Spec + JS reference implementation.", + "main": "mycelium.js", + "scripts": { + "test": "node example.js" + }, + "keywords": [ + "mycelium", + "janus-facing-architecture", + "append-only", + "tamper-evident", + "certificate-transparency", + "mutual-credit", + "ntari" + ], + "author": "Network Theory Applied Research Institute", + "license": "AGPL-3.0-or-later", + "repository": { + "type": "git", + "url": "https://github.com/NTARI-RAND/Mycelium.git" + }, + "homepage": "https://github.com/NTARI-RAND/Mycelium#readme", + "engines": { + "node": ">=16" + } +}