@clien-ai/mcp 0.10.9 → 0.11.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.
@@ -46,8 +46,9 @@
46
46
  * moves agent-authored text below that separator breaks the digest's own authority
47
47
  * rule, which a reader applies positionally.
48
48
  */
49
- import { collapseWhitespace, safeInline, safeId, clipReceiptQuote, RECEIPT_QUOTE_MAX, } from './render-safety.js';
49
+ import { collapseWhitespace, safeInline, safeId, clipReceiptQuote, containsSpan, hasVisibleContent, RECEIPT_QUOTE_MAX, } from './render-safety.js';
50
50
  import { canonicalContentType, contentTypeDeviationLine, contentTypeProvenanceLine, } from './content-type-display.js';
51
+ import { resolveSourceReceipt } from './receipt-children.js';
51
52
  import { resolveSupportedMarketSizing } from './market-sizing-proof.js';
52
53
  /**
53
54
  * Max claims rendered per spine. Above this the digest states how many were
@@ -184,6 +185,32 @@ const UNSOURCED_LABEL = 'unsourced';
184
185
  const HYPOTHESIS_LABEL = 'hypothesis';
185
186
  const SCOPE_CAVEAT_LABEL = '⚠️ scope caveat — persona support from OUTSIDE its credibility domain; directional, NOT grounded';
186
187
  const EVIDENCE_READING_LABEL = 'rests on';
188
+ /**
189
+ * The marker on a receipt-CHILD line, and the pool sentence that explains it (FUL-685 / T8).
190
+ *
191
+ * ⚠️ IT HAS TO BE VISUALLY SUBORDINATE, for the same reason the pool prints one row per source
192
+ * parent: a child line that opened with `- ` would read as another cached discussion, which is
193
+ * exactly the breadth inflation D13 refuses. `↳` under an existing row reads as "inside this
194
+ * one" and nothing else.
195
+ *
196
+ * ⚠️ AND THE WORD IS `excerpt`, NOT `quote`. T1b (FUL-697) measured the provider transforming
197
+ * the text it returns — deleting a paragraph break, replacing an author's hyperlink with the
198
+ * literal token `URL` — on the rail these children come from. Calling this a speaker's exact
199
+ * words would be a false claim on the one surface whose entire purpose is that the product does
200
+ * not make those. The sentence renders only when a row actually has a child, so a report with
201
+ * no multi-excerpt source is byte-identical to what it rendered before.
202
+ */
203
+ const RECEIPT_CHILD_MARKER = '↳';
204
+ /**
205
+ * The spine's half of the same fact. One discussion can legitimately yield several excerpts, so
206
+ * a claim that addresses one carries BOTH ids — `sourceId` for the discussion, `receiptId` for
207
+ * the excerpt inside it — and the pool row's matching `↳` line is the text it was checked
208
+ * against. An unresolvable pair is never softened into the parent: it loses the receipt.
209
+ */
210
+ const RECEIPT_CHILD_SPINE_NOTE = 'A claim carrying a second id after `·` addresses ONE excerpt inside that discussion — match it ' +
211
+ 'to the `↳` line of the same id in the receipt pool below, not to the row\'s first excerpt.';
212
+ const RECEIPT_CHILD_POOL_NOTE = 'A `↳` line is the bounded EXCERPT one claim\'s `receiptId` addresses inside that discussion — ' +
213
+ 'an excerpt, not a speaker\'s exact words, and the claim line above names the id it points at';
187
214
  // ---------------------------------------------------------------------------
188
215
  // Defensive accessors — every one of these answers "or nothing" rather than throwing
189
216
  // ---------------------------------------------------------------------------
