docs(quick-260717-a19): phishing allowlist/timing/idempotency fixes + Seubert reclassification

This commit is contained in:
lorentz 2026-07-17 07:27:02 -04:00
parent c2a64fab9a
commit 266fc19153
3 changed files with 343 additions and 1 deletions

View file

@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-07-14)
Phase: Milestone v3.0 complete
Plan: —
Status: Awaiting next milestone
Last activity: 2026-07-17 — Milestone v3.0 completed and archived
Last activity: 2026-07-17 — Completed quick task 260717-a19: phishing allowlist/timing/idempotency fixes
## Performance Metrics
@ -126,6 +126,7 @@ None yet.
| 260712-ash | Add PAX8 to admin sync overview page + detail page | 2026-07-12 | 6ed6c66 | [260712-ash-add-pax8-to-the-admin-sync-overview-page](./quick/260712-ash-add-pax8-to-the-admin-sync-overview-page/) |
| 260716-n46 | Fix Mimecast blast-radius future-end-date swallow bug + multi-tenant gap (per-company tenant resolution) | 2026-07-16 | 9951e53 | [260716-n46-fix-mimecast-blast-radius-date-window-fu](./quick/260716-n46-fix-mimecast-blast-radius-date-window-fu/) |
| 260716-pgr | Fix confidence display bug (0-1 scale rendered as raw percent, e.g. "1%" instead of "100%") | 2026-07-16 | 3f16268 | [260716-pgr-fix-confidence-display-bug-in-classifica](./quick/260716-pgr-fix-confidence-display-bug-in-classifica/) |
| 260717-a19 | Fix phishing simulation-vendor allowlist gaps (3 missing KnowBe4 domains), auto-parse timing race (retry on ticket.update), and parseAndStoreMessage idempotency; reclassified 6 stale Seubert campaigns (all flipped UNWANTED → USER_AWARENESS) | 2026-07-17 | (pending) | [260717-a19-fix-phishing-simulation-vendor-allowlist](./quick/260717-a19-fix-phishing-simulation-vendor-allowlist/) |
## Deferred Items

View file

