diff --git a/.planning/phases/21-autotask-triage-note/21-CONTEXT.md b/.planning/phases/21-autotask-triage-note/21-CONTEXT.md
new file mode 100644
index 0000000..ba85131
--- /dev/null
+++ b/.planning/phases/21-autotask-triage-note/21-CONTEXT.md
@@ -0,0 +1,233 @@
+# Phase 21: Autotask Triage Note - Context
+
+**Gathered:** 2026-07-16
+**Status:** Ready for planning
+
+
+## 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).
+
+
+
+
+## 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.
+
+
+
+
+## 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`, ~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)
+
+
+
+
+## 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).
+
+
+
+
+## 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.
+
+
+
+
+## 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.
+
+
+
+---
+
+*Phase: 21-autotask-triage-note*
+*Context gathered: 2026-07-16*
diff --git a/.planning/phases/21-autotask-triage-note/21-DISCUSSION-LOG.md b/.planning/phases/21-autotask-triage-note/21-DISCUSSION-LOG.md
new file mode 100644
index 0000000..7fd080f
--- /dev/null
+++ b/.planning/phases/21-autotask-triage-note/21-DISCUSSION-LOG.md
@@ -0,0 +1,97 @@
+# Phase 21: Autotask Triage Note - Discussion Log
+
+> **Audit trail only.** Do not use as input to planning, research, or execution agents.
+> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
+
+**Date:** 2026-07-16
+**Phase:** 21-autotask-triage-note
+**Areas discussed:** Ticket scoping, Trigger point, Re-trigger behavior, Note content, Write-failure fallback, Partial-failure response shape
+
+---
+
+## Ticket Scoping
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| All linked tickets | Post the same note to every report's ticket_id in the campaign | ✓ |
+| Most recent report's ticket | Post only to the most-recently-reported duplicate's ticket | |
+| Original (first) report's ticket | Post only to the earliest report's ticket | |
+| Operator specifies the ticket | Endpoint takes a ticket_id param, operator controls target | |
+
+**User's choice:** All linked tickets
+**Notes:** A campaign groups multiple duplicate-report tickets (`campaigns`→`reports` is 1-to-many). Every duplicate-report ticket gets the same note so whoever looks at any of them sees the full picture, at the cost of N Autotask writes per trigger.
+
+---
+
+## Trigger Point
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| New on-demand endpoint | Operator explicitly triggers, matches classify/approve/remediate pattern | ✓ |
+| Auto-fire after classify | Fires automatically as a side effect of a successful /classify call | |
+
+**User's choice:** New on-demand endpoint
+**Notes:** Matches Phase 19 D-02's precedent of keeping classification (and by extension every state-changing/reporting action this milestone) operator-initiated, not an automatic cascade.
+
+---
+
+## Re-trigger Behavior (follow-up on Ticket Scoping)
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Always post fresh | Every call posts a new note reflecting current state to every linked ticket | ✓ |
+| Skip tickets that already have a note from this exact classification | Track (campaign, classification_id, ticket_id) combos and skip duplicates | |
+
+**User's choice:** Always post fresh
+**Notes:** Duplicate Autotask notes across repeated calls are acceptable — same posture as Phase 19 D-02 allowing repeated `/classify` calls.
+
+---
+
+## Note Content
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Full picture: verdict + evidence + blast radius + current remediation state | Note reflects present truth, not just the classifier's frozen recommendation | ✓ |
+| Classification + blast radius only, no remediation section | Simpler, but conflicts with the phase's own success criteria wording | |
+
+**User's choice:** Full picture
+**Notes:** Directly satisfies the phase's success criteria ("recommended actions") while staying accurate after approval/remediation has actually happened.
+
+---
+
+## Write-Failure Fallback
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Fall back to returning note text via API response | Matches "never a silent no-op" — operator gets text to paste manually | ✓ |
+| Return an explicit error, no fallback | Simpler, but operator gets nothing usable back for that request | |
+
+**User's choice:** Fall back to returning note text via API response
+**Notes:** This is the actual scope of NOTE-01's "otherwise return via API" branch — a per-ticket runtime write failure, not a general capability gate (the safe write path already exists, confirmed by direct code inspection of `workflow-engine.ts`).
+
+---
+
+## Partial Failure (follow-up on multi-ticket writes)
+
+| Option | Description | Selected |
+|--------|-------------|----------|
+| Per-ticket status + note text always included | Response lists each ticket's outcome and always includes the note text | ✓ |
+| All-or-nothing: any failure fails the whole request | Simpler semantics, but can't actually un-post already-successful writes | |
+
+**User's choice:** Per-ticket status + note text always included
+**Notes:** A conceptual "rollback" isn't real here — a note already posted to one ticket stays posted even if a sibling ticket's write fails in the same call.
+
+---
+
+## Claude's Discretion
+
+- Exact sanitization rules beyond "no raw secrets/tokens/full malicious URL query strings"
+- Exact `noteType`/`publish` values passed to `createEntity('TicketNotes', ...)`
+- Exact note text format/template (prose structure)
+- Whether the endpoint requires `phishing:approve` or `phishing:analyze` permission tier
+- Response envelope naming details (camelCase convention applies)
+
+## Deferred Ideas
+
+- Persisting sent-note history (a table tracking which notes were posted, when) — not requested, Autotask itself is the record of truth
+- Dedup/skip-if-unchanged logic — explicitly rejected in favor of "always post fresh"