# Phase 21: Autotask Triage Note - Context

**Gathered:** 2026-07-16
**Status:** Ready for planning

<domain>
## Phase Boundary

Once a campaign is classified (Phase 19) with whatever remediation state
currently exists (Phase 20 — proposed/approved/completed), produce a
human-readable, sanitized internal triage note summarizing verdict, evidence,
blast radius, and current remediation state. A safe Autotask note-write path
already exists in this codebase (`workflow-engine.ts`'s
`client.createEntity('TicketNotes', ...)`), so this phase writes the note to
every ticket linked to the campaign via a new on-demand endpoint — it does
NOT fall back to API-only return except when an individual write actually
fails at request time. Never a raw/unsanitized dump, never a silent no-op.

Does NOT cover the LiveLink approval UI (Phase 22) — this phase is API/note-
generation only, no new UI (`UI hint: no` per ROADMAP.md).

</domain>

<decisions>
## Implementation Decisions

### Ticket Scoping
- **D-01:** The note is posted to **every ticket linked to the campaign** —
  one `TicketNotes` write per `reports.ticket_id` in the campaign, not just
  the newest or original report. A campaign can span multiple tickets
  (`reports.campaign_id` is 1-to-many per `migrations/097_phishing_triage_
  schema.sql`); every duplicate-report ticket gets the same note content so
  whoever's looking at any of them sees the full picture.

### Trigger Point
- **D-02:** A new **on-demand, campaign-scoped endpoint** — e.g.
  `POST /api/phishing/campaigns/{id}/triage-note` — matching the existing
  `classify`/`approve`/`remediate`/`mark-false-positive` pattern (Phase 18
  D-06 route shape, Phase 19 D-02 on-demand-only precedent). It does **not**
  fire automatically as a side effect of `POST /classify` — the operator
  explicitly triggers it, consistent with Phase 19's decision to keep
  classification (and by extension, every state-changing/reporting action in
  this milestone) operator-initiated rather than an automatic cascade.

### Re-trigger Behavior
- **D-03:** The endpoint is **idempotent-in-name-only** — it can be called
  repeatedly for the same campaign (e.g. after new remediation approvals
  change what the note should say), and **always posts a fresh note**
  reflecting current state at call time. No dedupe/skip tracking against
  prior triage-note calls. Duplicate Autotask notes across repeated calls are
  expected and acceptable — same posture as Phase 19 D-02 allowing repeated
  `/classify` calls to each append a new `classifications` row.

### Note Content
- **D-04:** The note includes the **full picture**: classification verdict +
  confidence + summary + reasons (Phase 19 `classifications` row), blast-
  radius counts (Phase 17 `getBlastRadius()` — delivered/held/clicked/etc.,
  or an explicit "unavailable" if Mimecast isn't configured), AND the
  **current** `remediation_actions` state for the campaign exactly as it
  stands right now — `proposed` only if no operator has acted yet, or
  `approved`/`completed` rows if they exist. The note reflects present truth,
  not just the classifier's original recommendation frozen at classify-time.
  This directly satisfies the phase's own success criteria wording
  ("recommended actions") while staying accurate after approval/remediation
  has happened.

### Write-Failure Fallback
- **D-05:** If an individual ticket's `TicketNotes` write fails at request
  time (Autotask API down, auth error, ticket deleted/closed, etc.), that
  specific failure does **not** abort the whole call and does **not** get
  silently swallowed. See D-06 for the exact response shape. This is the
  ONLY case where "return note text instead of writing" applies — the NOTE-01
  "otherwise return via API" branch is about handling a runtime write
  failure per-ticket, not a general capability gate (Pulse already has the
  safe write path, confirmed by direct code inspection — see canonical refs).

### Partial-Failure Response Shape
- **D-06:** The API response always includes: (a) the generated sanitized
  note text itself (regardless of write outcome — never withheld), and (b) a
  per-ticket status list (`ticket_id`, `posted: true/false`, error detail if
  failed). No conceptual "rollback" is attempted or implied — a note already
  posted to one ticket in the same call stays posted even if a sibling
  ticket's write fails; the response just reports which succeeded so the
  operator can manually paste the returned text into any ticket that failed.

### Claude's Discretion (explicitly deferred to research + planner)
- Exact sanitization rules beyond "no raw secrets/tokens/full malicious URL
  query strings" (NOTE-01) — e.g. whether URLs are truncated to
  scheme+host+path with query strings stripped entirely, whether sender
  email addresses/attachment hashes are shown as-is (they're evidence, not
  secrets) — planner's call, following the same spirit as
  `lib/services/analyzer/itglue-search.ts`'s existing redaction precedent
  even though that's a different data source.
- Exact `noteType`/`publish` values to pass to `createEntity('TicketNotes',
  ...)` — `workflow-engine.ts`'s existing call uses `noteType: 1` (commented
  "Internal") and `publish: 1` ("All Autotask Users" — internal staff, not
  customer portal); planner's call whether to reuse those exact values or
  pick different ones, as long as the note is never customer/portal-visible.
- Exact note text format/template (markdown-ish sections vs. plain prose) —
  no specific reference example surfaced; planner's call as long as it reads
  as human-readable prose, not a JSON dump.
- Whether the triage-note endpoint requires the same elevated permission tier
  as approve/remediate (`phishing:approve`) or the lower `phishing:analyze`
  tier (same as classify) — no strong preference surfaced; planner's call,
  leaning toward matching `classify`'s tier since generating a note is
  informational, not a state-changing security decision like approve/
  remediate.
