Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ef93cd9
feat(sync): make edge CG subscriptions on-demand by default
Aug 2, 2026
15098da
fix(sync): preserve core hosting with on-demand subscriptions
Aug 2, 2026
7f0c0ff
fix(sync): preserve durable intent after on-demand opens
Aug 2, 2026
910161f
test(sync): preserve registration subscription invariant
Aug 2, 2026
d84b1f4
fix(sync): retain implicit graph durable promotion
Aug 2, 2026
20271bf
test(sync): extract subscription persistence fixtures
Aug 2, 2026
9cad49e
fix(cli): require explicit context graph sync mode
Aug 2, 2026
2b219d5
test(cli): cover legacy subscribe sync mode
Aug 2, 2026
17b2830
fix(agent): preserve dormant durable sync intent
Aug 2, 2026
9471694
fix(cli): reject null context graph sync mode
Aug 2, 2026
42a0e66
feat(sync): expose verified CG convergence by plane
Aug 2, 2026
1ed1769
fix(sync): preserve coalesced catchup job scope
Aug 2, 2026
20c554b
fix(sync): harden catch-up convergence
Aug 2, 2026
81a74df
fix(sync): model catch-up scopes and refresh latest views
Aug 2, 2026
cdd6b05
refactor(sync): narrow catch-up route dependencies
Aug 2, 2026
f9570f8
fix(sync): preserve legacy tracker initialization
Aug 2, 2026
9871c75
fix(sync): centralize authoritative metadata checks
Aug 2, 2026
a3a45e3
fix(sync): preserve incomplete-round invariants
Aug 2, 2026
fd9ac43
refactor(sync): make tracker compatibility explicit
Aug 2, 2026
693ae68
fix(sync): preserve successful catch-up attempts
Aug 2, 2026
0fdb3cb
fix(sync): scope coalesced catch-up work
Aug 2, 2026
0427257
refactor(sync): canonicalize shared-memory scope
Aug 2, 2026
da58c0c
refactor(sync): own metadata classification policy
Aug 2, 2026
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
79 changes: 79 additions & 0 deletions packages/agent/src/context-graph-subscription-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type {
ContextGraphSub,
ContextGraphSubInput,
ContextGraphSubscriptionRecord,
ContextGraphSyncMode,
} from './dkg-agent-types.js';

export function normalizeLegacyContextGraphSubscriptionInput(
previous: ContextGraphSub | undefined,
next: ContextGraphSubInput,
): ContextGraphSub {
return {
...next,
syncMode: next.syncMode ?? previous?.syncMode ?? 'always-on',
};
}

export function resolveContextGraphSyncMode(input: {
existing?: Pick<ContextGraphSub, 'subscribed' | 'syncMode'>;
requested?: ContextGraphSyncMode;
hasDormantDurableIntent: boolean;
}): ContextGraphSyncMode {
if (
input.hasDormantDurableIntent
|| (input.existing?.subscribed === true && input.existing.syncMode === 'always-on')
) {
return 'always-on';
}
return input.requested ?? input.existing?.syncMode ?? 'always-on';
}

export type ContextGraphSubscriptionPersistenceProjection =
| { action: 'skip'; persistMemberIntent: false }
| { action: 'delete'; persistMemberIntent: true }
| {
action: 'save';
persistMemberIntent: boolean;
record: ContextGraphSubscriptionRecord;
};

