Skip to content

Commit 3a023a1

Browse files
committed
feat(service-matcher): dedup re-registrations by (peerId, providerTag)
1 parent f38edfa commit 3a023a1

8 files changed

Lines changed: 199 additions & 3 deletions

File tree

packages/sample-services/src/echo-service/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function buildRootObject(
5656
remotableSpec,
5757
getContactUrl: () => contactUrl,
5858
expectedToken: registrationToken,
59+
providerTag: 'echo',
5960
});
6061
contactUrl = await E(services.ocapURLIssuerService).issue(contact);
6162

packages/sample-services/src/random-number-service/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function buildRootObject(
5959
remotableSpec,
6060
getContactUrl: () => contactUrl,
6161
expectedToken: registrationToken,
62+
providerTag: 'random-number',
6263
});
6364
contactUrl = await E(services.ocapURLIssuerService).issue(contact);
6465

packages/sample-services/src/vat-lib/contact-endpoint.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ import type {
3333
* `ocapURLIssuerService.issue(...)` resolves.
3434
* @param options.expectedToken - The registration token the matcher must
3535
* present to validate registration.
36+
* @param options.providerTag - The provider-local identifier for the
37+
* service. Must be unique among services hosted by this provider and
38+
* must persist across restarts of the same logical service; the matcher
39+
* uses (peerId, providerTag) as the dedup key when re-registrations
40+
* arrive.
3641
* @returns A ContactPoint exo.
3742
*/
3843
export function makeContactEndpoint(options: {
@@ -42,6 +47,7 @@ export function makeContactEndpoint(options: {
4247
remotableSpec: RemotableSpec;
4348
getContactUrl: () => string;
4449
expectedToken: string;
50+
providerTag: string;
4551
}): ContactPoint {
4652
const {
4753
name,
@@ -50,6 +56,7 @@ export function makeContactEndpoint(options: {
5056
remotableSpec,
5157
getContactUrl,
5258
expectedToken,
59+
providerTag,
5360
} = options;
5461
let consumed = false;
5562

@@ -72,6 +79,7 @@ export function makeContactEndpoint(options: {
7279
}),
7380
description,
7481
contact: harden(contact),
82+
providerTag,
7583
});
7684
},
7785

packages/service-discovery-types/docs/design.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const description: ServiceDescription = {
113113
description:
114114
'A capability that lists wallet accounts and signs personal messages.',
115115
contact: [{ contactType: 'public', contactUrl }],
116+
providerTag: 'personal-message-signer',
116117
};
117118
```
118119

packages/service-discovery-types/src/index.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ describe('ServiceDescriptionStruct', () => {
6767
apiSpec: { properties: {} },
6868
description: 'a service',
6969
contact: [{ contactType: 'public', contactUrl: 'ocap:abc@peer' }],
70+
providerTag: 'svc',
7071
};
7172
expect(is(desc, ServiceDescriptionStruct)).toBe(true);
7273
});
@@ -80,6 +81,7 @@ describe('ServiceDescriptionStruct', () => {
8081
{ contactType: 'permissioned', contactUrl: 'ocap:b@peer' },
8182
{ contactType: 'validatedClient', contactUrl: 'ocap:c@peer' },
8283
],
84+
providerTag: 'svc',
8385
};
8486
expect(is(desc, ServiceDescriptionStruct)).toBe(true);
8587
});
@@ -89,6 +91,7 @@ describe('ServiceDescriptionStruct', () => {
8991
apiSpec: { properties: {} },
9092
description: 'a service',
9193
contact: [{ contactType: 'guest', contactUrl: 'ocap:a@peer' }],
94+
providerTag: 'svc',
9295
};
9396
expect(is(bad, ServiceDescriptionStruct)).toBe(false);
9497
});
@@ -97,6 +100,16 @@ describe('ServiceDescriptionStruct', () => {
97100
const bad = {
98101
apiSpec: { properties: {} },
99102
contact: [{ contactType: 'public', contactUrl: 'ocap:a@peer' }],
103+
providerTag: 'svc',
104+
};
105+
expect(is(bad, ServiceDescriptionStruct)).toBe(false);
106+
});
107+
108+
it('rejects a missing providerTag', () => {
109+
const bad = {
110+
apiSpec: { properties: {} },
111+
description: 'a service',
112+
contact: [{ contactType: 'public', contactUrl: 'ocap:a@peer' }],
100113
};
101114
expect(is(bad, ServiceDescriptionStruct)).toBe(false);
102115
});
@@ -125,6 +138,7 @@ describe('ServiceMatchListStruct', () => {
125138
apiSpec: { properties: {} },
126139
description: 'svc',
127140
contact: [{ contactType: 'public' as const, contactUrl: 'ocap:a@p' }],
141+
providerTag: 'svc',
128142
},
129143
rationale: 'matches "sign" semantics',
130144
};

packages/service-discovery-types/src/service-description.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,23 @@ export type ServiceContactInfo = {
120120

121121
/**
122122
* The full JSON-serializable description of a service.
123+
*
124+
* `providerTag` is a stable identifier that the provider assigns to each
125+
* service it hosts. It must be unique among services hosted by the same
126+
* provider (same kernel, same libp2p peer ID) and must persist across
127+
* restarts of that service — when a provider re-registers after a kernel
128+
* restart, the matcher uses the (peerId, providerTag) pair to recognize
129+
* the new registration as a replacement for the previous one and evict
130+
* the now-stale entry. Tags don't need to be globally unique: two
131+
* unrelated providers can both use `providerTag: 'main'` without
132+
* collision because their peer IDs differ. Conventional shape is a short
133+
* lowercase kebab-case slug naming the service.
123134
*/
124135
export type ServiceDescription = {
125136
apiSpec: ObjectSpec;
126137
description: string;
127138
contact: ServiceContactInfo[];
139+
providerTag: string;
128140
};
129141

130142
// ---------------------------------------------------------------------------
@@ -207,6 +219,7 @@ export const ServiceDescriptionStruct: Struct<ServiceDescription> = object({
207219
apiSpec: ObjectSpecStruct,
208220
description: string(),
209221
contact: array(ServiceContactInfoStruct),
222+
providerTag: string(),
210223
});
211224

212225
// Compile-time assertions that the hand-written types line up with the

packages/service-matcher/src/matcher-vat/index.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ type Services = {
2727
const sampleDescription = (
2828
name = 'Signer',
2929
contactUrl = 'ocap:abc@peer',
30+
providerTag = name.toLowerCase(),
3031
): ServiceDescription => ({
3132
apiSpec: { properties: {} },
3233
description: `A service called ${name}`,
3334
contact: [{ contactType: 'public', contactUrl }],
35+
providerTag,
3436
});
3537

3638
/**
@@ -420,6 +422,7 @@ describe('matcher vat', () => {
420422
apiSpec: { properties: {} },
421423
description: 'no contact',
422424
contact: [],
425+
providerTag: 'lonely',
423426
};
424427

425428
await expect(
@@ -428,6 +431,95 @@ describe('matcher vat', () => {
428431
});
429432
});
430433

434+
describe('same-peer same-tag dedup', () => {
435+
it('evicts the previous registration when (peer, tag) matches', async () => {
436+
await root.bootstrap({}, mocks.services);
437+
const publicFacet = root.getPublicFacet();
438+
439+
// First registration: peerA + tag=signer
440+
const description1 = sampleDescription(
441+
'Signer',
442+
'ocap:key1@peerA',
443+
'signer',
444+
);
445+
const contactA = makeMockContact({
446+
description: description1,
447+
expectedToken: 'tok-A',
448+
});
449+
mocks.redeem.mockResolvedValueOnce(contactA.contact);
450+
await publicFacet.registerService(description1, 'tok-A');
451+
expect(root.listAll()).toHaveLength(1);
452+
453+
// Second registration: same peerA + same tag=signer (simulates the
454+
// same logical provider re-registering after a kernel restart with a
455+
// fresh contact endpoint).
456+
const description2 = sampleDescription(
457+
'Signer',
458+
'ocap:key2@peerA',
459+
'signer',
460+
);
461+
const contactB = makeMockContact({
462+
description: description2,
463+
expectedToken: 'tok-B',
464+
});
465+
mocks.redeem.mockResolvedValueOnce(contactB.contact);
466+
await publicFacet.registerService(description2, 'tok-B');
467+
468+
// Only the most recent (peerA, signer) registration survives.
469+
const all = root.listAll();
470+
expect(all).toHaveLength(1);
471+
expect(all[0]?.description.contact[0]?.contactUrl).toBe(
472+
'ocap:key2@peerA',
473+
);
474+
});
475+
476+
it('keeps both when same peer registers different tags', async () => {
477+
await root.bootstrap({}, mocks.services);
478+
const publicFacet = root.getPublicFacet();
479+
480+
const echo = sampleDescription('Echo', 'ocap:k1@peerA', 'echo');
481+
const echoContact = makeMockContact({
482+
description: echo,
483+
expectedToken: 'tok-A',
484+
});
485+
mocks.redeem.mockResolvedValueOnce(echoContact.contact);
486+
await publicFacet.registerService(echo, 'tok-A');
487+
488+
const random = sampleDescription('Random', 'ocap:k2@peerA', 'random');
489+
const randomContact = makeMockContact({
490+
description: random,
491+
expectedToken: 'tok-B',
492+
});
493+
mocks.redeem.mockResolvedValueOnce(randomContact.contact);
494+
await publicFacet.registerService(random, 'tok-B');
495+
496+
expect(root.listAll()).toHaveLength(2);
497+
});
498+
499+
it('keeps both when different peers share a tag', async () => {
500+
await root.bootstrap({}, mocks.services);
501+
const publicFacet = root.getPublicFacet();
502+
503+
const fromA = sampleDescription('Signer', 'ocap:k1@peerA', 'signer');
504+
const contactA = makeMockContact({
505+
description: fromA,
506+
expectedToken: 'tok-A',
507+
});
508+
mocks.redeem.mockResolvedValueOnce(contactA.contact);
509+
await publicFacet.registerService(fromA, 'tok-A');
510+
511+
const fromB = sampleDescription('Signer', 'ocap:k2@peerB', 'signer');
512+
const contactB = makeMockContact({
513+
description: fromB,
514+
expectedToken: 'tok-B',
515+
});
516+
mocks.redeem.mockResolvedValueOnce(contactB.contact);
517+
await publicFacet.registerService(fromB, 'tok-B');
518+
519+
expect(root.listAll()).toHaveLength(2);
520+
});
521+
});
522+
431523
describe('findServices', () => {
432524
it('asks the bridge and returns whatever services it cites', async () => {
433525
const llm = makeMockLlm({

packages/service-matcher/src/matcher-vat/index.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,59 @@ export function buildRootObject(
369369
return reply.matches;
370370
}
371371

372+
/**
373+
* Extract the peer ID portion of an OCAP URL.
374+
*
375+
* The URL grammar is `ocap:<oid>@<peerId>[,<relayHint>]*`. Returns
376+
* the empty string if the URL doesn't match — callers treat that as
377+
* "no peer info, can't dedup".
378+
*
379+
* @param contactUrl - The OCAP URL to parse.
380+
* @returns The peer ID, or '' if it can't be extracted.
381+
*/
382+
function peerIdFromContactUrl(contactUrl: string): string {
383+
const at = contactUrl.indexOf('@');
384+
if (at < 0) {
385+
return '';
386+
}
387+
const rest = contactUrl.slice(at + 1);
388+
const comma = rest.indexOf(',');
389+
return comma < 0 ? rest : rest.slice(0, comma);
390+
}
391+
392+
/**
393+
* Find every existing registry entry that shares (peerId, providerTag)
394+
* with the given description. Used to evict superseded entries when a
395+
* provider re-registers after a restart.
396+
*
397+
* @param description - The incoming registration's description.
398+
* @returns Array of registry ids of matching entries.
399+
*/
400+
function findSamePeerSameTagEntries(
401+
description: ServiceDescription,
402+
): string[] {
403+
const newPeerId = peerIdFromContactUrl(
404+
description.contact[0]?.contactUrl ?? '',
405+
);
406+
if (newPeerId === '') {
407+
return [];
408+
}
409+
const newTag = description.providerTag;
410+
const matches: string[] = [];
411+
for (const entry of registry.values()) {
412+
if (entry.description.providerTag !== newTag) {
413+
continue;
414+
}
415+
const existingPeerId = peerIdFromContactUrl(
416+
entry.description.contact[0]?.contactUrl ?? '',
417+
);
418+
if (existingPeerId === newPeerId) {
419+
matches.push(entry.id);
420+
}
421+
}
422+
return matches;
423+
}
424+
372425
/**
373426
* Store a service in the (in-memory) registry.
374427
*
@@ -387,9 +440,16 @@ export function buildRootObject(
387440
}
388441

389442
/**
390-
* Final step shared by all `register*` paths: store locally and tell
391-
* the bridge. If the bridge call fails, undo the local store so the
392-
* registry never contains entries the LLM doesn't know about.
443+
* Final step shared by all `register*` paths: evict any superseded
444+
* registrations, store the new entry locally, and tell the bridge. If
445+
* the bridge call fails, undo the local store so the registry never
446+
* contains entries the LLM doesn't know about.
447+
*
448+
* Eviction key is (peerId, providerTag) — see ServiceDescription's
449+
* `providerTag` for the contract. The dead `svc:N` entries that get
450+
* evicted here may still be cited by the LLM bridge's stale
451+
* conversation context; `findServices` filters those out via the
452+
* existing "bridge cited unknown id" guard.
393453
*
394454
* @param description - The validated service description.
395455
* @param contact - The validated contact endpoint.
@@ -398,6 +458,12 @@ export function buildRootObject(
398458
description: ServiceDescription,
399459
contact: ContactPoint,
400460
): Promise<void> {
461+
for (const supersededId of findSamePeerSameTagEntries(description)) {
462+
registry.delete(supersededId);
463+
log(
464+
`evicted superseded registration ${supersededId} (providerTag=${description.providerTag})`,
465+
);
466+
}
401467
const id = store(description, contact);
402468
try {
403469
await ingestService(id, description);

0 commit comments

Comments
 (0)