@opennodes/registry 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennodes/registry",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "OpenNodes (ONP) reference registry: node registration with proof-of-resource qualification (Stage A/B/C), transparent ranking, enforceable estimates, catalog importers (OpenRouter, Hugging Face, models.dev, Ollama), MCP surface, and the web app (catalog, chat, advisor, Node Builder)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/server.js CHANGED
@@ -100,6 +100,22 @@ export async function startRegistry({
100
100
  return { card, sigOk };
101
101
  }
102
102
 
103
+ /**
104
+ * Re-index a registered node's card when its origin serves a newer revision (restart,
105
+ * reprice, config change). Probes pin the revision they hold; without this a restarted
106
+ * node answers 409 to every probe and would look like an identity mismatch.
107
+ */
108
+ async function refreshCard(nodeId) {
109
+ try {
110
+ const node = await store.getNode(nodeId);
111
+ if (!node?.card_url || node.source === 'import') return;
112
+ const current = await store.latestCard(nodeId);
113
+ const fresh = await fetchCard(node.card_url);
114
+ if (current && fresh.revision === current.revision) return;
115
+ await indexCard(nodeId, node.card_url);
116
+ } catch { /* unreachable or invalid: probes will record the failure themselves */ }
117
+ }
118
+
103
119
  const app = createApp([
104
120
  ['POST', '/v0/nodes', async (req, res) => {
105
121
  const { card_url } = await readJson(req);
@@ -172,8 +188,12 @@ export async function startRegistry({
172
188
  if (node.state !== 'community' && node.state !== 'verified') {
173
189
  return problem(res, 409, 'wrong-state', `Stage B requires community tier, node is ${node.state}`);
174
190
  }
191
+ await refreshCard(params.id);
175
192
  const card = await store.latestCard(params.id);
176
193
  const stageB = await runStageB(store, params.id, card, { safeFetch, capacity: stageBCapacity });
194
+ if (stageB.stale) {
195
+ return problem(res, 409, 'stale-card', 'the node changed its card revision during the battery; retry');
196
+ }
177
197
  if (stageB.identityFailed) {
178
198
  await store.setState(params.id, 'disputed', 'Stage B identity fingerprint mismatch');
179
199
  } else if (stageB.ok) {
@@ -372,7 +392,7 @@ export async function startRegistry({
372
392
  ...exportRoutes(store, registryKeys),
373
393
  ], { cors: true }); // public read API: third-party web clients may call it directly
374
394
 
375
- const stageCHandle = stageC ? startStageC(store, { safeFetch, ...stageC }) : null;
395
+ const stageCHandle = stageC ? startStageC(store, { safeFetch, refreshCard, ...stageC }) : null;
376
396
  const reimportHandle = reimport ? startReimport(store, { safeFetch, ...reimport }) : null;
377
397
  const actualPort = await listen(app, port, host);
378
398
  return {
package/src/stageb.js CHANGED
@@ -52,13 +52,14 @@ export async function captureFingerprints(safeFetch, card, offering) {
52
52
 
53
53
  /** DS-ADM-02: identity check against stored references. */
54
54
  async function probeIdentity(safeFetch, card, offering, refs) {
55
- let matches = 0;
55
+ let matches = 0, stale = false;
56
56
  for (const ref of refs) {
57
57
  const { status, text } = await chatOnce(safeFetch, card, offering, ref.prompt);
58
+ if (status === 409) stale = true; // pinned revision is no longer current: not evidence about identity
58
59
  if (status === 200 && text !== null && sha256(text) === ref.expected_hash) matches++;
59
60
  }
60
61
  // Deterministic battery: require all prompts to match the reference distribution.
61
- return { ok: matches === refs.length, matches, total: refs.length };
62
+ return { ok: matches === refs.length, matches, total: refs.length, ...(stale ? { stale: true } : {}) };
62
63
  }
63
64
 
64
65
  /** A needle counts as retrieved if the reply carries the full token or just its digits (small models drop the prefix). */
@@ -145,6 +146,10 @@ export async function runStageB(store, nodeId, card, { safeFetch, capacity = {}
145
146
  const refs = await store.getFingerprints(offering.model.artifact);
146
147
  if (refs.length) {
147
148
  verdict.identity = await probeIdentity(safeFetch, card, offering, refs);
149
+ if (verdict.identity.stale) {
150
+ // The node rejected our pinned revision (409). Abort without judging: the caller re-indexes and retries.
151
+ return { ok: false, identityFailed: false, stale: true, verdicts: [...verdicts, verdict] };
152
+ }
148
153
  await store.recordProbe(nodeId, offering.offering_id, 'B', 'identity', verdict.identity.ok,
149
154
  `${verdict.identity.matches}/${verdict.identity.total} reference matches`);
150
155
  } else {
package/src/stagec.js CHANGED
@@ -9,6 +9,7 @@ const OBSERVED_STATES = ['community', 'verified', 'attested'];
9
9
 
10
10
  export function startStageC(store, {
11
11
  safeFetch,
12
+ refreshCard = null, // re-index a node's card when its revision moved (restart / reprice)
12
13
  livenessIntervalMs = 300_000, // spec: <=5 min; tests shorten this
13
14
  blindIntervalMs = 3_600_000,
14
15
  failThreshold = 3,
@@ -47,6 +48,7 @@ export function startStageC(store, {
47
48
  async function blindSweep() {
48
49
  for (const node of await store.allNodes()) {
49
50
  if (!OBSERVED_STATES.includes(node.state)) continue;
51
+ await refreshCard?.(node.id);
50
52
  const card = await store.latestCard(node.id);
51
53
  if (!card) continue;
52
54
  const chatOfferings = card.offerings.filter((o) => o.binding.profile === 'onp.openai.chat/v1');
@@ -66,6 +68,7 @@ export function startStageC(store, {
66
68
  max_tokens: 32,
67
69
  }),
68
70
  }, 30_000);
71
+ if (status === 409) { await refreshCard?.(node.id); continue; } // stale pin, not a failure of the node
69
72
  const usage = body?.usage;
70
73
  const ok = status === 200 && Number.isFinite(usage?.prompt_tokens) && Number.isFinite(usage?.completion_tokens);
71
74
  await store.recordProbe(node.id, offering.offering_id, 'C', 'blind', ok,