Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions packages/agent/src/dkg-agent-cg-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,8 @@ export class ContextGraphRegistryMethods extends DKGAgentBase {
contextGraphId: string,
options: { signal?: AbortSignal; source?: string } = {},
): Promise<string | null> {
const subscribed = this.subscribedContextGraphs.get(contextGraphId)?.onChainId;
const directSubscription = this.subscribedContextGraphs.get(contextGraphId);
const subscribed = directSubscription?.onChainId;
if (subscribed) return subscribed;

// Registered CG events carry only the curator-committed name hash. Resolve
Expand All @@ -428,9 +429,45 @@ export class ContextGraphRegistryMethods extends DKGAgentBase {
const mappedLocalId = this.localCgIdForWireId(
this.contextGraphWireId(contextGraphId),
);
const mapped = this.subscribedContextGraphs.get(mappedLocalId)?.onChainId;
const mappedSubscription = this.subscribedContextGraphs.get(mappedLocalId);
const mapped = mappedSubscription?.onChainId;
if (mapped) return mapped;

// A cold node may select a CG long after ContextGraphCreated fell outside
// the live event poller's bounded lookback. Resolve the exact indexed
// nameHash from chain before consulting the legacy ontology projection.
// Scope this expensive historical operation to a locally admitted
// subscription (explicit Edge selection or Core-hosted record): an
// arbitrary remote id must never trigger a chain crawl.
const localSubscription = directSubscription ?? mappedSubscription;
const resolveHistorical = this.chain?.resolveContextGraphIdByNameHash;
const locallyAdmitted = localSubscription !== undefined && (
localSubscription.syncAdmission !== 'none'
|| localSubscription.coreHosted === true
Comment thread
branarakic marked this conversation as resolved.
);
if (locallyAdmitted && typeof resolveHistorical === 'function') {
// A direct subscription key is the original local identifier, including
// its spelling/case when a legitimate cleartext id happens to look like
// a bytes32 wire hash. Only use mappedLocalId when admission was found
// through the reverse wire-id index.
const localIdForCommitment = directSubscription !== undefined
? contextGraphId
: mappedLocalId;
const wireId = localSubscription.onChainHash
? this.contextGraphWireId(localSubscription.onChainHash)
: this.contextGraphNameCommitment(localIdForCommitment);
const resolved = options.signal === undefined
? await resolveHistorical.call(this.chain, wireId)
: await resolveHistorical.call(this.chain, wireId, { signal: options.signal });
if (resolved !== null) {
const boundLocalId = this.bindOnChainContextGraphIdFromNameHash(
wireId,
resolved.toString(),
);
if (boundLocalId !== null) return resolved.toString();
}
}

const ontologyGraph = contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY);
const contextGraphUri = `did:dkg:context-graph:${contextGraphId}`;
const result = await this.store.query(
Expand Down
169 changes: 169 additions & 0 deletions packages/agent/test/context-graph-historical-name-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from 'vitest';
import type { TripleStore } from '@origintrail-official/dkg-storage';
import { ContextGraphRegistryMethods } from '../src/dkg-agent-cg-registry.js';

const LOCAL_ID = 'selected-public-cg';
const NAME_HASH = `0x${'ab'.repeat(32)}`;

function selectedFixture(resolved: bigint | null = 42n) {
const query = vi.fn<TripleStore['query']>(async () => ({
type: 'bindings',
bindings: [],
}));
const resolveContextGraphIdByNameHash = vi.fn(async () => resolved);
const bindOnChainContextGraphIdFromNameHash = vi.fn(() => LOCAL_ID);
const subscription = {
subscribed: true,
synced: false,
syncMode: 'always-on',
syncAdmission: 'explicit',
onChainHash: NAME_HASH,
};
return {
query,
resolveContextGraphIdByNameHash,
bindOnChainContextGraphIdFromNameHash,
agent: {
store: { query } as unknown as TripleStore,
chain: { resolveContextGraphIdByNameHash },
subscribedContextGraphs: new Map([[LOCAL_ID, subscription]]),
contextGraphWireId: (id: string) => id === LOCAL_ID ? NAME_HASH : id.toLowerCase(),
contextGraphNameCommitment: (id: string) => id === LOCAL_ID ? NAME_HASH : id.toLowerCase(),
localCgIdForWireId: (id: string) => id.toLowerCase() === NAME_HASH ? LOCAL_ID : id,
bindOnChainContextGraphIdFromNameHash,
},
};
}

describe('cold historical Context Graph name binding', () => {
it('resolves and binds an explicit cleartext selection without ontology data', async () => {
const fixture = selectedFixture();
const signal = new AbortController().signal;

await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
{ signal },
),
).resolves.toBe('42');

expect(fixture.resolveContextGraphIdByNameHash).toHaveBeenCalledWith(
NAME_HASH,
{ signal },
);
expect(fixture.bindOnChainContextGraphIdFromNameHash).toHaveBeenCalledWith(
NAME_HASH,
'42',
);
expect(fixture.query).not.toHaveBeenCalled();
});

it('uses the same binding for a selected wire-hash request', async () => {
const fixture = selectedFixture();
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
NAME_HASH,
),
).resolves.toBe('42');
expect(fixture.resolveContextGraphIdByNameHash).toHaveBeenCalledWith(
NAME_HASH,
);
});

