sigrank 1.0.33 → 1.0.34

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": "sigrank",
3
- "version": "1.0.33",
3
+ "version": "1.0.34",
4
4
  "author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
5
5
  "description": "Upsilon measurement engine distributed through the sigrank CLI/MCP compatibility package, with SigRank public leaderboard tools.",
6
6
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
package/submit/index.mjs CHANGED
@@ -35,6 +35,24 @@ const PLATFORM_ENUM = new Set([
35
35
  const RULESET_VERSION = "sigrank-token-1";
36
36
  const DAY_MS = 86_400_000;
37
37
 
38
+ // ─── Idempotent actuation (HRN-004 principle 8) ──────────────────────────────
39
+ // snapshot_hash is the natural idempotency key: it is the SHA-256 of the
40
+ // canonical payload bytes. A retried POST (network error, user retry) carries
41
+ // the same hash, so the server can dedup on it. The client also keeps a
42
+ // short-lived in-memory cache so a rapid double-submit never hits the network.
43
+ //
44
+ // Server contract: POST /api/v1/snapshots MUST treat snapshot_hash as an
45
+ // idempotency key. A second POST with the same hash returns 200 with the
46
+ // original result (not 201, not a duplicate row). See sigrank-app ingest gates.
47
+ const DEDUP_TTL_MS = 5 * 60_000; // 5 minutes — covers retries + brief loops
48
+ const _dedupCache = new Map(); // hash → { submittedAt, result }
49
+
50
+ function _dedupEvict(now) {
51
+ for (const [k, v] of _dedupCache) {
52
+ if (now - v.submittedAt > DEDUP_TTL_MS) _dedupCache.delete(k);
53
+ }
54
+ }
55
+
38
56
  const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
39
57
  const round = (n, dp) => {
40
58
  const f = 10 ** dp;
@@ -212,6 +230,23 @@ export async function submitSignedWindow(
212
230
  opts,
213
231
  );
214
232
 
233
+ // Idempotent actuation: if we already submitted this exact snapshot_hash
234
+ // within the dedup window, return the cached result instead of re-POSTing.
235
+ // The server also dedups on snapshot_hash, but this avoids the network round
236
+ // trip and makes the client-side retry safe. opts.force bypasses the cache
237
+ // (use when the operator explicitly wants to re-submit after a server fix).
238
+ const now = Date.now();
239
+ _dedupEvict(now);
240
+ const hash = payload.agent.snapshot_hash;
241
+ if (!opts.force && _dedupCache.has(hash)) {
242
+ const cached = _dedupCache.get(hash);
243
+ return {
244
+ ...cached.result,
245
+ idempotent: true,
246
+ detail: `Duplicate submit of snapshot ${hash} within ${DEDUP_TTL_MS / 1000}s window — returning cached result. Use force:true to bypass.`,
247
+ };
248
+ }
249
+
215
250
  // Preflight: run the same anti-gaming checks the server will run, locally.
216
251
  // If the payload would be rejected or downgraded, warn the operator BEFORE
217
252
  // submitting. The server always runs its own checks — this is a preview.
@@ -251,6 +286,7 @@ export async function submitSignedWindow(
251
286
  payload,
252
287
  signature,
253
288
  preflight: pre,
289
+ idempotencyKey: hash,
254
290
  detail:
255
291
  "By submitting, you agree to the SignalAF Terms of Service and Privacy Policy. Nothing sent. Re-run without dry_run to publish.",
256
292
  };
@@ -282,7 +318,7 @@ export async function submitSignedWindow(
282
318
  };
283
319
  }
284
320
 
285
- return {
321
+ const result = {
286
322
  status: res.ok ? ack.status || "received" : "error",
287
323
  httpStatus: res.status,
288
324
  window: WINDOW_TYPE[windowKey] || windowKey,
@@ -291,9 +327,23 @@ export async function submitSignedWindow(
291
327
  // ranked = actually on the board (verified + written), not just "received".
292
328
  // An unenrolled/revoked device gets HTTP 202 received but is NEVER ranked.
293
329
  ranked: isRankedAck(res, ack),
294
- snapshot_hash: payload.agent.snapshot_hash,
330
+ snapshot_hash: hash,
331
+ idempotencyKey: hash,
295
332
  reason: res.ok ? null : ack.reason || ack.status || `http_${res.status}`,
296
333
  detail: ack.detail ?? null,
297
334
  preflight: pre,
298
335
  };
336
+
337
+ // Cache successful and received submissions for dedup. Don't cache errors
338
+ // (network failures, preflight rejections) — those should be retried.
339
+ if (res.ok) {
340
+ _dedupCache.set(hash, { submittedAt: now, result });
341
+ }
342
+
343
+ return result;
344
+ }
345
+
346
+ // Test-only: clear the dedup cache between test cases.
347
+ export function _clearDedupCache() {
348
+ _dedupCache.clear();
299
349
  }
@@ -111,6 +111,7 @@ export async function handleSubmitVerified(args, ctx) {
111
111
  platform: "multi",
112
112
  now: ctx.opts.now,
113
113
  dryRun: !!args?.dry_run,
114
+ force: !!ctx.opts.force,
114
115
  report,
115
116
  });
116
117
  out.push({ window: wk, pillars: sum, ...r });
@@ -139,6 +140,7 @@ export async function handleSubmitVerified(args, ctx) {
139
140
  platform: pulled.platform,
140
141
  now: ctx.opts.now,
141
142
  dryRun: !!args?.dry_run,
143
+ force: !!ctx.opts.force,
142
144
  report,
143
145
  });
144
146
  out.push({ window: w.window, pillars: w.pillars, ...r });