@aria-framework/ask 0.1.2 → 0.2.0

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/lib/check.js CHANGED
@@ -33,6 +33,22 @@
33
33
  * - claims something was done, asks the person to sign in again, speaks of another partner, or talks about its
34
34
  * instructions or the document.
35
35
  *
36
+ * CLAIMS (0.2.0, every answer): a VERDICT (compromised, malicious, benign, safe, a false positive, "no action needed" …) is
37
+ * refused unless an APP-WRITTEN field of a cited result says it (`unsupported_verdict`); an ALL/NONE claim about a state
38
+ * ("all of them are resolved", "none are open") is refused unless every row of the cited result shows it — and the result
39
+ * returned all its rows (`sweeping_claim`).
40
+ *
41
+ * ANALYSE MODE (0.2.0, `checkAnswer(reply, results, { mode: 'analyse', question })` — the plan's intent was analyse): the
42
+ * answer may group, compare and conclude, and is held to, per sentence:
43
+ * - it cites at least one result as [rN] (`uncited_sentence`), and every placeholder in it names a result it cites
44
+ * (`uncited_figure`);
45
+ * - a conclusion ("suggests", "likely", "therefore", "overall" …) says it comes from the results ("the results show",
46
+ * "based on these results") (`unframed_conclusion`);
47
+ * - every capitalised name in it is a word of the results it cites or of the question (`unknown_name`) — no new entity;
48
+ * and, over the whole answer, every typed id, host, value or number is one the CITED results returned (the sayable set is
49
+ * built from the cited results only). The obeyed phrasings (claimed actions, sign-in, other partners, instructions) apply
50
+ * as in every answer.
51
+ *
36
52
  * THIS IS A FLOOR, NOT A PROOF. It stops the obeyed shapes the tests name and the bench measures. What it guarantees is
37
53
  * that Ask cannot act, cannot invent a figure the page draws, and cannot link anywhere.
38
54
  */
@@ -59,17 +75,27 @@ const LINKISH = /(https?:|ftp:|mailto:|javascript:|data:|file:|www\.|<\s*[a-z!/]
59
75
  // open" and "Shell: bash" are prose (a space follows the colon).
60
76
  const DANGEROUS_SCHEME = /(?<![\p{L}\p{N}-])(?:ms-[a-z-]+|search-ms|smb|cifs|nfs|afp|webdav|tel|sip|sips|callto|skype|msteams|zoommtg|zoomus|vnc|rdp|ssh|telnet|sftp|ldaps?|vbscript|mhtml|mk|its|jar|shell|intent|vscode|microsoft-edge|onenote|steam):(?=\S)/iu;
61
77
  const DOMAINISH = /(?<![\p{L}\p{N}])[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}(?:\/[^\s]*)?(?![\p{L}\p{N}])/gu;
78
+ // A SNIPPET (0.2.0, lib/text.js) is attacker text on any path ending in .snippet, whether or not the tool lists it.
79
+ const SNIPPET = /(?:^|\.)snippet$/;
62
80
  const INTERNAL_SUFFIX = /\.(local|lan|internal|corp|home\.arpa|localdomain)$/i;
63
81
  const NUMBER_WORD = /\b(zero|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|hundreds?|thousands?|millions?|billions?|dozens?|twice|thrice)\b/i;
64
82
  // What an obeyed instruction says — SCOPED to that, not to the vocabulary a security answer is made of ("sign-in attempts",
65
83
  // "password spray"). Widened in review L of PR #9: up to two adverbs ("I have now closed it"), "was closed by me", "log
66
- // back in", "re-enter it at the login page", other tenants or resellers, "my rules".
84
+ // back in", "re-enter it at the login page", other tenants or resellers, "my rules". 0.2.0 (the Noc adoption): the network and
85
+ // device verbs — restarted, rebooted, muted, paused, (un)blocked, isolated, quarantined, disconnected, shut down, reset,
86
+ // acknowledged — in the same first-person, "has been … as/by" and "… by me/us" shapes. A device that "restarted at 03:10"
87
+ // in the data is not a claim.
67
88
  const OBEYED = [
68
89
  // "I/we" and the verb with up to two words between ("I have now closed", "we've quietly removed", "I have't yet sent").
69
- [/\b(i|we)\s+(?:[\p{L}'’]+\s+){0,2}(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped)\b/iu, 'claims to have done something', 'claims_action'],
70
- [/\b(has|have)\s+been\s+(closed|deleted|removed|resolved|approved|disabled|marked|purged|wiped)\s+(as|by)\b/i, 'claims something was done', 'claims_action'],
90
+ // An OBSERVATION ("we saw it restarted", "I noticed it was blocked") is not a claim: no seeing verb between (final round).
91
+ [/\b(i|we)\s+(?:(?!(?:saw|see|sees|seen|noticed|notice|found|find|observed|observe|watched|spotted)\b)[\p{L}'’]+\s+){0,2}(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\b/iu, 'claims to have done something', 'claims_action'],
92
+ [/\b(has|have)\s+been\s+(closed|deleted|removed|resolved|approved|disabled|marked|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\s+(as|by)\b/i, 'claims something was done', 'claims_action'],
93
+ // Done FOR the person ("The firewall was restarted for you"), and "Done:" with a change after it ("Done: the host is
94
+ // now isolated") — final round of PR #14.
95
+ [/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down)\s+for\s+you\b/i, 'claims something was done for the person', 'claims_action'],
96
+ [/\bdone\s*[:!—–-]\s*[^.]{0,80}?\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down)\b/i, 'claims something was done', 'claims_action'],
71
97
  // The verb "by me / us", however it is introduced ("closed by me", "It was resolved by us").
72
- [/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
98
+ [/\b(closed|deleted|removed|assigned|resolved|approved|disabled|enabled|changed|updated|sent|marked|escalated|acknowledged|archived|reset|purged|wiped|restarted|rebooted|muted|unmuted|paused|resumed|blocked|unblocked|isolated|quarantined|disconnected|shut\s+down|reset|acknowledged)\s+by\s+(me|us|the\s+assistant)\b/i, 'claims something was done', 'claims_action'],
73
99
  [/\b(were|was|been)\s+(deleted|removed|wiped|purged|erased)\b/i, 'claims something was deleted', 'claims_action'],
74
100
  [/\b(sign|log)\s?-?\s?(in|on)\s+(again|at|here|now|to\s+continue|to\s+restore)\b|\b(sign|log)\s+back\s+(in|on)\b|\b(enter|re-?enter|confirm|reset|verify)\s+your\s+(password|credentials?|login)\b|\bre-?enter\b[^.]{0,40}\b(log-?in|sign-?in)\s+page\b|\bsession\s+(has\s+)?expired\b/i, 'sends the person to sign in', 'sign_in'],
75
101
  [/\b(other|another)\s+(partners?|tenants?|resellers?)\b|\bevery\s+other\s+(partner|tenant)\b/i, 'speaks of another partner', 'other_partner'],
@@ -102,7 +128,9 @@ const readable = (t) => String(t).normalize('NFKC').replace(INVISIBLE, '').repla
102
128
 
103
129
  /** Every refusal code checkAnswer can return. */
104
130
  const REFUSAL_CODES = Object.freeze(['shape', 'control_chars', 'unknown_cite', 'unknown_figure', 'bad_placeholder', 'link', 'number_word', 'claims_action',
105
- 'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char']);
131
+ 'sign_in', 'other_partner', 'instructions', 'unknown_ref', 'typed_number', 'unknown_token', 'disallowed_char',
132
+ // 0.2.0: claims (every answer) and the analyse rules.
133
+ 'unsupported_verdict', 'sweeping_claim', 'uncited_sentence', 'uncited_figure', 'unframed_conclusion', 'unknown_name']);
106
134
 
107
135
  /**
108
136
  * THE MODEL'S OWN TEXT IS ALLOW-LISTED (the coordinator's round on PR #9, the same list as the soc AI-brief check): after
@@ -161,11 +189,13 @@ function verbatimOk(v, fromAttacker, sayIps) {
161
189
  // ATTACKER TEXT: no path ("/" never; "\" only as DOMAIN\user), no IP unless the app says IPs, a letter first, no ref, and
162
190
  // every dotted part an internal host — the attacker-path host rule, read on the value's parts.
163
191
  if (v.includes('/') || (v.includes('\\') && !DOMAIN_USER.test(v))) return false;
164
- if (!sayIps && IP_PART.test(v)) return false;
192
+ // AN IP INSIDE AN ADDRESS SHAPE is refused whatever sayIps says (0.2.0, from the Soc events.search adoption): sayIps
193
+ // means a plain IP from an IP field may be said (allowedTokens' ips), never one embedded in an @-, \- or bracketed value.
194
+ if (IP_PART.test(v)) return false;
165
195
  // A numeric host after "@" — decimal or hex (admin@3405803781, admin@0xCB007105) is an IP too (round 3 of PR #12).
166
196
  if (/@(?:\d+|0x[0-9a-f]+)(?![\p{L}\p{N}_-])/iu.test(v)) return false;
167
197
  if (!/^\p{L}/u.test(v) || (v.match(REF_TOKEN) || []).length) return false;
168
- for (const m of v.matchAll(/[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu)) if (!INTERNAL_SUFFIX.test(m[0]) && !(sayIps && IPV4.test(m[0]))) return false;
198
+ for (const m of v.matchAll(/[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+/gu)) if (!INTERNAL_SUFFIX.test(m[0])) return false;
169
199
  return true;
170
200
  }
171
201
  const escapeRe = (t) => t.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
@@ -201,6 +231,115 @@ const IDIOM_BEFORE = /^(?:windows|win|office|ios|android|iphone|pixel|galaxy|top
201
231
 
202
232
  const trim = (t) => String(t).replace(/^[.:\-/]+|[.:\-/]+$/g, '');
203
233
 
234
+ // ── 0.2.0: CLAIMS AND THE ANALYSE RULES ──────────────────────────────────────────────────────────────────────────────
235
+ // THE WORD LISTS ARE A FLOOR, NOT A PROOF: a verdict or an all/none claim in words no list names passes the check. The
236
+ // bench measures what slips through; review rounds widen the lists (fix round of PR #14: clean, legitimate, secure, no
237
+ // threat, not a concern, a breach occurred, was owned; no … are, there are no …, all … resolved, got, have all been).
238
+ /**
239
+ * A verdict the model may not reach on its own: it must be what an app-written text of the ROW the sentence names says
240
+ * (or, naming none, of the result itself or of every cited row).
241
+ */
242
+ const VERDICT = /\b(compromised|breached|hacked|infected|malicious|benign|harmless|clean|legitimate|secure|false[\s_-]+positives?|true[\s_-]+positives?|under\s+attack|exfiltrated|safe|(?:was|been|got)\s+owned|(?:a\s+)?breach\s+(?:occurred|happened)|no\s+threats?|not\s+a\s+(?:threat|concern)|nothing\s+suspicious|expected\s+activity|nothing\s+to\s+worry\s+about|no\s+cause\s+for\s+concern|no\s+(?:further\s+)?action\s+(?:is\s+)?(?:needed|required))\b/giu;
243
+ /**
244
+ * "ALL / NONE OF THEM ARE <state>" (review rounds of PR #14). A QUANTIFIER — all, every, each, both, everything, they,
245
+ * none, nothing, not one, no <noun>; or "not all" (a PARTIAL claim: at least one row is not in the state) — then, within
246
+ * six words, a COPULA (is, are, was, were, has/have been, remain(s), stay(s), got, became) and its COMPLEMENT: the state
247
+ * is the copula's complement ("every record from the closed quarter is OPEN"), never any word in between, so a state
248
+ * word inside a qualifier ("assigned to jdoe") is not the claim. The complement may be negated ("no longer open", "not
249
+ * open", "unresolved"), which flips the claim. Also: "all records resolved" (no copula, the state ending the clause),
250
+ * "there are no open records", "no open records remain". The state vocabulary is STATE_WORDS plus every state and
251
+ * synonym the cited tools declare. A claim is decided ONLY from the declared field (`stateFields`); a word no cited tool
252
+ * declares is refused. A qualifier between the quantifier and the copula narrows the set; the claim is evaluated over
253
+ * EVERY cited row — sound for all/none (true of all rows → true of any subset), and otherwise refused.
254
+ */
255
+ const STATE_WORDS = Object.freeze(['resolved', 'closed', 'fixed', 'solved', 'handled', 'complete', 'completed', 'healthy', 'reporting', 'online', 'offline',
256
+ 'patched', 'acknowledged', 'assigned', 'unassigned', 'open', 'failed', 'down', 'benign', 'malicious', 'compromised', 'safe', 'pending', 'active', 'inactive',
257
+ 'escalated', 'expired', 'overdue', 'muted', 'paused', 'blocked', 'isolated', 'enabled', 'disabled', 'stale', 'running', 'stopped', 'unresolved']);
258
+ const SWEEP_QUANT = /\b(not\s+all|all|every|each|both|everything|they|none|nothing|not\s+one|not\s+a\s+single|no\s+longer\s+any|neither|no(?!\s+(?:one|longer|doubt|question)\b))\b/giu;
259
+ const SWEEP_COPULA = /^(?:are|is|were|was|have\s+been|has\s+been|(?:continues?|continued|remains?|remained)\s+to\s+be|remain|remains|stay|stays|got|get|became|become)\b/iu;
260
+ const SWEEP_ADVERB = /^(?:now|still|already|currently|all|both|each|also|again)\b\s*/iu;
261
+ const SWEEP_NEGATION = /^(?:no\s+longer|not|never)\b\s*/iu;
262
+ /** The all/none/not-all claims in one flattened sentence: [{ word, polarity: 'all' | 'none' | 'notall' }]. */
263
+ function sweepsOf(lower, vocab) {
264
+ const isState = (w) => vocab.has(w) || (w.startsWith('un') && vocab.has(w.slice(2)));
265
+ const out = [];
266
+ const flip = (p) => (p === 'all' ? 'none' : p === 'none' ? 'all' : 'notall');
267
+ for (const q of lower.matchAll(SWEEP_QUANT)) {
268
+ const qw = q[1].replace(/\s+/g, ' ');
269
+ let polarity = qw === 'not all' ? 'notall' : ['all', 'every', 'each', 'both', 'everything', 'they'].includes(qw) ? 'all' : 'none';
270
+ const rest = lower.slice(q.index + q[0].length).split(/[.!?;,]/)[0];
271
+ const words = rest.trim().split(/\s+/).filter(Boolean);
272
+ let found = false;
273
+ // <quantifier> … <copula> <complement>, the copula within six words.
274
+ for (let i = 0; i <= Math.min(6, words.length - 1) && !found; i += 1) {
275
+ const tail = words.slice(i).join(' ');
276
+ const cop = SWEEP_COPULA.exec(tail);
277
+ if (!cop) continue;
278
+ let comp = tail.slice(cop[0].length).trim();
279
+ while (SWEEP_ADVERB.test(comp)) comp = comp.replace(SWEEP_ADVERB, '');
280
+ let p = polarity;
281
+ const neg = SWEEP_NEGATION.exec(comp);
282
+ if (neg) { comp = comp.slice(neg[0].length); p = flip(p); }
283
+ const w = (comp.split(/\s+/)[0] || '').toLowerCase();
284
+ if (isState(w)) {
285
+ out.push(vocab.has(w) ? { word: w, polarity: p } : { word: w.slice(2), polarity: flip(p) });
286
+ found = true;
287
+ } else if (cop) break; // the first copula's complement is not a state: no claim from this quantifier
288
+ }
289
+ // "all records resolved": no copula, the state ends the clause.
290
+ if (!found && polarity === 'all' && qw !== 'they') {
291
+ const m = /^((?:[\p{L}'’]+\s+){0,3})([\p{L}]+)\s*(?:now\s*)?$/u.exec(rest.trim());
292
+ if (m && isState(m[2]) && !SWEEP_COPULA.test(m[1])) out.push(vocab.has(m[2]) ? { word: m[2], polarity } : { word: m[2].slice(2), polarity: flip(polarity) });
293
+ }
294
+ }
295
+ // "there are no <state> …", "no <state> <noun> remain(s) / exist(s) / are left".
296
+ for (const m of lower.matchAll(/\bthere\s+(?:are|is|were|was)\s+(?:no|not\s+any)\s+([\p{L}]+)/giu)) if (isState(m[1])) out.push({ word: m[1], polarity: 'none' });
297
+ for (const m of lower.matchAll(/\bno\s+([\p{L}]+)\s+[\p{L}]+\s+(?:remain|remains|exist|exists|are\s+left|is\s+left)\b/giu)) if (isState(m[1])) out.push({ word: m[1], polarity: 'none' });
298
+ return out;
299
+ }
300
+ /** A conclusion, and the words that frame it as the results'. */
301
+ const CONCLUSION = /\b(therefore|thus|hence|overall|suggests?|suggesting|indicates?|indicating|implies|implying|points?\s+to|likely|unlikely|probably|appears?\s+to|seems?\s+to|conclude[sd]?|conclusion|taken\s+together|clearly|evidently|must\s+be)\b/i;
302
+ const FRAME = /\b(?:the|these|those|both|its|their)\s+(?:cited\s+)?(?:results?|look[\s-]?ups?|figures|records|rows|counts?)\b|\b(?:according\s+to|based\s+on|from|in)\s+(?:the\s+|these\s+)?(?:cited\s+)?(?:results?|look[\s-]?ups?)\b/i;
303
+ /** A capitalised word: a name, unless it is an ordinary word (NAME_COMMON) or a word of the results or the question. */
304
+ const CAPITALISED = /(?<![\p{L}\p{N}_'’-])\p{Lu}[\p{L}\p{N}'’-]*/gu;
305
+ const NAME_COMMON = new Set(('a an the this that these those there here it its they their them we our you your i he she his her one both each every '
306
+ + 'all any some most many more few fewer less least several no none nothing neither nor either not only just also and or but so yet '
307
+ + 'if then than as at by for from in into of on onto to with within without across between among about after before since until '
308
+ + 'while when where which who whom whose what why how although though because however whereas meanwhile otherwise still therefore '
309
+ + 'thus hence overall together taken compared comparing based according looking grouped group by first second third next finally last '
310
+ + 'yes today tonight yesterday currently right now recently lately earlier later open new is are was were be been has have had do does '
311
+ + 'did can could may might must should would will shall per summary status partner partners customer customers result results '
312
+ + 'look lookup lookups record records row rows figure figures count counts monday tuesday wednesday thursday friday saturday sunday '
313
+ + 'january february march april may june july august september october november december '
314
+ // Discourse adverbs (fix round of PR #14).
315
+ + 'similarly additionally notably moreover furthermore likewise conversely instead again specifically particularly generally '
316
+ + 'consequently accordingly nonetheless nevertheless besides alternatively importantly interestingly unlike like contrast').split(' '));
317
+ const wordsOf = (t) => String(t || '').normalize('NFKC').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
318
+ /**
319
+ * The answer's sentences: split after . ! ? and at newlines. Cites opening a piece ("open. [r1] The …") belong to the
320
+ * sentence before; a piece with no letter (a lone cite) joins the one before.
321
+ */
322
+ function sentencesOf(answer) {
323
+ const out = [];
324
+ for (let piece of String(answer).split(/(?<=[.!?])\s+|\n+/)) {
325
+ if (!piece.trim()) continue;
326
+ const lead = out.length ? /^(?:\s*\[r[1-4]\])+/.exec(piece) : null;
327
+ if (lead) { out[out.length - 1] += lead[0]; piece = piece.slice(lead[0].length); if (!piece.trim()) continue; }
328
+ const letters = /\p{L}/u.test(piece.replace(PLACEHOLDER, ' ').replace(CITE, ' '));
329
+ if (!letters && out.length) out[out.length - 1] += ` ${piece}`; else out.push(piece);
330
+ }
331
+ return out;
332
+ }
333
+ /** Lower-cased, with spaces, hyphens and underscores folded to one space ("false_positive" reads "false positive"). */
334
+ const flat = (t) => readable(t).toLowerCase().replace(/[\s_-]+/g, ' ').trim();
335
+ const hasPhrase = (text, phrase) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRe(phrase)}(?![\\p{L}\\p{N}])`, 'u').test(text);
336
+ /** Does this row's value of a declared state field show the state? (strings case-blind, booleans exactly) — undefined when the row has no value. */
337
+ function stateOf(row, def) {
338
+ const v = (row.fields || {})[def.field];
339
+ if (v === undefined || v === null || v === '') return undefined;
340
+ return def.values.some((x) => (typeof x === 'boolean' ? v === x : typeof v === 'string' && v.trim().toLowerCase() === String(x).toLowerCase()));
341
+ }
342
+
204
343
  /** Every string of a result with its path in attackerFields' spelling (rows[].fields.entity, facts.entities[].value). */
205
344
  function stringsOf(r) {
206
345
  const out = [];
@@ -273,6 +412,8 @@ function createCheck(o = {}) {
273
412
  const rowChipLabel = typeof o.rowChipLabel === 'function' ? o.rowChipLabel : DEFAULT_ROW_CHIP;
274
413
  const sayIps = o.sayIps === true;
275
414
  const sayWindows = o.sayWindows === true;
415
+ // KNOWN NAMES (0.2.0): words the app says are ordinary in its answers though no result holds them (a product, "Sophos").
416
+ const knownNames = Object.freeze([...(o.knownNames || [])].map((n) => { if (typeof n !== 'string' || !n.trim()) throw new Error('ask check: knownNames is a list of names'); return n; }).flatMap((n) => wordsOf(n)));
276
417
  // VERBATIM PATHS (0.1.2): a fact or a row field, in attackerFields' spelling — never a title, a label or a ref.
277
418
  const verbatimFields = Object.freeze([...(o.verbatimFields || [])]);
278
419
  for (const f of verbatimFields) {
@@ -313,7 +454,7 @@ function createCheck(o = {}) {
313
454
  for (const k of numericStructured) numeric((r.facts || {})[k]);
314
455
  for (const k of idFacts) own((r.facts || {})[k]);
315
456
  for (const [path, v] of stringsOf(r)) {
316
- const fromAttacker = remote || attacker.has(path);
457
+ const fromAttacker = remote || attacker.has(path) || SNIPPET.test(path);
317
458
  for (const w of readable(v).split(/[\s,;()"'“”‘’!?]+/)) {
318
459
  const t = trim(w);
319
460
  if (!t) continue;
@@ -344,7 +485,7 @@ function createCheck(o = {}) {
344
485
  const tool = r && r.tool ? toolOf(r.tool) : null;
345
486
  const remote = !tool || tool.kind === 'remote';
346
487
  const attacker = new Set(tool && !remote ? tool.attackerFields : []);
347
- for (const [path, v] of stringsOf(r)) if (remote || attacker.has(path)) out.push(readable(v).toLowerCase().replace(/\s+/g, ' '));
488
+ for (const [path, v] of stringsOf(r)) if (remote || attacker.has(path) || SNIPPET.test(path)) out.push(readable(v).toLowerCase().replace(/\s+/g, ' '));
348
489
  for (const x of r.extras || []) if ((remote || attacker.has('extras[].label')) && typeof x.label === 'string') out.push(readable(x.label).toLowerCase().replace(/\s+/g, ' '));
349
490
  }
350
491
  return out;
@@ -389,30 +530,181 @@ function createCheck(o = {}) {
389
530
  });
390
531
  }
391
532
 
533
+ /** The attacker paths of a result: its tool's attackerFields; every path of an unknown or remote tool; any snippet. */
534
+ function attackerOf(r) {
535
+ const tool = r && r.tool ? toolOf(r.tool) : null;
536
+ const remote = !tool || tool.kind === 'remote';
537
+ const attacker = new Set(tool && !remote ? tool.attackerFields : []);
538
+ return (path) => remote || attacker.has(path) || SNIPPET.test(path);
539
+ }
540
+ /**
541
+ * What the APP wrote at the RESULT level (facts, label, notes, extras' labels — no row, no attacker path), flattened. An
542
+ * extra whose label the app derives from an enum (a verdict or state code: `{ key, count, label, enum: true }`) is
543
+ * app-written even where the tool lists `extras[].label` as attacker text (fix round of PR #14: a breakdown's groups).
544
+ */
545
+ function resultAppTexts(r) {
546
+ const isAttacker = attackerOf(r);
547
+ const out = [];
548
+ for (const [path, v] of stringsOf(r)) if (path.startsWith('facts') && !isAttacker(path)) out.push(flat(v));
549
+ if (!isAttacker('label') && typeof r.label === 'string') out.push(flat(r.label));
550
+ // An extra marked enum counts only for a group-by key the tool declares (groupBy) and a label in its closed list.
551
+ const tool = r && r.tool ? toolOf(r.tool) : null;
552
+ const enumOk = (x) => x.enum === true && tool && tool.kind !== 'remote' && tool.groupBy && typeof x.by === 'string' && tool.groupBy[x.by]
553
+ && tool.groupBy[x.by].enum.some((v) => flat(v) === flat(x.label));
554
+ for (const x of r.extras || []) if ((enumOk(x) || !isAttacker('extras[].label')) && typeof x.label === 'string') out.push(flat(x.label));
555
+ for (const n of r.notes || []) if (typeof n.label === 'string') out.push(flat(n.label));
556
+ return out;
557
+ }
558
+ /** One row's app-written text, flattened: where a verdict about THAT row must show. */
559
+ function rowAppText(r, row) {
560
+ const isAttacker = attackerOf(r);
561
+ const parts = [];
562
+ if (!isAttacker('rows[].title') && typeof row.title === 'string') parts.push(row.title);
563
+ const walk = (v, path) => {
564
+ if (typeof v === 'string') { if (!isAttacker(path)) parts.push(v); } else if (Array.isArray(v)) v.forEach((x) => walk(x, `${path}[]`));
565
+ else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, `${path}.${k}`);
566
+ };
567
+ walk(row.fields || {}, 'rows[].fields');
568
+ return flat(parts.join(' '));
569
+ }
570
+ /**
571
+ * The rows a sentence NAMES (review round 3 of PR #14): by its ref, or by a field the tool DECLARES as the row's identity
572
+ * (`rowName: 'title'` or `['title', 'hostname']` — 'title' is the row's title, anything else a row field) — never by
573
+ * any other field, so a customer name every row shares, or a value planted in another row, names nothing.
574
+ */
575
+ function namedRows(r, sentence, lower) {
576
+ const tool = r && r.tool ? toolOf(r.tool) : null;
577
+ const names = tool && tool.kind !== 'remote' && tool.rowName ? [].concat(tool.rowName) : [];
578
+ return (r.rows || []).filter((row) => {
579
+ if (typeof row.ref === 'string' && row.ref && sentence.includes(row.ref)) return true;
580
+ return names.some((k) => {
581
+ const v = k === 'title' ? row.title : (row.fields || {})[k];
582
+ return typeof v === 'string' && flat(v).length >= 4 && hasPhrase(lower, flat(v));
583
+ });
584
+ });
585
+ }
586
+ /** The state words a sweep may claim: STATE_WORDS and every state and synonym the cited tools declare. */
587
+ function stateVocab(scope) {
588
+ const out = new Set(STATE_WORDS);
589
+ for (const r of scope) {
590
+ const tool = r && r.tool ? toolOf(r.tool) : null;
591
+ for (const [w, def] of Object.entries((tool && tool.stateFields) || {})) { out.add(w); for (const x of def.synonyms || []) out.add(x); }
592
+ }
593
+ return out;
594
+ }
595
+ /** The declared state definition a word claims in one result — the state itself, a synonym, or "un<state>" (negated). */
596
+ function defOf(r, word) {
597
+ const tool = r && r.tool ? toolOf(r.tool) : null;
598
+ const fields = tool && tool.kind !== 'remote' && tool.stateFields ? tool.stateFields : {};
599
+ for (const [w, def] of Object.entries(fields)) if (w === word || (def.synonyms || []).includes(word)) return { def, negated: false };
600
+ if (word.startsWith('un')) for (const [w, def] of Object.entries(fields)) if (w === word.slice(2) || (def.synonyms || []).includes(word.slice(2))) return { def, negated: true };
601
+ return null;
602
+ }
603
+ /** Every word a result holds, attacker text included ("every name comes from the results"), and its look-up's words. */
604
+ function vocabularyOf(r) {
605
+ const out = new Set(knownNames);
606
+ for (const [, v] of stringsOf(r)) for (const w of wordsOf(readable(v))) out.add(w);
607
+ for (const t of [r.label, r.words, ...(r.extras || []).map((x) => x.label), ...(r.notes || []).map((n) => n.label)]) for (const w of wordsOf(readable(t || ''))) out.add(w);
608
+ for (const row of r.rows || []) if (typeof row.ref === 'string') for (const w of wordsOf(row.ref)) out.add(w);
609
+ return out;
610
+ }
611
+
612
+ /**
613
+ * THE CLAIM AND ANALYSE RULES over the answer's sentences (see the header). Returns { injected, format } — the first
614
+ * refusal of each class (or null). EVERY sentence is read for the injection-class rules (verdict, all/none, unframed
615
+ * conclusion, unknown name) before any format-class refusal (uncited sentence or figure) counts, so an uncited lead
616
+ * sentence never hides a later obeyed one (review round 3 of PR #14). `cited` is the whole answer's cited indexes.
617
+ */
618
+ function claimRefusal(answer, results, cited, analyse, question) {
619
+ const qWords = new Set(wordsOf(readable(question || '')));
620
+ let format = null;
621
+ for (const sentence of sentencesOf(answer)) {
622
+ const own = [...new Set([...sentence.matchAll(CITE)].map((m) => Number(m[1].slice(1)) - 1))];
623
+ const scope = (own.length ? own : [...cited]).map((i) => results[i]).filter(Boolean);
624
+ const text = readable(sentence.replace(MARK_BEFORE_PUNCT, '').replace(MARK_AFTER_DOT, '').replace(PLACEHOLDER, ' ').replace(CITE, ' '));
625
+ const lower = flat(text);
626
+ // A VERDICT, PER ROW: what the app-written text of EVERY row the sentence names says; naming none, what the result
627
+ // itself says, or what every one of its rows says (rows that disagree settle nothing).
628
+ for (const m of text.matchAll(VERDICT)) {
629
+ const phrase = flat(m[0]);
630
+ const supported = scope.some((r) => {
631
+ const named = namedRows(r, sentence, lower);
632
+ if (named.length) return named.every((row) => hasPhrase(rowAppText(r, row), phrase));
633
+ if (resultAppTexts(r).some((t) => hasPhrase(t, phrase))) return true;
634
+ const rows = r.rows || [];
635
+ return rows.length > 0 && rows.every((row) => hasPhrase(rowAppText(r, row), phrase));
636
+ });
637
+ if (!supported) return { injected: { ok: false, code: 'unsupported_verdict', reason: `calls something “${m[0].slice(0, 30)}”, which no cited row says` }, format };
638
+ }
639
+ // ALL / NONE / NOT ALL OF THEM in a state: decided ONLY from a field the tool declares holds that state.
640
+ for (const claim of sweepsOf(lower, stateVocab(scope))) {
641
+ const holds = scope.length > 0 && scope.every((r) => {
642
+ const d = defOf(r, claim.word);
643
+ const rows = r.rows || [];
644
+ if (!d || !rows.length) return false;
645
+ const p = d.negated ? (claim.polarity === 'all' ? 'none' : claim.polarity === 'none' ? 'all' : 'notall') : claim.polarity;
646
+ // NOT ALL: one row that holds the field and is not in the state is enough, from any number of rows shown.
647
+ if (p === 'notall') return rows.some((row) => stateOf(row, d.def) === false);
648
+ if (Number.isInteger(r.count) && r.count !== rows.length) return false;
649
+ return rows.every((row) => stateOf(row, d.def) === (p === 'all'));
650
+ });
651
+ if (!holds) return { injected: { ok: false, code: 'sweeping_claim', reason: `says ${claim.polarity === 'notall' ? 'not all' : claim.polarity} of them are “${claim.word}”, which no declared state field of the cited rows shows` }, format };
652
+ }
653
+ if (!analyse) continue;
654
+ // ANALYSE: framed conclusions, no new names, every sentence cited, every figure from a result its sentence cites.
655
+ if (CONCLUSION.test(lower) && !FRAME.test(lower)) return { injected: { ok: false, code: 'unframed_conclusion', reason: 'draws a conclusion without saying it comes from the results' }, format };
656
+ const vocab = new Set(qWords);
657
+ for (const r of scope) for (const w of vocabularyOf(r)) vocab.add(w);
658
+ for (const m of text.matchAll(CAPITALISED)) {
659
+ for (const w of wordsOf(m[0])) {
660
+ if (w.length < 2 || /\p{N}/u.test(w) || NAME_COMMON.has(w) || vocab.has(w)) continue;
661
+ return { injected: { ok: false, code: 'unknown_name', reason: `names “${m[0].slice(0, 40)}”, which the cited results do not` }, format };
662
+ }
663
+ }
664
+ if (!format && !own.length) format = { ok: false, code: 'uncited_sentence', reason: 'a sentence cites no result' };
665
+ for (const m of sentence.matchAll(PLACEHOLDER)) {
666
+ if (!format && !own.includes(Number(m[1].slice(1)) - 1)) format = { ok: false, code: 'uncited_figure', reason: `a figure ${m[0]} from a result its sentence does not cite` };
667
+ }
668
+ }
669
+ return { injected: null, format };
670
+ }
671
+
392
672
  /**
393
673
  * Check one answer. Returns { ok: true, answer, cites } or { ok: false, code, reason } — `code` is one of REFUSAL_CODES.
394
674
  * @param {*} reply the model's parsed JSON
395
675
  * @param {object[]} results the look-ups' results, in order (r1 = results[0])
676
+ * @param {{mode?: 'lookup'|'analyse', question?: string}} [opts] 0.2.0: analyse mode, and the question its names may come from
396
677
  */
397
- function checkAnswer(reply, results) {
678
+ function checkAnswer(reply, results, opts = {}) {
679
+ const analyse = opts.mode === 'analyse';
398
680
  if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return { ok: false, code: 'shape', reason: 'not an object' };
399
681
  const keys = Object.keys(reply).filter((k) => !['answer', 'cites'].includes(k));
400
682
  if (keys.length) return { ok: false, code: 'shape', reason: 'unexpected fields' };
401
683
  let answer = typeof reply.answer === 'string' ? reply.answer.trim() : '';
402
684
  if (!answer) return { ok: false, code: 'shape', reason: 'empty answer' };
403
- if (answer.length > 700) return { ok: false, code: 'shape', reason: 'answer too long' };
685
+ // TOO LONG is a format refusal, applied AFTER the injection-class rules (final round of PR #14): a long obeyed answer is
686
+ // refused for what it obeys, not for its length.
687
+ const tooLong = answer.length > 700;
404
688
  if (!Array.isArray(reply.cites) || reply.cites.some((c) => typeof c !== 'string')) return { ok: false, code: 'shape', reason: 'cites is not a list of ids' };
405
689
  if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(answer)) return { ok: false, code: 'control_chars', reason: 'control characters' };
406
690
 
407
- for (const c of reply.cites) if (!/^r[1-4]$/.test(c) || !results[Number(c.slice(1)) - 1]) return { ok: false, code: 'unknown_cite', reason: `cites ${String(c).slice(0, 8)}, which no look-up returned` };
408
- for (const m of answer.matchAll(CITE)) if (!results[Number(m[1].slice(1)) - 1]) return { ok: false, code: 'unknown_cite', reason: `cites ${m[1]}, which no look-up returned` };
409
- answer = spellFigures(answer, reply.cites, results);
410
- for (const m of answer.matchAll(PLACEHOLDER)) if (!figureOf(results, m[1], m[2])) return { ok: false, code: 'unknown_figure', reason: `a figure ${m[0]} no look-up returned` };
691
+ // INJECTION-CLASS RULES FIRST (review round 3 of PR #14): a FORMAT refusal found on the way (an unknown cite or figure,
692
+ // a malformed placeholder, a number word) is kept, not returned, until every link, obeyed and claim rule has read the
693
+ // whole answer — so a format slip never hides an obeyed sentence.
694
+ let fmt = null;
695
+ const later = (code, reason) => { if (!fmt) fmt = { ok: false, code, reason }; };
696
+ for (const c of reply.cites) if (!/^r[1-4]$/.test(c) || !results[Number(c.slice(1)) - 1]) later('unknown_cite', `cites ${String(c).slice(0, 8)}, which no look-up returned`);
697
+ for (const m of answer.matchAll(CITE)) if (!results[Number(m[1].slice(1)) - 1]) later('unknown_cite', `cites ${m[1]}, which no look-up returned`);
698
+ const knownCites = reply.cites.filter((c) => /^r[1-4]$/.test(c) && results[Number(c.slice(1)) - 1]);
699
+ answer = spellFigures(answer, knownCites, results);
700
+ for (const m of answer.matchAll(PLACEHOLDER)) if (!figureOf(results, m[1], m[2])) later('unknown_figure', `a figure ${m[0]} no look-up returned`);
411
701
  const bare = answer.replace(MARK_BEFORE_PUNCT, '').replace(MARK_AFTER_DOT, '').replace(PLACEHOLDER, ' ').replace(CITE, ' ');
412
- if (/[{}]/.test(bare)) return { ok: false, code: 'bad_placeholder', reason: 'a malformed figure placeholder' };
702
+ if (/[{}]/.test(bare)) later('bad_placeholder', 'a malformed figure placeholder');
413
703
  // NFKC, other full stops and NO format characters for the word rules: what the person would read.
414
704
  const norm = readable(bare);
415
- const allowed = allowedTokens(results);
705
+ const cited = new Set([...knownCites, ...[...answer.matchAll(CITE)].map((m) => m[1])].map((c) => Number(c.slice(1)) - 1).filter((i) => results[i]));
706
+ // ANALYSE: what may be typed is what the CITED results returned.
707
+ const allowed = allowedTokens(analyse ? results.filter((_, i) => cited.has(i)) : results);
416
708
  if (LINKISH.test(norm) || DANGEROUS_SCHEME.test(norm)) return { ok: false, code: 'link', reason: 'a link or markup' };
417
709
  // VERBATIM SPANS read as one plain word by DOMAINISH, the token rules and the allow-list (and by nothing else).
418
710
  const verbatim = [...allowed.verbatim].sort((a, b) => b.length - a.length);
@@ -423,8 +715,13 @@ function createCheck(o = {}) {
423
715
  if (d.includes('/') || !(INTERNAL_SUFFIX.test(d) || allowed.has(trim(d)))) return { ok: false, code: 'link', reason: 'a link or markup' };
424
716
  }
425
717
  const word = NUMBER_WORD.exec(norm);
426
- if (word) return { ok: false, code: 'number_word', reason: `writes the number “${word[0]}” instead of a figure placeholder` };
718
+ if (word) later('number_word', `writes the number “${word[0]}” instead of a figure placeholder`);
427
719
  for (const [re, why, code] of OBEYED) if (re.test(norm)) return { ok: false, code, reason: why };
720
+ const claim = claimRefusal(answer, results, cited, analyse, opts.question);
721
+ if (claim.injected) return claim.injected;
722
+ if (tooLong) return { ok: false, code: 'shape', reason: 'answer too long' };
723
+ if (fmt) return fmt;
724
+ if (claim.format) return claim.format;
428
725
 
429
726
  const named = (x) => {
430
727
  let y = x;
@@ -494,7 +791,7 @@ function createCheck(o = {}) {
494
791
  return chips;
495
792
  }
496
793
 
497
- const config = Object.freeze({ structured, numericStructured, verbatimFields, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows,
794
+ const config = Object.freeze({ structured, numericStructured, verbatimFields, sayableIdFields: { facts: idFacts, rows: idRows }, sayIps, sayWindows, knownNames,
498
795
  namedTokens: namedTokens.map(([re, to]) => [String(re), String(to)]) });
499
796
  /**
500
797
  * WHAT DECIDES A VERDICT (review H3 of PR #9): the rule sources and this app's config, including its id test and row-chip
@@ -508,6 +805,7 @@ function createCheck(o = {}) {
508
805
  const RULE_SOURCES = [PLACEHOLDER, CITE, DIGIT_TOKEN, PURE_NUMBER, REF_TOKEN, HOSTLIKE, LINKISH, DANGEROUS_SCHEME, DOMAINISH, INTERNAL_SUFFIX, NUMBER_WORD, IPV4, IPV6, WINDOW,
509
806
  MARK_BEFORE_PUNCT, MARK_AFTER_DOT, VERBATIM_EXTRA, VERBATIM_VALUE, CIDR4, VERBATIM_BEFORE, VERBATIM_AFTER, SCHEMEISH, IP_PART, DOMAIN_USER, SMALL_WORD, JOINED_NUMBER, NOT_COUNTED, IDIOM_AFTER, IDIOM_BEFORE, JSON.stringify(SMALL_NUMBER),
510
807
  JSON.stringify(QUOTE_PAIRS), INVISIBLE, OTHER_DOTS, BRACKETED_DOT, SPELLED_DOT, SPACED_DOT, ALLOWED_CHAR, ...OBEYED.map(([re, why, code]) => `${re}|${why}|${code}`), REFUSAL_CODES.join(','),
511
- unitOf, isInternalHost, readable, verbatimOk, escapeRe, maskVerbatim, trim, stringsOf, figureOf, segmentsOf, helpChips, createCheck].map(String);
808
+ SNIPPET, VERDICT, STATE_WORDS.join(' '), SWEEP_QUANT, SWEEP_COPULA, SWEEP_ADVERB, SWEEP_NEGATION, sweepsOf, CONCLUSION, FRAME, CAPITALISED, [...NAME_COMMON].join(' '),
809
+ unitOf, isInternalHost, readable, verbatimOk, escapeRe, maskVerbatim, trim, wordsOf, sentencesOf, flat, hasPhrase, stateOf, stringsOf, figureOf, segmentsOf, helpChips, createCheck].map(String);
512
810
 
513
- module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, segmentsOf, figureOf, stringsOf, readable };
811
+ module.exports = { createCheck, REFUSAL_CODES, NUMBER_WORD, REF_TOKEN, PLACEHOLDER, LINKISH, VERDICT, segmentsOf, figureOf, stringsOf, readable, sentencesOf };
package/lib/eta.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * THE BENCH PROGRESS LINE (0.2.0): "N of M · about X min left", with the time elapsed after it. A pure function of the
3
+ * counts and two instants, so a test fixes the clock and the page needs no client script — the admin page re-renders the
4
+ * region on every call and draws whatever this returns.
5
+ *
6
+ * progressLine({ done: 12, of: 351, sinceMs, nowMs, startedMs, endpointsLeft })
7
+ * → { text: '12 of 351 · about 14 min left · 1 min 20 s elapsed', secondsLeft: 812 | null, elapsedSeconds: 80 }
8
+ *
9
+ * The estimate is (calls left) × (mean seconds per call so far on THIS endpoint); calls left include the whole of every
10
+ * endpoint still to come. It is shown only from MIN_CALLS calls on — two calls in, the mean is noise. Under a minute it
11
+ * says "less than a minute left". The first endpoint's mean is not a promise about the next one's; the estimate is only
12
+ * as good as the calls so far, and says "about".
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const MIN_CALLS = 5;
18
+
19
+ /** "42 s", "3 min 05 s", "1 h 02 min". */
20
+ function duration(seconds) {
21
+ const s = Math.max(0, Math.round(seconds));
22
+ if (s < 60) return `${s} s`;
23
+ if (s < 3600) return `${Math.floor(s / 60)} min ${String(s % 60).padStart(2, '0')} s`;
24
+ return `${Math.floor(s / 3600)} h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')} min`;
25
+ }
26
+
27
+ /** The estimate alone: seconds left, or null when there is not enough to say. */
28
+ function secondsLeft({ done, of, sinceMs, nowMs, endpointsLeft = 0 }) {
29
+ if (!(done >= MIN_CALLS) || !(of > 0) || !Number.isFinite(sinceMs) || !Number.isFinite(nowMs) || nowMs < sinceMs) return null;
30
+ const mean = (nowMs - sinceMs) / 1000 / done;
31
+ const left = Math.max(0, of - done) + Math.max(0, endpointsLeft) * of;
32
+ return left * mean;
33
+ }
34
+
35
+ // From 60 min on the elapsed format ("about 2 h 55 min left"), never "about 175 min" beside "2 h 55 min elapsed".
36
+ function leftWords(seconds) {
37
+ if (seconds < 60) return 'less than a minute left';
38
+ const m = Math.round(seconds / 60);
39
+ return m < 60 ? `about ${m} min left` : `about ${duration(m * 60)} left`;
40
+ }
41
+
42
+ function progressLine({ done, of, sinceMs, nowMs, startedMs, endpointsLeft = 0 }) {
43
+ const parts = [`${done} of ${of}`];
44
+ const left = secondsLeft({ done, of, sinceMs, nowMs, endpointsLeft });
45
+ if (left !== null) parts.push(leftWords(left));
46
+ const elapsedSeconds = Number.isFinite(startedMs) && Number.isFinite(nowMs) && nowMs >= startedMs ? (nowMs - startedMs) / 1000 : null;
47
+ if (elapsedSeconds !== null) parts.push(`${duration(elapsedSeconds)} elapsed`);
48
+ return { text: parts.join(' · '), secondsLeft: left, elapsedSeconds };
49
+ }
50
+
51
+ module.exports = { MIN_CALLS, progressLine, secondsLeft, duration };
package/lib/history.js CHANGED
@@ -1,16 +1,36 @@
1
1
  /**
2
- * A person's own Ask history (the last 20, kept per the retention setting) and their thumbs mark.
2
+ * A person's own Ask history (the last 20, kept per the retention setting), their thumbs mark, and — 0.2.0 — the two
3
+ * reads that use an earlier question again: a HISTORY READ (rechecked) and a FOLLOW-UP (carried, bounded).
3
4
  *
4
5
  * ONE PERSON'S ROWS SPAN PARTNERS, so each read widens to platform scope around ONE model call bounded by `asked_by = <the
5
6
  * principal's id>` — the model refuses any other scope. The id comes from `principalOf` (the session today), never from
6
7
  * the request: nothing a person sends can name somebody else's history, and another person's ref reads as none.
8
+ *
9
+ * THE HISTORY RECHECK (roadmap 150). An answer is drawn again only if the viewer, NOW, still passes `mayUse` for every
10
+ * tool that answer used (a tool no longer registered fails), no such tool is switched off (tools_off), and reads at the
11
+ * same reading, acting state and partner it was asked at. Otherwise the person sees "no longer available to you" — the answer is not sent. A permission removed
12
+ * since, or a partner the person no longer works inside, takes the old answer away with it.
13
+ *
14
+ * WHAT A FOLLOW-UP CARRIES (D-0169 item 9), and nothing else: the previous question's TEXT as the person typed it, the
15
+ * ids of the TOOLS its look-ups used, and the REFS those look-ups returned (rows' own `ref`, this app's public ids,
16
+ * passing `isPublicId`, at most 10). Never the model's answer, never a result's text or a resolved round-2 value. It is
17
+ * carried only when the previous question is the SAME PERSON's (the read is bounded by asked_by), at the SAME CONTEXT
18
+ * (kind and ref, or both none), passes the history recheck above (same reading, acting state, partner, every tool still
19
+ * usable), was answered or withheld (its look-ups ran), and was asked within FOLLOW_UP_MINUTES. Anything else: the
20
+ * question is asked fresh, with nothing carried.
7
21
  */
8
22
 
9
23
  'use strict';
10
24
 
11
- /** @param {{invoke, asPlatform, principalOf, modelName?}} o */
25
+ const FOLLOW_UP_MINUTES = 15;
26
+ const MAX_REFS = 10;
27
+ const utcMs = (t) => Date.parse(`${String(t).replace(' ', 'T')}Z`);
28
+
29
+ /** @param {{invoke, asPlatform, principalOf, modelName?, catalogue?, isPublicId?, now?}} o */
12
30
  function createHistory(o) {
13
31
  const model = o.modelName || 'AskQuestion';
32
+ const now = typeof o.now === 'function' ? o.now : Date.now;
33
+ const isPublicId = typeof o.isPublicId === 'function' ? o.isPublicId : () => false;
14
34
  const meOf = (req, can) => {
15
35
  const p = o.principalOf(req, can);
16
36
  if (!p || p.id == null) throw new Error('ask history: needs a signed-in user');
@@ -26,7 +46,49 @@ function createHistory(o) {
26
46
  }
27
47
  const one = (req, can, ref) => { const me = meOf(req, can); return o.asPlatform(() => o.invoke(model, 'oneFor', me, String(ref))); };
28
48
  const rate = (req, can, ref, mark) => { const me = meOf(req, can); return o.asPlatform(() => o.invoke(model, 'rate', me, String(ref), mark)); };
29
- return { list, one, rate };
49
+
50
+ /**
51
+ * May this viewer, now, see this stored question's answer again? { ok: true } or { ok: false, why: 'tool' | 'reading' |
52
+ * 'scope' }. `q` is oneFor's row (with `calls`); `reading` is the viewer's reading now.
53
+ */
54
+ function recheck(q, req, can, reading, toolsOff = []) {
55
+ const who = o.principalOf(req, can);
56
+ const off = new Set(Array.isArray(toolsOff) ? toolsOff : []);
57
+ for (const c of q.calls || []) {
58
+ const tool = o.catalogue ? o.catalogue.byId(c.tool) : null;
59
+ if (!tool || !o.catalogue.mayUse(can, tool)) return { ok: false, why: 'tool' };
60
+ // A TOOL SWITCHED OFF hides its old answers too; they stay stored and show again when it is switched back on.
61
+ if (off.has(c.tool)) return { ok: false, why: 'tool_off' };
62
+ }
63
+ const then = q.result && q.result.reading != null ? String(q.result.reading) : null;
64
+ if (then !== (reading == null ? null : String(reading))) return { ok: false, why: 'reading' };
65
+ if (!!q.acting !== !!(who && who.acting)) return { ok: false, why: 'scope' };
66
+ const pid = who && who.scope && who.scope.partnerId != null ? Number(who.scope.partnerId) : null;
67
+ if (pid != null && q.partner_id != null && Number(q.partner_id) !== pid) return { ok: false, why: 'scope' };
68
+ return { ok: true };
69
+ }
70
+
71
+ /**
72
+ * The previous question a follow-up carries — { ref, question, tools, refs } — or null (see the header). `context` is
73
+ * the NEW question's resolved context; `reading` the reading now.
74
+ */
75
+ async function followUp(req, can, ref, { context = null, reading = null, toolsOff = [] } = {}) {
76
+ if (typeof ref !== 'string' || !isPublicId(ref, 'ask_question')) return null;
77
+ const q = await one(req, can, ref);
78
+ if (!q || !['answered', 'withheld'].includes(q.outcome)) return null;
79
+ const age = now() - utcMs(q.created_at);
80
+ if (!(age >= 0 && age <= FOLLOW_UP_MINUTES * 60000)) return null;
81
+ if ((q.context_kind || null) !== (context ? context.kind : null) || (q.context_ref || null) !== (context ? context.ref : null)) return null;
82
+ if (!recheck(q, req, can, reading, toolsOff).ok) return null;
83
+ const tools = [...new Set((q.calls || []).map((c) => String(c.tool)))];
84
+ const refs = [];
85
+ for (const l of (q.result && q.result.lookups) || []) {
86
+ for (const r of l.refs || []) if (typeof r === 'string' && isPublicId(r) && !refs.includes(r) && refs.length < MAX_REFS) refs.push(r);
87
+ }
88
+ return { ref: q.public_id, question: String(q.question || ''), tools, refs };
89
+ }
90
+
91
+ return { list, one, rate, recheck, followUp, FOLLOW_UP_MINUTES };
30
92
  }
31
93
 
32
- module.exports = { createHistory };
94
+ module.exports = { createHistory, FOLLOW_UP_MINUTES, MAX_REFS };