Phase 2 — Durable Investigation Core
Goal
Wrap the validated discovery engine in a resumable, idempotent resource that runs outside an HTTP request and survives deploys without duplicating candidates.Domain contract
Create one explicitInvestigation kind for this slice: company_expansion.
States:
draft -> scoping -> planned -> queued -> running -> completed | partial | failed | cancelled
The platform owns a closed plan:
- scope;
- discover (one to three bounded rounds);
- resolve identity and dedupe;
- verify;
- enrich from semantic warehouse operations;
- prioritize;
- synthesize.
Persistence
Add exactly three tables in one forward-only migration:investigations
Parent lifecycle, workspace, creator, optional source thread, objective, status, typed plan JSON,
closed clarification answers, counters, heartbeat, cancellation request, failure reason and
timestamps.
investigation_events
Append-only curated replay with monotonic sequence per investigation. Store a closed kind, a
label_key, and numeric/enum detail only. Never store model reasoning, queries, tool/provider names
or raw result payloads.
investigation_candidates
Persist the existing WebMarketCandidate projection: deterministic candidate id, organization,
signal, geography, published date, evidence ids, independent-source count, status, score, ranking
reasons, warnings and optional resolved entity id.
Unique key: (investigation_id, candidate_id).
Do not create investigation_steps; step state lives in the versioned, runtime-validated plan JSON.
Do not reuse the orphaned WorkSession* or gtm_research_runs schema.
Execution
- Add a dedicated pg-boss queue following
StewardQueueService. - Reallocate the obsolete
workSessionWorkflowconnection-budget slot; do not increase the total connection budget without evidence. - Use compare-and-set for state transitions.
- Persist a step outcome before marking the step
done. - On retry, skip
done/skippedsteps and UPSERT candidates from any re-run step. - Add a reaper for stale queued/running rows following existing cron/state-machine conventions.
- Use
retryLimit: 2only while every step remains idempotent. Pin that invariant in tests/comments. - Reuse
AgentRunsServicefor each model attempt andModelUsageServicewith surfaceinvestigation. Do not write either ledger directly.
Cancellation and partial results
cancel_requested_atis checked between steps and aborts an in-process provider call when possible.- Cancelled and partial runs keep every candidate already persisted.
- A web failure ends web discovery; it must not delete warehouse enrichment or prior rows.
partialis an explicit terminal state with incomplete steps and warnings, not a disguised error.
Tests
- Legal and illegal state transitions.
- Two concurrent starts enqueue once.
- Re-entry from every step.
- Process death after candidate UPSERT but before step completion.
- Replayed step produces no duplicate rows.
- Tenant isolation.
- Stop preserves rows.
- Reaper handles stale heartbeats and leaves fresh jobs alone.
- Entity and migration registration guardrails.
- Architecture guard:
apps/api/src/chat/**andapps/api/src/investigations/**cannot import each other.
Acceptance
Kill the worker while discovery is running, restart it, and observe one terminal investigation with no duplicate or lost persisted candidates. The ordinary Chat test suite must pass without modifying Chat behavior.Rollback
Disable queue startup, removeInvestigationsModule registration, and revert the phase commit.
Tables remain inert until a separate forward-only removal migration is deliberately approved.