@metamynd/mmt-graph
v0.19.0
Published
Reference trust-graph engine (Oxigraph/SPARQL + SHACL) + HCS-anchor contract: apply mutation events, validate the MMTO §3 authority-chain invariant, batch the changelog into a Merkle root ready to anchor, and verify a disclosed tenant graph with MetaMynd
Maintainers
Readme
@metamynd/mmt-graph — reference trust-graph engine
The MMTO Phase 1/2 reference implementation: apply mutation events to a validated RDF
graph, and batch the resulting changelog into a Merkle root ready to anchor. See
docs/design/metamynd-trust-ontology.md
Appendix C for the full phased roadmap this package works through.
No Postgres reads/writes anywhere in this package, no MetaMynd backend required. The
MetaMynd-operated hosting mode wires this in as a best-effort, opt-in shadow-write
(backend/src/features/trust-graph/mmt-graph.adapter.ts, gated by MMT_GRAPH_ENABLED,
default off) — but that's one caller among many this package is designed to have, not a
dependency of it. npm test makes no live Hedera calls either — the one exception is
mmt-graph.anchor-live-demo.mjs, a manually-run, not-yet-executed script proving the
anchor contract against real testnet infrastructure (see "Status" below).
Self-hosting quickstart (Phase 4)
This package is designed to run identically whether MetaMynd operates it or you do — same
engine, same event vocabulary, same anchor contract, no MetaMynd credentials or MetaMynd
backend anywhere in the code path. That split was a Phase 1 design constraint, not a later
retrofit: mmt-graph.anchor.mjs never imports @hashgraph/sdk, and nothing in this
package imports a database driver.
npm install @metamynd/mmt-graph
node node_modules/@metamynd/mmt-graph/mmt-graph.self-host-quickstart.mjsThat script runs the whole lifecycle end to end, offline, with zero setup: register your
agents/principals/mandates, validate (SHACL + the §3 authority-chain invariant), query
(it's a real SPARQL 1.1 graph, not a fixed set of report endpoints), batch the event log
into a Merkle root, anchor it (a stub submit by default), and independently verify the
result — the same replay-and-check the Phase 6 auditor runs for a third party, run here for
yourself. Read the file; every step is commented with what a real deployment does
differently.
To anchor for real, replace the quickstart's stub submit with your own Hedera client
— mmt-graph.anchor-live-demo.mjs is the complete, real worked example (topic creation,
transaction signing, receipt handling) against your own operator account.
anchorBatch(batch, submit) is the entire seam: submit takes the canonical batch-root
message and returns {transactionId, status}; nothing else in the package needs to know
Hedera exists. Point it at your own topic (a fresh one, or MMT_GRAPH_TOPIC_ID if you
already have one) and your own testnet or mainnet account — this package has no opinion on
which.
What self-hosting does NOT give you today: a way to get your EXISTING historical
Policy/AgentIdentity/Principal rows into the graph (no backfill mechanism exists in
this package or the MetaMynd adapter — only new writes since you start applying events are
ever recorded), and no durability layer of its own (Oxigraph's JS binding is in-memory
only; your own event log — however you choose to persist it — is the real source of truth,
and replay() is how you rebuild the graph from it on restart). Both are real gaps, not
hidden ones; see Appendix C of the ontology doc for the intended direction.
Once you have events flowing and a batch anchored, a third party (a regulator, an auditor,
you) can verify your tenant's graph state with MetaMynd fully offline — see
mmt-graph.auditor.mjs/mmt-graph.auditor-cli.mjs below.
Files, in the order the phases that produced them shipped
mmt-graph.spike.mjs(Phase 1 spike, PR #382) — the storage-engine decision record. Proved Oxigraph (RDF/SPARQL, in-process WASM) +rdf-validate-shaclworks before the real engine was built. Kuzu was disqualified (archived October 2025 after Apple acquired the company behind it); Apache AGE was viable but a Postgres C-extension distribution burden with no SHACL; CozoDB was genuinely close (native persistence, time-travel queries) but Datalog rather than RDF/SPARQL, and pre-1.0 with no storage-compatibility promise. Oxigraph's JS binding is in-memory only — not disqualifying, because the changelog below is the durable source of truth and the store is a materialized view rebuilt from it, not a workaround.mmt-graph.types.mjs,mmt-graph.project.mjs,mmt-graph.shapes.ttl,mmt-graph.engine.mjs(Phase 1 engine, PR #383) — the real thing.MmtGraphEngineapplies{op, ...}mutation events (agentRegistered/principalRegistered/mandateGranted/mandateRevoked, grounded 1:1 against realPolicy/AgentIdentity/Principalrows — see the doc's domains A/C), projects them to RDF quads, andvalidate()runs the SHACL shapes plus a SPARQLASKmaking the §3 authority-chain invariant runnable for every agent with a mandate, root or delegated.replay()rebuilds from the event log — the "materialized view from a durable log" story made real.mmt-graph.merkle.mjs,mmt-graph.changelog.mjs,mmt-graph.anchor.mjs(Phase 2, PR #384) — turnsMmtGraphEngine#loginto an anchor-ready Merkle batch, and defines the anchor contract.mmt-graph.anchor.mjsnever imports@hashgraph/sdkand makes no network call:anchorBatch(batch, submit)takes an injectedsubmit(message) → {transactionId, status}function, so the actual Hedera client/credentials/topic management stay the hosting operator's problem — a MetaMynd backend wiring in something shaped likebackend/src/features/trust/trust.anchor.ts, or a self-hosted tenant wiring in their own. That split is what keeps this package identical across both hosting modes. Fixed post-Phase-5:mmt-graph.changelog.mjs'sEVENT_LEAF_FIELDS— the explicit, by-hand field list each event's Merkle leaf is built from — drifted out of sync as Phase 5's six slices added fields to existing ops and six wholly new ops, with nothing that actually anchors those events to catch it.agent-registered.principalDid,principal-registered's verification/rep fields, andmandate-granted.expiresAtwere silently EXCLUDED from their own leaf hash (not a crash — worse: a batch could commit to a leaf that omits a real field); the six new ops would have thrown out ofeventLeafentirely. Caught while scoping Phase 6 (a public verifier can't honestly claim to check a tenant's full event log while half the vocabulary is unhashable), fixed with a completeness test that constructs one of every real event op and assertseventLeafaccepts it, plus a regression test proving each previously-dropped field now actually changes the leaf.mmt-graph.anchor-live-demo.mjs— a real, workingsubmitwired against Hedera testnet, using@hashgraph/sdkas a devDependency (never imported bymmt-graph.anchor.mjsitself). Written and offline-test-verified, not yet actually run against testnet — see "Status: pending first live run" below.mmt-graph.evidence-path.mjs(Phase 5, first slice) — proves domain H (runtime activity/evidence) can answerevidence-path.ts's core question — "can a third party verify this decision without us?" — as a graph query. New event types (decisionRecorded/evidenceRecorded/batchAnchored) and their projectors, plusisIndependentlyVerifiable(engine, decisionId), which mirrors the real function's exact precedence rule (a batch's anchor status wins over the evidence event's own status when a batch exists — theFILTER NOT EXISTSin the SPARQL query is what enforces that, tested explicitly). Not wired to any live endpoint —MMT_GRAPH_ENABLEDis off by default, and even on, nothing backfills historical decisions into the graph yet, so there's no way today for this to answer for the population of existing decisions the waytrust-graph.repository.ts's live Postgres queries do.blast-radius,contagionandaccountability, initially deferred pending anIssuerconcept, verified SPARQL aggregate support and aHuman/person concept respectively, are each covered separately — seemmt-graph.contagion.mjs,mmt-graph.accountability.mjsandmmt-graph.blast-radius.mjsbelow.mmt-graph.contagion.mjs(Phase 5, second and fourth slices) — proves domain F (governance) can answercontagion.ts's hard question: not "what does this agent share with others" but "which shares are SPECIFIC enough to mean something," as a SPARQLCOUNT/GROUP BYaggregate query (confirmed Oxigraph supports this before building on it).isSpecific()is ported verbatim fromcontagion.ts— same constants (BROAD_COHORT_MIN = 10,BROAD_COHORT_SHARE = 0.5), same two-condition logic — and tested against the real measured platform-standard case (isSpecific(57, 92) === false), built as an actual 92-agent graph, not just the isolated function call. A nice convergence with Phase 3's tenant-isolation fix (PR #387): thetenantAgentsdenominatorisSpecificneeds no longer has to be passed in externally — eachMmtGraphEngineinstance already IS one tenant's data, so countingmmt:AIAgentnodes in it gives the right number for free. All four real cohort kinds are covered (sop/standardfrom the second slice,organization/unitfrom the fourth) even though the real algorithm computes them via two different code shapes — the aggregate query doesn't need to know that, becausemmt:sharesCohortis emitted uniformly for all four.organization/unitadditionally assert the real, already-speced Domain A relationshipmmt:memberOf(Agent →mmt:Organization/mmt:AgentGroup, both named in the doc, neither implemented until now) — deliberately two predicates, not one, because sharing a SOP and belonging to an organization are different kinds of fact and blurring them for query convenience would undo a distinction the ontology already drew (tested explicitly:organization/unitassertmmt:memberOf,sop/standarddo not).mmt-graph.accountability.mjs(Phase 5, fifth slice) — proves domain A (actors) can answeraccountability.ts's core question, "who is ultimately answerable for this decision?", for its load-bearing core:decision -> agent -> organization -> human, and the four gap codes that determineanswerableitself (agent-unresolved,no-organization,organization-unverified,no-authorized-representative). Closes themmt:Humangap the first slice's investigation flagged — humans get a synthetic IRI (no DID; theUsertable has none), reached viammt:operatedBy(Organization → Human, domain A's own relationship list, never implemented until now).principalIsVerifiedis ported verbatim frombackend/src/features/principal/principal.verification.ts, and the tests useaccountability.test.ts's exact fixture values (org-1/agent-1/user-rep/ "Ada Rep"), including its lapsed-KYB case. Deliberately NOT attempted, the same kind of scope split ascontagion's two slices: the operating-unit hop (unit-org-mismatch), the delegation-root distinction (the mandate's authorising principal, when it differs from the agent's home organisation), and human review/approval linkage (review-unlinked) — real parts ofaccountability.ts's full output shape, but refinements on top of the answerable/not-answerable determination this slice targets.mmt-graph.blast-radius.mjs(Phase 5, sixth and final slice) — answersblast-radius.ts's actual question, not the one originally posed. "Which agents hold credentials from this issuer" cannot be walked at all — issued VCs are signed and HCS-anchored but never persisted, per that file's own header — so it answers what the real code was rewritten to answer instead: an issuer's tier derives from its backing principal, so what depends on that principal, and which LIVE mandates would start getting blocked withPRINCIPAL_UNVERIFIEDif its verification lapsed (blockedOnLapseis a hypothetical count, not gated on whether verification is currently in force — mirrored exactly, not conflated withmmt-graph.accountability.mjs'sprincipalIsVerified).mmt:Issueris the one genuinely new class this package adds (the W3C VC Data Model's own term, grounded inissuer.model.ts's realdid), plus a newmmt:backedBy(Issuer → Organization, adopted from the real edge-kind name, same precedent asmmt:decidedFor). Everything else reuses vocabulary already built:mmt:memberOfis exactly "agents whose accountable organisation is this principal," and the previously-unimplementedmmt:grantsMandate(named since Phase 0, Domain C) givesauthorizedByPrincipalthe per-mandate precisionmmt:actsForalone can't (it lives on the agent node, not the mandate). Tested againstblast-radius.test.ts's real fixture values (iss-1/p-1/Acme Holdings). All four Platform Governance §7 questions now have a graph-query proof — none wired to a live endpoint; see "Not in scope here" below for why.mmt-graph.auditor.mjs,mmt-graph.auditor-cli.mjs(Phase 6) — the public verification tooling: same zero-MetaMynd-server posture asintegrations/magp-evidence'sauditor.mjs/magp-evidence.mjs(node:crypto + fetch only), extended for what a graph can disclose that a flat evidence leaf can't. Two shapes:{ event, proof, root }mirrorsmagp-evidenceexactly (reproduce the leaf, verify the Merkle proof, optionally confirm on-chain anchoring against a public Hedera mirror node);{ events: [...], root }REPLAYS a whole disclosed log through a freshMmtGraphEngineand checks the recomputed root against what was claimed AND that the replayed graph itself validates — the same SHACL shapes and §3 authority-chain invariantvalidate()runs internally, now run by a third party who never trusted MetaMynd to have run it correctly the first time. CLI:npm run auditor -- <disclosure.json> [--topic 0.0.x] [--network testnet|mainnet], same flags and VALID/INVALID verdict shape asmagp-evidence'sauditor.mjs. Fixed post-Phase-5, before building this on top of it:mmt-graph.changelog.mjs'sEVENT_LEAF_FIELDS— the explicit field list each event's Merkle leaf is built from — had drifted out of sync as Phase 5's six slices extended event schemas. Three existing ops (agent-registered,principal-registered,mandate-granted) had new fields silently EXCLUDED from their own leaf hash (not a crash — worse, a batch could commit to a leaf that omits a real field); six new ops had no field list at all and would have thrown out ofeventLeaf. Caught withimpact()showing zero real callers (nothing anchors Phase 5's proof-only events yet), fixed with a completeness test (one of every real event op, assertseventLeafaccepts it) and a regression test proving each previously-dropped field now actually changes the leaf.
Vocabulary note
The spike originally used mmt:actsFor for Mandate→Agent. Domain A of the MMTO draft
defines mmt:actsFor as Agent→Principal ("the Principal is the actor whose authority the
agent exercises"), and the source proposal's own worked example uses a separate
mmt:grantedTo for Mandate→Agent. Fixed in both the spike and the engine when the
inconsistency was found while building Phase 1 — see mmt-graph.project.mjs's header.
Status: pending first live run
mmt-graph.anchor-live-demo.mjs is code-complete and reuses the same dev testnet
credentials backend/.env already has configured (HEDERA_OPERATOR_ID/
HEDERA_OPERATOR_KEY/HEDERA_NETWORK=testnet — the same ones trust.anchor.ts uses) —
but it has never actually been executed. Running it was blocked by Claude Code's own
auto-mode safety classifier (an external-network-with-credentials action), and the call
was made to commit the working code rather than force it through. Whoever picks this up
next should run it once and record the result here:
cd integrations/mmt-graph
MMT_GRAPH_DOTENV_PATH="../../backend/.env" node mmt-graph.anchor-live-demo.mjsExpect it to print a topic ID, a transaction ID, and a mirror-node URL. If it works, this line should be replaced with the actual transaction ID and a ✅. If it doesn't, that's the first real signal about what Phase 3's live wiring will actually need to handle.
What a real submit looks like
The live demo script above is the working version. A MetaMynd-backend-shaped equivalent
would follow trust.anchor.ts's pattern instead of raw @hashgraph/sdk calls:
async function submit(message) {
const client = getHederaClient(); // backend/src/features/hedera/hedera.client.js
const topicId = await resolveOrCreateMmtGraphTopic(); // this tenant's own topic, Postgres-cached
const tx = new TopicMessageSubmitTransaction().setTopicId(topicId).setMessage(message);
const resp = await tx.execute(client.getClient());
const receipt = await resp.getReceipt(client.getClient());
return { transactionId: resp.transactionId.toString(), status: receipt.status.toString() };
}Run it
npm install
npm run spike # the Phase 1 decision record
npm test # engine + merkle + changelog + anchor-contract + evidence-path + contagion + accountability + blast-radius + auditor, all offline
npm run anchor-live-demo # NOT part of npm test — needs real testnet credentials, see above
npm run auditor -- disclosure.json [--topic 0.0.x] [--network testnet|mainnet] # Phase 6's public verifierNot in scope here
No backend wiring, no new DB tables, no Postgres access anywhere in this package
(including the live demo — topic resolution there is env-var based, mirroring
trust.anchor.ts's TRUST_TOPIC_ID fallback, not its DB-backed cache). npm test makes
no live Hedera/HCS calls — mmt-graph.anchor.test.mjs checks this by inspecting
mmt-graph.anchor.mjs's actual imports, not just by claiming it in prose; the live demo
script and the auditor's own mirror-node read (Phase 6, tested via an injected fetchImpl,
never a real call in npm test) are the only two exceptions. All four of Platform
Governance §7's questions now have a graph-query proof (Phase 5): evidence-path,
contagion (all four cohort kinds), accountability (its load-bearing core — see
mmt-graph.accountability.mjs's header for what's deliberately out of scope: the
operating-unit hop, the delegation-root distinction, human review linkage), and
blast-radius (see mmt-graph.blast-radius.mjs's header — answers the question the real
code was rewritten to ask, not the un-walkable original). None are wired to a live endpoint
— that cutover needs a real backfill/durability design, not attempted anywhere in this
package.
