@metamynd/mmt-graph 0.3.1 → 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.
- package/mmt-graph.project.mjs +8 -0
- package/mmt-graph.spend-anomaly.mjs +82 -0
- package/mmt-graph.types.mjs +16 -3
- package/package.json +5 -3
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
|
@@ -92,7 +92,17 @@
|
|
|
92
92
|
* `mmt:decidedFor` since Domain H names no equivalent predicate (see
|
|
93
93
|
* mmt-graph.accountability.mjs).
|
|
94
94
|
*/
|
|
95
|
-
/**
|
|
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 */
|
|
96
106
|
/** @typedef {{ op: 'evidence-recorded', eventId: string, decisionDigest?: string, anchorStatus?: string }} EvidenceRecorded */
|
|
97
107
|
/** @typedef {{ op: 'batch-anchored', batchId: string, memberEventIds: string[], anchorStatus?: string }} BatchAnchored */
|
|
98
108
|
|
|
@@ -195,9 +205,12 @@ export function agentReinstated({ agentDid }) {
|
|
|
195
205
|
}
|
|
196
206
|
|
|
197
207
|
/** @returns {DecisionRecorded} */
|
|
198
|
-
export function decisionRecorded({ decisionId, decisionDigest, agentDid }) {
|
|
199
|
-
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 };
|
|
200
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
|
+
}
|
|
201
214
|
return ev;
|
|
202
215
|
}
|
|
203
216
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@metamynd/mmt-graph",
|
|
3
|
-
"version": "0.3.
|
|
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"
|