From f18932370ada7a550b7cb2ca7e6381537a41a9e3 Mon Sep 17 00:00:00 2001 From: Branimir Rakic Date: Fri, 24 Jul 2026 02:29:49 +0200 Subject: [PATCH] fix(sync): skip chain reauth for exact materialized replays --- packages/agent/src/dkg-agent-lifecycle.ts | 11 ++ .../requester/graph-scoped-materialization.ts | 114 ++++++++++++++++++ ...-sync-graph-scoped-materialization.test.ts | 64 ++++++++++ .../durable-sync-lifecycle-binding.test.ts | 32 +++++ 4 files changed, 221 insertions(+) diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 6b9e363cd..ff0afb2b3 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -104,6 +104,7 @@ import { decodeChangelogRequest, encodeChangelogResponse } from './sync/changelo import { runChangelogSync, planPageApply } from './sync/requester/changelog-sync.js'; import { authenticateVerifiedGraphScopedAsset, + isAuthenticatedGraphScopedAssetMaterialized, materializeVerifiedGraphScopedAsset, type VerifiedGraphScopedAsset, type VerifyContextGraphBinding, @@ -4429,6 +4430,16 @@ export class LifecycleSyncMethods extends DKGAgentBase { source: 'agent.durableSync.storeInsert', }), storeGraphScopedAsset: async (asset, deadline) => { + if (await isAuthenticatedGraphScopedAssetMaterialized({ + store: this.store, + asset, + options: { + priority: 'background', + source: 'agent.durableSync.graphScopedReplayProbe', + }, + })) { + return 'stale'; + } const authentication = await authenticateDurableGraphScopedAsset({ chain: this.chain, asset, diff --git a/packages/agent/src/sync/requester/graph-scoped-materialization.ts b/packages/agent/src/sync/requester/graph-scoped-materialization.ts index fb0d92efd..0d69037f7 100644 --- a/packages/agent/src/sync/requester/graph-scoped-materialization.ts +++ b/packages/agent/src/sync/requester/graph-scoped-materialization.ts @@ -10,6 +10,7 @@ import { type TripleStore, } from '@origintrail-official/dkg-storage'; import { + computeFlatKCRootV10, mergeSameVersionGraphKnowledgeAssetMetadataV1, readGraphKnowledgeAssetConfirmationKindV1, readLocallyTrustedKnowledgeAssetControls, @@ -21,7 +22,9 @@ import { } from '../oversize-filter.js'; const ASSERTION_VERSION = 'http://dkg.io/ontology/assertionVersion'; +const ASSERTION_GRAPH = 'http://dkg.io/ontology/assertionGraph'; const MERKLE_ROOT = 'http://dkg.io/ontology/merkleRoot'; +const PRIVATE_MERKLE_ROOT = 'http://dkg.io/ontology/privateMerkleRoot'; const STATUS = 'http://dkg.io/ontology/status'; const TRANSACTION_HASH = 'http://dkg.io/ontology/transactionHash'; const MATERIALIZED_VERSION = 'http://dkg.io/ontology/materializedVersion'; @@ -52,6 +55,96 @@ export type VerifyContextGraphBinding = ( export type GraphScopedMaterializationOutcome = 'applied' | 'stale' | 'quarantined'; +/** + * Detect an exact replay that was already admitted through this requester's + * authenticated materialization path. `materializedVersion` and `status` are + * stripped from peer input before storage, so their local presence can be used + * as a trust marker only when the immutable assertion identity still matches. + * + * This is deliberately an optimization, not an admission fallback: missing, + * malformed, duplicated, or mismatched metadata returns false and forces the + * caller through normal chain authentication again. + */ +export async function isAuthenticatedGraphScopedAssetMaterialized(params: { + store: TripleStore; + asset: VerifiedGraphScopedAsset; + options?: QueryOptions; +}): Promise { + const { store, asset, options = {} } = params; + const incomingRoots = asset.metadataQuads + .filter((quad) => quad.predicate === MERKLE_ROOT) + .map((quad) => quad.object); + if (incomingRoots.length !== 1) return false; + + let incomingRoot: Uint8Array; + try { + incomingRoot = parseBytes32Literal(incomingRoots[0]!, 'merkleRoot'); + } catch { + return false; + } + const privateRoots: Uint8Array[] = []; + try { + for (const quad of asset.metadataQuads) { + if (quad.predicate === PRIVATE_MERKLE_ROOT) { + privateRoots.push(parseBytes32Literal(quad.object, 'privateMerkleRoot')); + } + } + } catch { + return false; + } + + const [metadataResult, dataResult] = await Promise.all([ + store.query(` + SELECT ?assertionVersion ?assertionGraph ?merkleRoot ?materializedVersion ?status WHERE { + GRAPH <${assertSafeIri(asset.metaGraph)}> { + <${assertSafeIri(asset.ual)}> <${ASSERTION_VERSION}> ?assertionVersion ; + <${ASSERTION_GRAPH}> ?assertionGraph ; + <${MERKLE_ROOT}> ?merkleRoot ; + <${MATERIALIZED_VERSION}> ?materializedVersion ; + <${STATUS}> ?status . + } + } + `, options), + store.query(` + SELECT ?s ?p ?o WHERE { + GRAPH <${assertSafeIri(asset.assertionGraph)}> { ?s ?p ?o } + } + `, options), + ]); + if ( + metadataResult.type !== 'bindings' + || metadataResult.bindings.length !== 1 + || dataResult.type !== 'bindings' + ) return false; + + const row = metadataResult.bindings[0]!; + const version = parseUnsignedIntegerLiteral(row.assertionVersion); + const storedRoot = parseBytes32LiteralOrUndefined(row.merkleRoot); + const materializedVersion = parseRdfLiteral(row.materializedVersion ?? ''); + const status = parseRdfLiteral(row.status ?? ''); + const storedAssertionGraph = stripIriBinding(row.assertionGraph); + const localData = dataResult.bindings.flatMap((binding): Quad[] => ( + binding.s !== undefined && binding.p !== undefined && binding.o !== undefined + ? [{ + subject: binding.s, + predicate: binding.p, + object: binding.o, + graph: asset.assertionGraph, + }] + : [] + )); + const localRoot = computeFlatKCRootV10(localData, privateRoots); + + return version === asset.assertionVersion + && storedAssertionGraph === asset.assertionGraph + && storedRoot !== undefined + && bytesEqual(storedRoot, incomingRoot) + && bytesEqual(localRoot, incomingRoot) + && materializedVersion !== undefined + && /^\d+:\d+$/.test(materializedVersion) + && status === 'confirmed'; +} + /** * Bind the peer-verified payload to current chain truth before its structural * metadata can influence local assertion ordering. No-chain development keeps @@ -436,6 +529,27 @@ function parseBytes32Literal(raw: string, field: string): Uint8Array { return Uint8Array.from(hex.match(/.{2}/g)!.map((pair) => Number.parseInt(pair, 16))); } +function parseBytes32LiteralOrUndefined(raw: string | undefined): Uint8Array | undefined { + if (raw === undefined) return undefined; + try { + return parseBytes32Literal(raw, 'merkleRoot'); + } catch { + return undefined; + } +} + +function parseUnsignedIntegerLiteral(raw: string | undefined): bigint | undefined { + if (raw === undefined) return undefined; + const lexical = parseRdfLiteral(raw) ?? raw; + if (!/^\d+$/.test(lexical)) return undefined; + return BigInt(lexical); +} + +function stripIriBinding(raw: string | undefined): string | undefined { + if (raw === undefined) return undefined; + return raw.match(/^<([^>]*)>$/)?.[1] ?? raw; +} + function parseTransactionHashLiteral(raw: string): string { const lexical = parseRdfLiteral(raw) ?? raw; if (!/^0x[0-9a-f]{64}$/i.test(lexical)) { diff --git a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts index 4dfb111ae..b581879a2 100644 --- a/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts +++ b/packages/agent/test/durable-sync-graph-scoped-materialization.test.ts @@ -25,6 +25,7 @@ import type { SyncPageResult } from '../src/sync/requester/page-fetch.js'; import { DKGAgent } from '../src/dkg-agent.js'; import { authenticateVerifiedGraphScopedAsset, + isAuthenticatedGraphScopedAssetMaterialized, materializeVerifiedGraphScopedAsset, type GraphScopedMaterializationOutcome, type VerifyContextGraphBinding, @@ -243,6 +244,69 @@ function runGraphScopedDurableSync(options: { } describe('durable graph-scoped KA materialization', () => { + it('recognizes only an exact replay with local authenticated materialization markers', async () => { + const store = new OxigraphStore(); + const root = computeFlatKCRootV10([dataQuad(2)], []); + const rootHex = toHex(root); + const asset: VerifiedGraphScopedAsset = { + contextGraphId, + ual, + assertionVersion: 2n, + assertionGraph, + metaGraph, + dataQuads: [dataQuad(2)], + metadataQuads: metadata(2, rootHex), + }; + await store.insert([ + ...asset.dataQuads, + ...asset.metadataQuads, + { + subject: ual, + predicate: `${DKG}materializedVersion`, + object: '"123:4"', + graph: metaGraph, + }, + { + subject: ual, + predicate: `${DKG}status`, + object: '"confirmed"', + graph: metaGraph, + }, + ]); + + await expect(isAuthenticatedGraphScopedAssetMaterialized({ + store, + asset, + })).resolves.toBe(true); + + const changedRoot = new Uint8Array(32); + changedRoot[31] = 3; + await expect(isAuthenticatedGraphScopedAssetMaterialized({ + store, + asset: { + ...asset, + metadataQuads: metadata(2, toHex(changedRoot)), + }, + })).resolves.toBe(false); + + await store.deleteByPattern({ graph: assertionGraph }); + await store.insert([dataQuad(1)]); + await expect(isAuthenticatedGraphScopedAssetMaterialized({ + store, + asset, + })).resolves.toBe(false); + + await store.deleteByPattern({ + graph: metaGraph, + subject: ual, + predicate: `${DKG}materializedVersion`, + }); + await expect(isAuthenticatedGraphScopedAssetMaterialized({ + store, + asset, + })).resolves.toBe(false); + }); + it('hands the exact context-graph deadline to graph-scoped storage', async () => { const deadline = 1_800_000_123_456; const storeGraphScopedAsset = vi.fn(async ( diff --git a/packages/agent/test/durable-sync-lifecycle-binding.test.ts b/packages/agent/test/durable-sync-lifecycle-binding.test.ts index ae7a8e8ab..89d9180c5 100644 --- a/packages/agent/test/durable-sync-lifecycle-binding.test.ts +++ b/packages/agent/test/durable-sync-lifecycle-binding.test.ts @@ -14,6 +14,7 @@ vi.mock('../src/sync/requester/graph-scoped-materialization.js', async (importOr >(); return { ...actual, + isAuthenticatedGraphScopedAssetMaterialized: vi.fn(async () => false), materializeVerifiedGraphScopedAsset: vi.fn(async () => 'applied' as const), }; }); @@ -22,6 +23,7 @@ import { DKGAgent } from '../src/dkg-agent.js'; import { LifecycleSyncMethods } from '../src/dkg-agent-lifecycle.js'; import { runDurableSync } from '../src/sync/requester/durable-sync.js'; import { + isAuthenticatedGraphScopedAssetMaterialized, materializeVerifiedGraphScopedAsset, type VerifiedGraphScopedAsset, } from '../src/sync/requester/graph-scoped-materialization.js'; @@ -34,6 +36,7 @@ const metaGraph = `did:dkg:context-graph:${contextGraphId}/_meta`; const ctx = { kind: 'sync', id: 'lifecycle-binding-test', startedAt: 0 } as OperationContext; const mockedRunDurableSync = vi.mocked(runDurableSync); +const mockedReplayProbe = vi.mocked(isAuthenticatedGraphScopedAssetMaterialized); const mockedMaterialize = vi.mocked(materializeVerifiedGraphScopedAsset); function graphScopedAsset( @@ -105,9 +108,38 @@ async function captureGraphScopedStore( describe('durable sync lifecycle chain binding', () => { beforeEach(() => { mockedRunDurableSync.mockClear(); + mockedReplayProbe.mockReset(); + mockedReplayProbe.mockResolvedValue(false); mockedMaterialize.mockClear(); }); + it('skips chain authentication for an exact locally authenticated replay', async () => { + const root = new Uint8Array(32); + root[31] = 2; + const getLatestMerkleRoot = vi.fn(async () => root); + const getMerkleRootCount = vi.fn(async () => 2n); + const getKAContextGraphId = vi.fn(async () => 14n); + const chain = { + chainId: 'otp:2043', + getLatestMerkleRoot, + getMerkleRootCount, + getKAContextGraphId, + } as ChainAdapter; + mockedReplayProbe.mockResolvedValueOnce(true); + + const storeGraphScopedAsset = await captureGraphScopedStore(chain); + await expect(storeGraphScopedAsset( + graphScopedAsset(root), + Date.now() + 60_000, + )).resolves.toBe('stale'); + + expect(mockedReplayProbe).toHaveBeenCalledTimes(1); + expect(getLatestMerkleRoot).not.toHaveBeenCalled(); + expect(getMerkleRootCount).not.toHaveBeenCalled(); + expect(getKAContextGraphId).not.toHaveBeenCalled(); + expect(mockedMaterialize).not.toHaveBeenCalled(); + }); + it('retries a transient binding read, caches only the successful proof, and persists the CG id', async () => { const root = new Uint8Array(32); root[31] = 2;