@@ -221,6 +248,25 @@ function renderId(raw, absent = '(no id)') {
221
248
  function num(value) {
222
249
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
223
250
  }
251
+ /**
252
+ * Acceptance is a count, not merely a finite number (FUL-740).
253
+ *
254
+ * Classify before interpreting so an invalid counter can never reach a comparison that reads it
255
+ * as evidence or absence. `Number.isInteger` rejects non-numbers, NaN, Infinity and fractions;
256
+ * the remaining check rejects negative integers. `null` is treated like an omitted field because
257
+ * both mean the producer supplied no usable counter; either way, only `valid` carries a number
258
+ * downstream.
259
+ */
260
+ function readAcceptanceCounter(value) {
261
+ if (value === undefined || value === null)
262
+ return { kind: 'absent' };
263
+ if (!Number.isInteger(value))
264
+ return { kind: 'invalid' };
265
+ const integer = value;
266
+ if (integer < 0)
267
+ return { kind: 'invalid' };
268
+ return { kind: 'valid', value: integer };
269
+ }
224
270
  /**
225
271
  * TRI-STATE boolean read: `true`, `false`, or `null` for "absent or unreadable".
226
272
  *
@@ -272,7 +318,27 @@ function safeReceiptUrl(raw) {
272
318
  return null;
273
319
  }
274
320
  }
275
- /** Resolve the single, persona-owned presentation grant behind a GROUNDED claim. */
321
+ /**
322
+ * Resolve the single, persona-owned presentation grant behind a GROUNDED claim.
323
+ *
324
+ * ⚠️ FUL-711 — TWO REFERENCES, AND THEY MUST AGREE. `sourceId` locates the source parent
325
+ * positionally; an optional `receiptId` locates the exact excerpt CHILD inside it. The three
326
+ * outcomes are deliberately not two:
327
+ *
328
+ * - **no `receiptId` key (or raw `undefined`)** — the legacy path. Resolve the parent, render
329
+ * its single cached excerpt, exactly as before this function learned about children.
330
+ * - **`receiptId` resolves AND the child carries the claim's `quoteSpan`** — the claim
331
+ * addresses that child, and the child's own text is what `clipReceiptQuote` gets as its
332
+ * haystack. Both halves, or neither: FUL-728 added the span check because resolving to a
333
+ * child that does not contain the span is FUL-711's defect with a content-addressed id on
334
+ * it (see the call site).
335
+ * - **`receiptId` present but unresolvable** — `null`, malformed, dangling, cross-source,
336
+ * duplicate, or a parent with no children at all: **return `null` and fail closed.** Our
337
+ * producer omits this optional key, so an explicit `null` has unknown provenance and cannot
338
+ * claim the legacy parent's quote as grounded evidence. Falling back would print a 200-char
339
+ * clip of excerpt 1 under a GROUNDED badge for a claim grounded on excerpt 2 — precisely the
340
+ * defect this resolution exists to remove, restored as an error path.
341
+ */
276
342
  function resolvePersonaReceipt(rawClaim, personas) {
277
343
  const claim = asRecord(rawClaim);
278
344
  if (str(claim?.state) !== 'GROUNDED')
@@ -288,11 +354,71 @@ function resolvePersonaReceipt(rawClaim, personas) {
288
354
  const href = safeReceiptUrl(source?.url);
289
355
  if (!source || !href)
290
356
  return null;
357
+ // Branch on the RAW value, not on the resolved one: only `undefined` is "legacy claim, nothing
358
+ // to resolve" and every present value — including `null` — is a promise this parent must keep.
359
+ const claimedReceiptId = claim?.receiptId;
360
+ let child = null;
361
+ if (claimedReceiptId !== undefined) {
362
+ child = resolveSourceReceipt(source.receipts, claimedReceiptId) ?? null;
363
+ if (!child)
364
+ return null;
365
+ // ⚠️ RESOLVING THE ID IS NOT CHECKING THE SPAN, AND FUL-711'S DEFECT SURVIVES THE GAP
366
+ // (FUL-728). A `receiptId` that is well-formed, lives under the RIGHT parent, and is unique
367
+ // there resolves cleanly — while the claim's `quoteSpan` sits in a DIFFERENT child of that
368
+ // same parent. Nothing above notices: the id was never asked to agree with the span, only to
369
+ // exist. `clipReceiptQuote` then finds no span in the child it was handed, takes the head
370
+ // clip, and the digest prints `[GROUNDED]` beside an excerpt that does not contain the
371
+ // sentence which earned the badge — FUL-711 resolved to the wrong child instead of
372
+ // positionally, which is the same lie with a content-addressed id on it. D12 asks the
373
+ // selected receipt to CONTAIN the exact span, so ask it here.
374
+ //
375
+ // ⚠️ THIS IS A DIFFERENT AXIS FROM THE `> 1` BOUND BELOW, and neither substitutes for the
376
+ // other. That one asks whether a claim named a child at all; this one asks whether the child
377
+ // it named is the right one. Widening that bound would break the single-child byte-identity
378
+ // guarantee it exists to hold; this check cannot, because it lives entirely inside the branch
379
+ // a `receiptId` opens — a legacy claim carries none and never reaches it.
380
+ //
381
+ // ⚠️ NO `quoteSpan` STILL GRANTS. There is no span to contradict the child, and refusing on
382
+ // absence would be a different rule — "a Reddit claim must carry a span" — which belongs to
383
+ // the producer and to T5's schema, not to a renderer inferring it. `containsSpan` uses the
384
+ // renderer's OWN lookup, so a span this package could not locate can never earn a badge here
385
+ // and then quietly render as an unwindowed head clip.
386
+ //
387
+ // ⚠️ BRANCH ON THE RAW VALUE, exactly as `receiptId` does two lines up (#1059 review). `str()`
388
+ // answers `null` for a NUMBER, an OBJECT and `''` alike, so reading through it would file a
389
+ // present-but-malformed span under "legacy claim, nothing to check" and grant. Those are
390
+ // different facts: absence is a claim that never promised a span, while a present one is a
391
+ // promise this child has to keep, and a producer that emitted `42` or `''` there has told us
392
+ // nothing about which sentence earned the badge. Fail closed on the promise it cannot keep.
393
+ const rawSpan = claim?.quoteSpan;
394
+ if (rawSpan !== undefined && rawSpan !== null) {
395
+ const span = str(rawSpan);
396
+ if (span === null || !containsSpan(child.excerpt, span))
397
+ return null;
398
+ }
399
+ }
400
+ else if (asArray(source.receipts).length > 1) {
401
+ // ⚠️ ABSENT `receiptId` ON A parent carrying SEVERAL children is not a legacy claim — it is a
402
+ // producer that failed to name one, and it is the only case that reaches the fallback this
403
+ // function's docblock forbids. `quote` holds excerpt 1 (D13: one staging row per permalink),
404
+ // so returning the parent prints a clip of excerpt 1 under a GROUNDED badge for a claim whose
405
+ // span may live in excerpt 2 — FUL-711's exact defect, restored as an error path that fires
406
+ // only on malformed input nobody is watching.
407
+ //
408
+ // ⚠️ THE BOUND IS `> 1`, NOT `> 0`, AND THAT IS D15's LINE — "multi-receipt evidence" needs an
409
+ // addressed child; "legacy single-receipt" keeps positional behaviour. With exactly ONE child
410
+ // the parent row and that child carry the same text BY CONSTRUCTION, so resolving positionally
411
+ // cannot show a span-free excerpt and the legacy byte-identity guarantee holds. Widening this
412
+ // to `> 0` breaks that guarantee for one-child sources, which is a real regression: an
413
+ // additive field would start changing what a legacy reader sees.
414
+ return null;
415
+ }
291
416
  return {
292
417
  sourceId: `RCP-p${personaIndex}-s${sourceIndex}`,
293
418
  href,
294
419
  quote: collapseWhitespace(str(source.quote) ?? ''),
295
420
  source,
421
+ child,
296
422
  };
297
423
  }
298
424
  function resolvePersonaRows(reportData) {
@@ -369,7 +495,13 @@ function renderPersonaClaim(raw, resolvedReceipt) {
369
495
  // why a pointer on a non-GROUNDED claim must never render as a receipt.
370
496
  let provenance = '';
371
497
  if (resolvedReceipt) {
372
- provenance = ` ← ${resolvedReceipt.sourceId}`;
498
+ // FUL-685: when the claim addressed a receipt CHILD, the arrow carries both halves of the
499
+ // pair. Without the child id a reader looking at two claims on one `RCP-p0-s0` row cannot
500
+ // tell which `↳` excerpt grounds which claim — the same "wrong excerpt under a GROUNDED
501
+ // badge" confusion this change removes, relocated one line down. `safeId` because it is an
502
+ // identifier: exact or nothing.
503
+ const childId = resolvedReceipt.child ? safeId(resolvedReceipt.child.receiptId) : null;
504
+ provenance = ` ← ${resolvedReceipt.sourceId}${childId ? ` · ${childId}` : ''}`;
373
505
  }
374
506
  else if (rawState === 'GROUNDED') {
375
507
  provenance = ' · Stored grade: GROUNDED — receipt unavailable; do not cite';
@@ -387,7 +519,8 @@ function renderPersonaClaim(raw, resolvedReceipt) {
387
519
  * A stored GROUNDED grade and a model-attestation marker are both untrusted report JSON, not proof
388
520
  * by themselves. Either earns its presentation only when its canonical pointer indexes the exact
389
521
  * `reportEvidence` pool and the target has a non-credentialed HTTP(S) destination. An attestation
390
- * additionally needs its nonblank captured window. Keeping this as one resolver lets the row,
522
+ * additionally needs a captured window with something VISIBLE in it — not merely a nonblank
523
+ * string, which U+200B and the C0 controls satisfy. Keeping this as one resolver lets the row,
391
524
  * tally, pointer and window consume the same answer instead of minting orphan citations.
392
525
  */
393
526
  function resolveReportReceipt(rawClaim, reportEvidence) {
@@ -405,7 +538,13 @@ function resolveReportReceipt(rawClaim, reportEvidence) {
405
538
  const attestation = asRecord(claim?.attestation);
406
539
  sourceId = typeof attestation?.sourceId === 'string' ? attestation.sourceId.trim() : '';
407
540
  quote = typeof attestation?.quote === 'string' ? collapseWhitespace(attestation.quote) : '';
408
- if (!quote)
541
+ // ⚠️ NONBLANK IS NOT THE SAME TEST AS "HAS A WINDOW" (#1059 review, same defect as FUL-728
542
+ // item 3). `collapseWhitespace` only collapses `\s`, which covers none of U+200B, U+2060,
543
+ // U+00AD or the C0 controls — so an attestation window built from those survives this as a
544
+ // non-empty string, clears the guard, and the claim earns `cited → RRCP-s0` plus an
545
+ // `Attested window: ""` line. A citation marker over a window nobody can read is the
546
+ // presentation this resolver exists to withhold.
547
+ if (!hasVisibleContent(quote))
409
548
  return null;
410
549
  }
411
550
  else {
@@ -523,9 +662,14 @@ function renderPersonaSpine(reportData, channel) {
523
662
  const tally = tallyPersonaStates(rows);
524
663
  const shown = rows.slice(0, CLAIM_RENDER_CAP);
525
664
  const lines = shown.map(({ claim, receipt }) => renderPersonaClaim(claim, receipt));
665
+ // FUL-685: only say the pair exists when a rendered claim actually carries one. A report with
666
+ // no multi-excerpt source renders this header byte-identically to before.
667
+ const anyChild = shown.some(({ receipt }) => receipt?.child);
526
668
  return (`### Persona claim spine — ${tallyLine(tally, rows.length)}\n` +
527
669
  'A GROUNDED claim\'s receipt id (`RCP-p{i}-s{j}`) indexes `personas[i].sources[j]` — the cached ' +
528
- 'forum post its quote span was code-verified against. A stored GROUNDED grade whose canonical, ' +
670
+ 'forum post its quote span was code-verified against. ' +
671
+ (anyChild ? `${RECEIPT_CHILD_SPINE_NOTE} ` : '') +
672
+ 'A stored GROUNDED grade whose canonical, ' +
529
673
  'persona-owned safe receipt is unavailable is rendered and counted NO_RECEIPT. NO_RECEIPT can mean a bad claim OR merely ' +
530
674
  'sparse evidence: check that persona\'s `insufficientEvidence` / `sourcesFound` below before ' +
531
675
  'discounting it. Anything not GROUNDED is unverified.\n' +
@@ -582,6 +726,25 @@ function renderReportSpine(reportData, channel) {
582
726
  * has no visible badge, so windowing a quote to support it would spend the excerpt
583
727
  * on a claim the agent cannot see, at the cost of one it can.
584
728
  */
729
+ /**
730
+ * The bucket a claim's verified span belongs to.
731
+ *
732
+ * ⚠️ FUL-711 — KEYED BY THE HAYSTACK, NOT BY THE SOURCE. Before receipt children there was one
733
+ * text per source and `sourceId` was both. Now a source can carry several excerpts, and a span
734
+ * verified inside excerpt 2 is not findable in excerpt 1 — so windowing the parent by it would
735
+ * locate nothing and silently fall back to the head clip. One bucket per (parent, child), with
736
+ * the childless bucket keyed by the empty string, keeps every span pointed at the text it is
737
+ * actually in. A NUL (`U+0000`) cannot appear in either id, so the two halves cannot alias.
738
+ *
739
+ * ⚠️ THE SEPARATOR IS WRITTEN `\u0000`, NEVER AS THE RAW BYTE. A literal NUL anywhere in a
740
+ * `.ts` file makes `file` classify it as `data` and makes grep and ripgrep skip it with a bare
741
+ * `Binary file matches` — which, on the digest, silently removes the repo's most trust-critical
742
+ * renderer from every search an agent runs here. The escape is the same byte at runtime.
743
+ * Enforced by `__tests__/integration/workflows/source-files-stay-greppable.test.ts`.
744
+ */
745
+ function receiptBucketKey(sourceId, receiptId) {
746
+ return `${sourceId}\u0000${receiptId ?? ''}`;
747
+ }
585
748
  function collectVerifiedSpans(rows) {
586
749
  const spans = new Map();
587
750
  for (const { claim: raw, receipt } of rows.slice(0, CLAIM_RENDER_CAP)) {
@@ -591,11 +754,12 @@ function collectVerifiedSpans(rows) {
591
754
  const span = str(claim.quoteSpan);
592
755
  if (!span)
593
756
  continue;
594
- const existing = spans.get(receipt.sourceId);
757
+ const key = receiptBucketKey(receipt.sourceId, receipt.child?.receiptId ?? null);
758
+ const existing = spans.get(key);
595
759
  if (existing)
596
760
  existing.push(span);
597
761
  else
598
- spans.set(receipt.sourceId, [span]);
762
+ spans.set(key, [span]);
599
763
  }
600
764
  return spans;
601
765
  }
@@ -622,18 +786,36 @@ function collectVerifiedSpans(rows) {
622
786
  * pages, whose "quote" would be a page excerpt chosen at fetch time rather than a
623
787
  * human's own words, and the pool already carries the one thing a reader needs
624
788
  * from them — `publishedDate`, the figure's actual recency (FUL-148).
789
+ *
790
+ * FUL-685 (T8 / D13/D15), spec in FUL-711: a source whose ONE url yielded several bounded
791
+ * excerpts gets ONE row here, still — receipt count must never inflate source or discussion
792
+ * breadth — with an indented `↳` line per excerpt a rendered claim actually ADDRESSES. Not per
793
+ * stored excerpt: printing the whole child set under a row would make one discussion look like
794
+ * several, which is the inflation D13 exists to prevent, and would spend the digest budget on
795
+ * text no claim points at.
625
796
  */
626
797
  function renderReceiptPools(reportData, channel) {
627
798
  const reportEvidence = asArray(reportData.reportEvidence);
628
799
  const personaRows = resolvePersonaRows(reportData).slice(0, CLAIM_RENDER_CAP);
629
800
  const spansByReceipt = collectVerifiedSpans(personaRows);
801
+ /**
802
+ * One entry per source PARENT, carrying the receipt children the rendered claims addressed.
803
+ * A `Map` keyed by child id so two claims on the same excerpt collapse to one `↳` line.
804
+ */
630
805
  const personaReceiptById = new Map();
631
806
  for (const { receipt } of personaRows) {
632
- if (receipt)
633
- personaReceiptById.set(receipt.sourceId, receipt);
807
+ if (!receipt)
808
+ continue;
809
+ let row = personaReceiptById.get(receipt.sourceId);
810
+ if (!row) {
811
+ row = { receipt, children: new Map() };
812
+ personaReceiptById.set(receipt.sourceId, row);
813
+ }
814
+ if (receipt.child)
815
+ row.children.set(receipt.child.receiptId, receipt.child);
634
816
  }
635
817
  const personaReceipts = [];
636
- for (const [receiptId, receipt] of personaReceiptById) {
818
+ for (const [receiptId, { receipt, children }] of personaReceiptById) {
637
819
  const source = receipt.source;
638
820
  // FUL-253: the quote below was already flattened — these two were not, and
639
821
  // they sit on the SAME `- RCP-…` line, so a newline in either forges the
@@ -647,19 +829,59 @@ function renderReceiptPools(reportData, channel) {
647
829
  // `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
648
830
  // list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
649
831
  // convincing `- RCP-` prefix would add an entry pointing at a source nobody
650
- // retrieved. A whitespace-only quote also collapses to '' here and correctly
651
- // renders as a bare pointer rather than as `""` — "the source said nothing".
832
+ // retrieved.
833
+ //
834
+ // ⚠️ WHETHER TO PRINT THE LINE AT ALL IS A DIFFERENT QUESTION, AND `hasVisibleContent` IS THE
835
+ // ONE THAT ANSWERS IT (#1059 review, same defect as FUL-728 item 3). A whitespace-only quote
836
+ // collapses to '' and correctly renders as a bare pointer — "the source said nothing" — but
837
+ // a quote of U+200B or U+0000 survives the collapse non-empty, and this line then prints `""`
838
+ // under a receipt GROUNDED claims point at. The forgery guard stays on the flattening; the
839
+ // emptiness question is asked of the visible characters.
652
840
  //
653
841
  // FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
654
842
  // GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
655
843
  // the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
656
844
  // `renderPersonaClaim` prints, so the two sides of the arrow are built from
657
845
  // the same expression and cannot drift into windowing the wrong receipt.
846
+ //
847
+ // ⚠️ FUL-685: the parent bucket is now the CHILDLESS one. A span verified inside excerpt 2
848
+ // is not findable in excerpt 1, so windowing this line by it would locate nothing and fall
849
+ // back to the head clip while looking like a window. Legacy claims carry no `receiptId`, so
850
+ // for every pre-existing report every span is still in this bucket and this line is
851
+ // byte-identical to what it rendered before.
658
852
  const quote = receipt.quote;
659
- const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
853
+ const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptBucketKey(receiptId, null)) ?? [], RECEIPT_QUOTE_MAX);
660
854
  const topic = safeInline(source?.topic, 40);
661
855
  const topicLine = topic ? `\n On ${topic}` : '';
662
- const quoteLine = quote ? `\n "${clipped}"` : '';
856
+ const quoteLine = hasVisibleContent(quote) ? `\n "${clipped}"` : '';
857
+ // The addressed receipt CHILDREN, one line each, sorted by receipt id.
858
+ //
859
+ // ⚠️ THE PARENT LINE STAYS, EVEN WHEN A CHILD REPEATS IT. The parent's `quote` IS the first
860
+ // excerpt (that is what `admitRedditPersonaSources` writes), so a claim addressing child 1
861
+ // prints the same sentence twice. That redundancy is deliberate and cheaper than the
862
+ // alternatives: suppressing the parent line whenever every claim addressed a child means a
863
+ // row can render with no excerpt at all on any path that miscounts, and suppressing a `↳`
864
+ // line that merely duplicates the parent breaks the header's own instruction — a claim's id
865
+ // would have no line to match, which is the mapping this change exists to establish.
866
+ //
867
+ // ⚠️ SORTED BY ID, NOT BY CLAIM ORDER. The id is content-addressed — `deriveReceiptId`
868
+ // hashes (normalized url, exact excerpt) — so sorting on it is stable across retry, merge,
869
+ // reopen and promotion, and matches the order the producer emits children in. Ordering by
870
+ // first-claim-seen would instead make the pool's shape a function of how the synthesiser
871
+ // happened to sequence its claims.
872
+ //
873
+ // `safeId` rather than `safeInline`: this is an IDENTIFIER, and a repaired id is a wrong id
874
+ // that still reads as one. It cannot fail in practice — nothing reaches here without
875
+ // matching `rr{n}:{16 hex}` — but exact-or-nothing is the rule for every id in this package
876
+ // and a silently clipped one would be a pointer to a child that does not exist.
877
+ const childLines = [...children.values()]
878
+ .sort((a, b) => (a.receiptId < b.receiptId ? -1 : a.receiptId > b.receiptId ? 1 : 0))
879
+ .map((child) => {
880
+ const childId = safeId(child.receiptId) ?? '(unrenderable receipt id)';
881
+ const clippedChild = clipReceiptQuote(child.excerpt, spansByReceipt.get(receiptBucketKey(receiptId, child.receiptId)) ?? [], RECEIPT_QUOTE_MAX);
882
+ return `\n ${RECEIPT_CHILD_MARKER} ${childId} — "${clippedChild}"`;
883
+ })
884
+ .join('');
663
885
  // FUL-560: the source type, stated ONCE for the pool (in the header below) and inline only
664
886
  // on a row that breaks the stated rule. This pool is the one place in the package where the
665
887
  // type is a near-constant: `stampEvidenceMetadata` (agent-side) prunes every non-`user_voice`
@@ -673,8 +895,9 @@ function renderReceiptPools(reportData, channel) {
673
895
  // would be exactly the "silence reads as reassurance" fail-open this change exists to close.
674
896
  const personaType = canonicalContentType(source?.contentType);
675
897
  const deviationLine = personaType === 'user_voice' ? '' : contentTypeDeviationLine(source?.contentType);
676
- personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${topicLine}${quoteLine}${deviationLine}`);
898
+ personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${topicLine}${quoteLine}${childLines}${deviationLine}`);
677
899
  }
900
+ const anyReceiptChildren = [...personaReceiptById.values()].some((row) => row.children.size > 0);
678
901
  const evidenceReceipts = reportEvidence.map((raw, n) => {
679
902
  const source = asRecord(raw);
680
903
  // FUL-253: every field on this row is scraped-page metadata, and the row is
@@ -713,7 +936,9 @@ function renderReceiptPools(reportData, channel) {
713
936
  if (personaReceipts.length > 0) {
714
937
  const shown = personaReceipts.slice(0, CLAIM_RENDER_CAP);
715
938
  sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) — safe targets of the visible \`RCP-\` pointers above. ` +
716
- 'Every receipt here is a first-hand `user_voice` post unless its own row says otherwise:\n' +
939
+ 'Every receipt here is a first-hand `user_voice` post unless its own row says otherwise' +
940
+ (anyReceiptChildren ? `. ${RECEIPT_CHILD_POOL_NOTE}` : '') +
941
+ ':\n' +
717
942
  shown.join('\n') +
718
943
  capNote(shown.length, personaReceipts.length, channel, 'report_data.personas[].sources'));
719
944
  }
@@ -1471,7 +1696,7 @@ function renderMarketSizing(reportData) {
1471
1696
  return `- ${labels[scope]}: — (Not established)`;
1472
1697
  }
1473
1698
  const validInputs = inputs.filter((input) => input !== null);
1474
- const sameBoundary = validInputs.every((input) => input.period === entry.period && input.geography === entry.geography && input.audience === entry.audience);
1699
+ const sameBoundary = validInputs.every((input) => input.kind === 'ratio_assumption' || (input.period === entry.period && input.geography === entry.geography && input.audience === entry.audience));
1475
1700
  const inputValues = validInputs.map((input) => num(input.value));
1476
1701
  const oneCurrency = validInputs.filter((input) => input.kind === 'currency' && input.currency === currency).length === 1;
1477
1702
  const product = inputValues.every((input) => input !== null)
@@ -1485,6 +1710,204 @@ function renderMarketSizing(reportData) {
1485
1710
  });
1486
1711
  return `### TAM / SAM / SOM — typed sizing\n${lines.join('\n')}`;
1487
1712
  }
1713
+ /**
1714
+ * The Reddit community-evidence rail's typed outcome (FUL-685 / T8, D6).
1715
+ *
1716
+ * ⚠️ SILENCE IS A STATE, AND SO IS SAYING NOTHING — they are different, and this function is
1717
+ * where the difference is kept. TWO cases render NOTHING at all:
1718
+ *
1719
+ * - the note is **absent or unreadable** → UNRECORDED. Every report written before the field
1720
+ * existed omits it, as does one served by an app deploy that predates it. Printing "Reddit
1721
+ * was not searched" off that silence would be inventing a fact about the run.
1722
+ * - the note says **`not_run`** → the rail did not dispatch, so there is no coverage claim to
1723
+ * make in either direction. The plan's placement table is explicit: no Reddit-specific copy
1724
+ * and no claim that Reddit ran; this is operator telemetry, and it lives in the ledger.
1725
+ *
1726
+ * The other four each get their own sentence, and `failed` may NEVER be relabelled
1727
+ * `completed_empty` (F7): a provider outage dressed up as an honest empty search is a false claim
1728
+ * about coverage, which is the one thing this whole digest exists not to make.
1729
+ *
1730
+ * ⚠️ COUNTS ARE TWO NUMBERS, NOT ONE. `acceptedSourceCount` is discussions and
1731
+ * `acceptedExcerptCount` is excerpts, because one thread legitimately yields several — collapsing
1732
+ * them would let receipt multiplicity inflate the breadth figure, which D12/D13 forbid.
1733
+ *
1734
+ * ⚠️ NO COST, NO LATENCY, NO REASON CODE ON A HEALTHY RUN. `costUsd`, `latencyMs`,
1735
+ * `requestsIssued`, `toolRequests` and the three intent counters are operator telemetry; they
1736
+ * change nothing an agent should do about a claim, and a digest an agent skims past is a digest it
1737
+ * does not read. The reason code renders only where it explains a limitation the reader has to act
1738
+ * on.
1739
+ *
1740
+ * ⚠️ ONE PRODUCER WRITES THIS TODAY, AND IT IS NOT THE HERO. `redditReportMethodology`
1741
+ * (`agent/src/scoped-research.ts`, FUL-686 / T9) stamps the summary on every scoped run whose rail
1742
+ * was ARMED; the hero orchestrator still DELETES the key at its enrich chokepoint, and T10
1743
+ * (FUL-687) owns replacing that delete with the real backfill plus the matching terminal event. So
1744
+ * a report reaching this renderer with no note is either a hero run or a pre-feature one — which
1745
+ * is exactly why UNRECORDED, and not "Reddit was not searched", is the only honest reading of
1746
+ * absence.
1747
+ */
1748
+ function renderRedditRetrieval(reportData) {
1749
+ const note = asRecord(asRecord(reportData.methodology)?.redditRetrieval);
1750
+ if (!note)
1751
+ return '';
1752
+ const outcome = str(note.outcome);
1753
+ if (!outcome || outcome === 'not_run')
1754
+ return '';
1755
+ const reason = safeInline(note.reason, 60);
1756
+ const because = reason ? ` (reason code: \`${reason}\`)` : '';
1757
+ const sourceCounter = readAcceptanceCounter(note.acceptedSourceCount);
1758
+ const excerptCounter = readAcceptanceCounter(note.acceptedExcerptCount);
1759
+ const sources = sourceCounter.kind === 'valid' ? sourceCounter.value : null;
1760
+ const excerpts = excerptCounter.kind === 'valid' ? excerptCounter.value : null;
1761
+ const validCounters = sourceCounter.kind === 'valid' && excerptCounter.kind === 'valid'
1762
+ ? { sources: sourceCounter.value, excerpts: excerptCounter.value }
1763
+ : null;
1764
+ // ⚠️ THE TWO COUNTERS ARE POSITIVE TOGETHER OR ZERO TOGETHER, AND A PAIR THAT DISAGREES IS A
1765
+ // CORRUPT NOTE (FUL-732). Both derive from the same accepted-source array and every accepted
1766
+ // source carries at least one excerpt, so `acceptedSourceCount: 0` beside `acceptedExcerptCount:
1767
+ // 7` is not a measurement this rail can produce — it is the note telling you it drifted. Reading
1768
+ // acceptance off whichever number happens to be positive trusts a record that has already proved
1769
+ // it cannot be trusted, which is what the OR below used to do.
1770
+ const countersContradict = validCounters !== null && validCounters.sources > 0 !== validCounters.excerpts > 0;
1771
+ // Counts are stated only when BOTH are readable AND they agree. "3 discussions · unknown
1772
+ // excerpts" reads as a measurement rather than as a gap in the record, and the outcome sentence
1773
+ // already carries the fact that matters; "0 accepted discussions · 7 bounded excerpts" reads as
1774
+ // a measurement of something impossible, which is worse. Gating it here rather than per-arm is
1775
+ // what stops the impossible pair reaching any outcome's wording — including the ones below that
1776
+ // never ask about acceptance at all.
1777
+ const counts = validCounters && !countersContradict
1778
+ ? ` ${plural(validCounters.sources, 'accepted discussion')} · ` +
1779
+ `${plural(validCounters.excerpts, 'bounded excerpt')}.`
1780
+ : '';
1781
+ // ⚠️ THIS QUESTION IS THREE-WAY, AND THE ORCHESTRATOR HAS COLLAPSED IT TO TWO IN BOTH
1782
+ // DIRECTIONS ALREADY (FUL-732). Read the history before touching these three lines:
1783
+ //
1784
+ // 1. FUL-728 keyed the unknown wording on `acceptedSourceCount` ALONE, so a note carrying
1785
+ // seven excerpts was reported as a pass that "may have accepted none" — a FALSE NEGATIVE.
1786
+ // 2. The correction made acceptance an OR over the two counters, so a note reporting `0`
1787
+ // sources beside `7` excerpts rendered "0 accepted discussions · 7 bounded excerpts.
1788
+ // Accepted evidence is real." plus the pool pointer — a line that contradicts itself,
1789
+ // reached by trusting whichever number was positive.
1790
+ //
1791
+ // Both are the same mistake: a two-valued test over a question with three answers. The rule that
1792
+ // holds is the table below, and neither arm may be folded into another.
1793
+ //
1794
+ // two valid counters agree and prove acceptance → accepted evidence is real
1795
+ // two valid counters agree on zero → accepted none
1796
+ // counters contradict, or either is absent/invalid → UNKNOWN wording, and NO pool pointer
1797
+ //
1798
+ // FUL-740 closes the repeated edge left by interpreting first and guarding second. A count is
1799
+ // valid only when it is a finite non-negative integer; one counter never validates its partner.
1800
+ // That makes the invalid state unrepresentable in either assertion below instead of relying on
1801
+ // every comparison to remember negatives, fractions and non-finite values separately.
1802
+ const acceptedProven = validCounters !== null &&
1803
+ !countersContradict &&
1804
+ validCounters.sources > 0 &&
1805
+ validCounters.excerpts > 0;
1806
+ const acceptedNoneProven = validCounters !== null &&
1807
+ !countersContradict &&
1808
+ validCounters.sources === 0 &&
1809
+ validCounters.excerpts === 0;
1810
+ // ⚠️ THE POOL POINTER BELONGS ONLY TO THE OUTCOMES THAT HAVE EVIDENCE. Appending it to all
1811
+ // four sent a `failed` reader looking for accepted excerpts in a pool the very next section
1812
+ // often declares EMPTY — a pointer to nothing, in the paragraph that just said the pass did not
1813
+ // complete. And even on a good run it is a POINTER, not a promise: the pool renders the sources
1814
+ // a GROUNDED claim resolved, so an accepted excerpt no claim cites is not there. The wording
1815
+ // says "any excerpt a claim cites", which is what the pool actually holds.
1816
+ const poolPointer = ' Any accepted excerpt a claim cites appears in the persona receipt pool below like any other ' +
1817
+ 'cached source; they are excerpts from a discussion, not a speaker\'s exact words.';
1818
+ // ⚠️ `completed` MEANS AT LEAST ONE SOURCE WAS ACCEPTED — that is the runtime contract, not a
1819
+ // convention — so `completed` beside a zero acceptance count is INCONSISTENT INPUT, not a state.
1820
+ // Rendering the coverage sentence for it announces a successful pass and points the reader at a
1821
+ // pool the very next section may declare EMPTY: the same defect the `completed_partial` branch
1822
+ // below already refuses, one outcome over. The honest answer to a note that contradicts itself
1823
+ // is the one an unrecognised outcome gets — coverage UNKNOWN.
1824
+ //
1825
+ // ⚠️ EITHER COUNTER READING ZERO IS THAT CONTRADICTION, NOT JUST `acceptedSourceCount`
1826
+ // (#1063 review). This branch keyed on the source count alone, on the argument that `completed`
1827
+ // independently asserts a source was accepted so the outcome sentence survives an
1828
+ // `acceptedExcerptCount: 0`. That argument uses one side of the contradiction to validate
1829
+ // itself: by the same invariant the contradictory pair rests on, every accepted source carries
1830
+ // at least one excerpt, so `excerpts === 0` PROVES `sources === 0` — the exact shape this
1831
+ // branch already refuses to render as a completed pass. Keeping the outcome sentence there and
1832
+ // dropping only the figures announced coverage from a note already known corrupt, and pointed
1833
+ // at the pool while doing it. On `completed` an impossible PAIR (7 / 0) is not a separate case,
1834
+ // because whichever valid counter reads exactly zero already contradicts the outcome.
1835
+ //
1836
+ // A `null` is UNREADABLE, not zero, and keeps the completed wording: the note omitted or
1837
+ // corrupted a telemetry counter, which is not the note claiming something impossible.
1838
+ const completedWithNoAcceptance = outcome === 'completed' &&
1839
+ (sources === 0 || excerpts === 0);
1840
+ const body = completedWithNoAcceptance
1841
+ ? `The Reddit pass reported \`completed\` beside a ZERO acceptance count${because}, which the ` +
1842
+ "rail's own contract does not allow — a completed pass accepted at least one source, and " +
1843
+ 'every accepted source carries at least one excerpt, so neither counter can read zero. The ' +
1844
+ 'note contradicts itself, so treat Reddit coverage as UNKNOWN and make no claim in either ' +
1845
+ 'direction.'
1846
+ : outcome === 'completed'
1847
+ ? `The Reddit pass completed.${counts}${poolPointer}`
1848
+ : outcome === 'completed_empty'
1849
+ ? 'No Reddit evidence cleared this run\'s bounded search and source checks. This does NOT ' +
1850
+ 'mean no relevant Reddit discussion exists, and it is not a finding about the market.'
1851
+ : outcome === 'completed_partial'
1852
+ ? // ⚠️ A PARTIAL RUN MAY HAVE ACCEPTED NOTHING, and the three cases must not read alike.
1853
+ // `completed_partial` means "a cap, deadline or failure stopped the pass", which is
1854
+ // compatible with ZERO accepted sources — so asserting "accepted evidence is real"
1855
+ // unconditionally makes a claim about evidence that may not exist, and appends a
1856
+ // pointer to a pool the next section renders EMPTY. That is the same defect the
1857
+ // pool-pointer comment above describes for `failed`, one outcome over.
1858
+ //
1859
+ // ⚠️ AND AN UNPROVABLE COUNT IS ITS OWN CASE (FUL-728). This used to take the
1860
+ // evidence wording whenever the counter was unreadable, on the argument that a run
1861
+ // cut short having accepted an unknown amount is likelier to have accepted something
1862
+ // than nothing. That argues from likelihood, and this digest does not assert from
1863
+ // likelihood: the whole outcome vocabulary exists so a reader is never handed a
1864
+ // probable fact wearing a stated one's clothes. `completed_partial` PERMITS zero —
1865
+ // that is the difference from `completed` above, whose own runtime contract
1866
+ // guarantees at least one accepted source and so keeps its wording when the counter
1867
+ // is unreadable. Here nothing guarantees it.
1868
+ //
1869
+ // ⚠️ BOTH COUNTERS MUST BE VALID BEFORE EITHER CLAIM IS AVAILABLE (FUL-740). Earlier
1870
+ // versions let one readable counter settle the question, which repeatedly made a
1871
+ // malformed partner acquire meaning through `> 0` or `<= 0`. Classification now
1872
+ // admits only finite non-negative integers, and the pair is interpreted only after
1873
+ // both pass. Missing, wrong-typed, negative, fractional, NaN and Infinity therefore
1874
+ // share the UNKNOWN arm and can manufacture neither a figure nor a claim.
1875
+ //
1876
+ // ⚠️ AND A CONTRADICTORY PAIR IS THE FOURTH SPELLING, NOT A FOURTH RULE (FUL-732). It
1877
+ // lands in the SAME row of the table as the unreadable case — UNKNOWN, no counts, no
1878
+ // pool pointer — but it gets its own sentence, because "not recorded readably" is
1879
+ // false of two numbers that are both perfectly readable and simply cannot both be
1880
+ // true. Naming the real defect is what tells an operator to go look at the producer
1881
+ // instead of at a dropped telemetry field.
1882
+ countersContradict
1883
+ ? `The Reddit pass was cut short${because}, so its coverage is PARTIAL. Its two ` +
1884
+ 'acceptance counters CONTRADICT each other, which no real pass produces, so this ' +
1885
+ 'note is corrupt on the question of what it accepted — treat Reddit as PARTIALLY ' +
1886
+ 'SEARCHED, and make no claim about what it found.'
1887
+ : acceptedNoneProven
1888
+ ? `The Reddit pass was cut short${because}, so its coverage is PARTIAL.${counts} ` +
1889
+ 'It accepted no evidence before stopping — treat Reddit as PARTIALLY SEARCHED, ' +
1890
+ 'not as searched and empty.'
1891
+ : !acceptedProven
1892
+ ? `The Reddit pass was cut short${because}, so its coverage is PARTIAL. How much ` +
1893
+ 'evidence it accepted before stopping was not recorded readably, and a partial ' +
1894
+ 'pass may have accepted none — treat Reddit as PARTIALLY SEARCHED, and make no ' +
1895
+ 'claim about what it found.'
1896
+ : `The Reddit pass was cut short${because}, so its coverage is PARTIAL.${counts} ` +
1897
+ 'Accepted evidence is real; the absence of more is not evidence of absence.' +
1898
+ poolPointer
1899
+ : outcome === 'failed'
1900
+ ? `The Reddit pass did NOT complete${because}. This run makes no claim about Reddit ` +
1901
+ 'coverage — treat it as unsearched, NOT as searched and empty.'
1902
+ : // ⚠️ A LIVE BRANCH, NOT A FORMALITY — see `RedditRetrievalNoteSchema`'s `outcome`,
1903
+ // which is deliberately lenient so a client older than the app that answered it
1904
+ // reports UNKNOWN rather than falling silent. Failing closed here is the whole
1905
+ // reason the schema does not reject the token itself.
1906
+ `The Reddit pass reported an outcome this client does not recognise ` +
1907
+ `(\`${safeInline(outcome, 40) ?? 'unreadable'}\`)${because}. Treat Reddit coverage ` +
1908
+ 'as UNKNOWN, and upgrade `@clien-ai/mcp` — the app is newer than this client.';
1909
+ return `### Reddit community evidence\n${body}`;
1910
+ }
1488
1911
  /**
1489
1912
  * Build the trust digest appended to `get_report`'s text.
1490
1913
  *
@@ -1502,6 +1925,9 @@ export function buildTrustDigest(reportData, channel) {
1502
1925
  renderReportSpine(data, channel),
1503
1926
  renderMarketSizing(data),
1504
1927
  renderCompetitorSourceQuality(data),
1928
+ // FUL-685: immediately above the pool the accepted rows land in, so a reader meets the
1929
+ // coverage caveat before the evidence rather than after it.
1930
+ renderRedditRetrieval(data),
1505
1931
  renderReceiptPools(data, channel),
1506
1932
  renderPersonas(data, channel),
1507
1933
  renderSycophancy(data, channel),