/**
* Canonical durable projection for live Context Graph subscription state.
*
* On-demand member intent remains process-local. A Core hosting obligation is
* independently durable and therefore projects to a host-only row. Always-on
* member intent projects the complete live readiness state.
*/
export function projectContextGraphSubscriptionPersistence(input: {
contextGraphId: string;
subscription: ContextGraphSub | undefined;
syncScoped: boolean;
}): ContextGraphSubscriptionPersistenceProjection {
const sub = input.subscription;
if (sub?.syncMode === 'on-demand' && sub.coreHosted !== true) {
return { action: 'skip', persistMemberIntent: false };
}
if (!sub?.subscribed && !sub?.coreHosted) {
return { action: 'delete', persistMemberIntent: true };
}

const persistMemberIntent = sub.syncMode !== 'on-demand';
return {
action: 'save',
persistMemberIntent,
record: {
id: input.contextGraphId,
name: sub.name,
subscribed: persistMemberIntent && sub.subscribed,
synced: persistMemberIntent && sub.synced,
sharedMemorySynced: persistMemberIntent ? sub.sharedMemorySynced : false,
metaSynced: persistMemberIntent ? sub.metaSynced : false,
onChainId: sub.onChainId,
onChainHash: sub.onChainHash,
lastReconciledOrdinal: sub.lastReconciledOrdinal,
coreHosted: sub.coreHosted,
syncScoped: persistMemberIntent && input.syncScoped,
},
};
}
6 changes: 4 additions & 2 deletions packages/agent/src/dkg-agent-cg-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,9 +1048,10 @@ export class ContextGraphRegistryMethods extends DKGAgentBase {
// one curator triple per node and `getContextGraphOwner`'s
// `LIMIT 1` made ownership nondeterministic — any subscriber could
// win the unordered query and look like the curator.
this.subscribeToContextGraph(opts.id);
this.subscribeToContextGraph(opts.id, { syncMode: 'always-on' });
this.setContextGraphSubscription(opts.id, {
name: opts.name,
syncMode: 'always-on',
subscribed: true,
synced: true,
metaSynced: true,
Expand Down Expand Up @@ -1108,9 +1109,10 @@ export class ContextGraphRegistryMethods extends DKGAgentBase {
this.contextGraphMetaProjection.markDirtyFromQuads(quads);
await gm.ensureNewContextGraph(opts.id);

this.subscribeToContextGraph(opts.id);
this.subscribeToContextGraph(opts.id, { syncMode: 'always-on' });
this.setContextGraphSubscription(opts.id, {
name: opts.name,
syncMode: 'always-on',
subscribed: true,
synced: true,
metaSynced: true,
Expand Down
8 changes: 6 additions & 2 deletions packages/agent/src/dkg-agent-context-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,7 @@ export class ContextGraphMethods extends DKGAgentBase {

this.setContextGraphSubscription(opts.id, {
name: opts.name,
syncMode: 'always-on',
subscribed: !opts.private,
synced: true,
metaSynced: true,
Expand Down Expand Up @@ -817,7 +818,7 @@ export class ContextGraphMethods extends DKGAgentBase {
}

if (!opts.private) {
this.subscribeToContextGraph(opts.id);
this.subscribeToContextGraph(opts.id, { syncMode: 'always-on' });

// Curated CGs: definition lives in _meta, NOT in ONTOLOGY. Do not
// broadcast to the network — only invited nodes will discover it via
Expand Down Expand Up @@ -1483,8 +1484,11 @@ export class ContextGraphMethods extends DKGAgentBase {
const next = { ...sub, onChainHash: nameHash };
this.bindSubscriptionOnChainId(id, next, onChainId);
this.setContextGraphSubscription(id, next, { persist: false });
this.subscribeToContextGraph(id, {
trackSyncScope: true,
syncMode: 'always-on',
});
if (!next.subscribed) {
this.subscribeToContextGraph(id, { trackSyncScope: true });
this.log.info(ctx, `Subscribed to newly registered context graph "${id}"`);
}
this.persistContextGraphSubscription(id);
Expand Down
91 changes: 55 additions & 36 deletions packages/agent/src/dkg-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ import {
type SyncReconcilerProbe,
type SyncReconcilerBackoff,
} from './dkg-agent-types.js';
import { projectContextGraphSubscriptionPersistence } from './context-graph-subscription-policy.js';
import {
authoritativeSyncPeerId,
resolveCuratorSyncPeer,
Expand Down Expand Up @@ -2338,6 +2339,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
// upsert a minimal stub first.
if (!this.subscribedContextGraphs.has(localId)) {
this.setContextGraphSubscription(localId, {
syncMode: 'always-on',
subscribed: false,
synced: false,
onChainHash: hashLower,
Expand Down Expand Up @@ -2674,6 +2676,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
}
const approvedSubscription: ContextGraphSub = {
...this.subscribedContextGraphs.get(contextGraphId),
syncMode: 'always-on',
subscribed: true,
pendingMeta: true,
metaSynced: false,
Expand Down Expand Up @@ -2742,6 +2745,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {
// update/finalization) wire up immediately as before.
this.subscribeToContextGraph(contextGraphId, {
deferSharedMemoryGossipSubscribe: true,
syncMode: 'always-on',
// The exact approval snapshot was committed above. Scheduling
// the ordinary background persistence here would reintroduce
// untracked writes around the compensating transaction.
Expand Down Expand Up @@ -3031,7 +3035,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {

// Subscribe to both system context graph GossipSub topics
for (const systemContextGraph of [SYSTEM_CONTEXT_GRAPHS.AGENTS, SYSTEM_CONTEXT_GRAPHS.ONTOLOGY]) {
this.subscribeToContextGraph(systemContextGraph);
this.subscribeToContextGraph(systemContextGraph, { syncMode: 'always-on' });
}

// Connect to bootstrap peers
Expand Down Expand Up @@ -6512,9 +6516,10 @@ export class LifecycleSyncMethods extends DKGAgentBase {
? this.contextGraphWireId(next.onChainHash)
: undefined;
const nextWireId = nextOnChainHash ?? localWireId;
const canonicalNext = next.onChainHash === nextOnChainHash
? next
: { ...next, onChainHash: nextOnChainHash };
const canonicalNext: ContextGraphSub = {
...next,
...(next.onChainHash === nextOnChainHash ? {} : { onChainHash: nextOnChainHash }),
};
if (
previousWireId !== nextWireId
&& this.wireIdToLocalCgId.get(previousWireId) === contextGraphId
Expand All @@ -6526,7 +6531,16 @@ export class LifecycleSyncMethods extends DKGAgentBase {
if (!canonicalNext.subscribed && !canonicalNext.coreHosted) {
this.clearVmReconcileStateForContextGraph(contextGraphId);
}
if (options?.persist !== false) {
// On-demand member subscriptions deliberately keep their live state and
// readiness process-local. A Core's independent hosting obligation is
// still durable, though: persistContextGraphSubscription writes a
// host-only snapshot without converting the member intent to always-on.
const persistence = projectContextGraphSubscriptionPersistence({
contextGraphId,
subscription: canonicalNext,
syncScoped: (this.config.syncContextGraphs ?? []).includes(contextGraphId),
});
if (options?.persist !== false && persistence.action !== 'skip') {
if (this.config.contextGraphSubscriptionStore) {
const revision = this.nextContextGraphSubscriptionPersistRevision(contextGraphId);
this.persistContextGraphSubscription(
Expand All @@ -6537,9 +6551,9 @@ export class LifecycleSyncMethods extends DKGAgentBase {
},
);
}
if (canonicalNext.subscribed) {
if (persistence.persistMemberIntent && canonicalNext.subscribed) {
this.persistLocalNodeMembership(contextGraphId);
} else {
} else if (persistence.persistMemberIntent) {
this.deleteContextGraphMember(contextGraphId, 'node', this.peerId);
}
}
Expand Down Expand Up @@ -6627,7 +6641,7 @@ export class LifecycleSyncMethods extends DKGAgentBase {

updateContextGraphSubscriptionRehydrationStatusAfterPersist(this: DKGAgent,
contextGraphId: string,
next?: ContextGraphSub,
next?: Pick<ContextGraphSubscriptionRecord, 'subscribed' | 'coreHosted'>,
): void {
const status = this.contextGraphSubscriptionRehydrationStatus;
if (!status) return;
Expand Down Expand Up @@ -6762,12 +6776,24 @@ export class LifecycleSyncMethods extends DKGAgentBase {
return;
}
const sub = this.subscribedContextGraphs.get(contextGraphId);
const persistence = projectContextGraphSubscriptionPersistence({
contextGraphId,
subscription: sub,
syncScoped: (this.config.syncContextGraphs ?? []).includes(contextGraphId),
});
if (persistence.action === 'skip') {
// Some lifecycle paths persist reconciliation watermarks directly
// instead of going through setContextGraphSubscription. Preserve the
// process-local lifetime at this lowest shared write boundary too.
this.clearContextGraphSubscriptionPersistRevisionStateIfIdle(contextGraphId);
return;
}
// Persist member subscriptions AND (Phase D) public CGs this Core hosts —
// the host-only record MUST survive restart so a Core that was offline
// during a publish remembers it hosts the CG and fills its gap. Drop the
// row only when the node neither subscribes to nor hosts the CG.
this.beginContextGraphSubscriptionPersistRevision(contextGraphId, options?.revision);
if (!sub?.subscribed && !sub?.coreHosted) {
if (persistence.action === 'delete') {
void this.enqueueContextGraphSubscriptionPersistWrite(contextGraphId, () => store.delete(contextGraphId))
.then(() => {
if (
Expand All @@ -6788,26 +6814,14 @@ export class LifecycleSyncMethods extends DKGAgentBase {
});
return;
}
const record = {
id: contextGraphId,
name: sub.name,
subscribed: sub.subscribed,
synced: sub.synced,
sharedMemorySynced: sub.sharedMemorySynced,
metaSynced: sub.metaSynced,
onChainId: sub.onChainId,
onChainHash: sub.onChainHash,
lastReconciledOrdinal: sub.lastReconciledOrdinal,
coreHosted: sub.coreHosted,
syncScoped: (this.config.syncContextGraphs ?? []).includes(contextGraphId),
};
const record = persistence.record;
void this.enqueueContextGraphSubscriptionPersistWrite(contextGraphId, () => store.save(record))
.then(() => {
if (
options?.updateRehydrationStatus === true &&
this.claimContextGraphSubscriptionPersistRevision(contextGraphId, options.revision)
) {
this.updateContextGraphSubscriptionRehydrationStatusAfterPersist(contextGraphId, sub);
this.updateContextGraphSubscriptionRehydrationStatusAfterPersist(contextGraphId, record);
}
}).catch((err) => {
this.log.warn(
Expand Down Expand Up @@ -6837,19 +6851,17 @@ export class LifecycleSyncMethods extends DKGAgentBase {
`Cannot acknowledge join approval for "${contextGraphId}": active subscription state is missing`,
);
}
const record = {
id: contextGraphId,
name: sub.name,
subscribed: sub.subscribed,
synced: sub.synced,
sharedMemorySynced: sub.sharedMemorySynced,
metaSynced: sub.metaSynced,
onChainId: sub.onChainId,
onChainHash: sub.onChainHash,
lastReconciledOrdinal: sub.lastReconciledOrdinal,
coreHosted: sub.coreHosted,
const persistence = projectContextGraphSubscriptionPersistence({
contextGraphId,
subscription: sub,
syncScoped: syncScoped ?? (this.config.syncContextGraphs ?? []).includes(contextGraphId),
};
});
if (persistence.action !== 'save' || !persistence.persistMemberIntent) {
throw new Error(
`Cannot acknowledge join approval for "${contextGraphId}": durable subscription intent is missing`,
);
}
const record = persistence.record;
// Queue behind any fire-and-forget writes scheduled by subscribe/mark so
// this final authoritative snapshot is the last write before the ACK.
await this.enqueueContextGraphSubscriptionPersistWrite(
Expand Down Expand Up @@ -7258,6 +7270,9 @@ export class LifecycleSyncMethods extends DKGAgentBase {
const restorePendingMeta = hasJoinApproval && !approvedAgentAuthorized;
this.setContextGraphSubscription(row.id, {
name: row.name,
// Every row in the durable store predates or represents explicit
// restart persistence, so absence of a mode is always-on.
syncMode: 'always-on',
subscribed: row.subscribed,
synced: restorePendingMeta ? false : row.synced,
sharedMemorySynced: restorePendingMeta ? false : row.sharedMemorySynced,
Expand All @@ -7274,7 +7289,11 @@ export class LifecycleSyncMethods extends DKGAgentBase {
this.trackSyncContextGraph(row.id);
}
if (row.subscribed) {
this.subscribeToContextGraph(row.id, { trackSyncScope: false, persist: false });
this.subscribeToContextGraph(row.id, {
trackSyncScope: false,
persist: false,
syncMode: 'always-on',
});
this.persistLocalNodeMembership(row.id, 'rehydrated-subscription');
}
// Upgrade/self-heal path for late private-CG members whose payload and
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/dkg-agent-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2495,9 +2495,9 @@ export class PublishMethods extends DKGAgentBase {
this.contextGraphMetaProjection.markDirtyFromQuads(quads);
await gm.ensureContextGraph(contextGraphId);
await this.store.flush?.();
this.subscribeToContextGraph(contextGraphId);
const promotedSub = this.subscribeToContextGraph(contextGraphId, { syncMode: 'always-on' });
this.setContextGraphSubscription(contextGraphId, {
...existingSub,
...promotedSub,
name,
subscribed: true,
synced: true,
Expand Down
10 changes: 9 additions & 1 deletion packages/agent/src/dkg-agent-swm-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,7 @@ export class SwmHostModeMethods extends DKGAgentBase {
// translate either direction without an extra RPC.
if (storageCgId !== contextGraphId) {
const storageSubscription = this.subscribedContextGraphs.get(storageCgId) ?? {
syncMode: 'always-on' as const,
subscribed: false,
synced: false,
pendingMeta: true,
Expand Down Expand Up @@ -1696,6 +1697,7 @@ export class SwmHostModeMethods extends DKGAgentBase {
// that did not create or join the CG.
if (!this.subscribedContextGraphs.has(wireId)) {
this.setContextGraphSubscription(wireId, {
syncMode: 'always-on',
subscribed: false,
synced: false,
onChainHash: wireId,
Expand Down Expand Up @@ -2448,7 +2450,13 @@ export class SwmHostModeMethods extends DKGAgentBase {
existing.coreHosted = true;
next = existing;
} else {
next = { subscribed: false, synced: false, onChainId: numericStr, coreHosted: true };
next = {
syncMode: 'always-on',
subscribed: false,
synced: false,
onChainId: numericStr,
coreHosted: true,
};
}
this.setContextGraphSubscription(localCgId, next);
this.log.info(
Expand Down
Loading
Loading