instar 1.3.954 → 1.3.955
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 +30 -10
- package/dist/commands/server.js.map +1 -1
- package/dist/core/DecisionQualityRecorderImpl.d.ts +22 -0
- package/dist/core/DecisionQualityRecorderImpl.d.ts.map +1 -1
- package/dist/core/DecisionQualityRecorderImpl.js +26 -0
- package/dist/core/DecisionQualityRecorderImpl.js.map +1 -1
- package/dist/core/MessagingToneGate.d.ts +67 -1
- package/dist/core/MessagingToneGate.d.ts.map +1 -1
- package/dist/core/MessagingToneGate.js +127 -19
- package/dist/core/MessagingToneGate.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +10 -0
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +44 -1
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/TelegramRelay.d.ts +27 -2
- package/dist/core/TelegramRelay.d.ts.map +1 -1
- package/dist/core/TelegramRelay.js +40 -1
- package/dist/core/TelegramRelay.js.map +1 -1
- 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/data/provenanceCoverage.d.ts.map +1 -1
- package/dist/data/provenanceCoverage.js +22 -0
- package/dist/data/provenanceCoverage.js.map +1 -1
- package/dist/messaging/outbound-credential-guard.d.ts +116 -0
- package/dist/messaging/outbound-credential-guard.d.ts.map +1 -0
- package/dist/messaging/outbound-credential-guard.js +157 -0
- package/dist/messaging/outbound-credential-guard.js.map +1 -0
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +4 -2
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +410 -11
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +64 -64
- package/src/data/provenanceCoverage.ts +22 -0
- package/src/scaffold/templates.ts +4 -2
- package/src/templates/scripts/telegram-reply.sh +93 -2
- package/upgrades/1.3.955.md +73 -0
- package/upgrades/side-effects/tone-gate-advisory-migration.md +336 -0
package/dist/server/routes.js
CHANGED
|
@@ -55,7 +55,7 @@ import { writeConfigAtomic, readSelfKnowledgeFlags } from '../core/BootSelfKnowl
|
|
|
55
55
|
import { rateLimiter, signViewPath, OUTBOUND_GATE_REVIEW_BUDGET_MS } from './middleware.js';
|
|
56
56
|
import { reviewWithinBudget } from './outboundGateBudget.js';
|
|
57
57
|
import { resolveToneRecipientClass } from './toneRecipientClass.js';
|
|
58
|
-
import { buildDegradedToneResult, resolveToneGateOperatorConfig, fingerprintAutomatedTemplate } from '../core/MessagingToneGate.js';
|
|
58
|
+
import { RULE_DISPOSITIONS, buildDegradedToneResult, resolveToneGateOperatorConfig, fingerprintAutomatedTemplate } from '../core/MessagingToneGate.js';
|
|
59
59
|
import { writeLifelineRestartSignal } from '../core/version-skew.js';
|
|
60
60
|
import { readSessionClocks } from '../core/SessionClockReader.js';
|
|
61
61
|
import { P13_PROTOCOL_VERSION } from '../core/CompletionEvaluator.js';
|
|
@@ -74,9 +74,9 @@ import { classifyMachineEmptyState } from './poolEmptyState.js';
|
|
|
74
74
|
import { decorateWithRopeCondition } from './poolRopeCondition.js';
|
|
75
75
|
// LLM-Decision Quality Meter (llm-decision-quality-meter §5.5) — P9/P10 routes.
|
|
76
76
|
import { runDecisionGradingPass } from '../core/decisionGradingPass.js';
|
|
77
|
-
import { annotateDecisionOutcome, getDecisionAnnotationRejectionCounters } from '../core/DecisionQualityRecorderImpl.js';
|
|
77
|
+
import { annotateDecisionOutcome, decisionQualityRecordingLive, getDecisionAnnotationRejectionCounters } from '../core/DecisionQualityRecorderImpl.js';
|
|
78
78
|
import { annotateCompletionRealcheck } from '../core/AutonomousRealCheckAnnotator.js';
|
|
79
|
-
import { DP_COMPLETION_EVALUATE, PROVENANCE_COVERAGE, findWiredWithoutGraders } from '../data/provenanceCoverage.js';
|
|
79
|
+
import { DP_COMPLETION_EVALUATE, DP_MESSAGING_TONE_GATE, PROVENANCE_COVERAGE, findWiredWithoutGraders } from '../data/provenanceCoverage.js';
|
|
80
80
|
import { RemoteCloseAudit } from '../core/RemoteCloseAudit.js';
|
|
81
81
|
import { RemoteAckStore } from '../core/RemoteAckStore.js';
|
|
82
82
|
import { FailureLedger } from '../monitoring/FailureLedger.js';
|
|
@@ -194,6 +194,9 @@ import { resolvePeerFingerprint, resolvePeerFingerprintByName } from '../threadl
|
|
|
194
194
|
import { ScopeCoherenceTracker } from '../core/ScopeCoherenceTracker.js';
|
|
195
195
|
import { isJunkPayload } from '../core/junk-payload.js';
|
|
196
196
|
import { detectLocalhostLink } from '../core/localhost-link.js';
|
|
197
|
+
import { RelayRefusedError } from '../core/TelegramRelay.js';
|
|
198
|
+
import { scrubForStore } from '../core/durableSecretScrub.js';
|
|
199
|
+
import { credentialGuardMessage, detectOutboundCredential } from '../messaging/outbound-credential-guard.js';
|
|
197
200
|
import { detectJargon } from '../core/JargonDetector.js';
|
|
198
201
|
import { detectRawFilePath } from '../core/raw-file-path.js';
|
|
199
202
|
import { detectParkedOnUser } from '../core/parked-on-user.js';
|
|
@@ -999,6 +1002,157 @@ export function createRoutes(ctx) {
|
|
|
999
1002
|
// reply isn't re-sent across a restart (finding_cross_restart_duplicate_replies,
|
|
1000
1003
|
// topic 21816). Fail-open: a backing hiccup degrades to in-memory-only.
|
|
1001
1004
|
new SqliteOutboundDedupStore(SqliteOutboundDedupStore.defaultPath(ctx.config.stateDir)));
|
|
1005
|
+
/**
|
|
1006
|
+
* Minimum override-reason length. Low ON PURPOSE — this is a "did you type
|
|
1007
|
+
* anything" floor, not a quality bar. A length check cannot judge whether a
|
|
1008
|
+
* reason is good; that is the bulk judge's job, later, reading the recorded
|
|
1009
|
+
* text. Setting it high would only teach the agent to pad.
|
|
1010
|
+
*/
|
|
1011
|
+
const TONE_ADVISORY_REASON_MIN = 8;
|
|
1012
|
+
/**
|
|
1013
|
+
* Record how the agent REACTED to an advisory nudge — the tone gate's first
|
|
1014
|
+
* real evidence source (`tone-agent-override-v1` / `tone-agent-complied-v1`).
|
|
1015
|
+
*
|
|
1016
|
+
* override → the verdict is graded `wrong`; complied → `right`. Both land at
|
|
1017
|
+
* the `self-report` rung, so an interested party's account can never outrank
|
|
1018
|
+
* an independent grader.
|
|
1019
|
+
*
|
|
1020
|
+
* Silent no-op without a `decisionRef`: an outcome with nothing to join to is
|
|
1021
|
+
* an orphan row that inflates volume and grades nothing. Total and
|
|
1022
|
+
* fire-and-forget — observability must never break the message path.
|
|
1023
|
+
*/
|
|
1024
|
+
/**
|
|
1025
|
+
* A router-minted correlation id: `d-<uuid>` / `b-<uuid>`, optionally with a
|
|
1026
|
+
* machine segment. The agent hands this back through request metadata, so it
|
|
1027
|
+
* is UNTRUSTED input to a durable write — shape-check before it reaches the
|
|
1028
|
+
* annotate chokepoint. (The chokepoint validates grade/rung/owner but has
|
|
1029
|
+
* never had to validate a correlation id, because until this feature no
|
|
1030
|
+
* agent-supplied string could reach it.)
|
|
1031
|
+
*/
|
|
1032
|
+
/**
|
|
1033
|
+
* Parse the four tone-advisory reaction fields off a request `metadata`
|
|
1034
|
+
* object, clamped. One helper rather than four inline ternaries per callsite:
|
|
1035
|
+
* the review found the fields plumbed into 2 of 7 outbound routes, and
|
|
1036
|
+
* per-callsite duplication is exactly how that happens again.
|
|
1037
|
+
*/
|
|
1038
|
+
function toneAdvisoryMetadata(metadata) {
|
|
1039
|
+
const str = (v, max) => typeof v === 'string' ? v.slice(0, max) : undefined;
|
|
1040
|
+
return {
|
|
1041
|
+
toneAdvisoryAck: str(metadata?.toneAdvisoryAck, 64),
|
|
1042
|
+
toneAdvisoryAckReason: str(metadata?.toneAdvisoryAckReason, 500),
|
|
1043
|
+
toneAdvisoryDecisionRef: str(metadata?.toneAdvisoryDecisionRef, 128),
|
|
1044
|
+
toneAdvisoryComplied: str(metadata?.toneAdvisoryComplied, 64),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* ── Server-side compliance correlation ──────────────────────────────────
|
|
1049
|
+
*
|
|
1050
|
+
* The problem this removes: an override is STRUCTURALLY enforced (the message
|
|
1051
|
+
* will not send without its reason, so the `wrong` grade always lands), while
|
|
1052
|
+
* compliance was an opt-in field the agent had to remember on a revised
|
|
1053
|
+
* re-send. That asymmetry biases the sample toward "the gate was wrong" — the
|
|
1054
|
+
* exact bias the compliance path exists to prevent — and "remember to declare
|
|
1055
|
+
* it" is precisely the willpower this codebase refuses to build on.
|
|
1056
|
+
*
|
|
1057
|
+
* So the server remembers instead. When an advisory 422 goes out we note
|
|
1058
|
+
* (topic → rule, decisionRef, text fingerprint). If the next PASSING send to
|
|
1059
|
+
* that topic within the window carries DIFFERENT text, the agent revised: the
|
|
1060
|
+
* original verdict is graded `right` automatically, with no agent metadata.
|
|
1061
|
+
*
|
|
1062
|
+
* Deliberately conservative — it grades `right` only on positive evidence of a
|
|
1063
|
+
* revision (a passing send with changed text inside the window). A silent
|
|
1064
|
+
* abandonment records nothing rather than being counted either way.
|
|
1065
|
+
*/
|
|
1066
|
+
// 10 minutes, not 30: the correlation cannot distinguish "revised the nudged
|
|
1067
|
+
// message" from "gave up on it and sent something unrelated next". A shorter
|
|
1068
|
+
// window is the cheap discriminator — a revision follows a nudge promptly,
|
|
1069
|
+
// while an unrelated message is more likely to arrive later. A similarity
|
|
1070
|
+
// threshold was considered and REJECTED: a genuine rewrite in response to a
|
|
1071
|
+
// nudge often shares almost no wording with the original ("I put it in
|
|
1072
|
+
// docs/x.md" → "want a link or the summary here?"), so similarity would
|
|
1073
|
+
// silently discard exactly the compliances worth recording.
|
|
1074
|
+
const ADVISORY_PENDING_TTL_MS = 10 * 60 * 1000;
|
|
1075
|
+
const ADVISORY_PENDING_MAX = 500;
|
|
1076
|
+
const pendingAdvisories = new Map();
|
|
1077
|
+
function sha256Short(s) {
|
|
1078
|
+
return createHash('sha256').update(s).digest('hex').slice(0, 32);
|
|
1079
|
+
}
|
|
1080
|
+
function notePendingAdvisory(topicId, rule, decisionRef, text) {
|
|
1081
|
+
if (typeof topicId !== 'number')
|
|
1082
|
+
return;
|
|
1083
|
+
// Bounded: drop the oldest rather than grow without limit.
|
|
1084
|
+
if (pendingAdvisories.size >= ADVISORY_PENDING_MAX) {
|
|
1085
|
+
const oldest = [...pendingAdvisories.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
1086
|
+
if (oldest)
|
|
1087
|
+
pendingAdvisories.delete(oldest[0]);
|
|
1088
|
+
}
|
|
1089
|
+
pendingAdvisories.set(topicId, { rule, decisionRef, textSha: sha256Short(text), at: Date.now() });
|
|
1090
|
+
}
|
|
1091
|
+
/** Returns the reaction to record, if this passing send revises a pending advisory. */
|
|
1092
|
+
function takeRevisionOf(topicId, text) {
|
|
1093
|
+
if (typeof topicId !== 'number')
|
|
1094
|
+
return null;
|
|
1095
|
+
const p = pendingAdvisories.get(topicId);
|
|
1096
|
+
if (!p)
|
|
1097
|
+
return null;
|
|
1098
|
+
if (Date.now() - p.at > ADVISORY_PENDING_TTL_MS) {
|
|
1099
|
+
pendingAdvisories.delete(topicId);
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
// Identical text is not a revision — it is a resend, and a resend that
|
|
1103
|
+
// happens to pass is NOT evidence the gate was right.
|
|
1104
|
+
if (sha256Short(text) === p.textSha)
|
|
1105
|
+
return null;
|
|
1106
|
+
pendingAdvisories.delete(topicId);
|
|
1107
|
+
return { rule: p.rule, decisionRef: p.decisionRef };
|
|
1108
|
+
}
|
|
1109
|
+
function isPlausibleDecisionRef(ref) {
|
|
1110
|
+
return /^[db]-[0-9a-f]{8}(-[0-9a-f-]{8,})?[0-9a-f-]*$/i.test(ref) && ref.length <= 128;
|
|
1111
|
+
}
|
|
1112
|
+
function recordToneAdvisoryReaction(input) {
|
|
1113
|
+
try {
|
|
1114
|
+
if (!input.decisionRef || !isPlausibleDecisionRef(input.decisionRef))
|
|
1115
|
+
return;
|
|
1116
|
+
const ref = input.decisionRef;
|
|
1117
|
+
const reaction = input.reaction;
|
|
1118
|
+
const rule = input.rule;
|
|
1119
|
+
const derivation = input.derivation;
|
|
1120
|
+
// The reason IS the evidence, and it is agent-authored free text landing
|
|
1121
|
+
// in two durable stores. The chokepoint's own scrub is weaker than the
|
|
1122
|
+
// pattern set this very change treats as authoritative (it misses a
|
|
1123
|
+
// 20-char AWS key), so scrub with the strong list HERE before handing it
|
|
1124
|
+
// over. A guard that walls a credential in the message body while storing
|
|
1125
|
+
// one from the reason field would be theatre.
|
|
1126
|
+
const note = input.reason ? scrubForStore(input.reason).text.slice(0, 500) : undefined;
|
|
1127
|
+
// ── Off the send path ────────────────────────────────────────────────
|
|
1128
|
+
// `upsertOutcome` is a synchronous better-sqlite3 transaction that
|
|
1129
|
+
// recomputes the whole (decision_point, day) rollup twice over a window-
|
|
1130
|
+
// function view SQLite cannot push a predicate into. Measured: ~3ms at
|
|
1131
|
+
// today's 1.4k rows, ~80ms at 60k, ~190ms at 120k — and this change puts
|
|
1132
|
+
// the FIRST high-frequency conversational path in front of it. The result
|
|
1133
|
+
// is discarded either way, so there is no reason for the operator's reply
|
|
1134
|
+
// latency to carry a growing rollup recompute.
|
|
1135
|
+
setImmediate(() => {
|
|
1136
|
+
try {
|
|
1137
|
+
annotateDecisionOutcome({
|
|
1138
|
+
correlationId: ref,
|
|
1139
|
+
ruleId: reaction === 'override' ? 'tone-agent-override-v1' : 'tone-agent-complied-v1',
|
|
1140
|
+
gradedBy: { component: 'ToneGateAdvisory' },
|
|
1141
|
+
grade: reaction === 'override' ? 'wrong' : 'right',
|
|
1142
|
+
decisionPoint: DP_MESSAGING_TONE_GATE,
|
|
1143
|
+
...(note ? { evidenceNote: note } : {}),
|
|
1144
|
+
evidence: { reaction, rule, ...(derivation ? { derivation } : {}) },
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
catch {
|
|
1148
|
+
/* @silent-fallback-ok — recording a reaction must never affect delivery */
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
/* @silent-fallback-ok — recording a reaction must never affect delivery */
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1002
1156
|
async function evaluateOutbound(text, channel, options) {
|
|
1003
1157
|
// ── Localhost-link guard (operator-mandated HARD rule, 2026-06-05) ──
|
|
1004
1158
|
// A clickable localhost/loopback link must never reach a user — the user
|
|
@@ -1025,6 +1179,30 @@ export function createRoutes(ctx) {
|
|
|
1025
1179
|
};
|
|
1026
1180
|
}
|
|
1027
1181
|
}
|
|
1182
|
+
// ── Live-credential hard wall (operator directive 2026-07-19) ──────────
|
|
1183
|
+
// The ONE non-overridable outbound check, and the precondition that makes
|
|
1184
|
+
// the advisory migration safe: every LLM judgment became an overridable
|
|
1185
|
+
// nudge, so the thing that must never be overridable moved to a
|
|
1186
|
+
// deterministic guard. Runs BEFORE the authority — like the localhost-link
|
|
1187
|
+
// guard — so it holds on installs with no gate configured, during a
|
|
1188
|
+
// provider outage, and under spawn-cap saturation. No `metadata` escape
|
|
1189
|
+
// hatch exists by design; the error names the credential CLASS only.
|
|
1190
|
+
{
|
|
1191
|
+
const cred = detectOutboundCredential(text);
|
|
1192
|
+
if (cred.detected && cred.kind) {
|
|
1193
|
+
return {
|
|
1194
|
+
ok: false,
|
|
1195
|
+
status: 422,
|
|
1196
|
+
reason: 'credential-exposure-guard',
|
|
1197
|
+
body: {
|
|
1198
|
+
error: credentialGuardMessage(cred.kind),
|
|
1199
|
+
blockedBy: 'credential-exposure-guard',
|
|
1200
|
+
credentialKind: cred.kind,
|
|
1201
|
+
overridable: false,
|
|
1202
|
+
},
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1028
1206
|
if (!ctx.messagingToneGate)
|
|
1029
1207
|
return { ok: true }; // No authority configured — pass through
|
|
1030
1208
|
try {
|
|
@@ -1298,7 +1476,11 @@ export function createRoutes(ctx) {
|
|
|
1298
1476
|
// Operator-tier delivery suppresses both fail-closed and degrade (we want OPEN).
|
|
1299
1477
|
const budgetFailClosed = !operatorTierDeliver && perCallAllowsClosed && globalFailClosed !== false;
|
|
1300
1478
|
const budgetDegrade = budgetFailClosed && globalFailClosed !== true
|
|
1301
|
-
? (latencyMs) =>
|
|
1479
|
+
? (latencyMs) =>
|
|
1480
|
+
// Same disposition as review()'s fast-throw degrade: the SLOW and
|
|
1481
|
+
// FAST manifestations of one outage must not disagree about
|
|
1482
|
+
// whether a caught artifact is a nudge or a wall.
|
|
1483
|
+
buildDegradedToneResult(text, latencyMs, 'budget-timeout', _toneCfg?.advisoryMigration === true)
|
|
1302
1484
|
: undefined;
|
|
1303
1485
|
const templateFingerprint = options.messageKind === 'automated' ? fingerprintAutomatedTemplate(text) : undefined;
|
|
1304
1486
|
const result = await reviewWithinBudget(ctx.messagingToneGate.review(text, {
|
|
@@ -1332,6 +1514,36 @@ export function createRoutes(ctx) {
|
|
|
1332
1514
|
signals,
|
|
1333
1515
|
result,
|
|
1334
1516
|
});
|
|
1517
|
+
// ── Compliance credit ────────────────────────────────────────────────
|
|
1518
|
+
// The revised re-send passed → the ORIGINAL verdict is graded `right`.
|
|
1519
|
+
// Recording only overrides would measure the gate exclusively through the
|
|
1520
|
+
// cases the agent disputed: a sample guaranteed to look worse than the
|
|
1521
|
+
// gate is.
|
|
1522
|
+
//
|
|
1523
|
+
// The SERVER correlates this (see notePendingAdvisory) so it does not
|
|
1524
|
+
// depend on the agent remembering two metadata fields across a revise
|
|
1525
|
+
// cycle — the override path is structurally enforced, and an opt-in
|
|
1526
|
+
// counterpart would leave the sample permanently skewed. The explicit
|
|
1527
|
+
// `toneAdvisoryComplied` remains honored as a direct declaration.
|
|
1528
|
+
if (result.pass) {
|
|
1529
|
+
const revised = takeRevisionOf(options.topicId, text);
|
|
1530
|
+
if (options.toneAdvisoryComplied) {
|
|
1531
|
+
recordToneAdvisoryReaction({
|
|
1532
|
+
decisionRef: options.toneAdvisoryDecisionRef ?? revised?.decisionRef,
|
|
1533
|
+
rule: options.toneAdvisoryComplied,
|
|
1534
|
+
reaction: 'complied',
|
|
1535
|
+
derivation: 'declared',
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
else if (revised) {
|
|
1539
|
+
recordToneAdvisoryReaction({
|
|
1540
|
+
decisionRef: revised.decisionRef,
|
|
1541
|
+
rule: revised.rule,
|
|
1542
|
+
reaction: 'complied',
|
|
1543
|
+
derivation: 'inferred',
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1335
1547
|
if (!result.pass) {
|
|
1336
1548
|
// ── Advisory disposition (operator directive 2026-07-18, topic 29723) ──
|
|
1337
1549
|
// An advisory-rule citation is a NUDGE, never a terminal block: the
|
|
@@ -1340,9 +1552,93 @@ export function createRoutes(ctx) {
|
|
|
1340
1552
|
// resend unchanged with metadata.toneAdvisoryAck = "<rule>" to
|
|
1341
1553
|
// acknowledge; the override is RECORDED (a decision-quality signal,
|
|
1342
1554
|
// never authority). A blocking rule can NEVER be overridden this way.
|
|
1555
|
+
// ── The evidence-capturability invariant ─────────────────────────────
|
|
1556
|
+
// NEVER trade authority for evidence we are not collecting.
|
|
1557
|
+
//
|
|
1558
|
+
// The migration's entire justification is that an overridable nudge
|
|
1559
|
+
// produces the disagreement data a hard block cannot. That bargain is
|
|
1560
|
+
// void whenever the reaction could not actually be recorded, and there
|
|
1561
|
+
// are two independent ways for that to be true — both reachable in the
|
|
1562
|
+
// DEFAULT configuration, which is what makes this a real hazard rather
|
|
1563
|
+
// than a theoretical one:
|
|
1564
|
+
//
|
|
1565
|
+
// 1. The quality seam is dark or dry-run. `provenance.uniformSeam` is
|
|
1566
|
+
// a SEPARATE gate from `toneGate.advisoryMigration`, and its dryRun
|
|
1567
|
+
// DEFAULTS TRUE — so flipping the migration on without it would
|
|
1568
|
+
// hand out every loosening and record nothing.
|
|
1569
|
+
// 2. No `decisionRef` was minted. The budget-timeout degrade builds
|
|
1570
|
+
// its verdict in the route, outside the gate, where the router's
|
|
1571
|
+
// correlation id is unreachable — precisely the path that fires
|
|
1572
|
+
// under load.
|
|
1573
|
+
//
|
|
1574
|
+
// In either case the verdict falls back to a BLOCK. That is the safe
|
|
1575
|
+
// direction (it preserves today's behaviour rather than relaxing it),
|
|
1576
|
+
// and it makes the migration self-limiting: it can only ever be as live
|
|
1577
|
+
// as its own evidence collection.
|
|
1578
|
+
//
|
|
1579
|
+
// SCOPE: only a MIGRATION-derived advisory is subject to this. A rule
|
|
1580
|
+
// whose BASELINE disposition is already advisory (B21, which shipped
|
|
1581
|
+
// that way in the correction-derived-hardening work) never made the
|
|
1582
|
+
// evidence bargain — it was always simply the agent's call — so
|
|
1583
|
+
// demoting it here would silently harden a shipped, working behaviour
|
|
1584
|
+
// on every install where the quality seam is dark. That would be a
|
|
1585
|
+
// regression introduced by a safety check, which is its own bug class.
|
|
1586
|
+
if (result.advisory === true &&
|
|
1587
|
+
RULE_DISPOSITIONS[result.rule] !== 'advisory' &&
|
|
1588
|
+
(!result.decisionRef || !decisionQualityRecordingLive())) {
|
|
1589
|
+
logToneGateDecision({
|
|
1590
|
+
text,
|
|
1591
|
+
channel,
|
|
1592
|
+
topicId: options.topicId,
|
|
1593
|
+
signals,
|
|
1594
|
+
result: { ...result, advisory: false, advisoryUnrecordable: true },
|
|
1595
|
+
});
|
|
1596
|
+
return {
|
|
1597
|
+
ok: false,
|
|
1598
|
+
status: 422,
|
|
1599
|
+
reason: 'tone-gate-blocked',
|
|
1600
|
+
body: {
|
|
1601
|
+
error: 'tone-gate-blocked',
|
|
1602
|
+
rule: result.rule,
|
|
1603
|
+
issue: result.issue,
|
|
1604
|
+
suggestion: result.suggestion,
|
|
1605
|
+
// Named, never silent: the operator can see WHY a nudge came back
|
|
1606
|
+
// as a wall (No Silent Degradation).
|
|
1607
|
+
advisoryUnavailable: !result.decisionRef
|
|
1608
|
+
? 'no-decision-ref'
|
|
1609
|
+
: 'quality-recording-not-live',
|
|
1610
|
+
latencyMs: result.latencyMs,
|
|
1611
|
+
},
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1343
1614
|
if (result.advisory === true) {
|
|
1344
1615
|
if (options.toneAdvisoryAck === result.rule) {
|
|
1345
|
-
|
|
1616
|
+
// The override REASON is structurally required, not requested. The
|
|
1617
|
+
// whole point of the migration is the evidence: an override with no
|
|
1618
|
+
// recorded reason is an ungradeable event, and "remember to explain
|
|
1619
|
+
// yourself" is exactly the willpower this codebase refuses to rely
|
|
1620
|
+
// on. So a reasonless ack is refused and the message stays unsent.
|
|
1621
|
+
const reason = (options.toneAdvisoryAckReason ?? '').trim();
|
|
1622
|
+
if (reason.length < TONE_ADVISORY_REASON_MIN) {
|
|
1623
|
+
return {
|
|
1624
|
+
ok: false,
|
|
1625
|
+
status: 422,
|
|
1626
|
+
reason: 'tone-gate-advisory-reason-required',
|
|
1627
|
+
body: {
|
|
1628
|
+
error: 'tone-gate-advisory-reason-required',
|
|
1629
|
+
notSent: true,
|
|
1630
|
+
rule: result.rule,
|
|
1631
|
+
howToProceed: `The override needs a reason: re-send with metadata.toneAdvisoryAck = "${result.rule}" AND ` +
|
|
1632
|
+
`metadata.toneAdvisoryAckReason = a short sentence on why the nudge is wrong here. ` +
|
|
1633
|
+
`It is recorded as the evidence that grades this check.`,
|
|
1634
|
+
},
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
const overridden = {
|
|
1638
|
+
...result,
|
|
1639
|
+
advisoryOverridden: true,
|
|
1640
|
+
overrideReasonHead: scrubForStore(reason).text.slice(0, 120),
|
|
1641
|
+
};
|
|
1346
1642
|
logToneGateDecision({
|
|
1347
1643
|
text,
|
|
1348
1644
|
channel,
|
|
@@ -1350,8 +1646,17 @@ export function createRoutes(ctx) {
|
|
|
1350
1646
|
signals,
|
|
1351
1647
|
result: overridden,
|
|
1352
1648
|
});
|
|
1649
|
+
recordToneAdvisoryReaction({
|
|
1650
|
+
decisionRef: options.toneAdvisoryDecisionRef ?? result.decisionRef,
|
|
1651
|
+
rule: result.rule,
|
|
1652
|
+
reaction: 'override',
|
|
1653
|
+
reason,
|
|
1654
|
+
});
|
|
1353
1655
|
return { ok: true };
|
|
1354
1656
|
}
|
|
1657
|
+
// Remember it so a later revised send is credited WITHOUT the agent
|
|
1658
|
+
// having to carry metadata back (the asymmetry fix).
|
|
1659
|
+
notePendingAdvisory(options.topicId, result.rule, result.decisionRef, text);
|
|
1355
1660
|
return {
|
|
1356
1661
|
ok: false,
|
|
1357
1662
|
status: 422,
|
|
@@ -1362,7 +1667,16 @@ export function createRoutes(ctx) {
|
|
|
1362
1667
|
rule: result.rule,
|
|
1363
1668
|
issue: result.issue,
|
|
1364
1669
|
suggestion: result.suggestion,
|
|
1365
|
-
|
|
1670
|
+
// Handed back so the resend can JOIN its reaction to this exact
|
|
1671
|
+
// decision. Without it an override is an orphan the grader can
|
|
1672
|
+
// never attach to the verdict it disputes.
|
|
1673
|
+
...(result.decisionRef ? { decisionRef: result.decisionRef } : {}),
|
|
1674
|
+
howToProceed: `ADVISORY — the message was NOT sent, and the decision is yours. Either (a) revise and re-send, adding ` +
|
|
1675
|
+
`metadata.toneAdvisoryComplied = "${result.rule}"` +
|
|
1676
|
+
(result.decisionRef ? ` and metadata.toneAdvisoryDecisionRef = "${result.decisionRef}"` : '') +
|
|
1677
|
+
` so the check gets credit for a catch; or (b) re-send unchanged with metadata.toneAdvisoryAck = ` +
|
|
1678
|
+
`"${result.rule}" plus metadata.toneAdvisoryAckReason explaining why the nudge is wrong here. ` +
|
|
1679
|
+
`Both are recorded — they are how this check learns whether it is any good.`,
|
|
1366
1680
|
latencyMs: result.latencyMs,
|
|
1367
1681
|
},
|
|
1368
1682
|
};
|
|
@@ -1724,6 +2038,22 @@ export function createRoutes(ctx) {
|
|
|
1724
2038
|
// failure so it is AUDITED, never silent (the No-Silent-Degradation
|
|
1725
2039
|
// reconciliation rests on this being observable).
|
|
1726
2040
|
failedOpenOperatorChannel: entry.result.failedOpenOperatorChannel || false,
|
|
2041
|
+
// ── Advisory migration audit (ALWAYS ON) ──────────────────────────
|
|
2042
|
+
// The override is the one event this whole feature exists to observe,
|
|
2043
|
+
// and until this line existed an override logged BYTE-IDENTICALLY to
|
|
2044
|
+
// the verdict it overrode. The structured evidence row is the rich
|
|
2045
|
+
// record, but it lives behind a separate feature gate that defaults to
|
|
2046
|
+
// recording nothing — so the audit that must never be absent belongs
|
|
2047
|
+
// HERE, on the unconditional stderr line, not there.
|
|
2048
|
+
advisory: entry.result.advisory || false,
|
|
2049
|
+
advisoryOverridden: entry.result.advisoryOverridden || false,
|
|
2050
|
+
// Present only when the advisory disposition was withdrawn because its
|
|
2051
|
+
// evidence could not be captured — names the reason rather than
|
|
2052
|
+
// silently reverting to a wall.
|
|
2053
|
+
advisoryUnrecordable: entry.result.advisoryUnrecordable || false,
|
|
2054
|
+
// A bounded, scrubbed head of the override reason. Enough to audit that
|
|
2055
|
+
// a reason existed and roughly what it said; never the whole field.
|
|
2056
|
+
overrideReasonHead: entry.result.overrideReasonHead ?? null,
|
|
1727
2057
|
latencyMs: entry.result.latencyMs,
|
|
1728
2058
|
signals: {
|
|
1729
2059
|
junk: entry.signals.junk?.detected ?? null,
|
|
@@ -11932,6 +12262,15 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
11932
12262
|
// Advisory-rule override (operator directive 2026-07-18): the FULL rule id
|
|
11933
12263
|
// the sender explicitly acknowledges. Length-clamped; validated against the
|
|
11934
12264
|
// cited rule at the seam (a mismatch or a blocking rule is never overridden).
|
|
12265
|
+
const toneAdvisoryAckReason = typeof metadata?.toneAdvisoryAckReason === 'string'
|
|
12266
|
+
? metadata.toneAdvisoryAckReason.slice(0, 500)
|
|
12267
|
+
: undefined;
|
|
12268
|
+
const toneAdvisoryDecisionRef = typeof metadata?.toneAdvisoryDecisionRef === 'string'
|
|
12269
|
+
? metadata.toneAdvisoryDecisionRef.slice(0, 128)
|
|
12270
|
+
: undefined;
|
|
12271
|
+
const toneAdvisoryComplied = typeof metadata?.toneAdvisoryComplied === 'string'
|
|
12272
|
+
? metadata.toneAdvisoryComplied.slice(0, 64)
|
|
12273
|
+
: undefined;
|
|
11935
12274
|
const toneAdvisoryAck = typeof metadata?.toneAdvisoryAck === 'string'
|
|
11936
12275
|
? metadata.toneAdvisoryAck.slice(0, 64)
|
|
11937
12276
|
: undefined;
|
|
@@ -12011,6 +12350,9 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
12011
12350
|
allowLocalhostLink,
|
|
12012
12351
|
messageKind,
|
|
12013
12352
|
toneAdvisoryAck,
|
|
12353
|
+
toneAdvisoryAckReason,
|
|
12354
|
+
toneAdvisoryDecisionRef,
|
|
12355
|
+
toneAdvisoryComplied,
|
|
12014
12356
|
})))
|
|
12015
12357
|
return;
|
|
12016
12358
|
// In-flight reservation (2026-07-03): the isDuplicate pre-check above and the
|
|
@@ -12038,12 +12380,23 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
12038
12380
|
// Relay-hop forwarding (§2.5): when this standby relays through the
|
|
12039
12381
|
// lease holder, the kind metadata must survive the hop so the
|
|
12040
12382
|
// HOLDER's gate/audit see accurate context. Direct sends ignore it.
|
|
12041
|
-
kindMetadata: messageKind || senderClass || advisoryAck
|
|
12383
|
+
kindMetadata: messageKind || senderClass || advisoryAck || toneAdvisoryAck || toneAdvisoryComplied
|
|
12042
12384
|
? {
|
|
12043
12385
|
...(messageKind ? { messageKind } : {}),
|
|
12044
12386
|
...(senderClass ? { senderClass } : {}),
|
|
12045
12387
|
...(metadataJobSlug ? { jobSlug: metadataJobSlug } : {}),
|
|
12046
12388
|
...(advisoryAck ? { advisoryAck: true, advisoryCodes } : {}),
|
|
12389
|
+
// ── Tone-advisory reaction across the relay hop ─────────────
|
|
12390
|
+
// A tokenless standby SKIPS its own gate and relays to the
|
|
12391
|
+
// lease holder, which gates on receipt. Without these four the
|
|
12392
|
+
// holder re-cites the advisory on every attempt and the ack can
|
|
12393
|
+
// never reach it — a relayed topic would hold a permanently
|
|
12394
|
+
// unsendable message with no recourse, which is the exact
|
|
12395
|
+
// failure the migration exists to remove.
|
|
12396
|
+
...(toneAdvisoryAck ? { toneAdvisoryAck } : {}),
|
|
12397
|
+
...(toneAdvisoryAckReason ? { toneAdvisoryAckReason } : {}),
|
|
12398
|
+
...(toneAdvisoryDecisionRef ? { toneAdvisoryDecisionRef } : {}),
|
|
12399
|
+
...(toneAdvisoryComplied ? { toneAdvisoryComplied } : {}),
|
|
12047
12400
|
}
|
|
12048
12401
|
: undefined,
|
|
12049
12402
|
});
|
|
@@ -12138,6 +12491,16 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
12138
12491
|
// retry of this exact text is not wrongly suppressed as a duplicate. Safe
|
|
12139
12492
|
// no-op when nothing was reserved (allowDuplicate / below-floor text).
|
|
12140
12493
|
outboundContentDedup.releaseReservation(topicId, text);
|
|
12494
|
+
// ── Holder refusal, relayed verbatim ────────────────────────────────
|
|
12495
|
+
// On a tokenless standby the tone gate runs on the HOLDER, so its 422
|
|
12496
|
+
// arrives here as a thrown RelayRefusedError. Re-emit the holder's own
|
|
12497
|
+
// status and body: reporting an actionable refusal (a nudge with a rule,
|
|
12498
|
+
// a decisionRef and how to proceed) as a generic 500 is what left a
|
|
12499
|
+
// relayed topic holding a permanently unanswerable message.
|
|
12500
|
+
if (err instanceof RelayRefusedError) {
|
|
12501
|
+
res.status(err.status).json({ ...err.body, relayedFromHolder: true });
|
|
12502
|
+
return;
|
|
12503
|
+
}
|
|
12141
12504
|
// @silent-fallback-ok — NOT a silent fallback: this catch surfaces a 500 to the
|
|
12142
12505
|
// caller. The tag exists because the no-silent-fallbacks scanner's fixed 20-line
|
|
12143
12506
|
// window reaches past this route's end into the next route's `db = null`
|
|
@@ -12356,6 +12719,11 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
12356
12719
|
// verdicts (a fast block) still hold; only the no-verdict-in-time path
|
|
12357
12720
|
// delivers. (Block/allow on a returned verdict is unchanged.)
|
|
12358
12721
|
failClosedOnBudgetTimeout: false,
|
|
12722
|
+
// Channel parity for the advisory migration. This route is MANDATED by
|
|
12723
|
+
// the agent template for every ship/restart narration and has no
|
|
12724
|
+
// fallback channel — so an advisory 422 here that promised an override
|
|
12725
|
+
// the route could not accept would loop the agent with nowhere to go.
|
|
12726
|
+
...toneAdvisoryMetadata(metadata),
|
|
12359
12727
|
}))
|
|
12360
12728
|
return;
|
|
12361
12729
|
try {
|
|
@@ -12898,9 +13266,10 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
12898
13266
|
if (await checkOutboundMessage(text, 'slack', res, {
|
|
12899
13267
|
allowDebugText: metadata?.allowDebugText === true,
|
|
12900
13268
|
allowDuplicate: metadata?.allowDuplicate === true,
|
|
12901
|
-
|
|
12902
|
-
|
|
12903
|
-
|
|
13269
|
+
// Channel parity: the override/compliance evidence path is not a
|
|
13270
|
+
// Telegram feature. A Slack override that recorded nothing would make
|
|
13271
|
+
// the meter's sample silently channel-dependent.
|
|
13272
|
+
...toneAdvisoryMetadata(metadata),
|
|
12904
13273
|
// Kind threading mirrors /telegram/reply — the jargon/filePath signal
|
|
12905
13274
|
// computation is single-sourced inside evaluateOutbound, so every
|
|
12906
13275
|
// channel gets it uniformly (spec outbound-jargon-filepath-gap §2.2).
|
|
@@ -14538,6 +14907,9 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
14538
14907
|
let right = 0;
|
|
14539
14908
|
let wrong = 0;
|
|
14540
14909
|
let unknown = 0;
|
|
14910
|
+
// right+wrong outcomes whose evidence is the interested party's own
|
|
14911
|
+
// account (see the honesty marker on gradeDistribution below).
|
|
14912
|
+
let selfReportGraded = 0;
|
|
14541
14913
|
const byRule = {};
|
|
14542
14914
|
const byRung = {};
|
|
14543
14915
|
const byStrength = {};
|
|
@@ -14548,6 +14920,9 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
14548
14920
|
wrong += r.n;
|
|
14549
14921
|
else if (r.grade === 'unknown')
|
|
14550
14922
|
unknown += r.n;
|
|
14923
|
+
if ((r.grade === 'right' || r.grade === 'wrong') && r.evidenceStrength === 'self-report') {
|
|
14924
|
+
selfReportGraded += r.n;
|
|
14925
|
+
}
|
|
14551
14926
|
bump(byRule, r.ruleId, r.grade, r.n);
|
|
14552
14927
|
bump(byRung, r.rung, r.grade, r.n);
|
|
14553
14928
|
bump(byStrength, r.evidenceStrength, r.grade, r.n);
|
|
@@ -14564,7 +14939,26 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
14564
14939
|
outcomesKnownRatio: decisions > 0 ? outcomesKnown / decisions : 0,
|
|
14565
14940
|
// Below the minimum sample an aggregate rate is not actionable (§5.5 codex r5).
|
|
14566
14941
|
insufficientEvidence: outcomesKnown < minSample,
|
|
14567
|
-
gradeDistribution: {
|
|
14942
|
+
gradeDistribution: {
|
|
14943
|
+
right,
|
|
14944
|
+
wrong,
|
|
14945
|
+
unknown,
|
|
14946
|
+
expired: expiredByPoint.get(entry.decisionPoint) ?? 0,
|
|
14947
|
+
// ── Self-report honesty marker ────────────────────────────────────
|
|
14948
|
+
// `byStrength` segregates evidence classes, but this FLAT sibling
|
|
14949
|
+
// blends them — and it is the field a casual reader (or a future
|
|
14950
|
+
// automated consumer) quotes. With the tone gate's agent-reaction
|
|
14951
|
+
// rules registered, a decision point can now have right/wrong grades
|
|
14952
|
+
// that are ENTIRELY the agent's own account of a judgment about its
|
|
14953
|
+
// own message. Unmarked, that reads as measured error rate.
|
|
14954
|
+
//
|
|
14955
|
+
// These two fields let any consumer of the flat distribution see how
|
|
14956
|
+
// much of it is self-report without having to know `byStrength`
|
|
14957
|
+
// exists. `selfReportOnly` is the loud case: every graded outcome
|
|
14958
|
+
// here is the interested party's word.
|
|
14959
|
+
selfReportShare: right + wrong > 0 ? selfReportGraded / (right + wrong) : 0,
|
|
14960
|
+
selfReportOnly: right + wrong > 0 && selfReportGraded === right + wrong,
|
|
14961
|
+
},
|
|
14568
14962
|
// Strength FIRST (default aggregate) — proof-like and heuristic grades are never conflated.
|
|
14569
14963
|
byStrength,
|
|
14570
14964
|
byRule,
|
|
@@ -16470,6 +16864,10 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
16470
16864
|
if (await checkOutboundMessage(text, 'whatsapp', res, {
|
|
16471
16865
|
allowDebugText: metadata?.allowDebugText === true,
|
|
16472
16866
|
allowDuplicate: metadata?.allowDuplicate === true,
|
|
16867
|
+
// Channel parity for the advisory migration — an override path that
|
|
16868
|
+
// works on Telegram and silently does not on WhatsApp would make the
|
|
16869
|
+
// meter's sample channel-dependent, and strand this channel's agent.
|
|
16870
|
+
...toneAdvisoryMetadata(metadata),
|
|
16473
16871
|
}))
|
|
16474
16872
|
return;
|
|
16475
16873
|
try {
|
|
@@ -16522,6 +16920,7 @@ document.getElementById('mcpForm').addEventListener('submit', async function (e)
|
|
|
16522
16920
|
if (await checkOutboundMessage(text, 'imessage', res, {
|
|
16523
16921
|
allowDebugText: imessageMetadata?.allowDebugText === true,
|
|
16524
16922
|
allowDuplicate: imessageMetadata?.allowDuplicate === true,
|
|
16923
|
+
...toneAdvisoryMetadata(imessageMetadata),
|
|
16525
16924
|
}))
|
|
16526
16925
|
return;
|
|
16527
16926
|
}
|