@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,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5, first slice: proves the engine can answer evidence-path.ts's core question —
|
|
3
|
+
* "can a third party verify this decision without us?" — as a graph query, not a
|
|
4
|
+
* replacement of that file. See docs/design/metamynd-trust-ontology.md Appendix C and the
|
|
5
|
+
* plan this shipped under for why: blast-radius/accountability/contagion each need real
|
|
6
|
+
* new work (an Issuer concept, a Human concept, SPARQL aggregates) this slice doesn't
|
|
7
|
+
* attempt, and even this one isn't wired to any live endpoint — MMT_GRAPH_ENABLED is off
|
|
8
|
+
* by default, and even on, nothing backfills historical decisions into the graph yet.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors evidence-path.ts's exact precedence rule, not a fresh guess at one:
|
|
11
|
+
* `anchorStatus = batch?.anchorStatus ?? evidence?.anchorStatus ?? null` — a batch's
|
|
12
|
+
* status, when a batch exists, always wins over the evidence event's own status. The
|
|
13
|
+
* `FILTER NOT EXISTS` in the direct-status branch below is what enforces "when a batch
|
|
14
|
+
* exists" — without it, an evidence event with a batch AND a stale direct status could
|
|
15
|
+
* verify via the wrong branch.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { MMT, decisionIri, evidenceIri } from './mmt-graph.project.mjs';
|
|
19
|
+
|
|
20
|
+
const ANCHORED_STATUSES = ['SUCCESS', 'ANCHORED_BATCH', 'ANCHORED'];
|
|
21
|
+
const STATUS_FILTER = ANCHORED_STATUSES.map((s) => JSON.stringify(s)).join(', ');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param engine {import('./mmt-graph.engine.mjs').MmtGraphEngine}
|
|
25
|
+
* @param decisionId {string}
|
|
26
|
+
* @returns {{ independentlyVerifiable: boolean, hasEvidence: boolean, hasDecisionDigest: boolean }}
|
|
27
|
+
*/
|
|
28
|
+
export function isIndependentlyVerifiable(engine, decisionId) {
|
|
29
|
+
const decision = decisionIri(decisionId).value;
|
|
30
|
+
|
|
31
|
+
const hasDecisionDigest = engine.query(`
|
|
32
|
+
PREFIX mmt: <${MMT}>
|
|
33
|
+
ASK { <${decision}> mmt:decisionDigest ?d . }
|
|
34
|
+
`);
|
|
35
|
+
|
|
36
|
+
const hasEvidence = engine.query(`
|
|
37
|
+
PREFIX mmt: <${MMT}>
|
|
38
|
+
ASK { <${decision}> mmt:generatedEvidence ?e . }
|
|
39
|
+
`);
|
|
40
|
+
|
|
41
|
+
// Batch status takes priority when a batch exists — the FILTER NOT EXISTS in the first
|
|
42
|
+
// branch is what makes that true, matching evidence-path.ts's `batch?.anchorStatus ??
|
|
43
|
+
// evidence?.anchorStatus` precedence rather than treating either status as equally valid.
|
|
44
|
+
const independentlyVerifiable = engine.query(`
|
|
45
|
+
PREFIX mmt: <${MMT}>
|
|
46
|
+
ASK {
|
|
47
|
+
<${decision}> mmt:generatedEvidence ?evidence .
|
|
48
|
+
{
|
|
49
|
+
?evidence mmt:anchorStatus ?directStatus .
|
|
50
|
+
FILTER(?directStatus IN (${STATUS_FILTER}))
|
|
51
|
+
FILTER NOT EXISTS { ?batch mmt:hasMember ?evidence }
|
|
52
|
+
}
|
|
53
|
+
UNION
|
|
54
|
+
{
|
|
55
|
+
?batch mmt:hasMember ?evidence ; mmt:anchorStatus ?batchStatus .
|
|
56
|
+
FILTER(?batchStatus IN (${STATUS_FILTER}))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
`);
|
|
60
|
+
|
|
61
|
+
return { independentlyVerifiable, hasEvidence, hasDecisionDigest };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Exported for tests that want to construct the evidence IRI without importing project.mjs directly. */
|
|
65
|
+
export { evidenceIri, decisionIri };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dependency-light Merkle tree — a direct port of backend/src/features/magp/merkle.ts's
|
|
3
|
+
* algorithm, not a reinvention: sha256-hex leaves/nodes, Bitcoin-style duplication of a
|
|
4
|
+
* lone odd node. Ported rather than imported so a batch anchored by this package and one
|
|
5
|
+
* anchored by the backend's existing evidence rail are verifiable the same way without this
|
|
6
|
+
* package depending on backend/ — the same reasoning integrations/magp-evidence already
|
|
7
|
+
* follows for the identical algorithm.
|
|
8
|
+
*
|
|
9
|
+
* Pure and deterministic: sha256 only, no clock/IO.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
/** sha256 → lower-case hex. */
|
|
15
|
+
export function sha256Hex(data) {
|
|
16
|
+
return createHash('sha256').update(data).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hashNodes(a, b) {
|
|
20
|
+
return sha256Hex(Buffer.concat([Buffer.from(a, 'hex'), Buffer.from(b, 'hex')]));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nextLevel(level) {
|
|
24
|
+
const next = [];
|
|
25
|
+
for (let i = 0; i < level.length; i += 2) {
|
|
26
|
+
const left = level[i];
|
|
27
|
+
const right = i + 1 < level.length ? level[i + 1] : left;
|
|
28
|
+
next.push(hashNodes(left, right));
|
|
29
|
+
}
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The Merkle root over the ordered leaves. Single leaf → itself; empty → sha256(""). */
|
|
34
|
+
export function merkleRoot(leaves) {
|
|
35
|
+
if (leaves.length === 0) return sha256Hex('');
|
|
36
|
+
let level = leaves.slice();
|
|
37
|
+
while (level.length > 1) level = nextLevel(level);
|
|
38
|
+
return level[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @typedef {{ sibling: string, position: 'left' | 'right' }} ProofStep */
|
|
42
|
+
|
|
43
|
+
/** The inclusion proof for `leaves[index]` — the sibling hashes from leaf to root. */
|
|
44
|
+
export function merkleProof(leaves, index) {
|
|
45
|
+
if (index < 0 || index >= leaves.length) throw new Error('merkleProof: index out of range');
|
|
46
|
+
const proof = [];
|
|
47
|
+
let level = leaves.slice();
|
|
48
|
+
let idx = index;
|
|
49
|
+
while (level.length > 1) {
|
|
50
|
+
const isRightNode = idx % 2 === 1;
|
|
51
|
+
const pairIdx = isRightNode ? idx - 1 : idx + 1;
|
|
52
|
+
const sibling = pairIdx < level.length ? level[pairIdx] : level[idx];
|
|
53
|
+
proof.push({ sibling, position: isRightNode ? 'left' : 'right' });
|
|
54
|
+
level = nextLevel(level);
|
|
55
|
+
idx = Math.floor(idx / 2);
|
|
56
|
+
}
|
|
57
|
+
return proof;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Verify a leaf's inclusion proof reproduces the anchored root. */
|
|
61
|
+
export function verifyMerkleProof(leaf, proof, root) {
|
|
62
|
+
let computed = leaf;
|
|
63
|
+
for (const step of proof) {
|
|
64
|
+
computed = step.position === 'left' ? hashNodes(step.sibling, computed) : hashNodes(computed, step.sibling);
|
|
65
|
+
}
|
|
66
|
+
return computed === root;
|
|
67
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure event → RDF quad projectors. No store, no I/O — the engine owns applying quads and
|
|
3
|
+
* resolving cross-event references (e.g. "which agent does the parent mandate's chain
|
|
4
|
+
* reach"); these functions only shape data, so they're independently testable and can't
|
|
5
|
+
* accidentally depend on apply-order.
|
|
6
|
+
*
|
|
7
|
+
* Vocabulary note, corrected from the storage-engine spike (integrations/mmt-graph's first
|
|
8
|
+
* version, PR #382): that spike used `mmt:actsFor` for Mandate→Agent. Domain A of the MMTO
|
|
9
|
+
* draft already defines `mmt:actsFor` as Agent→Principal ("the Principal is the actor whose
|
|
10
|
+
* authority the agent exercises"), and the source proposal's own worked example uses a
|
|
11
|
+
* separate `grantedTo` for Mandate→Agent. This module follows the doc's definition, not the
|
|
12
|
+
* spike's shortcut — see the spike-fix note in mmt-graph.spike.mjs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import oxigraph from 'oxigraph';
|
|
16
|
+
|
|
17
|
+
const { namedNode, literal, quad } = oxigraph;
|
|
18
|
+
|
|
19
|
+
export const MMT = 'https://schema.metamynd.ai/trust/v1#';
|
|
20
|
+
const RDF_TYPE = namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type');
|
|
21
|
+
const XSD_INTEGER = namedNode('http://www.w3.org/2001/XMLSchema#integer');
|
|
22
|
+
const XSD_DATETIME = namedNode('http://www.w3.org/2001/XMLSchema#dateTime');
|
|
23
|
+
|
|
24
|
+
const mmt = (local) => namedNode(MMT + local);
|
|
25
|
+
|
|
26
|
+
/** DIDs are already IRIs — an agent's or principal's node is its own DID, not a minted one. */
|
|
27
|
+
export const agentIri = (did) => namedNode(did);
|
|
28
|
+
export const principalIri = (did) => namedNode(did);
|
|
29
|
+
export const mandateIri = (policyId) => namedNode(`${MMT}mandate/${policyId}`);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `principalDid`, when given, is the agent's HOME organisation
|
|
33
|
+
* (`AgentIdentity.organizationPrincipalId` in accountability.ts) — projected onto
|
|
34
|
+
* `mmt:memberOf`, the same domain A relationship the fourth cohort slice already
|
|
35
|
+
* established for this exact field (see projectCohortShared's header). Deliberately NOT
|
|
36
|
+
* `mmt:actsFor`: that predicate is domain A's name for the root mandate's principalDid
|
|
37
|
+
* (see projectMandateGranted), a different fact that happens to often, but not always,
|
|
38
|
+
* name the same organisation — accountability.ts reports the two separately (`organization`
|
|
39
|
+
* vs `delegationRoot`) for exactly this reason.
|
|
40
|
+
*/
|
|
41
|
+
export function projectAgentRegistered(ev) {
|
|
42
|
+
const quads = [quad(agentIri(ev.agentDid), RDF_TYPE, mmt('AIAgent'))];
|
|
43
|
+
if (ev.principalDid) quads.push(quad(agentIri(ev.agentDid), mmt('memberOf'), principalIri(ev.principalDid)));
|
|
44
|
+
return quads;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The verification/representative fields are optional snapshot attributes — see
|
|
49
|
+
* AgentRegistered's header in mmt-graph.types.mjs for why this isn't a separate event.
|
|
50
|
+
* `mmt:operatedBy` (Organization → Human) is domain A's already-named relationship list
|
|
51
|
+
* (`ownedBy`, `operatedBy`, `actsFor`, `controlledBy`, `memberOf`), reused here for "the
|
|
52
|
+
* human who stands behind this entity's KYB" — the closest existing term to
|
|
53
|
+
* accountability.ts's own `answerable-by` edge, and never implemented until now.
|
|
54
|
+
*/
|
|
55
|
+
export function projectPrincipalRegistered(ev) {
|
|
56
|
+
const principal = principalIri(ev.principalDid);
|
|
57
|
+
const quads = [quad(principal, RDF_TYPE, mmt(ev.principalType ?? 'Organization'))];
|
|
58
|
+
if (ev.verificationStatus) quads.push(quad(principal, mmt('verificationStatus'), literal(ev.verificationStatus)));
|
|
59
|
+
if (ev.assuranceLevel) quads.push(quad(principal, mmt('assuranceLevel'), literal(ev.assuranceLevel)));
|
|
60
|
+
if (ev.verificationExpiresAt) quads.push(quad(principal, mmt('verificationExpiresAt'), literal(ev.verificationExpiresAt, XSD_DATETIME)));
|
|
61
|
+
if (ev.authorizedRepUserId) quads.push(quad(principal, mmt('operatedBy'), humanIri(ev.authorizedRepUserId)));
|
|
62
|
+
return quads;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param ev {import('./mmt-graph.types.mjs').MandateGranted}
|
|
67
|
+
* @param parentAgentDid {string | null} the DID `mmt:grantedTo` of the mandate named by
|
|
68
|
+
* `ev.delegatedFromPolicyId`, resolved by the engine before calling this — undefined/null
|
|
69
|
+
* when `ev` is a root mandate (no delegatedFromPolicyId).
|
|
70
|
+
*/
|
|
71
|
+
export function projectMandateGranted(ev, parentAgentDid) {
|
|
72
|
+
const mandate = mandateIri(ev.policyId);
|
|
73
|
+
const agent = agentIri(ev.agentDid);
|
|
74
|
+
const quads = [quad(mandate, RDF_TYPE, mmt('Mandate')), quad(mandate, mmt('grantedTo'), agent)];
|
|
75
|
+
if (ev.expiresAt) quads.push(quad(mandate, mmt('expiresAt'), literal(ev.expiresAt, XSD_DATETIME)));
|
|
76
|
+
|
|
77
|
+
if (ev.delegatedFromPolicyId) {
|
|
78
|
+
if (!parentAgentDid) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`mandate ${ev.policyId} delegates from ${ev.delegatedFromPolicyId}, but that mandate's ` +
|
|
81
|
+
`grantee could not be resolved — apply the parent mandate-granted event first`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
quads.push(quad(agent, mmt('derivesAuthorityFrom'), agentIri(parentAgentDid)));
|
|
85
|
+
quads.push(quad(agent, mmt('delegationDepth'), literal(String(ev.delegationDepth), XSD_INTEGER)));
|
|
86
|
+
} else if (ev.principalDid) {
|
|
87
|
+
quads.push(quad(agent, mmt('actsFor'), principalIri(ev.principalDid)));
|
|
88
|
+
// Domain C already names `mmt:grantsMandate` (Principal -> Mandate, the inverse of
|
|
89
|
+
// `grantedTo`) and never implements it. `actsFor` above lives on the AGENT node, so it
|
|
90
|
+
// can't distinguish which of an agent's several mandates was THIS principal's root
|
|
91
|
+
// grant — blast-radius.ts's `authorizedByPrincipal` is a per-MANDATE fact
|
|
92
|
+
// (`Policy.principalDid === principal.did`), not a per-agent one, so it needs an edge
|
|
93
|
+
// on the mandate itself. See mmt-graph.blast-radius.mjs.
|
|
94
|
+
quads.push(quad(principalIri(ev.principalDid), mmt('grantsMandate'), mandate));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return quads;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A revocation names what to remove rather than what to add — the engine deletes every quad with this subject. */
|
|
101
|
+
export function projectMandateRevoked(ev) {
|
|
102
|
+
return { removeSubject: mandateIri(ev.policyId) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Domain H (runtime activity/evidence) — Phase 5's first slice ------------------
|
|
106
|
+
|
|
107
|
+
export const decisionIri = (decisionId) => namedNode(`${MMT}decision/${decisionId}`);
|
|
108
|
+
export const evidenceIri = (eventId) => namedNode(`${MMT}evidence/${eventId}`);
|
|
109
|
+
export const batchIri = (batchId) => namedNode(`${MMT}batch/${batchId}`);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `mmt:decidedFor` (Decision → Agent), added in Phase 5's fifth slice: Domain H names no
|
|
113
|
+
* predicate for "which agent this decision was about" — evidence-path.ts's slice never
|
|
114
|
+
* needed one. Adopted as the mmt: name for the edge kind accountability.ts's own graph
|
|
115
|
+
* builder already calls `decided-for` (see mmt-graph.accountability.mjs).
|
|
116
|
+
*/
|
|
117
|
+
export function projectDecisionRecorded(ev) {
|
|
118
|
+
const decision = decisionIri(ev.decisionId);
|
|
119
|
+
const quads = [quad(decision, RDF_TYPE, mmt('AuthorizationDecision'))];
|
|
120
|
+
if (ev.decisionDigest) quads.push(quad(decision, mmt('decisionDigest'), literal(ev.decisionDigest)));
|
|
121
|
+
if (ev.agentDid) quads.push(quad(decision, mmt('decidedFor'), agentIri(ev.agentDid)));
|
|
122
|
+
return quads;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param ev {import('./mmt-graph.types.mjs').EvidenceRecorded}
|
|
127
|
+
* @param matchingDecisionIriValue {string | null} the IRI (as a string) of the
|
|
128
|
+
* mmt:AuthorizationDecision whose mmt:decisionDigest equals ev.decisionDigest, resolved
|
|
129
|
+
* by the engine before calling this — mirrors projectMandateGranted's parentAgentDid
|
|
130
|
+
* parameter. null when no decision carries this digest (or none was given) — matching
|
|
131
|
+
* evidence-path.ts's own "no fallback join, deliberately so" (see that file's header):
|
|
132
|
+
* an unmatched digest means no mmt:generatedEvidence edge, not a guessed one.
|
|
133
|
+
*/
|
|
134
|
+
export function projectEvidenceRecorded(ev, matchingDecisionIriValue) {
|
|
135
|
+
const evidence = evidenceIri(ev.eventId);
|
|
136
|
+
const quads = [quad(evidence, RDF_TYPE, mmt('Evidence'))];
|
|
137
|
+
if (ev.anchorStatus) quads.push(quad(evidence, mmt('anchorStatus'), literal(ev.anchorStatus.toUpperCase())));
|
|
138
|
+
if (matchingDecisionIriValue) {
|
|
139
|
+
quads.push(quad(namedNode(matchingDecisionIriValue), mmt('generatedEvidence'), evidence));
|
|
140
|
+
}
|
|
141
|
+
return quads;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* No engine-side resolution needed, unlike delegation: mmt:hasMember only needs the
|
|
146
|
+
* member IDs, not any of their resolved properties, so referencing an mmt:Evidence node
|
|
147
|
+
* that hasn't been recorded yet is harmless — the triple is valid RDF regardless of
|
|
148
|
+
* apply order, unlike derivesAuthorityFrom needing a real grantedTo value to point at.
|
|
149
|
+
*/
|
|
150
|
+
export function projectBatchAnchored(ev) {
|
|
151
|
+
const batch = batchIri(ev.batchId);
|
|
152
|
+
const quads = [quad(batch, RDF_TYPE, mmt('EvidenceBatch'))];
|
|
153
|
+
if (ev.anchorStatus) quads.push(quad(batch, mmt('anchorStatus'), literal(ev.anchorStatus.toUpperCase())));
|
|
154
|
+
for (const eventId of ev.memberEventIds) quads.push(quad(batch, mmt('hasMember'), evidenceIri(eventId)));
|
|
155
|
+
return quads;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- Domain F/A (governance / actors) — Phase 5's second and fourth slices ---------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `organization`/`unit` map onto mmt:Organization/mmt:AgentGroup — both already named in
|
|
162
|
+
* Domain A ("mmt:Actor ⊐ mmt:Human, mmt:Organization, mmt:AIAgent, mmt:Service,
|
|
163
|
+
* mmt:AgentGroup") and never implemented until now. `sop`/`standard` map onto Domain F's
|
|
164
|
+
* mmt:GovernanceInstrument subtypes, already implemented in name only (see domain F's
|
|
165
|
+
* "Today" note).
|
|
166
|
+
*/
|
|
167
|
+
const COHORT_TYPE = { sop: 'SOP', standard: 'Standard', organization: 'Organization', unit: 'AgentGroup' };
|
|
168
|
+
|
|
169
|
+
/** Whether membership in this cohort is also a real mmt:memberOf fact, not just cohort-computation plumbing — see projectCohortShared's header. */
|
|
170
|
+
const MEMBERSHIP_KINDS = new Set(['organization', 'unit']);
|
|
171
|
+
|
|
172
|
+
/** One node per (kind, key) pair, not per agent — every agent sharing a SOP/Standard points at the SAME cohort node, which is what makes COUNT(DISTINCT ?agent) over it meaningful. */
|
|
173
|
+
export const cohortIri = (kind, key) => namedNode(`${MMT}cohort/${kind}/${encodeURIComponent(key)}`);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Two predicates for organization/unit, one for sop/standard, deliberately not uniform:
|
|
177
|
+
* `mmt:sharesCohort` is mechanical plumbing this package invented so the contagion
|
|
178
|
+
* aggregate query can treat all four cohort kinds the same way. `mmt:memberOf` is the
|
|
179
|
+
* real Domain A relationship the doc already specs for group membership — sharing a SOP
|
|
180
|
+
* and belonging to an organization are different KINDS of fact, and reusing one edge for
|
|
181
|
+
* both would blur a distinction the ontology already drew. Emitting both keeps the
|
|
182
|
+
* mechanical query uniform without pretending organization membership is the same kind
|
|
183
|
+
* of thing as sharing a governance instrument.
|
|
184
|
+
*/
|
|
185
|
+
export function projectCohortShared(ev) {
|
|
186
|
+
const agent = agentIri(ev.agentDid);
|
|
187
|
+
const cohort = cohortIri(ev.kind, ev.key);
|
|
188
|
+
const quads = [
|
|
189
|
+
quad(agent, mmt('sharesCohort'), cohort),
|
|
190
|
+
quad(cohort, RDF_TYPE, mmt(COHORT_TYPE[ev.kind])),
|
|
191
|
+
quad(cohort, mmt('cohortKey'), literal(ev.key)),
|
|
192
|
+
quad(cohort, mmt('cohortLabel'), literal(ev.label)),
|
|
193
|
+
];
|
|
194
|
+
if (MEMBERSHIP_KINDS.has(ev.kind)) quads.push(quad(agent, mmt('memberOf'), cohort));
|
|
195
|
+
if (ev.version !== undefined) quads.push(quad(cohort, mmt('cohortVersion'), literal(String(ev.version), XSD_INTEGER)));
|
|
196
|
+
return quads;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- Domain A (actors), continued — Phase 5's fifth slice ------------------------
|
|
200
|
+
|
|
201
|
+
/** Humans carry no DID (the User table has none) — a synthetic IRI, same pattern as mandateIri/decisionIri for their own non-DID identifiers. */
|
|
202
|
+
export const humanIri = (userId) => namedNode(`${MMT}human/${encodeURIComponent(userId)}`);
|
|
203
|
+
|
|
204
|
+
/** Verbatim shape of accountability.ts's own personName() — full name when either part is given, else the email, never a blank label. */
|
|
205
|
+
function personName({ firstName, lastName, email }) {
|
|
206
|
+
const full = [firstName, lastName].filter(Boolean).join(' ').trim();
|
|
207
|
+
return full || email;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function projectHumanRegistered(ev) {
|
|
211
|
+
const human = humanIri(ev.userId);
|
|
212
|
+
const quads = [quad(human, RDF_TYPE, mmt('Human')), quad(human, mmt('email'), literal(ev.email))];
|
|
213
|
+
quads.push(quad(human, mmt('name'), literal(personName(ev))));
|
|
214
|
+
return quads;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Domain A/B (actors/identity) — Phase 5's sixth slice -------------------------
|
|
218
|
+
|
|
219
|
+
/** Issuers have real DIDs (issuer.model.ts) — the node is its own DID, same reasoning as agentIri/principalIri, not a minted one. */
|
|
220
|
+
export const issuerIri = (did) => namedNode(did);
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `mmt:Issuer` is the one genuinely new class this package adds — see IssuerRegistered's
|
|
224
|
+
* header in mmt-graph.types.mjs. `mmt:backedBy` (Issuer -> Organization) is a new
|
|
225
|
+
* predicate too: adopted from blast-radius.ts's own edge-kind name (`backed-by`), the same
|
|
226
|
+
* precedent as `mmt:decidedFor` — Domain A's relationship list names nothing for "the
|
|
227
|
+
* entity a credential issuer answers to" specifically enough to reuse without blurring it
|
|
228
|
+
* into `operatedBy`'s different (day-to-day-operator) sense.
|
|
229
|
+
*/
|
|
230
|
+
export function projectIssuerRegistered(ev) {
|
|
231
|
+
const issuer = issuerIri(ev.did);
|
|
232
|
+
const quads = [quad(issuer, RDF_TYPE, mmt('Issuer'))];
|
|
233
|
+
if (ev.name) quads.push(quad(issuer, mmt('name'), literal(ev.name)));
|
|
234
|
+
if (ev.principalDid) quads.push(quad(issuer, mmt('backedBy'), principalIri(ev.principalDid)));
|
|
235
|
+
return quads;
|
|
236
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 4: the self-hosting quickstart. Runs the WHOLE lifecycle a tenant running their own
|
|
3
|
+
* copy of this package would go through — register, validate, query, batch, anchor, verify
|
|
4
|
+
* — entirely offline except the one line marked NETWORK CALL, which you replace with your
|
|
5
|
+
* own Hedera client. No MetaMynd backend, no Postgres, no MetaMynd credentials anywhere in
|
|
6
|
+
* this file or anything it imports: that's the whole point of Phase 1-3's design (see
|
|
7
|
+
* mmt-graph.engine.mjs/anchor.mjs's own headers) — this script is the proof that it's true.
|
|
8
|
+
*
|
|
9
|
+
* Run it:
|
|
10
|
+
* npm install @metamynd/mmt-graph
|
|
11
|
+
* node node_modules/@metamynd/mmt-graph/mmt-graph.self-host-quickstart.mjs
|
|
12
|
+
* or, working from a clone of this package directly:
|
|
13
|
+
* npm run self-host-quickstart
|
|
14
|
+
*
|
|
15
|
+
* For a full worked example that actually submits to Hedera testnet (real @hashgraph/sdk
|
|
16
|
+
* client, your own operator account), see mmt-graph.anchor-live-demo.mjs — this file's
|
|
17
|
+
* `submit` below is a stub so the quickstart runs with zero setup and zero network access.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { MmtGraphEngine } from './mmt-graph.engine.mjs';
|
|
21
|
+
import { agentRegistered, principalRegistered, mandateGranted } from './mmt-graph.types.mjs';
|
|
22
|
+
import { buildChangelogBatch } from './mmt-graph.changelog.mjs';
|
|
23
|
+
import { anchorBatch } from './mmt-graph.anchor.mjs';
|
|
24
|
+
import { verifyReplayedLog } from './mmt-graph.auditor.mjs';
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
// 1. Register what you already have: your own agents, principals, mandates. In a real
|
|
28
|
+
// deployment these calls sit behind whatever event source you already have (a
|
|
29
|
+
// Postgres trigger, a migration script, an application hook) — see this package's
|
|
30
|
+
// README for how MetaMynd's own backend wires this (mmt-graph.adapter.ts), which you
|
|
31
|
+
// are free to copy or ignore entirely.
|
|
32
|
+
const engine = new MmtGraphEngine();
|
|
33
|
+
engine.apply(principalRegistered({ principalDid: 'did:hedera:testnet:your-org', principalType: 'Organization' }));
|
|
34
|
+
engine.apply(agentRegistered({ agentDid: 'did:hedera:testnet:your-agent-1', principalDid: 'did:hedera:testnet:your-org' }));
|
|
35
|
+
engine.apply(mandateGranted({ policyId: 'mandate-1', agentDid: 'did:hedera:testnet:your-agent-1', principalDid: 'did:hedera:testnet:your-org' }));
|
|
36
|
+
console.log(`1. Registered ${engine.log.length} event(s).`);
|
|
37
|
+
|
|
38
|
+
// 2. Validate: SHACL shapes + the §3 authority-chain invariant, over YOUR data, with no
|
|
39
|
+
// call out to MetaMynd — this is the same check mmt-graph.auditor.mjs's replay mode
|
|
40
|
+
// runs later, offered here for your own use during normal operation.
|
|
41
|
+
const report = await engine.validate();
|
|
42
|
+
console.log(`2. Validated: conforms=${report.conforms}, unreachable agents=${report.unreachableAgents.length}`);
|
|
43
|
+
|
|
44
|
+
// 3. Query: this is a real, general-purpose RDF graph, not a fixed set of report
|
|
45
|
+
// endpoints — ask it anything expressible in SPARQL 1.1.
|
|
46
|
+
const rows = [...engine.query('PREFIX mmt: <https://schema.metamynd.ai/trust/v1#> SELECT ?agent WHERE { ?agent a mmt:AIAgent }')];
|
|
47
|
+
console.log(`3. Queried: ${rows.length} agent(s) known to this graph.`);
|
|
48
|
+
|
|
49
|
+
// 4. Batch: hash the event log into a Merkle root ready to anchor. Only the root needs
|
|
50
|
+
// to leave your infrastructure.
|
|
51
|
+
const batch = buildChangelogBatch(engine.log);
|
|
52
|
+
console.log(`4. Batched: root=${batch.root.slice(0, 16)}… (${batch.size} event(s))`);
|
|
53
|
+
|
|
54
|
+
// 5. Anchor: `submit` is the ENTIRE seam between this package and Hedera. Replace this
|
|
55
|
+
// stub with your own @hashgraph/sdk client — see mmt-graph.anchor-live-demo.mjs for a
|
|
56
|
+
// complete, real example (topic creation, transaction signing, receipt handling)
|
|
57
|
+
// against your own testnet or mainnet account. Nothing else in this package needs to
|
|
58
|
+
// change: anchorBatch() only ever calls the function you give it.
|
|
59
|
+
async function submit(message) {
|
|
60
|
+
// <-- NETWORK CALL: replace this stub with a real Hedera TopicMessageSubmitTransaction.
|
|
61
|
+
console.log(` (stub submit — in a real deployment this line submits "${message.slice(0, 40)}…" to YOUR HCS topic)`);
|
|
62
|
+
return { transactionId: 'stub-tx-id', status: 'STUB_NOT_SUBMITTED' };
|
|
63
|
+
}
|
|
64
|
+
const receipt = await anchorBatch(batch, submit);
|
|
65
|
+
console.log(`5. Anchored (stub): status=${receipt.status}`);
|
|
66
|
+
|
|
67
|
+
// 6. Verify: prove to anyone — a regulator, an auditor, yourself six months from now —
|
|
68
|
+
// that this exact log is what was anchored, and that the graph it produces is sound,
|
|
69
|
+
// without needing this script, this engine instance, or MetaMynd to still exist.
|
|
70
|
+
const verification = await verifyReplayedLog(engine.log, batch.root);
|
|
71
|
+
console.log(`6. Verified independently: valid=${verification.valid}`);
|
|
72
|
+
for (const c of verification.checks) console.log(` ${c.ok ? '✓' : '✗'} ${c.check}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main().catch((err) => {
|
|
76
|
+
console.error(err);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# SHACL shapes for the MMTO reference engine. Structural, per-node invariants only — the
|
|
2
|
+
# graph-wide reachability property (does an agent's chain actually terminate at a
|
|
3
|
+
# Principal) is not expressible as a shape over a `+` path with SHACL alone, so it stays a
|
|
4
|
+
# SPARQL ASK in mmt-graph.engine.mjs. That split is what the storage-engine spike proved
|
|
5
|
+
# out (PR #382), not a new design.
|
|
6
|
+
#
|
|
7
|
+
# See docs/design/metamynd-trust-ontology.md Appendix A for the ODRL operator vocabulary
|
|
8
|
+
# and §3 for the authority-chain invariant these shapes are a structural piece of.
|
|
9
|
+
|
|
10
|
+
@prefix mmt: <https://schema.metamynd.ai/trust/v1#> .
|
|
11
|
+
@prefix sh: <http://www.w3.org/ns/shacl#> .
|
|
12
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
13
|
+
|
|
14
|
+
# Every mandate was granted to exactly one agent. Domain C, and the closest thing to a
|
|
15
|
+
# universal invariant a Mandate row has: mandate.types.ts's Mandate type has no notion of
|
|
16
|
+
# "granted to nobody" or "granted to more than one agent" — a Policy row's agentIdentityId
|
|
17
|
+
# is a single column, not a list.
|
|
18
|
+
mmt:MandateShape a sh:NodeShape ;
|
|
19
|
+
sh:targetClass mmt:Mandate ;
|
|
20
|
+
sh:property [
|
|
21
|
+
sh:path mmt:grantedTo ;
|
|
22
|
+
sh:minCount 1 ;
|
|
23
|
+
sh:maxCount 1 ;
|
|
24
|
+
] .
|
|
25
|
+
|
|
26
|
+
# Mirrors policy-core/delegation.ts's MAX_DELEGATION_DEPTH = 3: depth exactly 3 is valid,
|
|
27
|
+
# depth 4 is refused. Every agent carrying a recorded delegation depth must be within
|
|
28
|
+
# bounds — this is a structural check on the literal the projector attaches, not a walk of
|
|
29
|
+
# the derivesAuthorityFrom chain itself (SHACL can't count `+` path length).
|
|
30
|
+
mmt:DelegationDepthShape a sh:NodeShape ;
|
|
31
|
+
sh:targetSubjectsOf mmt:delegationDepth ;
|
|
32
|
+
sh:property [
|
|
33
|
+
sh:path mmt:delegationDepth ;
|
|
34
|
+
sh:datatype xsd:integer ;
|
|
35
|
+
sh:minCount 1 ;
|
|
36
|
+
sh:maxCount 1 ;
|
|
37
|
+
sh:minInclusive 1 ;
|
|
38
|
+
sh:maxInclusive 3 ;
|
|
39
|
+
] .
|