@mmmbuto/nexuscrew 0.9.58 → 0.9.60

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.
@@ -27,12 +27,13 @@ const OWNER_REASONS = new Set([
27
27
  'unknown', 'dismissed', 'answering', 'answered', 'store-unreadable',
28
28
  'delivery-unknown-block', 'already-delivered', 'paste-failed', 'bad-request-id',
29
29
  'no-receipts', 'request-conflict', 'receipt-cap', 'federation-peer-unreachable',
30
- 'federation-peer-timeout', 'federation-body-consumed',
30
+ 'federation-peer-timeout', 'federation-body-consumed', 'generation-mismatch', 'invalid-generation', 'unsupported',
31
31
  ]);
32
32
  function safeReason(reason) { return OWNER_REASONS.has(reason) ? reason : undefined; }
33
33
 
34
- function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot, peers = null, localPort = () => 0, localToken = () => '', nodesPath = null, slotVerifier = askSlotVerifier } = {}) {
34
+ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = () => {}, probeReverseSlotImpl = probeReverseSlot, peers = null, localPort = () => 0, localToken = () => '', nodesPath = null, availabilityBinding = () => null, slotVerifier = askSlotVerifier } = {}) {
35
35
  // (ownerId|askId|requestId) -> {state: 'sent'|'committed'|'failed'|'uncertain', requestId}
36
+ const channelObservations = new Map();
36
37
  const attempts = new Map();
37
38
  const answerFlights = new Map();
38
39
 
@@ -123,7 +124,7 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
123
124
  return `http://127.0.0.1:${port}/federation/route/_/${path.replace(/^\//, '')}`;
124
125
  }
125
126
 
