Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
20 changes: 14 additions & 6 deletions packages/agent/src/dkg-agent-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,21 +977,25 @@ export class WorkspaceCryptoMethods extends DKGAgentBase {
* caller naming the slot directly, not a cleartext→numeric remapping, so it is
* treated like the bare-numeric raw-slot path (not identity-bound) and gated
* by liveness + fresh policy alone.
*
* `requireCommittedNameHash` is the strict durable-sync mode: unlike the
* legacy policy probe, it rejects malformed ids and adapters that cannot
* prove a non-numeric local id from the chain commitment. The canonical
* direct numeric self-address remains valid in either mode.
*/
async localCgMatchesOnChainSlot(this: DKGAgent,
contextGraphId: string,
onChainId: string,
opCtx?: OperationContext,
options?: { requireCommittedNameHash?: boolean },
): Promise<boolean> {
const getNameHash = this.chain.getContextGraphNameHash;
if (typeof getNameHash !== 'function') return true;
let numericId: bigint;
try {
numericId = BigInt(onChainId);
} catch {
return true;
return options?.requireCommittedNameHash !== true;
}
if (numericId <= 0n) return true;
if (numericId <= 0n) return options?.requireCommittedNameHash !== true;

const trimmed = contextGraphId.trim();
// DIRECT NUMERIC SELF-ADDRESS: a local CG whose own id IS its numeric
Expand All @@ -1005,7 +1009,11 @@ export class WorkspaceCryptoMethods extends DKGAgentBase {
// so name-hash binding is inapplicable — defer to the liveness + fresh-policy
// gate. The stale-mapping risk the name-hash defends against (#884 review
// 🔴 GaZk2) only exists for a cleartext id that REMAPS to a different slot.
if (/^\d+$/.test(trimmed) && trimmed === onChainId.trim()) return true;
if (/^\d+$/.test(trimmed) && trimmed === numericId.toString()) return true;
const getNameHash = this.chain.getContextGraphNameHash;
if (typeof getNameHash !== 'function') {
return options?.requireCommittedNameHash !== true;
}
// A locally-resolved (cleartext) id can be committed two ways, and both are
// legitimate (#884 review 🔴 GZumc + 🔴 GaJf_), so accept a match against EITHER:
// - CLEARTEXT (always): a curator-created CG stores its cleartext id (even
Expand All @@ -1027,7 +1035,7 @@ export class WorkspaceCryptoMethods extends DKGAgentBase {
try {
acceptable.add(ethers.keccak256(ethers.toUtf8Bytes(trimmed)).toLowerCase());
} catch {
return true;
return options?.requireCommittedNameHash !== true;
}
if (/^0x[0-9a-fA-F]{64}$/.test(trimmed) && this.isWireIdKeyedSubscription(trimmed)) {
acceptable.add(trimmed.toLowerCase());
Expand Down
23 changes: 19 additions & 4 deletions packages/agent/src/dkg-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4156,14 +4156,29 @@ export class LifecycleSyncMethods extends DKGAgentBase {
source: 'agent.durableSync.storeInsert',
}),
storeGraphScopedAsset: async (asset) => {
const authenticatedAsset = await authenticateVerifiedGraphScopedAsset(
const authentication = await authenticateVerifiedGraphScopedAsset(
this.chain,
asset,
(cgId) => this.getContextGraphOnChainId(cgId),
(localContextGraphId, onChainContextGraphId) => this.localCgMatchesOnChainSlot(
localContextGraphId,
onChainContextGraphId.toString(),
ctx,
{ requireCommittedNameHash: true },
),
);
const verifiedOnChainId = authentication.onChainContextGraphId;
const subscription = this.subscribedContextGraphs.get(asset.contextGraphId);
if (verifiedOnChainId && subscription && subscription.onChainId !== verifiedOnChainId) {
this.bindSubscriptionOnChainId(
asset.contextGraphId,
subscription,
verifiedOnChainId,
);
this.persistContextGraphSubscriptionState(asset.contextGraphId);
}
const outcome = await materializeVerifiedGraphScopedAsset({
store: this.store,
asset: authenticatedAsset,
asset: authentication.asset,
options: {
priority: 'background',
source: 'agent.durableSync.graphScopedMaterialization',
Expand All @@ -4174,7 +4189,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
});
if (outcome === 'applied') {
this.invalidateListContextGraphsCache();
this.contextGraphMetaProjection.markDirtyFromQuads(authenticatedAsset.metadataQuads);
this.contextGraphMetaProjection.markDirtyFromQuads(authentication.asset.metadataQuads);
}
return outcome;
},
Expand Down
80 changes: 53 additions & 27 deletions packages/agent/src/sync/requester/graph-scoped-materialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,32 @@ export interface VerifiedGraphScopedAsset {
metadataQuads: Quad[];
}

export interface AuthenticatedGraphScopedAsset {
asset: VerifiedGraphScopedAsset;
/** Null only for explicit no-chain development mode. */
onChainContextGraphId: string | null;
}

export type VerifyContextGraphBinding = (
localContextGraphId: string,
onChainContextGraphId: bigint,
) => Promise<boolean>;

export type GraphScopedMaterializationOutcome = 'applied' | 'stale' | 'quarantined';

/**
* Bind the peer-verified payload to current chain truth before its structural
* metadata can influence local assertion ordering. No-chain development keeps
* the integrity-only behavior; production chains fail closed without both
* constant-size views.
* the integrity-only behavior; production chains fail closed without the
* required constant-size chain views. Local-id matching stays in the agent's
* context-graph identity layer and is injected here as a focused verifier.
*/
export async function authenticateVerifiedGraphScopedAsset(
chain: ChainAdapter,
asset: VerifiedGraphScopedAsset,
resolveOnChainContextGraphId?: (contextGraphId: string) => Promise<string | null>,
verifyContextGraphBinding?: VerifyContextGraphBinding,
receivedAt = new Date(),
): Promise<VerifiedGraphScopedAsset> {
): Promise<AuthenticatedGraphScopedAsset> {
const receivedAtMs = receivedAt.getTime();
if (!Number.isFinite(receivedAtMs)) {
throw new Error(`Graph-scoped durable sync ${asset.ual} has an invalid local receive time`);
Expand All @@ -74,15 +86,17 @@ export async function authenticateVerifiedGraphScopedAsset(
// tentative because it has integrity verification but no chain provenance.
if (chain.chainId === 'none') {
return {
...asset,
metadataQuads: [...asset.metadataQuads, ...locallyVisibleMetadata('tentative')],
asset: {
...asset,
metadataQuads: [...asset.metadataQuads, ...locallyVisibleMetadata('tentative')],
},
onChainContextGraphId: null,
};
}
if (
!chain.getLatestMerkleRoot
|| !chain.getMerkleRootCount
|| !chain.getKAContextGraphId
|| !resolveOnChainContextGraphId
) {
throw Object.assign(
new Error(
Expand All @@ -104,11 +118,10 @@ export async function authenticateVerifiedGraphScopedAsset(
if (roots.length !== 1) {
throw new Error(`Graph-scoped durable sync ${asset.ual} has ${roots.length} Merkle roots`);
}
const [latestRoot, rootCount, boundContextGraphId, expectedContextGraphId] = await Promise.all([
const [latestRoot, rootCount, boundContextGraphId] = await Promise.all([
chain.getLatestMerkleRoot(kaId),
chain.getMerkleRootCount(kaId),
chain.getKAContextGraphId(kaId),
resolveOnChainContextGraphId(asset.contextGraphId),
]);
if (latestRoot.length !== 32 || !bytesEqual(latestRoot, roots[0]!)) {
throw Object.assign(
Expand All @@ -125,15 +138,25 @@ export async function authenticateVerifiedGraphScopedAsset(
{ code: 'VM_CHAIN_ASSERTION_VERSION_MISMATCH' },
);
}
if (
expectedContextGraphId === null
|| BigInt(expectedContextGraphId) <= 0n
|| boundContextGraphId !== BigInt(expectedContextGraphId)
) {
if (boundContextGraphId <= 0n) {
throw Object.assign(
new Error(
`Graph-scoped durable sync ${asset.ual} is bound to context graph ${boundContextGraphId}, ` +
`not local context graph ${asset.contextGraphId} (${expectedContextGraphId ?? 'unresolved'})`,
`Graph-scoped durable sync ${asset.ual} is bound to invalid context graph ${boundContextGraphId}`,
),
{ code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH' },
);
}
if (!verifyContextGraphBinding) {
throw Object.assign(
new Error('Graph-scoped durable sync requires local-to-chain context-graph verification'),
{ code: 'VM_CHAIN_VERIFICATION_UNSUPPORTED' },
);
}
if (!(await verifyContextGraphBinding(asset.contextGraphId, boundContextGraphId))) {
throw Object.assign(
new Error(
`Graph-scoped durable sync ${asset.ual} is bound to context graph ${boundContextGraphId}, `
+ `which does not match local context graph ${asset.contextGraphId}`,
),
{ code: 'VM_CHAIN_CONTEXT_GRAPH_MISMATCH' },
);
Expand Down Expand Up @@ -211,17 +234,20 @@ export async function authenticateVerifiedGraphScopedAsset(
throw new Error(`Graph-scoped durable sync ${asset.ual} has invalid receipt ordering data`);
}
return {
...asset,
metadataQuads: [
...asset.metadataQuads,
...locallyVisibleMetadata('confirmed'),
{
subject: asset.ual,
predicate: MATERIALIZED_VERSION,
object: `"${materializedBlock}:${materializedTxIndex}"`,
graph: asset.metaGraph,
},
],
asset: {
...asset,
metadataQuads: [
...asset.metadataQuads,
...locallyVisibleMetadata('confirmed'),
{
subject: asset.ual,
predicate: MATERIALIZED_VERSION,
object: `"${materializedBlock}:${materializedTxIndex}"`,
graph: asset.metaGraph,
},
],
},
onChainContextGraphId: boundContextGraphId.toString(),
};
}

Expand Down
Loading
Loading