fix(audit): enforce phase0 summary max-limit semantics

Validate maxSessions/maxChannels/maxSkipReasons as non-negative finite values, make 0 produce zero rows, and add regression coverage. No architecture/protocol flow changes; diagram files reviewed and no updates were needed.
This commit is contained in:
William Valentin
2026-02-27 13:08:37 -08:00
parent fd0ab6e6df
commit c68fd2498e
3 changed files with 93 additions and 5 deletions
+59
View File
@@ -163,6 +163,65 @@ describe('summarizePhase0Baseline', () => {
expect(summary.run_outcomes.by_channel).toHaveLength(1);
expect(summary.run_outcomes.by_channel[0]?.key).toBe('telegram');
});
it('applies max row limits including zero', () => {
const events: AuditEvent[] = [
makeEvent(1000, 'run.state', {
session_id: 's1',
channel: 'telegram',
sender: 'u1',
source: 'channel',
state: 'complete',
}),
makeEvent(1100, 'run.state', {
session_id: 's2',
channel: 'discord',
sender: 'u2',
source: 'gateway',
state: 'error',
}),
makeEvent(1200, 'reaction.skip', {
session_id: 's1',
channel: 'telegram',
sender: 'u1',
source: 'channel',
reason: 'no_match',
}),
makeEvent(1300, 'reaction.skip', {
session_id: 's2',
channel: 'discord',
sender: 'u2',
source: 'gateway',
reason: 'no_rules',
}),
];
const none = summarizePhase0Baseline(events, {
maxChannels: 0,
maxSessions: 0,
maxSkipReasons: 0,
});
expect(none.run_outcomes.by_channel).toHaveLength(0);
expect(none.run_outcomes.by_session).toHaveLength(0);
expect(none.reactions.skip_reasons).toHaveLength(0);
const oneEach = summarizePhase0Baseline(events, {
maxChannels: 1,
maxSessions: 1,
maxSkipReasons: 1,
});
expect(oneEach.run_outcomes.by_channel).toHaveLength(1);
expect(oneEach.run_outcomes.by_session).toHaveLength(1);
expect(oneEach.reactions.skip_reasons).toHaveLength(1);
});
it('rejects negative max limits', () => {
const events: AuditEvent[] = [];
expect(() => summarizePhase0Baseline(events, { maxSessions: -1 })).toThrow('maxSessions');
expect(() => summarizePhase0Baseline(events, { maxChannels: -1 })).toThrow('maxChannels');
expect(() => summarizePhase0Baseline(events, { maxSkipReasons: -1 })).toThrow('maxSkipReasons');
});
});
describe('renderPhase0BaselineMarkdown', () => {
+22 -5
View File
@@ -119,6 +119,19 @@ function toPct(part: number, whole: number): number | null {
return Math.round((part / whole) * 10000) / 100;
}
function normalizeLimit(value: number | undefined, optionName: string): number | undefined {
if (value === undefined) {
return undefined;
}
if (!Number.isFinite(value)) {
throw new Error(`${optionName} must be a finite number.`);
}
if (value < 0) {
throw new Error(`${optionName} must be greater than or equal to 0.`);
}
return Math.floor(value);
}
function percentile(sortedAscending: number[], pct: number): number {
if (sortedAscending.length === 0) {
return 0;
@@ -183,7 +196,7 @@ function sortGroups(groups: Map<string, RunCounter>, limit?: number): RunOutcome
return a.key.localeCompare(b.key);
});
if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) {
if (typeof limit === 'number' && Number.isFinite(limit)) {
return rows.slice(0, Math.floor(limit));
}
return rows;
@@ -199,7 +212,7 @@ function sortSkipReasons(reasons: Map<string, number>, limit?: number): Reaction
pct: total > 0 ? Math.round((count / total) * 10000) / 100 : 0,
}));
if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) {
if (typeof limit === 'number' && Number.isFinite(limit)) {
return rows.slice(0, Math.floor(limit));
}
return rows;
@@ -244,6 +257,10 @@ export function summarizePhase0Baseline(
events: AuditEvent[],
options: Phase0BaselineSummaryOptions = {},
): Phase0BaselineSummary {
const maxSessions = normalizeLimit(options.maxSessions, 'maxSessions');
const maxChannels = normalizeLimit(options.maxChannels, 'maxChannels');
const maxSkipReasons = normalizeLimit(options.maxSkipReasons, 'maxSkipReasons');
const sessionFilter = new Set(options.sessionIds ?? []);
const channelFilter = new Set(options.channels ?? []);
const senderFilter = new Set(options.senders ?? []);
@@ -342,8 +359,8 @@ export function summarizePhase0Baseline(
event_counts: eventCounts,
run_outcomes: {
overall: buildRunOutcomeStats(runTotals),
by_channel: sortGroups(runByChannel, options.maxChannels),
by_session: sortGroups(runBySession, options.maxSessions),
by_channel: sortGroups(runByChannel, maxChannels),
by_session: sortGroups(runBySession, maxSessions),
},
cancel_latency_ms: computeLatencyStats(cancelLatencies),
reactions: {
@@ -352,7 +369,7 @@ export function summarizePhase0Baseline(
total: totalReactions,
match_rate_pct: toPct(reactionMatched, totalReactions),
skip_rate_pct: toPct(reactionSkipped, totalReactions),
skip_reasons: sortSkipReasons(skipReasons, options.maxSkipReasons),
skip_reasons: sortSkipReasons(skipReasons, maxSkipReasons),
},
};
}