@ -0,0 +1,281 @@
---
phase: quick-260717-a19
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- lib/services/campaign-classifier.ts
- lib/services/campaign-classifier.test.ts
- lib/services/phishing-eml-service.ts
- lib/services/phishing-eml-service.test.ts
- lib/services/webhook-service.ts
- scripts/reclassify-seubert-campaigns.ts
autonomous: true
requirements: [PHISH-FIX-ALLOWLIST, PHISH-FIX-PARSE-RETRY, PHISH-FIX-IDEMPOTENCY, PHISH-RECLASSIFY]
must_haves:
truths:
- "The 3 confirmed KnowBe4 lookalike domains classify as USER_AWARENESS, not UNWANTED"
- "parseAndStoreMessage returns early without a second messages row when one already exists for the report"
- "A ticket.update webhook re-attempts parsing when a flagged report still has no messages row and the company's auto_parse gate is on"
- "The 6 stale Seubert campaigns are reclassified against the corrected allowlist with before/after verdicts reported"
artifacts:
- path: "lib/services/campaign-classifier.ts"
provides: "Expanded knowbe4 domain allowlist"
contains: "customer-portal.info"
- path: "lib/services/phishing-eml-service.ts"
provides: "Idempotency existence guard in parseAndStoreMessage"
contains: "already-parsed"
- path: "lib/services/webhook-service.ts"
provides: "Retry-parse on ticket.update for unparsed flagged reports"
- path: "scripts/reclassify-seubert-campaigns.ts"
provides: "One-off reclassification script for the 6 affected campaigns"
key_links:
- from: "lib/services/webhook-service.ts"
to: "parseAndStoreMessage"
via: "retry path on WebhookEventType.UPDATE"
pattern: "parseAndStoreMessage"
- from: "lib/services/phishing-eml-service.ts"
to: "messages table"
via: "SELECT id FROM messages WHERE report_id existence check"
pattern: "report_id"
---
<objective>
Fix three verified defects in the phishing-triage pipeline discovered while
investigating Seubert ticket 699456, then reclassify the 6 affected campaigns.
Purpose: Confirmed KnowBe4 simulation campaigns are being routed to technician
review (UNWANTED) instead of auto-acknowledged (USER_AWARENESS) because of a
data gap in the sim-vendor allowlist, a timing race that permanently starves
the classifier of parsed .eml evidence, and a missing idempotency guard that
blocks safe retries. This undermines the milestone goal of eliminating
technician work for confirmed simulations.
Output: Updated allowlist data + test (Defect 1), idempotency guard + test
(Defect 3), a retry-on-update parse path reusing existing webhook update-event
traffic (Defect 2), and a one-off reclassification script for the 6 stale
Seubert campaigns.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
<investigation_findings>
Direct DB inspection of 8 Seubert phishing reports from a single burst on
2026-07-16/17 showed all 8 share an identical KnowBe4 URL fingerprint —
`https://{domain}/render-template/?csu={token}&status_id=n` and
`https://{domain}/tracking/?csu={token}&status_id=a` — differing only by
domain + csu token. Domains observed:
- it-support.care (already allowlisted, confirmed KnowBe4)
- customer-portal.info (tickets 699421, 699422, 699456) — MISSING
- cloud-service-care.com (tickets 699419, 699433) — MISSING
- bankonlinesupport.com (ticket 699435) — MISSING
The `parseAndStoreMessage` call fired within ~1-2s of ticket.created returned a
silent no-op (attachment not yet available via Autotask getAttachments), and
calling the same function minutes later succeeded — there is no retry. Ticket
699456 received 5+ ticket.update webhook events in the minutes after creation.
</investigation_findings>
<interfaces>
From lib/services/campaign-classifier.ts:
```typescript
export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[]
export function domainMatchesAllowlist(domain: string): boolean // exact-or-subdomain, do NOT change
export function classifyCampaign(campaignId: string): Promise<ClassifyResult> // ClassifyResult.verdict, .id
```
From lib/services/phishing-eml-service.ts:
```typescript
export interface ParseAndStoreResult { stored: boolean; messageId?: string; reason?: string; }
export async function parseAndStoreMessage(input: { reportId: string; ticketId: number }): Promise<ParseAndStoreResult>
```
From lib/services/phishing-automation-gate.ts:
```typescript
export async function getCompanyAutomationGate(companyId: number | null): Promise<{ autoParse: boolean; autoClassify: boolean; autoReport: boolean }>
```
reports table: id (UUID), ticket_id (BIGINT, UNIQUE), company_id, campaign_id (UUID)
messages table: id (UUID), report_id (UUID FK)
webhook types: WebhookEventType.CREATE | WebhookEventType.UPDATE, WebhookEntityType.TICKETS
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add 3 confirmed KnowBe4 domains to simulation allowlist (Defect 1)</name>
<files>lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts</files>
<read_first>
- lib/services/campaign-classifier.ts lines 33-56 (KNOWN_SIMULATION_SENDERS + domainMatchesAllowlist)
- lib/services/campaign-classifier.test.ts lines 41-77 (existing allowlist / domainMatchesAllowlist / isKnownSimulationSender tests)
</read_first>
<behavior>
- domainMatchesAllowlist('customer-portal.info') === true
- domainMatchesAllowlist('cloud-service-care.com') === true
- domainMatchesAllowlist('bankonlinesupport.com') === true
- Subdomain still matches: domainMatchesAllowlist('mail.customer-portal.info') === true
- Spoof guard still holds: domainMatchesAllowlist('customer-portal.info.attacker.net') === false
</behavior>
<action>
In `KNOWN_SIMULATION_SENDERS`, extend ONLY the `knowbe4` vendor entry's
`domains` array to include `'customer-portal.info'`,
`'cloud-service-care.com'`, and `'bankonlinesupport.com'` alongside the
existing `'it-support.care'`. This is a pure data update — do NOT modify
`domainMatchesAllowlist`, `isKnownSimulationSender`, or any matching logic
(they already do exact-or-subdomain matching correctly, guarded by T-19-01
tests). Update the inline comment on the knowbe4 entry to note these 3
domains were confirmed via the shared /render-template/ + /tracking/ URL
fingerprint across Seubert tickets 699419/699421/699422/699433/699435/699456
on 2026-07-16/17. In campaign-classifier.test.ts, add assertions to the
`domainMatchesAllowlist` describe block covering the 5 behaviors above.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx vitest run lib/services/campaign-classifier.test.ts</automated>
</verify>
<done>All 3 new domains (and a subdomain of one) match the allowlist; the suffix-spoof case still returns false; existing tests still pass.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add idempotency guard (Defect 3) + retry-parse on ticket.update (Defect 2)</name>
<files>lib/services/phishing-eml-service.ts, lib/services/phishing-eml-service.test.ts, lib/services/webhook-service.ts</files>
<read_first>
- lib/services/phishing-eml-service.ts lines 49-95 (parseAndStoreMessage entry + no-op return shapes)
- lib/services/phishing-eml-service.test.ts lines 12-130 (mock setup; note beforeEach default `queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })`)
- lib/services/webhook-service.ts lines 94-126 (event switch + fire-and-forget CREATE-only triggers)
- lib/services/webhook-service.ts lines 462-537 (triggerPhishingDetection + runGatedPhishingStages auto_parse path)
</read_first>
<behavior>
- parseAndStoreMessage returns `{ stored: false, reason: 'already-parsed' }` and performs NO getAttachments/getAttachmentContent/INSERT when a messages row already exists for the reportId.
- parseAndStoreMessage happy path still returns `{ stored: true, messageId }` when no existing messages row.
- On a ticket.update webhook, when a reports row exists for that ticket_id, has no linked messages row, and the company's auto_parse gate is on → parseAndStoreMessage is called exactly once.
- On a ticket.update webhook where the report already has a messages row → parseAndStoreMessage is NOT called (the existence check short-circuits).
- On a ticket.update webhook for a ticket with no reports row → no phishing work is triggered.
</behavior>
<action>
DEFECT 3 (idempotency), in phishing-eml-service.ts: at the very top of the
`try` block in `parseAndStoreMessage` (before the `getAttachments` call, ~line
58), add an existence check: `SELECT id FROM messages WHERE report_id = $1
LIMIT 1` with `[reportId]`. If `rows.length > 0`, return `{ stored: false,
reason: 'already-parsed' }` immediately — mirroring the existing
`{ stored: false, reason: ... }` no-op shape. Do not add ON CONFLICT to the
INSERT (the guard is the fix).
DEFECT 3 tests, in phishing-eml-service.test.ts: NOTE the beforeEach default
`queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })` — with the
new leading SELECT, this default now makes EVERY existing happy-path test
short-circuit as 'already-parsed' and fail. Fix by making the existence check
return empty for the pre-existing tests: change the default mock (or use
`queryMock.mockImplementation`) so a query whose SQL contains
`FROM messages WHERE report_id` resolves to `{ rows: [] }` while INSERT ...
RETURNING calls still resolve to `{ rows: [{ id: 'message-uuid-1' }] }`. Then
add ONE new test: existence check returns a row → assert result equals
`{ stored: false, reason: 'already-parsed' }`, and assert getAttachmentsMock
was NOT called and no `INSERT INTO messages` call occurred (reuse the existing
`callsContaining` helper).
DEFECT 2 (retry on update), in webhook-service.ts: add a new private method
`retryPhishingParseOnUpdate(payload: AutotaskWebhookPayload): Promise<void>`
that: (1) SELECTs `id::text, company_id` from `reports WHERE ticket_id = $1`
using `payload.entityId`; return if no row (not a flagged phishing ticket).
(2) SELECTs `id FROM messages WHERE report_id = $1 LIMIT 1`; return if a row
exists (already parsed). (3) calls `getCompanyAutomationGate(company_id)` and
returns unless `gate.autoParse` is true. (4) calls
`parseAndStoreMessage({ reportId, ticketId: Number(payload.entityId) })`
inside its own try/catch (log on error, never throw). Do NOT run detection,
grouping, classify, or report on update — only the missing-EML retry-parse.
Wire it in the fire-and-forget block (lines ~118-126): the existing CREATE
branch keeps calling `triggerWorkflowEngine` + `triggerPhishingDetection`.
Add a sibling branch: when `payload.entityType === WebhookEntityType.TICKETS
&& payload.eventType === WebhookEventType.UPDATE`, call
`this.retryPhishingParseOnUpdate(payload).catch(err => console.error('[WEBHOOK] Phishing retry-parse error:', err))`.
parseAndStoreMessage is now idempotent (Defect 3), so repeated update events
are safe.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx vitest run lib/services/phishing-eml-service.test.ts && npx tsc --noEmit --pretty</automated>
</verify>
<done>parseAndStoreMessage short-circuits with reason 'already-parsed' when a messages row exists; existing eml-service tests pass with the adjusted mock; webhook update events invoke retry-parse only for flagged, unparsed, auto_parse-gated tickets; tsc clean.</done>
</task>
<task type="auto">
<name>Task 3: One-off reclassify script for the 6 stale Seubert campaigns</name>
<files>scripts/reclassify-seubert-campaigns.ts</files>
<read_first>
- lib/services/campaign-classifier.ts lines 451-526 (classifyCampaign signature + ClassifyResult)
- Existing scripts/*.ts for the `postgresClient` + `POSTGRES_HOST=localhost npx tsx` run convention
</read_first>
<action>
Create a one-off ops script (NOT a test, matches scripts/ convention). It must
run AFTER Task 1's allowlist fix is committed (it depends on the new code path).
For the 6 affected tickets [699419, 699421, 699422, 699433, 699435, 699456]:
(1) Look up each campaign_id LIVE at execution time via
`SELECT campaign_id FROM reports WHERE ticket_id = $1` — do NOT hardcode
campaign UUIDs. Skip with a warning if campaign_id is null.
(2) Read the current/latest verdict for that campaign_id:
`SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`
(this is the "before" verdict).
(3) Call `classifyCampaign(campaignId)` (appends a fresh classifications row —
D-02 append-only, correct behavior; the newest row becomes current).
(4) Print a per-ticket line: ticketId, campaignId, before verdict → after
verdict (result.verdict), and confidence.
(5) Print a summary count of how many flipped to USER_AWARENESS.
Guard the campaign UUIDs against duplicates (699421+699422 and 699419+699433
may share a campaign) — dedupe campaign_ids before classifying so a shared
campaign isn't classified twice, but still report per-ticket mapping.
Exit cleanly (process.exit(0)) so the pg pool doesn't hang the process.
</action>
<verify>
<automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | grep -v '^#' | grep -c "scripts/reclassify-seubert-campaigns" | grep -q '^0$'</automated>
</verify>
<done>Script type-checks clean; running `POSTGRES_HOST=localhost npx tsx scripts/reclassify-seubert-campaigns.ts` prints before/after verdicts for all 6 tickets and the flip-count summary. (Live DB run is a human verification step — the automated gate only proves the script compiles.)</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Autotask webhook → webhook-service | Untrusted external POST; already HMAC-verified upstream (middleware/handler). |
| Parsed .eml content → DB | Message content is persisted, never dereferenced (T-16-03 invariant preserved). |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-A19-01 | Tampering | Allowlist data (KNOWN_SIMULATION_SENDERS) | mitigate | Only add exact confirmed domains; keep exact-or-subdomain matching (domainMatchesAllowlist untouched) so suffix-spoof `customer-portal.info.attacker.net` still fails — covered by test. |
| T-A19-02 | Denial of Service | retry-parse on every ticket.update | accept | Retry short-circuits immediately once a messages row exists (Defect 3 guard); no new polling/cron; reuses existing update traffic. Bounded to one parseAndStoreMessage attempt per update, gated on auto_parse. |
| T-A19-03 | Elevation/Repudiation | classifyCampaign append-only history | accept | Reclassification appends new classifications rows (D-02), never overwrites — full audit trail preserved. |
</threat_model>
<verification>
- `npx vitest run lib/services/campaign-classifier.test.ts lib/services/phishing-eml-service.test.ts` passes.
- `npx tsc --noEmit --pretty` is clean.
- `domainMatchesAllowlist` still rejects suffix-spoofed domains.
- No new messages row is created on a second parseAndStoreMessage call for the same report.
- Reclassification script prints before/after verdicts for all 6 tickets.
</verification>
<success_criteria>
- The 3 confirmed KnowBe4 domains classify as USER_AWARENESS via the allowlist short-circuit.
- parseAndStoreMessage is idempotent (returns `already-parsed`, no duplicate messages row).
- ticket.update webhooks retry parsing for flagged, unparsed, auto_parse-gated tickets — with no new cron/queue.
- The 6 Seubert campaigns are reclassified against the corrected allowlist with reported before/after verdicts.
</success_criteria>
<output>
Create `.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-SUMMARY.md` when done.
</output>

View file

@ -0,0 +1,60 @@
---
phase: quick-260717-a19
plan: 01
subsystem: phishing
tags: [phishing, classifier, webhook, eml-parser, allowlist]
key-decisions:
- Added 3 confirmed KnowBe4 lookalike domains (customer-portal.info, cloud-service-care.com, bankonlinesupport.com) to the existing knowbe4 vendor entry rather than creating a new vendor or a URL-pattern-based detector — confirmed via identical /render-template/?csu=X&status_id=n + /tracking/?csu=X&status_id=a URL fingerprint shared with the already-allowlisted it-support.care
- parseAndStoreMessage idempotency guard implemented as a leading existence check (SELECT id FROM messages WHERE report_id) rather than an ON CONFLICT — the guard is also the fix, no schema change needed
- Auto-parse retry reuses existing ticket.update webhook traffic rather than adding a new cron/polling mechanism — bounded to one parseAndStoreMessage attempt per update event, gated on auto_parse and short-circuited by the idempotency guard once evidence exists
key-files:
created:
- scripts/reclassify-seubert-campaigns.ts (one-off ops script, not committed as permanent app code)
modified:
- lib/services/campaign-classifier.ts (KNOWN_SIMULATION_SENDERS knowbe4 domains expanded)
- lib/services/campaign-classifier.test.ts (allowlist coverage for 3 new domains + subdomain + suffix-spoof rejection)
- lib/services/phishing-eml-service.ts (idempotency existence guard in parseAndStoreMessage)
- lib/services/phishing-eml-service.test.ts (mock adjusted for new leading query; new already-parsed test)
- lib/services/webhook-service.ts (retryPhishingParseOnUpdate wired into ticket.update fire-and-forget path)
---
# Quick Task 260717-a19: Fix phishing simulation-vendor allowlist gaps, auto-parse timing race, and parseAndStoreMessage idempotency
**One-liner:** Closed three verified defects in the phishing-triage pipeline (missing KnowBe4 allowlist domains, a silent auto-parse timing race with no retry, and a non-idempotent parse function) discovered while investigating Seubert ticket 699456, then reclassified the 6 campaigns those defects had misclassified — all 6 flipped from UNWANTED to USER_AWARENESS.
## What was done
### Task 1 — Simulation-vendor allowlist gap (Defect 1)
`KNOWN_SIMULATION_SENDERS`'s `knowbe4` vendor entry in `lib/services/campaign-classifier.ts` was extended from `['it-support.care']` to include `customer-portal.info`, `cloud-service-care.com`, and `bankonlinesupport.com`. Confirmed via direct DB inspection: all 8 Seubert phishing reports from the 2026-07-16/17 burst shared an identical URL fingerprint (`/render-template/?csu={token}&status_id=n` + `/tracking/?csu={token}&status_id=a`), differing only by sender domain and token — a platform fingerprint for KnowBe4's rotating lookalike-domain simulation templates. `domainMatchesAllowlist()` (exact-or-subdomain matching) was left untouched — this was a pure data update. Added test coverage for exact match, subdomain match, and suffix-spoof rejection (`customer-portal.info.attacker.net` still correctly fails) on all 3 new domains.
### Task 2 — Idempotency guard (Defect 3) + retry-on-update (Defect 2)
`parseAndStoreMessage()` in `lib/services/phishing-eml-service.ts` now does a leading `SELECT id FROM messages WHERE report_id = $1 LIMIT 1` before any Autotask attachment fetch; if a row already exists it returns `{ stored: false, reason: 'already-parsed' }` immediately, matching the function's existing no-op return shape. This closes the duplicate-`messages`-row risk and is the safety prerequisite for Defect 2.
`webhook-service.ts` gained a new private method `retryPhishingParseOnUpdate()`, wired into the `ticket.update` fire-and-forget path: for a ticket that already has a `reports` row, has no linked `messages` row yet, and whose company has `auto_parse` gated on, it calls `parseAndStoreMessage` once. This closes the timing race where the original `ticket.created` webhook fires `parseAndStoreMessage` within ~1-2 seconds of ticket creation — before Autotask's attachment is reliably available — with no prior retry path. Ticket update events (confirmed via logs to arrive reliably within seconds-to-minutes of every ticket creation) now serve as the natural retry, made safe by the Defect 3 idempotency guard. No new cron/polling infrastructure was added. Detection, grouping, classify, and report stages are unchanged on update events — only the missing-EML retry-parse was added.
Test mock adjustment: `phishing-eml-service.test.ts`'s `beforeEach` default (`queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })`) would have made the new leading existence-check short-circuit every pre-existing happy-path test as `already-parsed`. Fixed by branching the mock so `FROM messages WHERE report_id` queries return `{ rows: [] }` by default while `INSERT ... RETURNING` still resolves normally; added one new test asserting the already-parsed short-circuit (no `getAttachments`/`getAttachmentContent`/`INSERT` calls).
### Task 3 — Reclassify the 6 affected Seubert campaigns
Created `scripts/reclassify-seubert-campaigns.ts` (one-off ops script per repo convention, not permanent app code) that looks up each affected ticket's `campaign_id` live (no hardcoded UUIDs), reads the prior verdict, calls `classifyCampaign()` (D-02 append-only — appends a new `classifications` row, never overwrites), and prints a before → after report. Ran live against Postgres:
| Ticket | Campaign | Before | After | Confidence |
|--------|----------|--------|-------|------------|
| 699419 | a74edbea-4c41-4197-b431-d2c037ae07f1 | UNWANTED | USER_AWARENESS | 1 |
| 699421 | 1d1d778d-cf36-4bc4-bbe8-e11d23c3c190 | UNWANTED | USER_AWARENESS | 1 |
| 699422 | ab6f095d-4199-460d-bded-86d3dfc4bbfe | UNWANTED | USER_AWARENESS | 1 |
| 699433 | db03aaa2-b2a1-4959-b411-608381cd5f8f | UNWANTED | USER_AWARENESS | 1 |
| 699435 | 6a8fdb5c-aee9-4262-b695-7378a22bda83 | UNWANTED | USER_AWARENESS | 1 |
| 699456 | 5cdb31a3-64c9-4237-8edc-7e317f4f614c | UNWANTED | USER_AWARENESS | 1 |
All 6 flipped to USER_AWARENESS. Each ticket mapped to a distinct campaign_id (no dedup collisions in practice, though the script's dedup logic handles the shared-campaign case).
## Verification
- `npx vitest run lib/services/campaign-classifier.test.ts lib/services/phishing-eml-service.test.ts` — 53/53 passing (post-merge re-run by orchestrator)
- `npx tsc --noEmit --pretty` — clean (post-merge re-run by orchestrator)
- Live reclassification run confirmed all 6 target campaigns now correctly read USER_AWARENESS
## Deviations from plan
- The dispatched worktree's branch was initially stale (missing the source files this task needed to modify, per the executor's own report) — corrected via the plan's own worktree-branch-check protocol (hard-reset to the orchestrator-supplied base commit) before starting Task 1. No work was lost; this ran before any task commits.
- **Orchestrator note (not an executor deviation):** this SUMMARY.md file itself was lost when the orchestrator removed the git worktree without first rescuing the uncommitted file (the standard cleanup script's rescue-before-remove step was skipped during a manual cleanup). Reconstructed after the fact from the executor's final report and the actual `git show` output of its three commits — content should be accurate but is not verbatim the original.