diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 54485c9fd..9d8426874 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1967,12 +1967,16 @@ export class DKGAgent { * Registers it in the CG's `_meta` graph and creates the named graph in storage. * Sub-graphs use convention-based URI partitioning — no on-chain enforcement in V10.0. * - * V10.0 limitations: - * - Registration triples are stored locally only. Peers discover sub-graphs when - * they receive data via GossipSub or by querying the admin's node. - * - GossipSub broadcasts raw triples without sub-graph context; receivers store - * replicated data in the root data graph. The CG admin manages sub-graph - * organization on their own node. + * V10.0 replication behavior: + * - Registration triples are stored locally by the admin. Peers also auto-register + * sub-graphs on gossip publish, SWM write, and finalization replay paths: + * `gossip-publish-handler.ts`, `workspace-handler.ts`, and + * `finalization-handler.ts` call `ensureSubGraph()` and backfill the full + * `_meta` registration when it is missing. + * - Because `subGraphName` is carried on the wire (in the workspace publish request + * and the N-Quads' named-graph field), replicated data is routed into the correct + * sub-graph named graph on receiving nodes — not into the root data graph. + * - On-chain contracts are unaware of sub-graphs; enforcement remains convention-based. */ async createSubGraph(contextGraphId: string, subGraphName: string, opts?: { description?: string; @@ -2035,10 +2039,10 @@ export class DKGAgent { if (result.type !== 'bindings') return []; return result.bindings.map(row => ({ uri: row['subGraph'] ?? '', - name: (row['name'] ?? '').replace(/^"|"$/g, ''), + name: stripLiteral(row['name'] ?? ''), createdBy: row['createdBy'] ?? '', - createdAt: row['createdAt']?.replace(/^"|".*$/g, '') || undefined, - description: row['description']?.replace(/^"|"$/g, '') || undefined, + createdAt: row['createdAt'] ? stripLiteral(row['createdAt']) : undefined, + description: row['description'] ? stripLiteral(row['description']) : undefined, })); } diff --git a/packages/agent/src/finalization-handler.ts b/packages/agent/src/finalization-handler.ts index ef686d6aa..334afbac0 100644 --- a/packages/agent/src/finalization-handler.ts +++ b/packages/agent/src/finalization-handler.ts @@ -12,6 +12,7 @@ import { type ChainAdapter, type EventFilter } from '@origintrail-official/dkg-c import { computeFlatKCRootV10 as computeFlatKCRoot, autoPartition, generateConfirmedFullMetadata, getTentativeStatusQuad, + generateSubGraphRegistration, type KCMetadata, type KAMetadata, type OnChainProvenance, } from '@origintrail-official/dkg-publisher'; const DKG_NS = 'http://dkg.io/ontology/'; @@ -326,6 +327,24 @@ export class FinalizationHandler { await graphManager.ensureParanet(contextGraphId); if (subGraphName) { await graphManager.ensureSubGraph(contextGraphId, subGraphName); + const sgUri = contextGraphSubGraphUri(contextGraphId, subGraphName); + const metaGraph = `did:dkg:context-graph:${assertSafeIri(contextGraphId)}/_meta`; + const alreadyRegistered = await this.store.query( + `ASK { GRAPH <${metaGraph}> { + <${assertSafeIri(sgUri)}> a ; + ${JSON.stringify(subGraphName)} ; + ?createdBy . + } }`, + ); + if (alreadyRegistered.type !== 'boolean' || !alreadyRegistered.value) { + await this.store.insert(generateSubGraphRegistration({ + contextGraphId, + subGraphName, + createdBy: publisherAddress || 'finalization-discovery', + timestamp: new Date(), + })); + this.log.info(ctx, `Finalization: auto-registered sub-graph "${subGraphName}" in context graph "${contextGraphId}"`); + } } const dataGraph = subGraphName ? contextGraphSubGraphUri(contextGraphId, subGraphName) diff --git a/packages/agent/src/gossip-publish-handler.ts b/packages/agent/src/gossip-publish-handler.ts index 1899a506a..c8ba1bf0c 100644 --- a/packages/agent/src/gossip-publish-handler.ts +++ b/packages/agent/src/gossip-publish-handler.ts @@ -195,7 +195,11 @@ export class GossipPublishHandler { const sgUri = contextGraphSubGraphUri(request.paranetId, subGraphName); const metaGraph = `did:dkg:context-graph:${assertSafeIri(request.paranetId)}/_meta`; const alreadyRegistered = await this.store.query( - `ASK { GRAPH <${metaGraph}> { <${assertSafeIri(sgUri)}> a } }`, + `ASK { GRAPH <${metaGraph}> { + <${assertSafeIri(sgUri)}> a ; + ${JSON.stringify(subGraphName)} ; + ?createdBy . + } }`, ); if (alreadyRegistered.type !== 'boolean' || !alreadyRegistered.value) { const regQuads = generateSubGraphRegistration({ diff --git a/packages/agent/test/finalization-handler.test.ts b/packages/agent/test/finalization-handler.test.ts index dc7d24f02..9102f8dc8 100644 --- a/packages/agent/test/finalization-handler.test.ts +++ b/packages/agent/test/finalization-handler.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OxigraphStore } from '@origintrail-official/dkg-storage'; -import { encodeFinalizationMessage, type FinalizationMessageMsg, encodePublishRequest } from '@origintrail-official/dkg-core'; +import { encodeFinalizationMessage, type FinalizationMessageMsg, encodePublishRequest, createOperationContext } from '@origintrail-official/dkg-core'; import { FinalizationHandler } from '../src/finalization-handler.js'; const PARANET = 'test-paranet'; @@ -142,4 +142,44 @@ describe('FinalizationHandler', () => { expect(result.type).toBe('boolean'); if (result.type === 'boolean') expect(result.value).toBe(false); }); + + it('backfills full sub-graph registration metadata during finalization promotion', async () => { + const entity = 'urn:test:entity'; + const subGraphName = 'code'; + const publisherAddress = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + const metaGraph = `did:dkg:context-graph:${PARANET}/_meta`; + const subGraphUri = `did:dkg:context-graph:${PARANET}/${subGraphName}`; + + await (handler as any).promoteSharedMemoryToCanonical( + PARANET, + [{ subject: entity, predicate: 'http://schema.org/name', object: '"Alice"', graph: '' }], + 'did:dkg:evm:31337/0xABC/1', + [entity], + publisherAddress, + '0x' + 'ab'.repeat(32), + 100, + 1n, + 1n, + 1n, + createOperationContext('system'), + undefined, + subGraphName, + ); + + const registration = await store.query( + `ASK { GRAPH <${metaGraph}> { + <${subGraphUri}> a ; + "code" ; + . + } }`, + ); + expect(registration.type).toBe('boolean'); + if (registration.type === 'boolean') expect(registration.value).toBe(true); + + const canonical = await store.query( + `ASK { GRAPH <${subGraphUri}> { <${entity}> ?o } }`, + ); + expect(canonical.type).toBe('boolean'); + if (canonical.type === 'boolean') expect(canonical.value).toBe(true); + }); }); diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 0db2b3f35..bd1fc726d 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -419,6 +419,11 @@ export class DKGPublisher implements Publisher { /** * Read quads from the context graph's shared memory and publish them with full finality (data graph + chain). * Selection: 'all' or { rootEntities: string[] } to publish only those root entities from shared memory. + * + * @throws Error if `options.subGraphName` is combined with `options.publishContextGraphId`. + * The remap-on-publish flow targets `/context/{id}` URIs, which are incompatible with + * sub-graph URIs of shape `/{contextGraphId}/{subGraphName}`. To publish from a sub-graph, + * omit `publishContextGraphId` (publish remains in the source CG's sub-graph). */ async publishFromSharedMemory( contextGraphId: string, @@ -712,10 +717,7 @@ export class DKGPublisher implements Publisher { if (!sgValidation.valid) throw new Error(`Invalid sub-graph name: ${sgValidation.reason}`); const sgUri = contextGraphSubGraphUri(options.contextGraphId, options.subGraphName); - const registered = await this.store.query( - `ASK { GRAPH { <${assertSafeIri(sgUri)}> ?p ?o } }`, - ); - if (registered.type === 'boolean' && !registered.value) { + if (!(await this.isSubGraphRegistered(options.contextGraphId, options.subGraphName))) { throw new Error( `Sub-graph "${options.subGraphName}" has not been registered in context graph "${options.contextGraphId}". ` + `Call createSubGraph() first.`, @@ -1469,6 +1471,37 @@ export class DKGPublisher implements Publisher { } } + private async isSubGraphRegistered(contextGraphId: string, subGraphName: string): Promise { + const sgUri = contextGraphSubGraphUri(contextGraphId, subGraphName); + const registered = await this.store.query( + `ASK { GRAPH { + <${assertSafeIri(sgUri)}> a ; + ${JSON.stringify(subGraphName)} ; + ?createdBy . + } }`, + ); + return registered.type === 'boolean' && registered.value; + } + + /** + * Throws if `subGraphName` is provided but not registered in the CG's `_meta` graph. + * Mirrors the registration check in `publish()` for mutation paths that would + * otherwise create new orphaned sub-graph state. + */ + private async ensureSubGraphRegistered( + contextGraphId: string, + subGraphName: string | undefined, + ): Promise { + if (subGraphName === undefined) return; + DKGPublisher.validateOptionalSubGraph(subGraphName); + if (!(await this.isSubGraphRegistered(contextGraphId, subGraphName))) { + throw new Error( + `Sub-graph "${subGraphName}" has not been registered in context graph "${contextGraphId}". ` + + `Register it first via DKGAgent.createSubGraph() or by inserting the sub-graph registration into the context graph "_meta" graph.`, + ); + } + } + clearSubGraphOwnership(ownershipKey: string): void { this.sharedMemoryOwnedEntities.delete(ownershipKey); this.ownedEntities.delete(ownershipKey); @@ -1476,7 +1509,7 @@ export class DKGPublisher implements Publisher { } async assertionCreate(contextGraphId: string, name: string, agentAddress: string, subGraphName?: string): Promise { - DKGPublisher.validateOptionalSubGraph(subGraphName); + await this.ensureSubGraphRegistered(contextGraphId, subGraphName); const graphUri = contextGraphAssertionUri(contextGraphId, agentAddress, name, subGraphName); await this.store.createGraph(graphUri); return graphUri; @@ -1489,7 +1522,7 @@ export class DKGPublisher implements Publisher { input: Quad[] | Array<{ subject: string; predicate: string; object: string }>, subGraphName?: string, ): Promise { - DKGPublisher.validateOptionalSubGraph(subGraphName); + await this.ensureSubGraphRegistered(contextGraphId, subGraphName); const graphUri = contextGraphAssertionUri(contextGraphId, agentAddress, name, subGraphName); const quads = input.map((t) => ({ subject: t.subject, predicate: t.predicate, object: t.object, graph: graphUri, @@ -1517,7 +1550,7 @@ export class DKGPublisher implements Publisher { agentAddress: string, opts?: { entities?: string[] | 'all'; subGraphName?: string; publisherPeerId?: string }, ): Promise<{ promotedCount: number; gossipMessage?: Uint8Array }> { - DKGPublisher.validateOptionalSubGraph(opts?.subGraphName); + await this.ensureSubGraphRegistered(contextGraphId, opts?.subGraphName); const graphUri = contextGraphAssertionUri(contextGraphId, agentAddress, name, opts?.subGraphName); const swmGraphUri = this.graphManager.sharedMemoryUri(contextGraphId, opts?.subGraphName); diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index f21084ca5..e030a1233 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -150,7 +150,11 @@ export class SharedMemoryHandler { const sgUri = contextGraphSubGraphUri(contextGraphId, subGraphName); const metaGraph = `did:dkg:context-graph:${assertSafeIri(contextGraphId)}/_meta`; const alreadyRegistered = await this.store.query( - `ASK { GRAPH <${metaGraph}> { <${assertSafeIri(sgUri)}> a } }`, + `ASK { GRAPH <${metaGraph}> { + <${assertSafeIri(sgUri)}> a ; + ${JSON.stringify(subGraphName)} ; + ?createdBy . + } }`, ); if (alreadyRegistered.type !== 'boolean' || !alreadyRegistered.value) { const regQuads = generateSubGraphRegistration({ diff --git a/packages/publisher/test/draft-lifecycle.test.ts b/packages/publisher/test/draft-lifecycle.test.ts index 514801d79..537a12770 100644 --- a/packages/publisher/test/draft-lifecycle.test.ts +++ b/packages/publisher/test/draft-lifecycle.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; import { MockChainAdapter } from '@origintrail-official/dkg-chain'; -import { TypedEventBus, generateEd25519Keypair, contextGraphAssertionUri } from '@origintrail-official/dkg-core'; +import { + TypedEventBus, + generateEd25519Keypair, + contextGraphAssertionUri, + contextGraphSharedMemoryUri, +} from '@origintrail-official/dkg-core'; import { DKGPublisher } from '../src/index.js'; import { ethers } from 'ethers'; @@ -193,3 +198,148 @@ describe('Working Memory Assertion Lifecycle', () => { await publisher.assertionDiscard(CG_ID, ASSERTION_NAME, AGENT); }); }); + +describe('Working Memory Assertion sub-graph registration check', () => { + const SG_CG_ID = 'sg-check-cg'; + const SG_NAME = 'code'; + let store: OxigraphStore; + let publisher: DKGPublisher; + + beforeEach(async () => { + store = new OxigraphStore(); + const wallet = ethers.Wallet.createRandom(); + const chain = new MockChainAdapter('mock:31337', wallet.address); + const keypair = await generateEd25519Keypair(); + publisher = new DKGPublisher({ + store, + chain, + eventBus: new TypedEventBus(), + keypair, + publisherPrivateKey: wallet.privateKey, + publisherNodeIdentityId: 1n, + }); + }); + + async function registerSubGraph(): Promise { + const metaGraph = `did:dkg:context-graph:${SG_CG_ID}/_meta`; + const sgUri = `did:dkg:context-graph:${SG_CG_ID}/${SG_NAME}`; + await store.createGraph(metaGraph); + await store.insert([ + { + subject: sgUri, + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'http://dkg.io/ontology/SubGraph', + graph: metaGraph, + }, + { + subject: sgUri, + predicate: 'http://schema.org/name', + object: `"${SG_NAME}"`, + graph: metaGraph, + }, + { + subject: sgUri, + predicate: 'http://dkg.io/ontology/createdBy', + object: 'did:dkg:agent:test-agent', + graph: metaGraph, + }, + ]); + } + + it('assertionCreate throws when sub-graph is not registered', async () => { + await expect( + publisher.assertionCreate(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME), + ).rejects.toThrow(/Sub-graph "code" has not been registered/); + }); + + it('assertionWrite throws when sub-graph is not registered', async () => { + await expect( + publisher.assertionWrite(SG_CG_ID, ASSERTION_NAME, AGENT, TRIPLES, SG_NAME), + ).rejects.toThrow(/Sub-graph "code" has not been registered/); + }); + + it('assertionPromote throws when sub-graph is not registered', async () => { + await expect( + publisher.assertionPromote(SG_CG_ID, ASSERTION_NAME, AGENT, { subGraphName: SG_NAME }), + ).rejects.toThrow(/Sub-graph "code" has not been registered/); + }); + + it('assertion mutation guard requires full registration metadata, not just the SubGraph type marker', async () => { + const metaGraph = `did:dkg:context-graph:${SG_CG_ID}/_meta`; + const sgUri = `did:dkg:context-graph:${SG_CG_ID}/${SG_NAME}`; + await store.createGraph(metaGraph); + await store.insert([ + { + subject: sgUri, + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'http://dkg.io/ontology/SubGraph', + graph: metaGraph, + }, + ]); + + await expect( + publisher.assertionCreate(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME), + ).rejects.toThrow(/Sub-graph "code" has not been registered/); + }); + + it('assertionQuery and assertionDiscard still work for legacy unregistered sub-graph graphs', async () => { + const graphUri = contextGraphAssertionUri(SG_CG_ID, AGENT, ASSERTION_NAME, SG_NAME); + await store.createGraph(graphUri); + await store.insert(TRIPLES.map((triple) => ({ ...triple, graph: graphUri }))); + + const quads = await publisher.assertionQuery(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + expect(quads.length).toBe(3); + + await publisher.assertionDiscard(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + const afterDiscard = await publisher.assertionQuery(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + expect(afterDiscard.length).toBe(0); + }); + + it('assertion ops succeed after the sub-graph is registered', async () => { + await registerSubGraph(); + + const uri = await publisher.assertionCreate(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + expect(uri).toContain(`/${SG_NAME}/`); + + await publisher.assertionWrite(SG_CG_ID, ASSERTION_NAME, AGENT, TRIPLES, SG_NAME); + const quads = await publisher.assertionQuery(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + expect(quads.length).toBe(3); + + await publisher.assertionDiscard(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + const afterDiscard = await publisher.assertionQuery(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + expect(afterDiscard.length).toBe(0); + }); + + it('assertionPromote routes promoted triples into the registered sub-graph shared memory', async () => { + const swmGraph = contextGraphSharedMemoryUri(SG_CG_ID, SG_NAME); + + await registerSubGraph(); + await publisher.assertionCreate(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + await publisher.assertionWrite(SG_CG_ID, ASSERTION_NAME, AGENT, TRIPLES, SG_NAME); + + const result = await publisher.assertionPromote(SG_CG_ID, ASSERTION_NAME, AGENT, { subGraphName: SG_NAME }); + expect(result.promotedCount).toBe(3); + + const assertionQuads = await publisher.assertionQuery(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME); + expect(assertionQuads.length).toBe(0); + + const swmResult = await store.query( + `SELECT ?s ?p ?o WHERE { GRAPH <${swmGraph}> { ?s ?p ?o } }`, + ); + expect(swmResult.type).toBe('bindings'); + if (swmResult.type === 'bindings') { + expect(swmResult.bindings.length).toBe(3); + } + }); + + it('assertion ops without a sub-graph name still work (guard is opt-in)', async () => { + const uri = await publisher.assertionCreate(SG_CG_ID, ASSERTION_NAME, AGENT); + expect(uri).toBe(contextGraphAssertionUri(SG_CG_ID, AGENT, ASSERTION_NAME)); + }); + + it('invalid sub-graph name is rejected before the registration check', async () => { + await expect( + publisher.assertionCreate(SG_CG_ID, ASSERTION_NAME, AGENT, 'Invalid Name With Spaces'), + ).rejects.toThrow(/Invalid sub-graph name/); + }); +}); diff --git a/packages/storage/src/graph-manager.ts b/packages/storage/src/graph-manager.ts index 4aaec3767..05af056c9 100644 --- a/packages/storage/src/graph-manager.ts +++ b/packages/storage/src/graph-manager.ts @@ -110,8 +110,9 @@ export class ContextGraphManager { } /** - * Lists sub-graph names for a given context graph by inspecting named graphs - * in the store. Returns names like "code", "decisions" (without the CG prefix). + * @deprecated Prefer DKGAgent.listSubGraphs(), which reads spec-compliant + * registration metadata from the context graph `_meta` graph. This shim keeps + * the legacy storage-level graph-walk behavior for downstream callers. */ async listSubGraphs(contextGraphId: string): Promise { const prefix = `${CG_PREFIX}${contextGraphId}/`; diff --git a/packages/storage/test/storage.test.ts b/packages/storage/test/storage.test.ts index 02b8c8f8c..766466d83 100644 --- a/packages/storage/test/storage.test.ts +++ b/packages/storage/test/storage.test.ts @@ -285,6 +285,19 @@ describe('GraphManager', () => { expect(cgs.sort()).toEqual(['test1', 'test2']); }); + it('keeps listSubGraphs as a deprecated compatibility shim', async () => { + await store.insert([ + { subject: 'http://ex.org/s', predicate: 'http://ex.org/p', object: '"a"', graph: 'did:dkg:context-graph:test1/code' }, + { subject: 'http://ex.org/s', predicate: 'http://ex.org/p', object: '"b"', graph: 'did:dkg:context-graph:test1/decisions/_meta' }, + { subject: 'http://ex.org/s', predicate: 'http://ex.org/p', object: '"c"', graph: 'did:dkg:context-graph:test1/_meta' }, + { subject: 'http://ex.org/s', predicate: 'http://ex.org/p', object: '"d"', graph: 'did:dkg:context-graph:test1/assertion/agent/name' }, + { subject: 'http://ex.org/s', predicate: 'http://ex.org/p', object: '"e"', graph: 'did:dkg:context-graph:test2/notes' }, + ]); + + const subGraphs = await gm.listSubGraphs('test1'); + expect(subGraphs.sort()).toEqual(['code', 'decisions']); + }); + it('drops context graph', async () => { await store.insert([ { subject: 'http://ex.org/s', predicate: 'http://ex.org/p', object: '"a"', graph: 'did:dkg:context-graph:x' },