instar 1.3.729 → 1.3.731
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +254 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/core/CoherenceGate.d.ts +119 -0
- package/dist/core/CoherenceGate.d.ts.map +1 -1
- package/dist/core/CoherenceGate.js +342 -19
- package/dist/core/CoherenceGate.js.map +1 -1
- package/dist/core/CoherenceReviewer.d.ts +30 -0
- package/dist/core/CoherenceReviewer.d.ts.map +1 -1
- package/dist/core/CoherenceReviewer.js +14 -0
- package/dist/core/CoherenceReviewer.js.map +1 -1
- package/dist/core/FileClassifier.d.ts.map +1 -1
- package/dist/core/FileClassifier.js +7 -0
- package/dist/core/FileClassifier.js.map +1 -1
- package/dist/core/ModelSwapService.d.ts +13 -0
- package/dist/core/ModelSwapService.d.ts.map +1 -1
- package/dist/core/ModelSwapService.js +11 -0
- package/dist/core/ModelSwapService.js.map +1 -1
- package/dist/core/ModelTierEscalation.d.ts +10 -0
- package/dist/core/ModelTierEscalation.d.ts.map +1 -1
- package/dist/core/ModelTierEscalation.js +4 -0
- package/dist/core/ModelTierEscalation.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +1 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +43 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/ProactiveSwapMonitor.d.ts +74 -20
- package/dist/core/ProactiveSwapMonitor.d.ts.map +1 -1
- package/dist/core/ProactiveSwapMonitor.js +349 -28
- package/dist/core/ProactiveSwapMonitor.js.map +1 -1
- package/dist/core/QuotaAwareScheduler.d.ts +70 -3
- package/dist/core/QuotaAwareScheduler.d.ts.map +1 -1
- package/dist/core/QuotaAwareScheduler.js +100 -16
- package/dist/core/QuotaAwareScheduler.js.map +1 -1
- package/dist/core/ResponseReviewDecisionLog.d.ts +45 -0
- package/dist/core/ResponseReviewDecisionLog.d.ts.map +1 -0
- package/dist/core/ResponseReviewDecisionLog.js +85 -0
- package/dist/core/ResponseReviewDecisionLog.js.map +1 -0
- package/dist/core/SessionManager.d.ts +28 -0
- package/dist/core/SessionManager.d.ts.map +1 -1
- package/dist/core/SessionManager.js +102 -0
- package/dist/core/SessionManager.js.map +1 -1
- package/dist/core/SessionRefresh.d.ts +116 -1
- package/dist/core/SessionRefresh.d.ts.map +1 -1
- package/dist/core/SessionRefresh.js +280 -17
- package/dist/core/SessionRefresh.js.map +1 -1
- package/dist/core/SwapAntiThrash.d.ts +329 -0
- package/dist/core/SwapAntiThrash.d.ts.map +1 -0
- package/dist/core/SwapAntiThrash.js +1025 -0
- package/dist/core/SwapAntiThrash.js.map +1 -0
- package/dist/core/SwapLedger.d.ts +168 -0
- package/dist/core/SwapLedger.d.ts.map +1 -0
- package/dist/core/SwapLedger.js +275 -0
- package/dist/core/SwapLedger.js.map +1 -0
- package/dist/core/SwapWorkGate.d.ts +100 -0
- package/dist/core/SwapWorkGate.d.ts.map +1 -0
- package/dist/core/SwapWorkGate.js +175 -0
- package/dist/core/SwapWorkGate.js.map +1 -0
- package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
- package/dist/core/WriteDomainRegistry.js +21 -0
- package/dist/core/WriteDomainRegistry.js.map +1 -1
- package/dist/core/conversationContextWiring.d.ts +51 -0
- package/dist/core/conversationContextWiring.d.ts.map +1 -0
- package/dist/core/conversationContextWiring.js +78 -0
- package/dist/core/conversationContextWiring.js.map +1 -0
- package/dist/core/devGatedFeatures.d.ts.map +1 -1
- package/dist/core/devGatedFeatures.js +6 -0
- package/dist/core/devGatedFeatures.js.map +1 -1
- package/dist/core/reviewers/conversational-tone.d.ts.map +1 -1
- package/dist/core/reviewers/conversational-tone.js +15 -1
- package/dist/core/reviewers/conversational-tone.js.map +1 -1
- package/dist/core/types.d.ts +102 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/untrustedConversationContext.d.ts +77 -0
- package/dist/core/untrustedConversationContext.d.ts.map +1 -0
- package/dist/core/untrustedConversationContext.js +134 -0
- package/dist/core/untrustedConversationContext.js.map +1 -0
- package/dist/monitoring/ReviewCanaryBattery.d.ts +160 -0
- package/dist/monitoring/ReviewCanaryBattery.d.ts.map +1 -0
- package/dist/monitoring/ReviewCanaryBattery.js +416 -0
- package/dist/monitoring/ReviewCanaryBattery.js.map +1 -0
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +40 -1
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +6 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +4 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +20 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/CapabilityIndex.d.ts.map +1 -1
- package/dist/server/CapabilityIndex.js +1 -0
- package/dist/server/CapabilityIndex.js.map +1 -1
- package/dist/server/routes.d.ts +5 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +72 -4
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +65 -65
- package/src/scaffold/templates/jobs/instar/review-canary-battery.md +31 -0
- package/src/scaffold/templates.ts +6 -0
- package/upgrades/1.3.730.md +112 -0
- package/upgrades/1.3.731.md +97 -0
- package/upgrades/side-effects/context-aware-outbound-review.md +62 -0
- package/upgrades/side-effects/swap-continuity-antithrash.md +118 -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 =>
|
|
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
|
-
|
|
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": "..." }
|