instar 1.3.730 → 1.3.732

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.
Files changed (83) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +161 -1
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  5. package/dist/config/ConfigDefaults.js +26 -0
  6. package/dist/config/ConfigDefaults.js.map +1 -1
  7. package/dist/core/CoherenceGate.d.ts +119 -0
  8. package/dist/core/CoherenceGate.d.ts.map +1 -1
  9. package/dist/core/CoherenceGate.js +342 -19
  10. package/dist/core/CoherenceGate.js.map +1 -1
  11. package/dist/core/CoherenceReviewer.d.ts +30 -0
  12. package/dist/core/CoherenceReviewer.d.ts.map +1 -1
  13. package/dist/core/CoherenceReviewer.js +14 -0
  14. package/dist/core/CoherenceReviewer.js.map +1 -1
  15. package/dist/core/ConversationRegistry.d.ts +260 -0
  16. package/dist/core/ConversationRegistry.d.ts.map +1 -0
  17. package/dist/core/ConversationRegistry.js +1151 -0
  18. package/dist/core/ConversationRegistry.js.map +1 -0
  19. package/dist/core/FileClassifier.d.ts.map +1 -1
  20. package/dist/core/FileClassifier.js +7 -0
  21. package/dist/core/FileClassifier.js.map +1 -1
  22. package/dist/core/PostUpdateMigrator.d.ts +13 -0
  23. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  24. package/dist/core/PostUpdateMigrator.js +105 -0
  25. package/dist/core/PostUpdateMigrator.js.map +1 -1
  26. package/dist/core/ResponseReviewDecisionLog.d.ts +45 -0
  27. package/dist/core/ResponseReviewDecisionLog.d.ts.map +1 -0
  28. package/dist/core/ResponseReviewDecisionLog.js +85 -0
  29. package/dist/core/ResponseReviewDecisionLog.js.map +1 -0
  30. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  31. package/dist/core/WriteDomainRegistry.js +21 -0
  32. package/dist/core/WriteDomainRegistry.js.map +1 -1
  33. package/dist/core/conversationContextWiring.d.ts +51 -0
  34. package/dist/core/conversationContextWiring.d.ts.map +1 -0
  35. package/dist/core/conversationContextWiring.js +78 -0
  36. package/dist/core/conversationContextWiring.js.map +1 -0
  37. package/dist/core/conversationIdentity.d.ts +112 -0
  38. package/dist/core/conversationIdentity.d.ts.map +1 -0
  39. package/dist/core/conversationIdentity.js +141 -0
  40. package/dist/core/conversationIdentity.js.map +1 -0
  41. package/dist/core/deliverToConversation.d.ts +62 -0
  42. package/dist/core/deliverToConversation.d.ts.map +1 -0
  43. package/dist/core/deliverToConversation.js +85 -0
  44. package/dist/core/deliverToConversation.js.map +1 -0
  45. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  46. package/dist/core/devGatedFeatures.js +6 -0
  47. package/dist/core/devGatedFeatures.js.map +1 -1
  48. package/dist/core/reviewers/conversational-tone.d.ts.map +1 -1
  49. package/dist/core/reviewers/conversational-tone.js +15 -1
  50. package/dist/core/reviewers/conversational-tone.js.map +1 -1
  51. package/dist/core/types.d.ts +76 -0
  52. package/dist/core/types.d.ts.map +1 -1
  53. package/dist/core/types.js.map +1 -1
  54. package/dist/core/untrustedConversationContext.d.ts +77 -0
  55. package/dist/core/untrustedConversationContext.d.ts.map +1 -0
  56. package/dist/core/untrustedConversationContext.js +134 -0
  57. package/dist/core/untrustedConversationContext.js.map +1 -0
  58. package/dist/monitoring/ReviewCanaryBattery.d.ts +160 -0
  59. package/dist/monitoring/ReviewCanaryBattery.d.ts.map +1 -0
  60. package/dist/monitoring/ReviewCanaryBattery.js +416 -0
  61. package/dist/monitoring/ReviewCanaryBattery.js.map +1 -0
  62. package/dist/scaffold/templates.d.ts.map +1 -1
  63. package/dist/scaffold/templates.js +15 -0
  64. package/dist/scaffold/templates.js.map +1 -1
  65. package/dist/server/AgentServer.d.ts +7 -0
  66. package/dist/server/AgentServer.d.ts.map +1 -1
  67. package/dist/server/AgentServer.js +13 -0
  68. package/dist/server/AgentServer.js.map +1 -1
  69. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  70. package/dist/server/CapabilityIndex.js +15 -0
  71. package/dist/server/CapabilityIndex.js.map +1 -1
  72. package/dist/server/routes.d.ts +10 -0
  73. package/dist/server/routes.d.ts.map +1 -1
  74. package/dist/server/routes.js +114 -1
  75. package/dist/server/routes.js.map +1 -1
  76. package/package.json +1 -1
  77. package/src/data/builtin-manifest.json +64 -64
  78. package/src/scaffold/templates/jobs/instar/review-canary-battery.md +31 -0
  79. package/src/scaffold/templates.ts +15 -0
  80. package/upgrades/1.3.731.md +97 -0
  81. package/upgrades/1.3.732.md +107 -0
  82. package/upgrades/side-effects/context-aware-outbound-review.md +62 -0
  83. package/upgrades/side-effects/durable-conversation-identity-increment1.md +89 -0
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
+ import crypto from 'node:crypto';
18
19
  import { PolicyEnforcementLayer } from './PolicyEnforcementLayer.js';
