@metamynd/mmt-graph 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # @metamynd/mmt-graph — reference trust-graph engine
2
+
3
+ The MMTO Phase 1/2 reference implementation: apply mutation events to a validated RDF
4
+ graph, and batch the resulting changelog into a Merkle root ready to anchor. See
5
+ [`docs/design/metamynd-trust-ontology.md`](../../docs/design/metamynd-trust-ontology.md)
6
+ Appendix C for the full phased roadmap this package works through.
7
+
8
+ **No Postgres reads/writes anywhere in this package, no MetaMynd backend required.** The
9
+ MetaMynd-operated hosting mode wires this in as a best-effort, opt-in shadow-write
10
+ (`backend/src/features/trust-graph/mmt-graph.adapter.ts`, gated by `MMT_GRAPH_ENABLED`,
11
+ default off) — but that's one caller among many this package is designed to have, not a
12
+ dependency of it. `npm test` makes no live Hedera calls either — the one exception is
13
+ `mmt-graph.anchor-live-demo.mjs`, a manually-run, not-yet-executed script proving the
14
+ anchor contract against real testnet infrastructure (see "Status" below).
15
+
16
+ ## Self-hosting quickstart (Phase 4)
17
+
18
+ This package is designed to run identically whether MetaMynd operates it or you do — same
19
+ engine, same event vocabulary, same anchor contract, no MetaMynd credentials or MetaMynd
20
+ backend anywhere in the code path. That split was a Phase 1 design constraint, not a later
21
+ retrofit: `mmt-graph.anchor.mjs` never imports `@hashgraph/sdk`, and nothing in this
22
+ package imports a database driver.
23
+
24
+ ```bash
25
+ npm install @metamynd/mmt-graph
26
+ node node_modules/@metamynd/mmt-graph/mmt-graph.self-host-quickstart.mjs
27
+ ```
28
+
29
+ That script runs the whole lifecycle end to end, offline, with zero setup: register your
30
+ agents/principals/mandates, validate (SHACL + the §3 authority-chain invariant), query
31
+ (it's a real SPARQL 1.1 graph, not a fixed set of report endpoints), batch the event log
32
+ into a Merkle root, anchor it (a stub `submit` by default), and independently verify the
33
+ result — the same replay-and-check the Phase 6 auditor runs for a third party, run here for
34
+ yourself. Read the file; every step is commented with what a real deployment does
35
+ differently.
36
+
37
+ **To anchor for real**, replace the quickstart's stub `submit` with your own Hedera client
38
+ — `mmt-graph.anchor-live-demo.mjs` is the complete, real worked example (topic creation,
39
+ transaction signing, receipt handling) against your own operator account.
40
+ `anchorBatch(batch, submit)` is the entire seam: `submit` takes the canonical batch-root
41
+ message and returns `{transactionId, status}`; nothing else in the package needs to know
42
+ Hedera exists. Point it at your own topic (a fresh one, or `MMT_GRAPH_TOPIC_ID` if you
43
+ already have one) and your own testnet or mainnet account — this package has no opinion on
44
+ which.
45
+
46
+ **What self-hosting does NOT give you today**: a way to get your EXISTING historical
47
+ `Policy`/`AgentIdentity`/`Principal` rows into the graph (no backfill mechanism exists in
48
+ this package or the MetaMynd adapter — only new writes since you start applying events are
49
+ ever recorded), and no durability layer of its own (Oxigraph's JS binding is in-memory
50
+ only; your own event log — however you choose to persist it — is the real source of truth,
51
+ and `replay()` is how you rebuild the graph from it on restart). Both are real gaps, not
52
+ hidden ones; see Appendix C of the ontology doc for the intended direction.
53
+
54
+ Once you have events flowing and a batch anchored, a third party (a regulator, an auditor,
55
+ you) can verify your tenant's graph state with MetaMynd fully offline — see
56
+ `mmt-graph.auditor.mjs`/`mmt-graph.auditor-cli.mjs` below.
57
+
58
+ ## Files, in the order the phases that produced them shipped
59
+
60
+ - **`mmt-graph.spike.mjs`** (Phase 1 spike, PR #382) — the storage-engine decision record.
61
+ Proved Oxigraph (RDF/SPARQL, in-process WASM) + `rdf-validate-shacl` works before the
62
+ real engine was built. Kuzu was disqualified (archived October 2025 after Apple acquired
63
+ the company behind it); Apache AGE was viable but a Postgres C-extension distribution
64
+ burden with no SHACL; CozoDB was genuinely close (native persistence, time-travel
65
+ queries) but Datalog rather than RDF/SPARQL, and pre-1.0 with no storage-compatibility
66
+ promise. Oxigraph's JS binding is in-memory only — not disqualifying, because the
67
+ changelog below is the durable source of truth and the store is a materialized view
68
+ rebuilt from it, not a workaround.
69
+ - **`mmt-graph.types.mjs`, `mmt-graph.project.mjs`, `mmt-graph.shapes.ttl`,
70
+ `mmt-graph.engine.mjs`** (Phase 1 engine, PR #383) — the real thing. `MmtGraphEngine`
71
+ applies `{op, ...}` mutation events (`agentRegistered`/`principalRegistered`/
72
+ `mandateGranted`/`mandateRevoked`, grounded 1:1 against real `Policy`/`AgentIdentity`/
73
+ `Principal` rows — see the doc's domains A/C), projects them to RDF quads, and
74
+ `validate()` runs the SHACL shapes plus a SPARQL `ASK` making the §3 authority-chain
75
+ invariant runnable for every agent with a mandate, root or delegated. `replay()` rebuilds
76
+ from the event log — the "materialized view from a durable log" story made real.
77
+ - **`mmt-graph.merkle.mjs`, `mmt-graph.changelog.mjs`, `mmt-graph.anchor.mjs`** (Phase 2,
78
+ PR #384) — turns `MmtGraphEngine#log` into an anchor-ready Merkle batch, and defines
79
+ the anchor *contract*. `mmt-graph.anchor.mjs` never imports `@hashgraph/sdk` and makes no
80
+ network call: `anchorBatch(batch, submit)` takes an injected `submit(message) →
81
+ {transactionId, status}` function, so the actual Hedera client/credentials/topic
82
+ management stay the hosting operator's problem — a MetaMynd backend wiring in something
83
+ shaped like `backend/src/features/trust/trust.anchor.ts`, or a self-hosted tenant wiring
84
+ in their own. That split is what keeps this package identical across both hosting modes.
85
+ **Fixed post-Phase-5**: `mmt-graph.changelog.mjs`'s `EVENT_LEAF_FIELDS` — the explicit,
86
+ by-hand field list each event's Merkle leaf is built from — drifted out of sync as Phase
87
+ 5's six slices added fields to existing ops and six wholly new ops, with nothing that
88
+ actually anchors those events to catch it. `agent-registered`.`principalDid`,
89
+ `principal-registered`'s verification/rep fields, and `mandate-granted`.`expiresAt` were
90
+ silently EXCLUDED from their own leaf hash (not a crash — worse: a batch could commit to
91
+ a leaf that omits a real field); the six new ops would have thrown out of `eventLeaf`
92
+ entirely. Caught while scoping Phase 6 (a public verifier can't honestly claim to check a
93
+ tenant's full event log while half the vocabulary is unhashable), fixed with a
94
+ completeness test that constructs one of every real event op and asserts `eventLeaf`
95
+ accepts it, plus a regression test proving each previously-dropped field now actually
96
+ changes the leaf.
97
+ - **`mmt-graph.anchor-live-demo.mjs`** — a real, working `submit` wired
98
+ against **Hedera testnet**, using `@hashgraph/sdk` as a **devDependency** (never imported
99
+ by `mmt-graph.anchor.mjs` itself). **Written and offline-test-verified, not yet actually
100
+ run against testnet** — see "Status: pending first live run" below.
101
+ - **`mmt-graph.evidence-path.mjs`** (Phase 5, first slice) — proves domain H (runtime
102
+ activity/evidence) can answer `evidence-path.ts`'s core question — "can a third party
103
+ verify this decision without us?" — as a graph query. New event types
104
+ (`decisionRecorded`/`evidenceRecorded`/`batchAnchored`) and their projectors, plus
105
+ `isIndependentlyVerifiable(engine, decisionId)`, which mirrors the real function's exact
106
+ precedence rule (a batch's anchor status wins over the evidence event's own status when
107
+ a batch exists — the `FILTER NOT EXISTS` in the SPARQL query is what enforces that,
108
+ tested explicitly). **Not wired to any live endpoint** — `MMT_GRAPH_ENABLED` is off by
109
+ default, and even on, nothing backfills historical decisions into the graph yet, so there's
110
+ no way today for this to answer for the population of existing decisions the way
111
+ `trust-graph.repository.ts`'s live Postgres queries do. `blast-radius`, `contagion` and
112
+ `accountability`, initially deferred pending an `Issuer` concept, verified SPARQL
113
+ aggregate support and a `Human`/person concept respectively, are each covered separately —
114
+ see `mmt-graph.contagion.mjs`, `mmt-graph.accountability.mjs` and
115
+ `mmt-graph.blast-radius.mjs` below.
116
+ - **`mmt-graph.contagion.mjs`** (Phase 5, second and fourth slices) — proves domain F
117
+ (governance) can answer `contagion.ts`'s hard question: not "what does this agent share
118
+ with others" but "which shares are SPECIFIC enough to mean something," as a SPARQL
119
+ `COUNT`/`GROUP BY` aggregate query (confirmed Oxigraph supports this before building on
120
+ it). `isSpecific()` is ported verbatim from `contagion.ts` — same constants
121
+ (`BROAD_COHORT_MIN = 10`, `BROAD_COHORT_SHARE = 0.5`), same two-condition logic — and
122
+ tested against the real measured platform-standard case (`isSpecific(57, 92) === false`),
123
+ built as an actual 92-agent graph, not just the isolated function call. A nice
124
+ convergence with Phase 3's tenant-isolation fix (PR #387): the `tenantAgents`
125
+ denominator `isSpecific` needs no longer has to be passed in externally — each
126
+ `MmtGraphEngine` instance already IS one tenant's data, so counting `mmt:AIAgent` nodes
127
+ in it gives the right number for free. All four real cohort kinds are covered
128
+ (`sop`/`standard` from the second slice, `organization`/`unit` from the fourth) even
129
+ though the real algorithm computes them via two different code shapes — the aggregate
130
+ query doesn't need to know that, because `mmt:sharesCohort` is emitted uniformly for
131
+ all four. `organization`/`unit` additionally assert the real, already-speced Domain A
132
+ relationship `mmt:memberOf` (Agent → `mmt:Organization`/`mmt:AgentGroup`, both named in
133
+ the doc, neither implemented until now) — deliberately two predicates, not one, because
134
+ sharing a SOP and belonging to an organization are different kinds of fact and blurring
135
+ them for query convenience would undo a distinction the ontology already drew (tested
136
+ explicitly: `organization`/`unit` assert `mmt:memberOf`, `sop`/`standard` do not).
137
+ - **`mmt-graph.accountability.mjs`** (Phase 5, fifth slice) — proves domain A (actors) can
138
+ answer `accountability.ts`'s core question, "who is ultimately answerable for this
139
+ decision?", for its load-bearing core: `decision -> agent -> organization -> human`, and
140
+ the four gap codes that determine `answerable` itself (`agent-unresolved`,
141
+ `no-organization`, `organization-unverified`, `no-authorized-representative`). Closes the
142
+ `mmt:Human` gap the first slice's investigation flagged — humans get a synthetic IRI (no
143
+ DID; the `User` table has none), reached via `mmt:operatedBy` (Organization → Human,
144
+ domain A's own relationship list, never implemented until now). `principalIsVerified` is
145
+ ported verbatim from `backend/src/features/principal/principal.verification.ts`, and the
146
+ tests use `accountability.test.ts`'s exact fixture values (`org-1`/`agent-1`/`user-rep`/
147
+ "Ada Rep"), including its lapsed-KYB case. Deliberately NOT attempted, the same kind of
148
+ scope split as `contagion`'s two slices: the operating-unit hop (`unit-org-mismatch`),
149
+ the delegation-root distinction (the mandate's authorising principal, when it differs
150
+ from the agent's home organisation), and human review/approval linkage
151
+ (`review-unlinked`) — real parts of `accountability.ts`'s full output shape, but
152
+ refinements on top of the answerable/not-answerable determination this slice targets.
153
+ - **`mmt-graph.blast-radius.mjs`** (Phase 5, sixth and final slice) — answers
154
+ `blast-radius.ts`'s actual question, not the one originally posed. "Which agents hold
155
+ credentials from this issuer" cannot be walked at all — issued VCs are signed and
156
+ HCS-anchored but never persisted, per that file's own header — so it answers what the
157
+ real code was rewritten to answer instead: an issuer's tier derives from its backing
158
+ principal, so what depends on that principal, and which LIVE mandates would start getting
159
+ blocked with `PRINCIPAL_UNVERIFIED` if its verification lapsed (`blockedOnLapse` is a
160
+ hypothetical count, not gated on whether verification is currently in force — mirrored
161
+ exactly, not conflated with `mmt-graph.accountability.mjs`'s `principalIsVerified`).
162
+ `mmt:Issuer` is the one genuinely new class this package adds (the W3C VC Data Model's own
163
+ term, grounded in `issuer.model.ts`'s real `did`), plus a new `mmt:backedBy` (Issuer →
164
+ Organization, adopted from the real edge-kind name, same precedent as `mmt:decidedFor`).
165
+ Everything else reuses vocabulary already built: `mmt:memberOf` is exactly "agents whose
166
+ accountable organisation is this principal," and the previously-unimplemented
167
+ `mmt:grantsMandate` (named since Phase 0, Domain C) gives `authorizedByPrincipal` the
168
+ per-mandate precision `mmt:actsFor` alone can't (it lives on the agent node, not the
169
+ mandate). Tested against `blast-radius.test.ts`'s real fixture values
170
+ (`iss-1`/`p-1`/`Acme Holdings`). **All four Platform Governance §7 questions now have a
171
+ graph-query proof** — none wired to a live endpoint; see "Not in scope here" below for why.
172
+ - **`mmt-graph.auditor.mjs`, `mmt-graph.auditor-cli.mjs`** (Phase 6) — the public
173
+ verification tooling: same zero-MetaMynd-server posture as
174
+ `integrations/magp-evidence`'s `auditor.mjs`/`magp-evidence.mjs` (node:crypto + fetch
175
+ only), extended for what a graph can disclose that a flat evidence leaf can't. Two
176
+ shapes: `{ event, proof, root }` mirrors `magp-evidence` exactly (reproduce the leaf,
177
+ verify the Merkle proof, optionally confirm on-chain anchoring against a public Hedera
178
+ mirror node); `{ events: [...], root }` REPLAYS a whole disclosed log through a fresh
179
+ `MmtGraphEngine` and checks the recomputed root against what was claimed AND that the
180
+ replayed graph itself validates — the same SHACL shapes and §3 authority-chain invariant
181
+ `validate()` runs internally, now run by a third party who never trusted MetaMynd to have
182
+ run it correctly the first time. CLI: `npm run auditor -- <disclosure.json> [--topic
183
+ 0.0.x] [--network testnet|mainnet]`, same flags and VALID/INVALID verdict shape as
184
+ `magp-evidence`'s `auditor.mjs`. **Fixed post-Phase-5, before building this on top of
185
+ it**: `mmt-graph.changelog.mjs`'s `EVENT_LEAF_FIELDS` — the explicit field list each
186
+ event's Merkle leaf is built from — had drifted out of sync as Phase 5's six slices
187
+ extended event schemas. Three existing ops (`agent-registered`, `principal-registered`,
188
+ `mandate-granted`) had new fields silently EXCLUDED from their own leaf hash (not a
189
+ crash — worse, a batch could commit to a leaf that omits a real field); six new ops had
190
+ no field list at all and would have thrown out of `eventLeaf`. Caught with `impact()`
191
+ showing zero real callers (nothing anchors Phase 5's proof-only events yet), fixed with a
192
+ completeness test (one of every real event op, asserts `eventLeaf` accepts it) and a
193
+ regression test proving each previously-dropped field now actually changes the leaf.
194
+
195
+ ## Vocabulary note
196
+
197
+ The spike originally used `mmt:actsFor` for Mandate→Agent. Domain A of the MMTO draft
198
+ defines `mmt:actsFor` as Agent→Principal ("the Principal is the actor whose authority the
199
+ agent exercises"), and the source proposal's own worked example uses a separate
200
+ `mmt:grantedTo` for Mandate→Agent. Fixed in both the spike and the engine when the
201
+ inconsistency was found while building Phase 1 — see `mmt-graph.project.mjs`'s header.
202
+
203
+ ## Status: pending first live run
204
+
205
+ `mmt-graph.anchor-live-demo.mjs` is code-complete and reuses the same dev testnet
206
+ credentials `backend/.env` already has configured (`HEDERA_OPERATOR_ID`/
207
+ `HEDERA_OPERATOR_KEY`/`HEDERA_NETWORK=testnet` — the same ones `trust.anchor.ts` uses) —
208
+ but it has never actually been executed. Running it was blocked by Claude Code's own
209
+ auto-mode safety classifier (an external-network-with-credentials action), and the call
210
+ was made to commit the working code rather than force it through. **Whoever picks this up
211
+ next should run it once** and record the result here:
212
+
213
+ ```bash
214
+ cd integrations/mmt-graph
215
+ MMT_GRAPH_DOTENV_PATH="../../backend/.env" node mmt-graph.anchor-live-demo.mjs
216
+ ```
217
+
218
+ Expect it to print a topic ID, a transaction ID, and a mirror-node URL. If it works, this
219
+ line should be replaced with the actual transaction ID and a ✅. If it doesn't, that's the
220
+ first real signal about what Phase 3's live wiring will actually need to handle.
221
+
222
+ ## What a real `submit` looks like
223
+
224
+ The live demo script above is the working version. A MetaMynd-backend-shaped equivalent
225
+ would follow `trust.anchor.ts`'s pattern instead of raw `@hashgraph/sdk` calls:
226
+
227
+ ```js
228
+ async function submit(message) {
229
+ const client = getHederaClient(); // backend/src/features/hedera/hedera.client.js
230
+ const topicId = await resolveOrCreateMmtGraphTopic(); // this tenant's own topic, Postgres-cached
231
+ const tx = new TopicMessageSubmitTransaction().setTopicId(topicId).setMessage(message);
232
+ const resp = await tx.execute(client.getClient());
233
+ const receipt = await resp.getReceipt(client.getClient());
234
+ return { transactionId: resp.transactionId.toString(), status: receipt.status.toString() };
235
+ }
236
+ ```
237
+
238
+ ## Run it
239
+
240
+ ```bash
241
+ npm install
242
+ npm run spike # the Phase 1 decision record
243
+ npm test # engine + merkle + changelog + anchor-contract + evidence-path + contagion + accountability + blast-radius + auditor, all offline
244
+ npm run anchor-live-demo # NOT part of npm test — needs real testnet credentials, see above
245
+ npm run auditor -- disclosure.json [--topic 0.0.x] [--network testnet|mainnet] # Phase 6's public verifier
246
+ ```
247
+
248
+ ## Not in scope here
249
+
250
+ No backend wiring, no new DB tables, no Postgres access anywhere in this package
251
+ (including the live demo — topic resolution there is env-var based, mirroring
252
+ `trust.anchor.ts`'s `TRUST_TOPIC_ID` fallback, not its DB-backed cache). `npm test` makes
253
+ no live Hedera/HCS calls — `mmt-graph.anchor.test.mjs` checks this by inspecting
254
+ `mmt-graph.anchor.mjs`'s actual imports, not just by claiming it in prose; the live demo
255
+ script and the auditor's own mirror-node read (Phase 6, tested via an injected `fetchImpl`,
256
+ never a real call in `npm test`) are the only two exceptions. **All four of Platform
257
+ Governance §7's questions now have a graph-query proof** (Phase 5): `evidence-path`,
258
+ `contagion` (all four cohort kinds), `accountability` (its load-bearing core — see
259
+ `mmt-graph.accountability.mjs`'s header for what's deliberately out of scope: the
260
+ operating-unit hop, the delegation-root distinction, human review linkage), and
261
+ `blast-radius` (see `mmt-graph.blast-radius.mjs`'s header — answers the question the real
262
+ code was rewritten to ask, not the un-walkable original). None are wired to a live endpoint
263
+ — that cutover needs a real backfill/durability design, not attempted anywhere in this
264
+ package.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Phase 5, fifth slice: proves the engine can answer accountability.ts's core question —
3
+ * "who is ultimately answerable for this decision?" — as a graph query. Not a replacement of
4
+ * that file (same "proof not cutover" posture as evidence-path/contagion): this covers the
5
+ * chain's LOAD-BEARING core, `decision -> agent -> organization -> human`, and the four gap
6
+ * codes that determine `answerable` itself (`agent-unresolved`, `no-organization`,
7
+ * `organization-unverified`, `no-authorized-representative`). Deliberately NOT attempted
8
+ * here: the operating-unit hop (`unit-org-mismatch`), the delegation-root distinction
9
+ * (`no-delegation-root`/`delegation-root-unresolved`/`delegation-root-differs`), and human
10
+ * review/approval linkage (`review-unlinked`) — real parts of accountability.ts's full shape,
11
+ * but refinements on top of the answerable/not-answerable determination this slice targets,
12
+ * the same kind of scope split contagion's sop/standard-then-organization/unit slices used.
13
+ *
14
+ * `principalIsVerified` below is a verbatim port of
15
+ * backend/src/features/principal/principal.verification.ts's function of the same name —
16
+ * same three checks (status is verified/renewing, assurance level isn't none, expiry hasn't
17
+ * passed), same reason as isSpecific/isIndependentlyVerifiable's verbatim ports elsewhere in
18
+ * this package: the whole point is proving this against the real rule, not a fresh guess at
19
+ * one.
20
+ */
21
+
22
+ import { MMT, agentIri, decisionIri, principalIri, humanIri } from './mmt-graph.project.mjs';
23
+
24
+ /** Verbatim from backend/src/features/principal/principal.verification.ts. */
25
+ export function principalIsVerified(row, now = new Date()) {
26
+ if (!row) return false;
27
+ if (row.verificationStatus !== 'verified' && row.verificationStatus !== 'renewing') return false;
28
+ if (row.assuranceLevel === 'none') return false;
29
+ if (row.verificationExpiresAt && new Date(row.verificationExpiresAt).getTime() <= now.getTime()) return false;
30
+ return true;
31
+ }
32
+
33
+ export const ACCOUNTABILITY_GAPS = ['agent-unresolved', 'no-organization', 'organization-unverified', 'no-authorized-representative'];
34
+
35
+ /**
36
+ * @param engine {import('./mmt-graph.engine.mjs').MmtGraphEngine}
37
+ * @param decisionId {string}
38
+ * @param now {Date}
39
+ * @returns {{
40
+ * agentDid: string | null,
41
+ * organizationDid: string | null,
42
+ * verificationInForce: boolean,
43
+ * accountablePerson: { userId: string, name: string, email: string } | null,
44
+ * answerable: boolean,
45
+ * gaps: { code: string, severity: 'break', detail: string }[],
46
+ * }}
47
+ */
48
+ export function resolveAccountability(engine, decisionId, now = new Date()) {
49
+ const decision = decisionIri(decisionId).value;
50
+
51
+ const rows = [
52
+ ...engine.query(`
53
+ PREFIX mmt: <${MMT}>
54
+ SELECT ?agent ?org ?verificationStatus ?assuranceLevel ?verificationExpiresAt ?human ?humanEmail ?humanName WHERE {
55
+ <${decision}> a mmt:AuthorizationDecision .
56
+ OPTIONAL {
57
+ <${decision}> mmt:decidedFor ?agent .
58
+ ?agent a mmt:AIAgent .
59
+ OPTIONAL {
60
+ ?agent mmt:memberOf ?org .
61
+ ?org a mmt:Organization .
62
+ OPTIONAL { ?org mmt:verificationStatus ?verificationStatus }
63
+ OPTIONAL { ?org mmt:assuranceLevel ?assuranceLevel }
64
+ OPTIONAL { ?org mmt:verificationExpiresAt ?verificationExpiresAt }
65
+ OPTIONAL {
66
+ ?org mmt:operatedBy ?human .
67
+ ?human a mmt:Human .
68
+ OPTIONAL { ?human mmt:email ?humanEmail }
69
+ OPTIONAL { ?human mmt:name ?humanName }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ `),
75
+ ];
76
+
77
+ const row = rows[0] ?? null;
78
+ const get = (name) => row?.get(name)?.value ?? null;
79
+
80
+ const agentDid = get('agent');
81
+ const organizationDid = get('org');
82
+ const humanUserId = get('human') ? decodeURIComponent(get('human').slice(`${MMT}human/`.length)) : null;
83
+
84
+ const gaps = [];
85
+
86
+ if (!agentDid) {
87
+ gaps.push({
88
+ code: 'agent-unresolved',
89
+ severity: 'break',
90
+ detail: `No agent is linked to decision ${decisionId} via mmt:decidedFor, so the decision cannot be attributed to anything that acts.`,
91
+ });
92
+ }
93
+
94
+ if (agentDid && !organizationDid) {
95
+ gaps.push({
96
+ code: 'no-organization',
97
+ severity: 'break',
98
+ detail: 'The agent is not mapped to an accountable organisation (no mmt:memberOf edge to an mmt:Organization), so the chain stops at the agent itself.',
99
+ });
100
+ }
101
+
102
+ const verificationInForce = organizationDid
103
+ ? principalIsVerified({ verificationStatus: get('verificationStatus'), assuranceLevel: get('assuranceLevel'), verificationExpiresAt: get('verificationExpiresAt') }, now)
104
+ : false;
105
+ if (organizationDid && !verificationInForce) {
106
+ gaps.push({
107
+ code: 'organization-unverified',
108
+ severity: 'break',
109
+ detail: `The accountable entity's KYB is not currently in force (${get('verificationStatus') ?? 'unverified'}). Its answer for this action rests on a verification the platform no longer stands behind.`,
110
+ });
111
+ }
112
+
113
+ const accountablePerson = humanUserId ? { userId: humanUserId, name: get('humanName') ?? get('humanEmail'), email: get('humanEmail') } : null;
114
+ if (organizationDid && !accountablePerson) {
115
+ gaps.push({
116
+ code: 'no-authorized-representative',
117
+ severity: 'break',
118
+ detail: 'The organisation names no authorised representative (no mmt:operatedBy edge to an mmt:Human), so the chain ends at a legal entity rather than at a person.',
119
+ });
120
+ }
121
+
122
+ const answerable = Boolean(agentDid && organizationDid && verificationInForce && accountablePerson);
123
+
124
+ return { agentDid, organizationDid, verificationInForce, accountablePerson, answerable, gaps };
125
+ }
126
+
127
+ /** Exported for tests that want to construct these IRIs without importing project.mjs directly. */
128
+ export { decisionIri, agentIri, principalIri, humanIri };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The one exception to "everything in this package is offline." Wires a real `submit` for
3
+ * mmt-graph.anchor.mjs's anchorBatch() and submits an actual batch root to Hedera TESTNET.
4
+ *
5
+ * NOT run by `npm test` or CI — never add this to an automated test suite. A live network
6
+ * call to external infrastructure is exactly the kind of thing that shouldn't gate a test
7
+ * run (flaky, slow, an external dependency CI has no credentials for anyway). Run by hand:
8
+ *
9
+ * HEDERA_OPERATOR_ID=... HEDERA_OPERATOR_KEY=... node mmt-graph.anchor-live-demo.mjs
10
+ *
11
+ * or point it at backend/.env's existing dev testnet credentials (see README). Uses
12
+ * TESTNET HBAR, which has no real-world value — same credentials and network
13
+ * backend/src/features/trust/trust.anchor.ts already uses for the same kind of anchoring.
14
+ *
15
+ * Deliberately does NOT touch Postgres: topic resolution mirrors trust.anchor.ts's
16
+ * TRUST_TOPIC_ID env-var fallback rather than its DB-backed cache, so this package keeps
17
+ * no `db` import anywhere — that boundary is what keeps mmt-graph portable to a
18
+ * self-hosted tenant who has no MetaMynd backend at all.
19
+ */
20
+
21
+ import { config as loadEnv } from 'dotenv';
22
+ import {
23
+ Client,
24
+ AccountId,
25
+ PrivateKey,
26
+ TopicCreateTransaction,
27
+ TopicMessageSubmitTransaction,
28
+ TransactionId,
29
+ Timestamp,
30
+ } from '@hashgraph/sdk';
31
+
32
+ import { MmtGraphEngine } from './mmt-graph.engine.mjs';
33
+ import { agentRegistered, principalRegistered, mandateGranted } from './mmt-graph.types.mjs';
34
+ import { buildChangelogBatch } from './mmt-graph.changelog.mjs';
35
+ import { anchorBatch } from './mmt-graph.anchor.mjs';
36
+
37
+ // Loads a local .env by default; MMT_GRAPH_DOTENV_PATH lets it point at an existing one
38
+ // instead (e.g. backend/.env's dev testnet credentials) without copying secrets anywhere.
39
+ loadEnv(process.env.MMT_GRAPH_DOTENV_PATH ? { path: process.env.MMT_GRAPH_DOTENV_PATH } : undefined);
40
+
41
+ const operatorId = process.env.HEDERA_OPERATOR_ID;
42
+ const operatorKey = process.env.HEDERA_OPERATOR_KEY;
43
+ const network = process.env.HEDERA_NETWORK || 'testnet';
44
+ const mirrorBaseUrl = process.env.HEDERA_MIRROR_NODE_URL || 'https://testnet.mirrornode.hedera.com/api/v1';
45
+
46
+ if (!operatorId || !operatorKey) {
47
+ console.error(
48
+ 'HEDERA_OPERATOR_ID and HEDERA_OPERATOR_KEY must be set — this script never falls back to a ' +
49
+ 'default or hardcoded credential. See README.md for how to point it at an existing testnet account.'
50
+ );
51
+ process.exit(1);
52
+ }
53
+
54
+ if (network !== 'testnet') {
55
+ console.error(`refusing to run against network="${network}" — this demo is testnet-only by design.`);
56
+ process.exit(1);
57
+ }
58
+
59
+ /** Raw-hex ECDSA (fallback ED25519) or DER — same detection trust.anchor.ts's client relies on. */
60
+ function parseOperatorKey(raw) {
61
+ const s = raw.startsWith('0x') ? raw.slice(2) : raw;
62
+ if (s.startsWith('30')) return PrivateKey.fromStringDer(s);
63
+ try {
64
+ return PrivateKey.fromStringECDSA(s);
65
+ } catch {
66
+ return PrivateKey.fromStringED25519(s);
67
+ }
68
+ }
69
+
70
+ const client = Client.forName(network);
71
+ client.setOperator(AccountId.fromString(operatorId), parseOperatorKey(operatorKey));
72
+
73
+ async function ensureTopicId() {
74
+ const fromEnv = process.env.MMT_GRAPH_TOPIC_ID;
75
+ if (fromEnv) return fromEnv;
76
+
77
+ console.log('No MMT_GRAPH_TOPIC_ID set — creating a fresh topic (set this env var to reuse it next time).');
78
+ const resp = await new TopicCreateTransaction().execute(client);
79
+ const topicId = (await resp.getReceipt(client)).topicId?.toString();
80
+ if (!topicId) throw new Error('topic creation succeeded but returned no topicId');
81
+ return topicId;
82
+ }
83
+
84
+ /** The `submit` mmt-graph.anchor.mjs's anchorBatch() calls — see its header for the contract. */
85
+ async function submit(message) {
86
+ const topicId = await ensureTopicId();
87
+ const tx = new TopicMessageSubmitTransaction().setTopicId(topicId).setMessage(message);
88
+
89
+ // Backdate valid-start slightly — Hedera rejects a future start time. Same fix trust.anchor.ts applies.
90
+ const operatorAccountId = client.operatorAccountId;
91
+ if (operatorAccountId) {
92
+ tx.setTransactionId(TransactionId.withValidStart(operatorAccountId, new Timestamp(Math.floor(Date.now() / 1000) - 10, 0)));
93
+ }
94
+
95
+ const resp = await tx.execute(client);
96
+ const receipt = await resp.getReceipt(client);
97
+ return { topicId, transactionId: resp.transactionId.toString(), status: receipt.status.toString() };
98
+ }
99
+
100
+ async function main() {
101
+ const engine = new MmtGraphEngine();
102
+ engine.apply(principalRegistered({ principalDid: 'did:hedera:testnet:acme', principalType: 'Organization' }));
103
+ engine.apply(agentRegistered({ agentDid: 'did:hedera:testnet:agent17' }));
104
+ engine.apply(mandateGranted({ policyId: 'policy-82', agentDid: 'did:hedera:testnet:agent17', principalDid: 'did:hedera:testnet:acme' }));
105
+
106
+ const batch = buildChangelogBatch(engine.log);
107
+ console.log(`Batched ${batch.size} event(s). Root: ${batch.root}`);
108
+
109
+ const receipt = await anchorBatch(batch, submit);
110
+ console.log('\nAnchored.');
111
+ console.log(` topicId: ${receipt.topicId}`);
112
+ console.log(` transactionId: ${receipt.transactionId}`);
113
+ console.log(` status: ${receipt.status}`);
114
+ console.log(` verify: ${mirrorBaseUrl}/topics/${receipt.topicId}/messages`);
115
+
116
+ await client.close();
117
+ }
118
+
119
+ main().catch((err) => {
120
+ console.error(err);
121
+ process.exitCode = 1;
122
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The anchor CONTRACT, not an anchor implementation. This file never imports @hashgraph/sdk
3
+ * and never makes a network call — it builds the canonical batch-root message and hands it
4
+ * to an injected `submit` function, the same shape as evidence-batch.ts's single HCS
5
+ * message per batch (`{op:'evidence-batch-root', root, size}`) but for the trust-graph
6
+ * changelog.
7
+ *
8
+ * Why injected rather than built in: this package is meant to run identically whether
9
+ * MetaMynd operates it or a tenant self-hosts it (see docs/design/metamynd-trust-ontology.md
10
+ * Appendix C) — the Hedera client, credentials, and topic management differ by hosting mode,
11
+ * but what gets anchored and how it's hashed does not. `submit` is the entire seam; who
12
+ * constructs it (a `trust.anchor.ts`-shaped wrapper, a tenant's own SDK usage) is Phase 3.
13
+ */
14
+
15
+ const OP = 'mmt-graph-batch-root';
16
+
17
+ /**
18
+ * @param batch {{ root: string, size: number }} from buildChangelogBatch()
19
+ * @param submit {(message: string) => Promise<{ transactionId: string, status: string }>}
20
+ * @returns the receipt `submit` returns, unchanged — this function adds nothing to it
21
+ */
22
+ export async function anchorBatch(batch, submit) {
23
+ if (typeof submit !== 'function') {
24
+ throw new Error('anchorBatch: submit must be a function — this package does not talk to Hedera itself');
25
+ }
26
+ const message = JSON.stringify({ op: OP, root: batch.root, size: batch.size });
27
+ return submit(message);
28
+ }
29
+
30
+ /** Exported so a caller building their own `submit` can parse what they receive back from the mirror without duplicating the op name. */
31
+ export const MMT_GRAPH_BATCH_ROOT_OP = OP;
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // mmt-graph.auditor-cli.mjs — CLI for the mmt-graph public verifier (Phase 6).
3
+ //
4
+ // A regulator / disputing party runs this against a disclosure package a tenant opened. It
5
+ // verifies — with MetaMynd offline — that the disclosed event(s) were governed, unaltered,
6
+ // and (with --topic) anchored on-chain. Same CLI shape as integrations/magp-evidence's
7
+ // auditor.mjs, extended for mmt-graph's two disclosure shapes:
8
+ //
9
+ // { event, proof, root } — one event's inclusion in an anchored batch
10
+ // { events: [...], root } — a full raw log, independently replayed and validated
11
+ //
12
+ // node mmt-graph.auditor-cli.mjs <disclosure.json> [--topic 0.0.x] [--network testnet|mainnet]
13
+ //
14
+ // Exit 0 = VALID, 1 = INVALID/unverifiable.
15
+ import { readFileSync } from 'node:fs';
16
+ import { verifyDisclosure } from './mmt-graph.auditor.mjs';
17
+
18
+ const args = process.argv.slice(2);
19
+ const file = args.find((a) => !a.startsWith('--'));
20
+ const opt = (name) => {
21
+ const i = args.indexOf(`--${name}`);
22
+ return i >= 0 ? args[i + 1] : undefined;
23
+ };
24
+ if (!file) {
25
+ console.error('usage: node mmt-graph.auditor-cli.mjs <disclosure.json> [--topic 0.0.x] [--network testnet|mainnet]');
26
+ process.exit(2);
27
+ }
28
+
29
+ const pkg = JSON.parse(readFileSync(file, 'utf8'));
30
+ const topic = opt('topic');
31
+ if (topic) pkg.anchor = { ...pkg.anchor, topicId: topic, network: opt('network') ?? 'testnet' };
32
+
33
+ const r = await verifyDisclosure(pkg);
34
+
35
+ const mode = Array.isArray(pkg.events) ? 'full-log replay' : 'single-event inclusion';
36
+ console.log(`\nmmt-graph public verifier — ${file} (${mode})\n${'─'.repeat(56)}`);
37
+ for (const c of r.checks) console.log(` ${c.ok ? '✓' : '✗'} ${c.check}`);
38
+ if (!topic) console.log(' · on-chain anchoring not checked (pass --topic to verify)');
39
+
40
+ console.log(`${'─'.repeat(56)}\n${r.valid ? '✅ VALID' : '❌ INVALID'} — the disclosed record ${r.valid ? 'was governed, unaltered, and matches what was claimed.' : 'did NOT verify.'}\n`);
41
+ process.exit(r.valid ? 0 : 1);