@clien-ai/mcp 0.10.3 → 0.10.5

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.
@@ -217,23 +217,6 @@ function renderId(raw, absent = '(no id)') {
217
217
  return absent;
218
218
  return safeId(raw) ?? '(unrenderable id)';
219
219
  }
220
- /**
221
- * What a GROUNDED claim says when its receipt pointer did not render.
222
- *
223
- * The same absent-vs-unrenderable split as `renderId`, and here it changes what
224
- * the reader should DO. "no receipt id — unresolvable" tells an agent the claim
225
- * is grounded in something the report failed to record, which is a defect in the
226
- * run. A pointer that exists but is unrenderable is a defect in the POINTER: the
227
- * receipt is in `structuredContent` / `_meta` and can still be resolved there.
228
- * Printing the first for the second sends the agent to re-run research it does
229
- * not need.
230
- */
231
- function groundedWithoutReceipt(rawSourceId) {
232
- const absent = rawSourceId === undefined || rawSourceId === null || String(rawSourceId).trim() === '';
233
- return absent
234
- ? ' · GROUNDED but no receipt id — unresolvable'
235
- : ' · GROUNDED, receipt id present but unrenderable — resolve it from the raw report data';
236
- }
237
220
  function num(value) {
238
221
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
239
222
  }
@@ -273,12 +256,61 @@ function capNote(shown, total, channel, path) {
273
256
  return '';
274
257
  return `\n_(showing ${shown} of ${total} — the remaining ${total - shown} are in \`${channel}.${path}\`)_`;
275
258
  }