19
20
  import { CoherenceReviewer } from './CoherenceReviewer.js';
20
21
  import { OrgIntentManager } from './OrgIntentManager.js';
@@ -32,6 +33,14 @@ import { ResearchRateLimiter } from './ResearchRateLimiter.js';
32
33
  import { RecipientResolver } from './RecipientResolver.js';
33
34
  import { CustomReviewerLoader } from './CustomReviewerLoader.js';
34
35
  import { CanonicalState } from './CanonicalState.js';
36
+ import { clampConversation, renderUntrustedConversation, } from './untrustedConversationContext.js';
37
+ import { ResponseReviewDecisionLog } from './ResponseReviewDecisionLog.js';
38
+ import { scrubString } from './CredentialAuditEmit.js';
39
+ const ASK_LICENSE_MODES = new Set([
40
+ 'verified-operator',
41
+ 'single-sender',
42
+ 'weak-corroboration-only',
43
+ ]);
35
44
  // ── Category Mapping (reviewer → generic category for agent feedback) ─
36
45
  const REVIEWER_CATEGORY_MAP = {
37
46
  'conversational-tone': 'TONE ISSUE',
@@ -78,12 +87,18 @@ export class CoherenceGate {
78
87
  canonicalState;
79
88
  onResearchTriggered;
80
89
  liveConfig;
90
+ conversationContextProvider;
91
+ decisionLog;
81
92
  onLedgerEventSink = null;
82
93
  static RETENTION_DAYS = 30;
83
94
  constructor(options) {
84
95
  this.config = options.config;
85
96
  this.stateDir = options.stateDir;
86
97
  this.liveConfig = options.liveConfig;
98
+ this.conversationContextProvider = options.conversationContextProvider;
99
+ this.decisionLog =
100
+ options.decisionLog ??
101
+ new ResponseReviewDecisionLog(path.join(options.stateDir, '..', 'logs', 'response-review-decisions.jsonl'));
87
102
  this.onResearchTriggered = options.onResearchTriggered;
88
103
  this.researchRateLimiter = new ResearchRateLimiter({ stateDir: options.stateDir });
89
104
  this.canonicalState = new CanonicalState({ stateDir: path.join(options.stateDir, 'state') });
@@ -133,17 +148,17 @@ export class CoherenceGate {
133
148
  * Implements the 15-row normative decision matrix.
134
149
  */
135
150
  async evaluate(request) {
136
- const { message, sessionId, stopHookActive, context } = request;
151
+ const { message, sessionId, stopHookActive, context, telemetry } = request;
137
152
  // Session mutex — prevent concurrent reviews for same session
138
153
  await this.acquireMutex(sessionId);
139
154
  try {
140
- return await this._evaluate(message, sessionId, stopHookActive, context);
155
+ return await this._evaluate(message, sessionId, stopHookActive, context, telemetry);
141
156
  }
142
157
  finally {
143
158
  this.releaseMutex(sessionId);
144
159
  }
145
160
  }
146
- async _evaluate(message, sessionId, stopHookActive, context) {
161
+ async _evaluate(message, sessionId, stopHookActive, context, telemetry) {
147
162
  const isExternal = context.isExternalFacing ?? this.isExternalChannel(context.channel);
148
163
  const channelConfig = this.resolveChannelConfig(context.channel, isExternal);
149
164
  const recipientType = context.recipientType ?? 'primary-user';
@@ -166,7 +181,9 @@ export class CoherenceGate {
166
181
  if (currentVersion > retryState.transcriptVersion) {
167
182
  // User sent a new message — abandon stale revision
168
183
  this.retrySessions.delete(sessionId);
169
- this.logAudit(sessionId, context, 'abandoned', [], 'Conversation advanced during revision');
184
+ this.logAudit(sessionId, context, 'abandoned', [], 'Conversation advanced during revision', {
185
+ outcome: 'abandoned-stale', message, retryCount: retryState.retryCount, telemetry,
186
+ });
170
187
  return { pass: true, _outcome: 'abandoned-stale' };
171
188
  }
172
189
  }
@@ -186,7 +203,9 @@ export class CoherenceGate {
186
203
  // Row 1: PEL HARD_BLOCK → always block, no exceptions
187
204
  if (pelResult.outcome === 'hard_block') {
188
205
  const feedback = this.composePELFeedback(pelResult);
189
- this.logAudit(sessionId, context, 'pel-block', [], 'PEL hard block');
206
+ this.logAudit(sessionId, context, 'pel-block', [], 'PEL hard block', {
207
+ outcome: 'block', message, retryCount: retryState.retryCount, pelBlock: true, telemetry,
208
+ });
190
209
  // Integrated-Being: rule id only, no rule context. Spec §Write path §4.
191
210
  this.emitLedgerBlock('PEL_HARD_BLOCK', sessionId, context.channel);
192
211
  return {
@@ -212,6 +231,59 @@ export class CoherenceGate {
212
231
  const valueDocs = this.loadValueDocs();
213
232
  // ── Step 5b: Load canonical state for fact-checking ───────────
214
233
  const canonicalStateContext = this.loadCanonicalStateContext();
234
+ // ── Step 5c: Conversational context acquisition ────────────────
235
+ // (context-aware-outbound-review §D1) Once per _evaluate, inside its own
236
+ // try/catch, BEFORE the reviewer fan-out — only when the feature resolves
237
+ // LIVE, a topicId is present, and the recipient is the primary user (§D3
238
+ // structural scoping: a review for any other recipient never sees
239
+ // conversation, so nothing is even fetched). Any throw, empty result, or
240
+ // absent provider ⇒ recentConversation stays undefined ⇒ NO context
241
+ // section ⇒ byte-identical current behavior (§D5). The HTTP seam above
242
+ // this pipeline fails OPEN on a crash, so EVERY new context code path is
243
+ // individually contained (total containment rule, round-1 M6).
244
+ const ccCfg = this.getConversationalContextConfig();
245
+ let recentConversation;
246
+ let conversationContextMeta;
247
+ if (ccCfg &&
248
+ this.conversationContextProvider &&
249
+ typeof context.topicId === 'number' &&
250
+ recipientType === 'primary-user') {
251
+ try {
252
+ // Fetch limit 10 (§D6: headroom over maxMessages for role filtering).
253
+ const fetched = this.conversationContextProvider(context.topicId, Math.max(ccCfg.maxMessages, 10));
254
+ const mode = fetched?.askLicenseMode;
255
+ if (fetched &&
256
+ Array.isArray(fetched.messages) &&
257
+ fetched.messages.length > 0 &&
258
+ typeof mode === 'string' &&
259
+ ASK_LICENSE_MODES.has(mode)) {
260
+ const clamped = clampConversation(fetched.messages, ccCfg);
261
+ if (clamped.messages.length > 0) {
262
+ recentConversation = clamped.messages;
263
+ conversationContextMeta = {
264
+ messagesIncluded: clamped.messagesIncluded,
265
+ truncated: clamped.truncated,
266
+ source: 'topic-memory',
267
+ // Copied verbatim from the provider (R4-m1) — the gate NEVER
268
+ // computes or infers the mode.
269
+ askLicenseMode: mode,
270
+ };
271
+ }
272
+ }
273
+ }
274
+ catch {
275
+ // @silent-fallback-ok — §D5: a provider/tagging/meta throw is caught
276
+ // at acquisition; no context section, review proceeds byte-identical
277
+ // to current behavior (the STRICTER posture — the safe direction for
278
+ // an outbound gate). One debug-level breadcrumb, never a log flood.
279
+ }
280
+ }
281
+ // Resolved opt-in set (§D3): non-null ONLY when context was acquired for
282
+ // a primary-user review. Reviewers in this set — and ONLY these — receive
283
+ // the augmented ctx copy at the fan-out.
284
+ const injectSet = ccCfg && recentConversation && conversationContextMeta
285
+ ? this.resolveInjectReviewers(ccCfg)
286
+ : null;
215
287
  // ── Step 6: Build review context ─────────────────────────────
216
288
  const reviewCtx = {
217
289
  message,
@@ -246,18 +318,32 @@ export class CoherenceGate {
246
318
  const warnings = pelResult.outcome === 'warn'
247
319
  ? pelResult.violations.map(v => v.detail)
248
320
  : [];
249
- this.logAudit(sessionId, context, 'pass-gate', [], 'Gate skipped full review');
321
+ this.logAudit(sessionId, context, 'pass-gate', [], 'Gate skipped full review', {
322
+ outcome: 'pass', message, retryCount: retryState.retryCount, gateSkipped: true,
323
+ contextMeta: conversationContextMeta, telemetry,
324
+ });
250
325
  return {
251
326
  pass: true,
252
327
  warnings,
253
328
  _gateResult: gateResult,
254
329
  _outcome: 'pass',
330
+ _contextMeta: conversationContextMeta,
255
331
  };
256
332
  }
257
333
  }
258
334
  // ── Step 8: Specialist reviewers (parallel fan-out) ──────────
259
335
  const enabledReviewers = this.getEnabledReviewers(context.channel, recipientType, channelConfig, isExternal);
260
- const results = await Promise.allSettled(enabledReviewers.map(r => r.review(reviewCtx)));
336
+ const results = await Promise.allSettled(enabledReviewers.map(r => {
337
+ // §D3 structural availability (r3, round-2 m2): reviewers in the
338
+ // resolved opt-in set receive an AUGMENTED shallow copy (base ctx +
339
+ // the two conversation fields); every other reviewer receives the
340
+ // base ctx, which never carries conversation — a reviewer not handed
341
+ // the fields CANNOT render them, no matter what its buildPrompt does.
342
+ if (injectSet && injectSet.has(r.name) && recentConversation && conversationContextMeta) {
343
+ return r.review({ ...reviewCtx, recentConversation, conversationContextMeta });
344
+ }
345
+ return r.review(reviewCtx);
346
+ }));
261
347
  // Collect results
262
348
  const settled = [];
263
349
  let abstainCount = 0;
@@ -310,7 +396,10 @@ export class CoherenceGate {
310
396
  const capacityViolations = settled
311
397
  .filter((r) => r.capacityUnavailable)
312
398
  .map((r) => ({ reviewer: r.reviewer, severity: 'block', issue: r.issue, suggestion: r.suggestion, latencyMs: r.latencyMs }));
313
- this.logAudit(sessionId, context, 'block-capacity', capacityViolations, 'Spawn capacity saturated — held (fail-closed)');
399
+ this.logAudit(sessionId, context, 'block-capacity', capacityViolations, 'Spawn capacity saturated — held (fail-closed)', {
400
+ outcome: 'block-capacity', message, retryCount: retryState.retryCount,
401
+ contextMeta: conversationContextMeta, telemetry,
402
+ });
314
403
  return {
315
404
  pass: false,
316
405
  feedback: '[unreviewed] Response held — review capacity temporarily saturated (host spawn cap). Retry shortly.',
@@ -318,6 +407,7 @@ export class CoherenceGate {
318
407
  retryCount: retryState.retryCount,
319
408
  _auditViolations: capacityViolations,
320
409
  _outcome: 'block-capacity',
410
+ _contextMeta: conversationContextMeta,
321
411
  };
322
412
  }
323
413
  // ── Step 8b: Check for research trigger signals ────────────
@@ -379,18 +469,54 @@ export class CoherenceGate {
379
469
  : [];
380
470
  // Row 3: observeOnly → always pass
381
471
  if (observeOnly) {
382
- this.logAudit(sessionId, context, 'observe-only', auditViolations, `LLM: ${llmVerdict}`);
472
+ // Counterfactual re-review qualification (§D9.4): watch-mode would-block
473
+ // whose violations include an opted-in reviewer AND whose context was
474
+ // actually present (a block driven solely by reviewers that never saw
475
+ // context is skipped — round-2 L2). A `canary: true` evaluation is
476
+ // likewise skipped (r4 — the battery carries its OWN context-absent
477
+ // baseline arm; a counterfactual of a canary would double-count it).
478
+ // Soak-only by construction: this branch IS the observeOnly branch, so
479
+ // the counterfactual path NEVER runs under enforcement.
480
+ let pairId;
481
+ let counterfactualReviewers = [];
482
+ if (llmVerdict === 'BLOCK' &&
483
+ injectSet &&
484
+ conversationContextMeta &&
485
+ conversationContextMeta.messagesIncluded > 0 &&
486
+ telemetry?.canary !== true) {
487
+ counterfactualReviewers = [
488
+ ...new Set(auditViolations.filter(v => injectSet.has(v.reviewer)).map(v => v.reviewer)),
489
+ ];
490
+ if (counterfactualReviewers.length > 0) {
491
+ pairId = `cf-${Date.now().toString(36)}-${crypto.randomBytes(4).toString('hex')}`;
492
+ }
493
+ }
494
+ this.logAudit(sessionId, context, 'observe-only', auditViolations, `LLM: ${llmVerdict}`, {
495
+ outcome: 'pass-observe', message, llmVerdict, retryCount: retryState.retryCount,
496
+ contextMeta: conversationContextMeta, telemetry, pairId,
497
+ });
498
+ if (pairId) {
499
+ // Fire-and-forget: ONE context-stripped re-review per opted-in
500
+ // violating reviewer (v1 opt-in set is a single reviewer), logged
501
+ // beside the original with `counterfactual: true` + the shared
502
+ // pairId. Never delays the verdict; every failure is contained.
503
+ void this.runCounterfactualReReview(pairId, counterfactualReviewers, reviewCtx, context, sessionId);
504
+ }
383
505
  return {
384
506
  pass: true,
385
507
  warnings: [...pelWarnings, ...warnResults.map(r => r.issue)],
386
508
  _auditViolations: auditViolations,
387
509
  _gateResult: gateResult,
388
510
  _outcome: 'pass-observe',
511
+ _contextMeta: conversationContextMeta,
389
512
  };
390
513
  }
391
514
  // Row 4: LLM PASS → deliver
392
515
  if (llmVerdict === 'PASS') {
393
- this.logAudit(sessionId, context, 'pass', auditViolations, 'All reviewers pass');
516
+ this.logAudit(sessionId, context, 'pass', auditViolations, 'All reviewers pass', {
517
+ outcome: 'pass', message, llmVerdict, retryCount: retryState.retryCount,
518
+ contextMeta: conversationContextMeta, telemetry,
519
+ });
394
520
  return {
395
521
  pass: true,
396
522
  warnings: pelWarnings,
@@ -398,11 +524,15 @@ export class CoherenceGate {
398
524
  _gateResult: gateResult,
399
525
  _outcome: 'pass',
400
526
  _researchTriggered: researchTriggered || undefined,
527
+ _contextMeta: conversationContextMeta,
401
528
  };
402
529
  }
403
530
  // Row 5: WARN_ONLY → deliver with warnings
404
531
  if (llmVerdict === 'WARN_ONLY') {
405
- this.logAudit(sessionId, context, 'pass-warn', auditViolations, 'Warnings only');
532
+ this.logAudit(sessionId, context, 'pass-warn', auditViolations, 'Warnings only', {
533
+ outcome: 'pass-warn', message, llmVerdict, retryCount: retryState.retryCount,
534
+ contextMeta: conversationContextMeta, telemetry,
535
+ });
406
536
  return {
407
537
  pass: true,
408
538
  warnings: [...pelWarnings, ...warnResults.map(r => r.issue)],
@@ -410,6 +540,7 @@ export class CoherenceGate {
410
540
  _gateResult: gateResult,
411
541
  _outcome: 'pass-warn',
412
542
  _researchTriggered: researchTriggered || undefined,
543
+ _contextMeta: conversationContextMeta,
413
544
  };
414
545
  }
415
546
  // Rows 10-15: ALL_ABSTAIN, TIMEOUT, HIGH_CRIT_TIMEOUT
@@ -417,42 +548,57 @@ export class CoherenceGate {
417
548
  if (isExternal) {
418
549
  // Row 10, 12, 14: QUEUE for external
419
550
  if (channelConfig.queueOnFailure) {
420
- this.logAudit(sessionId, context, 'queued', auditViolations, `${llmVerdict}: queued`);
551
+ this.logAudit(sessionId, context, 'queued', auditViolations, `${llmVerdict}: queued`, {
552
+ outcome: 'queue', message, llmVerdict, retryCount: retryState.retryCount,
553
+ contextMeta: conversationContextMeta, telemetry,
554
+ });
421
555
  return {
422
556
  pass: false,
423
557
  feedback: '[unreviewed] Review system temporarily unavailable. Message held for review.',
424
558
  issueCategories: ['INFRASTRUCTURE'],
425
559
  _auditViolations: auditViolations,
426
560
  _outcome: 'queue',
561
+ _contextMeta: conversationContextMeta,
427
562
  };
428
563
  }
429
564
  // Fail-closed for external channels even without queueOnFailure,
430
565
  // unless explicitly configured as failOpen
431
566
  if (channelConfig.failOpen === false || channelConfig.failOpen === undefined) {
432
- this.logAudit(sessionId, context, 'block-failclosed', auditViolations, `${llmVerdict}: fail-closed (external)`);
567
+ this.logAudit(sessionId, context, 'block-failclosed', auditViolations, `${llmVerdict}: fail-closed (external)`, {
568
+ outcome: 'block-failclosed', message, llmVerdict, retryCount: retryState.retryCount,
569
+ contextMeta: conversationContextMeta, telemetry,
570
+ });
433
571
  return {
434
572
  pass: false,
435
573
  feedback: '[unreviewed] Review system unavailable. External message blocked for safety.',
436
574
  issueCategories: ['INFRASTRUCTURE'],
437
575
  _auditViolations: auditViolations,
438
576
  _outcome: 'block-failclosed',
577
+ _contextMeta: conversationContextMeta,
439
578
  };
440
579
  }
441
580
  }
442
581
  // Row 11, 13, 15: fail-open for internal (or explicitly failOpen external)
443
- this.logAudit(sessionId, context, 'pass-failopen', auditViolations, `${llmVerdict}: fail-open`);
582
+ this.logAudit(sessionId, context, 'pass-failopen', auditViolations, `${llmVerdict}: fail-open`, {
583
+ outcome: 'pass-failopen', message, llmVerdict, retryCount: retryState.retryCount,
584
+ contextMeta: conversationContextMeta, telemetry,
585
+ });
444
586
  return {
445
587
  pass: true,
446
588
  warnings: ['[unreviewed] Some reviewers were unavailable'],
447
589
  _auditViolations: auditViolations,
448
590
  _outcome: 'pass-failopen',
591
+ _contextMeta: conversationContextMeta,
449
592
  };
450
593
  }
451
594
  // Row 6: BLOCK + retries remaining → block for revision
452
595
  if (llmVerdict === 'BLOCK' && !retryExhausted) {
453
596
  const feedback = this.composeFeedback(blockResults, warnResults, retryState.retryCount, maxRetries);
454
597
  retryState.lastViolations = auditViolations;
455
- this.logAudit(sessionId, context, 'block', auditViolations, `Block: retry ${retryState.retryCount}/${maxRetries}`);
598
+ this.logAudit(sessionId, context, 'block', auditViolations, `Block: retry ${retryState.retryCount}/${maxRetries}`, {
599
+ outcome: 'block', message, llmVerdict, retryCount: retryState.retryCount,
600
+ contextMeta: conversationContextMeta, telemetry,
601
+ });
456
602
  // Integrated-Being: emit rule id ONLY (no context). Use the first block
457
603
  // reviewer's name as the rule id, consistent with existing audit logs.
458
604
  const ruleId = blockResults[0]?.reviewer ?? 'COHERENCE_BLOCK';
@@ -465,6 +611,7 @@ export class CoherenceGate {
465
611
  _auditViolations: auditViolations,
466
612
  _gateResult: gateResult,
467
613
  _outcome: 'block',
614
+ _contextMeta: conversationContextMeta,
468
615
  };
469
616
  }
470
617
  // Rows 7-9: BLOCK + retry exhausted
@@ -473,7 +620,10 @@ export class CoherenceGate {
473
620
  const hasHighStakes = categories.some(c => HIGH_STAKES_CATEGORIES.has(c));
474
621
  if (isExternal && hasHighStakes) {
475
622
  // Row 9: External + accuracy/alignment → HOLD for operator review
476
- this.logAudit(sessionId, context, 'hold', auditViolations, 'Retry exhausted on high-stakes issue');
623
+ this.logAudit(sessionId, context, 'hold', auditViolations, 'Retry exhausted on high-stakes issue', {
624
+ outcome: 'hold', message, llmVerdict, retryCount: retryState.retryCount,
625
+ contextMeta: conversationContextMeta, telemetry,
626
+ });
477
627
  return {
478
628
  pass: false,
479
629
  feedback: 'Response held for operator review due to unresolved accuracy/alignment concerns.',
@@ -481,10 +631,14 @@ export class CoherenceGate {
481
631
  retryCount: retryState.retryCount,
482
632
  _auditViolations: auditViolations,
483
633
  _outcome: 'hold',
634
+ _contextMeta: conversationContextMeta,
484
635
  };
485
636
  }
486
637
  // Rows 7-8: Internal, or external + low-stakes → PASS + attention queue
487
- this.logAudit(sessionId, context, 'pass-exhausted', auditViolations, 'Retry exhausted, delivering');
638
+ this.logAudit(sessionId, context, 'pass-exhausted', auditViolations, 'Retry exhausted, delivering', {
639
+ outcome: 'pass-exhausted', message, llmVerdict, retryCount: retryState.retryCount,
640
+ contextMeta: conversationContextMeta, telemetry,
641
+ });
488
642
  this.retrySessions.delete(sessionId);
489
643
  return {
490
644
  pass: true,
@@ -492,6 +646,7 @@ export class CoherenceGate {
492
646
  _auditViolations: auditViolations,
493
647
  _gateResult: gateResult,
494
648
  _outcome: 'pass-exhausted',
649
+ _contextMeta: conversationContextMeta,
495
650
  };
496
651
  }
497
652
  // Fallback (should not reach here)
@@ -626,6 +781,113 @@ export class CoherenceGate {
626
781
  }
627
782
  return this.config.failClosedOnCriticalAbstain !== false;
628
783
  }
784
+ /**
785
+ * Resolve the LIVE conversational-context config (context-aware-outbound-
786
+ * review §D10). Returns null when the feature is DARK. Load-bearing
787
+ * precedence (r3, round-2 L4): an ABSENT liveConfig getter resolves DARK —
788
+ * even against an `enabled: true` static config snapshot — so a mis-wired
789
+ * build fails toward current behavior, never toward stale-config context
790
+ * injection. Dev-gate resolution happened at the WIRING layer; this method
791
+ * requires a concrete `enabled === true` and never resolves the gate itself.
792
+ */
793
+ getConversationalContextConfig() {
794
+ try {
795
+ if (!this.liveConfig)
796
+ return null;
797
+ const cc = this.liveConfig().conversationalContext;
798
+ if (!cc || cc.enabled !== true)
799
+ return null;
800
+ return {
801
+ enabled: true,
802
+ maxMessages: typeof cc.maxMessages === 'number' && cc.maxMessages > 0 ? cc.maxMessages : 6,
803
+ maxCharsPerMessage: typeof cc.maxCharsPerMessage === 'number' && cc.maxCharsPerMessage > 0
804
+ ? cc.maxCharsPerMessage
805
+ : 500,
806
+ maxTotalChars: typeof cc.maxTotalChars === 'number' && cc.maxTotalChars > 0 ? cc.maxTotalChars : 4000,
807
+ injectReviewers: Array.isArray(cc.injectReviewers)
808
+ ? cc.injectReviewers.filter((n) => typeof n === 'string')
809
+ : ['conversational-tone'],
810
+ };
811
+ }
812
+ catch {
813
+ // @silent-fallback-ok — a throwing config getter resolves the feature
814
+ // DARK (current gate behavior, the stricter posture); it never crashes
815
+ // the review (§D5 total containment — the HTTP seam above fails open).
816
+ return null;
817
+ }
818
+ }
819
+ /**
820
+ * The resolved reviewer opt-in set (§D3): the config-resolved built-in list
821
+ * (v1 default `['conversational-tone']` ALONE — round-1 M1) plus any custom
822
+ * DynamicReviewer that opted in via its `contextRequirements`
823
+ * `'recent-conversation'` key. Honored ONLY within the structural scoping
824
+ * at the fan-out (primary-user recipient) — a config-only opt-in can never
825
+ * expand exposure beyond what the M1 exclusion accepted.
826
+ */
827
+ resolveInjectReviewers(cfg) {
828
+ const set = new Set(cfg.injectReviewers);
829
+ for (const [name, reviewer] of this.reviewers) {
830
+ if (reviewer.wantsRecentConversation())
831
+ set.add(name);
832
+ }
833
+ return set;
834
+ }
835
+ /**
836
+ * §D9.4 bounded counterfactual re-review: ONE context-stripped re-review of
837
+ * the SAME message through each opted-in reviewer that would-blocked WITH
838
+ * context, logged beside the original in the §D8 JSONL (`counterfactual:
839
+ * true`, shared pairId). A reviewer that would-blocks WITH context but
840
+ * PASSES without it is a CONTEXT-MINTED block — the one-way property (D3.2)
841
+ * read directly off the pair. Soak-only: callable ONLY from the observeOnly
842
+ * branch. Fire-and-forget; every failure is contained (telemetry never
843
+ * affects a verdict, and nothing here may throw into the pipeline).
844
+ */
845
+ async runCounterfactualReReview(pairId, reviewerNames, baseCtx, context, sessionId) {
846
+ for (const name of reviewerNames) {
847
+ try {
848
+ const reviewer = this.reviewers.get(name);
849
+ if (!reviewer)
850
+ continue;
851
+ // Base ctx — never carries conversation (context-stripped by
852
+ // construction, not by deletion).
853
+ const result = await reviewer.review(baseCtx);
854
+ this.decisionLog.append({
855
+ t: new Date().toISOString(),
856
+ counterfactual: true,
857
+ pairId,
858
+ reviewer: name,
859
+ sessionId,
860
+ channel: context.channel,
861
+ ...(typeof context.topicId === 'number' ? { topicId: context.topicId } : {}),
862
+ abstained: result.abstained === true,
863
+ // flagged=null when the reviewer abstained (no opinion — the pair is
864
+ // inconclusive, never counted as a context-minted block).
865
+ flagged: result.abstained === true ? null : !result.pass,
866
+ ...(result.abstained !== true && !result.pass
867
+ ? { severity: result.severity, issue: (result.issue ?? '').slice(0, 300) }
868
+ : {}),
869
+ });
870
+ }
871
+ catch {
872
+ // @silent-fallback-ok — the counterfactual is soak-only telemetry; a
873
+ // failure loses one measurement pair, never affects any verdict or
874
+ // delivery (§D5 total containment).
875
+ }
876
+ }
877
+ }
878
+ /**
879
+ * Append a row to the durable §D8 decision log through the gate's single
880
+ * writer. Used by the ReviewCanaryBattery for its per-run batterySummary
881
+ * row (R4-m5: the summary is a SECOND, additive row type on the SAME JSONL,
882
+ * written through the same writer). Never throws.
883
+ */
884
+ appendDecisionRow(row) {
885
+ this.decisionLog.append(row);
886
+ }
887
+ /** Absolute path of the §D8 decision log (observability + tests). */
888
+ getDecisionLogPath() {
889
+ return this.decisionLog.getPath();
890
+ }
629
891
  resolveCriticality(reviewerName, orgIntent) {
630
892
  const isFloor = CoherenceGate.CRITICAL_FLOOR.has(reviewerName);
631
893
  const explicit = this.config.reviewerCriticality?.[reviewerName];
@@ -898,7 +1160,7 @@ export class CoherenceGate {
898
1160
  }
899
1161
  }
900
1162
  // ── Audit Logging ──────────────────────────────────────────────────
901
- logAudit(sessionId, context, verdict, violations, note) {
1163
+ logAudit(sessionId, context, verdict, violations, note, decision) {
902
1164
  const entry = {
903
1165
  timestamp: new Date().toISOString(),
904
1166
  sessionId,
@@ -908,12 +1170,54 @@ export class CoherenceGate {
908
1170
  verdict,
909
1171
  violations,
910
1172
  note,
1173
+ ...(decision?.contextMeta ? { contextMeta: decision.contextMeta } : {}),
911
1174
  };
912
1175
  this.reviewHistory.push(entry);
913
1176
  // Prune old entries (keep last 1000)
914
1177
  if (this.reviewHistory.length > 1000) {
915
1178
  this.reviewHistory = this.reviewHistory.slice(-1000);
916
1179
  }
1180
+ // §D8 durable decision log — one line per _evaluate verdict (ALL
1181
+ // outcomes; the §D9.3 denominator matters), written at this same seam.
1182
+ // Context BODIES are never persisted — only contextMeta (§D7). textHead
1183
+ // is 200 credential-scrubbed chars (adjudication fidelity, decided §8-3).
1184
+ // The writer swallows its own failures (telemetry never gates delivery).
1185
+ if (decision) {
1186
+ this.decisionLog.append({
1187
+ t: entry.timestamp,
1188
+ sessionId,
1189
+ channel: context.channel,
1190
+ ...(typeof context.topicId === 'number' ? { topicId: context.topicId } : {}),
1191
+ recipientType: entry.recipientType,
1192
+ outcome: decision.outcome,
1193
+ ...(decision.llmVerdict ? { llmVerdict: decision.llmVerdict } : {}),
1194
+ violations: violations.map(v => ({
1195
+ reviewer: v.reviewer,
1196
+ severity: v.severity,
1197
+ issue: (v.issue ?? '').slice(0, 300),
1198
+ })),
1199
+ ...(decision.contextMeta ? { contextMeta: decision.contextMeta } : {}),
1200
+ ...(typeof decision.message === 'string'
1201
+ ? { textHead: scrubString(decision.message).slice(0, 200) }
1202
+ : {}),
1203
+ observeOnly: this.config.observeOnly ?? false,
1204
+ gateSkipped: decision.gateSkipped === true,
1205
+ retryCount: decision.retryCount ?? 0,
1206
+ ...(decision.pelBlock === true ? { pelBlock: true } : {}),
1207
+ // Test-route-only tags (§D9.4b): stamped by THIS writer, never a
1208
+ // stringly sessionId-prefix convention. `/review/evaluate` never
1209
+ // forwards telemetry, so a real turn cannot self-tag.
1210
+ ...(decision.telemetry?.canary === true
1211
+ ? {
1212
+ canary: true,
1213
+ ...(typeof decision.telemetry.fixtureId === 'string'
1214
+ ? { fixtureId: decision.telemetry.fixtureId }
1215
+ : {}),
1216
+ }
1217
+ : {}),
1218
+ ...(decision.pairId ? { pairId: decision.pairId } : {}),
1219
+ });
1220
+ }
917
1221
  }
918
1222
  // ── Public API for routes ──────────────────────────────────────────
919
1223
  getReviewHistory(options) {
@@ -1141,6 +1445,16 @@ class DynamicReviewer extends CoherenceReviewer {
1141
1445
  this.promptTemplate = promptTemplate;
1142
1446
  this.contextRequirements = contextRequirements;
1143
1447
  }
1448
+ /**
1449
+ * Custom reviewers opt in to recent conversation via their existing
1450
+ * `contextRequirements` mechanism — the `'recent-conversation'` key
1451
+ * (context-aware-outbound-review §D3). Honored ONLY within the gate's
1452
+ * structural scoping: an opted-in custom reviewer receives the augmented
1453
+ * ctx copy only for primary-user-recipient reviews.
1454
+ */
1455
+ wantsRecentConversation() {
1456
+ return this.contextRequirements?.['recent-conversation'] === true;
1457
+ }
1144
1458
  buildPrompt(context) {
1145
1459
  const boundary = this.generateBoundary();
1146
1460
  const preamble = this.buildAntiInjectionPreamble();
@@ -1159,9 +1473,18 @@ class DynamicReviewer extends CoherenceReviewer {
1159
1473
  prompt = prompt.replace(/\{custom_context\}/g, '(custom context unavailable)');
1160
1474
  }
1161
1475
  }
1476
+ // §D3 atomic context block — rendered only when the gate handed this
1477
+ // reviewer the augmented ctx (opt-in + primary-user recipient). Absent
1478
+ // fields ⇒ '' ⇒ prompt byte-identical to feature-dark.
1479
+ let conversationSection = '';
1480
+ if (context.recentConversation &&
1481
+ context.recentConversation.length > 0 &&
1482
+ context.conversationContextMeta) {
1483
+ conversationSection = renderUntrustedConversation(context.recentConversation, context.conversationContextMeta);
1484
+ }
1162
1485
  return `${preamble}
1163
1486
 
1164
- ${prompt}
1487
+ ${prompt}${conversationSection}
1165
1488
 
1166
1489
  Respond EXCLUSIVELY with valid JSON:
1167
1490
  { "pass": boolean, "severity": "block"|"warn", "issue": "...", "suggestion": "..." }