-- ARTEX PostgreSQL schema (single source of truth) -- Idempotent: safe to run repeatedly (IF NOT EXISTS / OR REPLACE / DROP TRIGGER IF EXISTS). -- ===================================================================== -- 0. Common: updated_at trigger -- ===================================================================== CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- Safe text→inet conversion: invalid values return NULL instead of raising 22P02. -- assets.ip is free text (the agent / asset API may write a hostname into it), so a -- bare a.ip::inet cast would let one dirty row break the whole company-attribution -- recompute statement. Callers use try_inet(...) IS NULL to find those rows and warn. -- pg_input_is_valid is not used because it needs PG16+; older existing databases must work. CREATE OR REPLACE FUNCTION try_inet(value text) RETURNS inet AS $$ BEGIN RETURN value::inet; EXCEPTION WHEN others THEN RETURN NULL; END; $$ LANGUAGE plpgsql IMMUTABLE STRICT; -- ===================================================================== -- A. Asset layer: companies / assets / company_scope -- ===================================================================== CREATE TABLE IF NOT EXISTS companies ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, nkey TEXT NOT NULL UNIQUE, logo TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_companies_nkey ON companies(nkey); DROP TRIGGER IF EXISTS trg_companies_upd ON companies; CREATE TRIGGER trg_companies_upd BEFORE UPDATE ON companies FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS assets ( id BIGSERIAL PRIMARY KEY, type TEXT NOT NULL CHECK (type IN ( 'root_domain','ip','subdomain','app','service','endpoint' )), company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL, -- explicit: caller/user selected the company; scope: derived from company_scope. -- Existing installations are conservatively migrated as explicit so a scope -- rebuild can never erase a historical manual association. company_source TEXT NOT NULL DEFAULT 'explicit' CHECK (company_source IN ('explicit','scope')), task_ids BIGINT[] NOT NULL DEFAULT '{}', domain TEXT, root_domain TEXT, ip TEXT, c_segment CIDR, port INTEGER CHECK (port BETWEEN 1 AND 65535), icp TEXT, bound_domains TEXT[] NOT NULL DEFAULT '{}', open_ports JSONB[] NOT NULL DEFAULT '{}', record_type TEXT, record_value TEXT[], bundle_id TEXT, app_name TEXT, category TEXT, app_description TEXT, app_icp TEXT, url TEXT, service_type TEXT CHECK (service_type IN ('http','other')), service_name TEXT, favicon_mmh3 TEXT, status_code INTEGER, content_length BIGINT, page_title TEXT, technologies TEXT[] NOT NULL DEFAULT '{}', auth JSONB[] NOT NULL DEFAULT '{}', method TEXT, params JSONB[] NOT NULL DEFAULT '{}', extra JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_root_domain ON assets(domain) WHERE type = 'root_domain'; CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_ip ON assets(ip) WHERE type = 'ip'; CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_subdomain ON assets(domain, COALESCE(record_type,'')) WHERE type = 'subdomain'; CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_app_bundle ON assets(bundle_id) WHERE type = 'app' AND bundle_id IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_app_name ON assets(app_name) WHERE type = 'app' AND bundle_id IS NULL; CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_service_http ON assets(url) WHERE type = 'service' AND service_type = 'http'; CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_service_other ON assets(COALESCE(domain,''), COALESCE(ip,''), port, service_name) WHERE type = 'service' AND service_type = 'other'; CREATE UNIQUE INDEX IF NOT EXISTS uq_av2_endpoint ON assets(url, method) WHERE type = 'endpoint'; CREATE INDEX IF NOT EXISTS idx_av2_company ON assets(company_id) WHERE company_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_av2_company_type ON assets(company_id, type) WHERE company_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_av2_task_ids ON assets USING GIN(task_ids); CREATE INDEX IF NOT EXISTS idx_av2_domain ON assets(domain) WHERE domain IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_av2_root_domain ON assets(root_domain) WHERE root_domain IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_av2_ip ON assets(ip) WHERE ip IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_av2_c_segment ON assets USING GIST(c_segment inet_ops) WHERE c_segment IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_av2_technologies ON assets USING GIN(technologies) WHERE type = 'service'; CREATE INDEX IF NOT EXISTS idx_av2_bound_domains ON assets USING GIN(bound_domains) WHERE type = 'ip'; CREATE INDEX IF NOT EXISTS idx_av2_open_ports ON assets USING GIN(open_ports) WHERE type = 'ip'; CREATE INDEX IF NOT EXISTS idx_av2_last_seen ON assets(last_seen DESC); CREATE INDEX IF NOT EXISTS idx_av2_type_seen ON assets(type, last_seen DESC); ALTER TABLE assets ADD COLUMN IF NOT EXISTS company_source TEXT; UPDATE assets SET company_source = 'explicit' WHERE company_source IS NULL; ALTER TABLE assets ALTER COLUMN company_source SET DEFAULT 'explicit'; ALTER TABLE assets ALTER COLUMN company_source SET NOT NULL; ALTER TABLE assets DROP CONSTRAINT IF EXISTS assets_company_source_check; ALTER TABLE assets ADD CONSTRAINT assets_company_source_check CHECK (company_source IN ('explicit','scope')); DROP TRIGGER IF EXISTS trg_av2_upd ON assets; CREATE TRIGGER trg_av2_upd BEFORE UPDATE ON assets FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS company_scope ( id BIGSERIAL PRIMARY KEY, company_id BIGINT NOT NULL REFERENCES companies(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('domain','ip','cidr','icp','keyword')), domain TEXT, net CIDR, value TEXT, raw TEXT NOT NULL, reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_sv2_domain UNIQUE (company_id, domain), CONSTRAINT uq_sv2_net UNIQUE (company_id, net), CONSTRAINT ck_company_scope_payload CHECK ( (kind = 'domain' AND domain IS NOT NULL AND net IS NULL AND value IS NULL) OR (kind IN ('ip','cidr') AND domain IS NULL AND net IS NOT NULL AND value IS NULL) OR (kind IN ('icp','keyword') AND domain IS NULL AND net IS NULL AND value IS NOT NULL) ) ); -- Existing installations need the new text payload and expanded kind check. ALTER TABLE company_scope ADD COLUMN IF NOT EXISTS value TEXT; ALTER TABLE company_scope DROP CONSTRAINT IF EXISTS company_scope_kind_check; ALTER TABLE company_scope ADD CONSTRAINT company_scope_kind_check CHECK (kind IN ('domain','ip','cidr','icp','keyword')); ALTER TABLE company_scope DROP CONSTRAINT IF EXISTS ck_company_scope_payload; ALTER TABLE company_scope ADD CONSTRAINT ck_company_scope_payload CHECK ( (kind = 'domain' AND domain IS NOT NULL AND net IS NULL AND value IS NULL) OR (kind IN ('ip','cidr') AND domain IS NULL AND net IS NOT NULL AND value IS NULL) OR (kind IN ('icp','keyword') AND domain IS NULL AND net IS NULL AND value IS NOT NULL) ); CREATE INDEX IF NOT EXISTS idx_sv2_domain ON company_scope(domain) WHERE kind = 'domain'; CREATE INDEX IF NOT EXISTS idx_sv2_net ON company_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr'); CREATE UNIQUE INDEX IF NOT EXISTS uq_sv2_value ON company_scope(company_id, kind, value) WHERE kind IN ('icp','keyword'); CREATE INDEX IF NOT EXISTS idx_sv2_icp ON company_scope(value) WHERE kind = 'icp'; CREATE INDEX IF NOT EXISTS idx_sv2_company ON company_scope(company_id); -- ===================================================================== -- B. Reasoning exploration layer -- ===================================================================== CREATE TABLE IF NOT EXISTS explorations ( id BIGSERIAL PRIMARY KEY, description TEXT, goal TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','achieved','failed')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- cold-digest (§2.3): per-task planner round counter — bumped once each time the -- planner wakes and processes a round. Drives the ≥R cold-node debounce (measured in -- this exploration's own rounds, not global node ids or wall-clock). ALTER TABLE explorations ADD COLUMN IF NOT EXISTS round_no BIGINT NOT NULL DEFAULT 0; DROP TRIGGER IF EXISTS trg_exp_upd ON explorations; CREATE TRIGGER trg_exp_upd BEFORE UPDATE ON explorations FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS exploration_nodes ( id BIGSERIAL PRIMARY KEY, exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE, kind TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}', priority INT NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'open', origin TEXT, owner TEXT, blocked_reason TEXT, delete_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest')), CONSTRAINT ck_node_state CHECK ( (kind='begin' AND state IN ('open')) OR (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped','deleted')) OR (kind='goal' AND state IN ('open','met','abandoned')) OR (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR (kind='finding' AND state IN ('confirmed','dismissed')) OR (kind='hint' AND state IN ('active','consumed')) OR (kind='digest' AND state IN ('active','superseded')) ) ); ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS blocked_reason TEXT; -- Intent soft delete: when state='deleted', delete_reason records the user-provided deletion reason. ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS delete_reason TEXT; -- cold-digest (§2.3/§5.3): content_version bumps on any change that could alter a -- digest body (summary/state/confidence); cold_since_round stamps the planner round -- a node most recently went from "has a live downstream branch" to none (NULL = hot). ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS content_version INT NOT NULL DEFAULT 0; ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS cold_since_round BIGINT; -- Intent claim lease: unclaim_token is the fencing token of the current claim (fresh -- per claim, cleared when the claim is settled or taken over); last_heartbeat_at is -- the claimer's latest heartbeat. A running intent whose heartbeat is older than -- db.IntentClaimTTL may be taken over. Both are NULL on rows claimed before these -- columns existed, which counts as expired. ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS last_heartbeat_at TIMESTAMPTZ; ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS unclaim_token TEXT; -- ck_node_kind: existing installs predate the 'digest' kind — recreate to allow it. DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conrelid='exploration_nodes'::regclass AND conname='ck_node_kind' AND pg_get_constraintdef(oid) NOT LIKE '%digest%' ) THEN ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_kind; ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest')); END IF; END $$; -- ck_node_state: recreate when it lacks the 'paused' (older), 'superseded' (digest rev), -- or 'deleted' (intent soft-delete rev) branches. DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint WHERE conrelid='exploration_nodes'::regclass AND conname='ck_node_state' AND (pg_get_constraintdef(oid) NOT LIKE '%paused%' OR pg_get_constraintdef(oid) NOT LIKE '%superseded%' OR pg_get_constraintdef(oid) NOT LIKE '%deleted%') ) THEN ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_state; ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_state CHECK ( (kind='begin' AND state IN ('open')) OR (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped','deleted')) OR (kind='goal' AND state IN ('open','met','abandoned')) OR (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR (kind='finding' AND state IN ('confirmed','dismissed')) OR (kind='hint' AND state IN ('active','consumed')) OR (kind='digest' AND state IN ('active','superseded')) ); END IF; END $$; CREATE INDEX IF NOT EXISTS idx_expnodes_part ON exploration_nodes(exploration_id, kind); CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC) WHERE kind='intent' AND state='open'; DROP TRIGGER IF EXISTS trg_expnodes_upd ON exploration_nodes; CREATE TRIGGER trg_expnodes_upd BEFORE UPDATE ON exploration_nodes FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS exploration_edges ( exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE, src_id BIGINT NOT NULL REFERENCES exploration_nodes(id) ON DELETE CASCADE, dst_id BIGINT NOT NULL REFERENCES exploration_nodes(id) ON DELETE CASCADE, rel TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (exploration_id, src_id, rel, dst_id), CONSTRAINT ck_edge_noself CHECK (src_id <> dst_id), CONSTRAINT ck_edge_rel CHECK (rel IN ('spawns','derived_from','yields','proves','covers')) ); CREATE INDEX IF NOT EXISTS idx_expedges_src ON exploration_edges(src_id, rel); CREATE INDEX IF NOT EXISTS idx_expedges_dst ON exploration_edges(dst_id, rel); -- cold-digest (§1): the 'covers' relation (digest→member) postdates shipped installs, -- whose rel CHECK is an inline auto-named constraint. Find and recreate it as ck_edge_rel. DO $$ DECLARE cname text; BEGIN SELECT conname INTO cname FROM pg_constraint WHERE conrelid='exploration_edges'::regclass AND contype='c' AND pg_get_constraintdef(oid) LIKE '%rel%' AND pg_get_constraintdef(oid) NOT LIKE '%covers%'; IF cname IS NOT NULL THEN EXECUTE 'ALTER TABLE exploration_edges DROP CONSTRAINT '||quote_ident(cname); ALTER TABLE exploration_edges ADD CONSTRAINT ck_edge_rel CHECK (rel IN ('spawns','derived_from','yields','proves','covers')); END IF; END $$; CREATE TABLE IF NOT EXISTS exploration_anchors ( node_id BIGINT NOT NULL REFERENCES exploration_nodes(id) ON DELETE CASCADE, asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, PRIMARY KEY (node_id, asset_id) ); CREATE INDEX IF NOT EXISTS idx_anchor_asset ON exploration_anchors(asset_id); -- task_constraints: operator-authored operation constraints (allow/deny) for a task. -- Extracted by the goals decomposer at round 0 (from goal/description), editable at -- runtime by the main agent + the overview "Constraint Management" page. Injected into the planner/worker system -- prompt each round (config-gated) to keep exploration within the operator's boundary. CREATE TABLE IF NOT EXISTS task_constraints ( id BIGSERIAL PRIMARY KEY, exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('allow','deny')), text TEXT NOT NULL, origin TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_task_constraints_exp ON task_constraints(exploration_id); CREATE TABLE IF NOT EXISTS activity ( id BIGSERIAL PRIMARY KEY, exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE, node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL, worker TEXT, kind TEXT, tool TEXT, tool_use_id TEXT, is_error BOOLEAN NOT NULL DEFAULT false, summary TEXT, detail TEXT, metadata JSONB NOT NULL DEFAULT '{}', input_tokens INTEGER, output_tokens INTEGER, cache_read_tokens INTEGER, cache_write_tokens INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE activity ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'; -- main_seg segments the main-agent session into resettable conversations: a new -- main session bumps the segment so its transcript + activity start clean while the -- task's graph/assets/goal are untouched. NULL == legacy rows == segment 0 (the -- original session). Only worker='mainagent' rows carry it. ALTER TABLE activity ADD COLUMN IF NOT EXISTS main_seg INTEGER; CREATE INDEX IF NOT EXISTS idx_act_node ON activity(exploration_id, node_id, id); CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_id, id); CREATE INDEX IF NOT EXISTS idx_act_tool_call ON activity(exploration_id, tool_use_id, id) WHERE kind IN ('tool_use', 'tool_result'); -- Main/Plan history pages filter by worker (both carry NULL node_id, so idx_act_node -- can't distinguish them); this covers reverse pagination of those sessions. CREATE INDEX IF NOT EXISTS idx_act_worker ON activity(exploration_id, worker, id); -- Main-session pages filter by segment on top of worker='mainagent'; this partial -- index covers reverse pagination within one segment. CREATE INDEX IF NOT EXISTS idx_act_main_seg ON activity(exploration_id, main_seg, id) WHERE worker='mainagent'; -- Task-list polls aggregate result usage and find the latest event repeatedly. -- Cover the token columns for index-only aggregation and the timestamp order for -- per-exploration latest-activity lookups. CREATE INDEX IF NOT EXISTS idx_act_result_usage ON activity(exploration_id) INCLUDE (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens) WHERE kind='result'; CREATE INDEX IF NOT EXISTS idx_act_latest ON activity(exploration_id, created_at DESC); -- main_sessions records the resettable main-agent conversation segments of a task. -- Segment 0 (the original session) is implicit and never stored; this table holds -- only the extra segments created by "New Session" (seq >= 1). The current segment is -- MAX(seq) or 0. Each segment gets its own transcript file + activity slice; the -- task's exploration graph/assets/goal are shared and never reset. CREATE TABLE IF NOT EXISTS main_sessions ( exploration_id BIGINT NOT NULL REFERENCES explorations(id) ON DELETE CASCADE, seq INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (exploration_id, seq) ); -- ===================================================================== -- C. LLM profiles -- ===================================================================== CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS llm_profiles ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses','ollama')), base_url TEXT, proxy TEXT, model TEXT NOT NULL, api_key TEXT, api_key_hint TEXT, rate_per_second DOUBLE PRECISION NOT NULL DEFAULT 0, rate_per_minute DOUBLE PRECISION NOT NULL DEFAULT 0, context_window_k INTEGER NOT NULL DEFAULT 0, -- Thinking params are split into two independent fields: thinking_type=thinking toggle (''/disabled/enabled), -- reasoning_effort=thinking effort (''/low/medium/high/xhigh/max); the two are independent. reasoning_effort TEXT NOT NULL DEFAULT '', thinking_type TEXT NOT NULL DEFAULT '', is_default BOOLEAN NOT NULL DEFAULT false, -- Rotation (failover) parameters, see the LLM rotation design doc: -- priority ordering; larger values are picked earlier; the active profile (is_default) always leads the chain, regardless of this value. -- pool_exclude true=not a failover target (can still be explicitly bound by an agent/task). priority INTEGER NOT NULL DEFAULT 0, pool_exclude BOOLEAN NOT NULL DEFAULT false, -- streaming=true (default) uses streaming SSE; false uses true non-streaming (stream:false, one-shot JSON). streaming BOOLEAN NOT NULL DEFAULT true, -- Output cap (tokens) for a single reply. 0=do not send the field; the server default decides — preserves existing behavior. -- Different from context_window_k (total model capacity, used only locally for the compression threshold): this value is sent with the request. max_tokens INTEGER NOT NULL DEFAULT 0, -- Which request field name carries the output cap; only applies to format='openai': -- '' = max_tokens (default, compatible with most gateways) -- 'max_completion_tokens' = the new field; OpenAI reasoning models (o-series/GPT-5) only accept it, -- sending max_tokens is rejected with unsupported_parameter. -- anthropic (max_tokens required) and openai-responses (max_output_tokens) carry their own field names and are unaffected by this value. max_tokens_field TEXT NOT NULL DEFAULT '', -- Custom session header: when non-empty, every request carries an HTTP header of this name whose value is the current session id -- (chat session/worker intent). For gateways that do prompt caching/sticky routing by a session-id header. ''=do not send. session_header_key TEXT NOT NULL DEFAULT '', -- Custom User-Agent: when non-empty, every outbound LLM request overrides the User-Agent header with this value. ''=do not override (keep the default UA). user_agent TEXT NOT NULL DEFAULT '', -- Retry overrides: attempts 0=use global default / -1=disable / >0=use this value; interval 0=use default exponential backoff / >0=fixed milliseconds. -- The three groups correspond to connect retry, empty-response retry, and same-provider safe-window retry; see the ALTER comment below. retry_connect_attempts INTEGER NOT NULL DEFAULT 0, retry_connect_interval_ms INTEGER NOT NULL DEFAULT 0, retry_empty_attempts INTEGER NOT NULL DEFAULT 0, retry_empty_interval_ms INTEGER NOT NULL DEFAULT 0, retry_stream_attempts INTEGER NOT NULL DEFAULT 0, retry_stream_interval_ms INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS uq_llm_one_default ON llm_profiles(is_default) WHERE is_default; DROP TRIGGER IF EXISTS trg_llm_upd ON llm_profiles; CREATE TRIGGER trg_llm_upd BEFORE UPDATE ON llm_profiles FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- Rotation order/exclusion flags; backfills old databases. Default 0 / false = every profile joins the rotation. ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0; ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false; -- Streaming toggle; backfills old databases. Default true = keep existing streaming behavior; old configs upgrade transparently. ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS streaming BOOLEAN NOT NULL DEFAULT true; -- Relax the format constraint to allow openai-responses (OpenAI Responses API) and -- ollama (local Ollama server, keyless OpenAI-compatible endpoint); backfills old databases. -- Runs at every startup, idempotent: drop the old CHECK first, then create the new four-value CHECK. ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check; ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check CHECK (format IN ('openai','anthropic','openai-responses','ollama')); -- Output cap and its field name; backfills old databases. Defaults 0 / '' = send no cap and keep the max_tokens field name; -- old configs behave exactly as before. ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 0; ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS max_tokens_field TEXT NOT NULL DEFAULT ''; -- Same as format: drop then recreate, keeping every startup idempotent. ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_field_check; ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_field_check CHECK (max_tokens_field IN ('','max_completion_tokens')); ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_check; ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check CHECK (max_tokens >= 0); -- Custom session header name; backfills old databases. Default '' = do not send; old configs behave unchanged. ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT ''; -- Custom User-Agent; backfills old databases. Default '' = do not override (keep the default UA); old configs behave unchanged. ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS user_agent TEXT NOT NULL DEFAULT ''; -- Per-profile retry overrides (see the LLM retry design doc). Each of the three groups is a pair of -- "attempts + fixed interval" with unified semantics: attempts 0=use the global default, -1=disable this layer's -- retry, >0=use this value; interval 0=use this layer's default exponential backoff, >0=use this fixed millisecond -- interval. All default to 0, so old databases/old configs behave as before. -- connect = connect retry (SDK doStream: connection reset/timeout/429/5xx, before the stream starts) -- empty = empty response retry (SDK: completed without any content block, openai format only) -- stream = same-provider safe-window retry (this project's task_llm: replay of a broken stream before output was delivered) ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_connect_attempts INTEGER NOT NULL DEFAULT 0; ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_connect_interval_ms INTEGER NOT NULL DEFAULT 0; ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_empty_attempts INTEGER NOT NULL DEFAULT 0; ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_empty_interval_ms INTEGER NOT NULL DEFAULT 0; ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_stream_attempts INTEGER NOT NULL DEFAULT 0; ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS retry_stream_interval_ms INTEGER NOT NULL DEFAULT 0; -- Same as format: drop then recreate, keeping every startup idempotent. Attempts floor is -1 (disabled); intervals cannot be negative. ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_retry_check; ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_retry_check CHECK ( retry_connect_attempts >= -1 AND retry_empty_attempts >= -1 AND retry_stream_attempts >= -1 AND retry_connect_interval_ms >= 0 AND retry_empty_interval_ms >= 0 AND retry_stream_interval_ms >= 0); -- The thinking toggle field thinking_type was split out of the old single reasoning_effort semantic in one pass. -- schema.sql runs at every startup, so the migration must run only once: backfill only when the column does not -- exist yet, otherwise every startup would overwrite combinations the user set manually later. Old reasoning_effort semantics: -- 'off' → explicitly off → thinking_type='disabled', effort cleared -- 'low/medium/high/max' → on + effort → thinking_type='enabled', effort kept -- '' → not sent → both empty (default) DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = 'llm_profiles' AND column_name = 'thinking_type' ) THEN ALTER TABLE llm_profiles ADD COLUMN thinking_type TEXT NOT NULL DEFAULT ''; UPDATE llm_profiles SET thinking_type = 'enabled' WHERE reasoning_effort IN ('low','medium','high','max'); UPDATE llm_profiles SET thinking_type = 'disabled', reasoning_effort = '' WHERE reasoning_effort = 'off'; END IF; END $$; -- LLM rotation breaker state: after a profile fails repeatedly (insufficient balance / invalid key / rate limited) it -- enters cooldown, and rotation skips it during the cooldown. The in-memory state is authoritative; persisting here -- only keeps the cooldown window across restarts — on load, only rows not yet expired (open_until > now) are taken; -- expired rows naturally return to "normal" and wait for the next call's half-open probe. CREATE TABLE IF NOT EXISTS llm_profile_health ( profile_id BIGINT PRIMARY KEY REFERENCES llm_profiles(id) ON DELETE CASCADE, fails INTEGER NOT NULL DEFAULT 0, -- current consecutive failure count (reset on success) trips INTEGER NOT NULL DEFAULT 0, -- cumulative breaker trips, used for cooldown exponential backoff open_until TIMESTAMPTZ, -- cooldown deadline; NULL/expired = not tripped last_error TEXT NOT NULL DEFAULT '', last_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- ===================================================================== -- D. Task layer -- ===================================================================== -- Global task categories are intentionally independent from task templates. -- Deleting a category only moves its tasks back to the uncategorized bucket. CREATE TABLE IF NOT EXISTS task_categories ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, nkey TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_task_categories_name ON task_categories(name, id); DROP TRIGGER IF EXISTS trg_task_categories_upd ON task_categories; CREATE TRIGGER trg_task_categories_upd BEFORE UPDATE ON task_categories FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS tasks ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL, description TEXT NOT NULL, goal TEXT NOT NULL, exploration_id BIGINT NOT NULL UNIQUE REFERENCES explorations(id) ON DELETE RESTRICT, status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created','running','paused','done','failed','timeout')), paused BOOLEAN NOT NULL DEFAULT false, queued BOOLEAN NOT NULL DEFAULT false, queued_at TIMESTAMPTZ, queue_mode TEXT NOT NULL DEFAULT '', llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, llm_chain_revision BIGINT NOT NULL DEFAULT 0, llm_rotate BOOLEAN NOT NULL DEFAULT false, company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL, parent_ref TEXT, timeout_seconds INTEGER NOT NULL DEFAULT 0, plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300, plan_quiet_mode BOOLEAN NOT NULL DEFAULT true, coverage_enabled BOOLEAN NOT NULL DEFAULT true, pinned_at TIMESTAMPTZ, first_run_at TIMESTAMPTZ, deadline_at TIMESTAMPTZ, archived_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_tasks_alive ON tasks(created_at DESC) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status) WHERE deleted_at IS NULL; DROP TRIGGER IF EXISTS trg_tasks_upd ON tasks; CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- planner heartbeat trigger interval (seconds); backfills old databases. Default 300s (5min). See docs/planner-trigger-impl-plan.md ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_quiet_mode BOOLEAN NOT NULL DEFAULT true; -- Approval gate for asset targets, opt-in per task; backfills old databases. false (default) = no gate, behaviour unchanged. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS target_gate BOOLEAN NOT NULL DEFAULT false; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_rotate BOOLEAN NOT NULL DEFAULT false; -- Concurrency-limit hold state; backfills old databases. true=queued due to the concurrency limit, auto-starts when a slot frees up. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false; -- Asset coverage feature switch; backfills old databases. true (default)=compute/show test coverage, auto-accumulate test scope, -- and open add_task_scope/list_untested_assets to the agent; false=all off (see task_scope.go). -- Existing tasks default to true to keep prior behavior; company associations (task_scope kind=company) are unaffected by this switch. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true; -- queued_at makes admission FIFO reflect the actual enqueue order rather than the -- task creation order. queue_mode distinguishes first bootstrap from resuming an -- exploration that already owns goals/history. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued_at TIMESTAMPTZ; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT ''; -- Pause freeze for the task timeout budget: stamped when a task is paused; on -- admission out of the paused state deadline_at is shifted forward by the paused -- interval (now() - paused_at) so pause time does not consume the timeout (#1). ALTER TABLE tasks ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ; -- Optional task name; backfills old databases. Empty string=unnamed; the frontend falls back to the description. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC) WHERE deleted_at IS NULL AND category_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_tasks_pinned ON tasks(pinned_at DESC) WHERE deleted_at IS NULL AND pinned_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_tasks_archived ON tasks(archived_at DESC) WHERE archived_at IS NOT NULL; -- Cold task archives retain only compact metadata in PostgreSQL. The complete -- task payload lives in a versioned .tar.zst package under data/archives/tasks. -- task_id stays unique so an operation can be retried safely after a restart. CREATE TABLE IF NOT EXISTS task_archives ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL UNIQUE REFERENCES tasks(id) ON DELETE CASCADE, state TEXT NOT NULL DEFAULT 'archive_queued' CHECK (state IN ( 'archive_queued','archiving','archive_failed','ready', 'restore_queued','restoring','restore_failed', 'delete_queued','deleting','delete_failed' )), phase TEXT NOT NULL DEFAULT 'queued', progress INTEGER NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100), error TEXT NOT NULL DEFAULT '', warnings JSONB NOT NULL DEFAULT '[]', format_version INTEGER NOT NULL DEFAULT 2, archive_path TEXT NOT NULL DEFAULT '', sha256 TEXT NOT NULL DEFAULT '', original_size BIGINT NOT NULL DEFAULT 0, compressed_size BIGINT NOT NULL DEFAULT 0, task_name TEXT NOT NULL DEFAULT '', task_description TEXT NOT NULL DEFAULT '', task_goal TEXT NOT NULL DEFAULT '', original_status TEXT NOT NULL DEFAULT '', category_id_snapshot BIGINT, category_name_snapshot TEXT NOT NULL DEFAULT '', source_task_ids BIGINT[] NOT NULL DEFAULT '{}', remaining_timeout_seconds BIGINT NOT NULL DEFAULT 0, data_counts JSONB NOT NULL DEFAULT '{}', aggregate_stats JSONB NOT NULL DEFAULT '{}', archived_at TIMESTAMPTZ, requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE task_archives ALTER COLUMN format_version SET DEFAULT 2; CREATE INDEX IF NOT EXISTS idx_task_archives_state ON task_archives(state, requested_at, id); CREATE INDEX IF NOT EXISTS idx_task_archives_archived ON task_archives(archived_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_task_archives_sources ON task_archives USING GIN(source_task_ids); DROP TRIGGER IF EXISTS trg_task_archives_upd ON task_archives; CREATE TRIGGER trg_task_archives_upd BEFORE UPDATE ON task_archives FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- Reusable task description/goal presets. nkey is the normalized, case-insensitive -- identity used to reject visually equivalent duplicate names. CREATE TABLE IF NOT EXISTS task_templates ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, nkey TEXT NOT NULL UNIQUE, description TEXT NOT NULL, goal TEXT NOT NULL, -- Preset task category; cleared when the category is deleted (same as tasks.category_id, non-blocking). category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL, -- Preset snapshot of task-level intercept/allow rules (AssetInterceptRuleInput array); poured into the new task when the template is applied. intercept_rules JSONB NOT NULL DEFAULT '[]', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Backfills old databases (already released; add columns with IF NOT EXISTS). ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL; ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS intercept_rules JSONB NOT NULL DEFAULT '[]'; CREATE INDEX IF NOT EXISTS idx_task_templates_updated ON task_templates(updated_at DESC, id DESC); DROP TRIGGER IF EXISTS trg_task_templates_upd ON task_templates; CREATE TRIGGER trg_task_templates_upd BEFORE UPDATE ON task_templates FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- Direct, read-only task context inheritance. Relations are intentionally not -- recursive: a task sees only the source tasks explicitly chosen at creation. CREATE TABLE IF NOT EXISTS task_relations ( task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, source_task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (task_id, source_task_id), CONSTRAINT ck_task_relation_not_self CHECK (task_id <> source_task_id) ); CREATE INDEX IF NOT EXISTS idx_task_relations_source ON task_relations(source_task_id); -- Task/asset provenance supplements the legacy assets.task_ids association. The -- array remains the compatibility source for existing query and cleanup paths; -- this relation records how each association was obtained for operator review. CREATE TABLE IF NOT EXISTS task_asset_links ( task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, source TEXT NOT NULL DEFAULT 'system', source_summary TEXT NOT NULL DEFAULT '', source_node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (task_id, asset_id) ); CREATE INDEX IF NOT EXISTS idx_task_asset_links_asset ON task_asset_links(asset_id, task_id); CREATE INDEX IF NOT EXISTS idx_task_asset_links_node ON task_asset_links(source_node_id) WHERE source_node_id IS NOT NULL; DROP TRIGGER IF EXISTS trg_task_asset_links_upd ON task_asset_links; CREATE TRIGGER trg_task_asset_links_upd BEFORE UPDATE ON task_asset_links FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- Keep provenance rows synchronized when existing asset upsert paths append or -- remove task ids. Detailed callers overwrite the generic source after upsert. CREATE OR REPLACE FUNCTION sync_task_asset_links() RETURNS trigger AS $$ BEGIN INSERT INTO task_asset_links(task_id, asset_id, source, source_summary) SELECT task.id, NEW.id, 'system', 'auto-associated during task execution' FROM unnest(NEW.task_ids) AS requested(task_id) JOIN tasks task ON task.id=requested.task_id AND task.deleted_at IS NULL ON CONFLICT (task_id, asset_id) DO NOTHING; DELETE FROM task_asset_links link WHERE link.asset_id=NEW.id AND NOT (link.task_id=ANY(NEW.task_ids)); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_assets_task_links ON assets; CREATE TRIGGER trg_assets_task_links AFTER INSERT OR UPDATE OF task_ids ON assets FOR EACH ROW EXECUTE FUNCTION sync_task_asset_links(); -- Existing installations receive an auditable legacy source without rewriting -- task_ids. Ignore stale array ids that no longer resolve to a live task. INSERT INTO task_asset_links(task_id, asset_id, source, source_summary) SELECT task.id, asset.id, 'legacy', 'migrated from historical task-asset associations' FROM assets asset CROSS JOIN LATERAL unnest(asset.task_ids) AS requested(task_id) JOIN tasks task ON task.id=requested.task_id AND task.deleted_at IS NULL ON CONFLICT (task_id, asset_id) DO NOTHING; -- Ordered task-level LLM failover chain. A quota-exhausted entry is skipped -- until the user saves/resets the chain, which clears all failure state. CREATE TABLE IF NOT EXISTS task_llm_profiles ( task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, profile_id BIGINT NOT NULL REFERENCES llm_profiles(id) ON DELETE CASCADE, position INTEGER NOT NULL CHECK (position >= 0), status TEXT NOT NULL DEFAULT 'ready' CHECK (status IN ('ready','quota_exhausted')), last_error TEXT, exhausted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (task_id, profile_id), UNIQUE (task_id, position) ); CREATE INDEX IF NOT EXISTS idx_task_llm_profiles_order ON task_llm_profiles(task_id, position); CREATE INDEX IF NOT EXISTS idx_task_llm_profiles_profile ON task_llm_profiles(profile_id, task_id); CREATE INDEX IF NOT EXISTS idx_tasks_llm_profile ON tasks(llm_profile_id) WHERE llm_profile_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_tasks_active_llm_profile ON tasks(active_llm_profile_id) WHERE active_llm_profile_id IS NOT NULL; DROP TRIGGER IF EXISTS trg_task_llm_profiles_upd ON task_llm_profiles; CREATE TRIGGER trg_task_llm_profiles_upd BEFORE UPDATE ON task_llm_profiles FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- One-time-compatible backfill: old pinned tasks become one-entry chains. A user -- can still clear the chain later because the update path also clears the legacy -- llm_profile_id column, preventing this block from re-adding it on restart. INSERT INTO task_llm_profiles(task_id, profile_id, position) SELECT t.id, t.llm_profile_id, 0 FROM tasks t WHERE t.llm_profile_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id) ON CONFLICT DO NOTHING; UPDATE tasks t SET active_llm_profile_id = t.llm_profile_id WHERE t.active_llm_profile_id IS NULL AND t.llm_profile_id IS NOT NULL AND EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id AND x.profile_id=t.llm_profile_id); -- Task test scope (the asset-coverage denominator; coverage scope, not enforced). -- Auto-filled (source='auto'): insertAssets' top level adds a conservative scope per asset type explicitly -- inserted by the worker (root_domain→root_domain, subdomain/service/endpoint→subdomain(host), ip→ip); -- side-effect-derived assets do not enter the scope (the hook sits at the handler top level; derivation is internal to the db layer). -- Agent-filled (source='agent'): add_task_scope adds company/root_domain/subdomain/ip. -- Coverage = among assets matching active rows (denominator), the share anchored by fact nodes (numerator). -- An auto row is active only under a declared (non-auto) domain or IP root of the same task (declaredScopeFilter in task_scope.go). CREATE TABLE IF NOT EXISTS task_scope ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, kind TEXT NOT NULL CHECK (kind IN ('company','root_domain','subdomain','ip','cidr','icp','keyword')), company_id BIGINT REFERENCES companies(id) ON DELETE CASCADE, -- kind='company' domain TEXT, -- root_domain / subdomain net CIDR, -- ip / cidr value TEXT, -- icp / keyword source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','agent','manual')), reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Old database upgrade: expand task scope to match the company scope's single-textbox recognition capability. ALTER TABLE task_scope ADD COLUMN IF NOT EXISTS value TEXT; ALTER TABLE task_scope DROP CONSTRAINT IF EXISTS task_scope_kind_check; ALTER TABLE task_scope ADD CONSTRAINT task_scope_kind_check CHECK (kind IN ('company','root_domain','subdomain','ip','cidr','icp','keyword')); -- Dedup: the same scope entry is stored only once per task (auto-fill bulk inserts rely on it for idempotence). DROP INDEX IF EXISTS uq_task_scope; CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope_v2 ON task_scope( task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0), COALESCE(value,'')); CREATE INDEX IF NOT EXISTS idx_ts_domain ON task_scope(domain) WHERE kind IN ('root_domain','subdomain'); CREATE INDEX IF NOT EXISTS idx_ts_net ON task_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr'); CREATE INDEX IF NOT EXISTS idx_ts_company ON task_scope(company_id) WHERE kind = 'company'; -- ===================================================================== -- E. Agents / prompt templates / variable catalog -- ===================================================================== CREATE TABLE IF NOT EXISTS agents ( id BIGSERIAL PRIMARY KEY, key TEXT NOT NULL UNIQUE CHECK (key ~ '^[a-z][a-z0-9_]*$'), name TEXT NOT NULL, description TEXT, role TEXT NOT NULL, builtin BOOLEAN NOT NULL DEFAULT true, enabled BOOLEAN NOT NULL DEFAULT true, llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, current_prompt_id BIGINT, max_turns INTEGER NOT NULL DEFAULT 0, run_seconds INTEGER NOT NULL DEFAULT 1200, web_search BOOLEAN NOT NULL DEFAULT false, interactive_shell BOOLEAN NOT NULL DEFAULT false, wrapup_prompt TEXT NOT NULL DEFAULT '', wrapup_max_turns INTEGER NOT NULL DEFAULT 0, task_timeout_wrapup_prompt TEXT NOT NULL DEFAULT '', task_timeout_wrapup_max_turns INTEGER NOT NULL DEFAULT 0, trigger_run_mode TEXT NOT NULL DEFAULT 'serial' CHECK (trigger_run_mode IN ('serial','parallel')), trigger_merge_mode TEXT NOT NULL DEFAULT 'all' CHECK (trigger_merge_mode IN ('by_task','all','none')), trigger_max_parallel INTEGER NOT NULL DEFAULT 5, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT agents_role_ck CHECK (role IN ('goals','main','planner','worker','assistant')) ); -- Column-add migration (already released; old databases get the columns; new databases already have them in CREATE. -- The migration carries no CHECK: existing rows stay safe + backend write whitelist is the fallback). ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_run_mode TEXT NOT NULL DEFAULT 'serial'; ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_merge_mode TEXT NOT NULL DEFAULT 'all'; ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_max_parallel INTEGER NOT NULL DEFAULT 5; -- per-agent LLM binding (agent-level default model): the column has been in the CREATE above since the first version; -- this ALTER is only a fallback for very old databases (idempotent). ALTER TABLE agents ADD COLUMN IF NOT EXISTS llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL; -- run_seconds per-run wall-clock default 600→1200: only the column default changes (affects future inserts); existing rows untouched. ALTER TABLE agents ALTER COLUMN run_seconds SET DEFAULT 1200; CREATE INDEX IF NOT EXISTS idx_agents_llm_profile ON agents(llm_profile_id) WHERE llm_profile_id IS NOT NULL; DROP TRIGGER IF EXISTS trg_agents_upd ON agents; CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS agent_prompts ( id BIGSERIAL PRIMARY KEY, agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, version INT NOT NULL, template_text TEXT NOT NULL, note TEXT, updated_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (agent_id, version) ); -- Circular foreign key: agents.current_prompt_id → agent_prompts.id (must be added after both tables exist) DO $$ BEGIN ALTER TABLE agents ADD CONSTRAINT fk_agents_curprompt FOREIGN KEY (current_prompt_id) REFERENCES agent_prompts(id) ON DELETE SET NULL; EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE TABLE IF NOT EXISTS agent_prompt_vars ( id BIGSERIAL PRIMARY KEY, agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, var_name TEXT NOT NULL, description TEXT, example TEXT, source TEXT NOT NULL CHECK (source IN ('exploration','runtime','distilled')), UNIQUE (agent_id, var_name) ); -- ===================================================================== -- F. MCP servers -- ===================================================================== CREATE TABLE IF NOT EXISTS mcp_servers ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, transport TEXT NOT NULL CHECK (transport IN ('stdio','http','sse')), command TEXT, args JSONB NOT NULL DEFAULT '[]', env JSONB NOT NULL DEFAULT '{}', url TEXT, enabled BOOLEAN NOT NULL DEFAULT true, insecure BOOLEAN NOT NULL DEFAULT false, -- http: skip TLS certificate verification (self-signed cert scenario, issue #108) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Allow legacy MCP SSE servers on databases created before SSE support. ALTER TABLE mcp_servers DROP CONSTRAINT IF EXISTS mcp_servers_transport_check; ALTER TABLE mcp_servers ADD CONSTRAINT mcp_servers_transport_check CHECK (transport IN ('stdio','http','sse')); -- Backfill column for old databases (schema.sql runs Exec at every startup). ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS insecure BOOLEAN NOT NULL DEFAULT false; -- Per-server request timeout for remote MCP transports, in seconds (default 120). Analysis -- servers whose tools legitimately run minutes (e.g. binary reverse engineering) need room -- beyond the default without forcing every other server to wait as long. ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS timeout_seconds INT NOT NULL DEFAULT 120; -- Connect health: updated on every connect attempt (discovery, refresh, per-agent-run -- wiring). NULL = never attempted. The WHERE-deduped update avoids write amplification. ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS last_connect_at TIMESTAMPTZ; ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS last_connect_ok BOOLEAN; ALTER TABLE mcp_servers ADD COLUMN IF NOT EXISTS last_connect_err TEXT; DROP TRIGGER IF EXISTS trg_mcp_upd ON mcp_servers; CREATE TRIGGER trg_mcp_upd BEFORE UPDATE ON mcp_servers FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- Default data-source placeholder: the ScopeSentry asset sync MCP (address and auth left empty, disabled). -- Lets the "Asset Sync" page detect whether a data source is configured; the user fills in url and X-API-Key on the page before enabling it. -- Inserted only when missing; never overwrites a server the user has configured/enabled (schema.sql runs Exec at every startup). INSERT INTO mcp_servers (name, transport, url, env, enabled) VALUES ('ScopeSentry', 'http', NULL, '{"X-API-Key":""}', false) ON CONFLICT (name) DO NOTHING; CREATE TABLE IF NOT EXISTS mcp_tools_cache ( id BIGSERIAL PRIMARY KEY, server_id BIGINT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, tool_name TEXT NOT NULL, description TEXT, schema JSONB, discovered_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (server_id, tool_name) ); -- ===================================================================== -- G. Visibility: agent × mcp / skill -- ===================================================================== CREATE TABLE IF NOT EXISTS agent_visibility ( agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, resource_kind TEXT NOT NULL CHECK (resource_kind IN ('mcp')), resource_id BIGINT NOT NULL, mcp_tool_name TEXT NOT NULL DEFAULT '', enabled BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (agent_id, resource_kind, resource_id, mcp_tool_name) ); CREATE INDEX IF NOT EXISTS idx_vis_resource ON agent_visibility(resource_kind, resource_id); CREATE TABLE IF NOT EXISTS agent_skill_visibility ( agent_id BIGINT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, skill_name TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (agent_id, skill_name) ); CREATE INDEX IF NOT EXISTS idx_askv_skill ON agent_skill_visibility(skill_name); -- Skill call ledger (see db/skill_usage.go). One row per Skill() call; dimensions only, no content. -- Deliberately no foreign keys: stats must survive task/conversation deletion (same as llm_usage), and a skill -- itself is just a directory name on the filesystem with no corresponding table. CREATE TABLE IF NOT EXISTS skill_usage ( id BIGSERIAL PRIMARY KEY, ts TIMESTAMPTZ NOT NULL DEFAULT now(), skill TEXT NOT NULL, agent_key TEXT, task_id BIGINT, exploration_id BIGINT, intent_id BIGINT, session_id TEXT, args_len INTEGER NOT NULL DEFAULT 0, -- false = the model named a skill that does not exist (a miss). These rows are kept too: they reflect -- a "wanted but missing" gap and inform which skills to add. found BOOLEAN NOT NULL DEFAULT true ); CREATE INDEX IF NOT EXISTS idx_skill_usage_skill ON skill_usage(skill, ts DESC); CREATE INDEX IF NOT EXISTS idx_skill_usage_task ON skill_usage(task_id); -- Tool call ledger (see db/tool_usage.go). One row per actual CoreTool.Call; attribution dimensions only, -- no tool arguments or return content. Deliberately no foreign keys, so stats survive task, session, or custom tool deletion. CREATE TABLE IF NOT EXISTS tool_usage ( id BIGSERIAL PRIMARY KEY, ts TIMESTAMPTZ NOT NULL DEFAULT now(), tool_key TEXT NOT NULL, agent_key TEXT, task_id BIGINT, exploration_id BIGINT, intent_id BIGINT, session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_tool_usage_tool ON tool_usage(tool_key, ts DESC); CREATE INDEX IF NOT EXISTS idx_tool_usage_task ON tool_usage(task_id); -- ===================================================================== -- H. Built-in tool catalog -- ===================================================================== CREATE TABLE IF NOT EXISTS tools ( key TEXT PRIMARY KEY, system BOOLEAN NOT NULL DEFAULT true, description TEXT NOT NULL DEFAULT '', schema JSONB NOT NULL DEFAULT '{}', agents JSONB NOT NULL DEFAULT '[]', enabled BOOLEAN NOT NULL DEFAULT true, kind TEXT NOT NULL DEFAULT 'builtin', exec JSONB NOT NULL DEFAULT '{}', deferred BOOLEAN NOT NULL DEFAULT false, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_tools_upd ON tools; CREATE TRIGGER trg_tools_upd BEFORE UPDATE ON tools FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- ===================================================================== -- I. Sessions (chat page) -- ===================================================================== CREATE TABLE IF NOT EXISTS conversations ( id BIGSERIAL PRIMARY KEY, agent_key TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, pinned_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ; -- Approval gate for asset targets, opt-in per conversation (a chat has no task). false (default) = no gate. ALTER TABLE conversations ADD COLUMN IF NOT EXISTS target_gate BOOLEAN NOT NULL DEFAULT false; CREATE INDEX IF NOT EXISTS idx_conversations_llm_profile ON conversations(llm_profile_id) WHERE llm_profile_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned_at DESC) WHERE pinned_at IS NOT NULL; DROP TRIGGER IF EXISTS trg_conversations_upd ON conversations; CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS conversation_activities ( id BIGSERIAL PRIMARY KEY, conversation_id BIGINT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, worker TEXT, kind TEXT, tool TEXT, tool_use_id TEXT, is_error BOOLEAN NOT NULL DEFAULT false, summary TEXT, detail TEXT, input_tokens INTEGER, output_tokens INTEGER, cache_read_tokens INTEGER, cache_write_tokens INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_conv_act ON conversation_activities(conversation_id, id); CREATE INDEX IF NOT EXISTS idx_conv_act_tool_call ON conversation_activities(conversation_id, tool_use_id, id) WHERE kind IN ('tool_use', 'tool_result'); -- ===================================================================== -- J. Agent triggers -- ===================================================================== CREATE TABLE IF NOT EXISTS agent_triggers ( id BIGSERIAL PRIMARY KEY, agent_key TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT true, interval_sec INTEGER NOT NULL DEFAULT 0, on_finding BOOLEAN NOT NULL DEFAULT false, on_goal_met BOOLEAN NOT NULL DEFAULT false, on_task_timeout BOOLEAN NOT NULL DEFAULT false, on_tool_call BOOLEAN NOT NULL DEFAULT false, on_task_create BOOLEAN NOT NULL DEFAULT false, interval_message TEXT NOT NULL DEFAULT '', finding_message TEXT NOT NULL DEFAULT '', goal_message TEXT NOT NULL DEFAULT '', task_timeout_message TEXT NOT NULL DEFAULT '', tool_call_message TEXT NOT NULL DEFAULT '', task_create_message TEXT NOT NULL DEFAULT '', tool_names TEXT NOT NULL DEFAULT '', last_fire TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_agent_triggers_agent ON agent_triggers(agent_key); -- Column-add migration (already released; old databases get the columns; new databases already have these columns -- in CREATE, so the ALTERs are no-ops). Idempotent; safe to re-run at every startup. ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS on_tool_call BOOLEAN NOT NULL DEFAULT false; ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS tool_call_message TEXT NOT NULL DEFAULT ''; ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS tool_names TEXT NOT NULL DEFAULT ''; ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS on_task_create BOOLEAN NOT NULL DEFAULT false; ALTER TABLE agent_triggers ADD COLUMN IF NOT EXISTS task_create_message TEXT NOT NULL DEFAULT ''; DROP TRIGGER IF EXISTS trg_agent_triggers_upd ON agent_triggers; CREATE TRIGGER trg_agent_triggers_upd BEFORE UPDATE ON agent_triggers FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS scheduler_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' ); -- ===================================================================== -- K. Interception rules -- ===================================================================== CREATE TABLE IF NOT EXISTS intercept_rules ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT true, priority INTEGER NOT NULL DEFAULT 0, match_target TEXT NOT NULL CHECK (match_target IN ('tool_name', 'tool_input')), match_type TEXT NOT NULL CHECK (match_type IN ('string', 'regex')), pattern TEXT NOT NULL, action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'ask')), message TEXT NOT NULL DEFAULT '', timeout_enabled BOOLEAN NOT NULL DEFAULT true, timeout_seconds INTEGER NOT NULL DEFAULT 60, timeout_action TEXT NOT NULL DEFAULT 'deny', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); DROP TRIGGER IF EXISTS trg_intercept_rules_upd ON intercept_rules; CREATE TRIGGER trg_intercept_rules_upd BEFORE UPDATE ON intercept_rules FOR EACH ROW EXECUTE FUNCTION set_updated_at(); CREATE TABLE IF NOT EXISTS intercept_pending ( id BIGSERIAL PRIMARY KEY, rule_id BIGINT REFERENCES intercept_rules(id) ON DELETE SET NULL, conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE, task_id TEXT, agent_name TEXT NOT NULL DEFAULT '', tool_name TEXT NOT NULL, tool_input JSONB NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'allowed', 'denied', 'timeout')), -- Decision reason: the rule message when a rule hits; the model's short reason (with the [model] prefix) for an LLM fallback decision. reason TEXT NOT NULL DEFAULT '', decided_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_intercept_pending_status ON intercept_pending(status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_intercept_pending_task ON intercept_pending(task_id, created_at DESC); -- Backfills old databases: the reason column (already released; add the column with IF NOT EXISTS). ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT ''; -- Detail payloads are lazy-loaded; NULL preserves the meaning of legacy history. ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS audit JSONB; ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decision_source TEXT NOT NULL DEFAULT ''; UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN 'rule' WHEN reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END WHERE decision_source=''; -- Judge decisions were stored as 'unknown' because the readers expected a capitalized -- prefix while the judge wrote '[model]' (and '[模型]' before e233c02). Idempotent: -- once corrected no row matches the WHERE clause again. UPDATE intercept_pending SET decision_source='model' WHERE decision_source='unknown' AND rule_id IS NULL AND (reason LIKE '[model]%' OR reason LIKE '[模型]%'); -- Actor that settled the request: '' (pending, or decided before this column -- existed) | human | rule | model | system. No backfill: readers fall back to -- decision_source for legacy rows instead of rewriting history. ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decided_by TEXT NOT NULL DEFAULT ''; -- Target approval requests. target_key = ':' of the asset target the row asks for; -- NULL = classic ask (and every row that predates the column). At most one open request per -- scope (task or conversation) and target: concurrent calls to one host share one row. ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS target_key TEXT; CREATE UNIQUE INDEX IF NOT EXISTS uq_intercept_pending_target ON intercept_pending (COALESCE(task_id,''), COALESCE(conversation_id,0), target_key) WHERE status = 'pending' AND target_key IS NOT NULL; -- Target pins: host + port approved by a human for one task or one conversation, with the -- addresses shown at approval time. Written only by the decide endpoint. No foreign keys on -- task_id, conversation_id and intercept_id: a pin outlives its task, its conversation and its -- request row as history (revoked, never deleted). status and revoked_reason carry no CHECK -- (validated in Go, same convention as findings.status). CREATE TABLE IF NOT EXISTS target_pins ( id BIGSERIAL PRIMARY KEY, task_id BIGINT, conversation_id BIGINT, host TEXT NOT NULL, -- normalised name, IP literal or CIDR port INTEGER NOT NULL DEFAULT 0, -- 0 = port not declared: covers every port of the host version INTEGER NOT NULL DEFAULT 1, pinned_ips TEXT[] NOT NULL DEFAULT '{}', -- canonical, sorted resolver TEXT NOT NULL DEFAULT '', resolved_at TIMESTAMPTZ NOT NULL DEFAULT now(), status TEXT NOT NULL DEFAULT 'active', -- active | superseded | revoked intercept_id BIGINT, -- the request row that carried the click decided_by TEXT NOT NULL DEFAULT '', -- approver: subject of the session token decided_from TEXT NOT NULL DEFAULT '', -- remote address of the click -- Approved while the upstream egress proxy was set: the proxy chooses the address, so the -- pin covers the name only and its addresses are not compared. Stays true if the proxy is -- removed later; enforcing addresses again takes a new approval. name_only_effective BOOLEAN NOT NULL DEFAULT false, revoked_at TIMESTAMPTZ, revoked_reason TEXT NOT NULL DEFAULT '', -- operator | task_deleted | conversation_deleted created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX IF NOT EXISTS uq_target_pins_active ON target_pins (COALESCE(task_id,0), COALESCE(conversation_id,0), host, port) WHERE status = 'active'; CREATE INDEX IF NOT EXISTS idx_target_pins_scope ON target_pins (task_id, conversation_id, status); -- Append-only history of the target-gate exemption list (settings key target_gate_exempt_hosts). CREATE TABLE IF NOT EXISTS target_gate_exemption_log ( id BIGSERIAL PRIMARY KEY, changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), changed_by TEXT NOT NULL DEFAULT '', before JSONB, after JSONB, digest TEXT NOT NULL DEFAULT '' ); -- ===================================================================== -- L. Finding persistence -- ===================================================================== CREATE TABLE IF NOT EXISTS findings ( id BIGSERIAL PRIMARY KEY, task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL, node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL, vulnclass TEXT NOT NULL DEFAULT '', -- Vulnerability name (human-readable title); when empty the frontend falls back to vulnclass. severity values: -- critical / high / medium / low (no CHECK; like status, validated by a server-side whitelist). name TEXT NOT NULL DEFAULT '', severity TEXT NOT NULL DEFAULT '', summary TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', worker TEXT NOT NULL DEFAULT '', asset_ids JSONB NOT NULL DEFAULT '[]', -- Disposition status: pending / in_progress / confirmed / resolved / fixed / -- false_positive / ignored / duplicate / risk_accepted. -- No CHECK on the values: old databases rely on the ALTER below to add the column and a CHECK cannot be -- backfilled, so a server-side whitelist validates uniformly. status TEXT NOT NULL DEFAULT 'pending', -- Detailed vulnerability report (Markdown); empty by default, read/shown only on the detail page, kept out of list APIs to avoid payload bloat. report TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'; ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT ''; CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC); -- The "by asset" view looks findings up via asset_ids @> '[]'; without this GIN index that is a full table scan. CREATE INDEX IF NOT EXISTS idx_findings_asset_ids ON findings USING GIN(asset_ids jsonb_path_ops); -- Manual retests are independent sessions; the verdict is stored separately from the original finding's disposition status. CREATE TABLE IF NOT EXISTS finding_retests ( id BIGSERIAL PRIMARY KEY, finding_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, conversation_id BIGINT UNIQUE REFERENCES conversations(id) ON DELETE SET NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','completed','failed','stopped')), verdict TEXT NOT NULL DEFAULT '' CHECK (verdict IN ('','reproduced','fixed','inconclusive')), notes TEXT NOT NULL DEFAULT '', snapshot JSONB NOT NULL, summary TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), started_at TIMESTAMPTZ, finished_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_finding_retests_history ON finding_retests(finding_id, id DESC); CREATE UNIQUE INDEX IF NOT EXISTS idx_finding_retests_active ON finding_retests(finding_id) WHERE status IN ('pending','running'); -- Deleting a conversation keeps the retest record while releasing any unfinished retest occupancy. CREATE OR REPLACE FUNCTION stop_deleted_conversation_retest() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN UPDATE finding_retests SET status='stopped', error='retest session deleted', finished_at=now() WHERE conversation_id=OLD.id AND status IN ('pending','running'); RETURN OLD; END; $$; DROP TRIGGER IF EXISTS trg_conversation_retest_delete ON conversations; CREATE TRIGGER trg_conversation_retest_delete BEFORE DELETE ON conversations FOR EACH ROW EXECUTE FUNCTION stop_deleted_conversation_retest(); ALTER TABLE findings ADD COLUMN IF NOT EXISTS evidence_version BIGINT NOT NULL DEFAULT 0; ALTER TABLE findings ADD COLUMN IF NOT EXISTS report_evidence_version BIGINT NOT NULL DEFAULT 0; -- Machine-checked proof state (S1). Separate from status (human disposition). Existing rows stay -- unverified / POE 0: history is never auto-verified. Values validated server-side, no CHECK. -- verification_status: verified / unverified / blocked / not_assessed / false_positive. ALTER TABLE findings ADD COLUMN IF NOT EXISTS poe_level SMALLINT NOT NULL DEFAULT 0; ALTER TABLE findings ADD COLUMN IF NOT EXISTS verification_status TEXT NOT NULL DEFAULT 'unverified'; ALTER TABLE findings ADD COLUMN IF NOT EXISTS verification_note TEXT NOT NULL DEFAULT ''; -- Reporter-supplied reasoning, both optional (empty = not given): counterevidence is what was -- tested and did not work; confidence_rationale is why the conclusion holds. ALTER TABLE findings ADD COLUMN IF NOT EXISTS counterevidence TEXT NOT NULL DEFAULT ''; ALTER TABLE findings ADD COLUMN IF NOT EXISTS confidence_rationale TEXT NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS traffic_evidence_snapshots ( id TEXT PRIMARY KEY, source_traffic_id TEXT NOT NULL, captured_at BIGINT NOT NULL, url TEXT NOT NULL, method TEXT NOT NULL, status INTEGER NOT NULL, content_type TEXT NOT NULL DEFAULT '', req_head TEXT NOT NULL, resp_head TEXT NOT NULL, req_hash TEXT NOT NULL, resp_hash TEXT NOT NULL, req_len BIGINT NOT NULL, resp_len BIGINT NOT NULL, unreferenced_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS finding_traffic_bindings ( id BIGSERIAL PRIMARY KEY, finding_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, snapshot_id TEXT NOT NULL REFERENCES traffic_evidence_snapshots(id), role TEXT NOT NULL DEFAULT 'supporting', note TEXT NOT NULL DEFAULT '', position INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(finding_id, snapshot_id) ); CREATE INDEX IF NOT EXISTS idx_finding_traffic_order ON finding_traffic_bindings(finding_id, position, id); CREATE INDEX IF NOT EXISTS idx_finding_traffic_snapshot ON finding_traffic_bindings(snapshot_id); -- ===================================================================== -- M. Backend log persistence -- ===================================================================== CREATE TABLE IF NOT EXISTS server_logs ( id BIGSERIAL PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), level TEXT NOT NULL DEFAULT 'info', tag TEXT NOT NULL DEFAULT '', text TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_server_logs_id ON server_logs(id DESC); -- Independent /btw history and the latest provider-ready main checkpoint. CREATE TABLE IF NOT EXISTS side_question_sessions ( session_key TEXT PRIMARY KEY, conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE, task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE, exploration_id BIGINT REFERENCES explorations(id) ON DELETE CASCADE, intent_id BIGINT REFERENCES exploration_nodes(id) ON DELETE CASCADE, run_id BIGINT NOT NULL, version BIGINT NOT NULL, snapshot JSONB NOT NULL, generation BIGINT NOT NULL DEFAULT 0, CHECK ((conversation_id IS NOT NULL AND task_id IS NULL AND exploration_id IS NULL AND intent_id IS NULL) OR (conversation_id IS NULL AND task_id IS NOT NULL AND exploration_id IS NOT NULL)) ); CREATE INDEX IF NOT EXISTS idx_side_sessions_conv ON side_question_sessions(conversation_id); CREATE INDEX IF NOT EXISTS idx_side_sessions_task ON side_question_sessions(task_id); CREATE INDEX IF NOT EXISTS idx_side_sessions_exp ON side_question_sessions(exploration_id); CREATE INDEX IF NOT EXISTS idx_side_sessions_intent ON side_question_sessions(intent_id); CREATE TABLE IF NOT EXISTS side_question_requests ( id TEXT PRIMARY KEY, ordinal BIGSERIAL UNIQUE, session_key TEXT NOT NULL REFERENCES side_question_sessions(session_key) ON DELETE CASCADE, generation BIGINT NOT NULL, client_id TEXT NOT NULL, question TEXT NOT NULL, answer TEXT NOT NULL DEFAULT '', status TEXT NOT NULL CHECK(status IN ('running','completed','failed','cancelled','interrupted')), error TEXT NOT NULL DEFAULT '', model JSONB NOT NULL, snapshot_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), sequence BIGINT NOT NULL DEFAULT 0, usage JSONB NOT NULL DEFAULT '{}', UNIQUE(session_key,generation,client_id) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_side_request_running ON side_question_requests(session_key) WHERE status='running'; CREATE INDEX IF NOT EXISTS idx_side_requests_history ON side_question_requests(session_key,ordinal DESC); -- Additive v3 archive fields; old archives restore these as empty objects. ALTER TABLE side_question_sessions ADD COLUMN IF NOT EXISTS memory JSONB NOT NULL DEFAULT '{}'; ALTER TABLE side_question_requests ADD COLUMN IF NOT EXISTS context_info JSONB NOT NULL DEFAULT '{}'; -- ===================================================================== -- Asset interception rules (global blacklist) -- Independent from §K command interception (intercept_rules): intercept_rules matches tool names/input text, -- while this table matches "target assets" — exact/fuzzy domains, IPs, URLs, and CIDR ranges. -- Rules only; the concrete match/intercept logic is implemented elsewhere. -- Seven kinds: -- exact_domain / exact_ip / exact_url —— exact match -- fuzzy_domain / fuzzy_ip / fuzzy_url —— fuzzy match -- cidr —— CIDR range -- ===================================================================== CREATE TABLE IF NOT EXISTS asset_intercept_rules ( id BIGSERIAL PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT true, kind TEXT NOT NULL CHECK (kind IN ( 'exact_domain', 'exact_ip', 'exact_url', 'fuzzy_domain', 'fuzzy_ip', 'fuzzy_url', 'cidr')), pattern TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', builtin BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_asset_intercept_enabled ON asset_intercept_rules(enabled); DROP TRIGGER IF EXISTS trg_asset_intercept_rules_upd ON asset_intercept_rules; CREATE TRIGGER trg_asset_intercept_rules_upd BEFORE UPDATE ON asset_intercept_rules FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- ===================================================================== -- Task-level asset intercept/allow rules -- Same shape as the global asset_intercept_rules (kind/pattern/note/enabled), but keyed by task_id -- and cascade-deleted with the task; entered at task creation, editable in task details. -- action: 'block'=intercept (forbid testing) 'allow'=allow (whitelist). -- Enforcement: match against interception rules (global ∪ task block) first; a hit forbids the test. -- If nothing hits and the task has enabled allow rules, some allow rule must hit to permit the test, -- otherwise "testing not allowed". -- ===================================================================== CREATE TABLE IF NOT EXISTS task_intercept_rules ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, enabled BOOLEAN NOT NULL DEFAULT true, action TEXT NOT NULL DEFAULT 'block' CHECK (action IN ('block','allow')), kind TEXT NOT NULL CHECK (kind IN ( 'exact_domain', 'exact_ip', 'exact_url', 'fuzzy_domain', 'fuzzy_ip', 'fuzzy_url', 'cidr')), pattern TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_task_intercept_task ON task_intercept_rules(task_id); -- Backfills old databases (an earlier revision created this table without the action column): add the column (with IF NOT EXISTS). -- Per-task test accounts (#3): user-declared (e.g. a program-provided alias -- account) or agent-registered and recorded so they are not forgotten. Passwords -- are local-only, same trust domain as captured traffic evidence; the UI masks -- them by default. CREATE TABLE IF NOT EXISTS task_credentials ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, domain TEXT NOT NULL, username TEXT NOT NULL, password TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'user' CHECK (source IN ('user','agent')), note TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (task_id, domain, username) ); ALTER TABLE task_intercept_rules ADD COLUMN IF NOT EXISTS action TEXT NOT NULL DEFAULT 'block'; DROP TRIGGER IF EXISTS trg_task_intercept_rules_upd ON task_intercept_rules; CREATE TRIGGER trg_task_intercept_rules_upd BEFORE UPDATE ON task_intercept_rules FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- ===================================================================== -- M. Finding IM delivery -- -- The three tables are deliberately separate; the core reason is **blast radius**: the transaction that writes a -- finding (RecordFindingTx, holding the task row lock) is only allowed one blind INSERT — it does not read the -- channel table and does not evaluate the user's filter rules. Otherwise one misconfigured webhook filter could -- pollute/abort the transaction and the finding would never be stored. -- -- notification_channels channel instance config (mutable, holds credentials, UI-managed) -- notification_events event facts (blind-inserted inside the finding-write transaction, with render snapshot) -- notification_deliveries delivery jobs (produced by fan-out outside the transaction, carrying state/retry/batch) -- ===================================================================== -- Channel instance: any number of instances per kind (e.g. one DingTalk bot each for the "incident group" and the "daily group"). -- kind values are validated by a server-side whitelist, no CHECK: same as findings.status, -- adding a channel kind later must not require a schema change. CREATE TABLE IF NOT EXISTS notification_channels ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, -- dingtalk DingTalk / feishu Feishu (Lark) / wecom WeCom (WeChat Work) / webhook generic / telegram / email kind TEXT NOT NULL, enabled BOOLEAN NOT NULL DEFAULT true, -- Credentials (stored in plaintext, masked when echoed to the UI; see maskChannelSecrets on the server side). -- The six channel kinds differ wildly in fields, so one JSONB column + strict per-kind validation on the Go -- side avoids a pile of NULL columns per channel: -- dingtalk {webhook,secret} -- feishu {webhook,secret} -- wecom {webhook} -- webhook {url,method,content_type,headers{},body_template} -- telegram {bot_token,chat_id,base_url} -- email {host,port,username,password,from,to[],tls} config JSONB NOT NULL DEFAULT '{}', -- Delivery timing: realtime pushes on hit / digest joins a batch aggregated into one message per the global period. mode TEXT NOT NULL DEFAULT 'realtime', -- Filter conditions; every field is optional (absent = no filtering): -- min_severity ''|low|medium|high|critical -- task_ids/asset_ids empty array = unrestricted; non-empty requires a non-empty intersection -- vulnclass_include/exclude keyword arrays (case-insensitive substring); empty include = accept all -- on_status_change bool, only meaningful in realtime mode filter JSONB NOT NULL DEFAULT '{}', -- Per-minute delivery cap; 0 = unlimited. Default 20 matches DingTalk/WeCom official hard limits. -- Over-limit messages are not dropped, only deferred to the next tick. rate_per_min INTEGER NOT NULL DEFAULT 20, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); DROP TRIGGER IF EXISTS trg_notification_channels_upd ON notification_channels; CREATE TRIGGER trg_notification_channels_upd BEFORE UPDATE ON notification_channels FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- Event facts. Written in the **same transaction** by RecordFindingTx / status-change transactions, keeping "finding -- persisted" and "delivery job exists" atomically consistent — there is no window where the commit succeeds but the -- message was never queued and is lost forever. -- The snapshot is deliberately redundant: a finding may later be renamed/re-leveled/re-statused, and a push should -- reflect "the moment it happened"; fan-out and rendering also avoid re-reading findings/tasks/assets. -- Events do not cascade-delete when the finding is deleted: consistent with the findings table's "survives task deletion" semantics. CREATE TABLE IF NOT EXISTS notification_events ( id BIGSERIAL PRIMARY KEY, -- finding_created | finding_status_changed kind TEXT NOT NULL, finding_id BIGINT NOT NULL, snapshot JSONB NOT NULL, -- fan-out idempotence marker: the dispatcher picks undispatched events by this column and sets it true when done. -- A column instead of row deletion, so delivery history can trace back to the event. fanned_out BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_notification_events_pending ON notification_events(id) WHERE NOT fanned_out; -- Delivery job: one event × one enabled channel = one row. Fan-out happens outside the transaction, so a channel -- enabled later does not backfill history (same semantics as agent_triggers' "late-enabled triggers do not backfill -- history", avoiding a flood of historical backlog the moment a channel is enabled). -- channel_id cascades: once the channel config is gone, its delivery history is meaningless. CREATE TABLE IF NOT EXISTS notification_deliveries ( id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE, channel_id BIGINT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, -- pending awaiting send / sent sent / failed retries exhausted (manual resend allowed) / skipped channel disabled or batch cancelled -- pending awaiting send / sending claimed by a dispatcher (lease not expired) / sent sent / -- failed retries exhausted or permanent failure (manual resend allowed) / skipped channel disabled. No CHECK on values, -- same as findings.status, validated by a server-side whitelist. state TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, -- Doubles as "next claimable time" and "lease expiry": claiming pushes it into the future, which constitutes the -- lease, so "lease not expired" and "not yet retry time" share one condition expression — no extra lease_until -- column needed. sending rows left by a crashed process are reclaimed by the next round once the lease expires. next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error TEXT NOT NULL DEFAULT '', -- Shared by one batch in digest mode; always NULL in realtime. The whole batch is rendered into one message and marked sent together. batch_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), sent_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_notification_deliveries_due ON notification_deliveries(next_attempt_at) WHERE state='pending'; CREATE INDEX IF NOT EXISTS idx_notification_deliveries_history ON notification_deliveries(id DESC); CREATE INDEX IF NOT EXISTS idx_notification_deliveries_batch ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel ON notification_deliveries(channel_id, id DESC); -- Cost ledger (#70): optional per-profile pricing, EUR per 1M tokens. NULL/0 = unset; -- usage aggregations compute cost only when at least one price is set. ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS price_input_per_m NUMERIC(12,6); ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS price_output_per_m NUMERIC(12,6); ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS price_cache_read_per_m NUMERIC(12,6); ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS price_cache_write_per_m NUMERIC(12,6); -- ===================================================================== -- RSI lesson library (shadow mode) -- Deterministic, evidence-gated lessons extracted from closed tasks. A -- lesson exists only when it has a cue, an action, and at least one -- evidence row; support is the number of distinct evidence sources, so -- re-running extraction is idempotent. Pattern from harness2 (Google -- Research, Apache-2.0, arXiv 2610.14224): no lesson without evidence. -- ===================================================================== CREATE TABLE IF NOT EXISTS rsi_lessons ( id BIGSERIAL PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, -- positive = a confirmed outcome worth reusing; negative = do not repeat; -- caution = recheck before reporting (version or retest sensitive). kind TEXT NOT NULL CHECK (kind IN ('positive','negative','caution')), cue TEXT NOT NULL, action TEXT NOT NULL, category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL, company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL, status TEXT NOT NULL DEFAULT 'candidate' CHECK (status IN ('candidate','adopted','retired')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_rsi_lessons_scope ON rsi_lessons(company_id, category_id, status); CREATE INDEX IF NOT EXISTS idx_rsi_lessons_kind ON rsi_lessons(kind, updated_at DESC); -- One row per (lesson, source). The primary key makes extraction idempotent: -- a second run for the same task adds nothing and cannot inflate support. CREATE TABLE IF NOT EXISTS rsi_lesson_evidence ( lesson_id BIGINT NOT NULL REFERENCES rsi_lessons(id) ON DELETE CASCADE, source_kind TEXT NOT NULL, source_id BIGINT NOT NULL, detail TEXT NOT NULL DEFAULT '', PRIMARY KEY (lesson_id, source_kind, source_id) ); -- What was shown to which task, and in which mode (shadow = logged but not -- injected; inject = appended to the planner/worker system prompt). CREATE TABLE IF NOT EXISTS rsi_lesson_usage ( lesson_id BIGINT NOT NULL REFERENCES rsi_lessons(id) ON DELETE CASCADE, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, mode TEXT NOT NULL CHECK (mode IN ('shadow','inject')), shown_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (lesson_id, task_id, mode) );