@clien-ai/mcp 0.13.0 → 0.13.2

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.
@@ -12,7 +12,7 @@
12
12
  * — see how research.ts handles it.
13
13
  */
14
14
  import { z } from 'zod';
15
- import { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_PRIORITIES, HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, hasCoherentRobustnessWorking, normalizeFinalHypothesisState, readRobustnessCounts, robustnessWorkingContradictsSummary, } from '../hypothesis-semantics.js';
15
+ import { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_PRIORITIES, HYPOTHESIS_SEMANTICS_VERSION, LEGACY_HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, finalClauseCoverageMatchesIntegrity, hasCoherentRobustnessWorking, hypothesisWebReceiptAuthenticates, normalizeFinalHypothesisState, readRobustnessCounts, reportClaimNeedsIndependentMarketAuthority, robustnessWorkingContradictsSummary, sourceAuthenticatesMarketEconomics, } from '../hypothesis-semantics.js';
16
16
  // ⚠️ A types module importing from `tools/` reads backwards, and it is deliberate.
17
17
  // `content-type-display.ts` imports NOTHING — it is the package's content-type vocabulary, which
18
18
  // happens to live beside the two renderers that were its first callers, and the FUL-341 coupling
@@ -21,6 +21,7 @@ import { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOT
21
21
  import { CONTENT_TYPE_EXCLUSIONS, CONTENT_TYPE_STATES } from '../tools/content-type-display.js';
22
22
  import { parseReceiptId } from '../tools/receipt-children.js';
23
23
  import { readSemanticTheme } from './semantic-theme.js';
24
+ import { isDisplaySuitableExcerpt } from '../lib/excerpt-suitability.js';
24
25
  export { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, } from '../hypothesis-semantics.js';
25
26
  /**
26
27
  * A competitor discovered during the research run.
@@ -264,13 +265,14 @@ export const RobustnessResultSchema = z
264
265
  */
265
266
  export const FinalHypothesisStateSchema = z
266
267
  .object({
267
- version: z.literal(HYPOTHESIS_SEMANTICS_VERSION).describe('Which semantics contract this block was written under.'),
268
+ version: z.union([z.literal(1), z.literal(HYPOTHESIS_SEMANTICS_VERSION)]).describe('Which semantics contract this block was written under.'),
268
269
  verdict: z.enum(VERDICT_STATUSES).describe('The EFFECTIVE verdict every surface shows. A robustness downgrade is applied only when it makes a strictly weaker claim, so a malformed re-ask block can never upgrade a row.'),
269
270
  originalVerdict: z.enum(VERDICT_STATUSES).describe('The synthesiser\'s own verdict, preserved for audit even when the row was downgraded.'),
270
271
  confidence: z.enum(CONFIDENCE_LEVELS).optional().describe('How reliable the final verdict is. ABSENT means withheld — see confidenceBasis. Never a probability that the hypothesis is true.'),
271
272
  confidenceBasis: z.enum(CONFIDENCE_BASES).describe('Why confidence says what it says: read off the evidence, recomputed after the verdict flipped under rephrasing, or withheld because the run attached no evidence.'),
272
273
  sufficiency: z.enum(EVIDENCE_SUFFICIENCY_LEVELS).describe('How much evidence sits behind the verdict, across how many research methods. A coverage axis, independent of which way the verdict pointed.'),
273
274
  robustness: z.enum(ROBUSTNESS_OUTCOMES).describe('What the robustness pass did: held, flipped, or never re-asked. `not_retested` is explicit — it is not the same as held.'),
275
+ clauseCoverage: z.enum(['complete', 'uncovered', 'contradicted', 'invalid']).optional(),
274
276
  priority: z.enum(HYPOTHESIS_PRIORITIES).optional().catch(undefined).describe('The product priority of the underlying hypothesis, for ranking what to go and ask about first.'),
275
277
  })
276
278
  .passthrough();
@@ -289,6 +291,8 @@ export const EvidenceSchema = z
289
291
  summary: z.string(),
290
292
  details: z.string().optional(),
291
293
  url: z.string().optional(),
294
+ nominationId: z.string().regex(/^wh2:[a-f0-9]{16}$/).optional(),
295
+ receipt: z.object({ sourceId: z.string(), quoteSpan: z.string() }).optional(),
292
296
  })
293
297
  .passthrough();
