chore: merge executor worktree (worktree-agent-a153b3afb38c39100)

This commit is contained in:
lorentz 2026-07-16 06:40:43 -04:00
commit c3ad292139
3 changed files with 408 additions and 14 deletions

View file

@ -0,0 +1,120 @@
---
phase: 18-campaign-grouping-phishing-analysis-api
plan: 05
subsystem: api
tags: [postgres, vitest, campaign-grouping, phishing, idempotency, gap-closure]
# Dependency graph
requires:
- phase: 18-01
provides: groupReportIntoCampaign tiered matching (CAMP-01/CAMP-02) and campaigns schema (migration 097)
- phase: 18-04
provides: reports.campaign_id on OwnReportRow and the same-campaign no-op guard this plan extends
provides:
- "CR-03 fix: own-campaign revalidation guard in groupReportIntoCampaign — re-analyzing a single-report campaign reuses the report's own existing campaign instead of creating a duplicate campaigns row with the same campaign_key"
- "CR-02 fix: decrementOriginCampaign() helper, called on cross-campaign migration AND on the signal-diverged create-new fall-through, so an abandoned origin campaign's report_count is never left stale"
- "Mocked regression tests (Tests A-H) reproducing both CR-03 and CR-02 exactly, with RED confirmed for Tests A, B, E, H against pre-fix code"
affects: [19-classification, 20-remediation-approval-audit]
# Tech tracking
tech-stack:
added: []
patterns:
- "Own-signal tier keys (tier1Key/tier2Key/tier3Key/currentKeys) computed once, shared between the own-campaign revalidation guard and the create-new fall-through"
- "Shared decrementOriginCampaign(client, campaignId) helper called from both abandonment sites (sibling-migration branch and signal-diverged create-new) to avoid duplicating the UPDATE statement"
key-files:
created: []
modified:
- lib/services/campaign-grouping-service.ts
- lib/services/campaign-grouping-service.test.ts
key-decisions:
- "Own-campaign guard runs ONLY when matchCampaignId is null (no sibling matched) AND ownReport.campaign_id is non-null — placed after the sibling-match branch, before create-new, so it never suppresses a legitimate D-08 sibling upgrade"
- "CR-02 decrement is scope-limited to decrement-only — never deletes an origin campaign even if report_count reaches 0 (FK-cascade/detail-route risk deferred to a later phase per plan's explicit scope note)"
- "CR-02 decrement applied in BOTH abandonment sites (existing sibling-migration branch AND Task 1's signal-diverged create-new fall-through) per plan-checker guidance — not just the migration branch"
requirements-completed: [CAMP-01, CAMP-02]
# Metrics
duration: n/a (partial — Task 3 checkpoint pending)
completed: null
---
# Phase 18 Plan 05: Campaign-grouping gap closure (CR-03 + CR-02) Summary — PARTIAL (Tasks 1-2 complete, Task 3 checkpoint pending)
**Own-campaign revalidation guard closes CR-03 (duplicate campaigns on single-report re-analyze); shared decrementOriginCampaign() helper closes CR-02 (stale report_count on migration and signal-diverged create-new) — both proven by 8 new mocked vitest regression tests with confirmed RED on Tests A, B, E, H.**
**STATUS: Tasks 1 and 2 are complete and committed. Task 3 (BLOCKING human-verify checkpoint — live database verification) has NOT yet run. This summary will be superseded/finalized once Task 3 resolves.**
## Performance
- **Tasks completed:** 2 of 3 (Task 3 is the blocking checkpoint, pending)
- **Files modified:** 2 (`lib/services/campaign-grouping-service.ts`, `lib/services/campaign-grouping-service.test.ts`)
## Accomplishments
- **CR-03 closed (code + mocked tests):** re-analyzing a single-report campaign (no sibling report exists yet) now reuses the report's own existing `campaign_id` with `created:false` — zero `INSERT`/`UPDATE` queries issued. Root cause was every tier query's required self-exclusion (`r.id != $x`) meaning a lone report could never match itself, so `matchCampaignId` stayed null and control fell straight into "create new campaign" on every repeat `/analyze` call.
- **CR-02 closed (code + mocked tests):** cross-campaign migration (report moves from campaign A to different existing campaign B) now decrements A's `report_count` in addition to incrementing B's. The identical decrement is also applied when the signal-diverged create-new fall-through abandons an origin campaign in favor of a brand-new one (the plan-checker-flagged second abandonment site).
- **D-08 preserved:** a report with a genuinely better sibling match still upgrades to that sibling's campaign; the own-campaign guard only runs when no sibling matched, and the CR-02 decrement correctly does not fire when there is no origin campaign (never-grouped report).
- **No regression:** first-time grouping of a genuinely new report (`campaign_id = null`) still creates exactly one campaign; the existing 18-04 same-campaign no-op test (renamed here to "Test G") still fires zero mutations.
## Task Commits
Each task was committed atomically:
1. **Task 1: Own-campaign revalidation guard before create-new (CR-03)**`633b48c` (fix)
2. **Task 2: Decrement origin campaign report_count on migration AND signal-diverged create-new (CR-02)**`6945591` (fix)
3. **Task 3: Live database verification of no-duplicate-on-reanalyze** — NOT YET RUN (blocking human-verify checkpoint)
_Both Task 1 and Task 2 followed TDD RED-before-GREEN discipline: RED confirmed via `npx vitest run` for Tests A/B (Task 1) and Tests E/H (Task 2) against the pre-fix code, then GREEN confirmed after implementing the fix._
## Files Created/Modified
- `lib/services/campaign-grouping-service.ts` — added `decrementOriginCampaign()` helper; hoisted `tier1Key`/`tier2Key`/`tier3Key`/`currentKeys` computation above the find-or-create block; added the CR-03 own-campaign revalidation guard between the sibling-match branch and the create-new fall-through; added the CR-02 decrement call in both the sibling-migration branch and the signal-diverged create-new fall-through
- `lib/services/campaign-grouping-service.test.ts` — added `ownCampaign` to `MockRows` + a new SQL-substring router branch (`SELECT campaign_key`); added Tests A-D (CR-03) and Tests E-H (CR-02, with the pre-existing 18-04 same-campaign no-op test relabeled as "Test G")
## Decisions Made
- Own-campaign guard placed **outside** (after) the sibling-match branch, not merged into it — the plan required this to guarantee it never suppresses a legitimate D-08 sibling upgrade, since the guard must only run when `matchCampaignId` is null.
- Factored the CR-02 decrement into a single shared `decrementOriginCampaign(client, campaignId)` helper rather than duplicating the raw `UPDATE campaigns SET report_count = report_count - 1 ...` statement at both call sites — reduces duplication while keeping each call site's guard condition (`if (ownReport.campaign_id)`) explicit and readable at the call site.
- Test G (the pre-existing 18-04 regression test) was renamed/annotated in place rather than duplicated, since its assertions already fully satisfy the plan's Test G requirement (same-campaign re-match remains a zero-mutation no-op).
## Deviations from Plan
**1. [Rule 1 - Bug, self-caught during GREEN] Missing null-guard on the CR-02 decrement in the migration branch**
- **Found during:** Task 2, first GREEN attempt
- **Issue:** The initial implementation called `decrementOriginCampaign(client, ownReport.campaign_id)` unconditionally after the increment/re-link in the sibling-migration branch, without checking `ownReport.campaign_id` was non-null first. This broke the existing "Tier 3 match increments..." regression test and the new Test F (D-08 upgrade with no prior campaign) — both scenarios have `ownReport.campaign_id` as `undefined`/`null` (a never-grouped report), and the unconditional call issued a spurious second `UPDATE campaigns` with `campaignId = undefined` as the bound parameter.
- **Fix:** Wrapped the call in `if (ownReport.campaign_id) { await decrementOriginCampaign(...); }`, matching the plan's explicit requirement that the decrement only fires "when there is no origin (campaign_id=null) or when the match resolves to the report's own campaign" → no decrement.
- **Files modified:** `lib/services/campaign-grouping-service.ts`
- **Verification:** Re-ran `npx vitest run lib/services/campaign-grouping-service.test.ts` — all 24 tests passed (previously 2 failing: the pre-existing Tier-3-match test and new Test F).
- **Committed in:** `6945591` (part of Task 2 commit — caught and fixed before the commit was made, not a separate follow-up commit)
---
**Total deviations:** 1 auto-fixed (Rule 1 — bug caught during the plan's own TDD GREEN verification step, not a deviation from the plan's design)
**Impact on plan:** No scope creep — this was a bug in my own first-draft implementation of the plan's specified behavior, caught by the plan's own Test F before committing. Zero impact on the plan's intended design.
## Issues Encountered
None beyond the self-caught deviation above. `npx tsc --noEmit --pretty` exits 0 after both tasks; `npx vitest run lib/services/campaign-grouping-service.test.ts` is green with all 24 tests (16 pre-existing + Tests A-H new).
## User Setup Required
None — no external service configuration required. Task 3 requires a human to run a live verification against a real Postgres database (dev instance or a cleaned-up production throwaway ticket); this is not "setup" but the plan's mandatory human-verification checkpoint, detailed in `18-05-PLAN.md` Task 3's `<how-to-verify>` block.
## Next Phase Readiness
**NOT READY — Task 3 (blocking human-verify checkpoint) has not run.** Per the plan's `coverage_caveat`: mocked-only unit coverage was proven insufficient last time (the 18-04 mocked regression test passed while CR-03 shipped and reproduced live in production), so this gap-closure plan is explicitly not considered closed until a human confirms, against a real database:
1. A second consecutive `/analyze` on the same single-report ticket returns the same `campaignId` with `created:false` (not a new campaign, not `created:true`).
2. A direct DB query shows exactly one `campaigns` row for that `campaign_key` with an unchanged `report_count`.
3. Any test data created during verification is cleaned up.
This worktree's code (commits `633b48c` and `6945591`) is ready for that live verification. No further code changes are anticipated unless the live verification surfaces a divergence from the mocked tests' assumptions.
---
*Phase: 18-campaign-grouping-phishing-analysis-api*
*Plan: 05*
*Status: PARTIAL — Tasks 1-2 complete, Task 3 checkpoint pending human live-verification*

