docs(21-02): add plan summary for triage-note service and endpoint

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-16 12:15:07 -04:00
parent e3a9cb5191
commit 950227ea4e

View file

@ -0,0 +1,103 @@
---
phase: 21-autotask-triage-note
plan: 02
subsystem: api
tags: [phishing-triage, autotask, triage-note, vitest, api-route]
# Dependency graph
requires:
- phase: 21-autotask-triage-note
plan: 01
provides: "formatTriageNote(evidence) + TriageNoteEvidence contract, sanitizeUrl/sanitizeNoteText"
- phase: 17-mimecast-blast-radius-lookup
provides: getBlastRadius(input) -> BlastRadiusResult
- phase: 19-classification-engine
provides: classifications table (verdict/confidence/summary/reasons/recommended_actions)
- phase: 20-remediation-approval-audit-safety
provides: remediation_actions status lifecycle rows
provides:
- "generateAndPostTriageNote(campaignId) orchestrator (lib/services/triage-note-service.ts)"
- "POST /api/phishing/campaigns/[id]/triage-note endpoint"
affects: [22-approval-ui-livelink-addressable-campaign-review-and-approve]
# Tech tracking
tech-stack:
added: []
patterns:
- "Per-ticket try/catch INSIDE the write loop (not around it) so one Autotask write failure never aborts remaining writes (D-05)"
- "NUMERIC confidence column defensively coerced with both a SQL ::float8 cast and a runtime Number() wrap, so the number|null contract holds even against a string-returning mock/driver"
- "Service always returns generated note text regardless of write outcome (D-06) — never a silent partial rollback"
key-files:
created:
- lib/services/triage-note-service.ts
- lib/services/triage-note-service.test.ts
- app/api/phishing/campaigns/[id]/triage-note/route.ts
modified: []
key-decisions:
- "Blast-radius sender/recipient inputs use best-available data from the bounded reports query (title only, sender/recipient left empty) rather than adding extra contact/message-header queries — getBlastRadius degrades gracefully (never throws) on sparse input, and the plan's evidence-gathering SQL is limited to the four queries it specifies (reports, classifications, remediation_actions, indicators)"
- "Verdict is cast from the classifications.verdict TEXT column to TriageNoteEvidence['verdict'] rather than validated against the union at runtime — consistent with how campaign-classifier.ts/classify route already trust this column"
requirements-completed: [NOTE-01]
# Metrics
duration: ~11min
completed: 2026-07-16
---
# Phase 21 Plan 02: Triage-Note Service + Endpoint Summary
**`generateAndPostTriageNote(campaignId)` gathers current campaign evidence (linked reports, real url indicators via the reports→messages→indicators join, most-recent classification with a numeric-coerced confidence, current remediation state, fresh blast radius), renders it via Plan 01's sanitized formatter, and posts one internal Autotask TicketNote per linked ticket with independent per-ticket failure capture — exposed via `POST /api/phishing/campaigns/{id}/triage-note`.**
## Performance
- **Duration:** ~11 min
- **Started:** 2026-07-16T16:03:00Z (approx.)
- **Completed:** 2026-07-16T16:14:33Z
- **Tasks:** 2 completed
- **Files modified:** 3 (all newly created)
## Accomplishments
- `generateAndPostTriageNote(campaignId)` orchestrates: linked-reports read, most-recent classification read (with `confidence::float8` SQL cast + defensive `Number()` runtime coercion), current `remediation_actions` state, and a real `reports → messages → indicators` join filtered to `indicator_type = 'url'` for extracted indicator URLs
- Fresh `getBlastRadius()` lookup per call (D-04 — always current, not frozen at classify-time)
- Builds a `TriageNoteEvidence` object and calls Plan 01's `formatTriageNote()` to get the sanitized note text
- Posts one `createEntity('TicketNotes', { ticketID, title, description, noteType: 1, publish: 1 })` write per linked ticket, each in its OWN try/catch (D-05) — a single ticket's failure is captured as `{ ticketId, posted: false, error }` without aborting the remaining writes
- `noteText` is always returned regardless of write outcome (D-06) — verified by a test where the only linked ticket's write fails and `noteText` is still non-empty
- `POST /api/phishing/campaigns/[id]/triage-note` — structural twin of `classify/route.ts`: `requirePermission('phishing', 'analyze')` gate, UUID guard (400), campaign-exists check (404), delegates to the service, returns its result verbatim, no audit-event write (deferred per CONTEXT.md)
## Task Commits
Task 1 followed RED → GREEN (TDD):
1. **Task 1: triage-note-service** - `34a0269` (test: RED, verified failing — module did not exist) → `2d410f8` (feat: GREEN, all 8 tests pass)
2. **Task 2: POST route** - `e3a9cb5` (feat, no TDD gate — `type="auto"` without `tdd="true"`)
_TDD gate compliance: Task 1 has a `test(...)` commit followed by a `feat(...)` commit; RED was verified by temporarily removing the implementation file and confirming the suite failed with "Cannot find module" before restoring it and re-running to GREEN. No refactor step was needed._
## Files Created/Modified
- `lib/services/triage-note-service.ts` - `TriageNotePostResult`/`TriageNoteResult` interfaces + `generateAndPostTriageNote(campaignId)` orchestrator
- `lib/services/triage-note-service.test.ts` - 8 Vitest cases: all-succeed write loop, one-of-three-fails isolation (D-05), note-text-always-returned (D-06), sanitized-URL-flows-into-note, zero-url-indicators, string-confidence coercion, zero-linked-reports, no-classification-row
- `app/api/phishing/campaigns/[id]/triage-note/route.ts` - `POST` handler: auth gate, UUID guard, 404, delegate, verbatim return, 500 catch
## Decisions Made
- Blast-radius sender/recipient use best-available data (title only) from the bounded reports query rather than adding extra contact/message-header lookups — matches the plan's literal four-query evidence-gathering scope and `getBlastRadius()`'s documented graceful-degradation behavior on sparse input (never throws).
- `classifications.verdict` (a TEXT column) is cast to `TriageNoteEvidence['verdict']` without an additional runtime validation step, consistent with how the existing classify route and campaign-classifier.ts already trust this column's values.
- Route uses `phishing:analyze` (not `phishing:approve`) per CONTEXT.md's explicit lean — note generation is informational, not a state-changing security decision.
## Deviations from Plan
None - plan executed exactly as written. Both tasks' `<behavior>`, `<action>`, and `<acceptance_criteria>` blocks were implemented as specified; no architectural changes, no missing critical functionality found, no blocking issues encountered.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required beyond what Phases 17-20 already established (Autotask/Mimecast env vars, if configured).
## Next Phase Readiness
- `generateAndPostTriageNote()` and the new `POST /api/phishing/campaigns/{id}/triage-note` endpoint are ready for Phase 22's LiveLink approval UI to call as its "send triage note" action.
- No blockers.
---
*Phase: 21-autotask-triage-note*
*Completed: 2026-07-16*