294
298
  /**
@@ -305,6 +309,20 @@ export const HypothesisResultSchema = z
305
309
  confidence: z.number().describe('Legacy numeric confidence stored on either the retired 0-1 or 0-100 scale. Preserve and label it as legacy; never compare it with final.confidence, rank on it, or trend it.'),
306
310
  supportingEvidence: z.array(EvidenceSchema).optional(),
307
311
  contradictingEvidence: z.array(EvidenceSchema).optional(),
312
+ clauseCoverage: z.array(z.object({
313
+ clauseId: z.string(),
314
+ status: z.enum(['supported', 'contradicted', 'uncovered']),
315
+ evidenceRefs: z.array(z.object({ side: z.enum(['supporting', 'contradicting']), index: z.number().int().min(0) })),
316
+ })).optional(),
317
+ clauseIntegrity: z.object({
318
+ outcome: z.enum(['complete', 'uncovered', 'contradicted', 'invalid']),
319
+ clauses: z.array(z.object({
320
+ clauseId: z.string(),
321
+ text: z.string(),
322
+ status: z.enum(['supported', 'contradicted', 'uncovered']),
323
+ evidenceRefs: z.array(z.object({ side: z.enum(['supporting', 'contradicting']), index: z.number().int().min(0) })),
324
+ })),
325
+ }).optional(),
308
326
  robustness: RobustnessResultSchema.optional().describe('Robustness re-ask survival count for this verdict (absent on legacy reports / when not re-tested).'),
309
327
  scopeCaveat: z.string().optional().describe('Belief-adjacent scope-mismatch note (FUL-102): present when this hypothesis\'s persona support came from a persona speaking outside its credibility domain. Treat such support as directional, not grounded.'),
310
328
  final: FinalHypothesisStateSchema.optional().describe('THE effective result (FUL-358) — verdict, reliability, evidence sufficiency and robustness outcome, reconciled once and read by every surface. Prefer `final.verdict` over `status` and `final.confidence` over the numeric `confidence` above. Absent on legacy reports, where the numeric `confidence` is the retired measure.'),
@@ -447,7 +465,7 @@ export const InsightSchema = z
447
465
  * way), but every OTHER typed field on that report loses its typing too. Which is why
448
466
  * this list grows in the SAME change as the agent's, never "when someone notices".
449
467
  */
450
- export const OPEN_QUESTION_STATUSES = ['never_asked', 'nobody_answered', 'voices_disagreed'];
468
+ export const OPEN_QUESTION_STATUSES = ['never_asked', 'nobody_answered', 'voices_disagreed', 'clause_uncovered'];
451
469
  /**
452
470
  * One thing the run did not settle. MIRROR of the agent's `OpenQuestionSchema`
453
471
  * (`agent/src/types.ts`); key set pinned by the coupling test.
@@ -468,6 +486,7 @@ export const OpenQuestionSchema = z
468
486
  status: z
469
487
  .enum(OPEN_QUESTION_STATUSES)
470
488
  .describe('WHY it is still open: never_asked (the run never touched it), nobody_answered (put to personas, none took a position), voices_disagreed (the personas split).'),
489
+ clauseId: z.string().optional().describe('Stable clause ID for clause_uncovered provenance.'),
471
490
  // Lenient `z.number()` against the agent's `z.number().int().min(0)`, deliberately.
472
491
  // This is the one field on the row the app is willing to degrade: its parser clamps a
473
492
  // count the run's own persona roster cannot support down to 0 and renders the
@@ -478,7 +497,12 @@ export const OpenQuestionSchema = z
478
497
  .number()
479
498
  .describe('How many DISTINCT personas were PUT this hypothesis — an interview counts when it returned a finding on it OR when the persona\'s brief claimed it, because a persona who was asked and said nothing was still asked. 0 for never_asked by construction; for nobody_answered it is what makes the row specific ("3 were asked; none took a position") rather than a vague claim of silence.'),
480
499
  })
481
- .passthrough();
500
+ .passthrough()
501
+ .superRefine((row, ctx) => {
502
+ if (row.status === 'clause_uncovered' && !row.clauseId) {
503
+ ctx.addIssue({ code: 'custom', path: ['clauseId'], message: 'Uncovered clauses require a clause ID' });
504
+ }
505
+ });
482
506
  /**
483
507
  * Persona identity priors (Exa Rail M5b, FUL-150 / U10). The demographic shape of
484
508
  * a persona's role — an explicit seniority RUNG + a career trajectory — force-filled
@@ -641,7 +665,7 @@ export const ReportClaimSchema = z
641
665
  */
642
666
  export const ReportEvidenceSchema = z
643
667
  .object({
644
- section: ReportClaimSectionSchema,
668
+ section: z.enum([...REPORT_CLAIM_SECTIONS, 'hypothesis']),
645
669
  url: z.string().describe('Live URL the highlight was collected from.'),
646
670
  platform: z.string().describe('The host the highlight came from.'),
647
671
  quote: z.string().describe('Verbatim cached excerpt (≤600 chars) so a market/competitor figure renders even if the URL later 404s.'),
@@ -666,6 +690,8 @@ export const ReportEvidenceSchema = z
666
690
  ' Stamped from the source by code, never model-emitted. ABSENT means UNCLASSIFIED: the ' +
667
691
  'source type is genuinely unknown, NOT checked and found benign. ' +
668
692
  CONTENT_TYPE_EXCLUSIONS),
693
+ publisherSubjectRelationship: z.enum(['independent', 'self', 'unknown']).optional(),
694
+ marketEconomicsAuthority: z.enum(['independent', 'commercial_report_seller', 'unknown']).optional(),
669
695
  })
