From 4208e78630be18ffc83e049b6f99876db3583593 Mon Sep 17 00:00:00 2001 From: code-engineer Date: Fri, 10 Apr 2026 16:54:07 +0200 Subject: [PATCH 1/4] fix: sub-graph polish (issue #81 findings 1,3,4,5,7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five targeted fixes for sub-graph implementation follow-ups identified in reviewing PR #101 against 03_PROTOCOL_CORE.md §16.2. No P0 changes — all P1/P2 polish on existing working code. 1. createSubGraph JSDoc now reflects V10.0 gossip auto-registration. The previous comment claimed "receivers store replicated data in the root data graph" which has been false since gossip auto-registration landed in gossip-publish-handler / workspace-handler / finalization- handler. 2. Deleted unused ContextGraphManager.listSubGraphs. It was a heuristic graph-URI walk with divergent semantics from DKGAgent.listSubGraphs (which is the spec-compliant _meta ASK path) and had zero callers in the codebase. Removes the divergence risk and the stale "draft/" reserved prefix. 3. assertion{Create,Write,Query,Promote,Discard} now validate that the sub-graph is registered in _meta before operating. New private helper ensureSubGraphRegistered mirrors the ASK pattern already used by publish(). Previously these ops silently orphaned triples under unregistered sub-graph URIs. Guard is opt-in: assertion ops without a subGraphName are unchanged. 4. DKGAgent.listSubGraphs literal stripping now uses the shared stripLiteral() helper instead of three per-field regex strips. The old /^"|".*$/g pattern on createdAt was greedy and ate any inner quote; the new code correctly handles datatype- and language-tagged literals. 5. publishFromSharedMemory @throws JSDoc added to document the existing constraint: subGraphName and publishContextGraphId cannot be combined (the remap flow targets /context/{id} which is incompatible with sub-graph URIs). Tests: 8 new unit tests in draft-lifecycle covering the registration check — 616/616 publisher pass, 70/70 storage pass, 15/15 agent sub-graph e2e pass. Refs: OriginTrail/dkgv10-spec#81 --- packages/agent/src/dkg-agent.ts | 23 +++-- packages/publisher/src/dkg-publisher.ts | 38 +++++++- .../publisher/test/draft-lifecycle.test.ts | 92 +++++++++++++++++++ packages/storage/src/graph-manager.ts | 20 ---- 4 files changed, 139 insertions(+), 34 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index a78a0b502..80204fbbe 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1967,12 +1967,17 @@ 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 discover sub-graphs + * automatically through gossip auto-registration: when a peer receives a VM publish + * or SWM write carrying `subGraphName`, it calls `ensureSubGraph()` and inserts + * a `generateSubGraphRegistration()` record into its own `_meta` graph if one does + * not already exist. See `gossip-publish-handler.ts`, `workspace-handler.ts`, and + * `finalization-handler.ts` for the auto-registration call sites. + * - 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 +2040,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/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 17848c60a..a88ec7bc4 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, @@ -1469,6 +1474,29 @@ export class DKGPublisher implements Publisher { } } + /** + * Throws if `subGraphName` is provided but not registered in the CG's `_meta` graph. + * Mirrors the registration check in `publish()` so that assertion operations cannot + * orphan triples under an unregistered sub-graph URI. + */ + private async ensureSubGraphRegistered( + contextGraphId: string, + subGraphName: string | undefined, + ): Promise { + if (subGraphName === undefined) return; + DKGPublisher.validateOptionalSubGraph(subGraphName); + const sgUri = contextGraphSubGraphUri(contextGraphId, subGraphName); + const registered = await this.store.query( + `ASK { GRAPH { <${assertSafeIri(sgUri)}> ?p ?o } }`, + ); + if (registered.type === 'boolean' && !registered.value) { + throw new Error( + `Sub-graph "${subGraphName}" has not been registered in context graph "${contextGraphId}". ` + + `Call createSubGraph() first.`, + ); + } + } + clearSubGraphOwnership(ownershipKey: string): void { this.sharedMemoryOwnedEntities.delete(ownershipKey); this.ownedEntities.delete(ownershipKey); @@ -1476,7 +1504,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 +1517,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, @@ -1503,7 +1531,7 @@ export class DKGPublisher implements Publisher { agentAddress: string, subGraphName?: string, ): Promise { - DKGPublisher.validateOptionalSubGraph(subGraphName); + await this.ensureSubGraphRegistered(contextGraphId, subGraphName); const graphUri = contextGraphAssertionUri(contextGraphId, agentAddress, name, subGraphName); const result = await this.store.query( `CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <${graphUri}> { ?s ?p ?o } }`, @@ -1517,7 +1545,7 @@ export class DKGPublisher implements Publisher { agentAddress: string, opts?: { entities?: string[] | 'all'; subGraphName?: string }, ): Promise<{ promotedCount: number }> { - 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); @@ -1561,7 +1589,7 @@ export class DKGPublisher implements Publisher { } async assertionDiscard(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.dropGraph(graphUri); } diff --git a/packages/publisher/test/draft-lifecycle.test.ts b/packages/publisher/test/draft-lifecycle.test.ts index 514801d79..87ccf5555 100644 --- a/packages/publisher/test/draft-lifecycle.test.ts +++ b/packages/publisher/test/draft-lifecycle.test.ts @@ -193,3 +193,95 @@ 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, + }, + ]); + } + + 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('assertionQuery throws when sub-graph is not registered', async () => { + await expect( + publisher.assertionQuery(SG_CG_ID, ASSERTION_NAME, AGENT, 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('assertionDiscard throws when sub-graph is not registered', async () => { + await expect( + publisher.assertionDiscard(SG_CG_ID, ASSERTION_NAME, AGENT, SG_NAME), + ).rejects.toThrow(/Sub-graph "code" has not been registered/); + }); + + 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('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..24f4600cb 100644 --- a/packages/storage/src/graph-manager.ts +++ b/packages/storage/src/graph-manager.ts @@ -109,26 +109,6 @@ export class ContextGraphManager { return [...contextGraphs]; } - /** - * 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). - */ - async listSubGraphs(contextGraphId: string): Promise { - const prefix = `${CG_PREFIX}${contextGraphId}/`; - const allGraphs = await this.store.listGraphs(); - const subGraphNames = new Set(); - const reservedPrefixes = ['_', 'assertion/', 'draft/', 'context/']; - for (const g of allGraphs) { - if (!g.startsWith(prefix)) continue; - const rest = g.slice(prefix.length); - if (reservedPrefixes.some(r => rest.startsWith(r))) continue; - const name = rest.endsWith('/_meta') ? rest.slice(0, -6) : rest; - if (name.includes('/')) continue; - if (name.length > 0) subGraphNames.add(name); - } - return [...subGraphNames]; - } - async hasContextGraph(contextGraphId: string): Promise { return this.store.hasGraph(this.dataGraphUri(contextGraphId)); } From 99363e8569d548d661a0de02565f17bdff1445db Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Fri, 10 Apr 2026 18:52:34 +0200 Subject: [PATCH 2/4] fix: address PR #112 review comments --- packages/publisher/src/dkg-publisher.ts | 8 +-- .../publisher/test/draft-lifecycle.test.ts | 50 +++++++++++++++---- packages/storage/src/graph-manager.ts | 21 ++++++++ packages/storage/test/storage.test.ts | 13 +++++ 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index a88ec7bc4..007d0f613 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1476,8 +1476,8 @@ export class DKGPublisher implements Publisher { /** * Throws if `subGraphName` is provided but not registered in the CG's `_meta` graph. - * Mirrors the registration check in `publish()` so that assertion operations cannot - * orphan triples under an unregistered sub-graph URI. + * Mirrors the registration check in `publish()` for mutation paths that would + * otherwise create new orphaned sub-graph state. */ private async ensureSubGraphRegistered( contextGraphId: string, @@ -1531,7 +1531,7 @@ export class DKGPublisher implements Publisher { agentAddress: string, subGraphName?: string, ): Promise { - await this.ensureSubGraphRegistered(contextGraphId, subGraphName); + DKGPublisher.validateOptionalSubGraph(subGraphName); const graphUri = contextGraphAssertionUri(contextGraphId, agentAddress, name, subGraphName); const result = await this.store.query( `CONSTRUCT { ?s ?p ?o } WHERE { GRAPH <${graphUri}> { ?s ?p ?o } }`, @@ -1589,7 +1589,7 @@ export class DKGPublisher implements Publisher { } async assertionDiscard(contextGraphId: string, name: string, agentAddress: string, subGraphName?: string): Promise { - await this.ensureSubGraphRegistered(contextGraphId, subGraphName); + DKGPublisher.validateOptionalSubGraph(subGraphName); const graphUri = contextGraphAssertionUri(contextGraphId, agentAddress, name, subGraphName); await this.store.dropGraph(graphUri); } diff --git a/packages/publisher/test/draft-lifecycle.test.ts b/packages/publisher/test/draft-lifecycle.test.ts index 87ccf5555..fd1a6591d 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'; @@ -241,22 +246,23 @@ describe('Working Memory Assertion sub-graph registration check', () => { ).rejects.toThrow(/Sub-graph "code" has not been registered/); }); - it('assertionQuery throws when sub-graph is not registered', async () => { - await expect( - publisher.assertionQuery(SG_CG_ID, ASSERTION_NAME, AGENT, 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('assertionDiscard throws when sub-graph is not registered', async () => { - await expect( - publisher.assertionDiscard(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 () => { @@ -274,6 +280,28 @@ describe('Working Memory Assertion sub-graph registration check', () => { 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)); diff --git a/packages/storage/src/graph-manager.ts b/packages/storage/src/graph-manager.ts index 24f4600cb..05af056c9 100644 --- a/packages/storage/src/graph-manager.ts +++ b/packages/storage/src/graph-manager.ts @@ -109,6 +109,27 @@ export class ContextGraphManager { return [...contextGraphs]; } + /** + * @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}/`; + const allGraphs = await this.store.listGraphs(); + const subGraphNames = new Set(); + const reservedPrefixes = ['_', 'assertion/', 'draft/', 'context/']; + for (const g of allGraphs) { + if (!g.startsWith(prefix)) continue; + const rest = g.slice(prefix.length); + if (reservedPrefixes.some(r => rest.startsWith(r))) continue; + const name = rest.endsWith('/_meta') ? rest.slice(0, -6) : rest; + if (name.includes('/')) continue; + if (name.length > 0) subGraphNames.add(name); + } + return [...subGraphNames]; + } + async hasContextGraph(contextGraphId: string): Promise { return this.store.hasGraph(this.dataGraphUri(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' }, From 9c36ab4b82421210f4f1f7e07c38597549c749f4 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Fri, 10 Apr 2026 19:39:04 +0200 Subject: [PATCH 3/4] fix: tighten sub-graph registration checks --- packages/agent/src/dkg-agent.ts | 15 +++++++++------ packages/publisher/src/dkg-publisher.ts | 19 ++++++++++--------- .../publisher/test/draft-lifecycle.test.ts | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 80204fbbe..8f81220eb 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1968,12 +1968,15 @@ export class DKGAgent { * Sub-graphs use convention-based URI partitioning — no on-chain enforcement in V10.0. * * V10.0 replication behavior: - * - Registration triples are stored locally by the admin. Peers discover sub-graphs - * automatically through gossip auto-registration: when a peer receives a VM publish - * or SWM write carrying `subGraphName`, it calls `ensureSubGraph()` and inserts - * a `generateSubGraphRegistration()` record into its own `_meta` graph if one does - * not already exist. See `gossip-publish-handler.ts`, `workspace-handler.ts`, and - * `finalization-handler.ts` for the auto-registration call sites. + * - Registration triples are stored locally by the admin. Peers also auto-register + * sub-graphs on gossip publish and SWM write paths: when a peer receives data + * carrying `subGraphName`, `gossip-publish-handler.ts` and `workspace-handler.ts` + * call `ensureSubGraph()` and insert a `generateSubGraphRegistration()` record + * into `_meta` if one does not already exist. + * - Finalization replay currently ensures the named graph exists via + * `ensureSubGraph()`, but it does not add a `_meta` registration record by + * itself. `listSubGraphs()` therefore should not be treated as a complete + * inventory on finalized-only replicas. * - 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. diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 007d0f613..94492a613 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -717,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.`, @@ -1474,6 +1471,14 @@ 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 } }`, + ); + 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 @@ -1485,11 +1490,7 @@ export class DKGPublisher implements Publisher { ): Promise { if (subGraphName === undefined) return; DKGPublisher.validateOptionalSubGraph(subGraphName); - const sgUri = contextGraphSubGraphUri(contextGraphId, subGraphName); - const registered = await this.store.query( - `ASK { GRAPH { <${assertSafeIri(sgUri)}> ?p ?o } }`, - ); - if (registered.type === 'boolean' && !registered.value) { + if (!(await this.isSubGraphRegistered(contextGraphId, subGraphName))) { throw new Error( `Sub-graph "${subGraphName}" has not been registered in context graph "${contextGraphId}". ` + `Call createSubGraph() first.`, diff --git a/packages/publisher/test/draft-lifecycle.test.ts b/packages/publisher/test/draft-lifecycle.test.ts index fd1a6591d..9e71f7a89 100644 --- a/packages/publisher/test/draft-lifecycle.test.ts +++ b/packages/publisher/test/draft-lifecycle.test.ts @@ -252,6 +252,24 @@ describe('Working Memory Assertion sub-graph registration check', () => { ).rejects.toThrow(/Sub-graph "code" has not been registered/); }); + it('assertion mutation guard ignores stray _meta triples without 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://schema.org/name', + object: '"code"', + 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); From 3a68aeabfc9b91061bffbdf71990a1b7a38d8b17 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Fri, 10 Apr 2026 19:56:34 +0200 Subject: [PATCH 4/4] fix: align sub-graph registration discovery --- packages/agent/src/dkg-agent.ts | 12 ++---- packages/agent/src/finalization-handler.ts | 19 +++++++++ packages/agent/src/gossip-publish-handler.ts | 6 ++- .../agent/test/finalization-handler.test.ts | 42 ++++++++++++++++++- packages/publisher/src/dkg-publisher.ts | 8 +++- packages/publisher/src/workspace-handler.ts | 6 ++- .../publisher/test/draft-lifecycle.test.ts | 18 ++++++-- 7 files changed, 95 insertions(+), 16 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 8f81220eb..bcfdc35aa 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -1969,14 +1969,10 @@ export class DKGAgent { * * V10.0 replication behavior: * - Registration triples are stored locally by the admin. Peers also auto-register - * sub-graphs on gossip publish and SWM write paths: when a peer receives data - * carrying `subGraphName`, `gossip-publish-handler.ts` and `workspace-handler.ts` - * call `ensureSubGraph()` and insert a `generateSubGraphRegistration()` record - * into `_meta` if one does not already exist. - * - Finalization replay currently ensures the named graph exists via - * `ensureSubGraph()`, but it does not add a `_meta` registration record by - * itself. `listSubGraphs()` therefore should not be treated as a complete - * inventory on finalized-only replicas. + * 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. 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 94492a613..5d4594475 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -1474,7 +1474,11 @@ 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 } }`, + `ASK { GRAPH { + <${assertSafeIri(sgUri)}> a ; + ${JSON.stringify(subGraphName)} ; + ?createdBy . + } }`, ); return registered.type === 'boolean' && registered.value; } @@ -1493,7 +1497,7 @@ export class DKGPublisher implements Publisher { if (!(await this.isSubGraphRegistered(contextGraphId, subGraphName))) { throw new Error( `Sub-graph "${subGraphName}" has not been registered in context graph "${contextGraphId}". ` + - `Call createSubGraph() first.`, + `Register it first via DKGAgent.createSubGraph() or by inserting the sub-graph registration into the context graph "_meta" graph.`, ); } } 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 9e71f7a89..537a12770 100644 --- a/packages/publisher/test/draft-lifecycle.test.ts +++ b/packages/publisher/test/draft-lifecycle.test.ts @@ -231,6 +231,18 @@ describe('Working Memory Assertion sub-graph registration check', () => { 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, + }, ]); } @@ -252,15 +264,15 @@ describe('Working Memory Assertion sub-graph registration check', () => { ).rejects.toThrow(/Sub-graph "code" has not been registered/); }); - it('assertion mutation guard ignores stray _meta triples without the SubGraph type marker', async () => { + 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://schema.org/name', - object: '"code"', + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'http://dkg.io/ontology/SubGraph', graph: metaGraph, }, ]);