@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 6: the public verification tooling `docs/design/metamynd-trust-ontology.md` Appendix C
|
|
3
|
+
* describes — "extend the `magp-evidence` auditor pattern so a third party can verify a
|
|
4
|
+
* tenant's HCS-anchored graph state with MetaMynd fully offline." Same posture as that
|
|
5
|
+
* package's `auditor.mjs`/`magp-evidence.mjs`: zero MetaMynd-server dependency, node:crypto +
|
|
6
|
+
* fetch only, a regulator or disputing party runs this standalone against a disclosure
|
|
7
|
+
* package a tenant (self-hosted or MetaMynd-operated) opened.
|
|
8
|
+
*
|
|
9
|
+
* Two disclosure shapes, because a graph gives a verifier something a flat evidence leaf
|
|
10
|
+
* doesn't: one event's inclusion in an anchored batch (mirrors magp-evidence exactly), OR a
|
|
11
|
+
* whole disclosed event log, independently REPLAYED and validated — not just hash-checked.
|
|
12
|
+
* A regulator asking "was this tenant's authority chain ever actually broken" can rebuild the
|
|
13
|
+
* graph from the raw log and run the real §3 invariant check themselves, with MetaMynd
|
|
14
|
+
* offline — that's the point of a queryable graph over an opaque evidence trail.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { MmtGraphEngine } from './mmt-graph.engine.mjs';
|
|
18
|
+
import { eventLeaf, buildChangelogBatch } from './mmt-graph.changelog.mjs';
|
|
19
|
+
import { verifyMerkleProof } from './mmt-graph.merkle.mjs';
|
|
20
|
+
import { MMT_GRAPH_BATCH_ROOT_OP } from './mmt-graph.anchor.mjs';
|
|
21
|
+
|
|
22
|
+
const mirrorBase = (network) => (network === 'mainnet' ? 'https://mainnet.mirrornode.hedera.com' : 'https://testnet.mirrornode.hedera.com');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Confirm `root` was anchored on the mmt-graph changelog topic (op: MMT_GRAPH_BATCH_ROOT_OP)
|
|
26
|
+
* by reading a public Hedera mirror node — MetaMynd offline. Mirrors
|
|
27
|
+
* magp-evidence.mjs's verifyRootAnchored exactly, same injected `fetchImpl` test seam,
|
|
28
|
+
* different op name.
|
|
29
|
+
*/
|
|
30
|
+
export async function verifyBatchAnchored(root, topicId, opts = {}) {
|
|
31
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
32
|
+
const url = `${mirrorBase(opts.network ?? 'testnet')}/api/v1/topics/${topicId}/messages?limit=${opts.limit ?? 200}&order=asc`;
|
|
33
|
+
let body = null;
|
|
34
|
+
try {
|
|
35
|
+
body = await fetchImpl(url).then((r) => (r.ok ? r.json() : null));
|
|
36
|
+
} catch {
|
|
37
|
+
return { anchored: false, consensusTimestamp: null, sequenceNumber: null };
|
|
38
|
+
}
|
|
39
|
+
for (const m of body?.messages ?? []) {
|
|
40
|
+
try {
|
|
41
|
+
const op = JSON.parse(Buffer.from(m.message, 'base64').toString('utf8'));
|
|
42
|
+
if (op.op === MMT_GRAPH_BATCH_ROOT_OP && op.root === root) {
|
|
43
|
+
return { anchored: true, consensusTimestamp: m.consensus_timestamp ?? null, sequenceNumber: m.sequence_number ?? null };
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
/* skip non-JSON */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { anchored: false, consensusTimestamp: null, sequenceNumber: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Verify ONE disclosed event's inclusion in an anchored batch. Shape:
|
|
54
|
+
* { event, proof: [{sibling, position}, …], root, leaf? }
|
|
55
|
+
* Mirrors magp-evidence's verifyDisclosure for a single record — the leaf reproduces the
|
|
56
|
+
* SAME way eventLeaf() does in mmt-graph.changelog.mjs, not a fresh hash scheme.
|
|
57
|
+
*/
|
|
58
|
+
export function verifyEventInclusion(pkg, { anchoredRoot } = {}) {
|
|
59
|
+
const checks = [];
|
|
60
|
+
const leaf = eventLeaf(pkg.event);
|
|
61
|
+
const leafMatches = pkg.leaf == null || pkg.leaf === leaf;
|
|
62
|
+
checks.push({ check: 'leaf reproduced from the disclosed event', ok: leafMatches });
|
|
63
|
+
|
|
64
|
+
const inclusionValid = verifyMerkleProof(leaf, pkg.proof, pkg.root);
|
|
65
|
+
checks.push({ check: 'Merkle inclusion proof reproduces the batch root', ok: inclusionValid });
|
|
66
|
+
|
|
67
|
+
let rootAnchored = true;
|
|
68
|
+
if (anchoredRoot != null) {
|
|
69
|
+
rootAnchored = pkg.root === anchoredRoot;
|
|
70
|
+
checks.push({ check: 'batch root matches the on-chain anchored root', ok: rootAnchored });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const valid = leafMatches && inclusionValid && rootAnchored;
|
|
74
|
+
return { valid, leafMatches, inclusionValid, rootAnchored, leaf, checks };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The graph-specific verification magp-evidence's flat records can't offer: replay a
|
|
79
|
+
* disclosed RAW EVENT LOG through a fresh engine and check two independent things —
|
|
80
|
+
* (1) the batch root it recomputes matches what was claimed/anchored, so the disclosed log
|
|
81
|
+
* is exactly what was committed to, not a doctored subset or superset; (2) the REPLAYED
|
|
82
|
+
* graph itself validates — the same SHACL shapes and §3 authority-chain invariant
|
|
83
|
+
* `MmtGraphEngine#validate()` runs internally, run here by a third party who never trusted
|
|
84
|
+
* MetaMynd to have run it correctly the first time.
|
|
85
|
+
*/
|
|
86
|
+
export async function verifyReplayedLog(events, claimedRoot) {
|
|
87
|
+
const checks = [];
|
|
88
|
+
const recomputedRoot = buildChangelogBatch(events).root;
|
|
89
|
+
const rootMatches = recomputedRoot === claimedRoot;
|
|
90
|
+
checks.push({ check: 'recomputed batch root matches the claimed/anchored root', ok: rootMatches });
|
|
91
|
+
|
|
92
|
+
const engine = new MmtGraphEngine();
|
|
93
|
+
engine.replay(events);
|
|
94
|
+
const report = await engine.validate();
|
|
95
|
+
checks.push({ check: 'replayed graph has no SHACL shape violations', ok: report.shapeViolations.length === 0 });
|
|
96
|
+
checks.push({ check: 'every agent with a mandate reaches an authoritative Principal (§3 invariant)', ok: report.unreachableAgents.length === 0 });
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
valid: rootMatches && report.conforms,
|
|
100
|
+
rootMatches,
|
|
101
|
+
recomputedRoot,
|
|
102
|
+
graphValid: report.conforms,
|
|
103
|
+
shapeViolations: report.shapeViolations,
|
|
104
|
+
unreachableAgents: report.unreachableAgents,
|
|
105
|
+
checks,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Top-level verdict for a disclosure package. Detects the shape: `pkg.events` (an array) runs
|
|
111
|
+
* the full-log replay; `pkg.event` (singular) runs single-event inclusion — mirroring
|
|
112
|
+
* magp-evidence.mjs's verifyDisclosure entry point, extended for the two shapes this graph
|
|
113
|
+
* can disclose. `anchor: { topicId, network }`, when present, also confirms on-chain
|
|
114
|
+
* anchoring via a public mirror node (needs network — kept separate from the pure checks
|
|
115
|
+
* above, same separation magp-evidence.mjs's own header describes).
|
|
116
|
+
*/
|
|
117
|
+
export async function verifyDisclosure(pkg, opts = {}) {
|
|
118
|
+
const result = Array.isArray(pkg.events) ? await verifyReplayedLog(pkg.events, pkg.root) : verifyEventInclusion(pkg);
|
|
119
|
+
|
|
120
|
+
let anchorCheck = null;
|
|
121
|
+
if (pkg.anchor?.topicId) {
|
|
122
|
+
const a = await verifyBatchAnchored(pkg.root, pkg.anchor.topicId, { network: pkg.anchor.network, fetchImpl: opts.fetchImpl });
|
|
123
|
+
anchorCheck = { check: 'batch root anchored on the mmt-graph changelog topic', ok: a.anchored, ...a };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const checks = anchorCheck ? [...result.checks, anchorCheck] : result.checks;
|
|
127
|
+
const valid = result.valid && (anchorCheck ? anchorCheck.ok : true);
|
|
128
|
+
return { ...result, valid, checks, anchor: anchorCheck };
|
|
129
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5, sixth slice: proves the engine can answer blast-radius.ts's real question — not
|
|
3
|
+
* the one originally posed ("which agents hold credentials from this issuer", which cannot
|
|
4
|
+
* be walked at all: issued VCs are signed and HCS-anchored but never persisted — see that
|
|
5
|
+
* file's own header) but the one it was rewritten to answer instead: "an issuer's tier
|
|
6
|
+
* derives from its backing principal; what depends on that same principal, and which live
|
|
7
|
+
* mandates would start getting blocked with PRINCIPAL_UNVERIFIED if its verification
|
|
8
|
+
* lapsed?" That reframing is what makes this tractable — this slice does not attempt
|
|
9
|
+
* credential-holder tracking either, and says so in `coverage`, same as the real code.
|
|
10
|
+
*
|
|
11
|
+
* `blockedOnLapse` mirrors blast-radius.ts exactly: `liveMandates.filter(authorizedByPrincipal)`,
|
|
12
|
+
* a HYPOTHETICAL count ("would block on a lapse"), not gated on whether the principal's
|
|
13
|
+
* verification is currently in force. Not the same question mmt-graph.accountability.mjs
|
|
14
|
+
* answers with `principalIsVerified` — deliberately not reused here.
|
|
15
|
+
*
|
|
16
|
+
* `authorizedByPrincipal` is a per-MANDATE fact (`Policy.principalDid === principal.did`),
|
|
17
|
+
* not a per-agent one — `mmt:actsFor` alone (set on the agent node) can't distinguish which
|
|
18
|
+
* of an agent's several mandates was THIS principal's own root grant, so this slice needed
|
|
19
|
+
* one new edge on the mandate itself: see `mmt:grantsMandate` in mmt-graph.project.mjs.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { MMT, issuerIri, principalIri, mandateIri } from './mmt-graph.project.mjs';
|
|
23
|
+
|
|
24
|
+
const MANDATE_PREFIX = `${MMT}mandate/`;
|
|
25
|
+
const policyIdFromMandateIri = (iri) => decodeURIComponent(iri.slice(MANDATE_PREFIX.length));
|
|
26
|
+
|
|
27
|
+
/** Verbatim rule from blast-radius.ts's isLive: absent expiry means no expiry, not expired. */
|
|
28
|
+
function isLive(expiresAt, now) {
|
|
29
|
+
if (!expiresAt) return true;
|
|
30
|
+
return new Date(expiresAt).getTime() > now.getTime();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param engine {import('./mmt-graph.engine.mjs').MmtGraphEngine}
|
|
35
|
+
* @param issuerDid {string}
|
|
36
|
+
* @param now {Date}
|
|
37
|
+
* @returns {{
|
|
38
|
+
* issuerDid: string,
|
|
39
|
+
* principalDid: string | null,
|
|
40
|
+
* agents: { agentDid: string, mandates: number }[],
|
|
41
|
+
* mandates: { policyId: string, agentDid: string, expiresAt: string | null, live: boolean, authorizedByPrincipal: boolean }[],
|
|
42
|
+
* siblingIssuers: { did: string, name: string | null }[],
|
|
43
|
+
* impact: { agents: number, liveMandates: number, expiredMandates: number, siblingIssuers: number, blockedOnLapse: number },
|
|
44
|
+
* coverage: { issuedCredentials: 'not-tracked' },
|
|
45
|
+
* }}
|
|
46
|
+
*/
|
|
47
|
+
export function resolveBlastRadius(engine, issuerDid, now = new Date()) {
|
|
48
|
+
const issuer = issuerIri(issuerDid).value;
|
|
49
|
+
const coverage = { issuedCredentials: 'not-tracked' };
|
|
50
|
+
|
|
51
|
+
const principalRow = [
|
|
52
|
+
...engine.query(`
|
|
53
|
+
PREFIX mmt: <${MMT}>
|
|
54
|
+
SELECT ?principal WHERE {
|
|
55
|
+
<${issuer}> mmt:backedBy ?principal .
|
|
56
|
+
?principal a mmt:Organization .
|
|
57
|
+
}
|
|
58
|
+
`),
|
|
59
|
+
][0];
|
|
60
|
+
const principalDid = principalRow?.get('principal')?.value ?? null;
|
|
61
|
+
|
|
62
|
+
// An issuer with no accountable entity has an empty radius BY CONSTRUCTION, not by
|
|
63
|
+
// accident — same rule as buildIssuerBlastRadius's early return: nothing verified is
|
|
64
|
+
// behind it, so there is nothing to lapse and nothing downstream to lapse with it.
|
|
65
|
+
if (!principalDid) {
|
|
66
|
+
return {
|
|
67
|
+
issuerDid,
|
|
68
|
+
principalDid: null,
|
|
69
|
+
agents: [],
|
|
70
|
+
mandates: [],
|
|
71
|
+
siblingIssuers: [],
|
|
72
|
+
impact: { agents: 0, liveMandates: 0, expiredMandates: 0, siblingIssuers: 0, blockedOnLapse: 0 },
|
|
73
|
+
coverage,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const mandateRows = [
|
|
78
|
+
...engine.query(`
|
|
79
|
+
PREFIX mmt: <${MMT}>
|
|
80
|
+
SELECT DISTINCT ?mandate ?agent ?expiresAt WHERE {
|
|
81
|
+
?mandate a mmt:Mandate ; mmt:grantedTo ?agent .
|
|
82
|
+
OPTIONAL { ?mandate mmt:expiresAt ?expiresAt }
|
|
83
|
+
{
|
|
84
|
+
<${principalDid}> mmt:grantsMandate ?mandate .
|
|
85
|
+
}
|
|
86
|
+
UNION
|
|
87
|
+
{
|
|
88
|
+
?agent mmt:memberOf <${principalDid}> .
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
`),
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const mandateCountByAgent = new Map();
|
|
95
|
+
const mandates = mandateRows.map((row) => {
|
|
96
|
+
const mandateIriValue = row.get('mandate').value;
|
|
97
|
+
const agentDid = row.get('agent').value;
|
|
98
|
+
const expiresAtTerm = row.get('expiresAt');
|
|
99
|
+
const expiresAt = expiresAtTerm ? expiresAtTerm.value : null;
|
|
100
|
+
const authorizedByPrincipal = engine.query(`
|
|
101
|
+
PREFIX mmt: <${MMT}>
|
|
102
|
+
ASK { <${principalDid}> mmt:grantsMandate <${mandateIriValue}> }
|
|
103
|
+
`);
|
|
104
|
+
|
|
105
|
+
mandateCountByAgent.set(agentDid, (mandateCountByAgent.get(agentDid) ?? 0) + 1);
|
|
106
|
+
|
|
107
|
+
return { policyId: policyIdFromMandateIri(mandateIriValue), agentDid, expiresAt, live: isLive(expiresAt, now), authorizedByPrincipal };
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Every agent whose home organisation IS this principal, PLUS any agent reached only via
|
|
111
|
+
// a mandate this principal directly granted — the real code's step 4: "an agent can hold
|
|
112
|
+
// a mandate from this principal without being mapped to it as an organisation."
|
|
113
|
+
const agentRows = [
|
|
114
|
+
...engine.query(`
|
|
115
|
+
PREFIX mmt: <${MMT}>
|
|
116
|
+
SELECT DISTINCT ?agent WHERE {
|
|
117
|
+
{
|
|
118
|
+
?agent mmt:memberOf <${principalDid}> .
|
|
119
|
+
?agent a mmt:AIAgent .
|
|
120
|
+
}
|
|
121
|
+
UNION
|
|
122
|
+
{
|
|
123
|
+
?mandate a mmt:Mandate ; mmt:grantedTo ?agent .
|
|
124
|
+
<${principalDid}> mmt:grantsMandate ?mandate .
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
`),
|
|
128
|
+
];
|
|
129
|
+
const agents = agentRows.map((row) => {
|
|
130
|
+
const agentDid = row.get('agent').value;
|
|
131
|
+
return { agentDid, mandates: mandateCountByAgent.get(agentDid) ?? 0 };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const siblingRows = [
|
|
135
|
+
...engine.query(`
|
|
136
|
+
PREFIX mmt: <${MMT}>
|
|
137
|
+
SELECT ?sibling ?name WHERE {
|
|
138
|
+
?sibling a mmt:Issuer ; mmt:backedBy <${principalDid}> .
|
|
139
|
+
OPTIONAL { ?sibling mmt:name ?name }
|
|
140
|
+
FILTER(?sibling != <${issuer}>)
|
|
141
|
+
}
|
|
142
|
+
`),
|
|
143
|
+
];
|
|
144
|
+
const siblingIssuers = siblingRows.map((row) => ({ did: row.get('sibling').value, name: row.get('name')?.value ?? null }));
|
|
145
|
+
|
|
146
|
+
const liveMandates = mandates.filter((m) => m.live);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
issuerDid,
|
|
150
|
+
principalDid,
|
|
151
|
+
agents,
|
|
152
|
+
mandates,
|
|
153
|
+
siblingIssuers,
|
|
154
|
+
impact: {
|
|
155
|
+
agents: agents.length,
|
|
156
|
+
liveMandates: liveMandates.length,
|
|
157
|
+
expiredMandates: mandates.length - liveMandates.length,
|
|
158
|
+
siblingIssuers: siblingIssuers.length,
|
|
159
|
+
blockedOnLapse: liveMandates.filter((m) => m.authorizedByPrincipal).length,
|
|
160
|
+
},
|
|
161
|
+
coverage,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Exported for tests that want to construct these IRIs without importing project.mjs directly. */
|
|
166
|
+
export { issuerIri, principalIri, mandateIri };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the engine's mutation log (MmtGraphEngine#log — see mmt-graph.engine.mjs) into an
|
|
3
|
+
* anchor-ready Merkle batch. Same discipline as backend/src/features/magp/evidence-batch.ts's
|
|
4
|
+
* evidenceLeaf(): canonical (sorted-key) JSON over an EXPLICITLY listed field set per event
|
|
5
|
+
* op, never a spread — a spread hashes whatever the caller happens to carry, which is how a
|
|
6
|
+
* field silently reshaping historical leaves happens in the first place. Adding a field to
|
|
7
|
+
* mmt-graph.types.mjs does not change any existing leaf until someone edits EVENT_LEAF_FIELDS.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { merkleRoot, merkleProof, sha256Hex } from './mmt-graph.merkle.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Kept in sync BY HAND with every event constructor in mmt-graph.types.mjs — the file
|
|
14
|
+
* header's whole point is that this is a deliberate, reviewable list, not a spread. It drifted
|
|
15
|
+
* once already: Phase 5's six slices (evidence-path/contagion/accountability/blast-radius)
|
|
16
|
+
* each added fields to existing ops (`agent-registered`.principalDid, `principal-registered`'s
|
|
17
|
+
* verification/rep fields, `mandate-granted`.expiresAt) and six wholly new ops
|
|
18
|
+
* (`decision-recorded`/`evidence-recorded`/`batch-anchored`/`cohort-shared`/
|
|
19
|
+
* `human-registered`/`issuer-registered`), and none of it ever touched this file — the new
|
|
20
|
+
* fields were silently excluded from their own event's Merkle leaf, and the six new ops would
|
|
21
|
+
* have thrown out of `eventLeaf`. Caught before anything anchored either shape (`impact()`
|
|
22
|
+
* upstream of `eventLeaf` returns zero real callers today — nothing in this package batches
|
|
23
|
+
* Phase 5's proof-only events), fixed here rather than left for Phase 6's auditor to discover
|
|
24
|
+
* the hard way.
|
|
25
|
+
*/
|
|
26
|
+
const EVENT_LEAF_FIELDS = {
|
|
27
|
+
'agent-registered': ['op', 'agentDid', 'kind', 'principalDid'],
|
|
28
|
+
'principal-registered': [
|
|
29
|
+
'op',
|
|
30
|
+
'principalDid',
|
|
31
|
+
'principalType',
|
|
32
|
+
'verificationStatus',
|
|
33
|
+
'assuranceLevel',
|
|
34
|
+
'verificationExpiresAt',
|
|
35
|
+
'authorizedRepUserId',
|
|
36
|
+
],
|
|
37
|
+
'mandate-granted': ['op', 'policyId', 'agentDid', 'principalDid', 'delegatedFromPolicyId', 'delegationDepth', 'expiresAt'],
|
|
38
|
+
'mandate-revoked': ['op', 'policyId'],
|
|
39
|
+
'decision-recorded': ['op', 'decisionId', 'decisionDigest', 'agentDid'],
|
|
40
|
+
'evidence-recorded': ['op', 'eventId', 'decisionDigest', 'anchorStatus'],
|
|
41
|
+
'batch-anchored': ['op', 'batchId', 'memberEventIds', 'anchorStatus'],
|
|
42
|
+
'cohort-shared': ['op', 'agentDid', 'kind', 'key', 'label', 'version'],
|
|
43
|
+
'human-registered': ['op', 'userId', 'email', 'firstName', 'lastName'],
|
|
44
|
+
'issuer-registered': ['op', 'did', 'name', 'principalDid'],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Canonical JSON: keys sorted, missing/undefined normalized to null — so the hash is stable. */
|
|
48
|
+
function canonical(obj) {
|
|
49
|
+
return JSON.stringify(
|
|
50
|
+
Object.keys(obj)
|
|
51
|
+
.sort()
|
|
52
|
+
.map((k) => [k, obj[k] ?? null])
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The reproducible Merkle leaf for one mutation event. */
|
|
57
|
+
export function eventLeaf(event) {
|
|
58
|
+
const fields = EVENT_LEAF_FIELDS[event.op];
|
|
59
|
+
if (!fields) throw new Error(`eventLeaf: no leaf field list for event op "${event.op}" — add one to EVENT_LEAF_FIELDS`);
|
|
60
|
+
const obj = {};
|
|
61
|
+
for (const f of fields) obj[f] = event[f];
|
|
62
|
+
return sha256Hex(canonical(obj));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Batch a sequence of mutation events: hash each to a leaf, compute the Merkle root, and
|
|
67
|
+
* produce a per-event inclusion proof. Only `root` need be anchored (mmt-graph.anchor.mjs);
|
|
68
|
+
* each `proof` lets a holder later prove one event was in the anchored batch.
|
|
69
|
+
*/
|
|
70
|
+
export function buildChangelogBatch(events) {
|
|
71
|
+
const leaves = events.map(eventLeaf);
|
|
72
|
+
const root = merkleRoot(leaves);
|
|
73
|
+
const entries = events.map((event, index) => ({
|
|
74
|
+
index,
|
|
75
|
+
op: event.op,
|
|
76
|
+
leaf: leaves[index],
|
|
77
|
+
proof: merkleProof(leaves, index),
|
|
78
|
+
}));
|
|
79
|
+
return { root, size: events.length, entries };
|
|
80
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5, second and fourth slices: proves the engine can answer contagion.ts's hard
|
|
3
|
+
* question — not "what does this agent share with others" (an easy join) but "which of
|
|
4
|
+
* those shares are SPECIFIC enough to mean something" — as a SPARQL aggregate query. All
|
|
5
|
+
* four real cohort kinds (`sop`, `standard`, `organization`, `unit`) are covered, even
|
|
6
|
+
* though the real algorithm computes them via two different code shapes
|
|
7
|
+
* (`buildKeyed`/`buildSingle`) — the aggregate query below doesn't need to know that
|
|
8
|
+
* distinction, because `mmt:sharesCohort` is emitted uniformly for all four (see
|
|
9
|
+
* mmt-graph.project.mjs's projectCohortShared for why `organization`/`unit` additionally
|
|
10
|
+
* assert `mmt:memberOf`, which this query does not need). Not wired to any live endpoint,
|
|
11
|
+
* same "proof not cutover" posture as mmt-graph.evidence-path.mjs.
|
|
12
|
+
*
|
|
13
|
+
* `isSpecific` below is a verbatim port of contagion.ts's function — same constants, same
|
|
14
|
+
* two-condition logic, not re-derived — because the whole point of that function is a
|
|
15
|
+
* measured, load-bearing threshold (a real platform standard linked 57 of 92 agents when
|
|
16
|
+
* it was written) and a fresh guess at the numbers would defeat the purpose of proving
|
|
17
|
+
* this against the real rule.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { MMT, agentIri, cohortIri } from './mmt-graph.project.mjs';
|
|
21
|
+
|
|
22
|
+
/** Verbatim from backend/src/features/trust-graph/contagion.ts — see that file for why both conditions are required. */
|
|
23
|
+
export const BROAD_COHORT_MIN = 10;
|
|
24
|
+
export const BROAD_COHORT_SHARE = 0.5;
|
|
25
|
+
|
|
26
|
+
export function isSpecific(sharedBy, tenantAgents) {
|
|
27
|
+
if (sharedBy < BROAD_COHORT_MIN) return true;
|
|
28
|
+
if (tenantAgents <= 0) return true;
|
|
29
|
+
return sharedBy / tenantAgents < BROAD_COHORT_SHARE;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const COHORT_TYPE_TO_KIND = {
|
|
33
|
+
[`${MMT}SOP`]: 'sop',
|
|
34
|
+
[`${MMT}Standard`]: 'standard',
|
|
35
|
+
[`${MMT}Organization`]: 'organization',
|
|
36
|
+
[`${MMT}AgentGroup`]: 'unit',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param engine {import('./mmt-graph.engine.mjs').MmtGraphEngine}
|
|
41
|
+
* @param subjectAgentDid {string}
|
|
42
|
+
* @returns {{ cohorts: Array<{ kind: string, key: string, label: string, version: number|null, sharedBy: number, tenantAgents: number, specific: boolean, peers: string[] }>, tenantAgents: number }}
|
|
43
|
+
*/
|
|
44
|
+
export function computeCohorts(engine, subjectAgentDid) {
|
|
45
|
+
const subject = agentIri(subjectAgentDid).value;
|
|
46
|
+
|
|
47
|
+
// COUNT(DISTINCT ?peerAgent) is inclusive of the subject itself (the subject is one of
|
|
48
|
+
// the agents pointing at its own cohort node) — this matches contagion.ts's
|
|
49
|
+
// `sharedBy = peers.length + 1` (peers excludes the subject, then +1 adds it back)
|
|
50
|
+
// without needing separate include/exclude arithmetic here.
|
|
51
|
+
const cohortRows = [
|
|
52
|
+
...engine.query(`
|
|
53
|
+
PREFIX mmt: <${MMT}>
|
|
54
|
+
SELECT ?cohort ?kindType ?key ?label ?version (COUNT(DISTINCT ?peerAgent) AS ?sharedBy) WHERE {
|
|
55
|
+
<${subject}> mmt:sharesCohort ?cohort .
|
|
56
|
+
?cohort a ?kindType ; mmt:cohortKey ?key ; mmt:cohortLabel ?label .
|
|
57
|
+
OPTIONAL { ?cohort mmt:cohortVersion ?version }
|
|
58
|
+
?peerAgent mmt:sharesCohort ?cohort .
|
|
59
|
+
}
|
|
60
|
+
GROUP BY ?cohort ?kindType ?key ?label ?version
|
|
61
|
+
`),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Phase 3's per-tenant engine (one MmtGraphEngine instance per ownerId) means this count
|
|
65
|
+
// is already correctly tenant-scoped for free — no separate tenantAgentCount input needed,
|
|
66
|
+
// unlike contagion.ts's real signature, which takes it explicitly because Postgres has no
|
|
67
|
+
// equivalent physical boundary.
|
|
68
|
+
const tenantAgentsRow = [
|
|
69
|
+
...engine.query(`PREFIX mmt: <${MMT}> SELECT (COUNT(DISTINCT ?agent) AS ?count) WHERE { ?agent a mmt:AIAgent }`),
|
|
70
|
+
][0];
|
|
71
|
+
const tenantAgents = Number(tenantAgentsRow.get('count').value);
|
|
72
|
+
|
|
73
|
+
const cohorts = cohortRows.map((row) => {
|
|
74
|
+
const cohortIriValue = row.get('cohort').value;
|
|
75
|
+
const kindTypeIri = row.get('kindType').value;
|
|
76
|
+
const kind = COHORT_TYPE_TO_KIND[kindTypeIri] ?? kindTypeIri;
|
|
77
|
+
const sharedBy = Number(row.get('sharedBy').value);
|
|
78
|
+
const versionTerm = row.get('version');
|
|
79
|
+
|
|
80
|
+
const peerRows = [
|
|
81
|
+
...engine.query(`
|
|
82
|
+
PREFIX mmt: <${MMT}>
|
|
83
|
+
SELECT DISTINCT ?peerAgent WHERE {
|
|
84
|
+
?peerAgent mmt:sharesCohort <${cohortIriValue}> .
|
|
85
|
+
FILTER(?peerAgent != <${subject}>)
|
|
86
|
+
}
|
|
87
|
+
`),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
kind,
|
|
92
|
+
key: row.get('key').value,
|
|
93
|
+
label: row.get('label').value,
|
|
94
|
+
version: versionTerm ? Number(versionTerm.value) : null,
|
|
95
|
+
sharedBy,
|
|
96
|
+
tenantAgents,
|
|
97
|
+
specific: isSpecific(sharedBy, tenantAgents),
|
|
98
|
+
peers: peerRows.map((r) => r.get('peerAgent').value),
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return { cohorts, tenantAgents };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { cohortIri };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference trust-graph engine: apply mutation events, validate the §3 authority-chain
|
|
3
|
+
* invariant, query the result. In-memory only (Oxigraph's JS binding has no disk
|
|
4
|
+
* persistence) — Phase 2's HCS-anchored changelog is the intended durable source of truth;
|
|
5
|
+
* `replay()` is the "rebuild from a durable log" story made real, even though the log
|
|
6
|
+
* itself doesn't exist yet. No Postgres, no HCS, no backend wiring here — see
|
|
7
|
+
* docs/design/metamynd-trust-ontology.md Appendix C for what's deferred to which phase.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
import oxigraph from 'oxigraph';
|
|
15
|
+
import rdf from '@zazuko/env-node';
|
|
16
|
+
import SHACLValidator from 'rdf-validate-shacl';
|
|
17
|
+
|
|
18
|
+
import * as project from './mmt-graph.project.mjs';
|
|
19
|
+
import { MMT, mandateIri } from './mmt-graph.project.mjs';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const SHAPES_TTL = readFileSync(join(__dirname, 'mmt-graph.shapes.ttl'), 'utf8');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An agent's authority is valid if it either acts directly for a Principal (a root
|
|
26
|
+
* mandate) or its derivesAuthorityFrom chain reaches an agent that does — the §3
|
|
27
|
+
* invariant, "every executable authority must have an unbroken, currently valid path to
|
|
28
|
+
* an authoritative Principal," made runnable. `+` is Oxigraph's SPARQL 1.1 property-path
|
|
29
|
+
* one-or-more operator; a 1-hop delegation and a 3-hop one are the same query.
|
|
30
|
+
*/
|
|
31
|
+
const REACHES_PRINCIPAL = (agentIriValue) => `
|
|
32
|
+
PREFIX mmt: <${MMT}>
|
|
33
|
+
ASK {
|
|
34
|
+
{ <${agentIriValue}> mmt:actsFor ?p . ?p a mmt:Organization . }
|
|
35
|
+
UNION
|
|
36
|
+
{ <${agentIriValue}> mmt:derivesAuthorityFrom+ ?parent . ?parent mmt:actsFor ?p . ?p a mmt:Organization . }
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const AGENTS_WITH_MANDATES = `
|
|
41
|
+
PREFIX mmt: <${MMT}>
|
|
42
|
+
SELECT DISTINCT ?agent WHERE { ?mandate a mmt:Mandate ; mmt:grantedTo ?agent . }
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
export class MmtGraphEngine {
|
|
46
|
+
#store = new oxigraph.Store();
|
|
47
|
+
#log = [];
|
|
48
|
+
|
|
49
|
+
/** @param event {import('./mmt-graph.types.mjs').AgentRegistered | import('./mmt-graph.types.mjs').PrincipalRegistered | import('./mmt-graph.types.mjs').MandateGranted | import('./mmt-graph.types.mjs').MandateRevoked} */
|
|
50
|
+
apply(event) {
|
|
51
|
+
const projected = this.#project(event);
|
|
52
|
+
if (Array.isArray(projected)) {
|
|
53
|
+
for (const q of projected) this.#store.add(q);
|
|
54
|
+
} else if (projected?.removeSubject) {
|
|
55
|
+
for (const q of [...this.#store.match(projected.removeSubject, null, null)]) this.#store.delete(q);
|
|
56
|
+
}
|
|
57
|
+
this.#log.push(event);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#project(event) {
|
|
61
|
+
switch (event.op) {
|
|
62
|
+
case 'agent-registered':
|
|
63
|
+
return project.projectAgentRegistered(event);
|
|
64
|
+
case 'principal-registered':
|
|
65
|
+
return project.projectPrincipalRegistered(event);
|
|
66
|
+
case 'mandate-granted': {
|
|
67
|
+
const parentAgentDid = event.delegatedFromPolicyId
|
|
68
|
+
? this.#grantedToAgent(event.delegatedFromPolicyId)
|
|
69
|
+
: null;
|
|
70
|
+
return project.projectMandateGranted(event, parentAgentDid);
|
|
71
|
+
}
|
|
72
|
+
case 'mandate-revoked':
|
|
73
|
+
return project.projectMandateRevoked(event);
|
|
74
|
+
case 'decision-recorded':
|
|
75
|
+
return project.projectDecisionRecorded(event);
|
|
76
|
+
case 'evidence-recorded': {
|
|
77
|
+
const matchingDecisionIriValue = event.decisionDigest ? this.#decisionForDigest(event.decisionDigest) : null;
|
|
78
|
+
return project.projectEvidenceRecorded(event, matchingDecisionIriValue);
|
|
79
|
+
}
|
|
80
|
+
case 'batch-anchored':
|
|
81
|
+
return project.projectBatchAnchored(event);
|
|
82
|
+
case 'cohort-shared':
|
|
83
|
+
return project.projectCohortShared(event);
|
|
84
|
+
case 'human-registered':
|
|
85
|
+
return project.projectHumanRegistered(event);
|
|
86
|
+
case 'issuer-registered':
|
|
87
|
+
return project.projectIssuerRegistered(event);
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`unknown mutation event op: ${event.op}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolves `mmt:grantedTo` for a parent mandate — the value-equality join delegation-created needs. */
|
|
94
|
+
#grantedToAgent(policyId) {
|
|
95
|
+
const iri = mandateIri(policyId).value;
|
|
96
|
+
const rows = [...this.#store.query(`PREFIX mmt: <${MMT}> SELECT ?agent WHERE { <${iri}> mmt:grantedTo ?agent }`)];
|
|
97
|
+
return rows.length > 0 ? rows[0].get('agent').value : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolves the mmt:AuthorizationDecision whose mmt:decisionDigest matches — the value-equality join evidence-recorded needs, mirroring #grantedToAgent. */
|
|
101
|
+
#decisionForDigest(digest) {
|
|
102
|
+
const rows = [
|
|
103
|
+
...this.#store.query(
|
|
104
|
+
`PREFIX mmt: <${MMT}> SELECT ?d WHERE { ?d a mmt:AuthorizationDecision ; mmt:decisionDigest ${JSON.stringify(digest)} }`
|
|
105
|
+
),
|
|
106
|
+
];
|
|
107
|
+
return rows.length > 0 ? rows[0].get('d').value : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
query(sparql) {
|
|
111
|
+
return this.#store.query(sparql);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Rebuilds from empty by re-applying every event in order — the log is the source of truth, the store is derived. */
|
|
115
|
+
replay(events) {
|
|
116
|
+
this.#store = new oxigraph.Store();
|
|
117
|
+
this.#log = [];
|
|
118
|
+
for (const event of events) this.apply(event);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get log() {
|
|
122
|
+
return [...this.#log];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Runs the SHACL shapes plus the §3 reachability ASK for every agent with a mandate.
|
|
127
|
+
* One combined report so callers don't need to reconcile two different result shapes.
|
|
128
|
+
*/
|
|
129
|
+
async validate() {
|
|
130
|
+
const shapesStore = new oxigraph.Store();
|
|
131
|
+
shapesStore.load(SHAPES_TTL, { format: 'text/turtle' });
|
|
132
|
+
const shapesDataset = rdf.dataset([...shapesStore.match()]);
|
|
133
|
+
const dataDataset = rdf.dataset([...this.#store.match()]);
|
|
134
|
+
const validator = new SHACLValidator(shapesDataset, { factory: rdf });
|
|
135
|
+
const shapeReport = await validator.validate(dataDataset);
|
|
136
|
+
|
|
137
|
+
const unreachableAgents = [];
|
|
138
|
+
for (const row of this.#store.query(AGENTS_WITH_MANDATES)) {
|
|
139
|
+
const agentIriValue = row.get('agent').value;
|
|
140
|
+
if (!this.#store.query(REACHES_PRINCIPAL(agentIriValue))) unreachableAgents.push(agentIriValue);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
conforms: shapeReport.conforms && unreachableAgents.length === 0,
|
|
145
|
+
shapeViolations: shapeReport.results.map((r) => ({
|
|
146
|
+
focusNode: r.focusNode.value,
|
|
147
|
+
message: r.message?.[0]?.value ?? null,
|
|
148
|
+
path: r.path?.value ?? null,
|
|
149
|
+
})),
|
|
150
|
+
unreachableAgents,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|