diff --git a/docs/architecture.md b/docs/architecture.md
index 411374ef9..cd549fa1e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -79,6 +79,7 @@ src/
│ └── index.ts Mastra singleton
├── server/ Product domain services
│ ├── approvals/ Durable approval logic
+│ ├── blockers/ Durable safety and workflow blockers
│ ├── chat/ Turn runner, Mastra adapter, stream projection
│ ├── projects/ Project and thread service layer
│ ├── targets/ Target inventory and authorization
@@ -132,6 +133,8 @@ Generated evidence and uploads should flow through `src/server/evidence/artifact
The artifact service writes or references object content, records metadata, links evidence to project/thread/target/task/finding scope, computes stable hashes, indexes text evidence for retrieval when possible, and notifies the project UI. Findings link back to evidence artifact IDs instead of relying on chat prose.
+Blockers are canonical project records in the app database. Passive-auth normalization upserts one blocker per project, target, task, and reason, retaining the current report and raw source Artifact IDs. Its suggested actions are review-only labels: projecting a passive signal never retries a tool, broadens network scope, requests approval, or interacts with a target. The in-memory repository remains available only for explicitly isolated tests.
+
Evidence ingestion keeps format-aware segmentation for source symbols, log windows, record-oriented data, and diff hunks, then delegates final chunking to Mastra's document chunker. Vector and GraphRAG queries share one versioned index configuration and preserve canonical citation metadata. Both retrieval tools require project context; thread filtering is optional. When the embedding or chunk/filter schema changes, `pnpm rag:reindex` rebuilds readable artifact vectors into the current index. The v3 artifact index adds LanceDB's flattened metadata columns used by replacement deletes; reindexing succeeds before stale indexes are pruned.
The pinned `@mastra/lance` package carries a local patch for three adapter defects exercised by retrieval tests: camelCase metadata columns must be quoted for DataFusion filters, memory-style indexes must target their table when deleting multiple vectors, and local IVFFlat creation must use LanceDB's IVFFlat implementation. Remove each patch hunk when the corresponding upstream behavior is available in the pinned release.
diff --git a/src/components/chat/WorkspaceDashboard.tsx b/src/components/chat/WorkspaceDashboard.tsx
index e270612a3..3f99b8d33 100644
--- a/src/components/chat/WorkspaceDashboard.tsx
+++ b/src/components/chat/WorkspaceDashboard.tsx
@@ -56,6 +56,14 @@ function getCockpitSignal(cockpit: ResearchCockpitView | null) {
if (!cockpit) {
return null;
}
+ const activeBlockers = cockpit.summary.blockers.total;
+ if (activeBlockers > 0) {
+ return {
+ label: `${activeBlockers} blocker${activeBlockers === 1 ? "" : "s"}`,
+ detail: "Review durable blocker evidence before continuing research.",
+ icon: ,
+ };
+ }
const pendingApprovals = cockpit.summary.approvals.byStatus.pending ?? 0;
if (pendingApprovals > 0) {
return {
@@ -178,6 +186,7 @@ export function WorkspaceDashboard({
targetAuthorizationDecisions,
});
const attackPaths = cockpit?.attackPaths ?? [];
+ const activeBlockers = cockpit?.activeBlockers ?? [];
const completeSignals =
planItems.filter((item) => item.status === "completed").length +
evidenceItems.filter((item) => item.done).length;
@@ -194,6 +203,7 @@ export function WorkspaceDashboard({
const hasDashboardContent =
planItems.length > 0 ||
evidenceItems.length > 0 ||
+ activeBlockers.length > 0 ||
attackPaths.length > 0 ||
Boolean(latestFinding) ||
Boolean(latestApproval);
@@ -308,6 +318,62 @@ export function WorkspaceDashboard({
+ {activeBlockers.length > 0 ? (
+
+
+
+
+
+ Active blockers
+
+
+ Active blockers
+
+
+
+ {activeBlockers.length < (cockpit?.summary.blockers.total ?? 0)
+ ? `${activeBlockers.length} of ${cockpit?.summary.blockers.total}`
+ : activeBlockers.length}{" "}
+ blocker{(cockpit?.summary.blockers.total ?? activeBlockers.length) === 1 ? "" : "s"}
+
+
+
+
+ ) : null}
+
{planItems.length > 0 ? (
};
attackPaths: { total: number; byStatus: Record };
schedulerTasks: { total: number; byStatus: Record };
+ blockers: {
+ total: number;
+ byReason: Record;
+ bySeverity: Record;
+ };
codeReviewRuns?: { total: number; byStatus: Record };
artifacts: {
total: number;
@@ -300,6 +305,22 @@ export type ResearchCockpitView = {
blockerCount: number;
updatedAt: string;
}>;
+ activeBlockers: Array<{
+ id: string;
+ threadId?: string;
+ targetId?: string;
+ taskId?: string;
+ reason: string;
+ title: string;
+ detail: string;
+ severity: "info" | "warning" | "danger";
+ source: string;
+ sourceRefId?: string;
+ summaryArtifactId?: string;
+ evidenceArtifactIds?: string[];
+ nextActions: Array<{ label: string; action: string }>;
+ createdAt: string;
+ }>;
recentCodeReviewRuns?: Array<{
id: string;
threadId?: string;
diff --git a/src/server/blockers/index.ts b/src/server/blockers/index.ts
index 367728758..46f2c2906 100644
--- a/src/server/blockers/index.ts
+++ b/src/server/blockers/index.ts
@@ -1,5 +1,7 @@
export {
+ createDatabaseBlockerRepository,
createInMemoryBlockerRepository,
+ DatabaseBlockerRepository,
InMemoryBlockerRepository,
} from "./repository";
export {
@@ -19,7 +21,7 @@ export type {
ListBlockersQuery,
} from "./types";
-import { createInMemoryBlockerRepository } from "./repository";
+import { createDatabaseBlockerRepository, createInMemoryBlockerRepository } from "./repository";
import { createBlockerService } from "./service";
import type { BlockerService } from "./types";
@@ -27,7 +29,13 @@ let defaultBlockerService: BlockerService | undefined;
export function getDefaultBlockerService(): BlockerService {
if (!defaultBlockerService) {
- defaultBlockerService = createBlockerService(createInMemoryBlockerRepository());
+ // Product runtime must survive restart. Tests keep their existing isolated
+ // repository unless they explicitly exercise the database implementation.
+ const repository =
+ process.env.NODE_ENV === "test"
+ ? createInMemoryBlockerRepository()
+ : createDatabaseBlockerRepository();
+ defaultBlockerService = createBlockerService(repository);
}
return defaultBlockerService;
}
diff --git a/src/server/blockers/repository.ts b/src/server/blockers/repository.ts
index b7285f44a..493165e7d 100644
--- a/src/server/blockers/repository.ts
+++ b/src/server/blockers/repository.ts
@@ -1,86 +1,299 @@
import { createId } from "../../lib/ids";
+import { type Queryable, withDatabase } from "../db/client";
import type {
- BlockerRecord,
- BlockerRepository,
- CreateBlockerInput,
- ListBlockersQuery,
+ BlockerRecord,
+ BlockerRepository,
+ CreateBlockerInput,
+ ListBlockersQuery,
} from "./types";
export class InMemoryBlockerRepository implements BlockerRepository {
- private readonly blockers = new Map();
-
- async create(input: CreateBlockerInput): Promise {
- const now = input.createdAt ?? new Date().toISOString();
- const record: BlockerRecord = {
- id: createId("blocker"),
- projectId: input.projectId,
- reason: input.reason,
- title: input.title,
- detail: input.detail,
- severity: input.severity,
- nextActions: input.nextActions ? [...input.nextActions] : [],
- source: input.source ?? "manual",
- createdAt: now,
- ...(input.threadId ? { threadId: input.threadId } : {}),
- ...(input.targetId ? { targetId: input.targetId } : {}),
- ...(input.taskId ? { taskId: input.taskId } : {}),
- ...(input.sourceRefId ? { sourceRefId: input.sourceRefId } : {}),
- ...(input.metadata ? { metadata: { ...input.metadata } } : {}),
- };
- this.blockers.set(record.id, cloneBlocker(record));
- return cloneBlocker(record);
- }
-
- async get(id: string): Promise {
- const record = this.blockers.get(id);
- return record ? cloneBlocker(record) : undefined;
- }
-
- async list(query: ListBlockersQuery): Promise {
- const items = [...this.blockers.values()].filter((blocker) => {
- if (query.projectId && blocker.projectId !== query.projectId) return false;
- if (query.threadId && blocker.threadId !== query.threadId) return false;
- if (query.targetId && blocker.targetId !== query.targetId) return false;
- if (query.taskId && blocker.taskId !== query.taskId) return false;
- if (query.reason && blocker.reason !== query.reason) return false;
- if (query.resolved !== undefined) {
- const isResolved = Boolean(blocker.resolvedAt);
- if (query.resolved !== isResolved) return false;
- }
- return true;
- });
- items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
- const limit = query.limit ?? items.length;
- return items.slice(0, limit).map(cloneBlocker);
- }
-
- async resolve(id: string, resolvedAt?: string): Promise {
- const record = this.blockers.get(id);
- if (!record) {
- throw new Error(`Blocker ${id} was not found.`);
- }
- const now = resolvedAt ?? new Date().toISOString();
- const updated: BlockerRecord = {
- ...record,
- resolvedAt: now,
- };
- this.blockers.set(updated.id, updated);
- return cloneBlocker(updated);
- }
-
- async listActive(projectId: string): Promise {
- return this.list({ projectId, resolved: false });
- }
+ private readonly blockers = new Map();
+
+ async create(input: CreateBlockerInput): Promise {
+ const now = input.createdAt ?? new Date().toISOString();
+ const record: BlockerRecord = {
+ id: createId("blocker"),
+ projectId: input.projectId,
+ reason: input.reason,
+ title: input.title,
+ detail: input.detail,
+ severity: input.severity,
+ nextActions: input.nextActions ? [...input.nextActions] : [],
+ source: input.source ?? "manual",
+ createdAt: now,
+ ...(input.threadId ? { threadId: input.threadId } : {}),
+ ...(input.targetId ? { targetId: input.targetId } : {}),
+ ...(input.taskId ? { taskId: input.taskId } : {}),
+ ...(input.sourceRefId ? { sourceRefId: input.sourceRefId } : {}),
+ ...(input.metadata ? { metadata: { ...input.metadata } } : {}),
+ };
+ this.blockers.set(record.id, cloneBlocker(record));
+ return cloneBlocker(record);
+ }
+
+ async get(id: string): Promise {
+ const record = this.blockers.get(id);
+ return record ? cloneBlocker(record) : undefined;
+ }
+
+ async list(query: ListBlockersQuery): Promise {
+ const items = [...this.blockers.values()].filter((blocker) => {
+ if (query.projectId && blocker.projectId !== query.projectId)
+ return false;
+ if (query.threadId && blocker.threadId !== query.threadId) return false;
+ if (query.targetId && blocker.targetId !== query.targetId) return false;
+ if (query.taskId && blocker.taskId !== query.taskId) return false;
+ if (query.reason && blocker.reason !== query.reason) return false;
+ if (query.resolved !== undefined) {
+ const isResolved = Boolean(blocker.resolvedAt);
+ if (query.resolved !== isResolved) return false;
+ }
+ return true;
+ });
+ items.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
+ const limit = query.limit ?? items.length;
+ return items.slice(0, limit).map(cloneBlocker);
+ }
+
+ async resolve(id: string, resolvedAt?: string): Promise {
+ const record = this.blockers.get(id);
+ if (!record) {
+ throw new Error(`Blocker ${id} was not found.`);
+ }
+ const now = resolvedAt ?? new Date().toISOString();
+ const updated: BlockerRecord = {
+ ...record,
+ resolvedAt: now,
+ };
+ this.blockers.set(updated.id, updated);
+ return cloneBlocker(updated);
+ }
+
+ async listActive(projectId: string): Promise {
+ return this.list({ projectId, resolved: false });
+ }
}
export function createInMemoryBlockerRepository(): BlockerRepository {
- return new InMemoryBlockerRepository();
+ return new InMemoryBlockerRepository();
+}
+
+type BlockerRow = {
+ id: string;
+ project_id: string;
+ thread_id: string | null;
+ target_id: string | null;
+ task_id: string | null;
+ reason: string;
+ title: string;
+ detail: string;
+ severity: BlockerRecord["severity"];
+ next_actions: unknown;
+ source: string;
+ source_ref_id: string | null;
+ metadata: unknown;
+ created_at: string;
+ resolved_at: string | null;
+};
+
+export class DatabaseBlockerRepository implements BlockerRepository {
+ constructor(private readonly database?: Queryable) {}
+
+ private run(operation: (db: Queryable) => Promise): Promise {
+ return this.database ? operation(this.database) : withDatabase(operation);
+ }
+
+ async create(input: CreateBlockerInput): Promise {
+ return this.run(async (db) => {
+ const id = createId("blocker");
+ const now = input.createdAt ?? new Date().toISOString();
+ const values = [
+ id,
+ input.projectId,
+ input.threadId ?? null,
+ input.targetId ?? null,
+ input.taskId ?? null,
+ input.reason,
+ input.title,
+ input.detail,
+ input.severity,
+ JSON.stringify(input.nextActions ?? []),
+ input.source ?? "manual",
+ input.sourceRefId ?? null,
+ JSON.stringify(input.metadata ?? {}),
+ input.dedupeKey ?? null,
+ now,
+ ];
+ const result = await db.query(
+ `INSERT INTO blockers
+ (id, project_id, thread_id, target_id, task_id, reason, title, detail,
+ severity, next_actions, source, source_ref_id, metadata, dedupe_key,
+ created_at, updated_at)
+ SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9,$10::jsonb,$11,$12,$13::jsonb,$14,$15,$15
+ WHERE ($3::text IS NULL OR EXISTS (
+ SELECT 1 FROM chat_threads WHERE project_id = $2 AND id = $3
+ ))
+ AND ($4::text IS NULL OR EXISTS (
+ SELECT 1 FROM targets WHERE project_id = $2 AND id = $4
+ ))
+ AND ($5::text IS NULL OR EXISTS (
+ SELECT 1 FROM tasks
+ WHERE project_id = $2 AND id = $5
+ AND ($3::text IS NULL OR thread_id = $3)
+ AND ($4::text IS NULL OR metadata->>'targetId' = $4)
+ ))
+ ON CONFLICT (project_id, dedupe_key) DO UPDATE SET
+ thread_id = excluded.thread_id,
+ target_id = excluded.target_id,
+ task_id = excluded.task_id,
+ reason = excluded.reason,
+ title = excluded.title,
+ detail = excluded.detail,
+ severity = excluded.severity,
+ next_actions = excluded.next_actions,
+ source = excluded.source,
+ source_ref_id = excluded.source_ref_id,
+ metadata = excluded.metadata,
+ resolved_at = NULL,
+ updated_at = excluded.updated_at
+ WHERE blockers.thread_id IS NOT DISTINCT FROM excluded.thread_id
+ AND blockers.target_id IS NOT DISTINCT FROM excluded.target_id
+ AND blockers.task_id IS NOT DISTINCT FROM excluded.task_id
+ AND blockers.reason = excluded.reason
+ RETURNING *`,
+ values,
+ );
+ return mapBlockerRow(requireBlockerRow(result.rows[0], "attribution"));
+ });
+ }
+
+ async get(id: string): Promise {
+ return this.run(async (db) => {
+ const result = await db.query(
+ "SELECT * FROM blockers WHERE id = $1 LIMIT 1",
+ [id],
+ );
+ return result.rows[0] ? mapBlockerRow(result.rows[0]) : undefined;
+ });
+ }
+
+ async list(query: ListBlockersQuery): Promise {
+ return this.run(async (db) => {
+ const values: unknown[] = [query.projectId];
+ const where = ["project_id = $1"];
+ const add = (column: string, value: unknown) => {
+ values.push(value);
+ where.push(`${column} = $${values.length}`);
+ };
+ if (query.threadId) add("thread_id", query.threadId);
+ if (query.targetId) add("target_id", query.targetId);
+ if (query.taskId) add("task_id", query.taskId);
+ if (query.reason) add("reason", query.reason);
+ if (query.resolved === true) where.push("resolved_at IS NOT NULL");
+ if (query.resolved === false) where.push("resolved_at IS NULL");
+ values.push(Math.max(0, Math.min(500, Math.trunc(query.limit ?? 100))));
+ const result = await db.query(
+ `SELECT * FROM blockers WHERE ${where.join(" AND ")}
+ ORDER BY created_at ASC, id ASC LIMIT $${values.length}`,
+ values,
+ );
+ return result.rows.map(mapBlockerRow);
+ });
+ }
+
+ async resolve(id: string, resolvedAt?: string): Promise {
+ return this.run(async (db) => {
+ const now = resolvedAt ?? new Date().toISOString();
+ const result = await db.query(
+ "UPDATE blockers SET resolved_at = $2, updated_at = $2 WHERE id = $1 RETURNING *",
+ [id, now],
+ );
+ return mapBlockerRow(requireBlockerRow(result.rows[0], id));
+ });
+ }
+
+ async listActive(projectId: string): Promise {
+ return this.run(async (db) => {
+ const result = await db.query(
+ `SELECT * FROM blockers
+ WHERE project_id = $1 AND resolved_at IS NULL
+ ORDER BY created_at DESC, id ASC`,
+ [projectId],
+ );
+ return result.rows.map(mapBlockerRow);
+ });
+ }
+}
+
+export function createDatabaseBlockerRepository(
+ database?: Queryable,
+): BlockerRepository {
+ return new DatabaseBlockerRepository(database);
+}
+
+function requireBlockerRow(
+ row: BlockerRow | undefined,
+ id: string,
+): BlockerRow {
+ if (!row) throw new Error(`Blocker ${id} was not found.`);
+ return row;
+}
+
+function mapBlockerRow(row: BlockerRow): BlockerRecord {
+ const nextActions = parseJson(row.next_actions, []);
+ const metadata = parseJson(row.metadata, {});
+ return {
+ id: row.id,
+ projectId: row.project_id,
+ reason: row.reason,
+ title: row.title,
+ detail: row.detail,
+ severity: row.severity,
+ nextActions: Array.isArray(nextActions)
+ ? nextActions.filter(isBlockerAction).map((action) => ({ ...action }))
+ : [],
+ source: row.source,
+ createdAt: row.created_at,
+ ...(row.thread_id ? { threadId: row.thread_id } : {}),
+ ...(row.target_id ? { targetId: row.target_id } : {}),
+ ...(row.task_id ? { taskId: row.task_id } : {}),
+ ...(row.source_ref_id ? { sourceRefId: row.source_ref_id } : {}),
+ ...(row.resolved_at ? { resolvedAt: row.resolved_at } : {}),
+ ...(isPlainRecord(metadata) ? { metadata } : {}),
+ };
+}
+
+function isBlockerAction(
+ value: unknown,
+): value is BlockerRecord["nextActions"][number] {
+ return (
+ isPlainRecord(value) &&
+ typeof value.label === "string" &&
+ typeof value.action === "string" &&
+ (value.params === undefined || isPlainRecord(value.params)) &&
+ (value.requiresApproval === undefined ||
+ typeof value.requiresApproval === "boolean")
+ );
+}
+
+function isPlainRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function parseJson(value: unknown, fallback: unknown): unknown {
+ if (typeof value !== "string") return value ?? fallback;
+ try {
+ return JSON.parse(value) as unknown;
+ } catch {
+ return fallback;
+ }
}
function cloneBlocker(blocker: BlockerRecord): BlockerRecord {
- return {
- ...blocker,
- nextActions: blocker.nextActions.map((action) => ({ ...action })),
- ...(blocker.metadata ? { metadata: { ...blocker.metadata } } : {}),
- };
+ return {
+ ...blocker,
+ nextActions: blocker.nextActions.map((action) => ({ ...action })),
+ ...(blocker.metadata ? { metadata: { ...blocker.metadata } } : {}),
+ };
}
diff --git a/src/server/blockers/types.ts b/src/server/blockers/types.ts
index 06075f413..c906f13f9 100644
--- a/src/server/blockers/types.ts
+++ b/src/server/blockers/types.ts
@@ -53,6 +53,8 @@ export type CreateBlockerInput = {
sourceRefId?: string;
createdAt?: string;
metadata?: Record;
+ /** Stable identity for sources that should update one blocker instead of appending duplicates. */
+ dedupeKey?: string;
};
export type ListBlockersQuery = {
diff --git a/src/server/chat/research-cockpit.ts b/src/server/chat/research-cockpit.ts
index 1bc657bb5..16c8a24ef 100644
--- a/src/server/chat/research-cockpit.ts
+++ b/src/server/chat/research-cockpit.ts
@@ -167,6 +167,8 @@ export type ResearchCockpitBlocker = {
severity: BlockerSeverity;
source: string;
sourceRefId?: string;
+ summaryArtifactId?: string;
+ evidenceArtifactIds?: string[];
nextActions: BlockerAction[];
createdAt: string;
};
@@ -433,7 +435,7 @@ async function buildProjectResearchCockpitWithDb(
},
recentFindings,
attackPaths,
- activeBlockers,
+ activeBlockers: activeBlockers.slice(0, 5),
recentSchedulerTasks,
recentValidationPlans,
recentCodeReviewRuns,
@@ -472,7 +474,6 @@ async function readActiveBlockers(
return blockers
.filter((blocker) => !threadId || blocker.threadId === threadId || !blocker.threadId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
- .slice(0, 5)
.map((blocker) => ({
id: blocker.id,
...(blocker.threadId ? { threadId: blocker.threadId } : {}),
@@ -484,11 +485,27 @@ async function readActiveBlockers(
severity: blocker.severity,
source: blocker.source,
...(blocker.sourceRefId ? { sourceRefId: blocker.sourceRefId } : {}),
+ ...readBlockerArtifactLinks(blocker.metadata),
nextActions: blocker.nextActions.map((action) => ({ ...action })),
createdAt: blocker.createdAt,
}));
}
+function readBlockerArtifactLinks(metadata: Record | undefined) {
+ if (!metadata) return {};
+ const summaryArtifactId =
+ typeof metadata.summaryArtifactId === "string" ? metadata.summaryArtifactId : undefined;
+ const evidenceArtifactIds = Array.isArray(metadata.sourceArtifactIds)
+ ? metadata.sourceArtifactIds
+ .filter((value): value is string => typeof value === "string")
+ .slice(0, 12)
+ : [];
+ return {
+ ...(summaryArtifactId ? { summaryArtifactId } : {}),
+ ...(evidenceArtifactIds.length > 0 ? { evidenceArtifactIds } : {}),
+ };
+}
+
function summarizeActiveBlockers(
blockers: ResearchCockpitBlocker[],
): ResearchCockpitBlockerSummary {
diff --git a/src/server/db/migrations/20260827020000_durable_blockers.sql b/src/server/db/migrations/20260827020000_durable_blockers.sql
new file mode 100644
index 000000000..821d6bfe0
--- /dev/null
+++ b/src/server/db/migrations/20260827020000_durable_blockers.sql
@@ -0,0 +1,30 @@
+-- migrate:up
+CREATE TABLE IF NOT EXISTS blockers (
+ id text PRIMARY KEY,
+ project_id text NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ thread_id text REFERENCES chat_threads(id) ON DELETE SET NULL,
+ target_id text REFERENCES targets(id) ON DELETE SET NULL,
+ task_id text REFERENCES tasks(id) ON DELETE SET NULL,
+ reason text NOT NULL,
+ title text NOT NULL,
+ detail text NOT NULL,
+ severity text NOT NULL CHECK (severity IN ('info', 'warning', 'danger')),
+ next_actions text NOT NULL DEFAULT '[]',
+ source text NOT NULL,
+ source_ref_id text,
+ metadata text NOT NULL DEFAULT '{}',
+ dedupe_key text,
+ created_at text NOT NULL,
+ updated_at text NOT NULL,
+ resolved_at text,
+ UNIQUE(project_id, dedupe_key)
+);
+CREATE INDEX IF NOT EXISTS blockers_project_active_idx
+ ON blockers(project_id, resolved_at, created_at, id);
+CREATE INDEX IF NOT EXISTS blockers_target_active_idx
+ ON blockers(project_id, target_id, resolved_at, reason);
+CREATE INDEX IF NOT EXISTS blockers_thread_active_idx
+ ON blockers(project_id, thread_id, resolved_at, created_at);
+
+-- migrate:down
+DROP TABLE IF EXISTS blockers;
diff --git a/src/server/db/postgres-migrate.ts b/src/server/db/postgres-migrate.ts
index 40f847975..bbc42cfc2 100644
--- a/src/server/db/postgres-migrate.ts
+++ b/src/server/db/postgres-migrate.ts
@@ -176,6 +176,7 @@ const APP_TABLES = [
"policy_learning_trajectories",
"policy_trajectory_feedback",
"passive_policy_shadow_records",
+ "blockers",
] as const;
const POSTGRES_JSON_TEXT_EXCEPTIONS = new Set([
// This is a constrained workflow enum, despite sharing a legacy SQLite JSON-column name.
diff --git a/src/server/db/sqlite-schema.ts b/src/server/db/sqlite-schema.ts
index a3781866b..f674aa4c7 100644
--- a/src/server/db/sqlite-schema.ts
+++ b/src/server/db/sqlite-schema.ts
@@ -74,6 +74,7 @@ export const SQLITE_JSON_COLUMNS = new Set([
"raw_diagnostic",
"provenance",
"resources",
+ "next_actions",
]);
export const SQLITE_BOOLEAN_COLUMNS = new Set([
diff --git a/src/server/recon/index.ts b/src/server/recon/index.ts
index ff7643d40..f3b305161 100644
--- a/src/server/recon/index.ts
+++ b/src/server/recon/index.ts
@@ -10,6 +10,11 @@ export {
type NormalizedDiscoveryUrl,
normalizeDiscoveryArtifacts,
} from "./discovery-artifact-normalizer";
+export {
+ describePassiveBlocker,
+ type PersistPassiveAuthBlockersInput,
+ persistPassiveAuthBlockers,
+} from "./passive-auth-blockers";
export {
buildPassiveAuthSurfaceSummary,
type PassiveAuthSurfaceSummary,
diff --git a/src/server/recon/passive-auth-blockers.ts b/src/server/recon/passive-auth-blockers.ts
new file mode 100644
index 000000000..b2bda7e86
--- /dev/null
+++ b/src/server/recon/passive-auth-blockers.ts
@@ -0,0 +1,147 @@
+import type {
+ BlockerAction,
+ BlockerRecord,
+ BlockerService,
+ BlockerSeverity,
+} from "../blockers";
+import type { DiscoveryBlockerReason } from "./discovery-artifact-normalizer";
+import type { PassiveAuthSurfaceSummary } from "./passive-auth-surface";
+
+export type PersistPassiveAuthBlockersInput = {
+ projectId: string;
+ threadId?: string;
+ targetId: string;
+ taskId?: string;
+ summaryArtifactId: string;
+ summary: Pick;
+};
+
+type PassiveBlockerPresentation = {
+ title: string;
+ detail: string;
+ severity: BlockerSeverity;
+ nextActions: BlockerAction[];
+};
+
+export async function persistPassiveAuthBlockers(
+ input: PersistPassiveAuthBlockersInput,
+ blockerService: BlockerService,
+): Promise {
+ const records: BlockerRecord[] = [];
+ for (const signal of input.summary.blockers) {
+ const presentation = describePassiveBlocker(signal.reason, signal.evidence);
+ records.push(
+ await blockerService.record({
+ projectId: input.projectId,
+ ...(input.threadId ? { threadId: input.threadId } : {}),
+ targetId: input.targetId,
+ ...(input.taskId ? { taskId: input.taskId } : {}),
+ reason: signal.reason,
+ ...presentation,
+ source: "passive-auth-surface",
+ sourceRefId: input.summaryArtifactId,
+ dedupeKey: passiveBlockerDedupeKey(input, signal.reason),
+ metadata: {
+ workflow: "passive-auth-surface-v1",
+ summaryArtifactId: input.summaryArtifactId,
+ sourceArtifactIds: [...signal.evidenceArtifactIds].sort(),
+ passiveOnly: true,
+ },
+ }),
+ );
+ }
+ return records;
+}
+
+function passiveBlockerDedupeKey(
+ input: Pick,
+ reason: DiscoveryBlockerReason,
+) {
+ return JSON.stringify([
+ "passive-auth-surface-v1",
+ input.targetId,
+ input.taskId ?? null,
+ reason,
+ ]);
+}
+
+export function describePassiveBlocker(
+ reason: DiscoveryBlockerReason,
+ evidence: string,
+): PassiveBlockerPresentation {
+ const review = (label: string): BlockerAction[] => [
+ { label, action: "queue_action", params: { label } },
+ ];
+ switch (reason) {
+ case "captcha-detected":
+ return {
+ title: "Captcha observed in stored evidence",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review captcha evidence"),
+ };
+ case "bot-block-detected":
+ return {
+ title: "Bot protection observed in stored evidence",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review bot-protection evidence"),
+ };
+ case "waf-denied":
+ return {
+ title: "WAF denial observed in stored evidence",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review WAF evidence"),
+ };
+ case "rate-limited":
+ return {
+ title: "Rate limiting observed in stored evidence",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review rate-limit evidence"),
+ };
+ case "auth-required":
+ return {
+ title: "Authentication is required",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review authentication boundary"),
+ };
+ case "approval-required":
+ return {
+ title: "Approval is required",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review approval requirements"),
+ };
+ case "target-authorization-required":
+ return {
+ title: "Target authorization is required",
+ detail: evidence,
+ severity: "danger",
+ nextActions: review("Review target scope and authorization"),
+ };
+ case "workspace-locked":
+ return {
+ title: "Workspace is locked",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review workspace owner"),
+ };
+ case "network-profile-blocked":
+ return {
+ title: "Network profile blocked the prior work",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review authorized network scope"),
+ };
+ case "tool-unavailable":
+ return {
+ title: "Required tool is unavailable",
+ detail: evidence,
+ severity: "warning",
+ nextActions: review("Review tool availability"),
+ };
+ }
+}
diff --git a/src/server/recon/passive-auth-surface.ts b/src/server/recon/passive-auth-surface.ts
index 88a33be68..fa1e251d5 100644
--- a/src/server/recon/passive-auth-surface.ts
+++ b/src/server/recon/passive-auth-surface.ts
@@ -1,3 +1,8 @@
+import {
+ type BlockerRecord,
+ type BlockerService,
+ getDefaultBlockerService,
+} from "../blockers";
import type {
ArtifactServiceInstance,
CreateArtifactResult,
@@ -9,6 +14,7 @@ import {
type NormalizedDiscovery,
normalizeDiscoveryArtifacts,
} from "./discovery-artifact-normalizer";
+import { persistPassiveAuthBlockers } from "./passive-auth-blockers";
export type PassiveAuthSurfaceSummary = {
targetId: string;
@@ -48,6 +54,7 @@ export type PersistPassiveAuthSurfaceResult = {
normalized: NormalizedDiscovery;
summary: PassiveAuthSurfaceSummary;
artifact: CreateArtifactResult;
+ blockers: BlockerRecord[];
};
type ArtifactWriter = Pick;
@@ -55,6 +62,7 @@ type ArtifactWriter = Pick;
export async function persistPassiveAuthSurface(
input: PersistPassiveAuthSurfaceInput,
artifactWriter: ArtifactWriter,
+ blockerService: BlockerService = getDefaultBlockerService(),
): Promise {
const normalized = normalizeDiscoveryArtifacts(input.artifacts);
const summary = buildPassiveAuthSurfaceSummary(
@@ -85,8 +93,19 @@ export async function persistPassiveAuthSurface(
authCategories: summary.authRoutes.map((route) => route.category),
},
});
+ const blockers = await persistPassiveAuthBlockers(
+ {
+ projectId: input.projectId,
+ ...(input.threadId ? { threadId: input.threadId } : {}),
+ targetId: input.targetId,
+ ...(input.taskId ? { taskId: input.taskId } : {}),
+ summaryArtifactId: artifact.id,
+ summary,
+ },
+ blockerService,
+ );
- return { normalized, summary, artifact };
+ return { normalized, summary, artifact, blockers };
}
export function buildPassiveAuthSurfaceSummary(
diff --git a/src/styles/chat.css b/src/styles/chat.css
index 84f8e7f14..a417a0588 100644
--- a/src/styles/chat.css
+++ b/src/styles/chat.css
@@ -2851,10 +2851,67 @@
.dashboard-finding-content,
.dashboard-empty-panel,
.dashboard-attack-path-list,
-.dashboard-evidence-list {
+.dashboard-evidence-list,
+.dashboard-blocker-list {
min-width: 0;
}
+.dashboard-blocker-list {
+ display: grid;
+ gap: 0.7rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ overflow-y: auto;
+ max-height: 40vh;
+}
+
+.dashboard-blocker-list li {
+ display: grid;
+ gap: 0.35rem;
+ border-left: 2px solid rgb(244 199 106 / 65%);
+ padding-left: 0.65rem;
+}
+
+.dashboard-blocker-list li > div:first-child {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.6rem;
+}
+
+.dashboard-blocker-list p,
+.dashboard-blocker-list small {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+.dashboard-blocker-list p {
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ line-height: 1.45;
+}
+
+.dashboard-blocker-list small {
+ color: var(--text-soft);
+ font-size: 0.7rem;
+}
+
+.dashboard-blocker-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+}
+
+.dashboard-blocker-actions span {
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: rgb(255 255 255 / 4%);
+ color: var(--text-muted);
+ padding: 0.2rem 0.45rem;
+ font-size: 0.68rem;
+}
+
.dashboard-approval-content {
display: grid;
gap: 0.7rem;
diff --git a/tests/integration/discovery-artifact-normalizer.test.ts b/tests/integration/discovery-artifact-normalizer.test.ts
index 0546429ce..f9f42746e 100644
--- a/tests/integration/discovery-artifact-normalizer.test.ts
+++ b/tests/integration/discovery-artifact-normalizer.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
+import { createBlockerService, createInMemoryBlockerRepository } from "../../src/server/blockers";
import {
normalizeDiscoveryArtifacts,
persistPassiveAuthSurface,
@@ -115,6 +116,7 @@ describe("discovery artifact normalization", () => {
],
},
{ createArtifact } as never,
+ createBlockerService(createInMemoryBlockerRepository()),
);
expect(result.summary.authRoutes).toEqual([
@@ -129,6 +131,19 @@ describe("discovery artifact normalization", () => {
reason: "rate-limited",
evidenceArtifactIds: ["artifact-raw"],
});
+ expect(result.blockers).toEqual([
+ expect.objectContaining({
+ projectId: "project-1",
+ threadId: "thread-1",
+ targetId: "target-1",
+ taskId: "task-1",
+ reason: "rate-limited",
+ sourceRefId: "artifact-summary",
+ nextActions: [
+ expect.objectContaining({ action: "queue_action" }),
+ ],
+ }),
+ ]);
expect(createArtifact).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "project-1",
diff --git a/tests/integration/passive-auth-blockers.test.ts b/tests/integration/passive-auth-blockers.test.ts
new file mode 100644
index 000000000..63689b530
--- /dev/null
+++ b/tests/integration/passive-auth-blockers.test.ts
@@ -0,0 +1,301 @@
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import {
+ createBlockerService,
+ createDatabaseBlockerRepository,
+} from "../../src/server/blockers";
+import { getProjectStore } from "../../src/server/chat/projectAdapter";
+import { buildProjectResearchCockpit } from "../../src/server/chat/research-cockpit";
+import { withDatabase } from "../../src/server/db/client";
+import {
+ describePassiveBlocker,
+ persistPassiveAuthBlockers,
+} from "../../src/server/recon";
+import { upsertProjectTarget } from "../../src/server/targets";
+import { upsertResearchTasks } from "../../src/server/tasks/tracker";
+
+describe("durable passive-auth blockers", () => {
+ let databaseRoot: string;
+ let previousDatabaseUrl: string | undefined;
+
+ beforeEach(async () => {
+ previousDatabaseUrl = process.env.EH_APP_DB_URL;
+ databaseRoot = await mkdtemp(join(tmpdir(), "passive-auth-blockers-"));
+ process.env.EH_APP_DB_URL = `sqlite://${join(databaseRoot, "app.sqlite")}`;
+ });
+
+ afterEach(async () => {
+ if (previousDatabaseUrl === undefined) delete process.env.EH_APP_DB_URL;
+ else process.env.EH_APP_DB_URL = previousDatabaseUrl;
+ await rm(databaseRoot, { recursive: true, force: true });
+ });
+
+ it("updates one target-bound blocker and reconstructs it from a fresh service", async () => {
+ const store = await getProjectStore();
+ const project = await store.createProject({
+ name: "Durable passive blockers",
+ });
+ const thread = await store.createThread(project.id, {
+ title: "Map authentication",
+ });
+ await upsertProjectTarget(project.id, {
+ id: "target-app",
+ threadId: thread.id,
+ kind: "web",
+ label: "Authorized app",
+ locator: "https://app.example.test",
+ });
+ await upsertResearchTasks(
+ project.id,
+ [
+ {
+ id: "task-map-auth",
+ threadId: thread.id,
+ targetId: "target-app",
+ title: "Map authentication",
+ },
+ ],
+ { threadId: thread.id },
+ );
+
+ const firstService = createBlockerService(
+ createDatabaseBlockerRepository(),
+ );
+ const baseInput = {
+ projectId: project.id,
+ threadId: thread.id,
+ targetId: "target-app",
+ taskId: "task-map-auth",
+ summary: {
+ blockers: [
+ {
+ reason: "rate-limited" as const,
+ evidenceArtifactIds: ["artifact-source-a"],
+ evidence: "HTTP 429 Too Many Requests",
+ },
+ ],
+ },
+ };
+ const first = await persistPassiveAuthBlockers(
+ { ...baseInput, summaryArtifactId: "artifact-summary-a" },
+ firstService,
+ );
+ const second = await persistPassiveAuthBlockers(
+ {
+ ...baseInput,
+ summaryArtifactId: "artifact-summary-b",
+ summary: {
+ blockers: [
+ {
+ reason: "rate-limited",
+ evidenceArtifactIds: ["artifact-source-a", "artifact-source-b"],
+ evidence: "A later stored response also reports rate limiting",
+ },
+ ],
+ },
+ },
+ firstService,
+ );
+
+ expect(second[0]?.id).toBe(first[0]?.id);
+ const reloaded = await createBlockerService(
+ createDatabaseBlockerRepository(),
+ ).listActive(project.id);
+ expect(reloaded).toEqual([
+ expect.objectContaining({
+ id: first[0]?.id,
+ projectId: project.id,
+ threadId: thread.id,
+ targetId: "target-app",
+ taskId: "task-map-auth",
+ reason: "rate-limited",
+ detail: "A later stored response also reports rate limiting",
+ source: "passive-auth-surface",
+ sourceRefId: "artifact-summary-b",
+ metadata: expect.objectContaining({
+ passiveOnly: true,
+ summaryArtifactId: "artifact-summary-b",
+ sourceArtifactIds: ["artifact-source-a", "artifact-source-b"],
+ }),
+ }),
+ ]);
+ const cockpit = await buildProjectResearchCockpit(project.id, {
+ threadId: thread.id,
+ blockerService: createBlockerService(createDatabaseBlockerRepository()),
+ });
+ expect(cockpit.summary.blockers).toEqual({
+ total: 1,
+ byReason: { "rate-limited": 1 },
+ bySeverity: { warning: 1 },
+ });
+ expect(cockpit.activeBlockers).toEqual([
+ expect.objectContaining({
+ id: first[0]?.id,
+ summaryArtifactId: "artifact-summary-b",
+ evidenceArtifactIds: ["artifact-source-a", "artifact-source-b"],
+ }),
+ ]);
+
+ await upsertProjectTarget(project.id, {
+ id: "a:b",
+ threadId: thread.id,
+ kind: "web",
+ label: "Colon target one",
+ locator: "https://one.example.test",
+ });
+ await upsertProjectTarget(project.id, {
+ id: "a",
+ threadId: thread.id,
+ kind: "web",
+ label: "Colon target two",
+ locator: "https://two.example.test",
+ });
+ await upsertResearchTasks(
+ project.id,
+ [
+ { id: "c", threadId: thread.id, targetId: "a:b", title: "First key" },
+ { id: "b:c", threadId: thread.id, targetId: "a", title: "Second key" },
+ ],
+ { threadId: thread.id },
+ );
+ const collisionInput = {
+ projectId: project.id,
+ threadId: thread.id,
+ summaryArtifactId: "artifact-summary-colon",
+ summary: {
+ blockers: [
+ {
+ reason: "captcha-detected" as const,
+ evidenceArtifactIds: ["artifact-source-colon"],
+ evidence: "Captcha present",
+ },
+ ],
+ },
+ };
+ const colonFirst = await persistPassiveAuthBlockers(
+ { ...collisionInput, targetId: "a:b", taskId: "c" },
+ firstService,
+ );
+ const colonSecond = await persistPassiveAuthBlockers(
+ { ...collisionInput, targetId: "a", taskId: "b:c" },
+ firstService,
+ );
+ expect(colonSecond[0]?.id).not.toBe(colonFirst[0]?.id);
+ expect(
+ await firstService.list({
+ projectId: project.id,
+ reason: "captcha-detected",
+ }),
+ ).toHaveLength(2);
+ });
+
+ it("rejects cross-project thread, target, and task attribution", async () => {
+ const store = await getProjectStore();
+ const project = await store.createProject({ name: "Blocker owner" });
+ const other = await store.createProject({
+ name: "Foreign blocker records",
+ });
+ const foreignThread = await store.createThread(other.id, {
+ title: "Foreign thread",
+ });
+ await upsertProjectTarget(other.id, {
+ id: "foreign-target",
+ threadId: foreignThread.id,
+ kind: "web",
+ label: "Foreign target",
+ locator: "https://foreign.example.test",
+ });
+ await upsertResearchTasks(
+ other.id,
+ [
+ {
+ id: "foreign-task",
+ threadId: foreignThread.id,
+ targetId: "foreign-target",
+ title: "Foreign task",
+ },
+ ],
+ { threadId: foreignThread.id },
+ );
+
+ const service = createBlockerService(createDatabaseBlockerRepository());
+ await expect(
+ service.record({
+ projectId: project.id,
+ threadId: foreignThread.id,
+ targetId: "foreign-target",
+ taskId: "foreign-task",
+ reason: "auth-required",
+ title: "Wrong project",
+ detail: "Must not persist",
+ severity: "warning",
+ dedupeKey: "foreign-attribution",
+ }),
+ ).rejects.toThrow(/attribution/u);
+ expect(await service.listActive(project.id)).toEqual([]);
+ });
+
+ it("does not cap canonical active-blocker totals at the list page size", async () => {
+ const store = await getProjectStore();
+ const project = await store.createProject({ name: "Large blocker ledger" });
+ const service = createBlockerService(createDatabaseBlockerRepository());
+ await withDatabase((db) =>
+ db.query(
+ `WITH RECURSIVE sequence(value) AS (
+ SELECT 0
+ UNION ALL SELECT value + 1 FROM sequence WHERE value < 100
+ )
+ INSERT INTO blockers
+ (id, project_id, reason, title, detail, severity, next_actions, source,
+ metadata, created_at, updated_at)
+ SELECT 'blocker-page-' || value, $1, 'record-' || value,
+ 'Blocker ' || value, 'Durable blocker total regression', 'info',
+ '[]', 'manual', '{}', '2026-08-27T00:00:00.000Z',
+ '2026-08-27T00:00:00.000Z'
+ FROM sequence`,
+ [project.id],
+ ),
+ );
+
+ const cockpit = await buildProjectResearchCockpit(project.id, {
+ blockerService: service,
+ });
+ expect(cockpit.summary.blockers.total).toBe(101);
+ expect(cockpit.activeBlockers).toHaveLength(5);
+ });
+
+ it("maps every normalized signal to review-only next actions", () => {
+ const reasons = [
+ "captcha-detected",
+ "bot-block-detected",
+ "waf-denied",
+ "rate-limited",
+ "auth-required",
+ "approval-required",
+ "target-authorization-required",
+ "workspace-locked",
+ "network-profile-blocked",
+ "tool-unavailable",
+ ] as const;
+
+ for (const reason of reasons) {
+ const blocker = describePassiveBlocker(
+ reason,
+ `Stored evidence for ${reason}`,
+ );
+ expect(blocker.nextActions).toEqual([
+ expect.objectContaining({ action: "queue_action" }),
+ ]);
+ expect(blocker.nextActions).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ action: "approve_request" }),
+ expect.objectContaining({ action: "confirm_target_authorization" }),
+ ]),
+ );
+ }
+ });
+});