Campaigns
A campaign is one audit run against a specific git state: a branch, a commit, and a remote. Campaigns are created by a developer's run, by a forge webhook (PR opened/synchronized), or as a retest of existing findings. Each campaign pins a config_version so in-flight work renders from the methodology it started with.
Choose a campaign type
| Type | When to use | Scope |
|---|---|---|
full | first scan of a repo, or a periodic re-baseline | every bucket × every pass × every model |
incremental | a PR / branch — only what changed | buckets/passes whose scope intersects changed_paths + risk-trigger files |
retest | verifying a fix landed | a single finding's bucket + the passes whose scope matches its files |
Create a campaign
From the agent (full)
thespider-agent run without --campaign creates a full campaign from local git detect — the client detects branch/commit/remote_url/dirty and the server records it.
From the API (any type)
POST /v1/projects/{id}/campaigns
{ type, git:{branch,commit,remote_url,dirty}, base_commit?, changed_paths?, config_version? }
→ { campaign_id, config_version_id, status, reused }
changed_paths (file paths, not contents). The server intersects them with pass-scope globs + risk triggers — paths travel, contents never do.Dedup identity is keyed by (project, branch, commit, type), so re-creating the same campaign reuses the existing one instead of duplicating work.
Scope an incremental campaign
Incremental targeting is decided server-side in campaign planning, fed by client-supplied changed_paths. It ports two rules verbatim from the original engine:
- Pass-scope glob matching — a pass only runs on a changed file if the pass's scope globs match it.
- Global risk triggers —
Dockerfile,.env*, lockfiles,*.config.*, and similar risk-pattern files expand scope to all passes, regardless of the pass's own scope.
This is why a one-line .env change can fan out into a broad re-scan, while a docs-only change runs nothing.
Understand finding identity across campaigns
Fingerprint is the fast path, not the identity. Ingest resolves identity in three steps:
- Alias-table hit on
(project_id, fingerprint)→ KNOWN: record a sighting, inherit current triage status (a won't-fix onmainstays suppressed on a PR branch). - Miss → candidate dedupe project-scoped, with bounded candidates and threshold gates. If adjudicated duplicate (heuristic or AI) → insert an alias row mapping this fingerprint to the canonical finding + record a sighting. This absorbs renames and wording drift, which change the fingerprint. For entitled orgs, AI adjudication batches the finding's eligible candidate pairs into model requests of up to
AI_DEDUPE_BATCH_SIZEpairs (defective entries are retried individually); each pair's decision is cached individually and reused forever. - Still no match → NEW finding + its birth alias row.
Merges repoint alias rows to the keeper and set merged_into_finding_id (a soft merge — never DELETE), so a merged-away fingerprint re-imported later lands on the keeper deterministically.
Run a retest
Retest is a targeted verification campaign, not a naive re-scan. It is scoped to the finding's bucket + the passes whose scope matches its files, gated on HEAD matching the expected fix commit.
# server-created retest campaign; the agent claims it like any other work
thespider-agent run --campaign <retest-campaign-id> --once
Known nuance: fixed/regressed is decided by reappearance. Near-matches route through the dedupe layer before concluding "fixed" (so wording drift doesn't masquerade as a fix), and for high-severity findings you may require N consecutive clean runs. Absence only counts as evidence when the retest scope actually covered the finding — which the bucket/pass targeting guarantees.