From c68fd2498e90239d610e36e76812632f08186772 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 27 Feb 2026 13:08:37 -0800 Subject: [PATCH] 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. --- docs/plans/state.json | 12 +++++ src/audit/phase0BaselineSummary.test.ts | 59 +++++++++++++++++++++++++ src/audit/phase0BaselineSummary.ts | 27 ++++++++--- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index a6de620..9af7261 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -463,6 +463,18 @@ ], "test_status": "pnpm test:run src/audit/phase0BaselineDrift.test.ts + pnpm typecheck passing" }, + "phase0-live-baseline-summary-limit-validation-hardening": { + "status": "completed", + "date": "2026-02-27", + "updated": "2026-02-27", + "summary": "Hardened phase-0 baseline summary limit handling so `maxSessions`, `maxChannels`, and `maxSkipReasons` enforce non-negative finite values, treat `0` as zero rows, and reject negative limits with explicit errors.", + "files_modified": [ + "src/audit/phase0BaselineSummary.ts", + "src/audit/phase0BaselineSummary.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/audit/phase0BaselineSummary.test.ts + pnpm typecheck passing" + }, "phase0-instrumentation-ticket-checklist": { "status": "completed", "date": "2026-02-25", diff --git a/src/audit/phase0BaselineSummary.test.ts b/src/audit/phase0BaselineSummary.test.ts index 10ea549..3675ec1 100644 --- a/src/audit/phase0BaselineSummary.test.ts +++ b/src/audit/phase0BaselineSummary.test.ts @@ -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', () => { diff --git a/src/audit/phase0BaselineSummary.ts b/src/audit/phase0BaselineSummary.ts index 711fc6a..87168d8 100644 --- a/src/audit/phase0BaselineSummary.ts +++ b/src/audit/phase0BaselineSummary.ts @@ -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, 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, 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), }, }; }