276
- function tallyStates(claims) {
259
+ /** Return the normalized URL only when it is a safe, non-credentialed web receipt. */
260
+ function safeReceiptUrl(raw) {
261
+ if (typeof raw !== 'string' || !raw.trim())
262
+ return null;
263
+ try {
264
+ const url = new URL(raw.trim());
265
+ if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
266
+ return null;
267
+ }
268
+ return url.href;
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ }
274
+ /** Resolve the single, persona-owned presentation grant behind a GROUNDED claim. */
275
+ function resolvePersonaReceipt(rawClaim, personas) {
276
+ const claim = asRecord(rawClaim);
277
+ if (str(claim?.state) !== 'GROUNDED')
278
+ return null;
279
+ const personaIndex = personas.findIndex((_, index) => str(claim?.personaId) === `persona-${index}`);
280
+ if (personaIndex < 0)
281
+ return null;
282
+ const sources = asArray(asRecord(personas[personaIndex])?.sources);
283
+ const sourceIndex = sources.findIndex((_, index) => str(claim?.sourceId) === `RCP-p${personaIndex}-s${index}`);
284
+ if (sourceIndex < 0)
285
+ return null;
286
+ const source = asRecord(sources[sourceIndex]);
287
+ const href = safeReceiptUrl(source?.url);
288
+ if (!source || !href)
289
+ return null;
290
+ return {
291
+ sourceId: `RCP-p${personaIndex}-s${sourceIndex}`,
292
+ href,
293
+ quote: collapseWhitespace(str(source.quote) ?? ''),
294
+ source,
295
+ };
296
+ }
297
+ function resolvePersonaRows(reportData) {
298
+ const personas = asArray(reportData.personas);
299
+ return asArray(reportData.claims).map((claim) => ({
300
+ claim,
301
+ receipt: resolvePersonaReceipt(claim, personas),
302
+ }));
303
+ }
304
+ function tallyPersonaStates(rows) {
277
305
  const tally = { grounded: 0, speculation: 0, noReceipt: 0, other: 0 };
278
- for (const raw of claims) {
306
+ for (const { claim: raw, receipt } of rows) {
279
307
  const state = str(asRecord(raw)?.state);
280
- if (state === 'GROUNDED')
281
- tally.grounded += 1;
308
+ if (state === 'GROUNDED') {
309
+ if (receipt)
310
+ tally.grounded += 1;
311
+ else
312
+ tally.noReceipt += 1;
313
+ }
282
314
  else if (state === 'SPECULATION')
283
315
  tally.speculation += 1;
284
316
  else if (state === 'NO_RECEIPT')
@@ -308,7 +340,7 @@ function tallyLine(tally, total) {
308
340
  * itself is what an agent pattern-matches on — a digest that says "this claim has
309
341
  * a receipt" without printing `RCP-p0-s2` leaves the agent unable to follow it.
310
342
  */
311
- function renderPersonaClaim(raw) {
343
+ function renderPersonaClaim(raw, resolvedReceipt) {
312
344
  const claim = asRecord(raw);
313
345
  if (!claim)
314
346
  return '- (unreadable claim entry)';
@@ -323,92 +355,103 @@ function renderPersonaClaim(raw) {
323
355
  //
324
356
  // The GROUNDED gates keep reading the RAW `str()` value: flattening trims, so
325
357
  // gating on the flattened one would newly admit `" GROUNDED "` as grounded and
326
- // put the row's badge at odds with `tallyStates`, which reads raw. Flattening
358
+ // put the row's badge at odds with `tallyPersonaStates`, which reads raw. Flattening
327
359
  // is for the RENDER; it must not widen what counts as a receipt.
328
360
  const rawState = str(claim.state);
329
- const state = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
361
+ const storedState = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
362
+ const state = rawState === 'GROUNDED' && !resolvedReceipt ? 'NO_RECEIPT' : storedState;
330
363
  const id = renderId(claim.id);
331
364
  const personaId = safeId(claim.personaId);
332
365
  const sourceId = safeId(claim.sourceId);
333
366
  const text = safeInline(claim.text, CLAIM_TEXT_MAX);
334
367
  // Gated on `state`, not on `sourceId` presence — see `renderReportClaim` for
335
368
  // why a pointer on a non-GROUNDED claim must never render as a receipt.
336
- let receipt = '';
337
- if (rawState === 'GROUNDED') {
338
- receipt = sourceId ? ` ← ${sourceId}` : groundedWithoutReceipt(claim.sourceId);
369
+ let provenance = '';
370
+ if (resolvedReceipt) {
371
+ provenance = ` ← ${resolvedReceipt.sourceId}`;
372
+ }
373
+ else if (rawState === 'GROUNDED') {
374
+ provenance = ' · Stored grade: GROUNDED — receipt unavailable; do not cite';
339
375
  }
340
376
  else if (sourceId) {
341
- receipt = ` (carries ${sourceId}, which does NOT grant grounding — state is ${state})`;
377
+ provenance = ` (carries ${sourceId}, which does NOT grant grounding — state is ${storedState})`;
342
378
  }
343
379
  const who = personaId ? ` (${personaId})` : '';
344
380
  const body = text ? ` — "${text}"` : '';
345
- return `- [${state}] ${id}${who}${receipt}${body}`;
381
+ return `- [${state}] ${id}${who}${provenance}${body}`;
346
382
  }
347
383
  /**
348
- * One report-spine claim line. Differs from the persona one in two ways that
349
- * matter to a reader: it carries a `section` (market/competitor) instead of a
350
- * persona, and a NO_RECEIPT claim has TWO distinct provenance readings that must
351
- * be told apart — "Cited" (a page was read and independently vouched, no exact
352
- * span pinned) versus plain model knowledge (recalled, unverified).
353
- *
354
- * ⚠️ THE WORD IS THE ONE FROM `MODEL_ATTESTED_BADGE_LABEL` (FUL-416), lower-cased.
355
- * This digest is the THIRD mirror of that label inside the MCP package — the tool
356
- * advert in `registry.ts` is the other two — and it is the one that renders into
357
- * the payload an agent actually READS. When the re-map first landed it was missed:
358
- * the advert promised a claim "marked \"Inferred\"" while this line still emitted
359
- * `model-attested`, so an agent keying on the advert's word would have found
360
- * nothing, and the same server described one tier two different ways.
361
- *
362
- * Neither reading is grounding. The attestation's `sourceId` is printed under an
363
- * explicit `cited →` label rather than the bare `←` used for real receipts, so
364
- * the two can never be skim-read as the same thing. That distinction matters MORE now
365
- * that the word itself is shared with a span-verified receipt.
366
- *
367
- * FUL-615: an attested claim now also carries the WINDOW the judge read, clipped to
368
- * `RECEIPT_QUOTE_MAX` on a labelled continuation line. `cited` was the one provenance
369
- * tier on this surface whose supporting text reached no agent-visible channel at all —
370
- * a GROUNDED claim's span is readable as the `RCP-` excerpt it steers, but an attested
371
- * claim's pointer resolves into `reportEvidence`, whose rows deliberately carry no quote
372
- * (FUL-560). So the tier a reader is asked to weigh ("stronger than recall, still NOT a
373
- * receipt") was the one tier they could not see for themselves.
374
- *
375
- * ⚠️ THE ABSENCE OF A QUOTE USED TO BE HALF THE SEPARATION, and this change spends it.
376
- * What replaces it is `ATTESTED_WINDOW_LABEL` — see its doc for why a bare indented
377
- * quote would have made an attested row read as a grounded one.
378
- *
379
- * DIGEST COST, in the idiom `RECEIPT_QUOTE_MAX`'s own doc sets, because a digest an
380
- * agent truncates is a digest it does not read. Bounded by construction: this spine
381
- * renders at most `CLAIM_RENDER_CAP` (40) rows and only attested ones get a window, so
382
- * the worst case is 40 × (200 + ~40 for the label, indent, quotes and newline) ≈ 9.6 KB.
383
- * Today it is 0 on every prod report — the A2 grantor writes no attestations yet.
384
+ * Resolve the one presentation grant behind either report-level Cited treatment.
385
+ *
386
+ * A stored GROUNDED grade and a model-attestation marker are both untrusted report JSON, not proof
387
+ * by themselves. Either earns its presentation only when its canonical pointer indexes the exact
388
+ * `reportEvidence` pool and the target has a non-credentialed HTTP(S) destination. An attestation
389
+ * additionally needs its nonblank captured window. Keeping this as one resolver lets the row,
390
+ * tally, pointer and window consume the same answer instead of minting orphan citations.
384
391
  */
385
- function renderReportClaim(raw) {
392
+ function resolveReportReceipt(rawClaim, reportEvidence) {
393
+ const claim = asRecord(rawClaim);
394
+ const rawState = str(claim?.state);
395
+ let kind;
396
+ let sourceId = '';
397
+ let quote = '';
398
+ if (rawState === 'GROUNDED') {
399
+ kind = 'grounded';
400
+ sourceId = typeof claim?.sourceId === 'string' ? claim.sourceId.trim() : '';
401
+ }
402
+ else if (rawState === 'NO_RECEIPT') {
403
+ kind = 'attested';
404
+ const attestation = asRecord(claim?.attestation);
405
+ sourceId = typeof attestation?.sourceId === 'string' ? attestation.sourceId.trim() : '';
406
+ quote = typeof attestation?.quote === 'string' ? collapseWhitespace(attestation.quote) : '';
407
+ if (!quote)
408
+ return null;
409
+ }
410
+ else {
411
+ return null;
412
+ }
413
+ if (!sourceId)
414
+ return null;
415
+ const parsed = /^RRCP-s(0|[1-9]\d*)$/.exec(sourceId);
416
+ if (!parsed)
417
+ return null;
418
+ const sourceIndex = Number(parsed[1]);
419
+ if (!Number.isSafeInteger(sourceIndex))
420
+ return null;
421
+ const source = asRecord(reportEvidence[sourceIndex]);
422
+ if (!source || !safeReceiptUrl(source.url))
423
+ return null;
424
+ return { kind, sourceId, quote };
425
+ }
426
+ function renderReportClaim(raw, receipt) {
386
427
  const claim = asRecord(raw);
387
428
  if (!claim)
388
429
  return '- (unreadable claim entry)';
389
430
  // FUL-253: same guard and same raw-gate split as `renderPersonaClaim` — this
390
431
  // spine's rows are the ones a forged `[GROUNDED] … ← RRCP-s0` would hide in.
391
432
  const rawState = str(claim.state);
392
- const state = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
433
+ const storedState = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
434
+ const state = rawState === 'GROUNDED' && receipt?.kind !== 'grounded'
435
+ ? 'NO_RECEIPT'
436
+ : storedState;
393
437
  const id = renderId(claim.id);
394
438
  const section = safeInline(claim.section, 40);
395
439
  const sourceId = safeId(claim.sourceId);
396
440
  const text = safeInline(claim.text, CLAIM_TEXT_MAX);
397
- const attestation = asRecord(claim.attestation);
398
- const attestedSourceId = safeId(attestation?.sourceId);
399
- // The bare `←` arrow means RECEIPT, so it is gated on `state`, NOT on the mere
400
- // presence of a `sourceId`. Keying on presence would render an explicitly
401
- // unsupported claim with the same marker a span-verified one gets: the schema
402
- // types `state` and `sourceId` independently and `.passthrough()`s unknown
403
- // shapes, and `getReport` hands the RAW payload through when Zod rejects it, so
404
- // `{ state: 'NO_RECEIPT', sourceId: 'RRCP-s0' }` genuinely reaches this code.
405
- // An agent following that arrow would treat a NO_RECEIPT figure as source-checked.
441
+ // The bare `←` arrow means RECEIPT, so raw state and pointer presence are both
442
+ // insufficient. The shared resolver must also find the exact evidence row and a
443
+ // safe destination; otherwise even a stored GROUNDED grade degrades to neutral
444
+ // audit copy. `getReport` hands RAW payload through after schema drift, so every
445
+ // one of those checks is a render-boundary responsibility.
406
446
  let provenance = '';
407
- if (rawState === 'GROUNDED') {
408
- provenance = sourceId ? ` ← ${sourceId}` : groundedWithoutReceipt(claim.sourceId);
447
+ if (receipt?.kind === 'grounded') {
448
+ provenance = ` ← ${receipt.sourceId}`;
409
449
  }
410
- else if (rawState === 'NO_RECEIPT' && attestedSourceId) {
411
- provenance = ` · ${ATTESTED_LABEL} → ${attestedSourceId} (NOT a receipt)`;
450
+ else if (rawState === 'GROUNDED') {
451
+ provenance = ' · Stored grade: GROUNDED — receipt unavailable; do not cite';
452
+ }
453
+ else if (receipt?.kind === 'attested') {
454
+ provenance = ` · ${ATTESTED_LABEL} → ${receipt.sourceId} (NOT a receipt)`;
412
455
  }
413
456
  else if (rawState === 'NO_RECEIPT') {
414
457
  provenance = ` · ${UNSOURCED_LABEL} — from model knowledge, unverified`;
@@ -416,11 +459,11 @@ function renderReportClaim(raw) {
416
459
  // A pointer on a non-grounded claim is shown but explicitly disarmed, so it is
417
460
  // neither hidden from the reader nor readable as grounding.
418
461
  if (rawState !== 'GROUNDED' && sourceId) {
419
- provenance += ` (carries ${sourceId}, which does NOT grant grounding — state is ${state})`;
462
+ provenance += ` (carries ${sourceId}, which does NOT grant grounding — state is ${storedState})`;
420
463
  }
421
464
  const where = section ? ` (${section})` : '';
422
465
  const body = text ? ` — "${text}"` : '';
423
- return `- [${state}] ${id}${where}${provenance}${body}${attestedWindow(rawState, attestedSourceId, attestation?.quote)}`;
466
+ return `- [${state}] ${id}${where}${provenance}${body}${attestedWindow(receipt)}`;
424
467
  }
425
468
  /**
426
469
  * The attested claim's window: the captured snapshot text an independent judge read
@@ -445,31 +488,48 @@ function renderReportClaim(raw) {
445
488
  * budget of its own, for the reason that constant's doc gives: an agent reading the
446
489
  * same source text clipped two different ways has to wonder which is the real wording.
447
490
  */
448
- function attestedWindow(rawState, attestedSourceId, rawQuote) {
449
- if (rawState !== 'NO_RECEIPT' || !attestedSourceId)
450
- return '';
451
- const quote = collapseWhitespace(str(rawQuote) ?? '');
452
- if (!quote)
491
+ function attestedWindow(receipt) {
492
+ if (receipt?.kind !== 'attested')
453
493
  return '';
454
- return `\n ${ATTESTED_WINDOW_LABEL}: "${clipReceiptQuote(quote, [], RECEIPT_QUOTE_MAX)}"`;
494
+ return `\n ${ATTESTED_WINDOW_LABEL}: "${clipReceiptQuote(receipt.quote, [], RECEIPT_QUOTE_MAX)}"`;
495
+ }
496
+ function tallyReportStates(rows) {
497
+ const tally = { grounded: 0, speculation: 0, noReceipt: 0, other: 0 };
498
+ for (const { claim: raw, receipt } of rows) {
499
+ const state = str(asRecord(raw)?.state);
500
+ if (state === 'GROUNDED') {
501
+ if (receipt?.kind === 'grounded')
502
+ tally.grounded += 1;
503
+ else
504
+ tally.noReceipt += 1;
505
+ }
506
+ else if (state === 'SPECULATION')
507
+ tally.speculation += 1;
508
+ else if (state === 'NO_RECEIPT')
509
+ tally.noReceipt += 1;
510
+ else
511
+ tally.other += 1;
512
+ }
513
+ return tally;
455
514
  }
456
515
  function renderPersonaSpine(reportData, channel) {
457
- const claims = asArray(reportData.claims);
458
- if (claims.length === 0) {
516
+ const rows = resolvePersonaRows(reportData);
517
+ if (rows.length === 0) {
459
518
  return ('### Persona claim spine — NOT PRESENT on this run\n' +
460
519
  'No persona claim registry was produced, so NOTHING in the persona/interview prose above ' +
461
520
  'has been machine-checked. Absent is not clean: treat those statements as unverified.');
462
521
  }
463
- const tally = tallyStates(claims);
464
- const shown = claims.slice(0, CLAIM_RENDER_CAP);
465
- const lines = shown.map(renderPersonaClaim);
466
- return (`### Persona claim spine — ${tallyLine(tally, claims.length)}\n` +
522
+ const tally = tallyPersonaStates(rows);
523
+ const shown = rows.slice(0, CLAIM_RENDER_CAP);
524
+ const lines = shown.map(({ claim, receipt }) => renderPersonaClaim(claim, receipt));
525
+ return (`### Persona claim spine — ${tallyLine(tally, rows.length)}\n` +
467
526
  'A GROUNDED claim\'s receipt id (`RCP-p{i}-s{j}`) indexes `personas[i].sources[j]` — the cached ' +
468
- 'forum post its quote span was code-verified against. NO_RECEIPT can mean a bad claim OR merely ' +
527
+ 'forum post its quote span was code-verified against. A stored GROUNDED grade whose canonical, ' +
528
+ 'persona-owned safe receipt is unavailable is rendered and counted NO_RECEIPT. NO_RECEIPT can mean a bad claim OR merely ' +
469
529
  'sparse evidence: check that persona\'s `insufficientEvidence` / `sourcesFound` below before ' +
470
530
  'discounting it. Anything not GROUNDED is unverified.\n' +
471
531
  `${lines.join('\n')}` +
472
- capNote(shown.length, claims.length, channel, 'report_data.claims'));
532
+ capNote(shown.length, rows.length, channel, 'report_data.claims'));
473
533
  }
474
534
  function renderReportSpine(reportData, channel) {
475
535
  const claims = asArray(reportData.reportClaims);
@@ -479,14 +539,16 @@ function renderReportSpine(reportData, channel) {
479
539
  'executive-summary assertion in the prose above has been machine-checked. Treat every such ' +
480
540
  'figure as unverified.');
481
541
  }
482
- const tally = tallyStates(claims);
483
- const attested = claims.filter((raw) => {
484
- const claim = asRecord(raw);
485
- return str(claim?.state) === 'NO_RECEIPT' && str(asRecord(claim?.attestation)?.sourceId) !== null;
486
- }).length;
542
+ const reportEvidence = asArray(reportData.reportEvidence);
543
+ const rows = claims.map((claim) => ({
544
+ claim,
545
+ receipt: resolveReportReceipt(claim, reportEvidence),
546
+ }));
547
+ const tally = tallyReportStates(rows);
548
+ const attested = rows.filter(({ receipt }) => receipt?.kind === 'attested').length;
487
549
  const attestedNote = attested > 0 ? ` (of which ${attested} ${ATTESTED_LABEL})` : '';
488
- const shown = claims.slice(0, CLAIM_RENDER_CAP);
489
- const lines = shown.map(renderReportClaim);
550
+ const shown = rows.slice(0, CLAIM_RENDER_CAP);
551
+ const lines = shown.map(({ claim, receipt }) => renderReportClaim(claim, receipt));
490
552
  return (`### Report claim spine — ${tallyLine(tally, claims.length)}${attestedNote}\n` +
491
553
  'A SEPARATE pool from the persona spine — the two never cross. Sections are `market` (sizing/' +
492
554
  'trend figures), `competitor` (profile facts) and `summary` (an assertion quoted VERBATIM from ' +
@@ -497,8 +559,9 @@ function renderReportSpine(reportData, channel) {
497
559
  'supporting page an independent check vouched for, but no exact span was pinned — stronger than ' +
498
560
  `recall, still NOT a receipt. The \`${ATTESTED_WINDOW_LABEL}\` line beneath such a row is that page's ` +
499
561
  'text as we captured it: read it as the context the judge weighed, never as a span checked against ' +
500
- 'the claim. Unmarked NO_RECEIPT is recalled model knowledge, unverified. ' +
501
- 'Key on the state, never on whether a source id is present.\n' +
562
+ 'the claim. Unmarked NO_RECEIPT is recalled model knowledge, unverified. A stored GROUNDED grade ' +
563
+ 'whose receipt is unavailable is shown as neutral audit context and counted NO_RECEIPT. ' +
564
+ 'Key on the rendered state and receipt arrow, never on stored state or source-id presence alone.\n' +
502
565
  `${lines.join('\n')}` +
503
566
  capNote(shown.length, claims.length, channel, 'report_data.reportClaims'));
504
567
  }
@@ -506,9 +569,9 @@ function renderReportSpine(reportData, channel) {
506
569
  * The verified spans each persona receipt has to be able to SHOW, keyed by the
507
570
  * receipt id its claims point at (FUL-252).
508
571
  *
509
- * Gated on `state === 'GROUNDED'`, matching `renderPersonaClaim`'s own gate and for
510
- * the same reason: only a GROUNDED claim's span earned a badge, so only a GROUNDED
511
- * claim's span is what the excerpt owes the reader. Keying on the mere presence of
572
+ * Gated on the same resolved receipt as `renderPersonaClaim`: only a GROUNDED claim
573
+ * with a canonical, persona-owned safe receipt earned a badge, so only that claim's
574
+ * span is what the excerpt owes the reader. Keying on the mere presence of
512
575
  * `quoteSpan` would let a SPECULATION claim steer the window — pushing the excerpt
513
576
  * away from the sentence a real receipt was verified against, in favour of one no
514
577
  * check ever ran on. `getReport` hands the RAW payload through when Zod rejects it,
@@ -518,21 +581,20 @@ function renderReportSpine(reportData, channel) {
518
581
  * has no visible badge, so windowing a quote to support it would spend the excerpt
519
582
  * on a claim the agent cannot see, at the cost of one it can.
520
583
  */
521
- function collectVerifiedSpans(reportData) {
584
+ function collectVerifiedSpans(rows) {
522
585
  const spans = new Map();
523
- for (const raw of asArray(reportData.claims).slice(0, CLAIM_RENDER_CAP)) {
586
+ for (const { claim: raw, receipt } of rows.slice(0, CLAIM_RENDER_CAP)) {
524
587
  const claim = asRecord(raw);
525
- if (!claim || str(claim.state) !== 'GROUNDED')
588
+ if (!claim || !receipt)
526
589
  continue;
527
- const sourceId = str(claim.sourceId);
528
590
  const span = str(claim.quoteSpan);
529
- if (!sourceId || !span)
591
+ if (!span)
530
592
  continue;
531
- const existing = spans.get(sourceId);
593
+ const existing = spans.get(receipt.sourceId);
532
594
  if (existing)
533
595
  existing.push(span);
534
596
  else
535
- spans.set(sourceId, [span]);
597
+ spans.set(receipt.sourceId, [span]);
536
598
  }
537
599
  return spans;
538
600
  }
@@ -561,54 +623,55 @@ function collectVerifiedSpans(reportData) {
561
623
  * from them — `publishedDate`, the figure's actual recency (FUL-148).
562
624
  */
563
625
  function renderReceiptPools(reportData, channel) {
564
- const personas = asArray(reportData.personas);
565
626
  const reportEvidence = asArray(reportData.reportEvidence);
566
- const spansByReceipt = collectVerifiedSpans(reportData);
627
+ const personaRows = resolvePersonaRows(reportData).slice(0, CLAIM_RENDER_CAP);
628
+ const spansByReceipt = collectVerifiedSpans(personaRows);
629
+ const personaReceiptById = new Map();
630
+ for (const { receipt } of personaRows) {
631
+ if (receipt)
632
+ personaReceiptById.set(receipt.sourceId, receipt);
633
+ }
567
634
  const personaReceipts = [];
568
- personas.forEach((rawPersona, i) => {
569
- const sources = asArray(asRecord(rawPersona)?.sources);
570
- sources.forEach((rawSource, j) => {
571
- const source = asRecord(rawSource);
572
- // FUL-253: the quote below was already flattened — these two were not, and
573
- // they sit on the SAME `- RCP-…` line, so a newline in either forges the
574
- // receipt row the comment below says a forged quote would.
575
- const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
576
- const url = safeInline(source?.url, 200) ?? '(no url)';
577
- // Indented under its own receipt line so the pool still scans as a list of
578
- // pointers; a receipt with no cached quote simply has no second line, which
579
- // is itself worth seeing — it is a pointer that grounds nothing until fetched.
580
- //
581
- // `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
582
- // list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
583
- // convincing `- RCP-` prefix would add an entry pointing at a source nobody
584
- // retrieved. A whitespace-only quote also collapses to '' here and correctly
585
- // renders as a bare pointer rather than as `""` — "the source said nothing".
586
- //
587
- // FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
588
- // GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
589
- // the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
590
- // `renderPersonaClaim` prints, so the two sides of the arrow are built from
591
- // the same expression and cannot drift into windowing the wrong receipt.
592
- const receiptId = `RCP-p${i}-s${j}`;
593
- const quote = collapseWhitespace(str(source?.quote) ?? '');
594
- const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
595
- const quoteLine = quote ? `\n "${clipped}"` : '';
596
- // FUL-560: the source type, stated ONCE for the pool (in the header below) and inline only
597
- // on a row that breaks the stated rule. This pool is the one place in the package where the
598
- // type is a near-constant: `stampEvidenceMetadata` (agent-side) prunes every non-`user_voice`
599
- // source out of `persona.sources`, so a per-row line here would print the same word down
600
- // forty rows and bury the one that mattered. The header carries the claim; this carries the
601
- // exception, in the shared wording.
602
- //
603
- // ⚠️ ABSENT AND NON-CANONICAL ARE BOTH DEVIATIONS. `canonicalContentType` fails closed, so a
604
- // legacy untyped receipt (M0a predates plenty of stored runs) and a token from a newer
605
- // producer both land here rather than being silently absorbed by the header's claim — which
606
- // would be exactly the "silence reads as reassurance" fail-open this change exists to close.
607
- const personaType = canonicalContentType(source?.contentType);
608
- const deviationLine = personaType === 'user_voice' ? '' : contentTypeDeviationLine(source?.contentType);
609
- personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}${deviationLine}`);
610
- });
611
- });
635
+ for (const [receiptId, receipt] of personaReceiptById) {
636
+ const source = receipt.source;
637
+ // FUL-253: the quote below was already flattened — these two were not, and
638
+ // they sit on the SAME `- RCP-…` line, so a newline in either forges the
639
+ // receipt row the comment below says a forged quote would.
640
+ const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
641
+ const url = safeInline(receipt.href, 200) ?? '(no url)';
642
+ // Indented under its own receipt line so the pool still scans as a list of
643
+ // pointers; a receipt with no cached quote simply has no second line, which
644
+ // is itself worth seeing — it is a pointer that grounds nothing until fetched.
645
+ //
646
+ // `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
647
+ // list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
648
+ // convincing `- RCP-` prefix would add an entry pointing at a source nobody
649
+ // retrieved. A whitespace-only quote also collapses to '' here and correctly
650
+ // renders as a bare pointer rather than as `""` — "the source said nothing".
651
+ //
652
+ // FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
653
+ // GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
654
+ // the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
655
+ // `renderPersonaClaim` prints, so the two sides of the arrow are built from
656
+ // the same expression and cannot drift into windowing the wrong receipt.
657
+ const quote = receipt.quote;
658
+ const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
659
+ const quoteLine = quote ? `\n "${clipped}"` : '';
660
+ // FUL-560: the source type, stated ONCE for the pool (in the header below) and inline only
661
+ // on a row that breaks the stated rule. This pool is the one place in the package where the
662
+ // type is a near-constant: `stampEvidenceMetadata` (agent-side) prunes every non-`user_voice`
663
+ // source out of `persona.sources`, so a per-row line here would print the same word down
664
+ // forty rows and bury the one that mattered. The header carries the claim; this carries the
665
+ // exception, in the shared wording.
666
+ //
667
+ // ⚠️ ABSENT AND NON-CANONICAL ARE BOTH DEVIATIONS. `canonicalContentType` fails closed, so a
668
+ // legacy untyped receipt (M0a predates plenty of stored runs) and a token from a newer
669
+ // producer both land here rather than being silently absorbed by the header's claim — which
670
+ // would be exactly the "silence reads as reassurance" fail-open this change exists to close.
671
+ const personaType = canonicalContentType(source?.contentType);
672
+ const deviationLine = personaType === 'user_voice' ? '' : contentTypeDeviationLine(source?.contentType);
673
+ personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}${deviationLine}`);
674
+ }
612
675
  const evidenceReceipts = reportEvidence.map((raw, n) => {
613
676
  const source = asRecord(raw);
614
677
  // FUL-253: every field on this row is scraped-page metadata, and the row is
@@ -640,13 +703,13 @@ function renderReceiptPools(reportData, channel) {
640
703
  });
641
704
  if (personaReceipts.length === 0 && evidenceReceipts.length === 0) {
642
705
  return ('### Receipt pools — EMPTY\n' +
643
- 'No cached sources were attached to this run, so no claim above can be followed to a source. ' +
644
- 'Any receipt id referenced in the prose is unresolvable.');
706
+ 'No visible claim resolves to a canonical, persona-owned safe receipt and no report evidence ' +
707
+ 'was attached to this run. Any receipt id referenced in the prose is unresolvable.');
645
708
  }
646
709
  const sections = ['### Receipt pools'];
647
710
  if (personaReceipts.length > 0) {
648
711
  const shown = personaReceipts.slice(0, CLAIM_RENDER_CAP);
649
- sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) — the target of an \`RCP-\` pointer. ` +
712
+ sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) — safe targets of the visible \`RCP-\` pointers above. ` +
650
713
  'Every receipt here is a first-hand `user_voice` post unless its own row says otherwise:\n' +
651
714
  shown.join('\n') +
652
715
  capNote(shown.length, personaReceipts.length, channel, 'report_data.personas[].sources'));
@@ -838,12 +901,48 @@ function renderSycophancy(reportData, channel) {
838
901
  */
839
902
  function hypothesisDetail(result) {
840
903
  const statement = safeInline(result?.statement, HYPOTHESIS_STATEMENT_MAX);
841
- const scopeCaveat = safeInline(result?.scopeCaveat, HYPOTHESIS_NOTE_MAX);
842
904
  const evidenceReading = safeInline(result?.evidenceReading, HYPOTHESIS_NOTE_MAX);
843
905
  return ((statement ? `\n ${HYPOTHESIS_LABEL}: "${statement}"` : '') +
844
- (scopeCaveat ? `\n ${SCOPE_CAVEAT_LABEL}: "${scopeCaveat}"` : '') +
906
+ scopeCaveatDetail(result) +
845
907
  (evidenceReading ? `\n ${EVIDENCE_READING_LABEL}: ${evidenceReading}` : ''));
846
908
  }
909
+ /** The exact quoted/labelled FUL-614 shape, shared by robust and caveat-only rows. */
910
+ function scopeCaveatDetail(result) {
911
+ const scopeCaveat = safeInline(result?.scopeCaveat, HYPOTHESIS_NOTE_MAX);
912
+ return scopeCaveat ? `\n ${SCOPE_CAVEAT_LABEL}: "${scopeCaveat}"` : '';
913
+ }
914
+ /**
915
+ * Scope caveats for hypotheses that do not have a robustness row (FUL-626).
916
+ *
917
+ * A caveat already rides on {@link hypothesisDetail} when the verdict was re-tested. This separate
918
+ * block covers only the other half so existing robustness rows stay byte-identical and a
919
+ * caveat-only fact never sits under a heading that claims robustness. Values are MODEL-WRITTEN,
920
+ * therefore quoted, labelled and passed through `safeInline` before entering a `- ` row.
921
+ *
922
+ * Deliberately no `LIST_RENDER_CAP`: the acceptance contract is that every nonblank caveat reaches
923
+ * `content`, and silently dropping the 26th trust qualification would be worse than a larger
924
+ * digest. The measured production rate was 26/371 rows (7%) on 2026-08-24, each value is bounded by
925
+ * `HYPOTHESIS_NOTE_MAX`, and ordinary runs carry only 5-8 hypotheses. The full result array remains
926
+ * the natural runaway bound, while version-skewed/malformed rows fail closed through `asArray`,
927
+ * `asRecord`, `renderId` and `safeInline`.
928
+ */
929
+ function renderUnretestedScopeCaveats(reportData) {
930
+ const lines = asArray(reportData.hypothesisResults).flatMap((raw) => {
931
+ const result = asRecord(raw);
932
+ if (!result || asRecord(result.robustness) !== null)
933
+ return [];
934
+ const detail = scopeCaveatDetail(result);
935
+ if (!detail)
936
+ return [];
937
+ return [`- ${renderId(result.hypothesisId)}${detail}`];
938
+ });
939
+ if (lines.length === 0)
940
+ return '';
941
+ return ('### Scope caveats on hypotheses not re-tested\n' +
942
+ 'These model-written qualifications apply to single-shot verdicts above; they are not ' +
943
+ 'robustness results.\n' +
944
+ lines.join('\n'));
945
+ }
847
946
  /**
848
947
  * What a persona name that arrived unreadable renders as (FUL-616).
849
948
  *
@@ -1298,6 +1397,7 @@ export function buildTrustDigest(reportData, channel) {
1298
1397
  renderReceiptPools(data, channel),
1299
1398
  renderPersonas(data, channel),
1300
1399
  renderSycophancy(data, channel),
1400
+ renderUnretestedScopeCaveats(data),
1301
1401
  renderRobustness(data, channel),
1302
1402
  ].filter((section) => section.length > 0);
1303
1403
  return `${DIGEST_HEADING}\n\n${sections.join('\n\n')}`;