View file

@ -70,6 +70,8 @@ interface MockRows {
tier2Candidates?: unknown[];
candidateIndicators?: unknown[];
tier3?: unknown[];
/** CR-03: rows for the own-campaign revalidation SELECT (campaigns WHERE id = ownReport.campaign_id). */
ownCampaign?: unknown[];
insertCampaign?: unknown[];
}
@ -102,6 +104,9 @@ function makeClient(rows: MockRows) {
if (sql.includes('BETWEEN $4::timestamptz')) {
return { rows: rows.tier3 ?? [], rowCount: rows.tier3?.length ?? 0 };
}
if (sql.includes('SELECT campaign_key')) {
return { rows: rows.ownCampaign ?? [], rowCount: rows.ownCampaign?.length ?? 0 };
}
if (sql.includes('UPDATE campaigns')) {
return { rows: [], rowCount: 1 };
}
@ -334,7 +339,7 @@ describe('groupReportIntoCampaign', () => {
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
});
it('re-analyzing a report already linked to a campaign that a sibling report re-matches does not increment report_count a second time', async () => {
it('Test G (CR-02 guard against over-firing): same-campaign re-match remains a pure no-op — the origin decrement must NOT fire when the match resolves back to the report\'s own campaign', async () => {
// Own report is already linked to campaign-1. A SIBLING report (not this
// report's own excluded row) also belongs to campaign-1 and matches via
// Tier 3 (sender + subject + client). This is the CAMP-02/CR-01 gap:
@ -360,4 +365,194 @@ describe('groupReportIntoCampaign', () => {
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
});
// ===========================================================================
// CR-03 (gap closure, 18-05 Task 1): own-campaign revalidation guard.
//
// Root cause: every tier query above self-excludes the report's OWN row
// (`r.id != $x`). For a single-report campaign (no sibling report exists
// yet), all three tiers return zero rows on re-analyze, matchCampaignId
// stays null, and — pre-fix — control fell straight into "create new
// campaign", producing a SECOND campaigns row with the identical
// campaign_key. Tests A/B below reproduce that exact scenario and FAILED
// against the pre-fix code (confirmed during RED).
// ===========================================================================
it('Test A (CR-03 core): single-report re-analyze via Tier-1 stored key reuses the report\'s own existing campaign with zero mutating queries', async () => {
stage({
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
ownMessage: [{ id: 'own-msg-id', message_id: 'lone-message-id' }],
tier1: [], // no sibling shares this message_id — single-report campaign
ownIndicators: [],
tier2Candidates: [],
tier3: [],
ownCampaign: [{ campaign_key: 'message_id:lone-message-id', group_method: 'message_id' }],
});
const result = await groupReportIntoCampaign('report-own-a');
expect(result).toEqual({
campaignId: 'campaign-own',
groupMethod: 'message_id',
created: false,
});
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
});
it('Test B (CR-03 Tier-3-only variant): single-report re-analyze via Tier-3 stored key reuses the report\'s own existing campaign with zero mutating queries', async () => {
stage({
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
ownMessage: [],
tier3: [],
ownCampaign: [
{ campaign_key: 'sender_subject_client:5:invoice alert:10', group_method: 'sender_subject_client' },
],
});
const result = await groupReportIntoCampaign('report-own-b');
expect(result).toEqual({
campaignId: 'campaign-own',
groupMethod: 'sender_subject_client',
created: false,
});
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(0);
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
expect(callsContaining('UPDATE reports SET campaign_id')).toHaveLength(0);
});
it('Test C (no regression): campaign_id=null still creates exactly one campaign and never runs the own-campaign guard', async () => {
stage({
ownReport: [REPORT_ROW], // no campaign_id field => null/undefined, never grouped
ownMessage: [],
tier3: [],
insertCampaign: [{ id: 'brand-new-campaign' }],
});
const result = await groupReportIntoCampaign('report-own-c');
expect(result).toEqual({
campaignId: 'brand-new-campaign',
groupMethod: 'sender_subject_client',
created: true,
});
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
expect(callsContaining('UPDATE campaigns')).toHaveLength(0);
// The own-campaign guard must not fire when there is no own campaign_id.
expect(callsContaining('SELECT campaign_key')).toHaveLength(0);
});
it('Test D (signal genuinely diverged): stored own-campaign key no longer matches current signal, falls through to create-new', async () => {
stage({
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-own' }],
ownMessage: [],
tier3: [],
ownCampaign: [
{ campaign_key: 'sender_subject_client:5:old subject:10', group_method: 'sender_subject_client' },
],
insertCampaign: [{ id: 'diverged-new-campaign' }],
});
const result = await groupReportIntoCampaign('report-own-d');
expect(result).toEqual({
campaignId: 'diverged-new-campaign',
groupMethod: 'sender_subject_client',
created: true,
});
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
});
// ===========================================================================
// CR-02 (gap closure, 18-05 Task 2): decrement the origin campaign's
// report_count on cross-campaign migration, AND on the signal-diverged
// create-new fall-through added by Task 1 (CR-03) above — both are the
// same "abandoning an origin campaign" situation, just landing on an
// existing sibling's campaign vs. a brand-new one. Tests E and H below
// FAILED against the pre-Task-2 code (confirmed during RED).
// ===========================================================================
it('Test E (CR-02 migration decrements origin): cross-campaign migration decrements the origin and increments the destination exactly once each', async () => {
stage({
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-A' }],
ownMessage: [],
tier3: [{ campaign_id: 'campaign-B', title: 'Invoice Alert' }],
});
const result = await groupReportIntoCampaign('report-e');
expect(result).toEqual({
campaignId: 'campaign-B',
groupMethod: 'sender_subject_client',
created: false,
});
const updateCampaignCalls = callsContaining('UPDATE campaigns');
expect(updateCampaignCalls).toHaveLength(2);
const incrementCall = updateCampaignCalls.find((c) => c.sql.includes('report_count + 1'));
const decrementCall = updateCampaignCalls.find((c) => c.sql.includes('report_count - 1'));
expect(incrementCall?.params).toEqual(['campaign-B']);
expect(decrementCall?.params).toEqual(['campaign-A']);
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
expect(updateReportCalls).toHaveLength(1);
expect(updateReportCalls[0].params).toEqual(['campaign-B', 'report-e']);
});
it('Test F (D-08 upgrade preserved, no origin to decrement): a never-grouped report upgrading to a sibling campaign increments the destination only', async () => {
stage({
ownReport: [REPORT_ROW], // campaign_id null/undefined — never grouped before
ownMessage: [{ id: 'own-msg-id', message_id: 'shared-message-id-upgrade' }],
tier1: [{ campaign_id: 'campaign-B' }],
});
const result = await groupReportIntoCampaign('report-f');
expect(result).toEqual({
campaignId: 'campaign-B',
groupMethod: 'message_id',
created: false,
});
const updateCampaignCalls = callsContaining('UPDATE campaigns');
expect(updateCampaignCalls).toHaveLength(1);
expect(updateCampaignCalls[0].sql).toContain('report_count + 1');
expect(updateCampaignCalls[0].params).toEqual(['campaign-B']);
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
expect(updateReportCalls).toHaveLength(1);
expect(updateReportCalls[0].params).toEqual(['campaign-B', 'report-f']);
});
it('Test H (plan-checker-flagged divergence case): signal-diverged create-new also decrements the abandoned origin campaign exactly once', async () => {
stage({
ownReport: [{ ...REPORT_ROW, campaign_id: 'campaign-A' }],
ownMessage: [],
tier3: [],
ownCampaign: [
{ campaign_key: 'sender_subject_client:5:old subject:10', group_method: 'sender_subject_client' },
],
insertCampaign: [{ id: 'diverged-new-campaign-h' }],
});
const result = await groupReportIntoCampaign('report-h');
expect(result).toEqual({
campaignId: 'diverged-new-campaign-h',
groupMethod: 'sender_subject_client',
created: true,
});
expect(callsContaining('INSERT INTO campaigns')).toHaveLength(1);
const updateCampaignCalls = callsContaining('UPDATE campaigns');
expect(updateCampaignCalls).toHaveLength(1);
expect(updateCampaignCalls[0].sql).toContain('report_count - 1');
expect(updateCampaignCalls[0].params).toEqual(['campaign-A']);
const updateReportCalls = callsContaining('UPDATE reports SET campaign_id');
expect(updateReportCalls).toHaveLength(1);
expect(updateReportCalls[0].params).toEqual(['diverged-new-campaign-h', 'report-h']);
});
});

View file

@ -20,6 +20,7 @@
* Tier 3 (sender + normalized subject + client + 24h window).
*/
import type { PoolClient } from 'pg';
import { postgresClient } from './postgres-client';
// =============================================================================
@ -118,6 +119,24 @@ function computeTier3Key(
return `sender_subject_client:${requesterContactId}:${normalizedSubject}:${companyId}`;
}
/**
* CR-02: decrements a now-abandoned origin campaign's report_count. Shared
* by both places a report can be abandoned from its origin campaign the
* sibling-migration branch (moving to a DIFFERENT existing campaign) and the
* signal-diverged create-new fall-through (moving to a brand-new campaign)
* so neither is left with a stale, inflated count. Decrement only; never
* deletes the origin row even if its count reaches 0 (out of scope see
* CR-02 scope note at the call sites).
*/
async function decrementOriginCampaign(client: PoolClient, originCampaignId: string): Promise<void> {
await client.query(
`UPDATE campaigns
SET report_count = report_count - 1, updated_at = NOW()
WHERE id = $1`,
[originCampaignId]
);
}
/**
* Shared entry point called by the webhook path, the cron sweep, and the
* on-demand `/analyze` route. Looks up the report's own signal (message,
@ -318,6 +337,27 @@ export async function groupReportIntoCampaign(
}
}
// ---------------------------------------------------------------------
// Own-signal tier keys, computed once here and reused by both the
// own-campaign revalidation guard (CR-03, below) and the create-new
// fall-through further down — avoids computing them twice.
// ---------------------------------------------------------------------
const tier1Key = computeTier1Key(ownMessage?.message_id ?? null);
const tier2Key = computeTier2Key(
ownAttachmentHashes,
ownUrlDomains,
normalizedSubject,
ownSenderValue
);
const tier3Key = computeTier3Key(
ownReport.requester_contact_id,
normalizedSubject,
ownReport.company_id
);
const currentKeys = [tier1Key, tier2Key, tier3Key, `report:${reportId}`].filter(
(k): k is string => k !== null
);
// ---------------------------------------------------------------------
// Find-or-create against `campaigns` (CAMP-02).
// ---------------------------------------------------------------------
@ -342,26 +382,65 @@ export async function groupReportIntoCampaign(
`UPDATE reports SET campaign_id = $1, updated_at = NOW() WHERE id = $2`,
[matchCampaignId, reportId]
);
// CR-02: if the report had a DIFFERENT prior campaign (the
// same-campaign case returned above; a never-grouped report has no
// origin — D-08), decrement it so it doesn't keep a permanently
// stale report_count for a report it no longer holds. Scope:
// decrement only, never delete the origin row even if its count
// reaches 0 (deleting introduces FK-cascade/detail-route risk out of
// scope for this gap fix; an empty campaign is a strictly lesser
// problem than a stale count).
if (ownReport.campaign_id) {
await decrementOriginCampaign(client, ownReport.campaign_id);
}
return { campaignId: matchCampaignId, groupMethod: matchGroupMethod, created: false };
}
// ---------------------------------------------------------------------
// CR-03: no sibling matched on any tier (matchCampaignId is null).
// Every tier query above self-excludes the report's own row
// (`r.id != $x`), so a single-report campaign (no sibling report
// exists yet — the common case for any brand-new phishing lure before
// a second person reports it) always reaches here with matchCampaignId
// still null. Pre-fix, control fell straight into "create new
// campaign" below, abandoning the report's still-valid campaign and
// producing a SECOND campaigns row with the identical campaign_key on
// every repeat /analyze call. This guard sits OUTSIDE the sibling-
// match branch above precisely because it must only run when NO
// sibling matched — it never suppresses a legitimate D-08 sibling
// upgrade, which returns from the branch above instead.
// ---------------------------------------------------------------------
if (!matchCampaignId && ownReport.campaign_id) {
const ownCampaignRes = await client.query<{ campaign_key: string; group_method: string }>(
`SELECT campaign_key, group_method
FROM campaigns
WHERE id = $1`,
[ownReport.campaign_id]
);
const ownCampaign = ownCampaignRes.rows[0];
if (ownCampaign && currentKeys.includes(ownCampaign.campaign_key)) {
// The report's own campaign still satisfies its freshly-recomputed
// current tier key — it's still validly linked, reuse it as-is.
return {
campaignId: ownReport.campaign_id,
groupMethod: ownCampaign.group_method as GroupReportResult['groupMethod'],
created: false,
};
}
// CR-02: own campaign row is gone, or the report's signal has
// genuinely diverged from its stored key — this report is about to
// be abandoned from its origin campaign in favor of a brand-new one
// below (the same class of abandonment as the sibling-migration
// branch above, just landing on a new row instead of an existing
// sibling's). Decrement now so the origin doesn't show a stale
// count. Falls through to create-new unchanged.
await decrementOriginCampaign(client, ownReport.campaign_id);
}
// No match on any tier — create a new campaign. Prefer the strongest
// available key (Tier 1 > Tier 2 > Tier 3) so future duplicates of THIS
// report can match it; do NOT set/transition campaigns.status (leave
// migration default 'open' — Phase 19/20's concern).
const tier1Key = computeTier1Key(ownMessage?.message_id ?? null);
const tier2Key = computeTier2Key(
ownAttachmentHashes,
ownUrlDomains,
normalizedSubject,
ownSenderValue
);
const tier3Key = computeTier3Key(
ownReport.requester_contact_id,
normalizedSubject,
ownReport.company_id
);
let newCampaignKey: string;
let newGroupMethod: GroupReportResult['groupMethod'];
if (tier1Key) {