@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), parseOperatorKey(operatorKey));
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
- const resp = await new TopicCreateTransaction().execute(client);
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;
@@ -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
+ }
@@ -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
- /** @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 */
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.0",
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"