@metamynd/mmt-graph 0.2.0 → 0.3.2
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.
|
@@ -67,15 +67,18 @@ function parseOperatorKey(raw) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
const operatorPrivateKey = parseOperatorKey(operatorKey);
|
|
70
71
|
const client = Client.forName(network);
|
|
71
|
-
client.setOperator(AccountId.fromString(operatorId),
|
|
72
|
+
client.setOperator(AccountId.fromString(operatorId), operatorPrivateKey);
|
|
72
73
|
|
|
73
74
|
async function ensureTopicId() {
|
|
74
75
|
const fromEnv = process.env.MMT_GRAPH_TOPIC_ID;
|
|
75
76
|
if (fromEnv) return fromEnv;
|
|
76
77
|
|
|
77
78
|
console.log('No MMT_GRAPH_TOPIC_ID set — creating a fresh topic (set this env var to reuse it next time).');
|
|
78
|
-
|
|
79
|
+
// Admin key IS set (the operator key) so this topic stays upgradeable later (e.g. an
|
|
80
|
+
// HSuite multisig validator key) — omitting it makes a topic permanently immutable.
|
|
81
|
+
const resp = await new TopicCreateTransaction().setAdminKey(operatorPrivateKey).execute(client);
|
|
79
82
|
const topicId = (await resp.getReceipt(client)).topicId?.toString();
|
|
80
83
|
if (!topicId) throw new Error('topic creation succeeded but returned no topicId');
|
|
81
84
|
return topicId;
|
package/mmt-graph.project.mjs
CHANGED
|
@@ -20,6 +20,7 @@ export const MMT = 'https://schema.metamynd.ai/trust/v1#';
|
|
|
20
20
|
const RDF_TYPE = namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type');
|
|
21
21
|
const XSD_INTEGER = namedNode('http://www.w3.org/2001/XMLSchema#integer');
|
|
22
22
|
const XSD_DATETIME = namedNode('http://www.w3.org/2001/XMLSchema#dateTime');
|
|
23
|
+
const XSD_DECIMAL = namedNode('http://www.w3.org/2001/XMLSchema#decimal');
|
|
23
24
|
|
|
24
25
|
const mmt = (local) => namedNode(MMT + local);
|
|
25
26
|
|
|
@@ -119,6 +120,13 @@ export function projectDecisionRecorded(ev) {
|
|
|
119
120
|
const quads = [quad(decision, RDF_TYPE, mmt('AuthorizationDecision'))];
|
|
120
121
|
if (ev.decisionDigest) quads.push(quad(decision, mmt('decisionDigest'), literal(ev.decisionDigest)));
|
|
121
122
|
if (ev.agentDid) quads.push(quad(decision, mmt('decidedFor'), agentIri(ev.agentDid)));
|
|
123
|
+
// amount/currency/action/occurredAt: see this event's own header in mmt-graph.types.mjs —
|
|
124
|
+
// added for spend-anomaly.mjs's baseline-deviation query, which needs to ORDER BY
|
|
125
|
+
// occurredAt and aggregate over amount, hence the typed (not plain-string) literals.
|
|
126
|
+
if (ev.amount !== undefined) quads.push(quad(decision, mmt('amount'), literal(String(ev.amount), XSD_DECIMAL)));
|
|
127
|
+
if (ev.currency) quads.push(quad(decision, mmt('currency'), literal(ev.currency)));
|
|
128
|
+
if (ev.action) quads.push(quad(decision, mmt('action'), literal(ev.action)));
|
|
129
|
+
if (ev.occurredAt) quads.push(quad(decision, mmt('occurredAt'), literal(ev.occurredAt, XSD_DATETIME)));
|
|
122
130
|
return quads;
|
|
123
131
|
}
|
|
124
132
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend-pattern anomaly detection (resilience review, item 4 — the one thing rate limits
|
|
3
|
+
* and circuit breakers genuinely don't cover). Both of those enforce a fixed threshold a
|
|
4
|
+
* human set ahead of time; neither notices "this specific agent's amount just jumped 10x
|
|
5
|
+
* its own normal pattern" unless someone happened to configure a cap at exactly that level.
|
|
6
|
+
* This queries the agent's OWN recorded history (mmt:AuthorizationDecision, populated by
|
|
7
|
+
* recordAuthorizationDecided — see mandate.service.ts) and flags a deviation from ITS
|
|
8
|
+
* baseline, not a pre-set number.
|
|
9
|
+
*
|
|
10
|
+
* Split the same way mandate-anchor-audit.ts and rate-window/circuit-breaker's logic/service
|
|
11
|
+
* files are: `recentAmounts` is the only I/O (a SPARQL SELECT against the engine, which is
|
|
12
|
+
* itself in-memory — no network, no DB, but still "ask the store" rather than pure
|
|
13
|
+
* computation); `isSpendAnomaly` is pure and independently testable with plain arrays.
|
|
14
|
+
*/
|
|
15
|
+
import { MMT } from './mmt-graph.project.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The agent's most recent recorded decision amounts, newest first, optionally scoped to one
|
|
19
|
+
* action. Only decisions with BOTH an amount and an occurredAt are returned — one without
|
|
20
|
+
* the other cannot be ordered/aggregated meaningfully, and both are always set together by
|
|
21
|
+
* recordAuthorizationDecided, so an entry missing either predates that wiring or came from a
|
|
22
|
+
* different producer and is excluded rather than guessed at.
|
|
23
|
+
*
|
|
24
|
+
* @param engine {import('./mmt-graph.engine.mjs').MmtGraphEngine}
|
|
25
|
+
* @param agentDid {string}
|
|
26
|
+
* @param opts {{ action?: string, limit?: number }}
|
|
27
|
+
* @returns {number[]}
|
|
28
|
+
*/
|
|
29
|
+
export function recentAmounts(engine, agentDid, opts = {}) {
|
|
30
|
+
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 20;
|
|
31
|
+
const actionFilter = opts.action ? `FILTER(?action = ${JSON.stringify(opts.action)})` : '';
|
|
32
|
+
const rows = [
|
|
33
|
+
...engine.query(`
|
|
34
|
+
PREFIX mmt: <${MMT}>
|
|
35
|
+
SELECT ?amount ?occurredAt WHERE {
|
|
36
|
+
?d a mmt:AuthorizationDecision ;
|
|
37
|
+
mmt:decidedFor <${agentDid}> ;
|
|
38
|
+
mmt:amount ?amount ;
|
|
39
|
+
mmt:occurredAt ?occurredAt .
|
|
40
|
+
OPTIONAL { ?d mmt:action ?action }
|
|
41
|
+
${actionFilter}
|
|
42
|
+
}
|
|
43
|
+
ORDER BY DESC(?occurredAt)
|
|
44
|
+
LIMIT ${limit}
|
|
45
|
+
`),
|
|
46
|
+
];
|
|
47
|
+
return rows.map((r) => Number(r.get('amount').value));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether `currentAmount` deviates from `history`'s baseline enough to be a genuine anomaly
|
|
52
|
+
* rather than ordinary variance. Pure — no I/O, no clock, just arrays and numbers.
|
|
53
|
+
*
|
|
54
|
+
* - Too little history (`< minHistory`) never flags: a brand-new agent's first few real
|
|
55
|
+
* transactions ARE its baseline, not a deviation from one that doesn't exist yet.
|
|
56
|
+
* - A near-zero stddev (a very regular spender — e.g. always exactly $50) falls back to a
|
|
57
|
+
* flat multiple of the mean, because dividing by ~0 would make almost ANY amount read as
|
|
58
|
+
* "many standard deviations away" and flag on trivial rounding noise.
|
|
59
|
+
* - Only a genuine INCREASE can flag. A smaller-than-usual amount is never "anomalous" in
|
|
60
|
+
* the sense this exists to catch (an attacker extracting more than the agent normally
|
|
61
|
+
* moves) — flagging a decrease would just be noise with no security value.
|
|
62
|
+
*
|
|
63
|
+
* @param history {number[]} — most recent amounts, current one NOT included
|
|
64
|
+
* @param currentAmount {number}
|
|
65
|
+
* @param opts {{ minHistory?: number, deviationMultiple?: number }}
|
|
66
|
+
*/
|
|
67
|
+
export function isSpendAnomaly(history, currentAmount, opts = {}) {
|
|
68
|
+
const minHistory = Number.isFinite(opts.minHistory) && opts.minHistory > 0 ? Math.floor(opts.minHistory) : 5;
|
|
69
|
+
const deviationMultiple = Number.isFinite(opts.deviationMultiple) && opts.deviationMultiple > 0 ? opts.deviationMultiple : 4;
|
|
70
|
+
|
|
71
|
+
if (history.length < minHistory) {
|
|
72
|
+
return { anomaly: false, reason: 'insufficient-history', mean: null, stddev: null, threshold: null };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const mean = history.reduce((a, b) => a + b, 0) / history.length;
|
|
76
|
+
const variance = history.reduce((a, b) => a + (b - mean) ** 2, 0) / history.length;
|
|
77
|
+
const stddev = Math.sqrt(variance);
|
|
78
|
+
const threshold = stddev > 0 ? mean + deviationMultiple * stddev : mean * deviationMultiple;
|
|
79
|
+
|
|
80
|
+
const anomaly = currentAmount > mean && currentAmount > threshold;
|
|
81
|
+
return { anomaly, reason: anomaly ? 'deviates-from-baseline' : null, mean, stddev, threshold };
|
|
82
|
+
}
|
package/mmt-graph.types.mjs
CHANGED
|
@@ -60,6 +60,25 @@
|
|
|
60
60
|
*/
|
|
61
61
|
/** @typedef {{ op: 'mandate-revoked', policyId: string }} MandateRevoked */
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Owner-initiated agent containment (distinct from a rule-fired suspend/quarantine,
|
|
65
|
+
* neither of which anchor to this graph today). `reason` is the free-text
|
|
66
|
+
* `AgentIdentity.containedReason` at the moment of decommission, capped the same way
|
|
67
|
+
* the DB column is.
|
|
68
|
+
*
|
|
69
|
+
* NOT YET WIRED to a projector or an adapter/backend call site (unlike
|
|
70
|
+
* MandateGranted/MandateRevoked, both of which have engine.mjs `#project()` cases —
|
|
71
|
+
* MandateRevoked's is just never invoked from a backend call site today). Applying
|
|
72
|
+
* AgentReinstated correctly needs a projector that clears just the prior
|
|
73
|
+
* containmentStatus/containmentReason quads for the agent — `apply()`'s only removal
|
|
74
|
+
* primitive today is `{removeSubject}`, which deletes the WHOLE agent node (its type,
|
|
75
|
+
* memberOf, mandates, cohorts…), not a single predicate. Add an
|
|
76
|
+
* `mmt-graph.engine.mjs` case (+ project.mjs projector) with a predicate-scoped
|
|
77
|
+
* removal before wiring an adapter call — until then this is vocabulary-only.
|
|
78
|
+
*/
|
|
79
|
+
/** @typedef {{ op: 'agent-decommissioned', agentDid: string, reason?: string }} AgentDecommissioned */
|
|
80
|
+
/** @typedef {{ op: 'agent-reinstated', agentDid: string }} AgentReinstated */
|
|
81
|
+
|
|
63
82
|
/**
|
|
64
83
|
* Domain H (runtime activity/evidence) — Phase 5's first slice, proving the engine can
|
|
65
84
|
* answer evidence-path.ts's "independently verifiable" question, not a full replacement
|
|
@@ -73,7 +92,17 @@
|
|
|
73
92
|
* `mmt:decidedFor` since Domain H names no equivalent predicate (see
|
|
74
93
|
* mmt-graph.accountability.mjs).
|
|
75
94
|
*/
|
|
76
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* `amount`/`currency`/`action`/`occurredAt`, added for the resilience-review's spend-
|
|
97
|
+
* pattern anomaly detector (mmt-graph.spend-anomaly.mjs): the graph already recorded that a
|
|
98
|
+
* decision happened, but not what it was FOR, so there was no history a baseline-deviation
|
|
99
|
+
* check could query. Deliberately only the fields spend-anomaly.mjs's SPARQL actually reads
|
|
100
|
+
* — not amountCharged/bookingRef/settlementTxHash, which belong to capture(), a different
|
|
101
|
+
* event this package does not yet model. Recorded only for a decision that PERMITTED
|
|
102
|
+
* (allow/observe) — a blocked/escalated attempt was never a real transaction and would
|
|
103
|
+
* corrupt the baseline it's meant to protect, not describe it.
|
|
104
|
+
*/
|
|
105
|
+
/** @typedef {{ op: 'decision-recorded', decisionId: string, decisionDigest?: string, agentDid?: string, amount?: number, currency?: string, action?: string, occurredAt?: string }} DecisionRecorded */
|
|
77
106
|
/** @typedef {{ op: 'evidence-recorded', eventId: string, decisionDigest?: string, anchorStatus?: string }} EvidenceRecorded */
|
|
78
107
|
/** @typedef {{ op: 'batch-anchored', batchId: string, memberEventIds: string[], anchorStatus?: string }} BatchAnchored */
|
|
79
108
|
|
|
@@ -161,10 +190,27 @@ export function mandateRevoked({ policyId }) {
|
|
|
161
190
|
return ev;
|
|
162
191
|
}
|
|
163
192
|
|
|
193
|
+
/** @returns {AgentDecommissioned} */
|
|
194
|
+
export function agentDecommissioned({ agentDid, reason }) {
|
|
195
|
+
const ev = { op: 'agent-decommissioned', agentDid, reason };
|
|
196
|
+
requireFields(ev, ['agentDid']);
|
|
197
|
+
return ev;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** @returns {AgentReinstated} */
|
|
201
|
+
export function agentReinstated({ agentDid }) {
|
|
202
|
+
const ev = { op: 'agent-reinstated', agentDid };
|
|
203
|
+
requireFields(ev, ['agentDid']);
|
|
204
|
+
return ev;
|
|
205
|
+
}
|
|
206
|
+
|
|
164
207
|
/** @returns {DecisionRecorded} */
|
|
165
|
-
export function decisionRecorded({ decisionId, decisionDigest, agentDid }) {
|
|
166
|
-
const ev = { op: 'decision-recorded', decisionId, decisionDigest, agentDid };
|
|
208
|
+
export function decisionRecorded({ decisionId, decisionDigest, agentDid, amount, currency, action, occurredAt }) {
|
|
209
|
+
const ev = { op: 'decision-recorded', decisionId, decisionDigest, agentDid, amount, currency, action, occurredAt };
|
|
167
210
|
requireFields(ev, ['decisionId']);
|
|
211
|
+
if (amount !== undefined && (typeof amount !== 'number' || !Number.isFinite(amount))) {
|
|
212
|
+
throw new Error(`decision-recorded for ${decisionId}: amount must be a finite number`);
|
|
213
|
+
}
|
|
168
214
|
return ev;
|
|
169
215
|
}
|
|
170
216
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/mmt-graph",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference trust-graph engine (Oxigraph/SPARQL + SHACL) + HCS-anchor contract: apply mutation events, validate the MMTO §3 authority-chain invariant, batch the changelog into a Merkle root ready to anchor, and verify a disclosed tenant graph with MetaMynd fully offline. Hosting-mode-agnostic — no MetaMynd backend, no Postgres, no MetaMynd credentials anywhere in the code path. See docs/design/metamynd-trust-ontology.md Appendix C.",
|
|
6
6
|
"main": "mmt-graph.engine.mjs",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"./contagion": "./mmt-graph.contagion.mjs",
|
|
15
15
|
"./accountability": "./mmt-graph.accountability.mjs",
|
|
16
16
|
"./blast-radius": "./mmt-graph.blast-radius.mjs",
|
|
17
|
-
"./auditor": "./mmt-graph.auditor.mjs"
|
|
17
|
+
"./auditor": "./mmt-graph.auditor.mjs",
|
|
18
|
+
"./spend-anomaly": "./mmt-graph.spend-anomaly.mjs"
|
|
18
19
|
},
|
|
19
20
|
"bin": {
|
|
20
21
|
"mmt-graph-auditor": "mmt-graph.auditor-cli.mjs"
|
|
@@ -35,11 +36,12 @@
|
|
|
35
36
|
"mmt-graph.auditor.mjs",
|
|
36
37
|
"mmt-graph.auditor-cli.mjs",
|
|
37
38
|
"mmt-graph.self-host-quickstart.mjs",
|
|
39
|
+
"mmt-graph.spend-anomaly.mjs",
|
|
38
40
|
"README.md"
|
|
39
41
|
],
|
|
40
42
|
"scripts": {
|
|
41
43
|
"spike": "node mmt-graph.spike.mjs",
|
|
42
|
-
"test": "node mmt-graph.test.mjs && node mmt-graph.merkle.test.mjs && node mmt-graph.changelog.test.mjs && node mmt-graph.anchor.test.mjs && node mmt-graph.evidence-path.test.mjs && node mmt-graph.contagion.test.mjs && node mmt-graph.accountability.test.mjs && node mmt-graph.blast-radius.test.mjs && node mmt-graph.auditor.test.mjs",
|
|
44
|
+
"test": "node mmt-graph.test.mjs && node mmt-graph.merkle.test.mjs && node mmt-graph.changelog.test.mjs && node mmt-graph.anchor.test.mjs && node mmt-graph.evidence-path.test.mjs && node mmt-graph.contagion.test.mjs && node mmt-graph.accountability.test.mjs && node mmt-graph.blast-radius.test.mjs && node mmt-graph.auditor.test.mjs && node mmt-graph.spend-anomaly.test.mjs",
|
|
43
45
|
"anchor-live-demo": "node mmt-graph.anchor-live-demo.mjs",
|
|
44
46
|
"auditor": "node mmt-graph.auditor-cli.mjs",
|
|
45
47
|
"self-host-quickstart": "node mmt-graph.self-host-quickstart.mjs"
|