diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index b18b3d4..4681d6c 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -468,7 +468,9 @@ of a functional dependency on Phase 21.
1. If Pulse has a safe existing Autotask note-writing method, triggering note generation for a classified campaign posts an internal triage note summarizing classification, evidence, blast radius, and recommended actions to the originating ticket
2. The posted (or returned) note text is sanitized — no raw secrets/tokens/full malicious URL query strings appear in it
3. If no safe note-writing path exists, the same note content is returned via the API response instead of attempting any Autotask write, and no partial/unsanitized write is ever attempted as a fallback
-**Plans**: TBD
+**Plans**: 2 plans
+- [ ] 21-01-PLAN.md — Pure text layer: triage-note-sanitize (URL query/secret stripping) + triage-note-format (TriageNoteEvidence + formatTriageNote) with Vitest coverage (NOTE-01)
+- [ ] 21-02-PLAN.md — triage-note-service (evidence gather + per-ticket TicketNotes post loop + partial-failure result) + POST /api/phishing/campaigns/[id]/triage-note route (NOTE-01)
**UI hint**: no
## Progress
@@ -500,7 +502,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P
| 18. Campaign Grouping & Phishing Analysis API | v3.0 | 5/5 | Complete | 2026-07-16 |
| 19. Classification Engine | v3.0 | 2/2 | Complete | 2026-07-16 |
| 20. Remediation, Approval & Audit Safety | v3.0 | 2/2 | Complete | 2026-07-16 |
-| 21. Autotask Triage Note | v3.0 | 0/TBD | Not started | - |
+| 21. Autotask Triage Note | v3.0 | 0/2 | Planned | - |
| 22. Approval UI (LiveLink) | v3.0 | 0/TBD | Not started | - |
### Phase 22: Approval UI (LiveLink)
diff --git a/.planning/phases/21-autotask-triage-note/21-01-PLAN.md b/.planning/phases/21-autotask-triage-note/21-01-PLAN.md
new file mode 100644
index 0000000..9e20aaf
--- /dev/null
+++ b/.planning/phases/21-autotask-triage-note/21-01-PLAN.md
@@ -0,0 +1,186 @@
+---
+phase: 21-autotask-triage-note
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - lib/services/triage-note-sanitize.ts
+ - lib/services/triage-note-sanitize.test.ts
+ - lib/services/triage-note-format.ts
+ - lib/services/triage-note-format.test.ts
+autonomous: true
+requirements: [NOTE-01]
+
+must_haves:
+ truths:
+ - "A pure sanitizer strips query strings and fragments from every URL and redacts token/secret markers before any text can reach a note"
+ - "A pure formatter turns a structured campaign-evidence object into human-readable prose containing verdict, confidence, summary, reasons, blast-radius state, and current remediation state"
+ - "The formatted note never contains a raw URL query string, Bearer token, or credential query-param value even when the source evidence does"
+ artifacts:
+ - path: "lib/services/triage-note-sanitize.ts"
+ provides: "sanitizeUrl + sanitizeNoteText pure functions, exported for tests"
+ contains: "export function sanitizeUrl"
+ - path: "lib/services/triage-note-format.ts"
+ provides: "TriageNoteEvidence interface + formatTriageNote(evidence) => string"
+ contains: "export interface TriageNoteEvidence"
+ - path: "lib/services/triage-note-sanitize.test.ts"
+ provides: "Vitest coverage of URL-query stripping + secret redaction"
+ - path: "lib/services/triage-note-format.test.ts"
+ provides: "Vitest coverage of note sections + no-raw-secret invariant"
+ key_links:
+ - from: "lib/services/triage-note-format.ts"
+ to: "lib/services/triage-note-sanitize.ts"
+ via: "import sanitizeUrl/sanitizeNoteText"
+ pattern: "from './triage-note-sanitize'"
+ - from: "lib/services/triage-note-format.ts"
+ to: "lib/services/mimecast-blast-radius.ts"
+ via: "import type BlastRadiusResult"
+ pattern: "BlastRadiusResult"
+---
+
+
+Build the pure, deterministic text layer for the Autotask triage note: a sanitizer that removes malicious/secret content from any string destined for a note, and a formatter that renders a structured campaign-evidence object into human-readable prose. Both are pure functions with no DB, network, or Autotask dependency — fully unit-testable in isolation.
+
+Purpose: NOTE-01 requires the note text be sanitized (no raw secrets/tokens/full malicious URL query strings) and human-readable. Isolating this logic as pure functions lets the security-critical sanitization be tested exhaustively and lets Plan 02's service simply gather evidence and post the produced text. Defining `TriageNoteEvidence` here also gives Plan 02 a concrete contract to build against (interface-first).
+
+Output: `lib/services/triage-note-sanitize.ts`, `lib/services/triage-note-format.ts`, and their sibling test files.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/21-autotask-triage-note/21-CONTEXT.md
+@.planning/phases/21-autotask-triage-note/21-PATTERNS.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Task 1: Sanitizer (URL query stripping + secret redaction)
+ lib/services/triage-note-sanitize.ts, lib/services/triage-note-sanitize.test.ts
+
+ - lib/services/analyzer/itglue-redact.ts (module comment convention + exported-pure-function-for-tests shape to mirror; do NOT reuse its by-key redaction technique)
+ - lib/services/remediation-service.test.ts (Vitest file structure/idioms used across lib/services)
+ - .planning/phases/21-autotask-triage-note/21-CONTEXT.md (Claude's Discretion block: URL truncated to scheme+host+path, query stripped; sender emails + attachment hashes are evidence, NOT secrets — must be preserved)
+
+
+ - sanitizeUrl('https://evil.example/login?token=abc123&next=/x#frag') returns 'https://evil.example/login' (scheme+host+path only; query AND fragment removed)
+ - sanitizeUrl on a malformed/non-URL string (e.g. 'not a url', '') does not throw and returns a safe placeholder or the input with any '?...'/'#...' tail removed
+ - sanitizeUrl preserves the path but never the query even for benign params (query is always stripped, not selectively)
+ - sanitizeNoteText redacts an Authorization/Bearer token: input containing 'Bearer eyJabc.def.ghi' yields '[REDACTED]' in place of the token
+ - sanitizeNoteText redacts credential-style query-param values inside free text: 'see http://x/y?access_token=SECRET&password=p' has no substring 'access_token=SECRET' and no substring 'password=p' in the output
+ - sanitizeNoteText PRESERVES a bare sender email (e.g. 'attacker@evil.example') and a 64-char hex attachment hash unchanged (these are evidence per D — not treated as secrets)
+ - Both functions are pure (no I/O), deterministic, and exported for direct import in tests
+
+
+ Create `lib/services/triage-note-sanitize.ts` with a module-level SECURITY-CRITICAL comment (mirroring the "why it exists" convention at the top of `lib/services/analyzer/itglue-redact.ts`) stating plainly that this module exists so no raw secret, token, or full malicious URL query string ever reaches an Autotask note posted by Pulse.
+ Export `sanitizeUrl(value: string): string` — parse with the WHATWG `URL` constructor inside a try/catch; on success return `url.origin + url.pathname` (drops search + hash); on parse failure, fall back to truncating at the first `?` or `#` and return the head, never throwing. Do not selectively keep any query params — strip the query wholesale (this satisfies NOTE-01's "full malicious URL query strings").
+ Export `sanitizeNoteText(text: string): string` — apply, in order: (1) replace any `Bearer ` / `Authorization: ` sequence with `[REDACTED]`; (2) replace credential query-param assignments matching keys `token|access_token|refresh_token|secret|password|api[_-]?key|key|credential` (case-insensitive) `=` with `=[REDACTED]`; (3) find any full URL substring and replace it with its `sanitizeUrl()` form so query strings embedded in prose are also stripped. Use `REDACTED_MARKER = '[REDACTED]'` constant. Do NOT redact bare email addresses or hex hashes — reason in a comment that those are evidence, not credentials, per CONTEXT.md Claude's Discretion.
+ Write `lib/services/triage-note-sanitize.test.ts` covering every bullet in , following the Vitest `describe/it/expect` structure used in `lib/services/remediation-service.test.ts` (no mocks needed — pure functions).
+
+
+ npx vitest run lib/services/triage-note-sanitize.test.ts
+
+
+ - `npx vitest run lib/services/triage-note-sanitize.test.ts` passes with tests for: URL query+fragment stripping, malformed-URL no-throw, Bearer redaction, credential query-param redaction, and email/hash preservation
+ - Source assertion: `grep -q "export function sanitizeUrl" lib/services/triage-note-sanitize.ts` and `grep -q "export function sanitizeNoteText" lib/services/triage-note-sanitize.ts`
+ - Behavior assertion: a test feeds 'https://evil.example/a?token=abc#f' to sanitizeUrl and asserts the result === 'https://evil.example/a'
+ - Behavior assertion: a test asserts sanitizeNoteText output for input with 'access_token=SECRET' contains no substring 'access_token=SECRET'
+ - `npx tsc --noEmit --pretty` reports no new errors in these files
+
+ Sanitizer functions exist, are pure and exported, and all sanitize tests pass; type-check clean.
+
+
+
+ Task 2: Note formatter + TriageNoteEvidence contract
+ lib/services/triage-note-format.ts, lib/services/triage-note-format.test.ts
+
+ - lib/services/triage-note-sanitize.ts (the functions this formatter must route all URL/free-text through — created in Task 1)
+ - lib/services/mimecast-blast-radius.ts (BlastRadiusResult discriminated union both branches — import as type only)
+ - lib/services/campaign-classifier.ts (Verdict type, confidence/reasons/recommended_actions shape produced by Phase 19 that this note summarizes)
+ - .planning/phases/21-autotask-triage-note/21-CONTEXT.md (D-04 full-picture content requirement; note text format is planner's/Claude's discretion but must read as human-readable prose, not a JSON dump)
+
+
+ - formatTriageNote returns a string containing the verdict label (e.g. 'THREAT') and the confidence value
+ - Output contains a Summary line and a Reasons section listing each reason string
+ - Output contains a Blast Radius section: when blastRadius.status==='ok' it shows delivered/held/rejected/clicked counts; when status==='unavailable' it contains an explicit 'unavailable' string naming the reason (never silently omits the section) — per D-04
+ - Output contains a Remediation section: when remediationActions is empty it states no action has been taken / actions are proposed-only; when rows exist it lists each action_type + status (+ approver when approved) reflecting CURRENT state per D-04
+ - Output lists any indicator URLs in sanitized form: given an evidence URL 'http://evil.example/p?token=leak', the output contains no substring 'token=leak'
+ - The whole returned string is passed through sanitizeNoteText so no secret/token survives even from free-text fields
+ - Exports `TriageNoteEvidence` interface consumed by Plan 02
+
+
+ Create `lib/services/triage-note-format.ts`. Export an interface `TriageNoteEvidence` with these fields (camelCase, project convention): `campaignId: string`, `reportCount: number`, `companyName?: string | null`, `subject?: string | null`, `verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | null`, `confidence: number | null`, `summary: string | null`, `reasons: string[]`, `recommendedActions: string[]`, `requiresApproval: boolean`, `blastRadius: BlastRadiusResult` (import the type from `./mimecast-blast-radius`), `remediationActions: Array<{ actionType: string; status: string; approvedBy: string | null; approvedAt: string | null }>`, `urls: string[]`.
+ Export `formatTriageNote(evidence: TriageNoteEvidence): string` that assembles clearly-labeled prose sections in this order — a header line ('Phishing Triage Summary' + campaign/report count), Classification (verdict + confidence + summary), Reasons (bulleted list of `evidence.reasons`), Blast Radius (render both BlastRadiusResult branches: for 'ok' show matched/delivered/held/rejected/clicked; for 'unavailable' write an explicit line like 'Blast radius: unavailable (reason)'), Recommended Actions (list `recommendedActions`; note `requiresApproval` when true), and Current Remediation State (if `remediationActions` empty, state actions are proposed-only / none taken; else list actionType — status, plus approver/approvedAt when present). Route every URL through `sanitizeUrl` and pass the fully assembled string through `sanitizeNoteText` before returning, so query strings/secrets are stripped regardless of source field. Handle null verdict/confidence gracefully (render 'not yet classified') rather than throwing.
+ Write `lib/services/triage-note-format.test.ts` covering every bullet, constructing `TriageNoteEvidence` fixtures for: an 'ok' blast-radius THREAT campaign, an 'unavailable' blast-radius campaign, an empty-remediation campaign, an approved-remediation campaign, and a campaign whose evidence URL carries a token query param (assert it is absent from output). No mocks required — pure function.
+
+
+ npx vitest run lib/services/triage-note-format.test.ts
+
+
+ - `npx vitest run lib/services/triage-note-format.test.ts` passes
+ - Source assertion: `grep -q "export interface TriageNoteEvidence" lib/services/triage-note-format.ts` and `grep -q "export function formatTriageNote" lib/services/triage-note-format.ts`
+ - Source assertion: `grep -q "triage-note-sanitize" lib/services/triage-note-format.ts` (formatter routes through the sanitizer)
+ - Behavior assertion: a test with a 'unavailable' BlastRadiusResult asserts the output contains the substring 'unavailable'
+ - Behavior assertion: a test with an evidence URL containing 'token=leak' asserts output has no substring 'token=leak'
+ - `npx tsc --noEmit --pretty` reports no new errors
+
+ Formatter and TriageNoteEvidence contract exist, all format tests pass, output is sanitized and human-readable; type-check clean.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| campaign/message evidence → note text | Attacker-controlled email content (subject, sender display name, URLs, reasons derived from headers) flows into a string that will be written to Autotask |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-21-01 | Information Disclosure | sanitizeUrl / sanitizeNoteText | mitigate | Strip URL query+fragment wholesale (scheme+host+path only); redact Bearer/Authorization and credential query-param values before any text is returned. Proven by Task 1 tests asserting `token=`/`access_token=`/`password=` values and Bearer tokens are absent from output. |
+| T-21-02 | Injection | formatTriageNote output | mitigate | Note is plain-text (Autotask TicketNotes description is not HTML-rendered by Pulse); formatter emits prose, never executable markup. Evidence values are only ever concatenated as text, not evaluated. |
+| T-21-03 | Information Disclosure | formatTriageNote (missing evidence) | accept | When blast radius is 'unavailable' or classification is null, the formatter emits an explicit "unavailable"/"not yet classified" line rather than leaking internal error detail — bounded, operator-facing, no secret content. |
+| T-21-SC | Tampering | npm/pip/cargo installs | accept | No new package installs in this plan; RESEARCH disabled for project and no install tasks present. |
+
+
+
+- `npx vitest run lib/services/triage-note-sanitize.test.ts lib/services/triage-note-format.test.ts` — both suites pass
+- `npx tsc --noEmit --pretty` — no new type errors
+- `grep -rq "sanitizeNoteText\|sanitizeUrl" lib/services/triage-note-format.ts` — formatter uses the sanitizer
+
+
+
+- Sanitizer strips URL query strings/fragments and redacts secrets/tokens, with email/hash evidence preserved (NOTE-01 sanitization foundation)
+- Formatter renders verdict, confidence, summary, reasons, blast-radius (both branches), and current remediation state as human-readable prose
+- `TriageNoteEvidence` is exported for Plan 02 to build against
+- All unit tests pass; type-check clean
+
+
+
diff --git a/.planning/phases/21-autotask-triage-note/21-02-PLAN.md b/.planning/phases/21-autotask-triage-note/21-02-PLAN.md
new file mode 100644
index 0000000..5ce0a0e
--- /dev/null
+++ b/.planning/phases/21-autotask-triage-note/21-02-PLAN.md
@@ -0,0 +1,217 @@
+---
+phase: 21-autotask-triage-note
+plan: 02
+type: execute
+wave: 2
+depends_on: [21-01]
+files_modified:
+ - lib/services/triage-note-service.ts
+ - lib/services/triage-note-service.test.ts
+ - app/api/phishing/campaigns/[id]/triage-note/route.ts
+autonomous: true
+requirements: [NOTE-01]
+
+must_haves:
+ truths:
+ - "POST /api/phishing/campaigns/{id}/triage-note requires the phishing:analyze permission and rejects unauthenticated/unauthorized callers with 401/403"
+ - "For a classified campaign the endpoint posts an internal (non-portal) Autotask TicketNote to every ticket linked to the campaign"
+ - "The API response always includes the generated sanitized note text plus a per-ticket posted/error status list"
+ - "A single ticket's Autotask write failure is captured per-ticket and does not abort the whole call, and no unsanitized/partial write is attempted as a fallback"
+ artifacts:
+ - path: "lib/services/triage-note-service.ts"
+ provides: "generateAndPostTriageNote(campaignId) orchestrator: evidence gather + note post loop + partial-failure result"
+ contains: "export async function generateAndPostTriageNote"
+ - path: "app/api/phishing/campaigns/[id]/triage-note/route.ts"
+ provides: "POST route: permission gate + UUID guard + campaign-exists check + service delegation"
+ exports: ["POST"]
+ - path: "lib/services/triage-note-service.test.ts"
+ provides: "Vitest coverage of per-ticket write loop + partial-failure + note text return"
+ key_links:
+ - from: "app/api/phishing/campaigns/[id]/triage-note/route.ts"
+ to: "lib/services/triage-note-service.ts"
+ via: "generateAndPostTriageNote(id)"
+ pattern: "generateAndPostTriageNote"
+ - from: "lib/services/triage-note-service.ts"
+ to: "Autotask TicketNotes"
+ via: "getAutotaskClient().createEntity('TicketNotes', ...)"
+ pattern: "createEntity\\('TicketNotes'"
+ - from: "lib/services/triage-note-service.ts"
+ to: "lib/services/triage-note-format.ts"
+ via: "formatTriageNote(evidence)"
+ pattern: "formatTriageNote"
+ - from: "app/api/phishing/campaigns/[id]/triage-note/route.ts"
+ to: "lib/auth-utils.ts"
+ via: "requirePermission('phishing', 'analyze')"
+ pattern: "requirePermission\\('phishing', 'analyze'\\)"
+---
+
+
+Build the triage-note service and its on-demand API route. The service gathers a campaign's current evidence (linked reports/tickets, most-recent classification, current remediation state, fresh blast radius), renders the sanitized note via Plan 01's formatter, and posts one internal Autotask TicketNote per linked ticket — capturing each write's success/failure independently. The route is a thin, access-controlled controller matching the existing `classify` route shape.
+
+Purpose: Satisfies NOTE-01 — Pulse already has a safe Autotask note-write path (`createEntity('TicketNotes', ...)`), so this phase posts the note to every ticket linked to the campaign (D-01) via a new operator-triggered endpoint (D-02), always posting fresh state (D-03), including the full picture of classification + blast radius + current remediation state (D-04), and reporting per-ticket write outcomes so a failed write never aborts the call or gets silently swallowed (D-05/D-06).
+
+Output: `lib/services/triage-note-service.ts`, its test, and `app/api/phishing/campaigns/[id]/triage-note/route.ts`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/21-autotask-triage-note/21-CONTEXT.md
+@.planning/phases/21-autotask-triage-note/21-PATTERNS.md
+@.planning/phases/21-autotask-triage-note/21-01-SUMMARY.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Task 1: triage-note-service (evidence gather + per-ticket post loop)
+ lib/services/triage-note-service.ts, lib/services/triage-note-service.test.ts
+
+ - lib/services/triage-note-format.ts (TriageNoteEvidence contract + formatTriageNote — from Plan 01)
+ - lib/services/campaign-classifier.ts (evidence-gathering query shapes: reports WHERE campaign_id, getBlastRadius call inputs, classification/reasons handling)
+ - lib/services/remediation-service.ts (most-recent-row ORDER BY created_at DESC LIMIT 1 idiom; remediation_actions column reads)
+ - lib/services/workflow-engine.ts (runAiTroubleshooting ~lines 560-613: the createEntity('TicketNotes', {...}) write shape to copy — noteType:1 Internal, publish:1)
+ - lib/services/remediation-service.test.ts (Vitest vi.mock('./postgres-client') + vi.fn query pattern to mirror; this codebase mocks postgres and dependencies rather than hitting a live DB)
+ - lib/services/autotask-factory.ts (getAutotaskClient singleton to mock in the test)
+ - .planning/phases/21-autotask-triage-note/21-CONTEXT.md (D-01 every linked ticket; D-04 current remediation state; D-05/D-06 per-ticket failure semantics + response shape)
+
+
+ - generateAndPostTriageNote(campaignId) resolves { noteText: string; tickets: Array<{ ticketId: string; posted: boolean; error?: string }> }
+ - Given a campaign with 3 linked reports (3 ticket_ids), createEntity('TicketNotes', ...) is called exactly 3 times, once per ticketID (Number-coerced), with description === the generated noteText and noteType:1, publish:1
+ - result.tickets has one entry per linked report; all posted:true when every write succeeds
+ - When getAutotaskClient().createEntity rejects for exactly one of the 3 tickets, that ticket's entry is posted:false with a non-empty error string, the other two are posted:true, and the promise still resolves (the whole call is NOT aborted) — D-05
+ - result.noteText is always present and non-empty regardless of any write outcome — D-06 (text never withheld)
+ - noteText is produced by formatTriageNote(evidence) built from the most-recent classification row, current remediation_actions rows, and a getBlastRadius() result — so an evidence URL with a token query param is absent from noteText (sanitization flows through)
+ - A campaign with zero linked reports resolves { noteText, tickets: [] } (still returns the note text; no writes attempted) rather than throwing
+ - A campaign with no classification row still resolves (note renders 'not yet classified' via formatter) rather than throwing
+
+
+ Create `lib/services/triage-note-service.ts` exporting `interface TriageNotePostResult { ticketId: string; posted: boolean; error?: string }`, `interface TriageNoteResult { noteText: string; tickets: TriageNotePostResult[] }`, and `async function generateAndPostTriageNote(campaignId: string): Promise`.
+ Read side: import the default `postgresClient` from `./postgres-client`. Query all linked reports with `SELECT id::text, ticket_id::text AS ticket_id, ticket_number, title, company_name, requester_contact_id, evidence, created_at FROM reports WHERE campaign_id = $1 ORDER BY created_at ASC`. Query the most-recent classification with `SELECT verdict, confidence, summary, reasons, recommended_actions, requires_approval, created_at FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1` (nullable — handle absence). Query current remediation state with `SELECT action_type, status, approved_by, approved_at::text AS approved_at FROM remediation_actions WHERE campaign_id = $1 ORDER BY created_at ASC`. Parse JSONB `reasons`/`recommended_actions` into `string[]` defensively (already-array vs JSON string). Derive candidate `urls` from report `evidence` if present.
+ Blast radius: call `getBlastRadius({ sender, recipient, subject, dateWindow: { start, end } })` fresh (D-04 current truth) using best-available sender/recipient/subject derived from the primary report's evidence/title and a ±24h window around the primary report `created_at` — copy the input-shaping approach from `campaign-classifier.ts`. `getBlastRadius` never throws (returns `{ status: 'unavailable' }` when Mimecast is not configured), so no guard needed beyond passing what you have.
+ Build a `TriageNoteEvidence` object (import the interface + `formatTriageNote` from `./triage-note-format`) mapping: campaignId, reportCount = reports.length, companyName/subject from the primary report, verdict/confidence/summary/reasons/recommendedActions/requiresApproval from the classification (nulls when absent), blastRadius, remediationActions (map action_type→actionType, approved_by→approvedBy, approved_at→approvedAt), urls. Call `formatTriageNote(evidence)` to get `noteText`.
+ Write side: `const client = getAutotaskClient();` then loop the reports; for each, in its OWN try/catch, `await client.createEntity('TicketNotes', { ticketID: Number(report.ticket_id), title: 'Phishing Triage Summary', description: noteText, noteType: 1, publish: 1 })` and push `{ ticketId: report.ticket_id, posted: true }`; on catch, `console.error('[PHISHING-TRIAGE-NOTE] Failed to post note to ticket', report.ticket_id, err)` and push `{ ticketId, posted: false, error: err instanceof Error ? err.message : 'Unknown error' }`. The per-ticket try/catch MUST be inside the loop (not around it) so one failure never aborts remaining writes (D-05). Return `{ noteText, tickets }`.
+ Write `lib/services/triage-note-service.test.ts` following `remediation-service.test.ts`: `vi.mock('./postgres-client', ...)` with a `queryMock` returning seeded reports/classification/remediation rows per SQL, `vi.mock('./autotask-factory', ...)` exposing a `createEntityMock`, and `vi.mock('./mimecast-blast-radius', ...)` returning a fixed BlastRadiusResult. Cover every bullet, especially the one-ticket-fails-others-succeed case and the note-text-always-returned case.
+
+
+ npx vitest run lib/services/triage-note-service.test.ts
+
+
+ - `npx vitest run lib/services/triage-note-service.test.ts` passes
+ - Source assertion: `grep -q "export async function generateAndPostTriageNote" lib/services/triage-note-service.ts`
+ - Source assertion: `grep -q "createEntity('TicketNotes'" lib/services/triage-note-service.ts` and `grep -q "noteType: 1" lib/services/triage-note-service.ts` and `grep -q "publish: 1" lib/services/triage-note-service.ts`
+ - Source assertion: `grep -q "formatTriageNote" lib/services/triage-note-service.ts` (uses Plan 01 formatter)
+ - Behavior assertion: a test mocks createEntity to reject on the 2nd of 3 tickets and asserts tickets[1].posted===false, tickets[0].posted===true, tickets[2].posted===true, and the returned promise resolves (call not aborted)
+ - Behavior assertion: a test asserts result.noteText is a non-empty string even when a write fails
+ - `npx tsc --noEmit --pretty` reports no new errors
+
+ Service gathers current campaign evidence, renders the sanitized note, posts one internal TicketNote per linked ticket with independent per-ticket error capture, and returns note text + per-ticket status; all tests pass; type-check clean.
+
+
+
+ Task 2: POST /api/phishing/campaigns/[id]/triage-note route
+ app/api/phishing/campaigns/[id]/triage-note/route.ts
+
+ - app/api/phishing/campaigns/[id]/classify/route.ts (exact structural twin to copy: imports, UUID_RE, permission gate, campaign-exists 404 check, delegation, catch/500 shape)
+ - lib/services/triage-note-service.ts (generateAndPostTriageNote signature + TriageNoteResult return shape — from Task 1)
+ - lib/auth-utils.ts (requirePermission early-return pattern)
+ - lib/permissions.ts (confirms 'phishing'/'analyze' is already granted — no new permission statement needed)
+ - .planning/phases/21-autotask-triage-note/21-CONTEXT.md (Claude's Discretion: use the 'analyze' tier like classify, not 'approve'; D-06 response returned verbatim)
+
+
+ Create `app/api/phishing/campaigns/[id]/triage-note/route.ts` by copying the structure of `app/api/phishing/campaigns/[id]/classify/route.ts`. Import `NextRequest, NextResponse` from 'next/server', `requirePermission` from '@/lib/auth-utils', default `postgresClient` from '@/lib/services/postgres-client', and `generateAndPostTriageNote` from '@/lib/services/triage-note-service'. Reuse the identical `UUID_RE` regex constant. Export `async function POST(request, { params }: { params: Promise<{ id: string }> })`.
+ In the handler: call `const { session, error } = await requirePermission('phishing', 'analyze'); if (error) return error;` (D — informational action uses the same tier as classify, NOT 'approve'). Await `params`, validate `id` against `UUID_RE` returning 400 `{ error: 'Invalid campaign id' }` on mismatch. In a try block, run `SELECT id FROM campaigns WHERE id = $1` and return 404 `{ error: 'Campaign not found' }` if no row; otherwise `const result = await generateAndPostTriageNote(id);` and `return NextResponse.json(result);` (return the service result verbatim — note text + per-ticket status per D-06, no reshaping). In catch, `console.error('[PHISHING-TRIAGE-NOTE] Failed to generate triage note', id, err)` and return 500 `{ error: 'Failed to generate triage note', message: err instanceof Error ? err.message : 'Unknown error' }`. Do NOT add an audit-event write (CONTEXT.md: not required for this phase; sent-note history is a deferred idea). This outer catch only fires for whole-request failures — per-ticket write failures are already captured inside the service and returned in the 200 body (D-05).
+
+
+ npx tsc --noEmit --pretty
+
+
+ - Source assertion: `grep -q "requirePermission('phishing', 'analyze')" app/api/phishing/campaigns/[id]/triage-note/route.ts`
+ - Source assertion: `grep -q "generateAndPostTriageNote" app/api/phishing/campaigns/[id]/triage-note/route.ts`
+ - Source assertion: `grep -q "UUID_RE" app/api/phishing/campaigns/[id]/triage-note/route.ts` and route returns 404 on missing campaign (`grep -q "Campaign not found"`)
+ - Source assertion: route does NOT import or call writeAuditEvent (`! grep -q "writeAuditEvent" app/api/phishing/campaigns/[id]/triage-note/route.ts`)
+ - `npx tsc --noEmit --pretty` reports no errors
+ - `npx next build` (or `npx tsc`) recognizes the route as a valid App Router handler exporting POST
+
+ The POST route exists, gates on phishing:analyze, guards the UUID, 404s unknown campaigns, delegates to generateAndPostTriageNote, returns the result verbatim, and type-checks clean.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| client → API route | Untrusted caller hits POST /api/phishing/campaigns/{id}/triage-note with an arbitrary campaign id |
+| campaign evidence → Autotask note | Attacker-controlled email content flows through the formatter into notes written to Autotask tickets |
+| Autotask API → API response | External write outcomes (incl. error messages) are surfaced back to the caller |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-21-04 | Elevation of Privilege | route permission tier | mitigate | Route calls `requirePermission('phishing', 'analyze')` and early-returns 401/403 before any DB read or Autotask write — same convention as every other /api/phishing/* route (ACCESS-01 carry-forward). Note generation is informational (not a destructive state change), so 'analyze' (not 'approve') is the correct tier per CONTEXT.md discretion. |
+| T-21-05 | Tampering / Injection | UUID guard + parameterized queries | mitigate | `UUID_RE` rejects malformed ids with 400 before querying; all reads use `$1` parameterized queries (no string interpolation), preventing SQL injection via the id or campaign data. |
+| T-21-06 | Information Disclosure | note content written to Autotask | mitigate | Note text is produced solely by Plan 01's `formatTriageNote`, which routes all URLs/free-text through `sanitizeNoteText`/`sanitizeUrl` (query strings + tokens stripped) — no raw malicious URL or secret is written. Notes are `noteType:1`/`publish:1` (internal Autotask users, never customer portal per AUTOTASK_API_GUIDE.md). |
+| T-21-07 | Information Disclosure | partial-failure response detail | accept | Per-ticket `error` returns only `err.message` (Autotask API message), never a stack trace or internal path; the whole-request catch likewise returns only `err.message`. Operator-facing internal tool; bounded error text is acceptable and necessary for the "paste failed note manually" workflow (D-06). |
+| T-21-08 | Denial of Service / Repudiation | no note-history persistence | accept | No audit/history row is written (CONTEXT.md defers this); Autotask itself is the record of truth for posted notes. Repeated calls posting duplicate notes are expected/acceptable (D-03). |
+| T-21-SC | Tampering | npm/pip/cargo installs | accept | No new package installs in this plan; RESEARCH disabled for project and no install tasks present. |
+
+
+
+- `npx vitest run lib/services/triage-note-service.test.ts` — passes (per-ticket loop + partial-failure + note-text-always-returned)
+- `npx tsc --noEmit --pretty` — no new type errors
+- `grep -q "requirePermission('phishing', 'analyze')" app/api/phishing/campaigns/[id]/triage-note/route.ts` — auth gate present
+- `grep -q "createEntity('TicketNotes'" lib/services/triage-note-service.ts` — safe write path used
+
+
+
+- POST /api/phishing/campaigns/{id}/triage-note is auth-gated (phishing:analyze), UUID-guarded, and 404s unknown campaigns (NOTE-01 + ACCESS-01 carry-forward)
+- For a classified campaign, an internal triage note summarizing classification + evidence + blast radius + current remediation state is posted to every linked ticket (D-01, D-04)
+- The response always returns the sanitized note text plus a per-ticket posted/error status list (D-06)
+- A single ticket's write failure is captured per-ticket without aborting the call and without any unsanitized/partial fallback write (D-05)
+- All tests pass; type-check clean
+
+
+