it('never lets an arbitrary unselected id trigger a historical chain scan', async () => {
const fixture = selectedFixture();
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
'unselected-remote-cg',
),
).resolves.toBeNull();
expect(fixture.resolveContextGraphIdByNameHash).not.toHaveBeenCalled();
expect(fixture.query).toHaveBeenCalledTimes(1);
});

it('does not let a passive non-admitted local record trigger a historical chain scan', async () => {
const fixture = selectedFixture();
fixture.agent.subscribedContextGraphs.get(LOCAL_ID)!.syncAdmission = 'none';
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
),
).resolves.toBeNull();
expect(fixture.resolveContextGraphIdByNameHash).not.toHaveBeenCalled();
expect(fixture.query).toHaveBeenCalledTimes(1);
});

it('allows a host-only Core record to recover its historical binding', async () => {
const fixture = selectedFixture();
const subscription = fixture.agent.subscribedContextGraphs.get(LOCAL_ID)!;
subscription.subscribed = false;
subscription.syncAdmission = 'none';
subscription.coreHosted = true;

await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
),
).resolves.toBe('42');
expect(fixture.resolveContextGraphIdByNameHash).toHaveBeenCalledWith(NAME_HASH);
});

it('hashes the original spelling of a hash-shaped cleartext subscription id', async () => {
const fixture = selectedFixture();
const localId = `0x${'AB'.repeat(32)}`;
const committedHash = `0x${'cd'.repeat(32)}`;
const subscription = fixture.agent.subscribedContextGraphs.get(LOCAL_ID)!;
fixture.agent.subscribedContextGraphs = new Map([[
localId,
{ ...subscription, onChainHash: undefined },
]]);
fixture.agent.contextGraphWireId = (id: string) => id.toLowerCase();
fixture.agent.localCgIdForWireId = (id: string) => id.toLowerCase();
fixture.agent.contextGraphNameCommitment = vi.fn((id: string) =>
id === localId ? committedHash : NAME_HASH);
fixture.bindOnChainContextGraphIdFromNameHash.mockReturnValue(localId);

await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
localId,
),
).resolves.toBe('42');
expect(fixture.agent.contextGraphNameCommitment).toHaveBeenCalledWith(localId);
expect(fixture.resolveContextGraphIdByNameHash).toHaveBeenCalledWith(committedHash);
});

it('retains the legacy ontology fallback for a selected pre-name-hash miss', async () => {
const fixture = selectedFixture(null);
fixture.query.mockResolvedValueOnce({
type: 'bindings',
bindings: [{ id: '"7"' }],
});
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
),
).resolves.toBe('7');
expect(fixture.bindOnChainContextGraphIdFromNameHash).not.toHaveBeenCalled();
});

it('propagates ambiguous or failed chain resolution instead of trusting local metadata', async () => {
const fixture = selectedFixture();
fixture.resolveContextGraphIdByNameHash.mockRejectedValueOnce(
new Error('ambiguous name hash'),
);
await expect(
ContextGraphRegistryMethods.prototype.getContextGraphOnChainId.call(
fixture.agent as never,
LOCAL_ID,
),
).rejects.toThrow('ambiguous name hash');
expect(fixture.query).not.toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions packages/chain/src/chain-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1840,6 +1840,22 @@ export interface ChainAdapter {
contextGraphId: bigint,
options?: ChainReadOptions,
): Promise<string | null>;

/**
* Resolve the numeric ContextGraphStorage slot committed to an exact
* `ContextGraphCreated.nameHash` topic.
*
* This is the cold-start inverse of {@link getContextGraphNameHash}: a node
* that selected a CG after its creation event fell outside the live poller's
* lookback still needs an authoritative hash -> numeric-id binding before it
* can evaluate policy or authorize SWM. Implementations MUST fail closed on
* ambiguous matches and independently verify the current slot still commits
* to `nameHash` before returning it.
*/
resolveContextGraphIdByNameHash?(
nameHash: string,
options?: ChainReadOptions,
): Promise<bigint | null>;
}

// ----- Backward-compat deprecated aliases -----
Expand Down
Loading
Loading