126
- async function forward(candidate, path, { method = 'GET', body, op } = {}) {
127
+ async function forward(candidate, path, { method = 'GET', body, expectedTs, op } = {}) {
127
128
  op.check();
128
129
  const st = loadStore();
129
130
  slotVerifier.sync(st, proofScope);
@@ -143,7 +144,8 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
143
144
  : { authorization: `Bearer ${candidate.forwardToken}`, 'x-nexuscrew-visited': st && st.nodeId || '' };
144
145
  try {
145
146
  op.check(); op.delivered = true;
146
- const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json' },
147
+ const r = await op.wait(fetchImpl(url, { method, headers: { ...headers, 'content-type': 'application/json',
148
+ ...(expectedTs === undefined ? {} : { 'x-nexuscrew-ask-ts': String(expectedTs) }) },
147
149
  ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: op.controller.signal }));
148
150
  op.check();
149
151
  const payload = await op.wait(r.json().catch(error => {
@@ -290,7 +292,15 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
290
292
  }
291
293
 
292
294
  // Remote dismiss: same resolution rules as the answer (DELETE on the owner).
293
- async function relayDismissOnce({ ownerId, askId } = {}, op) {
295
+ function retryAfterMs(response) {
296
+ const value = response.headers?.get?.('retry-after');
297
+ if (typeof value !== 'string') return 0;
298
+ const seconds = /^\d+(?:\.\d+)?$/.test(value.trim()) ? Number(value) : null;
299
+ const delay = seconds === null ? Date.parse(value) - now() : seconds * 1000;
300
+ return Number.isFinite(delay) && delay > 0 ? delay : 0;
301
+ }
302
+
303
+ async function relayDismissOnce({ ownerId, askId, expectedTs } = {}, op) {
294
304
  const candidate = await candidateOwner(ownerId, op);
295
305
  if (!candidate) return { ok: false, code: 404, error: 'owner non tra i peer autorizzati', reason: 'owner-unknown' };
296
306
  if (candidate.kind === 'inbound-unverified') {
@@ -303,9 +313,9 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
303
313
  const peer = candidate.node;
304
314
  const store = loadStore();
305
315
  try {
306
- const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', op });
307
- if (!r.ok) return { ok: false, code: r.status, error: 'owner refused', reason: body.reason };
308
- return { ok: true };
316
+ const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}`, { method: 'DELETE', expectedTs, op });
317
+ if (!r.ok) return { ok: false, code: r.status, error: 'owner refused', reason: body.reason, retryAfterMs: retryAfterMs(r) };
318
+ return { ok: true, ...(body.outcome ? { outcome: body.outcome } : {}) };
309
319
  } catch (e) { if (!op.active()) return deadlineResult(op, { ownerId, askId }, false); return { ok: false, code: 502, reason: safeReason(e.reason), error: 'owner unreachable' }; }
310
320
  }
311
321
 
@@ -343,32 +353,72 @@ function createAskRelay({ loadStore, now = Date.now, fetchImpl = fetch, log = ()
343
353
  const denied = (status, reason) => ({ ownerId, askId, canReply: false, status, ...(reason ? { reason } : {}) });
344
354
  const candidate = await candidateOwner(ownerId, op);
345
355
  if (!candidate) return denied('unreachable', 'owner-unknown');
346
- if (candidate.kind === 'inbound-unverified') return denied('unreachable', 'reverse-slot-unverified');
356
+ if (candidate.kind === 'inbound-unverified') { op.channelUnavailable = true; return denied('unreachable', 'reverse-slot-unverified'); }
347
357
  if (candidate.kind === 'inbound') {
348
358
  const gate = await verifyInboundChannel(candidate, op);
349
- if (!gate.ok) return denied('unreachable', gate.reason);
359
+ if (!gate.ok) { op.channelUnavailable = true; return denied('unreachable', gate.reason); }
350
360
  }
351
361
  try {
352
362
  const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
353
- if (body.reason === 'reverse-slot-unverified') return denied('unreachable', body.reason);
363
+ if (['reverse-slot-unverified', 'federation-peer-unreachable', 'federation-peer-timeout'].includes(body.reason)) { op.channelUnavailable = true; return denied('unreachable', body.reason); }
354
364
  if (r.status === 404 || r.status === 501) return denied('unsupported', body.reason);
355
365
  if (r.status === 403) return denied('denied', body.reason);
356
366
  if (!r.ok || body.ownerId !== ownerId || body.askId !== askId || typeof body.canReply !== 'boolean'
357
367
  || !['open', 'denied', 'answered', 'dismissed'].includes(body.status)
358
368
  || (body.canReply && body.status !== 'open')) return denied('unreachable');
359
369
  return { ownerId, askId, canReply: body.canReply, status: body.status };
360
- } catch (_) { return denied('unreachable', op.active() ? undefined : 'relay-deadline'); }
370
+ } catch (_) { op.channelUnavailable = true; return denied('unreachable', op.active() ? undefined : 'relay-deadline'); }
371
+ }
372
+
373
+ async function inspectDismissalOnce({ ownerId, askId } = {}, op) {
374
+ const candidate = await candidateOwner(ownerId, op);
375
+ if (!candidate) return { ok: false, code: 404, reason: 'owner-unknown' };
376
+ if (candidate.kind === 'inbound-unverified') return { ok: false, code: 404, reason: 'reverse-slot-unverified' };
377
+ if (candidate.kind === 'inbound') {
378
+ const proof = await verifyInboundChannel(candidate, op);
379
+ if (!proof.ok) return proof;
380
+ }
381
+ const { r, body } = await forward(candidate, `/event-feed/asks/${encodeURIComponent(askId)}/capability`, { op });
382
+ if (!r.ok) return { ok: false, code: r.status, reason: body.reason || 'unknown', retryAfterMs: retryAfterMs(r) };
383
+ if (body.ownerId !== ownerId || body.askId !== askId || !['open', 'answered', 'dismissed'].includes(body.status)) {
384
+ return { ok: false, code: 403, reason: 'unknown' };
385
+ }
386
+ if (body.generationPrecondition !== true) return { ok: false, code: 501, reason: 'unsupported' };
387
+ const ask = body.ask;
388
+ if (!ask || ask.id !== askId || !Number.isSafeInteger(ask.ts) || ask.ts <= 0
389
+ || typeof ask.question !== 'string' || typeof ask.session !== 'string'
390
+ || (ask.options !== undefined && (!Array.isArray(ask.options) || ask.options.some(option => typeof option !== 'string')))) {
391
+ return { ok: false, code: 409, reason: 'generation-mismatch' };
392
+ }
393
+ return { ok: true, status: body.status, ask, generationPrecondition: true };
361
394
  }
395
+ function inspectDismissal(args = {}) { return operation(args, op => inspectDismissalOnce(args, op)); }
362
396
 
363
397
  function relayDismiss(args = {}) { return operation(args, op => relayDismissOnce(args, op)); }
364
398
  function verifyStatus(args = {}) { return operation(args, op => verifyStatusOnce(args, op)); }
365
- function replyCapability(args = {}) { return operation(args, op => replyCapabilityOnce(args, op), true); }
399
+ function channelBinding(ownerId) {
400
+ const store = loadStore();
401
+ // Kept only in process memory; credentials never enter the dismissal store.
402
+ return crypto.createHash('sha256').update(JSON.stringify([store, availabilityBinding(ownerId)])).digest('hex');
403
+ }
404
+ function channelState(ownerId) {
405
+ const observation = channelObservations.get(ownerId);
406
+ return { unavailable: !!(observation && now() - observation.at < 30000 && observation.binding === channelBinding(ownerId) && observation.unavailable) };
407
+ }
408
+ function replyCapability(args = {}) {
409
+ const initial = channelBinding(args.ownerId);
410
+ return operation(args, async op => {
411
+ const result = await replyCapabilityOnce(args, op);
412
+ channelObservations.set(args.ownerId, { at: now(), binding: initial, unavailable: op.channelUnavailable === true });
413
+ return result;
414
+ }, true);
415
+ }
366
416
 
367
417
  function state() {
368
418
  return [...attempts.entries()].map(([key, a]) => ({ key, ...a }));
369
419
  }
370
420
 
371
- return { relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
421
+ return { channelState, inspectDismissal, relayAnswer, relayDismiss, verifyStatus, replyCapability, state, isUncertain, requestId };
372
422
  }
373
423
 
374
424
  module.exports = { createAskRelay };
@@ -17,6 +17,22 @@ const MAX_OPEN = 100;
17
17
  // che arrivano da un peer e quelle poste da questo nodo sono due classi
18
18
  // distinte, e le prime non devono poter esaurire il budget delle seconde.
19
19
  const MAX_OPEN_IMPORTED = 100;
20
+ const MAX_IMPORTED_DISMISSALS = 1000;
21
+ const SYNC_STATES = new Set(['pending', 'confirmed-dismissed', 'confirmed-answered', 'blocked']);
22
+ function askFingerprint(ask) {
23
+ return crypto.createHash('sha256').update(JSON.stringify([String(ask.question || ''), Array.isArray(ask.options) ? ask.options : [], String(ask.session || '')])).digest('hex');
24
+ }
25
+ function validDismissals(value) {
26
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.entries(value).every(([key, r]) =>
27
+ r && key === `${r.ownerId}|${r.ownerAskId}` && /^[a-f0-9]{32}$/.test(r.ownerId) && /^[a-f0-9]{8}$/.test(r.ownerAskId)
28
+ && /^[a-f0-9]{64}$/.test(r.ownerAskFingerprint) && SYNC_STATES.has(r.syncState)
29
+ && ['known', 'unknown'].includes(r.generation)
30
+ && (r.generation === 'unknown' ? r.ownerAskTs === null : Number.isSafeInteger(r.ownerAskTs) && r.ownerAskTs > 0)
31
+ && (r.cellId === undefined || (typeof r.cellId === 'string' && /^[A-Za-z0-9._-]{1,32}$/.test(r.cellId)))
32
+ && r.dismissed === true && Number.isSafeInteger(r.attempts) && r.attempts >= 0
33
+ && Number.isFinite(r.nextRetryAt) && r.nextRetryAt >= 0
34
+ && r.dismissedReason === 'owner-unreachable' && Number.isFinite(r.dismissedTs));
35
+ }
20
36
  const MAX_KEEP = 100; // ask totali persistiti (i piu' vecchi answered si potano)
21
37
  const MAX_QUESTION = 2000;
22
38
  const MAX_OPTIONS = 8;
@@ -39,11 +55,14 @@ function createAsksStore(opts = {}) {
39
55
  // per non sovrascrivere il file malformato con un defaults vuoto, che
40
56
  // cancellerebbe le domande aperte.
41
57
  let asks = null;
58
+ let importedDismissals = null;
59
+ const dismissalCap = opts.maxImportedDismissals || MAX_IMPORTED_DISMISSALS;
42
60
  function load() {
43
61
  if (asks) return asks;
44
62
  const r = readJsonStrict(filePath);
45
- if (r.state === 'absent') { asks = []; return asks; }
46
- if (r.state === 'ok' && Array.isArray(r.value.asks)) {
63
+ if (r.state === 'absent') { asks = []; importedDismissals = {}; return asks; }
64
+ if (r.state === 'ok' && Array.isArray(r.value.asks) && (r.value.importedDismissals === undefined || validDismissals(r.value.importedDismissals))) {
65
+ importedDismissals = r.value.importedDismissals || {};
47
66
  asks = r.value.asks.filter((a) => a && typeof a === 'object' && typeof a.id === 'string');
48
67
  return asks;
49
68
  }
@@ -58,7 +77,8 @@ function createAsksStore(opts = {}) {
58
77
  if (asks) return { readable: true };
59
78
  const r = readJsonStrict(filePath);
60
79
  if (r.state === 'absent') return { readable: true };
61
- if (r.state === 'ok' && Array.isArray(r.value.asks)) return { readable: true };
80
+ if (r.state === 'ok' && Array.isArray(r.value.asks) && (r.value.importedDismissals === undefined || validDismissals(r.value.importedDismissals))) return { readable: true };
81
+ if (r.state === 'ok' && r.value.importedDismissals !== undefined && !validDismissals(r.value.importedDismissals)) return { readable: false, reason: 'malformed imported dismissals' };
62
82
  const reason = r.state === 'malformed'
63
83
  ? r.reason
64
84
  : `forma invalida: 'asks' non e' un array (${r.value && r.value.asks !== undefined ? typeof r.value.asks : 'assente'})`;
@@ -86,7 +106,7 @@ function createAsksStore(opts = {}) {
86
106
  const drop = new Set(answered.slice(0, excess).map((a) => a.id));
87
107
  asks = list.filter((a) => !drop.has(a.id));
88
108
  }
89
- atomicWriteJson(filePath, { asks: load() });
109
+ atomicWriteJson(filePath, { asks: load(), importedDismissals: importedDismissals || {} });
90
110
  }
91
111
 
92
112
  // Validazione input fail-closed. Ritorna {ok:false,error} o {ok:true,value}.
@@ -121,17 +141,28 @@ function createAsksStore(opts = {}) {
121
141
  // L'`id` locale e' nostro e all'owner non dice nulla; e' la coppia che nomina
122
142
  // lo STESSO oggetto sui due nodi, quindi e' la chiave con cui si riconcilia.
123
143
  function findImported(ownerId, ownerAskId) {
124
- return load().find((a) => isImported(a) && a.ownerId === String(ownerId)
144
+ return load().slice().reverse().find((a) => isImported(a) && a.ownerId === String(ownerId)
125
145
  && a.ownerAskId === String(ownerAskId)) || null;
126
146
  }
127
147
 
128
148
  // Chiusura dell'alias locale quando l'OWNER chiude la domanda. Durevole: la
129
149
  // riga resta nello storico marcata, quindi non ricompare a un reload e non
130
150
  // torna risponibile.
131
- function closeImported({ ownerId, ownerAskId, outcome }) {
151
+ function closeImported({ ownerId, ownerAskId, ownerAskTs, outcome }) {
132
152
  const ur = unreadableGuard();
133
153
  if (ur) return { ok: false, reason: 'store-unreadable', changed: false, ask: null };
134
154
  const ask = findImported(ownerId, ownerAskId);
155
+ const record = getImportedDismissal(ownerId, ownerAskId);
156
+ // Known closure generations cannot mutate a newer alias or dismissal receipt.
157
+ const closureTs = Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? ownerAskTs : null;
158
+ const recordMismatch = closureTs && record && record.ownerAskTs && record.ownerAskTs !== closureTs;
159
+ if ((closureTs && ask && generationTs(ask) && generationTs(ask) !== closureTs) || (!ask && recordMismatch)) {
160
+ return { ok: true, changed: false, ask: null, generationMismatch: true };
161
+ }
162
+ if (record && !recordMismatch) {
163
+ const synced = updateImportedDismissal(ownerId, ownerAskId, { syncState: outcome === 'answered' ? 'confirmed-answered' : 'confirmed-dismissed', ownerOutcome: outcome });
164
+ if (!synced.ok) return { ...synced, changed: false, ask: null };
165
+ }
135
166
  if (!ask) return { ok: true, changed: false, ask: null };
136
167
  if (ask.answered || ask.dismissed) return { ok: true, changed: false, ask };
137
168
  if (outcome === 'answered') { ask.answered = true; ask.answeredReconciled = true; }
@@ -141,7 +172,7 @@ function createAsksStore(opts = {}) {
141
172
  return { ok: true, changed: true, ask };
142
173
  }
143
174
 
144
- function create({ question, options, session, ownerId, ownerAskId, originNode, originCell }) {
175
+ function create({ question, options, session, ownerId, ownerAskId, originNode, originCell, ownerAskTs }) {
145
176
  const ur = unreadableGuard();
146
177
  if (ur) return { ok: false, reason: 'store-unreadable', error: `store illeggibile (${ur}): creazione rifiutata` };
147
178
  const v = validate({ question, options });
@@ -152,7 +183,12 @@ function createAsksStore(opts = {}) {
152
183
  // se ne crea un secondo.
153
184
  if (originNode && ownerId && ownerAskId) {
154
185
  const existing = findImported(ownerId, ownerAskId);
155
- if (existing) return { ok: true, ask: existing, deduped: true };
186
+ const incoming = { id: ownerAskId, question: v.value.question, options: v.value.options, session: String(session || ''), originNode, ownerAskTs };
187
+ if (isImportedDismissed(ownerId, incoming)) return { ok: true, ask: existing, suppressed: true, deduped: !!existing };
188
+ if (existing && askFingerprint(existing) === askFingerprint(incoming)
189
+ && (generationTs(existing) === null || generationTs(incoming) === null || generationTs(existing) === generationTs(incoming))) {
190
+ return { ok: true, ask: existing, deduped: true };
191
+ }
156
192
  }
157
193
  // Cap duro sugli aperti, SEPARATO PER CLASSE: gli importati non consumano il
158
194
  // budget dei locali. Con un cap unico, cento domande ricevute da un peer
@@ -188,6 +224,7 @@ function createAsksStore(opts = {}) {
188
224
  // ri-esportato (nessun loop A->B->A). Un ask locale non ha questo campo.
189
225
  ...(originNode ? { originNode: String(originNode) } : {}),
190
226
  ...(originCell ? { originCell: String(originCell) } : {}),
227
+ ...(Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? { ownerAskTs } : {}),
191
228
  ts: now(),
192
229
  revision: 0,
193
230
  answered: false,
@@ -208,6 +245,83 @@ function createAsksStore(opts = {}) {
208
245
  .map((a) => ({ ...a, ...(a.options ? { options: a.options.slice() } : {}) }));
209
246
  }
210
247
 
248
+ function getImportedDismissal(ownerId, askId) {
249
+ load();
250
+ const record = importedDismissals && importedDismissals[`${ownerId}|${askId}`];
251
+ return record ? { ...record } : null;
252
+ }
253
+ function listImportedDismissals() {
254
+ load();
255
+ return Object.values(importedDismissals || {}).map(r => ({ ...r }));
256
+ }
257
+ function generationTs(ask) {
258
+ const ts = Object.hasOwn(ask, 'ownerAskTs') ? ask.ownerAskTs : ask.originNode ? null : ask.ts;
259
+ return Number.isSafeInteger(ts) && ts > 0 ? ts : null;
260
+ }
261
+ function sameImportedGeneration(a, b) {
262
+ const at = generationTs(a), bt = generationTs(b);
263
+ return askFingerprint(a) === askFingerprint(b) && (at === null || bt === null || at === bt);
264
+ }
265
+ function isImportedDismissed(ownerId, ask) {
266
+ const record = getImportedDismissal(ownerId, ask.ownerAskId || ask.id);
267
+ if (!record || record.ownerAskFingerprint !== askFingerprint(ask)) return false;
268
+ // Historical imports have no original owner timestamp. Reusing an id with
269
+ // identical content remains suppressed; this is not proof of historical identity.
270
+ const ts = generationTs(ask);
271
+ return record.generation === 'unknown' || ts === null || record.ownerAskTs === ts;
272
+ }
273
+ function dismissalTransaction(change) {
274
+ const ur = unreadableGuard();
275
+ if (ur) return { ok: false, reason: 'store-unreadable' };
276
+ load();
277
+ const beforeAsks = asks.map(a => ({ ...a }));
278
+ const beforeRecords = Object.fromEntries(Object.entries(importedDismissals).map(([k, r]) => [k, { ...r }]));
279
+ try { const result = change(); if (!result.ok) return result; save(); return result; }
280
+ catch (_) { asks = beforeAsks; importedDismissals = beforeRecords; return { ok: false, reason: 'persist-failed' }; }
281
+ }
282
+ function dismissImported({ ownerId, ownerAskId, ask, cellId, retryBinding, syncState = 'pending' } = {}) {
283
+ return dismissalTransaction(() => {
284
+ if (!/^[a-f0-9]{32}$/.test(ownerId) || !/^[a-f0-9]{8}$/.test(ownerAskId) || !ask || (ask.ownerAskId || ask.id) !== ownerAskId) return { ok: false, reason: 'unknown' };
285
+ const candidate = findImported(ownerId, ownerAskId);
286
+ const alias = candidate && sameImportedGeneration(candidate, ask) ? candidate : null;
287
+ if (alias && answering.has(alias.id)) return { ok: false, reason: 'answering' };
288
+ if (ask.answered || (alias && alias.answered)) return { ok: false, reason: 'answered' };
289
+ const key = `${ownerId}|${ownerAskId}`;
290
+ const existing = importedDismissals[key];
291
+ if (existing && isImportedDismissed(ownerId, ask)) return { ok: true, record: { ...existing }, idempotent: true };
292
+ if (!existing && Object.keys(importedDismissals).length >= dismissalCap) return { ok: false, reason: 'dismissal-cap' };
293
+ const ts = generationTs(ask);
294
+ const record = { ownerId, ownerAskId, ...(cellId ? { cellId } : {}), ...(alias ? { localAliasId: alias.id } : {}),
295
+ dismissed: true, dismissedReason: 'owner-unreachable', dismissedTs: now(),
296
+ generation: ts === null ? 'unknown' : 'known', ownerAskTs: ts,
297
+ ownerAskFingerprint: askFingerprint(ask), syncState, attempts: 0, nextRetryAt: 0,
298
+ ...(syncState === 'blocked' ? { lastReason: 'permission-denied', ...(retryBinding ? { retryBinding } : {}) } : {}) };
299
+ importedDismissals[key] = record;
300
+ if (alias) {
301
+ if (!alias.dismissed) { alias.dismissed = true; alias.dismissedTs = now(); alias.revision = (alias.revision || 0) + 1; }
302
+ alias.dismissedReason = 'owner-unreachable';
303
+ }
304
+ return { ok: true, record: { ...record } };
305
+ });
306
+ }
307
+ function updateImportedDismissal(ownerId, askId, patch) {
308
+ return dismissalTransaction(() => {
309
+ const record = importedDismissals[`${ownerId}|${askId}`];
310
+ if (!record) return { ok: false, reason: 'unknown' };
311
+ const next = { ...record, ...patch };
312
+ if (!validDismissals({ [`${ownerId}|${askId}`]: next })) return { ok: false, reason: 'invalid' };
313
+ importedDismissals[`${ownerId}|${askId}`] = next;
314
+ return { ok: true, record: { ...next } };
315
+ });
316
+ }
317
+ function adoptImportedDismissal(ownerId, askId, ask) {
318
+ const record = getImportedDismissal(ownerId, askId);
319
+ const ts = generationTs(ask);
320
+ if (!record || (ask.ownerAskId || ask.id) !== askId || ts === null || record.ownerAskFingerprint !== askFingerprint(ask)
321
+ || (record.generation === 'known' && record.ownerAskTs !== ts)) return { ok: false, reason: 'generation-mismatch' };
322
+ return updateImportedDismissal(ownerId, askId, { generation: 'known', ownerAskTs: ts });
323
+ }
324
+
211
325
  // --- ciclo answer (da revisione): claim atomico open -> answering ---------------
212
326
  // Node e' single-threaded ma il paste e' un await: due answer concorrenti
213
327
  // superavano entrambe il check `answered` prima che una marcasse. Il claim
@@ -301,7 +415,7 @@ function createAsksStore(opts = {}) {
301
415
  return commit(id, text);
302
416
  }
303
417
 
304
- return { create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, health, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
418
+ return { sameImportedGeneration, dismissImported, getImportedDismissal, listImportedDismissals, isImportedDismissed, updateImportedDismissal, adoptImportedDismissal, askFingerprint, create, get, list, openCount, isImported, findImported, closeImported, claim, release, commit, markAnswered, markReconciled, isAnswering, dismiss, validate, health, filePath, MAX_OPEN, MAX_OPEN_IMPORTED };
305
419
  }
306
420
 
307
- module.exports = { createAsksStore };
421
+ module.exports = { createAsksStore, askFingerprint };
@@ -93,7 +93,7 @@ function createClosureRetryQueue({
93
93
  // riceve una seconda consegna inutile.
94
94
  const targets = [...entry.targets];
95
95
  try {
96
- results = await run({ askId: entry.askId, outcome: entry.outcome, session: entry.session, targets });
96
+ results = await run({ askId: entry.askId, ...(entry.ownerAskTs ? { ownerAskTs: entry.ownerAskTs } : {}), outcome: entry.outcome, session: entry.session, targets });
97
97
  } catch (e) {
98
98
  results = targets.map((target) => ({ target, status: 'unknown', reason: 'dispatch-threw' }));
99
99
  try { log(`chiusura ask ${entry.askId}: tentativo fallito (${String(e && e.message || e)})`); } catch (_) {}
@@ -129,11 +129,12 @@ function createClosureRetryQueue({
129
129
  // stessa chiusura UNISCE i target invece di essere buttata via: e' cio' che
130
130
  // impedisce a un peer spento di sparire dalla coda quando un ALTRO peer
131
131
  // risponde, o quando la stessa chiusura viene riprovata piu' tardi.
132
- function enqueue({ askId, outcome, session, targets } = {}) {
132
+ function enqueue({ askId, ownerAskTs, outcome, session, targets } = {}) {
133
133
  if (stopped || !askId || !outcome) return { ok: false, reason: 'invalid' };
134
134
  const nuovi = [...new Set((Array.isArray(targets) ? targets : []).map((t) => String(t)).filter(Boolean))];
135
135
  if (!nuovi.length) return { ok: false, reason: 'no-targets' };
136
- const existing = entries.find((e) => e.askId === askId && e.outcome === outcome);
136
+ const generation = Number.isSafeInteger(ownerAskTs) && ownerAskTs > 0 ? ownerAskTs : null;
137
+ const existing = entries.find((e) => e.askId === askId && e.outcome === outcome && (e.ownerAskTs || null) === generation);
137
138
  if (existing) {
138
139
  const prima = existing.targets.size;
139
140
  for (const t of nuovi) existing.targets.add(t);
@@ -148,7 +149,7 @@ function createClosureRetryQueue({
148
149
  try { log(`coda chiusure piena (${maxEntries}): scartata la piu' vecchia (${oldest.askId})`); } catch (_) {}
149
150
  }
150
151
  const entry = {
151
- askId, outcome, session, targets: new Set(nuovi),
152
+ askId, ...(generation ? { ownerAskTs: generation } : {}), outcome, session, targets: new Set(nuovi),
152
153
  attempts: 0, createdAt: now(), nextAt: 0, timer: null, inFlight: false,
153
154
  };
154
155
  entries.push(entry);
@@ -102,7 +102,9 @@ function createEventFeedAsksRoutes(deps) {
102
102
  const readonly = deps.readonly && deps.readonly();
103
103
  const status = !resolved || readonly ? 'denied'
104
104
  : resolved.ask.dismissed ? 'dismissed' : resolved.ask.answered ? 'answered' : 'open';
105
- return res.json({ ownerId: g.self, askId, canReply: status === 'open', status });
105
+ return res.json({ ownerId: g.self, askId, canReply: status === 'open', status,
106
+ ...(resolved && !readonly ? { generationPrecondition: true, ask: { id: resolved.ask.id, ts: resolved.ask.ts, question: resolved.ask.question,
107
+ ...(resolved.ask.options ? { options: resolved.ask.options } : {}), session: resolved.ask.session } } : {}) });
106
108
  });
107
109
 
108
110
  r.post('/:id/answer', json, async (req, res) => {
@@ -157,15 +159,26 @@ function createEventFeedAsksRoutes(deps) {
157
159
  const askId = String(req.params.id || '');
158
160
  const resolved = resolveAskForPeer(g.peer, askId);
159
161
  if (!resolved) return res.status(404).json({ error: 'ask inesistente' });
160
- // Local answer in flight wins: 409, and a dismissed ask is not answerable.
162
+ const expected = req.headers['x-nexuscrew-ask-ts'];
163
+ if (expected !== undefined && (typeof expected !== 'string' || !/^[1-9][0-9]{0,15}$/.test(expected)
164
+ || !Number.isSafeInteger(Number(expected)))) {
165
+ return res.status(400).json({ error: 'invalid generation precondition', reason: 'invalid-generation' });
166
+ }
167
+ // Keep the generation and terminal guards next to the synchronous service:
168
+ // authorization may await, but no yield is allowed between this check and dismiss.
169
+ if (expected !== undefined && resolved.ask.ts !== Number(expected)) {
170
+ return res.status(409).json({ error: 'ask generation changed', reason: 'generation-mismatch' });
171
+ }
172
+ if (resolved.ask.answered) return res.json({ dismissed: false, id: askId, outcome: 'answered', idempotent: true });
173
+ if (resolved.ask.dismissed) return res.json({ dismissed: true, id: askId, outcome: 'dismissed', idempotent: true });
161
174
  const out = deps.answerService.dismiss(askId);
162
175
  if (!out.ok) {
163
176
  if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
164
- if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
165
- return res.status(500).json({ error: 'dismiss non riuscito' });
177
+ if (['answering', 'delivery-unknown-block'].includes(out.reason)) return res.status(409).json({ error: out.error || 'ask cannot be dismissed', reason: out.reason });
178
+ return res.status(out.code || 500).json({ error: out.error || 'dismiss failed', reason: out.reason });
166
179
  }
167
180
  if (out.closure) { try { await out.closure; } catch (_) {} }
168
- return res.json({ dismissed: true, id: askId, idempotent: out.idempotent === true });
181
+ return res.json({ dismissed: true, id: askId, outcome: 'dismissed', idempotent: out.idempotent === true });
169
182
  });
170
183
 
171
184
  r.get('/:id/requests/:requestId', async (req, res) => {