@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.
@@ -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
+ }
@@ -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
- /** @typedef {{ op: 'decision-recorded', decisionId: string, decisionDigest?: string, agentDid?: string }} DecisionRecorded */
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.1",
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"