@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 +264 -0
- package/mmt-graph.accountability.mjs +128 -0
- package/mmt-graph.anchor-live-demo.mjs +122 -0
- package/mmt-graph.anchor.mjs +31 -0
- package/mmt-graph.auditor-cli.mjs +41 -0
- package/mmt-graph.auditor.mjs +129 -0
- package/mmt-graph.blast-radius.mjs +166 -0
- package/mmt-graph.changelog.mjs +80 -0
- package/mmt-graph.contagion.mjs +105 -0
- package/mmt-graph.engine.mjs +153 -0
- package/mmt-graph.evidence-path.mjs +65 -0
- package/mmt-graph.merkle.mjs +67 -0
- package/mmt-graph.project.mjs +236 -0
- package/mmt-graph.self-host-quickstart.mjs +78 -0
- package/mmt-graph.shapes.ttl +39 -0
- package/mmt-graph.types.mjs +207 -0
- package/package.json +75 -0
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);
|