670
696
  .passthrough();
671
697
  /**
@@ -837,6 +863,12 @@ export const MethodologyNoteSchema = z
837
863
  totalTokensUsed: z.number(),
838
864
  jobDurationSeconds: z.number(),
839
865
  saturation: SaturationNoteSchema.optional(),
866
+ evidenceOutcome: z
867
+ .object({ code: z.literal('bounded_search_no_admissible_evidence') })
868
+ .strict()
869
+ .optional()
870
+ .catch(undefined)
871
+ .describe('Bounded acquisition completed without admissible external evidence.'),
840
872
  // FUL-685 (T8 / D6). `.catch(undefined)` and not merely `.optional()`: a malformed or
841
873
  // unrecognised value must degrade THIS FIELD to unrecorded, never reject an otherwise valid
842
874
  // legacy report — the same fail-soft-per-field posture `personasSynthesis` takes.
@@ -928,6 +960,7 @@ export const ReportOverviewSchema = z.object({
928
960
  */
929
961
  export const ResearchReportDataSchema = z
930
962
  .object({
963
+ hypothesisSemanticsVersion: z.number().optional(),
931
964
  competitors: z.array(CompetitorSchema).optional(),
932
965
  hypotheses: z.array(z.unknown()).optional(),
933
966
  /**
@@ -1122,20 +1155,101 @@ export function projectReportDataForMcp(raw) {
1122
1155
  value.replace(/[\p{C}\p{Z}\p{Default_Ignorable_Code_Point}]/gu, '').length > 0;
1123
1156
  const hasHighlights = Object.prototype.hasOwnProperty.call(record, 'interviewHighlights');
1124
1157
  const hasHypothesisResults = Object.prototype.hasOwnProperty.call(record, 'hypothesisResults');
1158
+ const hasClaims = Object.prototype.hasOwnProperty.call(record, 'claims');
1159
+ const hasReportClaims = Object.prototype.hasOwnProperty.call(record, 'reportClaims');
1160
+ const hasReportEvidence = Object.prototype.hasOwnProperty.call(record, 'reportEvidence');
1125
1161
  const hasReportTitle = Object.prototype.hasOwnProperty.call(record, 'reportTitle');
1126
1162
  const hasOverview = Object.prototype.hasOwnProperty.call(record, 'marketOverview') ||
1127
1163
  Object.prototype.hasOwnProperty.call(record, 'communityOverview');
1128
1164
  const hasCrossCutting = Object.prototype.hasOwnProperty.call(record, 'crossCuttingFindings');
1129
1165
  const hasPersonas = Object.prototype.hasOwnProperty.call(record, 'personas');
1130
1166
  const hasForumResearch = Object.prototype.hasOwnProperty.call(record, 'forumResearch');
1167
+ const hasHypothesisSemanticsVersion = Object.prototype.hasOwnProperty.call(record, 'hypothesisSemanticsVersion');
1131
1168
  const methodology = record.methodology;
1132
1169
  const hasRedditNote = methodology !== null &&
1133
1170
  typeof methodology === 'object' &&
1134
1171
  !Array.isArray(methodology) &&
1135
1172
  Object.prototype.hasOwnProperty.call(methodology, 'redditRetrieval');
1136
- if (!hasHighlights && !hasRedditNote && !hasHypothesisResults && !hasOverview && !hasReportTitle && !hasCrossCutting && !hasPersonas && !hasForumResearch)
1173
+ if (!hasHighlights && !hasRedditNote && !hasHypothesisResults && !hasClaims && !hasReportClaims && !hasReportEvidence && !hasOverview && !hasReportTitle && !hasCrossCutting && !hasPersonas && !hasForumResearch && !hasHypothesisSemanticsVersion)
1137
1174
  return raw;
1138
1175
  const projected = { ...record };
1176
+ // Source excerpts are immutable evidence in storage, but every MCP channel is a display
1177
+ // surface. Rebuild only the containers which can carry verbatim source text, preserving safe
1178
+ // strings byte-for-byte and omitting an unsafe exact receipt rather than repairing its text.
1179
+ const projectReceipts = (value) => {
1180
+ if (!Array.isArray(value))
1181
+ return undefined;
1182
+ const safeReceipts = [];
1183
+ for (const rawReceipt of value) {
1184
+ const receipt = asProjectionRecord(rawReceipt);
1185
+ if (!receipt || typeof receipt.excerpt !== 'string')
1186
+ return undefined;
1187
+ if (isDisplaySuitableExcerpt(receipt.excerpt))
1188
+ safeReceipts.push({ ...receipt });
1189
+ }
1190
+ return safeReceipts;
1191
+ };
1192
+ const projectSource = (rawSource) => {
1193
+ const source = asProjectionRecord(rawSource);
1194
+ if (!source)
1195
+ return rawSource;
1196
+ const next = { ...source };
1197
+ if (Object.prototype.hasOwnProperty.call(source, 'quote')) {
1198
+ if (typeof source.quote === 'string' && !isDisplaySuitableExcerpt(source.quote)) {
1199
+ // `undefined` is absent on the JSON wire but remains distinguishable from a historical
1200
+ // source which never had a quote, so renderers can downgrade the dependent receipt.
1201
+ next.quote = undefined;
1202
+ }
1203
+ else if (typeof source.quote !== 'string') {
1204
+ delete next.quote;
1205
+ }
1206
+ }
1207
+ if (Object.prototype.hasOwnProperty.call(source, 'receipts')) {
1208
+ const receipts = projectReceipts(source.receipts);
1209
+ if (receipts === undefined)
1210
+ delete next.receipts;
1211
+ else
1212
+ next.receipts = receipts;
1213
+ }
1214
+ return next;
1215
+ };
1216
+ if (hasReportEvidence) {
1217
+ projected.reportEvidence = Array.isArray(record.reportEvidence)
1218
+ ? record.reportEvidence.map(projectSource)
1219
+ : [];
1220
+ }
1221
+ const reportEvidence = Array.isArray(projected.reportEvidence) ? projected.reportEvidence : [];
1222
+ const rejectedMarketEconomics = (Array.isArray(record.reportClaims) ? record.reportClaims : [])
1223
+ .some((rawClaim) => {
1224
+ const claim = asProjectionRecord(rawClaim);
1225
+ if (!claim || !reportClaimNeedsIndependentMarketAuthority(claim.section, claim.text))
1226
+ return false;
1227
+ const state = claim.state;
1228
+ const attestation = asProjectionRecord(claim.attestation);
1229
+ const sourceId = state === 'GROUNDED'
1230
+ ? claim.sourceId
1231
+ : state === 'NO_RECEIPT' && hasProjectionVisibleContent(attestation?.quote)
1232
+ ? attestation?.sourceId
1233
+ : null;
1234
+ const match = typeof sourceId === 'string' ? /^RRCP-s(0|[1-9]\d*)$/.exec(sourceId) : null;
1235
+ const source = match ? asProjectionRecord(reportEvidence[Number(match[1])]) : null;
1236
+ return Boolean(source && !sourceAuthenticatesMarketEconomics(source));
1237
+ });
1238
+ if (rejectedMarketEconomics)
1239
+ delete projected.recommendations;
1240
+ let clauseSemanticsFailure = false;
1241
+ const outerVersion = record.hypothesisSemanticsVersion;
1242
+ const outerVersionPresent = hasHypothesisSemanticsVersion;
1243
+ const rawHypothesisRows = Array.isArray(record.hypothesisResults) ? record.hypothesisResults : [];
1244
+ const rawRowsClaimV2 = rawHypothesisRows.some((rawRow) => {
1245
+ const row = asProjectionRecord(rawRow);
1246
+ return asProjectionRecord(row?.final)?.version === HYPOTHESIS_SEMANTICS_VERSION ||
1247
+ (row !== null && Object.prototype.hasOwnProperty.call(row, 'clauseIntegrity'));
1248
+ });
1249
+ const requiresClauseIntegrity = rawRowsClaimV2 || (outerVersionPresent && outerVersion !== LEGACY_HYPOTHESIS_SEMANTICS_VERSION);
1250
+ if (requiresClauseIntegrity && rawHypothesisRows.length === 0) {
1251
+ clauseSemanticsFailure = true;
1252
+ }
1139
1253
  if (hasReportTitle)
1140
1254
  projected.reportTitle = sanitizeMcpReportTitle(record.reportTitle);
1141
1255
  // Validate the frozen decoration before either the typed parse or raw schema-drift exit.
@@ -1147,6 +1261,10 @@ export function projectReportDataForMcp(raw) {
1147
1261
  if (!persona)
1148
1262
  return rawPersona;
1149
1263
  const nextPersona = { ...persona };
1264
+ if (Array.isArray(persona.sources))
1265
+ nextPersona.sources = persona.sources.map(projectSource);
1266
+ else if (Object.prototype.hasOwnProperty.call(persona, 'sources'))
1267
+ delete nextPersona.sources;
1150
1268
  const portrait = ReportPortraitSchema.safeParse(persona.portrait);
1151
1269
  if (portrait.success)
1152
1270
  nextPersona.portrait = portrait.data;
@@ -1174,6 +1292,19 @@ export function projectReportDataForMcp(raw) {
1174
1292
  if (!thread)
1175
1293
  return rawThread;
1176
1294
  const nextThread = { ...thread };
1295
+ if (Array.isArray(thread.relevantQuotes)) {
1296
+ nextThread.relevantQuotes = thread.relevantQuotes.filter(isDisplaySuitableExcerpt);
1297
+ }
1298
+ else if (Object.prototype.hasOwnProperty.call(thread, 'relevantQuotes')) {
1299
+ delete nextThread.relevantQuotes;
1300
+ }
1301
+ if (Object.prototype.hasOwnProperty.call(thread, 'receipts')) {
1302
+ const receipts = projectReceipts(thread.receipts);
1303
+ if (receipts === undefined)
1304
+ delete nextThread.receipts;
1305
+ else
1306
+ nextThread.receipts = receipts;
1307
+ }
1177
1308
  const theme = typeof thread.title === 'string'
1178
1309
  ? readSemanticTheme(thread.theme, thread.title)
1179
1310
  : null;
@@ -1195,6 +1326,105 @@ export function projectReportDataForMcp(raw) {
1195
1326
  delete projected.forumResearch;
1196
1327
  }
1197
1328
  }
1329
+ const projectionSupportsSpan = (excerpt, span) => {
1330
+ if (!isDisplaySuitableExcerpt(excerpt))
1331
+ return false;
1332
+ if (span === undefined || span === null)
1333
+ return true;
1334
+ if (typeof span !== 'string')
1335
+ return false;
1336
+ const normalize = (value) => value
1337
+ .normalize('NFC')
1338
+ .replace(/\s+/gu, ' ')
1339
+ .trim()
1340
+ .toLocaleLowerCase('en-US');
1341
+ const normalizedSpan = normalize(span);
1342
+ return normalizedSpan.length > 0 && normalize(excerpt).includes(normalizedSpan);
1343
+ };
1344
+ const omitClaimReceipt = (claim) => {
1345
+ const next = { ...claim };
1346
+ delete next.sourceId;
1347
+ delete next.receiptId;
1348
+ delete next.quoteSpan;
1349
+ delete next.attestation;
1350
+ return next;
1351
+ };
1352
+ if (hasReportClaims) {
1353
+ projected.reportClaims = Array.isArray(record.reportClaims)
1354
+ ? record.reportClaims.map((rawClaim) => {
1355
+ const claim = asProjectionRecord(rawClaim);
1356
+ if (!claim)
1357
+ return rawClaim;
1358
+ if (claim.state === 'GROUNDED') {
1359
+ const hasSourceId = Object.prototype.hasOwnProperty.call(claim, 'sourceId');
1360
+ const match = typeof claim.sourceId === 'string'
1361
+ ? /^RRCP-s(0|[1-9]\d*)$/.exec(claim.sourceId)
1362
+ : null;
1363
+ const rawSource = match
1364
+ ? asProjectionRecord(Array.isArray(record.reportEvidence)
1365
+ ? record.reportEvidence[Number(match[1])]
1366
+ : undefined)
1367
+ : null;
1368
+ if (!hasSourceId && Object.prototype.hasOwnProperty.call(claim, 'quoteSpan')) {
1369
+ return omitClaimReceipt(claim);
1370
+ }
1371
+ if (hasSourceId && (!rawSource || !projectionSupportsSpan(rawSource.quote, claim.quoteSpan))) {
1372
+ return omitClaimReceipt(claim);
1373
+ }
1374
+ }
1375
+ else if (claim.state === 'NO_RECEIPT') {
1376
+ const attestation = asProjectionRecord(claim.attestation);
1377
+ if (attestation && !isDisplaySuitableExcerpt(attestation.quote)) {
1378
+ const next = { ...claim };
1379
+ delete next.attestation;
1380
+ return next;
1381
+ }
1382
+ }
1383
+ return { ...claim };
1384
+ })
1385
+ : [];
1386
+ }
1387
+ if (Object.prototype.hasOwnProperty.call(record, 'claims')) {
1388
+ projected.claims = Array.isArray(record.claims)
1389
+ ? record.claims.map((rawClaim) => {
1390
+ const claim = asProjectionRecord(rawClaim);
1391
+ if (!claim || claim.state !== 'GROUNDED')
1392
+ return rawClaim;
1393
+ const hasSourceId = Object.prototype.hasOwnProperty.call(claim, 'sourceId');
1394
+ const match = typeof claim.sourceId === 'string'
1395
+ ? /^RCP-p(0|[1-9]\d*)-s(0|[1-9]\d*)$/.exec(claim.sourceId)
1396
+ : null;
1397
+ const personas = Array.isArray(record.personas) ? record.personas : [];
1398
+ const persona = match ? asProjectionRecord(personas[Number(match[1])]) : null;
1399
+ const sources = Array.isArray(persona?.sources) ? persona.sources : [];
1400
+ const source = match ? asProjectionRecord(sources[Number(match[2])]) : null;
1401
+ if (!hasSourceId && Object.prototype.hasOwnProperty.call(claim, 'quoteSpan')) {
1402
+ return omitClaimReceipt(claim);
1403
+ }
1404
+ if (hasSourceId && !source)
1405
+ return omitClaimReceipt(claim);
1406
+ if (!source)
1407
+ return { ...claim };
1408
+ const receipts = Array.isArray(source.receipts) ? source.receipts : [];
1409
+ const hasReceiptId = Object.prototype.hasOwnProperty.call(claim, 'receiptId');
1410
+ if (hasReceiptId) {
1411
+ const matches = receipts.filter((rawReceipt) => asProjectionRecord(rawReceipt)?.receiptId === claim.receiptId);
1412
+ const receipt = matches.length === 1 ? asProjectionRecord(matches[0]) : null;
1413
+ return receipt && projectionSupportsSpan(receipt.excerpt, claim.quoteSpan)
1414
+ ? { ...claim }
1415
+ : omitClaimReceipt(claim);
1416
+ }
1417
+ if (typeof source.quote === 'string' &&
1418
+ !projectionSupportsSpan(source.quote, claim.quoteSpan)) {
1419
+ return omitClaimReceipt(claim);
1420
+ }
1421
+ if (claim.quoteSpan !== undefined && typeof source.quote !== 'string') {
1422
+ return omitClaimReceipt(claim);
1423
+ }
1424
+ return { ...claim };
1425
+ })
1426
+ : [];
1427
+ }
1198
1428
  const projectOverviewRef = (rawRef) => {
1199
1429
  if (rawRef === null || typeof rawRef !== 'object' || Array.isArray(rawRef))
1200
1430
  return null;
@@ -1243,6 +1473,32 @@ export function projectReportDataForMcp(raw) {
1243
1473
  const safeRef = projectOverviewRef(rawRef);
1244
1474
  if (!safeRef)
1245
1475
  continue;
1476
+ if (safeRef.kind === 'report_evidence') {
1477
+ const match = typeof safeRef.sourceId === 'string'
1478
+ ? /^RRCP-s(0|[1-9]\d*)$/.exec(safeRef.sourceId)
1479
+ : null;
1480
+ const source = match
1481
+ ? asProjectionRecord(Array.isArray(projected.reportEvidence)
1482
+ ? projected.reportEvidence[Number(match[1])]
1483
+ : undefined)
1484
+ : null;
1485
+ if (!source || !projectionSupportsSpan(source.quote, claim.span))
1486
+ continue;
1487
+ }
1488
+ if (safeRef.kind === 'forum_receipt') {
1489
+ const matches = (Array.isArray(asProjectionRecord(projected.forumResearch)?.threads)
1490
+ ? asProjectionRecord(projected.forumResearch).threads
1491
+ : []).flatMap((rawThread) => {
1492
+ const thread = asProjectionRecord(rawThread);
1493
+ return (Array.isArray(thread?.receipts) ? thread.receipts : []).filter((rawReceipt) => {
1494
+ const receipt = asProjectionRecord(rawReceipt);
1495
+ return receipt !== null && receipt.receiptId === safeRef.receiptId &&
1496
+ projectionSupportsSpan(receipt.excerpt, claim.span);
1497
+ });
1498
+ });
1499
+ if (matches.length !== 1)
1500
+ continue;
1501
+ }
1246
1502
  const refKey = safeRef.kind === 'report_evidence'
1247
1503
  ? `${safeRef.kind}:${safeRef.sourceId}`
1248
1504
  : safeRef.kind === 'forum_receipt'
@@ -1258,13 +1514,13 @@ export function projectReportDataForMcp(raw) {
1258
1514
  return [{
1259
1515
  span: claim.span,
1260
1516
  claimType: claim.claimType,
1261
- evidenceRefsTruncated: rawEvidenceRefs.length > MCP_OVERVIEW_REF_CAP,
1517
+ evidenceRefsTruncated: claim.evidenceRefsTruncated === true || rawEvidenceRefs.length > MCP_OVERVIEW_REF_CAP,
1262
1518
  evidenceRefs,
1263
1519
  }];
1264
1520
  });
1265
1521
  projected[key] = {
1266
1522
  text: overviewRecord.text,
1267
- claimSpansTruncated: overviewRecord.claimSpans.length > MCP_OVERVIEW_SPAN_CAP,
1523
+ claimSpansTruncated: overviewRecord.claimSpansTruncated === true || overviewRecord.claimSpans.length > MCP_OVERVIEW_SPAN_CAP,
1268
1524
  claimSpans,
1269
1525
  };
1270
1526
  }
@@ -1285,22 +1541,24 @@ export function projectReportDataForMcp(raw) {
1285
1541
  const resolvesCrossCuttingRef = (ref) => {
1286
1542
  if (ref.kind === 'report_evidence') {
1287
1543
  const match = /^RRCP-s(0|[1-9]\d*)$/.exec(ref.sourceId ?? '');
1288
- const source = match && asProjectionRecord(Array.isArray(record.reportEvidence)
1289
- ? record.reportEvidence[Number(match[1])]
1544
+ const source = match && asProjectionRecord(Array.isArray(projected.reportEvidence)
1545
+ ? projected.reportEvidence[Number(match[1])]
1290
1546
  : undefined);
1291
1547
  return Boolean(source &&
1292
1548
  safeProjectionUrl(source.url) &&
1549
+ (!Object.prototype.hasOwnProperty.call(source, 'quote') ||
1550
+ isDisplaySuitableExcerpt(source.quote)) &&
1293
1551
  (source.section === 'market' || source.section === 'competitor' || source.section === 'summary'));
1294
1552
  }
1295
1553
  if (ref.kind === 'forum_receipt') {
1296
- const matches = (Array.isArray(asProjectionRecord(record.forumResearch)?.threads)
1297
- ? asProjectionRecord(record.forumResearch).threads
1554
+ const matches = (Array.isArray(asProjectionRecord(projected.forumResearch)?.threads)
1555
+ ? asProjectionRecord(projected.forumResearch).threads
1298
1556
  : []).flatMap((rawThread) => {
1299
1557
  const thread = asProjectionRecord(rawThread);
1300
1558
  return (Array.isArray(thread?.receipts) ? thread.receipts : []).filter((rawReceipt) => {
1301
1559
  const receipt = asProjectionRecord(rawReceipt);
1302
1560
  return Boolean(receipt && receipt.receiptId === ref.receiptId &&
1303
- hasProjectionVisibleContent(receipt.excerpt));
1561
+ isDisplaySuitableExcerpt(receipt.excerpt));
1304
1562
  });
1305
1563
  });
1306
1564
  return matches.length === 1;
@@ -1357,18 +1615,110 @@ export function projectReportDataForMcp(raw) {
1357
1615
  }
1358
1616
  if (hasHypothesisResults && Array.isArray(record.hypothesisResults)) {
1359
1617
  projected.hypothesisResults = record.hypothesisResults.map((rawRow) => {
1360
- if (rawRow === null || typeof rawRow !== 'object' || Array.isArray(rawRow))
1618
+ if (rawRow === null || typeof rawRow !== 'object' || Array.isArray(rawRow)) {
1619
+ if (requiresClauseIntegrity)
1620
+ clauseSemanticsFailure = true;
1361
1621
  return rawRow;
1622
+ }
1362
1623
  const row = rawRow;
1363
1624
  const safeRow = { ...row };
1364
1625
  let changed = false;
1365
- if (Object.prototype.hasOwnProperty.call(row, 'final')) {
1366
- const normalized = normalizeFinalHypothesisState(row);
1626
+ let evidenceSemanticsFailure = false;
1627
+ const integrityClauses = asProjectionRecord(row.clauseIntegrity)?.clauses;
1628
+ for (const key of ['supportingEvidence', 'contradictingEvidence']) {
1629
+ if (!Array.isArray(row[key])) {
1630
+ if (Object.prototype.hasOwnProperty.call(row, key)) {
1631
+ // A schema-drift fallback must not forward a malformed evidence container whose
1632
+ // nested shape bypasses the per-item raw nomination scrub below.
1633
+ delete safeRow[key];
1634
+ changed = true;
1635
+ }
1636
+ continue;
1637
+ }
1638
+ const side = key === 'supportingEvidence' ? 'supporting' : 'contradicting';
1639
+ safeRow[key] = row[key].flatMap((rawEvidence, evidenceIndex) => {
1640
+ const evidence = asProjectionRecord(rawEvidence);
1641
+ if (!evidence) {
1642
+ // Dropping the item also invalidates index-based clause refs; preserving the old
1643
+ // working could make the next evidence row inherit a removed row's authority.
1644
+ evidenceSemanticsFailure = true;
1645
+ return [];
1646
+ }
1647
+ const safeEvidence = { ...evidence };
1648
+ // These are model-owned nomination fields on every evidence shape, even when `source`
1649
+ // is absent, malformed, or lies about the method. They never cross the MCP boundary.
1650
+ delete safeEvidence.url;
1651
+ delete safeEvidence.nominationId;
1652
+ if (evidence.source !== 'web_research') {
1653
+ // Only web evidence can carry a producer-granted report receipt. A model-authored
1654
+ // pointer on any other method is inert and must not cross either MCP exit.
1655
+ delete safeEvidence.receipt;
1656
+ }
1657
+ else if (Object.prototype.hasOwnProperty.call(evidence, 'receipt')) {
1658
+ const linkedClauses = Array.isArray(integrityClauses)
1659
+ ? integrityClauses.flatMap((rawClause) => {
1660
+ const clause = asProjectionRecord(rawClause);
1661
+ const text = typeof clause?.text === 'string' ? clause.text : null;
1662
+ const refs = Array.isArray(clause?.evidenceRefs) ? clause.evidenceRefs : [];
1663
+ const linked = refs.some((rawRef) => {
1664
+ const ref = asProjectionRecord(rawRef);
1665
+ return ref?.side === side && ref.index === evidenceIndex;
1666
+ });
1667
+ return linked && text ? [text] : [];
1668
+ })
1669
+ : [];
1670
+ const authenticatesEveryLinkedClause = linkedClauses.length > 0 && linkedClauses.every((text) => hypothesisWebReceiptAuthenticates(evidence, text, row.category, record));
1671
+ if (!authenticatesEveryLinkedClause) {
1672
+ delete safeEvidence.receipt;
1673
+ // An unreferenced receipt is inert model decoration. A referenced one is the
1674
+ // producer-owned basis of clauseIntegrity, so rejecting it invalidates that working.
1675
+ if (linkedClauses.length > 0)
1676
+ evidenceSemanticsFailure = true;
1677
+ }
1678
+ }
1679
+ return [safeEvidence];
1680
+ });
1681
+ changed = true;
1682
+ }
1683
+ // A rejected source/span cannot leave a sibling producer-owned coverage block claiming that
1684
+ // same evidence completed a clause. Preserve the row and evidence prose, but remove the
1685
+ // unauthenticated working and force the final block through the current fail-closed state.
1686
+ if (evidenceSemanticsFailure) {
1687
+ delete safeRow.clauseIntegrity;
1688
+ changed = true;
1689
+ }
1690
+ const finalVersion = asProjectionRecord(row.final)?.version;
1691
+ const rowClaimsCurrentSemantics = finalVersion === HYPOTHESIS_SEMANTICS_VERSION ||
1692
+ Object.prototype.hasOwnProperty.call(row, 'clauseIntegrity');
1693
+ const clauseIntegrityMismatch = finalVersion === HYPOTHESIS_SEMANTICS_VERSION &&
1694
+ !finalClauseCoverageMatchesIntegrity(safeRow, record);
1695
+ const semanticsMismatch = (rowClaimsCurrentSemantics && outerVersion !== HYPOTHESIS_SEMANTICS_VERSION) || (outerVersionPresent && (outerVersion === HYPOTHESIS_SEMANTICS_VERSION
1696
+ ? finalVersion !== HYPOTHESIS_SEMANTICS_VERSION
1697
+ : outerVersion === LEGACY_HYPOTHESIS_SEMANTICS_VERSION
1698
+ ? finalVersion !== undefined && finalVersion !== LEGACY_HYPOTHESIS_SEMANTICS_VERSION
1699
+ : true));
1700
+ if (clauseIntegrityMismatch || semanticsMismatch) {
1701
+ delete safeRow.clauseIntegrity;
1702
+ changed = true;
1703
+ }
1704
+ if (Object.prototype.hasOwnProperty.call(row, 'final') || semanticsMismatch) {
1705
+ const rowForNormalization = semanticsMismatch || clauseIntegrityMismatch
1706
+ ? { ...safeRow, final: { version: HYPOTHESIS_SEMANTICS_VERSION } }
1707
+ : safeRow;
1708
+ const normalized = normalizeFinalHypothesisState(rowForNormalization);
1709
+ if (semanticsMismatch ||
1710
+ clauseIntegrityMismatch ||
1711
+ normalized?.clauseCoverage === 'invalid' ||
1712
+ (normalized?.clauseCoverage !== undefined &&
1713
+ normalized.clauseCoverage !== 'complete' &&
1714
+ normalized.originalVerdict !== normalized.verdict) ||
1715
+ (requiresClauseIntegrity && normalized === null))
1716
+ clauseSemanticsFailure = true;
1367
1717
  if (normalized)
1368
1718
  safeRow.final = normalized;
1369
1719
  else
1370
1720
  delete safeRow.final;
1371
- changed = safeRow.final !== row.final;
1721
+ changed ||= safeRow.final !== row.final;
1372
1722
  }
1373
1723
  const rawRobustness = row.robustness;
1374
1724
  const robustnessRecord = rawRobustness !== null &&
@@ -1415,6 +1765,11 @@ export function projectReportDataForMcp(raw) {
1415
1765
  return safeRow;
1416
1766
  });
1417
1767
  }
1768
+ if (clauseSemanticsFailure) {
1769
+ projected.executiveSummary = 'This run did not establish every material part of at least one hypothesis. Treat the affected hypotheses as unresolved.';
1770
+ projected.verdictSummary = projected.executiveSummary;
1771
+ delete projected.recommendations;
1772
+ }
1418
1773
  // ⚠️ FUL-685 — THE REDDIT NOTE IS PROJECTED HERE FOR THE SAME REASON `researchTaskId` IS:
1419
1774
  // `prepareReportDataForMcp` returns this projection UNPARSED when the full schema drifts, so a
1420
1775
  // field stripped only by `RedditRetrievalNoteSchema` would come straight back on that path — and