chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts

Bundles several in-progress efforts that were sitting uncommitted:
- User queue-preferences (migration 087, API route, popover component)
- QBO invoice soft-delete (migration 088) and AR diagnostics route
- Dashboard/mobile engagement route and page adjustments
- Docker Compose log-rotation config
- One-off ticket/RMM investigation scripts (scripts/)
- Planning docs: phase verification/pattern notes, mobile shell design spec
- .gitignore: exclude local scratch financial/inventory data and Claude Code
  worktree/local-settings runtime state (never meant for version control)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
This commit is contained in:
lorentz 2026-07-18 06:34:57 -04:00
parent b638189cb0
commit 672f17b7f9
35 changed files with 2801 additions and 92 deletions

View file

@ -36,4 +36,4 @@
"agent_skills": {},
"mode": "yolo",
"granularity": "standard"
}
}

View file

@ -0,0 +1,121 @@
---
phase: 16-eml-mime-evidence-parser
verified: 2026-07-15T14:44:12Z
status: passed
score: 8/8 must-haves verified
overrides_applied: 0
---
# Phase 16: EML/MIME Evidence Parser Verification Report
**Phase Goal:** Given a ticket's attachments, Pulse selects the correct original reported message and parses its RFC822/MIME structure into normalized, actionable evidence — without ever executing or fetching anything from the message.
**Verified:** 2026-07-15T14:44:12Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths (ROADMAP Success Criteria + PLAN must_haves, merged)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Given synthetic fixtures with both `rfc.eml` and `OriginatingEmail.eml` present, selection picks `rfc.eml` matching by `message/rfc822` content-type, not filename alone | ✓ VERIFIED | `lib/services/eml-parser.ts:50-65` `selectOriginalMessage` filters `isMessageRfc822` first, then matches exact `rfc.eml`; test `eml-parser.test.ts:28-37` asserts tier 1 + case-insensitive tier 1; also covers KnowBe4 tier 2 (`:39-43`) and OriginatingEmail-only tier 3 (`:45-49`) and ambiguous/null cases (`:51-69`). All pass live (`npx vitest run` — 49/49 across the 4 phase test files). |
| 2 | Parsing a synthetic `.eml` fixture produces normalized headers (From/displayName/senderEmail/senderDomain/Reply-To/Return-Path/To/Cc/Subject/Date/Message-ID/Received chain, SPF/DKIM/DMARC), URLs, and attachment metadata (name/content-type/size/hash) | ✓ VERIFIED | `parseEml` (`eml-parser.ts:227-282`) builds exactly this shape from `mailparser`'s `ParsedMail`; test `eml-parser.test.ts:137-164` asserts every field against `RICH_MULTIPART_EML`, including `authResults` structured verdicts (not raw text), sha256 checksum format, and `related` flag. Runs against real `simpleParser` (no mocking of the parser itself) — not a tautology. |
| 3 | The parser never executes or fetches any URL found in a message — verified by tests asserting no outbound network calls happen during parsing | ✓ VERIFIED | `eml-parser.test.ts:188-197` spies on `global.fetch` across 5 fixture parses and asserts zero calls; `extractUrls`/`buildBodyPreview` are pure string operations (`eml-parser.ts:140-184`) with no fetch/render path. Orchestration-level (service) invariant separately verified below (Truth 6). |
| 4 | Parsed output includes a sanitized/truncated body preview stored alongside raw evidence, distinct from the full raw body | ✓ VERIFIED | `buildBodyPreview` (`eml-parser.ts:176-184`) truncates to `MAX_BODY_PREVIEW_LENGTH=500` and strips HTML via `stripHtmlToText`; test `eml-parser.test.ts:128-133,178-181` confirms truncation and distinctness on a 2000-char fixture. Raw bytes are never persisted to Postgres — only to B2 (`raw_ref` is an object key, `phishing-eml-service.ts:97-106,142-157`), consistent with D-05. |
| 5 | `npx vitest run` for the new parser test file passes using synthetic fixtures only (no real customer email) | ✓ VERIFIED | Ran live: `eml-parser.test.ts` 26/26 pass. A dedicated test (`eml-parser.test.ts:205-218`) asserts no fixture contains `wulfconsulting.com` and all use RFC 2606 reserved `.test`/`.com`-fake (`evil-example.test`) domains. `deferred-items.md` documents the 2 pre-existing unrelated `itglue-search.test.ts` failures (confirmed via `git log` — last touched at commit `a0a6e7f`/`8f8b5ab`, predating all Phase 16 commits). |
| 6 | (16-02/16-03 supporting truth) AutotaskClient.getAttachmentContent reads `response.items?.[0]`, not `.item`; b2 EML_OBJECT_KEY_REGEX enforces path-traversal-safe `.eml` keys in parallel with the untouched LogLift regex; migration 099 adds `indicators.metadata` JSONB | ✓ VERIFIED | `autotask-client.ts:443-456` reads `response.items?.[0] ?? null`; test asserts an `{item:...}`-shaped response yields null (guards the exact regression risk called out in the plan). `b2/client.ts:41-42` adds `EML_OBJECT_KEY_REGEX` beside the unmodified `OBJECT_KEY_REGEX` (`:31-32`); `presignDownload`/`presignUpload`/`downloadToBuffer` (`:155-218`) take an optional `keyRegex` param defaulting to `OBJECT_KEY_REGEX`; `rmm/executor.ts:337`'s existing 2-arg call site still compiles (confirmed via `tsc --noEmit`, exit 0). Migration 099 applied live: `information_schema.columns` on the dev DB (`pulse-postgres`) reports `indicators.metadata` as `jsonb`. |
| 7 | Orchestration service (16-03) wires list→select→fetch→(B2 gated)→parse→persist end to end, writing one `messages` row + `indicators` rows with D-07 metadata, and no-op (no throw) when no `.eml` attachment or B2 unconfigured | ✓ VERIFIED | `phishing-eml-service.ts:49-224` imports and calls `getAutotaskClient().getAttachments`, `selectOriginalMessage`, `getAttachmentContent`, `isB2Configured`/`presignUpload`/`EML_OBJECT_KEY_REGEX`, `parseEml` — exact function signatures match Plan 01/02's exports (verified by reading both source files side by side). `messages` INSERT (`:142-157`) and 3 `indicators` INSERT loops (`:164-211`) match migration 097/099's actual column list (`report_id, message_id, headers, urls, attachments, body_preview, raw_ref` / `message_id, indicator_type, value, metadata`). Test suite (`phishing-eml-service.test.ts`, 6/6 passing) exercises the happy path (real `parseEml` invoked on `RICH_MULTIPART_EML`, only DB/Autotask/B2 boundaries mocked — not tautological), no-eml no-op, B2-configured/unconfigured branches, no-network invariant, and indicator metadata shape. |
| 8 | No outbound network call is made to any URL found in the message, end to end (service level) | ✓ VERIFIED | `phishing-eml-service.test.ts:155-170` asserts `global.fetch` is called exactly once (the B2 presigned PUT) and never with the fixture's embedded body URL (`http://evil-example.test/verify`). Source review confirms the only `fetch(` call in `phishing-eml-service.ts` targets `uploadUrl` (a B2-presigned URL Pulse itself constructed), never a value derived from `normalized.urls`. |
**Score:** 8/8 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `lib/services/eml-parser.ts` | parseEml, selectOriginalMessage, parseAuthResults, extractUrls, buildBodyPreview, NormalizedMessage (min 120 lines) | ✓ VERIFIED | 282 lines; all 5 functions + types exported exactly per plan interface contract |
| `lib/services/eml-parser.fixtures.ts` | synthetic fixtures for all 3 selection tiers + parse fixtures | ✓ VERIFIED | Exists, imported and exercised by both `eml-parser.test.ts` and `phishing-eml-service.test.ts`; RFC 2606-safe synthetic content only |
| `lib/services/eml-parser.test.ts` | EVID-02/03/04 vitest coverage incl. no-network spy + 3-tier selection (min 100 lines) | ✓ VERIFIED | 219 lines, 26 tests, all passing live |
| `lib/services/autotask-client.ts` | getAttachmentContent(entityName, entityId, attachmentId) | ✓ VERIFIED | Present at line 443, reads `items?.[0] ?? null` |
| `lib/services/autotask-client.test.ts` | first AutotaskClient unit coverage — items[0] behavior (min 30 lines) | ✓ VERIFIED | 73 lines, 3 tests, all passing |
| `lib/services/b2/client.ts` | EML_OBJECT_KEY_REGEX + parameterized key validation | ✓ VERIFIED | Present, OBJECT_KEY_REGEX untouched (git-diff-style visual check against current content confirms LogLift regex line unchanged), all 3 functions parameterized |
| `migrations/099_indicators_metadata.sql` | ALTER TABLE indicators ADD COLUMN metadata JSONB | ✓ VERIFIED | Present, applied live to dev DB (confirmed via information_schema query) |
| `lib/services/phishing-eml-service.ts` | parseAndStoreMessage orchestration (min 90 lines) | ✓ VERIFIED | 224 lines, full list→select→fetch→size-guard→B2→parse→persist flow |
| `lib/services/phishing-eml-service.test.ts` | orchestration coverage with mocked autotask/b2/postgres incl. no-network + B2-gated + no-eml no-op (min 70 lines) | ✓ VERIFIED | 195 lines, 6 tests, all passing |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `eml-parser.ts` | `mailparser` | `simpleParser` import w/ `checksumAlgo: 'sha256'` | ✓ WIRED | `eml-parser.ts:21,236` |
| `eml-parser.test.ts` | `global.fetch` | `vi.spyOn` zero-call assertion | ✓ WIRED | `eml-parser.test.ts:188-197` |
| `autotask-client.ts getAttachmentContent` | Autotask `Tickets/{id}/Attachments/{attachmentId}` | `makeApiCall` GET, `items?.[0]` | ✓ WIRED | `autotask-client.ts:443-456`; test confirms both shapes |
| `b2/client.ts presignUpload` | `EML_OBJECT_KEY_REGEX` | optional `keyRegex` param | ✓ WIRED | `b2/client.ts:165-173` |
| `phishing-eml-service.ts` | `eml-parser.ts` | `parseEml`+`selectOriginalMessage` import | ✓ WIRED | `phishing-eml-service.ts:27`; both invoked in the real flow (not stubbed in production code) |
| `phishing-eml-service.ts` | `AutotaskClient.getAttachmentContent` | `getAutotaskClient().getAttachmentContent(...)` | ✓ WIRED | `phishing-eml-service.ts:65-69` |
| `phishing-eml-service.ts` | `messages`/`indicators` tables | `postgresClient.query` INSERT ... RETURNING id::text | ✓ WIRED | `phishing-eml-service.ts:142-157,166-211`; column lists match migration 097+099 schema exactly |
| `phishing-eml-service.ts` | B2 (`presignUpload`+`EML_OBJECT_KEY_REGEX`) | `isB2Configured` gate then self-PUT | ✓ WIRED | `phishing-eml-service.ts:97-122`; gated, graceful-degrades on failure |
### Data-Flow Trace (Level 4)
Not applicable in the traditional UI-rendering sense — this phase is a pure backend service/library. Instead, traced data flow through the orchestration pipeline directly:
| Stage | Input | Output | Verified Real (not hardcoded) |
|-------|-------|--------|-------------------------------|
| `getAttachments``selectOriginalMessage` | live Autotask attachment list | selected `Attachment \| null` | ✓ Algorithm is a real filter/find chain over the input array, not a static return |
| `getAttachmentContent``Buffer.from(data,'base64')` | live Autotask base64 payload | decoded raw bytes | ✓ Real base64 decode, test proves items[0]-vs-item distinction |
| `parseEml``messages` INSERT | real `simpleParser` output | JSONB headers/urls/attachments payload | ✓ `headersPayload` built field-by-field from `normalized.*`, not a static object; test asserts `authResults` reaches the persisted param |
| `normalized.attachments/urls/from``indicators` INSERT loops | parsed message | per-indicator rows w/ metadata | ✓ Loops iterate real arrays from the parse result; test asserts attachment_hash metadata shape from an actual parsed fixture |
No hollow/static-return patterns found in the traced path.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Full phase-16 vitest suite | `npx vitest run lib/services/eml-parser.test.ts lib/services/autotask-client.test.ts lib/services/b2/client.test.ts lib/services/phishing-eml-service.test.ts` | 4 files, 49/49 tests passed | ✓ PASS |
| Repo-wide type check | `npx tsc --noEmit --pretty` | exit 0, no output | ✓ PASS |
| Full test suite (regression check) | `npm test` | 25/26 files pass, 282/284 tests pass; only pre-existing unrelated `itglue-search.test.ts` failures (confirmed via `git log` predating Phase 16 commits) | ✓ PASS (no phase-16 regressions) |
| Migration 099 applied to dev DB | `docker exec pulse-postgres psql ... information_schema.columns` | returns `jsonb` | ✓ PASS |
| `rmm/executor.ts` presignUpload call site unaffected | `grep presignUpload lib/services/rmm/executor.ts` + tsc clean | 2-arg call, `presignUpload(objectKey, 1800)`, compiles | ✓ PASS |
### Probe Execution
No `scripts/*/tests/probe-*.sh` probes declared or discovered for this phase; not a migration/tooling phase in that sense. SKIPPED (no probe files apply — verification instead relied on the project's own vitest suite, run directly by the verifier, not narrated by SUMMARY.md).
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| EVID-02 | 16-01 | Prefer `rfc.eml` over `OriginatingEmail.eml`, matching by content-type not filename alone | ✓ SATISFIED | `selectOriginalMessage` three-tier algorithm + full test coverage |
| EVID-03 | 16-01, 16-02, 16-03 | Parse RFC822/MIME into normalized headers/auth-verdicts/URLs/attachment metadata, persisted | ✓ SATISFIED | `parseEml` + `messages`/`indicators` persistence, both tested |
| EVID-04 | 16-01, 16-02, 16-03 | Sanitized/truncated body preview stored alongside raw evidence; never fetch/execute message content | ✓ SATISFIED | `buildBodyPreview` + B2 raw storage (D-05) + no-network spy at both parser and service level |
Note: `.planning/REQUIREMENTS.md` still shows EVID-02/03/04 as unchecked/"Pending" — this appears to be a tracking-doc staleness issue (the doc is not updated by the execute-phase workflow), not evidence of non-completion. All three requirements are satisfied by the code and tests as verified above.
### Anti-Patterns Found
None. Scanned all 10 phase-modified/created files for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER`, "not yet implemented", "coming soon" — zero matches.
### Deviations Reviewed (from SUMMARY.md, checked against CLAUDE.md conventions)
1. **16-01: hand-rolled HTML stripper instead of `html-to-text`.** Acceptable — avoids depending on an unpinned transitive dependency; consistent with "don't introduce dependencies not required" project posture; fully test-covered.
2. **16-01: test title renamed for `-t` filter discoverability.** Cosmetic, no behavior change.
3. **16-02: migration 099 applied via direct `docker exec` instead of `scripts/apply-migrations.sh`.** Consistent with CLAUDE.md's own documented caveat ("check first; behavior varies") — the script's hardcoded `MIGRATIONS_DIR` didn't see the worktree's file; the same real credentials (`pulse_user`/`pulse_autotask`) were used, not fallback defaults. Verified live on the dev DB.
4. **16-03: self-PUT to B2 (first instance of Pulse's own server code PUTting to B2, vs. handing a presigned URL to an external collector).** Explicitly anticipated and researched in 16-RESEARCH.md as a deliberate new pattern, not an ad hoc deviation; gated behind `isB2Configured()` with graceful degrade on failure.
5. **16-03: Task 1/Task 2 executed as separate implementation-then-test-suite commits rather than interleaved RED/GREEN within one task.** Matches how the plan's own task structure was written (Task 1's verify was tsc-only; Task 2's verify included vitest). No coverage gap — the full suite passes and covers real behavior, not a rubber-stamp.
None of these deviations reduce scope or introduce risk beyond what's already accepted in the phase's own threat model.
### Human Verification Required
None. This phase is a pure backend library + orchestration service with no UI, no new API route, and no external-service live-credential dependency (Autotask/B2/Postgres are all mocked in tests; the live on-demand trigger route is explicitly deferred to Phase 18). All success criteria are mechanically verifiable via source review + live test execution, both performed above.
### Gaps Summary
No gaps found. All 3 requirements (EVID-02, EVID-03, EVID-04) are implemented, wired end-to-end from the pure parser (16-01) through supporting infrastructure (16-02) to the orchestration service (16-03), and covered by tests that exercise real parsing logic (not mocked-to-pass tautologies) with mocks confined to true I/O boundaries (Postgres, Autotask HTTP, B2 HTTP). Live verification (not just SUMMARY.md narrative) confirms: 49/49 phase-specific tests pass, `tsc --noEmit` is clean, the dev-DB migration is actually applied, and the pre-existing unrelated `itglue-search.test.ts` failures are correctly out of scope (confirmed via git history predating this phase).
---
_Verified: 2026-07-15T14:44:12Z_
_Verifier: Claude (gsd-verifier)_

View file

@ -0,0 +1,94 @@
---
phase: 17-mimecast-blast-radius-lookup
verified: 2026-07-15T14:35:00Z
status: passed
score: 5/5 must-haves verified
overrides_applied: 0
---
# Phase 17: Mimecast Blast Radius Lookup Verification Report
**Phase Goal:** Pulse can ask "how far did this message spread" via a Mimecast blast-radius abstraction when Mimecast is configured, and gets a clean `unavailable` signal — never a crash or a block — when it isn't.
**Verified:** 2026-07-15T14:35:00Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | `isMimecastConfigured()` returns true only when both `MIMECAST_CLIENT_ID` and `MIMECAST_CLIENT_SECRET` are set, false otherwise | VERIFIED | `lib/services/mimecast-client.ts:647-649``!!(process.env.MIMECAST_CLIENT_ID && process.env.MIMECAST_CLIENT_SECRET)`. Tested in `mimecast-client.test.ts` (4 cases: neither/only-ID/only-secret/both), all pass. |
| 2 | `getBlastRadius()` returns `status:'unavailable' reason:'not_configured'` synchronously (no Mimecast call) when unconfigured | VERIFIED | `mimecast-blast-radius.ts:92-95` returns before any client construction. Test asserts `getMimecastClientMock`/all 3 fan-out mocks `.not.toHaveBeenCalled()`. |
| 3 | `getBlastRadius()` when configured returns normalized matched/delivered/held/rejected/clicked counts + per-recipient status array, built by fanning out to `searchDeliveredMessages` + `getHeldMessages` + `getThreatEvents` UNCONDITIONALLY (not gated behind a `getMessageInfo` miss — corrected D-01 / Pitfall 1) | VERIFIED | `mimecast-blast-radius.ts:104-128``getMessageInfo` (line 109-111) is called only for supplementary body/header evidence when `messageId` present, its result is discarded (not awaited into a variable used downstream), and is NOT inside any conditional that gates the `Promise.all([...])` fan-out at line 118, which always runs. Merge logic at 130-184 builds real counts, not fallback zeros. Test "merges delivered/held/threat-event fixtures..." confirms counts (`matched:2, delivered:1, held:1, rejected:1, clicked:1`) and `perRecipient` array. |
| 4 | An unexpected error thrown during the fan-out degrades to `status:'unavailable' reason:'lookup_failed'` rather than propagating | VERIFIED | `mimecast-blast-radius.ts:188-194` catch block. Test "degrades to unavailable/lookup_failed (never throws) when a fan-out call rejects" — `getHeldMessagesMock.mockRejectedValue(...)`, asserts resolved (not rejected) result equals `{status:'unavailable', reason:'lookup_failed', error:'Mimecast API timeout'}`. |
| 5 | A repeated lookup for the same message identity within the cache TTL returns the cached result without re-calling any MimecastClient method | VERIFIED | `mimecast-blast-radius.ts:101-102``getCachedData` checked and returned BEFORE `getMimecastClient()` is called at line 105. Test "returns the cached result on a cache hit..." asserts all 3 fan-out mocks and `setCachedDataMock` not called. |
**Score:** 5/5 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `lib/services/mimecast-client.ts` | `isMimecastConfigured()` + `_resetMimecastClient()` config gate / test seam, existing exports untouched | VERIFIED | Lines 647-649 (`isMimecastConfigured`), 651-655 (`_resetMimecastClient`) added directly above unmodified `getMimecastClient()` (657-672) and `getMimecastClientForTenant()` (674+). |
| `lib/services/mimecast-blast-radius.ts` | `getBlastRadius()` orchestration + `BlastRadiusInput`/`BlastRadiusResult` types | VERIFIED | 195 lines; exports `getBlastRadius`, `BlastRadiusInput`, `BlastRadiusResult` (discriminated union) exactly as specced. |
| `lib/services/mimecast-client.test.ts` | Unit tests for `isMimecastConfigured()` + `getMimecastClient()` throw/cache behavior | VERIFIED | 63 lines, 7 tests, all pass (`npx vitest run` confirmed). |
| `lib/services/mimecast-blast-radius.test.ts` | Unit tests for config gate, fan-out merge, never-throw, cache-hit | VERIFIED | 227 lines, 6 tests, all pass. |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|----|--------|---------|
| `mimecast-blast-radius.ts` | `mimecast-client.ts` | `import { isMimecastConfigured, getMimecastClient, type MimecastDeliveredMessage, type MimecastHeldMessage } from './mimecast-client'` | WIRED | Import present (line 37-42); both interface types confirmed exported from `mimecast-client.ts` (lines 52, 76, 92 — `MimecastThreatEvent`, `MimecastHeldMessage`, `MimecastDeliveredMessage`). |
| `mimecast-blast-radius.ts` | `redis-client.ts` | `import { getCachedData, setCachedData } from './redis-client'` | WIRED | Import present (line 43); both functions called and awaited correctly (cache-check before client construction, cache-write after successful merge only). |
### D-05 Multi-Tenant Comment Verification
`grep -qi "D-05" lib/services/mimecast-blast-radius.ts` → present. The module doc-comment (lines 25-34) explicitly states: "KNOWN LIMITATION — MULTI-TENANT GAP (D-05): this module uses only the single global env-var-configured getMimecastClient(), NOT the per-company `mimecast_tenants` table / getMimecastClientForTenant(). Reports belonging to companies with their own registered Mimecast tenant ... will return `status: 'unavailable'`..." This is a genuine code comment in the shipped file, not just a plan/summary claim.
### Pitfall-Avoidance Verification (17-RESEARCH.md's 3 documented pitfalls)
| Pitfall | Research Concern | Shipped-Code Verification |
|---------|------------------|---------------------------|
| #1`getMessageInfo()` has no status/counts; fan-out must be unconditional, not gated on a miss | Implementer might skip fan-out when `getMessageInfo` hits | Confirmed avoided: `getMessageInfo` call (line 110) is a bare `await` whose return value is discarded; the `Promise.all` fan-out (lines 118-128) is unconditional — no `if` branch separates "exact match" from "fallback." Code comment at line 107-108 explicitly documents why. |
| #2`getThreatEvents()` click derivation is best-effort, not confirmed-zero | Risk of overstating confidence in `clicked: 0` | Confirmed avoided: doc-comment (b) at lines 17-23 states `clicked: 0` means "no click-type threat event found... NOT confirmed zero clicks." `isClickEvent()` helper (line 88-90) comment references D-02 explicitly. |
| #3 — Multi-tenant gap silently missed | Risk of a silent single-tenant assumption | Confirmed avoided: D-05 comment present and specific (see above); T-17-03 in the plan's threat model documents the accepted risk; not touched by this phase per explicit scope. |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| BLAST-01 | 17-01-PLAN.md | Query blast-radius abstraction for delivery data when configured, keyed on message ID/sender/recipient/subject/date-window | SATISFIED | `getBlastRadius()` implemented and tested per Truth #3 above; ROADMAP.md and REQUIREMENTS.md both mark BLAST-01 `[x]`/`Complete`. |
| BLAST-02 | 17-01-PLAN.md | Not configured → `status: unavailable`, never blocks; unexpected error also degrades | SATISFIED | Truths #2 and #4 above; both paths tested with explicit call-count/rejection assertions. |
No orphaned requirements found for Phase 17 in REQUIREMENTS.md (only BLAST-01/BLAST-02 map to this phase).
### Anti-Patterns Found
None. Scanned all 4 phase-modified/created files (`mimecast-client.ts` diff region, `mimecast-blast-radius.ts`, `mimecast-blast-radius.test.ts`, `mimecast-client.test.ts`) for `TBD|FIXME|XXX|TODO|HACK|PLACEHOLDER|placeholder|not yet implemented` — zero matches.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| New unit tests pass in isolation | `npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts` | 2 files, 13 tests, all passed | PASS |
| Type-check clean | `npx tsc --noEmit --pretty` | No output / exit 0 | PASS |
| Full suite has no new regressions | `npm test` | 27 passed / 1 failed file (`itglue-search.test.ts`, 2 tests) — confirmed pre-existing via `git log` (`a0a6e7f`, predates this phase's commits `8b032c3`/`efbc437`) and unrelated to any file this phase touched | PASS (pre-existing failure correctly excluded per task instructions) |
### Test Isolation / Mocking Verification
Both test files declare `vi.mock('./mimecast-client', ...)` / `vi.mock('./redis-client', ...)` (blast-radius test) and import the real `mimecast-client.ts` module only for the config-gate/factory tests (which intentionally exercise the real singleton with env-var manipulation and `_resetMimecastClient()` — no network egress since `getMimecastClient()` only constructs the object, doesn't call out). No `MIMECAST_CLIENT_ID`/`MIMECAST_CLIENT_SECRET` real credentials are referenced; no live HTTP calls are made in either test file (confirmed by reading both files in full — no `fetch`/`request`/network imports present). Redis is fully mocked (`getCachedDataMock`/`setCachedDataMock`).
### Human Verification Required
None. This phase produces no UI, no HTTP route, and no externally-observable runtime behavior beyond the unit-testable function contract — all Success Criteria are objectively verifiable via code + tests.
### Gaps Summary
No gaps found. All 5 derived truths verified, both roadmap Success Criteria requirements (BLAST-01, BLAST-02) satisfied, all 3 documented research pitfalls confirmed avoided in the shipped code (not just claimed in SUMMARY.md), D-05 comment confirmed present in the actual file, cache-short-circuit-before-client-call confirmed via call-count assertions, and the full test suite has zero new regressions (the 2 failing `itglue-search.test.ts` tests are confirmed pre-existing and unrelated).
---
*Verified: 2026-07-15T14:35:00Z*
*Verifier: Claude (gsd-verifier)*

View file

@ -0,0 +1,105 @@
---
phase: 20-remediation-approval-audit-safety
verified: 2026-07-16T11:00:00Z
status: passed
score: 6/6 must-haves verified
overrides_applied: 0
---
# Phase 20: Remediation Approval & Audit Safety Verification Report
**Phase Goal:** Deliver a remediation/approval/audit-safety layer where recommended
remediation actions are proposed-only until an operator explicitly approves them,
remediation execution is idempotent and gated, false-positive marking is
conflict-guarded, and every state-changing action (classify/approve/remediate/
mark-false-positive) is audit-logged and permission-gated.
**Verified:** 2026-07-16T11:00:00Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | REMED-01: Recommended actions are `proposed`, never auto-executed | VERIFIED | `lib/services/campaign-classifier.ts` has no INSERT into `remediation_actions`; `remediation_actions.status` defaults to `'proposed'` in migration 097; only `approveRemediationActions` creates `'approved'` rows, only on explicit operator call. |
| 2 | REMED-02: Operator can approve via `POST /approve`, recording approver, timestamp, exact params | VERIFIED | `app/api/phishing/campaigns/[id]/approve/route.ts` gates `requirePermission('phishing','approve')`, captures actor from `session.user.email` (never request body), delegates to `approveRemediationActions` which inserts `approved_by=actor, approved_at=NOW(), params=$3::jsonb` per action, validated against latest `classifications.recommended_actions`. Unit tests confirm reject-if-not-recommended and reject-if-no-classification. |
| 3 | REMED-03: `POST /remediate` proceeds only for approved actions, non-destructive-by-default (simulated), never silently succeeds | VERIFIED | `remediateApprovedActions` throws `RemediationValidationError` when zero `remediation_actions` rows exist (route maps to 400). Only `status='approved'` rows transition; effect is simulated (`UPDATE ... status='completed'`, no external provider call per D-01, confirmed via grep — 0 matches for `not_implemented` outside comments and no HTTP/client calls to Mimecast/other providers in this file). |
| 4 | REMED-04: Re-running remediation is idempotent | VERIFIED | `remediateApprovedActions` selects `FOR UPDATE` and only acts on `status='approved'` rows; already-`completed` rows are skipped (no UPDATE, no audit write). Test `is idempotent: a second call transitions nothing and writes no second audit row` asserts 0 additional UPDATE calls and audit-write count stays at 2 across two calls. |
| 5 | REMED-05: Operator can mark false positive via API, blocked when remediation approved/completed exists | VERIFIED | `mark-false-positive/route.ts` gated `requirePermission('phishing','approve')`, delegates to `markCampaignFalsePositive`, which runs a `SELECT ... WHERE status IN ('approved','completed') FOR UPDATE` guard before any write; a hit throws `RemediationConflictError` -> route returns 409. Test confirms guard trips and confirms happy-path sets `campaigns.status='false_positive'`. |
| 6 | REMED-06: Every state-changing action (classify/approve/remediate/mark-false-positive) writes an audit event | VERIFIED | `writeAuditEvent` is the single INSERT path into `audit_events` (grep confirms no other file inserts into this table). All three remediation-service functions call it inside the same `postgresClient.transaction` as their state write (atomic — rollback discards both). `classify/route.ts` was edited to call `writeAuditEvent({..., eventType:'campaign_classified'})` after `classifyCampaign()` succeeds, closing the fourth action. |
**Score:** 6/6 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `lib/services/phishing-audit.ts` | `writeAuditEvent(input, client?)` single audit insert path | VERIFIED | 55 lines, parameterized INSERT (`$1`-`$4`), returns `RETURNING id::text`, accepts optional transaction client. |
| `lib/services/remediation-service.ts` | approve/remediate/mark-false-positive + typed errors | VERIFIED | 279 lines. Exports `approveRemediationActions`, `remediateApprovedActions`, `markCampaignFalsePositive`, `RemediationValidationError`, `RemediationConflictError` — all present and match plan signatures. |
| `lib/services/remediation-service.test.ts` | idempotency/audit/D-04/recommended-only proofs | VERIFIED | 259 lines, 8 real behavioral test cases (not tautological — fake transaction client records actual SQL calls and asserts on them). |
| `lib/services/phishing-audit.test.ts` | writer proof | VERIFIED | 3 tests: standalone insert, injected-client routing. |
| `lib/permissions.ts` | grants approve+remediate to admin/super-admin only | VERIFIED | `superAdminRole` and `adminRole` both `phishing: ["read","analyze","approve","remediate"]`; `userRole` remains `phishing: ["read"]`. `hasPermission()` enforces this at runtime (not just declared) — verified by reading `lib/auth-utils.ts` `requirePermission()` which calls `hasPermission(userRole, resource, action)` and returns 403 on failure. |
| `app/api/phishing/campaigns/[id]/approve/route.ts` | POST approve endpoint | VERIFIED | 77 lines. Permission gate, UUID guard, JSON body validation, actor from session, error mapping (400/409/500), delegates to service. |
| `app/api/phishing/campaigns/[id]/remediate/route.ts` | POST remediate endpoint | VERIFIED | 64 lines. Same shell, `phishing:remediate` gate, no body required. |
| `app/api/phishing/campaigns/[id]/mark-false-positive/route.ts` | POST mark-false-positive endpoint | VERIFIED | 76 lines. `phishing:approve` gate, optional-body tolerant JSON parse, 409 on conflict. |
| `app/api/phishing/campaigns/[id]/classify/route.ts` | audit event wiring | VERIFIED | Edited to destructure `session`, import `writeAuditEvent`, call it post-classify with `eventType:'campaign_classified'`. |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `remediation-service.ts` | `audit_events` | `writeAuditEvent(client)` inside `postgresClient.transaction` | WIRED | All three functions call `writeAuditEvent(..., client)` using the same transaction client as their state write — atomic. |
| `remediation-service.ts` | `classifications.recommended_actions` | latest-classification read validates approvable types | WIRED | `approveRemediationActions` selects `ORDER BY created_at DESC LIMIT 1` and rejects non-recommended action types before any insert. |
| `remediation-service.ts` | `remediation_actions.status` | `FOR UPDATE` status filter drives idempotent completion | WIRED | Confirmed via grep + test (`FOR UPDATE` present in both `remediateApprovedActions` and the D-04 guard in `markCampaignFalsePositive`). |
| `approve/route.ts` | `approveRemediationActions` | `requirePermission('phishing','approve')` -> service delegation | WIRED | Route imports and calls the function directly with `(id, actions, actor)`; not a stub — errors from the service propagate to typed HTTP status mapping. |
| `remediate/route.ts` | `remediateApprovedActions` | `requirePermission('phishing','remediate')` -> service delegation | WIRED | Same pattern confirmed. |
| `mark-false-positive/route.ts` | `markCampaignFalsePositive` | `requirePermission('phishing','approve')` -> service delegation | WIRED | Same pattern confirmed. |
| `classify/route.ts` | `writeAuditEvent` | post-classify audit write | WIRED | Call is inside the existing try block after `classifyCampaign(id)` succeeds. |
| routes | `middleware.ts` | auth gate | WIRED | `/api/phishing/*` is NOT in `middleware.ts`'s public-route allowlist (confirmed via grep — zero matches), so unauthenticated requests are redirected/blocked before reaching route-level `requirePermission`. |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Service unit tests (11 assertions across approve/remediate/mark-fp/audit-writer) | `npx vitest run lib/services/phishing-audit.test.ts lib/services/remediation-service.test.ts` | 2 files, 11 tests, all passed | PASS |
| Type check | `npx tsc --noEmit --pretty` | clean, no output | PASS |
| Full test suite regression | `npx vitest run` | 369/371 passing; 2 failures isolated to `lib/services/analyzer/itglue-search.test.ts` | PASS (pre-existing, out-of-scope failure confirmed — last touched by commit `8f8b5ab`, an unrelated earlier commit, not part of this phase's diff) |
| No auto-execution of remediation on classify | `grep -n "remediation_actions" lib/services/campaign-classifier.ts` | no matches | PASS |
| Single audit insert path | `grep -rn "INSERT INTO audit_events"` across `lib/` and `app/` | only in `phishing-audit.ts` | PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| REMED-01 | 20-01 | Proposed-only, never auto-executed | SATISFIED | See Truth 1 |
| REMED-02 | 20-01, 20-02 | Approve via API, records approver/timestamp/params | SATISFIED | See Truth 2 |
| REMED-03 | 20-01, 20-02 | Remediate only proceeds for approved, non-destructive, never silent success | SATISFIED | See Truth 3 |
| REMED-04 | 20-01 | Idempotent re-run | SATISFIED | See Truth 4 |
| REMED-05 | 20-01, 20-02 | Mark false positive via API | SATISFIED | See Truth 5 |
| REMED-06 | 20-01, 20-02 | Every state-changing action audited | SATISFIED | See Truth 6 |
Note: `.planning/REQUIREMENTS.md` still lists REMED-01..06 as "Pending" in its coverage table (lines 198-203) and unchecked (`[ ]`) in the requirement list — this is a tracking-document staleness issue, not a code gap. Recommend updating REQUIREMENTS.md status table as a follow-up, but it does not block phase goal achievement since the underlying code is verified.
### Anti-Patterns Found
None found in the phase's modified/created files. No `TODO`/`FIXME`/`HACK`/`PLACEHOLDER` markers, no empty handlers, no hardcoded empty returns feeding into rendering, no `not_implemented` code paths (only descriptive prose in comments, explicitly called out in 20-01-SUMMARY.md as a deliberate wording fix to avoid tripping the D-01 grep check).
### Human Verification Required
None. This phase is service + API layer only (no new UI in this phase — Phase 22 covers the approval UI). All behaviors are verifiable via code inspection, unit tests, and static grep checks.
### Gaps Summary
No gaps found. All six REMED requirements are backed by real, non-stub implementations:
- The service layer (`phishing-audit.ts`, `remediation-service.ts`) performs real parameterized SQL, real transactional atomicity between state writes and audit writes, and real validation logic (not pass-through stubs).
- The route layer enforces real permission checks (`requirePermission` calls `hasPermission` against actual role definitions, returning 403 on failure — not just declaring permissions without enforcing them).
- Idempotency and the D-04 conflict guard are proven by unit tests that assert on actual SQL call counts and audit-write counts, not just "does not throw."
- Actor identity is derived from the server session in all three new routes and in the classify route edit — never from request body, closing the spoofing/repudiation threat noted in the phase's own threat model.
---
_Verified: 2026-07-16T11:00:00Z_
_Verifier: Claude (gsd-verifier)_

View file

@ -0,0 +1,364 @@
# Phase 21: Autotask Triage Note - Pattern Map
**Mapped:** 2026-07-16
**Files analyzed:** 2 new (route + service), 1 optional (service test)
**Analogs found:** 2 / 2 (both exact/near-exact structural matches)
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `app/api/phishing/campaigns/[id]/triage-note/route.ts` (new) | controller (route) | request-response | `app/api/phishing/campaigns/[id]/classify/route.ts` | exact |
| `lib/services/triage-note-service.ts` (new — planner may name differently) | service | CRUD (read-many) + file/external-write (Autotask `TicketNotes` POST per linked ticket) | `lib/services/campaign-classifier.ts` (evidence gathering half) + `lib/services/workflow-engine.ts` `runAiTroubleshooting` (Autotask write half) | role-match (composite — no single existing file does both halves) |
| `lib/services/triage-note-service.test.ts` (optional, if planner follows sibling-test convention) | test | n/a | `lib/services/remediation-service.test.ts` / `lib/services/campaign-classifier.test.ts` | role-match |
## Pattern Assignments
### `app/api/phishing/campaigns/[id]/triage-note/route.ts` (controller, request-response)
**Analog:** `app/api/phishing/campaigns/[id]/classify/route.ts` (full file read — 67 lines)
This is a near-identical structural twin. Copy the whole shape: UUID guard,
`requirePermission`, campaign-exists pre-check, service delegation, try/catch
with typed error branches, `console.error` with a `[PHISHING-*]` tag.
**Imports pattern** (lines 12-16):
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
import { classifyCampaign } from '@/lib/services/campaign-classifier';
import { writeAuditEvent } from '@/lib/services/phishing-audit';
```
For the new route, swap the service import for the new triage-note service
export (e.g. `generateAndPostTriageNote`) — `writeAuditEvent` is optional here
(no explicit audit event is required by CONTEXT.md D-01..D-06 for this phase;
if the planner wants one, `remediation-service.ts`'s in-transaction audit
pattern is the reference — see Shared Patterns below).
**UUID guard + permission gate** (lines 18, 20-32):
```typescript
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { session, error } = await requirePermission('phishing', 'analyze');
if (error) return error;
const { id } = await params;
// V5: validate UUID shape before querying — a malformed id would otherwise
// surface as an unhandled Postgres error -> uncaught 500.
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 });
}
```
Per CONTEXT.md's deferred discretion note, `'analyze'` (not `'approve'`) is
the recommended permission tier — matches `classify`'s tier since this is
informational, not a destructive state change.
**Campaign-exists pre-check + service delegation + response** (lines 34-43, 58):
```typescript
try {
const campaignRes = await postgresClient.query<{ id: string }>(
`SELECT id FROM campaigns WHERE id = $1`,
[id]
);
if (!campaignRes.rows[0]) {
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 });
}
const result = await classifyCampaign(id); // -> generateAndPostTriageNote(id)
return NextResponse.json(result);
```
D-06's response shape (note text + per-ticket `posted`/error status list)
should be returned directly as the service's return value — no reshaping
needed in the route, matching how `classify`/`approve`/`remediate` all just
`NextResponse.json(result)` the service's return type verbatim.
**Error handling pattern** (lines 59-65 — the ONLY error branch this route
needs, since D-05 says individual write failures are captured *inside* the
service's return value, not thrown):
```typescript
} catch (err) {
console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err);
return NextResponse.json(
{ error: 'Failed to classify campaign', message: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 }
);
}
}
```
Rename the log tag (e.g. `[PHISHING-TRIAGE-NOTE]`) and message. This catch
block should only ever fire for a whole-request failure (e.g. campaign
evidence-gathering itself throws) — NOT for a single ticket's Autotask write
failing, which D-05/D-06 require to be caught per-ticket inside the service
and reported in the 200 response body instead.
**Optional: typed-error branches** if the service throws domain errors (see
`approve/route.ts` lines 63-69 for the pattern, not strictly needed here since
this phase has no validation-conflict states like approve/remediate do):
```typescript
if (err instanceof RemediationValidationError) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
```
---
### `lib/services/triage-note-service.ts` (service, CRUD-read + external-write)
No single existing file does both halves this service needs, so it composes
two analogs: **evidence gathering** (read side, copy shape from
`campaign-classifier.ts`'s `gatherCampaignEvidence`) and **Autotask note
write** (write side, copy verbatim from `workflow-engine.ts`'s
`runAiTroubleshooting`).
**Imports pattern** — composite of `campaign-classifier.ts` (lines 17-19) and
the Autotask factory used across `workflow-engine.ts`:
```typescript
import { postgresClient } from './postgres-client';
import { getBlastRadius, type BlastRadiusResult } from './mimecast-blast-radius';
import { getAutotaskClient } from './autotask-factory';
import type { TicketNote } from '@/lib/types/autotask';
```
**Read-side pattern — bulk-fetch linked reports, then classification +
remediation state** (`campaign-classifier.ts` lines 270-296, adapted; also see
`app/api/phishing/campaigns/[id]/route.ts` lines 84-96 for the same
`reports WHERE campaign_id = $1 ORDER BY created_at ASC` bulk-fetch shape used
a third time in this codebase):
```typescript
const reportsRes = await postgresClient.query<ReportDbRow>(
`SELECT r.id::text AS id, r.ticket_id::text AS ticket_id, r.ticket_number,
r.title, r.created_at::text AS created_at,
c.email_address AS requester_email
FROM reports r
LEFT JOIN contacts c ON c.id = r.requester_contact_id
WHERE r.campaign_id = $1
ORDER BY r.created_at ASC`,
[campaignId]
);
```
**Most-recent classification** (`remediation-service.ts` lines 89-96 — same
`ORDER BY created_at DESC LIMIT 1` idiom used for "current" state per D-04):
```typescript
const classificationRes = await client.query<ClassificationRow>(
`SELECT recommended_actions
FROM classifications
WHERE campaign_id = $1
ORDER BY created_at DESC
LIMIT 1`,
[campaignId]
);
```
For the triage note, select the full row (`verdict, confidence, summary,
reasons, recommended_actions, requires_approval, created_at`), not just
`recommended_actions` — D-04 requires verdict/confidence/summary/reasons in
the note.
**Current remediation_actions state** (D-04 — "proposed only if no operator
has acted, else approved/completed rows") — same table/columns
`remediation-service.ts` already reads/writes (lines 133-148, 175-179):
```typescript
const remediationRes = await postgresClient.query<RemediationActionRow>(
`SELECT id::text, action_type, status, approved_by, approved_at::text
FROM remediation_actions
WHERE campaign_id = $1
ORDER BY created_at ASC`,
[campaignId]
);
```
**Blast radius — reuse the exact `getBlastRadius()` call shape** from
`campaign-classifier.ts` lines 332-351 (D-04/Claude's-Discretion: planner may
call fresh or reuse Phase 19's persisted `reasons` — either way this is the
call signature to copy if calling fresh):
```typescript
const blastRadius = await getBlastRadius({
sender: senderIndicator?.value ?? primaryMessage?.from.email ?? '',
recipient: primaryReport.requesterEmail ?? '',
subject: primaryMessage?.subject ?? primaryReport.title ?? '',
dateWindow: {
start: new Date(createdAt.getTime() - 24 * 60 * 60 * 1000),
end: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000),
},
});
// BlastRadiusResult is a discriminated union — status: 'ok' | 'unavailable'.
// D-04 requires an explicit "unavailable" string in the note when this
// branch is hit, never a silent omission.
```
**Write-side pattern — one `createEntity('TicketNotes', ...)` call per linked
ticket, copied verbatim from `workflow-engine.ts` lines 581-589**:
```typescript
const client = getAutotaskClient();
await client.createEntity('TicketNotes', {
ticketID: ticket.id, // -> report.ticketId for each linked report (D-01)
title: 'Troubleshooting Steps (Auto-Generated)', // -> e.g. 'Phishing Triage Summary'
description: steps, // -> the generated sanitized note text
noteType: 1, // Internal
publish: 1,
});
```
`TicketNote` interface for reference (`lib/types/autotask.ts` lines 185-196):
```typescript
export interface TicketNote {
id: number;
ticketID: number;
title?: string;
description?: string;
noteType?: number;
publish?: number;
creatorResourceID?: number;
creatorType?: number;
lastActivityDate?: string;
createDateTime?: string;
}
```
`createEntity<T>` generic signature (`lib/services/autotask-client.ts` lines
175-189) — throws `Error('Failed to create entity')` if Autotask's response
has no `item`, and lets network/HTTP errors from `makeApiCall` propagate
uncaught. **This is exactly the failure mode D-05 requires the service to
catch per-ticket** — wrap each `createEntity` call in its own try/catch inside
a loop over linked tickets, not one try/catch around the whole loop:
```typescript
const ticketResults: Array<{ ticketId: string; posted: boolean; error?: string }> = [];
for (const report of reports) {
try {
await client.createEntity('TicketNotes', {
ticketID: Number(report.ticketId),
title: 'Phishing Triage Summary',
description: noteText,
noteType: 1,
publish: 1,
});
ticketResults.push({ ticketId: report.ticketId, posted: true });
} catch (err) {
console.error('[TRIAGE-NOTE] Failed to post note to ticket', report.ticketId, err);
ticketResults.push({
ticketId: report.ticketId,
posted: false,
error: err instanceof Error ? err.message : 'Unknown error',
});
}
}
```
**Sanitization precedent** — `lib/services/analyzer/itglue-redact.ts` (full
file, 65 lines) is the spirit-reference named in CONTEXT.md, though it
redacts by KEY NAME across an arbitrary object tree (IT Glue documents), which
doesn't map directly onto this phase's need (truncating URL query strings
inside plain prose text). Two concrete things to actually copy:
1. The **module-level "why" comment convention** — state plainly what must
never leak and why, mirroring lines 1-16 of `itglue-redact.ts`.
2. The **exported-for-tests + pure-function** shape — a small
`sanitizeIndicatorValue(value: string, type: string): string` (or similar)
function, unit-testable in isolation, same as `redact()`/`isSensitiveKey()`
are exported standalone in `itglue-redact.ts` lines 26-28 and 62-64. For
URL truncation specifically there is no existing analog in this codebase —
this is genuinely new logic (strip query string via `new URL(value).origin
+ new URL(value).pathname`, wrapped in try/catch for malformed URLs).
**No-op / synthesized-value pattern for missing evidence** — copy
`mimecast-blast-radius.ts`'s discriminated union (`status: 'ok' | 'unavailable'`,
never a thrown error for a missing/misconfigured integration) as the model for
how the note text should render "blast radius data unavailable" rather than
omitting the section — same spirit as `campaign-classifier.ts` line 350's
`{ status: 'unavailable', reason: 'not_configured' }` synthesis when there are
no linked reports at all.
---
### `lib/services/triage-note-service.test.ts` (test, optional)
**Analog:** `lib/services/remediation-service.test.ts` and
`lib/services/campaign-classifier.test.ts` (not read in full — file names
only, per early-stopping guidance; both are existing Vitest suites under
`lib/services/` that test service functions directly against a real/fixture
Postgres, following the project's stated test coverage: `lib/services/**` is
covered). Structure to follow: mock or seed `campaigns`/`reports`/
`classifications`/`remediation_actions` rows, mock `getAutotaskClient()` (or
the whole `autotask-factory` module) to assert `createEntity` was called once
per linked ticket with the expected `ticketID`/`description`, and assert the
per-ticket failure path (D-05/D-06) when a mocked `createEntity` rejects for
one of several tickets.
## Shared Patterns
### Auth/Permission gate
**Source:** `lib/auth-utils.ts` lines 51-71 (`requirePermission`), used
identically by `classify/route.ts` line 24, `approve/route.ts` line 28,
`remediate/route.ts` line 27, `route.ts` (GET) line 62.
**Apply to:** the new triage-note route.
```typescript
const { session, error } = await requirePermission('phishing', 'analyze');
if (error) return error;
```
`lib/permissions.ts` line 33/50/64/78 confirms `'analyze'` is already granted
to admin/super-admin/user roles (only the read-only-ish role at line 78 lacks
it) — no new permission statement needed.
### UUID param validation
**Source:** identical `UUID_RE` regex + early-400 pattern in all four existing
`campaigns/[id]/*` routes (`classify`, `approve`, `remediate`, base `route.ts`).
**Apply to:** the new triage-note route — copy the exact regex, don't
re-derive it.
### Campaign-exists pre-check before service delegation
**Source:** `classify/route.ts` lines 34-41, `approve/route.ts` lines 52-59,
`remediate/route.ts` lines 40-46 — all three query `SELECT id FROM campaigns
WHERE id = $1` and return 404 before calling their service function.
**Apply to:** the new triage-note route, same shape.
### Error response shape
**Source:** every phishing route's catch block:
`NextResponse.json({ error: '...', message: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 })`
with a `console.error('[PHISHING-<ACTION>] ...')` line immediately before.
**Apply to:** the new route's outer catch (whole-request failures only — see
D-05 note above about per-ticket failures NOT using this branch).
### Safe Autotask ticket-note write
**Source:** `lib/services/workflow-engine.ts` lines 581-589
(`runAiTroubleshooting`), backed by `lib/services/autotask-client.ts`
`createEntity<T>` (lines 175-189) and `lib/services/autotask-factory.ts`
`getAutotaskClient()`.
**Apply to:** the new service's write loop — `noteType: 1` (Internal),
`publish: 1` (All Autotask Users, still non-portal per
`AUTOTASK_API_GUIDE.md` line 365) are the existing codebase's only precedent
values; reuse them unless the planner has a specific reason to pick
`publish: 2` (Internal Users Only — even more restrictive, also non-portal).
### Audit trail (optional — not required by CONTEXT.md for this phase)
**Source:** `lib/services/phishing-audit.ts` (full file, 55 lines) —
`writeAuditEvent({ campaignId, actor, eventType, payload }, client?)`. Used by
every state-*changing* action (classify/approve/remediate/false-positive).
This phase is read+external-write, not a Postgres state change, so an audit
row is NOT strictly required by any D-0x decision — CONTEXT.md's Deferred
Ideas section explicitly puts "persisting sent-note history" out of scope.
If the planner still wants a lightweight audit trail of *when* a triage note
was requested (not full content), this is the write shape to reuse; `client`
param is optional so it can be called standalone (no transaction needed since
there's no corresponding state row to keep atomic with).
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| URL/text sanitization helper (e.g. `lib/services/triage-note-sanitize.ts`, if split out) | utility | transform | No existing codebase function truncates URL query strings or formats human-readable prose from structured evidence — `itglue-redact.ts` redacts by object key name (a different technique for a different data shape); this is genuinely new logic per CONTEXT.md's "Claude's Discretion" section. |
| Note-text template/formatter | utility | transform | No existing "build human-readable prose from campaign+classification+remediation rows" function exists anywhere in the codebase — closest precedent is `campaign-classifier.ts`'s one-line `summary` string (line 487), which is far shorter than what D-04 requires here. |
## Metadata
**Analog search scope:** `app/api/phishing/**`, `lib/services/campaign-classifier.ts`,
`lib/services/remediation-service.ts`, `lib/services/campaign-grouping-service.ts`,
`lib/services/phishing-audit.ts`, `lib/services/workflow-engine.ts`,
`lib/services/autotask-client.ts`, `lib/services/autotask-factory.ts`,
`lib/services/mimecast-blast-radius.ts`, `lib/services/analyzer/itglue-search.ts`,
`lib/services/analyzer/itglue-redact.ts`, `lib/permissions.ts`, `lib/auth-utils.ts`,
`lib/types/autotask.ts`, `migrations/097_phishing_triage_schema.sql`.
**Files scanned:** 15
**Pattern extraction date:** 2026-07-16