- Response envelope naming/shape details (camelCase per project convention) —
  standard API convention applies, no new decision needed.

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### Safe Autotask write path (confirms NOTE-01's "if a safe path exists" premise — it does)
- `lib/services/workflow-engine.ts` (`runAiTroubleshooting`, ~line 560-613) —
  existing precedent: `getAutotaskClient().createEntity('TicketNotes', {
  ticketID, title, description, noteType: 1, publish: 1 })`. This IS the safe
  write path NOTE-01 asks about — confirmed by direct inspection, not
  assumed.
- `lib/services/autotask-client.ts` (`createEntity<T>`, ~line 175) — generic
  entity-create method used by the above; `lib/services/autotask-factory.ts`
  → `getAutotaskClient()` for the singleton client.
- `lib/types/autotask.ts` (`TicketNote` interface, ~line 185) —
  `{ id, ticketID, title?, description?, noteType?, publish?,
  creatorResourceID?, creatorType?, lastActivityDate?, createDateTime? }`.
- `AUTOTASK_API_GUIDE.md` (~line 365) — `publish: 1 = All Autotask Users,
  2 = Internal Users Only` — both values keep the note off the customer
  portal; neither is customer-visible.
- `.planning/codebase/INTEGRATIONS.md` (Outgoing Webhooks section) —
  independent codebase-map confirmation: "Autotask write-back: workflow
  engine executes Autotask API calls (POST notes, status updates, custom
  fields)".

### Schema (this phase reads, does not modify)
- `migrations/097_phishing_triage_schema.sql` — `campaigns` (id, status,
  report_count), `reports` (ticket_id BIGINT NOT NULL REFERENCES tickets(id),
  campaign_id nullable FK, UNIQUE(ticket_id) — the 1-to-many campaign→ticket
  relationship D-01 is built on), `classifications` (verdict, confidence,
  summary, reasons JSONB, recommended_actions JSONB, requires_approval),
  `remediation_actions` (action_type, status, params, approved_by,
  approved_at), `audit_events` (actor, event_type, payload).

### Evidence inputs (read-only for this phase)
- `.planning/phases/19-classification-engine/19-CONTEXT.md` — classification
  shape (verdict/confidence/summary/reasons/recommended_actions), D-08 action
  vocabulary (7 action types, destructive vs non-destructive).
- `.planning/phases/17-mimecast-blast-radius-lookup/17-CONTEXT.md` — D-03,
  `getBlastRadius()` is ephemeral (not persisted by Phase 17 itself); Phase
  19 persists what it needs into `classifications.reasons` — this phase may
  need a fresh `getBlastRadius()` call or may read what Phase 19 already
  captured, planner's call which is more current/accurate.
- `.planning/phases/20-remediation-approval-audit-safety/20-CONTEXT.md` —
  `remediation_actions` status lifecycle (`proposed` → `approved` →
  `completed`), D-01's simulated-effect model (no real external provider
  wired yet in this milestone).

### Auth/permissions (established by Phase 18, reused here)
- `lib/permissions.ts` — `phishing` resource statement; `analyze`/`approve`/
  `remediate` actions already granted per role (Phase 18 D-05, Phase 20 D-02).
- `lib/auth-utils.ts` — `requirePermission(resource, action)` — see
  `app/api/phishing/campaigns/[id]/classify/route.ts` for the exact
  call-and-early-return pattern this phase's new route should copy.

### Prior phase decisions (for consistency)
- `.planning/phases/18-campaign-grouping-phishing-analysis-api/18-CONTEXT.md`
  (D-06 — per-route `requirePermission` convention; campaign/report shape)

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable Assets
- `app/api/phishing/campaigns/[id]/classify/route.ts` — exact structural
  precedent (UUID param guard, `requirePermission`, service delegation,
  error shape) for the new triage-note route.
- `workflow-engine.ts`'s `createEntity('TicketNotes', {...})` call — copy
  this shape directly rather than reinventing the Autotask write.

### Established Patterns
- `requirePermission(resource, action)` early-return pattern for every
  `/api/phishing/*` route (Phase 18 D-06).
- Campaign-scoped routes read the campaign's linked `reports` to get
  `ticket_id`s (same join `campaign-grouping-service.ts` and the classifier
  already do to gather evidence per campaign).

### Integration Points
- New route reads: `campaigns` row, all linked `reports` (for ticket_ids),
  most recent `classifications` row, most recent/relevant
  `remediation_actions` rows, and blast-radius data.
- New route writes: one `TicketNotes` entity per linked ticket via
  `getAutotaskClient().createEntity()`. Does NOT write to any
  `phishing_triage` schema table itself — this phase is a read+external-write
  phase, no new Postgres writes needed (no note-history table decided here;
  see Claude's Discretion if planner finds a reason to persist sent notes).

</code_context>

<specifics>
## Specific Ideas

No specific note-text template/example was provided — the process was to
identify the concrete data model gap (does a safe write path exist? yes,
confirmed by inspection) and lock down operational decisions (ticket
scoping, re-trigger semantics, partial-failure handling) rather than
literal wording.

</specifics>

<deferred>
## Deferred Ideas

- **Persisting sent-note history** (a table tracking which notes were posted
  to which tickets, when) — not requested, no signal it's needed; planner
  should treat as out of scope unless a success criterion requires proving
  a note was sent (Autotask itself is the record of truth for what got
  posted).
- **Dedup/skip-if-unchanged logic** — explicitly rejected in favor of D-03
  (always post fresh); noted here so a future phase doesn't reintroduce it
  without reason.

None — discussion stayed within phase scope.

</deferred>

---

*Phase: 21-autotask-triage-note*
*Context gathered: 2026-07-16*
