716 files rewritten, 30 renamed, same family mapping as the frontend pass.
Held back, and each for a reason rather than by oversight:
db_table values insights_hogfunction, insights_hogflow,
insights_hogfunctiontemplate, hogflow_templates.
Renaming a table is ALTER TABLE, so it ships as a
migration with the raw SQL in nodejs that names it.
hog_invocation_results a Datastore table with Kafka engine tables on it
products/review_hog the module path is the Django app path; moving it
moves app_label and every migration is recorded under
(app_label, name). That is the same history break as
renaming a migration file, one level up.
*/migrations/* five files, untouched
whogho an integration vendor
Checked: 395 of 397 changed Python files compile. The two that do not —
insightsql/database/database.py and temporal/schedule.py — fail identically at
origin/main under the same interpreter, because they use `type X = ...` and
`except*`, which need the 3.13 this repo pins and not the 3.9 doing the check.
Running the check against the original is what told those apart from a real
break.
Co-authored-by: Hanzo Dev <[email protected]>
154 KiB
Signals System Architecture
Overview
The Signals product is a signal grouping and report-generation pipeline. Signals from multiple products and integrations — including session replay, AI observability, error tracking, GitHub, Linear, Zendesk, and the headless Signals agent itself (a scheduled scout that emits cross-source findings into the same pipeline) — are emitted into a shared Datastore embeddings table, grouped into SignalReports via embedding similarity + LLM matching, and then optionally promoted into an agentic report-research flow.
Today the active ingestion path is emitter → buffer → grouping v2. The summary path is no longer a simple "summarize signals" LLM step: it runs a report-level safety judge, selects a repository, then performs sandbox-backed multi-turn research that produces findings, actionability, priority, title, summary, and suggested reviewers. Reports that are immediately actionable can automatically start a Tasks coding run via the autonomy system.
A report is a living document: every piece of work done on it — judgments, repo selection, research findings, pushed commits, task runs, free-form notes — is recorded as an attributed, schema-validated, append-only SignalReportArtefact. The status-bearing types resolve latest-wins; the rest accumulate as a work log. This is the surface autonomous agents (the pipeline, custom scouts, and other inbox-driven products) read and write as they act on a report.
Temporal Workflows
Signals ingestion uses a three-stage pipeline: emitter → buffer → grouping v2. The emitter and buffer workflows are defined in backend/temporal/emitter.py and backend/temporal/buffer.py. The grouping v2 workflow is in backend/temporal/grouping_v2.py and delegates to the shared _process_signal_batch() implementation in backend/temporal/grouping.py. The report summary workflow is defined in backend/temporal/summary.py.
The original TeamSignalGroupingWorkflow (v1) in backend/temporal/grouping.py is still registered but is no longer started by emit_signal(). Its shared activities and _process_signal_batch() implementation are still actively used by v2.
Signals workflows and activities are registered in backend/temporal/__init__.py and wired into the VIDEO_EXPORT_TASK_QUEUE worker by insights/management/commands/start_temporal_worker.py.
If you add or remove a Signals workflow/activity from backend/temporal/__init__.py, you also need to update insights/temporal/tests/ai/test_module_integrity.py (TestSignalsProductModuleIntegrity). That test intentionally snapshots the registered workflow/activity lists and will fail until its expected names are updated.
Several additional Signals workflows also exist but are not part of the main report pipeline:
backfill-error-tracking(backend/temporal/backfill_error_tracking.py) — backfills recent error tracking issues as signalsemit-eval-signal(backend/temporal/emit_eval_signal.py) — converts LLMA evaluation results into Signals inputs on the Signals worker queuerun-signals-scout-coordinator(backend/temporal/agentic/scout_coordinator.py) — periodic tick (everyCOORDINATOR_INTERVAL_MINUTES = 30) that fans out scheduledsignals-scout-*scout runs per (team, skill). Spec'd separately below.RunSignalsScoutWorkflow(backend/temporal/agentic/scout_scheduler.py) — child workflow per planned run; thin wrapper around the harness activity. Spec'd separately below.
Activity decoration
Every async Signals Temporal activity is decorated with @scoped_temporal() from insights/temporal/common/scoped.py (not upstream @hanzo_insights.scoped()). It scopes hanzo_insights.tag() calls to the activity invocation and auto-captures uncaught exceptions into Insights error tracking with the workflow's tags attached. The upstream decorator wraps async def in a sync wrapper, breaking Temporal's iscoroutinefunction dispatch — the worker returns the unawaited coroutine and crashes on JSON encoding. scoped_temporal() is the async-aware equivalent (sync helpers can keep using upstream @hanzo_insights.scoped()).
from insights.temporal.common.scoped import scoped_temporal
@temporalio.activity.defn
@scoped_temporal()
async def my_activity(input: ...) -> ...:
...
Signal Ingestion Pipeline (v2)
The v1 TeamSignalGroupingWorkflow buffered raw EmitSignalInputs in memory and carried them over on continue_as_new. Under higher signal volume the continue_as_new payload could grow too large. The v2 pipeline fixes that by flushing buffered signals to object storage and passing only lightweight object keys between workflows.
emit_signal() SignalEmitterWorkflow BufferSignalsWorkflow TeamSignalGroupingV2Workflow
│ │ │ │
├─ start (idempotent) ───────────────────────────────────────────────────►│ │
│ │ │ │
├─ start (fire-and-forget) ──────────►│ │ │
│ │ │ │
│ activity: query buffer size │ │
│ ├─ get_buffer_size ───────────────►│ │
│ │◄─ size ─────────────────────────┤│ │
│ │ (poll+sleep if full) │ │
│ activity: signal submit_signal │ │
│ ├─────────────────────────────────►│ │
│ │ │ │
│ │ (buffer fills / timeout) │
│ │ │ │
│ │ activity: safety filter + flush │
│ │ ├──► object storage: signals/signal_batches/<uuid>
│ │ │ │
│ │ activity: signal-with-start │
│ │ ├─ submit_batch(object_key) ──────►│
│ │ │ │
│ │ continue_as_new activity: read from object storage
│ │ │ ├──► object storage
│ │ │ │
│ │ │ _process_signal_batch()
│ │ │ │
│ │ │ continue_as_new
SignalEmitterWorkflow (signal-emitter)
Ephemeral per-signal workflow that provides backpressure between emit_signal() and the buffer. One instance per signal, with workflow ID signal-emitter-{team_id}-{uuid}.
Defined in backend/temporal/emitter.py.
Flow:
- Run
submit_signal_to_buffer_activity, which: a. Queries the buffer workflow’sget_buffer_sizequery b. If the buffer is full (>= BUFFER_MAX_SIZE), polls with jittered sleep until space is available, heartbeating while it waits c. Signals the buffer workflow’ssubmit_signalhandler with the signal
This keeps emit_signal() fire-and-forget while the emitter workflow absorbs backpressure. The activity currently uses a 3-hour start_to_close_timeout and 10-minute heartbeat_timeout.
BufferSignalsWorkflow (buffer-signals)
Buffers incoming signals in memory and periodically flushes them to object storage. One instance per team, with workflow ID buffer-signals-{team_id}.
Defined in backend/temporal/buffer.py.
Architecture:
- New signals arrive via
@workflow.signal(submit_signal), sent bySignalEmitterWorkflowinstances. - Exposes
@workflow.query(get_buffer_size) so emitters can implement backpressure by polling buffer occupancy before sending. - The main loop waits for signals, then waits until either the buffer reaches
BUFFER_MAX_SIZE(20) orBUFFER_FLUSH_TIMEOUT_SECONDS(5s) elapses since the first signal arrived. - On flush: drains the buffer, runs the safety filter on all signals in parallel via
safety_filter_activity, writes the safe signals to object storage atsignals/signal_batches/<uuid>viaflush_signals_to_s3_activity, then sends the object key to the grouping v2 workflow viasignal_with_start_grouping_v2_activity(which starts the workflow if needed). If the entire batch is unsafe, the flush and grouping steps are skipped. - If the buffer is already full again after flushing (signals arrived during the flush activities), it loops immediately and flushes again rather than paying a
continue_as_newrestart cost. - Otherwise it calls
continue_as_new, carrying over any signals that arrived between drain and now viaBufferSignalsInput.pending_signals. - Stored batch objects are cleaned up by object-storage lifecycle policies, not by the workflows.
TeamSignalGroupingV2Workflow (team-signal-grouping-v2)
Long-running entity workflow that processes batches of signals from object storage. One instance per team, with workflow ID team-signal-grouping-v2-{team_id}.
Defined in backend/temporal/grouping_v2.py.
Architecture:
- Receives object keys via
@workflow.signal(submit_batch), sent byBufferSignalsWorkflow. - Buffers pending object keys in memory as
list[str], which is much lighter than buffering fullEmitSignalInputs. - The main loop waits for a batch key, downloads the signals via
read_signals_from_s3_activity, then processes the full batch via_process_signal_batch()fromgrouping.py. - Caches signal type examples across batches with a TTL (
TYPE_EXAMPLES_CACHE_TTL, 5 minutes). - Calls
continue_as_newafter each batch, carrying over any pending keys that arrived during processing. - Supports pause/unpause via
set_paused_until,clear_paused, andget_paused_state. The API exposes this throughSignalProcessingViewSet. - Errors processing a batch are caught and logged — the workflow continues to the next batch.
TeamSignalGroupingWorkflow (team-signal-grouping) — v1, legacy
A long-running entity workflow that serializes all signal grouping for a single team. Exactly one instance per team, with workflow ID team-signal-grouping-{team_id}. It is still registered, but emit_signal() no longer starts it.
What is still active:
- The workflow class itself is legacy.
- The shared
_process_signal_batch()implementation in this file is still the active grouping implementation used by v2.
Signal processing flow (per batch, in _process_signal_batch()):
- Embed all signal descriptions + fetch signal type examples from Datastore in parallel
- Generate 1–3 search queries per signal via LLM, using type examples for cross-source context
- Embed each query
- Semantic search the Datastore
document_embeddingsInsightsQL alias for nearest neighbors viacosineDistance() - LLM match — decide whether the signal belongs to an existing report or needs a new one
- Assign the signal to a
SignalReportin Postgres, increment counts/weights, check promotion threshold, and emit the signal into the embeddings pipeline in one atomic operation - Wait for Datastore — poll until the just-emitted signals are query-visible so subsequent grouping decisions can find them
- If promoted (weight ≥ threshold), spawn child
SignalReportSummaryWorkflowwithParentClosePolicy.ABANDON;WorkflowAlreadyStartedErroris ignored
Steps 1–4 run in parallel across the batch. Steps 5–7 run sequentially per signal, and earlier-in-batch matches are injected into later signals’ candidate sets before the LLM match call.
SignalReportSummaryWorkflow (signal-report-summary)
Runs when a report is promoted to candidate. The current flow is safety judge → repository selection → agentic research → state transition.
Defined in backend/temporal/summary.py.
Flow:
- Quota gate →
check_report_quota_gate_activity(stage=summary_entry; see Billing limit enforcement below). When it blocks, the workflow exits and the report stayscandidate. The gate re-runs before repository selection (pre_repo_selection) and before agentic research (pre_research) — a block there reverts the reportin_progress → candidateviarevert_report_to_candidate_activitybefore exiting, so a promotion rule can pick it up again. All three sites are gated withworkflow.patched("self-driving-quota-gates"). - Fetch signals for the report from Datastore →
fetch_signals_for_report_activity - Mark in-progress in Postgres and advance
signals_at_runby 3 →mark_report_in_progress_activity - Safety judge →
report_safety_judge_activity- Evaluates the underlying signals for prompt injection / manipulation attempts
- Persists a
safety_judgmentartefact
- Select repository →
select_repository_activity(temporal/agentic/select_repository.py) — see Repository Selection below- If no repository is selected, the workflow synthesizes a “repository selection required” title/summary and transitions to
pending_input
- If no repository is selected, the workflow synthesizes a “repository selection required” title/summary and transitions to
- Agentic research →
run_agentic_report_activity(temporal/agentic/report.py)- Sandbox-backed multi-turn research over the selected repository plus MCP data
- Appends
repo_selection,signal_finding,actionability_judgment,priority_judgment, andsuggested_reviewersartefacts atomically on success (append-only — on re-research only entries that actually changed are appended)
- Conditional coding-task auto-start (inside
run_agentic_report_activity) — see Autonomy & Auto-Start below - Apply the decision:
- If not actionable →
reset_report_to_potential_activity(weight resets to 0, status becomespotential) - If requires human input →
mark_report_pending_input_activity - If immediately actionable →
mark_report_ready_activity
- If not actionable →
- If new signals arrived during the run,
mark_report_ready_activityre-promotes the report tocandidateand the workflow loops internally rather than spawning a new workflow - When a run completes without immediately looping, the workflow publishes a Kafka
signals report completedmessage viapublish_report_completed_activity
On any unhandled exception, the workflow marks the report failed and re-raises.
Timeout / retry details:
- The grouping workflow starts the summary child with a 1-hour
execution_timeout, not 5 hours - The heavy step is the agentic research activity, which uses a 4-hour
start_to_close_timeoutand 5-minute heartbeat timeout - Most activities use 3-attempt retry policies
- Repo selection and agentic research use single-attempt retries to avoid duplicate sandbox work
Repository Selection
Before running the agentic research, the workflow calls select_repository_activity (temporal/agentic/select_repository.py) — a separate activity that selects the most relevant repository from the team’s GitHub integrations via select_repository_for_report() (report_generation/select_repo.py).
Keeping repository selection in its own activity gives it independent retry / timeout behavior and makes it reusable across re-promotions.
- Re-promoted report: loads and reuses the latest
repo_selectionartefact if it contains a repository - 0 repos connected: returns
None→ workflow transitions topending_input - 1 repo connected: returns it directly
- N repos connected: runs a sandbox repo-discovery agent using
Insights/.githubas a lightweight dummy clone; the agent usesghCLI to inspect candidate repositories and choose the best match - The activity runs in a sandbox environment restricted to GitHub-related domains
Repository heavy cache
IntegrationRepositoryCacheEntry (Postgres, defined in insights/models/integration_repository_cache.py) stores per-repo README + recursive blob paths + descriptive metadata, populated lazily by GitHubRepositoryFullCache.sync_full_cache_entry(). It's exposed to the selection agent as the InsightsQL system table system.integration_repository_cache so the agent can grep paths server-side via ARRAY JOIN splitByString('\n', tree_paths) instead of hitting GitHub's /search/code endpoint (30 req/min hard ceiling). The lightweight (id, name, full_name) list stays on Integration.repository_cache (JSONField) so the IDE repo-dropdown read path is unchanged.
sync_full_cache_entry() uses a two-tier freshness check, both keyed off default_branch_sha as the hydration sentinel (so repos legitimately without a README still hit the fast paths):
- TTL gate — fresh row → return immediately, zero API calls.
- SHA gate — past TTL, two cheap calls to compare the live default-branch SHA against the cached one. Match → refresh only mutable metadata, skip README and tree refetch.
- Heavy refetch — SHA changed → fetch README and the recursive file tree pinned to the same commit, then upsert.
Bulk sync (sync_full_cache) fans the per-repo sync out via run_parallel_with_backoff (concurrency 10) over the JSONField list as source of truth — orphan rows are evicted, per-repo errors are returned in-place rather than raised. Secondary rate limits propagate with retry hints so the helper backs off cooperatively. The bulk sync is single-flighted per integration via a Redis lock: concurrent reports for the same team queue behind the leader (poll every 1s, hard cap 20m wait) and then read the warm cache; the leader heartbeats every 60s to extend its 15m lease, and a lost lease cancels the in-flight body to prevent duplicate syncs.
Eligibility filter: Before invoking the agent, select_repository_for_report drops candidates that are archived or missing from the heavy cache (e.g., a row whose sync errored during a cold start). The prompt treats SQL hits as primary evidence, so a missing row would read as a false negative. If filtering leaves zero or one candidates, the activity short-circuits without running the agent.
Truncation caveat: GitHub's recursive tree endpoint truncates at ~50k entries / 7MB. The tree_truncated flag marks affected rows; tree_paths is incomplete on those rows and will silently miss files in InsightsQL grep. Consumers must filter on tree_truncated=False and explicitly degrade for truncated repos. Affects <2% of repos; paginated subtree fetch is future work.
Re-promotion
Reports are re-promoted when new evidence arrives. A READY / RESOLVED report re-promotes on every new matching signal (so research reruns with the latest evidence), and a report reset to potential re-promotes once it clears the signals_at_run snooze gate again.
Re-research cap. The research activity reads every non-deleted signal, so re-research cost scales with report size. Once an already-researched report exceeds RERESEARCH_MAX_SIGNALS (SIGNAL_RERESEARCH_MAX_SIGNALS, default 10), READY re-promotion is suppressed: new signals are still assigned, weighted, and emitted, but no new summary run spawns. The cap is enforced in two places: the grouping promotion gate (assign_and_emit_signal_activity), which fires the signal_report_reresearch_skipped event per suppressed signal so the saved volume is trackable, and the summary self-loop (mark_report_ready_activity), which stops an in-flight run from looping into another research pass (no event — this is a rare mid-run edge).
The cap covers only the READY / RESOLVED path (the one that re-promotes on every signal). Re-promotions through the potential gate stay uncapped — first research, candidate self-heal, snooze return, and a not-actionable reset re-accumulating weight — because they are weight / signals_at_run-gated rather than per-signal, so strong new evidence can still resurface a large report.
On re-promotion:
- Repo selection reuses the previous
repo_selectionartefact when possible - Agentic research reconstructs previous findings / actionability / priority from artefacts and reuses prior work signal-by-signal when still valid
- Agentic artefacts are append-only — the previous run's rows are kept; the new run appends only the entries that actually changed (the agent confirms a still-correct finding/judgment instead of regenerating it), and status types resolve latest-wins
task_runartefacts are never removed on re-promotion; they are the historical record of research and auto-started coding runs- Auto-start is deduplicated per report by a legacy
SignalReportTaskimplementation link (not the freeformtask_runlog), checked inside the report-rowselect_for_update - Workflow ID includes
run_counton reruns to avoid Temporal ID collisions with earlier executions
SignalReportReingestionWorkflow (signal-report-reingestion)
Deletes a report and re-ingests its signals through the current Signals ingestion pipeline. Useful when grouping decisions need to be re-evaluated after prompt or matching changes.
Defined in backend/temporal/reingestion.py. Workflow ID: signal-report-reingestion-{team_id}-{report_id}.
Flow:
- Fetch signals for the report from Datastore →
fetch_signals_for_report_activity. If no signals are found, the workflow deletes the report only. - Soft-delete signals in Datastore →
soft_delete_report_signals_activity2b. Wait for Datastore →wait_for_signal_in_datastore_activity, so re-emitted signals do not race stale rows during semantic search - Delete report in Postgres →
delete_report_activity - Re-ingest signals →
reingest_signals_activity, which converts eachSignalDataback into anemit_signal()call
emit_signal() now starts the buffer + emitter + grouping v2 pipeline, so reingestion flows through the same active path as fresh Signals traffic.
TeamSignalReingestionWorkflow (team-signal-reingestion)
Soft-deletes every non-deleted signal for a team and queues all of them for re-ingestion through the active pipeline.
Defined in backend/temporal/reingestion.py. Workflow ID: team-signal-reingestion-{team_id}.
This workflow is intended for full-team regrouping after changes to matching / prompting / grouping behavior.
It also supports a delete-only mode via TeamSignalReingestionWorkflowInputs(delete_only=True) / reingest_team_signals --delete, which soft-deletes all team signals and clears ORM report state without re-emitting anything.
Flow:
- Capture existing grouping pause state →
get_grouping_paused_state_activity - Pause grouping v2 for the team, extending the pause to at least
now + 10 minutes→pause_grouping_until_activity - Process one batch of non-deleted signals →
process_team_signals_batch_activity- Fetches the current first
50non-deleted signals from Datastore usingORDER BY timestamp DESC, document_id DESC - Soft-deletes each signal by emitting a replacement row with the original
document_id, timestamp, and metadata plusdeleted=true - In normal mode, calls
emit_signal()for each signal while grouping is paused, so the new signals are buffered but not grouped yet - In delete-only mode, skips the
emit_signal()step entirely - Waits for the deleted rows from that batch to land in Datastore before returning
- Fetches the current first
- Refresh the pause window if needed so grouping stays paused across long runs while keeping workflow history small
- Repeat until a batch processes
0remaining non-deleted signals - Delete all team reports + artefacts in Postgres →
delete_team_reports_activity- Deletes all
SignalReportArtefactrows for the team - Deletes all
SignalReportrows for the team - Runs after all signal batches have been processed, but while grouping is still paused
- In normal mode, this gives the re-emitted signals a clean ORM state to regroup into once processing resumes
- In delete-only mode, it leaves both Datastore and ORM report state cleared
- Deletes all
- Restore the prior grouping pause state →
restore_grouping_pause_activity
Important detail: the workflow intentionally does not paginate across iterations with offsets. Each batch mutates the underlying non-deleted result set by emitting delete rows, so once those rows land in Datastore the result set shrinks. Re-fetching the current first batch each time avoids skipping signals.
This workflow is currently started via the Django management command reingest_team_signals, not via a REST endpoint. Pass --delete to run the delete-only variant.
SignalReportDeletionWorkflow (signal-report-deletion)
Soft-deletes a report and all its signals. Triggered by DELETE /signal_reports/{id}/.
Defined in backend/temporal/deletion.py. Workflow ID: signal-report-deletion-{team_id}-{report_id}.
Flow:
- Fetch signals for the report from Datastore. If none are found, skip to report deletion.
- Soft-delete signals in Datastore 2b. Wait for Datastore until the deleted rows land
- Delete report in Postgres
This shares the same activities as reingestion; the only difference is that it stops after deletion.
SignalsScoutCoordinatorWorkflow (run-signals-scout-coordinator)
Polling coordinator for the headless Signals agent. Driven by a Temporal Schedule
defined in backend/temporal/agentic/schedule.py with every=COORDINATOR_INTERVAL_MINUTES
(30min) and ScheduleOverlapPolicy.SKIP to drop ticks rather than queue them. The tick
is just polling granularity. Each scout's own run_interval_minutes schedule decides
when it actually runs, unless an optional project-local cron run_cron_schedule overrides it.
Defined in backend/temporal/agentic/scout_coordinator.py.
Flow:
- Activity
fetch_enabled_signals_scout_runs_activitybounds candidates to the teams enrolled via thesignals-scoutfeature flag's JSON payload allowlist —guaranteed_team_idsminusskip_team_ids, with a hardcoded fail-safe default (_participating_teams→_enrolled_team_ids, modeled oninsights/temporal/ai_observability/team_discovery.py). Enrollment is flag-driven: editing the payload in the flag UI enrolls or drains a team next tick with no manual seed. For each enrolled team it callssync_canonical_skills(team, prune=True)to mirror the on-disksignals-scout-*skills onto the team'sLLMSkillrows, then auto-registers aSignalScoutConfigfor any scout skill missing one (scout_harness/config_registry.register_missing_configs). Thescout-createendpoint atomically creates a custom skill and config;scout-config-createis the lower-level explicit upsert for an existing skill. Failures here are logged and the tick continues — a stale skill is preferable to a dead tick. - For each enabled config, the coordinator computes how overdue the scout is. Rolling schedules are due when
last_run_at is None, ornow - last_run_at >= run_interval_minutes. A config with arun_cron_scheduleis due at the earliest unfulfilled cron slot, evaluated as wall-clock time in the project's timezone (so slots follow daylight-saving changes). It runs on the first coordinator tick at or after that slot. Saving the config after a slot has passed waits for the next slot. There is no sampling — every due scout is planned. - Due runs are sorted most-overdue-first and truncated at
MAX_RUNS_PER_TICK(50 per tick; the cost bound — overflow catches up next tick).last_run_atis advanced via.update()for everything dispatched (bypassessave(), so the per-tick write never hits the activity log). Planned runs are re-sorted by(team_id, skill_name)for stable child IDs. - Each
PlannedRunbecomes a childRunSignalsScoutWorkflowstarted withParentClosePolicy.ABANDONand a deterministic workflow ID per(team_id, skill_name, tick_id)so retried coordinators can't double-launch within a tick.
The coordinator's lifetime is seconds regardless of fan-out width; throttling happens at the Temporal task queue + worker concurrency layer. Pausing a scout is enabled=False on its config; slowing it is a larger run_interval_minutes. A cron run_cron_schedule (e.g. 30 9 * * *, 0 9 * * 1-5) anchors runs to wall-clock slots instead; null preserves the rolling interval behavior. All are tunable via the scout-config-update MCP tool.
Per-scout holdback (withheld_skills). The same flag payload carries a hard denylist for keeping an unreleased scout off the fleet while dogfooding it on a single project. A withheld_skills list (a default_team_config fleet-wide default, overridable per team via team_configs[<id>].withheld_skills with replace-not-merge semantics — set [] to release the full fleet to one team) names scouts that, for a held-back team, are never seeded into its LLMSkill rows (sync_canonical_skills skips them), never get a SignalScoutConfig (register_missing_configs drops them from its return), and are never dispatched. Resolved by _resolve_withheld_skills, most-specific-layer-first like the run caps. Unlike the soft enabled_skills seed allowlist (a default a user can still toggle on), this is a hard gate at the seed + dispatch layer — e.g. default_team_config.withheld_skills = ["signals-scout-error-tracking"] with team_configs["2"].withheld_skills = [] dogfoods error tracking on project 2 only.
RunSignalsScoutWorkflow
Child workflow per planned run. Defined in backend/temporal/agentic/scout_scheduler.py.
Thin wrapper around run_signals_scout_activity, which delegates to
scout_harness.runner.arun_signals_scout. The activity inserts the SignalScoutRun
bridge row at the start of the run; status, timing, and chat log live on the linked
tasks.TaskRun via MultiTurnSession, not on the bridge row. The workflow's only
job is to spawn the activity with start_to_close_timeout=WORKFLOW_HARD_CEILING_S,
a 2-minute heartbeat, and RetryPolicy(maximum_attempts=1) — the spec calls for "fail
safe and silent": a bad run does not retry blindly; the next scheduled tick will try
again. Single-flight is a best-effort app-layer guard: _has_running_run skips
dispatch when a prior run for the same (team, skill_name) has
task_run.status = IN_PROGRESS. An earlier partial unique constraint on
(team, skill_name) WHERE status='running' was dropped together with the bridge
model's own status column; active recovery of stranded IN_PROGRESS task runs
(_self_heal_stale_runs is a no-op today) is a tracked follow-up.
Findings emitted during the run go through the harness's emit_signal_* tools,
which call emit_signal() with source_product="signals_scout" and
source_type="cross_source_issue" — from there the signal flows through the same
emitter → buffer → grouping v2 path as any other source.
See backend/scout_harness/AGENTS.md for the harness internals (runner, prompt
assembly, scratchpad + profile + run-history reads, lazy seed) and
skills/AGENTS.md for the scout fleet convention.
Django Models (Postgres)
Defined in backend/models.py.
SignalReport
The core model. Status machine:
potential → candidate → in_progress → ready
→ pending_input
→ failed
→ potential (reset by actionability judge)
→ candidate (mid-run quota pause; re-promotes on the next matching signal)
# Re-promotion: READY reports are re-promoted to candidate on each new matching signal,
# triggering a new summary run that reuses the previous repo selection and findings for
# already-seen signals. Suppressed once signal_count > RERESEARCH_MAX_SIGNALS (see Re-research cap).
# RESOLVED is terminal and never re-promotes: a recurrence spawns a fresh report, linked back to the
# resolved one via a related_to artefact (assign_and_emit_signal_activity).
ready → candidate
# Resolve: a report is marked resolved when the requested work is done. Two paths:
# - automatic: the tasks GitHub webhook resolves a report when its linked implementation PR merges
# - user-driven: the state API accepts state='resolved' (single + bulk) so a user or MCP agent
# can resolve directly — useful for PR-less fixes (skill-body changes, NO_REPO reports) that the
# webhook could never reach. Restricted to the same statuses the machine allows below.
ready | pending_input → resolved
suppressed → resolved (resolve an archived report straight out of the archive; the state action
refuses this unless it was researched before being archived, so an unresearched
report can't be laundered candidate → suppressed → resolved)
# Refund interaction: a refund is final (later PR runs are never billed). The refund path suppresses
# the report so it leaves the inbox and the dismissal receiver closes the implementation PR that was
# paid for and then refunded — including a manually-resolved one, whose PR may still be open. Only a
# report resolved by a merged PR stays resolved (nothing to close). The state action then refuses to
# move a refunded report back to potential — the live status the pipeline re-promotes from — whether
# it sits in suppressed or resolved, and refuses to resolve it back out of the archive.
# Transitions enforced by SignalReport.transition_to():
# - deleted is terminal (no transitions out; excluded from API via queryset)
# - suppressed transitions back to potential, or straight to the researched status it held before
# being archived (ready | pending_input | resolved | failed) — see restore_target_status()
# - any non-deleted status can transition to deleted or suppressed
# - snooze = transition to potential with snooze_for=N (sets signals_at_run = signal_count + N)
suppressed → potential
any (except deleted) → deleted
any (except deleted) → suppressed
| Field | Type | Description |
|---|---|---|
team |
FK → Team | Owning team |
status |
CharField | One of: potential, candidate, in_progress, pending_input, ready, failed, deleted, suppressed |
total_weight |
Float | Sum of all assigned signal weights (reset to 0 if deemed not actionable) |
signal_count |
Int | Number of signals assigned |
title |
Text (nullable) | LLM-generated title (set during matching or summarization) |
summary |
Text (nullable) | LLM-generated summary |
error |
Text (nullable) | Error message if failed, or reason if pending input / reset to potential |
signals_at_run |
Int | Forward-looking promotion threshold. A potential or ready report will not be (re-)promoted to candidate until signal_count >= signals_at_run. Defaults to 0, so fresh reports always pass immediately. Advanced by 3 each time a summary run starts, preventing the report from immediately re-promoting. Snoozing sets this to signal_count + N, pushing the threshold forward by an additional N signals. |
run_count |
Int | How many times the summary workflow has run for this report. Incremented on each candidate → in_progress transition. Used in the Temporal workflow ID to give re-promoted reports a unique execution ID. |
promoted_at |
DateTime (nullable) | When report was promoted to candidate (cleared on reset to potential) |
last_run_at |
DateTime (nullable) | When summary workflow last ran |
conversation |
DEPRECATED | Was: FK → Conversation. Wrapped in deprecate_field() |
relevant_user_count |
DEPRECATED | Was: Int for relevant user count. Wrapped in deprecate_field() |
cluster_centroid |
DEPRECATED | Was: ArrayField(Float) for video segment clustering. Wrapped in deprecate_field() |
cluster_centroid_updated_at |
DEPRECATED | Was: DateTime for centroid freshness. Wrapped in deprecate_field() |
Indexes: (team, status, promoted_at), (team, created_at)
SignalReportArtefact
An append-only, attributed, schema-validated log of the work done on a report. A report reads as a living document: the evidence the research agent gathered, the commits it pushed, the task runs that executed, and free-form notes, accumulating over time. Producers never mutate in place — re-assessing appends a new row.
| Field | Type | Description |
|---|---|---|
team |
FK → Team | Owning team |
report |
FK → SignalReport | Parent report (related_name="artefacts") |
type |
CharField | Artefact type (see ArtefactType enum) |
content |
TextField | JSON content stored as text |
created_at |
DateTime | Auto-set; rows are ordered newest-first |
updated_at |
DateTime (nullable, auto_now) |
Log entries are editable in place |
created_by |
FK → User (SET_NULL, nullable) |
Attribution — the user who produced the row, if any |
task |
FK → Task (SET_NULL, nullable) |
Attribution — the task that produced the row, if any |
created_by / task are nullable: legacy rows and explicit system writes carry NULLs.
Status vs log. Everything is append-only; the STATUS_ARTEFACT_TYPES / LOG_ARTEFACT_TYPES sets classify what an entry means:
- status — the report's current state (
safety_judgment,actionability_judgment,priority_judgment,repo_selection,suggested_reviewers). Each (re)assessment appends a row; the current status is the latest row of that type (serializers derive priority/actionability/reviewers viaorder_by("-created_at")[:1]). - log — discrete work (
code_reference,commit,task_run,note); these accumulate and are addressable by UUID (PATCH/DELETE). signal_findingis in neither set: its identity is(report, content.signal_id), latest per signal wins.dismissalentries stack.
Artefact types (SignalReportArtefact.ArtefactType enum):
| Type | Content |
|---|---|
video_segment |
Video segment data from session clustering |
safety_judgment |
{"choice": bool, "explanation": "..."} — true = safe |
actionability_judgment |
{"actionability": "immediately_actionable" | "requires_human_input" | "not_actionable", "explanation": "...", "already_addressed": bool} |
priority_judgment |
{"priority": "P0"|"P1"|"P2"|"P3"|"P4", "explanation": "..."} |
signal_finding |
{"signal_id": "...", "relevant_code_paths": [...], "relevant_commit_hashes": {"abc1234": "reason"}, "data_queried": "...", "verified": bool} |
repo_selection |
{"repository": "owner/repo" | null, "reason": "...", "task_id"?: "..."} |
suggested_reviewers |
[{"github_login": "...", "github_name": "...", "relevant_commits": [...]}] — enriched with current Insights user data at serializer read time |
dismissal |
{"reason"?, "note"?, "user_id"?, "user_uuid"?, "slack_user_id"?} — stacking dismissal entries |
code_reference |
{"file_path": "...", "start_line": int, "end_line": int, "contents": "...", "relevance_note": "..."} — a span of source lines (single line = equal start/end) |
commit |
{"repository": "owner/repo", "branch": "...", "commit_sha": "...", "message": "...", "note"?: "..."} — one pushed commit |
task_run |
{"task_id": "...", "run_id"?: "...", "product": "...", "type": "..."} — a task run associated with the report (see below) |
note |
{"note": "...", "author"?: "..."} — free-form note (markdown allowed) |
Content schemas. artefact_schemas.py is the canonical, pydantic-only home of every content shape, collected in ARTEFACT_CONTENT_SCHEMAS (one model per type; a test asserts exact coverage). Raw payloads become typed models once, at the boundaries (parse_artefact_content); the model helpers derive a row's type from the content model's class (artefact_type_for), so a type can never mismatch its content. repo_selection reuses the tasks product's RepoSelectionResult DTO directly (kept in the dependency-light leaf module repo_selection/types.py so importing the schema registry doesn't pull in the sandbox runtime). Reads of legacy rows stay tolerant — parse failures are skipped or degraded, never raised.
Charts. SignalReport.charts holds the queries the inbox renders on the report, so a finding about a metric move can be seen rather than taken on trust. They sit on the report rather than in its artefact log because a chart is part of what the report says, not a record of something that happened to it — it illustrates the summary and is replaced with it, where a log entry would accumulate versions the reader never asked for. Their schema lives in report_charts.py (ReportChart), kept as dependency-light as artefact_schemas.py for the same reason: it loads with the signals models.
query is an InsightVizNode / DataVisualizationNode / SavedInsightNode node, kept as an unparsed dict — validating it against the real node models would mean importing insights.schema, so only the kind allowlist, a size bound, and the executable-payload refusals (bytecode, a nested ScriptQuery, sendRawQuery — a chart renders data, it does not run code — plus a nested SuggestedQuestionsQuery, refused for cost, since its runner calls an LLM once per reader who opens the report) are enforced server-side, and the renderer degrades in place on a bad query. That is the same contract a notebook's ph-query node has.
chart_id is the author's own slug because a report's summary and its charts are written in one call: the summary places a chart by referencing it as a markdown link with a chart: target ([Daily signups](chart:signups-drop)), and an unreferenced chart still renders below the summary. Ids are unique within the set, so a reference resolves to exactly one chart. Placement is resolved from the same markdown parse the renderer runs (frontend/src/scenes/inbox/utils/chartPlacement.ts) rather than by matching the raw summary: a reference in a code span is literal text, one in a table cell or heading has no room to draw, and a repeated reference points back at a chart rather than asking for a second copy — each of those resolves to "not placed here", so the chart falls to the trailing block instead of being drawn twice or lost. A paragraph holding nothing but references lays its charts out as a row. Height comes from the node (a BoldNumber gets a short box, a retention grid a tall scrolling one) unless the author sets size.
Scouts write them via charts on emit_report / edit_report. On an edit charts is the report's whole set — it replaces what was there, the way summary replaces the summary. Omitting the field (or sending null) leaves the existing charts alone; an explicit empty list clears them, so a scout can retract a chart its finding has outgrown rather than only ever swap one for another. On the emit path the safety judge sees a report's chart titles, captions, and queries alongside its prose, so an injected chart is suppressed with the report. The edit path judges nothing today — not title, summary, notes, reviewers, or charts — so a chart set by a later edit reaches the reader unscreened, exactly as an edited summary does.
The main agentic research pipeline authors charts too, so every signal source that funnels through it (not just scouts) can produce a visual report. The research agent's presentation step (report_generation/research.py) returns charts alongside the title and summary; the caller activity (temporal/agentic/report.py) replaces SignalReport.charts with them, and a re-research is shown the report's current charts to keep, refresh, or drop. It is gated per team by the signals-report-charts flag (_team_report_charts_enabled, on in DEBUG) so it ships dark on the fleet-wide path. Unlike the scout emit path, these charts are not safety-judged: the pipeline judge screens the input signals before research runs, so it never sees research-authored charts — the same as it never sees the research-authored title/summary, which are agent output from already-screened signals.
Attribution. Every write helper (append_status / add_log / append_finding / append_dismissal) requires an ArtefactAttribution — exactly one of from_user(user_id) / from_task(task_id) / system() — so no write site can silently skip it. Agent writes are attributed deterministically: sandbox provisioning bakes the agent's task id into an X-Insights-Task-Id header on its MCP config, forwarded by the MCP server on every API call; the LLM never handles its own task id. The header is attribution metadata, not an authorization boundary (the token is team-scoped and the named task must belong to the same team).
Write surface. SignalReportArtefactViewSet exposes POST / PATCH / DELETE for any type (a status write appends a new latest-wins row), the bespoke suggested_reviewers PUT, and a diff action that renders a commit artefact's branch against the repository default branch via GitHubIntegration.get_diff (GitHub compare API, validated repo/ref/sha). All gated by scope_object = "task" (task:write). Custom agents queue artefacts during a run via CustomSignalAgent.register_artefact, persisted in the report's transaction and attributed to the agent's task — except commit (written automatically by the signed-commit harness) and task_run (written by report persistence), which never need registering there.
Task↔report association. A task_run artefact is the association (no link table): its task FK is the task it records. Associating is just POSTing a task_run (its content.task_id defaults from the header — "associate me"); the reports list accepts ?task_id=. Auto-start idempotency does not key on this freeform, API-mutable log — it uses a legacy SignalReportTask implementation row, which auto-start dual-writes alongside the task_run artefact (see Autonomy & Auto-Start).
Notes:
- The serializer still supports legacy
actionability_judgmentpayloads that usedchoiceinstead ofactionability.
Indexes: (report) (signals_sig_report__idx) plus a latest-wins (report, type, -created_at) index backing status derivation.
SignalTeamConfig
Per-team singleton config for Signals settings, including the default autonomy priority threshold.
| Field | Type | Description |
|---|---|---|
id |
UUID (PK) | Primary key (UUIDModel) |
team |
OneToOne → Team | Owning team (related_name="signal_team_config") |
default_autostart_priority |
CharField | Default severity threshold for auto-start (P0–P4, where P0 is highest) |
created_at |
DateTime | Auto-set on creation |
updated_at |
DateTime | Auto-set on save |
Notes:
- Auto-created as a team extension via
register_team_extension_signal default_autostart_prioritydefaults toP4(every report priority auto-starts). The threshold is no longer user-configurable in the inbox UI; everyone runs on this default.SignalUserAutonomyConfig.autostart_prioritycan still hold a per-user override at the data layer (null= use the team default), but there is no UI to set it.
SignalUserAutonomyConfig
Per-user opt-in config for Signals autonomy. Existence of a row means the user is opted in.
| Field | Type | Description |
|---|---|---|
id |
UUID (PK) | Primary key (UUIDModel) |
user |
OneToOne → User | The opted-in user (related_name="signal_autonomy_config") |
autostart_priority |
CharField (nullable) | Per-user priority override (P0–P4); null = use team's default_autostart_priority |
created_at |
DateTime | Auto-set on creation |
updated_at |
DateTime | Auto-set on save |
Notes:
- One row per user (enforced by
OneToOneField) - User is not scoped to a team — the autostart logic resolves team membership at runtime
- Managed via
PUT /v1/users/@me/signal_autonomy/(opt in / update) andDELETE(opt out)
SignalReportTask (legacy — implementation gate only)
The legacy report↔task link table. General task↔report association has moved to task_run artefacts (a task_run artefact's task FK is the association; purpose comes from its (product, type)). This table survives for one job: it's the auto-start idempotency gate. record_implementation_task dual-writes a relationship="implementation" row here and the task_run artefact; auto-start checks this table (not the freeform, API-mutable artefact log) when deciding whether an implementation has already started. Once backfill_task_run_artefacts has converted every legacy row into a task_run artefact, the gate can move to the artefact log and this table can be dropped.
SignalSourceConfig
Per-team configuration for which signal sources are enabled.
| Field | Type | Description |
|---|---|---|
team |
FK → Team | Owning team (related_name="signal_source_configs") |
source_product |
CharField | One of: session_replay, llm_analytics, github, linear, zendesk, conversations, error_tracking, signals_scout (SourceProduct enum) |
source_type |
CharField | One of: session_analysis_cluster, evaluation, evaluation_report, issue, ticket, issue_created, issue_reopened, issue_spiking, cross_source_issue (SourceType enum) |
enabled |
Boolean | Whether this source is active (default True) |
config |
JSONField | Source-specific configuration |
created_by |
FK → User | User who created the config (nullable) |
Behavioral notes:
llm_analyticssignals go through the standard enabled-row check like every other source. Per-resultevaluationsignals are additionally filtered by the per-evaluationevaluation_idsallowlist in the row'sconfig, enforced upstream in the eval workflows;evaluation_reportsignals are gated by their own(llm_analytics, evaluation_report)row (the inbox "AI observability" toggle).- For session replay configs, serializer validation enforces that
config.recording_filtersis a JSON object when present. - The serializer exposes a computed
statusfield:session_analysis_clusterderives status from the Temporal clustering workflow- data-import-backed sources (
github,linear,zendesk) derive status fromExternalDataSchema
- The
signals_scoutsource variant pairs withsource_type=cross_source_issueand is the emission channel used by the headless Signals agent'semit_signal_*tools. It is the only(source_product, source_type)pair the agent emits today.
Constraints: Unique on (team, source_product, source_type)
SignalScoutConfig
Per-scout binding for the headless Signals agent: one row per (team, skill_name). The coordinator auto-creates a row when it discovers a signals-scout-* skill on a participating team. Changes are activity-logged (they drive spend); team-level participation is gated by the signals-scout flag at the coordinator, not here. See backend/scout_harness/AGENTS.md for the harness internals.
| Field | Type | Description |
|---|---|---|
team |
FK → Team | Owning team (related_name="signal_scout_configs"). unique_together(team, skill_name). |
skill_name |
CharField | The signals-scout-* skill this row controls. Auto-registered by the coordinator when it finds the skill on a participating team. |
enabled |
Boolean | Per-scout switch; defaults True. Derived from status (enabled = status in (active, pending_pause)) but kept as a real column for SQL filtering and the warehouse mirror; save() reconciles the pair for writers that set only one side. |
status |
Char (choices) | Lifecycle source of truth: active / pending_pause / paused_by_system / paused_by_user. Who paused is the state itself: the system may resume its own pauses but must never touch a human's. pending_pause still runs (a warning, not a pause) and is cleared by any human config edit. |
pause_reason |
Char (nullable) | Why the system paused or warned: no_output (warn-only: silence flags a scout but never pauses it) / ignored (nobody acts on its reports: warns, then pauses) / repeated_failures. Also names the writer that owns the pause: a system writer may only move a pause carrying a reason it owns, and the inactivity sweep owns both of its reasons (transition_status_by_system()). Null outside pending_pause / paused_by_system. |
status_changed_at |
DateTime (nullable) | When status last changed. Anchors the cold-start grace window (in_cold_start_grace()): any move back to active re-anchors it, deliberately independent of the actor so the window survives account deletion. |
status_changed_by |
FK → User (nullable) | The human behind the last status change; null on system transitions, so a human re-enable and a system resume are distinguishable and a system pause never reads as a person's action. |
auto_pause_exempt |
Boolean | Opt-out from the daily inactivity sweep (scout_harness/inactivity.py, the no_output / ignored writer), for watchdog scouts whose whole job is staying quiet. Defaults False. Also set automatically when a human re-enables a scout the sweep paused, so the sweep never overrules a person twice. |
emit |
Boolean | Dry-run vs emit. Defaults True: a freshly authored scout is live from its first tick. Flip to False for dry-run — the scout runs and logs but emit_finding writes nothing — to validate it on a team before its findings reach the inbox. |
run_interval_minutes |
PositiveInteger | Minutes between runs. The coordinator dispatches rolling schedules when last_run_at is None or now - last_run_at >= run_interval_minutes. Default 1440 (daily). Validated 30 <= N <= 43200. |
run_cron_schedule |
Char (nullable) | Optional five-field cron expression anchoring runs to wall-clock slots; takes precedence over run_interval_minutes when set. Interpreted in the project's timezone so daylight-saving changes are applied automatically. Occurrences must be ≥ 30 minutes apart. Null keeps the rolling interval behavior. |
schedule_changed_at |
DateTime (nullable) | Stamped by the config serializers only when a schedule field actually changes. Anchors the cron due-check (with last_run_at / created_at) instead of updated_at, so an unrelated emit/enabled save can never defer an overdue scheduled run. |
last_run_at |
DateTime (nullable) | Stamped after each dispatch; drives the due-check. Excluded from activity logging (written every run). |
created_by |
FK → User (nullable) | Audit pointer |
enabled_by |
FK → User (nullable) | Who last flipped enabled — tracked because enablement drives spend. |
SignalScoutRun
Thin bridge from a Tasks TaskRun to the scout skill that ran inside it: one scout-domain row per scheduled agent run that links its TaskRun to the skill it executed. Status, timing, error context, and the full chat log live on the TaskRun; emitted findings are Signal / SignalReport rows written by emit_signal(). This row carries only the scout-specific fields that need to be queryable as real columns.
| Field | Type | Description |
|---|---|---|
team |
FK → Team | Owning team (related_name="signal_scout_runs"). Denormalised tenant boundary; canonical value is task_run.task.team. |
task_run |
OneToOne → tasks.TaskRun | The TaskRun the scout span ran inside (related_name="signal_scout_run", CASCADE — bridge row goes when the TaskRun is purged). |
scout_config |
FK → SignalScoutConfig (SET_NULL) | Audit pointer; SET_NULL so deleting and recreating a config doesn't destroy run history. |
skill_name |
CharField(200) | The signals-scout-* skill the run executed. |
skill_version |
Int | The LLMSkill.version snapshot at run start. |
summary |
TextField | One-paragraph close-out the agent writes at end-of-run. Searchable via ILIKE on the list endpoint so future runs can dedupe even when no Signal row was emitted. |
metadata |
JSONField | Server-written run context in two regions: runner-stamped top-level keys (always harness_prompt_version / report_channel / skill_origin; plus model / runtime_adapter / reasoning_effort when routing overrode the default) and the nested derived map of harness-computed booleans written at finalize. See below. |
created_at |
DateTime | Auto-set on creation. |
Status, timing, and chat log live on the linked TaskRun. The bridge row carries no status / started_at / completed_at / findings / run_metrics of its own — those moved to tasks.TaskRun so the LLM-analytics token / cost roll-up, the Tasks UI, and the harness all see one canonical record.
Run provenance is stamped, not re-derived. harness_prompt_version (a hash of scout_harness/prompt.py plus the imported constants its templates render, identifying the prompt build), report_channel (none/emit/edit/both, since the prompt branches on the two report capabilities separately), skill_origin, and github_guidance are written at run creation because none can be reconstructed later: the harness prompt carries no version history, allowed_tools is editable, and a seeded canonical skill row becomes custom as soon as a team edits it. Together they let a model or prompt A/B compare only runs that were given the same prompt shape. All four ride the signals_scout_run_started / _finished events too, which is where that readout happens.
Run metadata is derived, never self-reported. metadata.derived answers "what kind of run was this?" (has_emit_report, has_edit_report, has_self_improvement, has_chart, has_self_validation) and is computed server-side at finalize by scout_harness/derived_metadata.py from the run's own settled output. Asking the scout to declare these instead would make each flag only as reliable as the model remembering to write it, so the column stays queryable precisely because nothing in it is agent-authored. An absent derived region is unknown rather than all-false: the run predates the field, never reached finalize, or its stamp failed (best-effort, logged). MultiTurnSession owns the TaskRun lifecycle; the on_task_run_created hook attaches the TaskRun to the bridge row before the agent's first turn.
Tasks UI cross-link. Run serializers expose a computed task_url field on scout-runs-list and scout-runs-retrieve MCP responses — /project/{team_id}/tasks/{task_run.task_id}?runId={task_run_id}. task_url is null for rows whose task_run link is missing (rows aborted before MultiTurnSession.start() returned).
Indexes: (team, skill_name).
Constraints: None at the DB level today. Single-flight is a best-effort app-layer guard (_has_running_run against task_run.status = IN_PROGRESS). An earlier partial unique constraint on (team, skill_name) WHERE status='running' was dropped together with the bridge model's own status column; a task_run.status-based constraint plus active recovery of stranded IN_PROGRESS task runs (_self_heal_stale_runs is a no-op today) is a tracked follow-up.
SignalScratchpad
Narrow per-team scratchpad surface the scout fleet writes during runs and reads back on future runs (known issues, false positives, dedupe fingerprints, learned team quirks). Distinct from SignalProjectProfile: profile is deterministic ground truth, scratchpad is the scout's inferred learnings (possibly wrong). MCP-readable across agents so Insights AI and other scouts can see what the fleet has learned about a team.
| Field | Type | Description |
|---|---|---|
team |
FK → Team | Owning team (related_name="signal_scratchpads") |
key |
CharField(300) | Semantic key, agent-chosen; unique per team |
content |
TextField | Prose for prompt injection — the agent reads this verbatim |
created_by_run |
FK → SignalScoutRun (SET_NULL) | The run that wrote this entry; SET_NULL so deleting a run row doesn't destroy the entry |
created_at |
DateTime | Auto-set on creation |
updated_at |
DateTime | Auto-set on save |
Constraints: Unique on (team, key).
authority, tags, and expires_at (with their backing GIN / expiry indexes) were dropped in the PR2 review simplification — retrieval is now plain ILIKE over key + content, and every entry is durable per-team scratchpad.
SignalProjectProfile
Deterministic snapshot of "what's true about this project" — the agent's orientation surface. Time-series so future phases can diff a new profile against the previous row to populate payload.deltas. Computed by scout_harness/profile/builders.py from authoritative tables; v1 writes 10 inventory sections (events, properties, cohorts, feature flags, experiments, surveys, dashboards, insights, data warehouse sources, integrations).
| Field | Type | Description |
|---|---|---|
team |
FK → Team | Owning team (related_name="signal_project_profiles") |
computed_at |
DateTime | Auto-set on creation |
expires_at |
DateTime | Soft TTL — get_project_profile treats rows past expiry as cache misses and recomputes (~36h gives a safety margin against the daily refresh). |
source_version |
CharField(40) | Bumps when the inventory schema changes meaningfully so get_project_profile can invalidate stale rows without a manual backfill |
payload |
JSONField | {inventory: {...}} in v1; deltas, activity_notes, narrative slots reserved for later phases. Inline jsonb is fine — even a rich profile is small. |
Indexes: (team, -computed_at) — supports the ORDER BY computed_at DESC LIMIT 1 lookup used by get_project_profile.
SignalEmissionRecord
Tracks which source records have been emitted as signals. Owned by the signals app so source models (e.g. Ticket) stay decoupled. One row per source record, upserted on emission.
| Field | Type | Description |
|---|---|---|
team |
FK → Team | Owning team |
source_product |
CharField(100) | Mirror of SignalSourceConfig.SourceProduct |
source_type |
CharField(100) | Mirror of SignalSourceConfig.SourceType |
source_id |
CharField(200) | Source-side primary key |
emitted_at |
DateTime | When this record was last emitted |
Constraints: Unique on (team, source_product, source_type, source_id)
Indexes: (team, source_product, source_type)
Datastore Storage
Signals are stored in the insights_document_embeddings table, which is shared across products (error tracking, session replay, AI observability, etc.).
Table Schema
Defined in products/error_tracking/backend/embedding.py:
| Column | Type | Description |
|---|---|---|
team_id |
Int64 | Team identifier |
product |
LowCardinality(String) | Product bucket — signals uses 'signals' |
document_type |
LowCardinality(String) | Document type — 'signal' for signals, 'report' for report documents (see below) |
model_name |
LowCardinality(String) | Embedding model name (e.g., text-embedding-3-small-1536) |
rendering |
LowCardinality(String) | How content was rendered — signals use 'plain', report documents use 'title_summary_v1' |
document_id |
String | Unique signal ID (UUID) |
timestamp |
DateTime64(3, 'UTC') | Document creation time |
inserted_at |
DateTime64(3, 'UTC') | When the embedding was inserted (used for dedup) |
content |
String | The signal description text |
metadata |
String | JSON string containing report_id, source_product, source_type, source_id, weight, extra, match_metadata, deleted |
embedding |
Array(Float64) | The embedding vector |
Engine: ReplacingMergeTree (sharded), partitioned by toMonday(timestamp), 3-month TTL.
Ordering: (team_id, toDate(timestamp), product, document_type, model_name, rendering, cityHash64(document_id))
Data Flow
emit_embedding_request() → Kafka (document_embeddings_input topic)
→ Kafka table → Materialized View → Writable Distributed table → Sharded ReplacingMergeTree
Report Documents
Alongside the per-signal rows, each SignalReport gets one embedding of its own: document_type = 'report', document_id = the report UUID, content rendered from the report's title and summary.
This gives a report a single vector instead of only the cloud of constituent-signal vectors,
and is the feature-side building block for the inbox ranking model,
whose label stream is the signal_report_status_changed event emitted by backend/receivers.py.
Emission lives in backend/report_embeddings.py,
driven by a post_save receiver that fires whenever a report's title or summary actually changes.
That covers the matcher writing text at creation, the summary workflow on IN_PROGRESS -> READY, re-research runs, and the scout channel's update_authored_content.
Two properties are load-bearing:
- The row's
timestampis pinned to the report'screated_at, never the emission time. The table partitions bytoMonday(timestamp)and orders bytoDate(timestamp), so a re-emission stamped "now" would land in a different partition and sit alongside the earlier row rather than superseding it. The trade-off is that the 3-month TTL runs from report creation, so a report open longer than that loses its vector while still live. renderingis versioned (title_summary_v1) rather than'plain', because a report document is a composition of fields we expect to extend. Bumping to a v2 lets both compositions coexist and be compared instead of silently replacing each other.
Metadata is deliberately limited to report_id plus the deleted tombstone flag.
It only refreshes when the report's text changes or the report is deleted, so mutable state (status, priority, signal_count) would go stale there.
Join that from Postgres or the status-change event stream instead.
Safety gating and retraction
A report's title and summary are derived from its signals, so they inherit the same prompt-injection exposure.
Emission is therefore gated on the durable safety_judgment artefact: when the latest verdict is choice: false, no vector is written.
That read is pinned to the writer (using("default")), because it runs immediately after the transaction that wrote the verdict and ReplicaRouter documents replication lag on exactly that pattern.
Withholding new emissions is not sufficient on its own, because a report can be embedded while safe and only later be judged unsafe.
Three paths therefore retract an existing vector by re-emitting the row with metadata.deleted = true, preserving created_at so it replaces the live row in the same partition:
- Deletion: the report-level counterpart to
soft_delete_report_signals. Both the soft path, wheredelete_report_activityflips status toDELETED, and a harddelete()of the row, which is whatdelete_team_reports_activityand thecleanup_signalscommand issue. The hard path matters most: once the row is gone, no later write can retract the vector. - A later unsafe verdict: the summary workflow re-judges safety on every run, and a READY report re-researches whenever new signals join it.
- An unreviewed edit: the
PATCHendpoint and the scoutedit_reportchannel supply text the judge has never seen, so the report is retracted and left unindexed until the pipeline writes judged text again.
Tombstones carry fixed placeholder content (TOMBSTONE_CONTENT) rather than the report's own text.
Content is not part of the ReplacingMergeTree key, so a placeholder supersedes a live row just as well, and it means a tombstone can be emitted without first knowing whether a live row exists, which is the question none of these paths can answer cheaply.
It also guarantees a retraction can never introduce the very text the safety judge withheld.
This is a deliberate divergence from soft_delete_report_signals, which re-emits signal text verbatim and so makes rows filterable rather than erased.
Readers must filter with NOT JSONExtractBool(metadata, 'deleted'), as every existing signals query already does.
A caller removing reports in bulk should delete the reports and let their artefacts cascade, rather than deleting artefacts first.
Django reports the cascade as the artefact's deletion origin, which is what lets the verdict receiver skip reconciliation it does not need:
the report is going away in the same operation and retracts its own embedding.
Deleting artefacts directly costs two extra queries each, which delete_team_reports_activity cannot afford inside its five minute budget.
Soft Deletion
Signals are soft-deleted by re-emitting the embedding row with metadata.deleted = true, preserving the original timestamp so it lands in the same partition and replaces the original via ReplacingMergeTree. Most read queries filter with NOT JSONExtractBool(metadata, 'deleted'); the exception is wait_for_signal_in_datastore_activity, which deliberately includes deleted rows (it only cares that the row landed, not whether it's visible).
InsightsQL Queries
The canonical Datastore / InsightsQL helpers live in backend/temporal/signal_queries.py and use the InsightsQL alias document_embeddings. Most read queries share a dedup subquery that uses argMax(..., inserted_at) grouped by document_id, which makes reads stable despite ReplacingMergeTree merge timing.
All Signals queries filter to product = 'signals' and document_type = 'signal'. Key queries:
- Fetch signal type examples (
fetch_signal_type_examples_activity): fetches one example signal per unique(source_product, source_type)pair from the last month, selecting the most recent example per type. Used to give the search-query generation LLM context about heterogeneous signal types. - Semantic search (
run_signal_semantic_search_activity): usescosineDistance(embedding, {embedding})to find nearest neighbors with a non-emptyreport_id, limited to the last month. - Fetch for report (
fetch_signals_for_report_activity): fetches all non-deleted signals for a report, ordered by timestamp ascending. - Wait for Datastore (
wait_for_signal_in_datastore_activity): a two-tier poll tuned byWaitForDatastoreMode. Every attempt first checks the embedding worker's recently-seen store (a cheap key-value lookup overinsights.api.embedding_worker.async_get_recently_seen_documents). When a document already exists in Datastore, its cachedemitted_atmust be later than that document's latestinserted_at; newly created documents only require a cache hit. Once the store confirms every signal, the wait either ends outright (optimistic— grouping and report deletion) or runs the Datastore confirmation query (ch_confirmed— reprocessing). For the first 5 minutes the wait is otherwise store-exclusive; after that Datastore also polls on every 3rd attempt (a third of the store's rate) as a fallback, since the store is best-effort. The Datastore query counts emitteddocument_ids within a widened timestamp range (min(timestamp)-2mtomax(timestamp)+2m) andinserted_at >= now() - 30 minutes, which avoids matching stale earlier emissions of the same IDs while tolerating precision loss and queueing delay. This query intentionally does not filter ondeleted. - Filter reports by source product (
fetch_report_ids_for_source_products): used by the list API’ssource_productfilter. Note that it currently has a hardLIMIT 300.
execute_insightsql_query_with_retry() in backend/temporal/datastore.py wraps transient Datastore failures with heartbeat-safe retry behavior for activities.
Soft Deletion Caveat
soft_delete_report_signals() lives in backend/temporal/signal_queries.py (with a compatibility re-export in backend/utils.py), not in backend/api.py.
It soft-deletes report signals by re-emitting the same document_id and original timestamp with metadata.deleted = true, across all embedding models, so the new row replaces the original in the same ReplacingMergeTree partition.
One implementation caveat: the helper currently fetches report signals with limit=5000. So while the intent is “delete all signals for the report”, very large reports can currently leave undeleted rows beyond that cap.
API Layer
Entry Point: emit_signal() (backend/api.py)
The primary programmatic entry point. Called by other Insights products and Signals-related workflows to emit signals.
Current behavior:
- Verifies org-level AI data processing approval
- Checks
SignalSourceConfig.is_source_enabled() - Validates the payload against
insights.schema.SignalInput - Idempotently starts
BufferSignalsWorkflow - Fire-and-forget starts
SignalEmitterWorkflow
So emit_signal() now feeds the buffer + emitter + grouping v2 path rather than signaling the legacy v1 grouping workflow directly.
Guards / validation:
- The team’s organization must have
is_ai_data_processing_approved - The source must be enabled by
SignalSourceConfig.is_source_enabled() - Description length is capped at
MAX_SIGNAL_DESCRIPTION_TOKENS(currently 8000 tokens) - The input must validate against the shared
SignalInputschema
Utility: soft_delete_report_signals()
The canonical helper lives in backend/temporal/signal_queries.py (with a compatibility re-export in backend/utils.py), not in backend/api.py.
It soft-deletes report signals by re-emitting them with metadata.deleted = true while preserving original timestamps so rows replace originals via ReplacingMergeTree. It intentionally includes already-deleted rows for idempotency, but currently fetches at most 5000 signals per report.
REST Endpoints
All signals endpoints live under the projects/:team_id/signals/ path, registered on the projects_router in insights/api/__init__.py.
SignalViewSet (DEBUG only)
| Method | Path | Description |
|---|---|---|
| POST | signals/emit/ |
Manually emit a signal (debug/testing only) |
InternalSignalViewSet
Internal-only service-to-service endpoint authenticated via X-Internal-Api-Secret.
| Method | Path | Description |
|---|---|---|
| POST | /v1/projects/{team_id}/internal/signals/emit |
Emit a signal for a specific team |
SignalSourceConfigViewSet
Full CRUD for per-team signal source configurations. Uses IsAuthenticated + APIScopePermission with scope object task.
| Method | Path | Description |
|---|---|---|
| GET | signals/source_configs/ |
List configs for the team |
| POST | signals/source_configs/ |
Create a new config |
| GET | signals/source_configs/{id}/ |
Retrieve a config |
| PATCH | signals/source_configs/{id}/ |
Update a config |
| DELETE | signals/source_configs/{id}/ |
Delete a config |
Important side effects:
- Creating or enabling a
session_analysis_clusterconfig starts the clustering workflow - Creating an enabled
error_tracking / issue_createdconfig starts the error-tracking backfill workflow - Enabling data-import-backed sources can trigger external data syncs
- Disabling a clustering config cancels the clustering workflow
SignalTeamConfigViewSet
Team-scoped singleton config for the default autonomy priority threshold. Uses IsAuthenticated + APIScopePermission (scope: task). Returns 404 if no config exists for the team.
| Method | Path | Description |
|---|---|---|
| GET | signals/config/ |
Retrieve the team's SignalTeamConfig |
| POST | signals/config/ |
Update default_autostart_priority |
User Autonomy Config (action on UserViewSet)
Per-user autonomy opt-in, registered as an action on the root UserViewSet at api/users/:uuid/signal_autonomy/.
| Method | Path | Description |
|---|---|---|
| GET | users/@me/signal_autonomy/ |
Get own autonomy config (404 if not opted in) |
| PUT | users/@me/signal_autonomy/ |
Opt in / update autostart_priority |
| DELETE | users/@me/signal_autonomy/ |
Opt out (deletes the SignalUserAutonomyConfig row) |
Notes:
- Non-staff users can only access
@me; staff can access any user by UUID PUTbody:{"autostart_priority": "P2"}or{"autostart_priority": null}(use team default)- Inherits auth/permissions from
UserViewSet(scope:user)
SignalReportViewSet
Read + delete + state transitions. Uses IsAuthenticated + APIScopePermission (scope: task). Composed from RetrieveModelMixin, ListModelMixin, DestroyModelMixin, and GenericViewSet. Deleted reports are excluded from all endpoints via safely_get_queryset.
| Method | Path | Description |
|---|---|---|
| GET | signals/reports/ |
List reports. Excludes deleted always and excludes suppressed by default. Supports ?status=, ?search=, ?source_product=, ?suggested_reviewers=, ?task_id= (resolved through task_run artefacts), and ?ordering=. |
| GET | signals/reports/{id}/ |
Retrieve a single report |
| DELETE | signals/reports/{id}/ |
Soft-delete a report and its signals. Starts SignalReportDeletionWorkflow. On success returns 202. If the workflow is already running, returns 200 {"status": "already_running"}. The API immediately transitions the Postgres report to deleted to hide it from list results while Datastore cleanup runs asynchronously. |
| POST | signals/reports/{id}/state/ |
Transition report state. Body: { "state": "suppressed" | "potential" | "resolved", ...transition_to kwargs }. suppressed, potential, and resolved are exposed via API (resolved for user-driven "work is done"; the model still gates which statuses may resolve). Returns 409 on invalid transitions and 400 on invalid arguments. |
| POST | signals/reports/{id}/reingest/ |
Delete a report and re-ingest its signals. Starts SignalReportReingestionWorkflow. On success returns 202. If already running, returns 200 {"status": "already_running"}. Same team access as other report endpoints; personal API keys need task:write. |
| GET | signals/reports/{id}/artefacts/ |
List all artefacts for a report, ordered by -created_at |
| GET | signals/reports/{id}/signals/ |
Fetch all signals for a report from Datastore, including full metadata |
| GET | signals/reports/available_reviewers/ |
List available suggested reviewers for the team |
Ordering: Configurable via ?ordering= with comma-separated fields. Supported fields: status, is_suggested_reviewer, signal_count, total_weight, priority, created_at, updated_at, id.
The status clause sorts by annotated pipeline_status_rank (not lexicographic status text). Ascending rank lists earlier pipeline stages first. Ranks:
0—readyand actionable (includes reports with no actionability judgment yet)1—readyand latest actionability judgment isnot_actionable2—pending_input3—in_progress4—candidate5—potential6—failed7—suppressed8—deleted(deleted rows are not returned by the API; rank exists for queryset consistency)
So with ordering=status, failed sorts after actionable ready. With ordering=-status, failed sorts before actionable ready.
Default ordering is -is_suggested_reviewer,status,-updated_at,id.
Dismissal feedback. dismissal_reason / dismissal_note on the state and bulk-state bodies persist as a stacking dismissal artefact on the report, which stays the record of truth.
When the caller typed a note, that feedback is also forwarded to a SignalScoutNote (dismissal_notes.forward_dismissal_note).
The artefact alone only reaches a scout if some later run happens to search the inbox and land on that report; notes are read by name at the start of every run, which is where a "this was noise, and here is why" verdict belongs.
"Forward" rather than "promote" because promotion in this product means the pipeline moving a report up the status ladder to candidate (promoted_at, signals_at_run, re-promotion), and nothing here changes a report's standing.
Forwarding targets the scout that authored the report (resolved in Postgres from SignalScoutRun.emitted_report_ids, then edited_report_ids) and addresses the whole fleet when no run claims it, and it writes one note per targeted scout per request, so a bulk dismissal of 40 reports under one note does not write 40 notes.
Each note describes the status the report actually landed in, not the requested target, so a restore out of the archive is never reported to a scout as a snooze.
A report that ends up resolved is never forwarded: resolving says the report did its job, so the note on it records how the work landed rather than whether filing it was worth it, and this channel is for the latter.
Derived notes carry origin=report_dismissal and a 30-day TTL so they cannot permanently crowd out deliberate human steering, and a failure to write one never fails the transition the caller asked for.
Because the notes table is otherwise gated to skill-authoring authorization (scouts read note content verbatim while holding privileged sandbox tools), forwarding re-checks that the dismisser could have left the note by hand: canonical-project access, a token whose scoped_teams cover that project, and the llm_skill editor level, mirroring SignalScoutNoteViewSet (dismissal_notes._may_steer_scouts).
In practice the llm_skill leg only bites in orgs with the access-control entitlement where an admin has explicitly restricted skill editing, since default_access_level grants editor otherwise; the token scoped_teams and child-environment legs are what constrain the common case.
A dismisser who can't clear that bar still gets their feedback recorded on the report; it just doesn't enter the steering channel.
Two audience rules follow from a note living on the canonical project while the report it quotes may not.
Reports on a child environment are never forwarded, since the note would be readable by people with no access to that environment.
And on the read side, scout-notes-list withholds report_dismissal notes from callers who can't read reports (task scope plus task RBAC), so the notes surface can't be used to read report ids, titles, and dismissal text around the reports API (scout_harness/views._may_read_reports).
SignalReportArtefactViewSet
The artefact log read/write surface, nested under the reports router (environment_signal_report_artefacts). Team-scoped via safely_get_queryset (report id from the URL + self.team); gated by scope_object = "task".
| Method | Path | Description |
|---|---|---|
| GET | signals/reports/{id}/artefacts/ |
List all artefacts for a report, ordered by -created_at |
| GET | signals/reports/{id}/artefacts/{aid}/ |
Retrieve one artefact |
| POST | signals/reports/{id}/artefacts/ |
Append an artefact of any type (status types append a new latest-wins row); per-type schema validation |
| PATCH | signals/reports/{id}/artefacts/{aid}/ |
Edit a log/status row's content in place |
| DELETE | signals/reports/{id}/artefacts/{aid}/ |
Delete an artefact row |
| GET | signals/reports/{id}/artefacts/{aid}/diff/ |
Render a commit artefact's branch against the repo default branch via GitHubIntegration.get_diff |
POSTing a task_run artefact is the "associate me" operation: content.task_id defaults from the X-Insights-Task-Id header, product/type default to tasks/agent_run, the named task must belong to the team, and re-association is idempotent. The bespoke suggested_reviewers PUT (reviewer enrichment) also lives here. Writes are attributed to the header's task when present, else the requesting user.
SignalProcessingViewSet
View + control API for the v2 grouping pipeline. Uses scope object INTERNAL.
| Method | Path | Description |
|---|---|---|
| GET | signals/processing/ |
Return current pause state |
| PUT | signals/processing/pause/ |
Pause grouping until a given timestamp |
| DELETE | signals/processing/pause/ |
Clear the paused state |
Signals Agent endpoints (backend/scout_harness/views.py)
The harness exposes three viewsets routed under environment_signals_scout_* basenames in insights/api/__init__.py. They are surfaced to MCP callers as scout-* tools via products/signals/mcp/tools.yaml. Reads are scoped to the team; writes (scratchpad remember / forget, signal emit) require the matching MCP scope.
SignalScoutRunViewSet— list / retrieve scout run rows; nested actionruns/{id}/emit-signal/for the harness to push findings during a run.SignalScratchpadViewSet— search / remember / forgetSignalScratchpadrows for the team. Thescout-scratchpad-searchtool is the agent's primary "what do I already know" read at prompt-assembly time.SignalProjectProfileViewSet—GET .../current/returns the freshest non-expiredSignalProjectProfilerow for the team (recomputes if the cache is stale).
Generated MCP tool names:
| Tool | Purpose |
|---|---|
scout-runs-list |
List scout runs (filterable by skill / status / time) |
scout-runs-retrieve |
Fetch a single run row including the full findings payload |
scout-emit-signal |
Push a finding from inside a run (used by the harness's emit_signal_* tools) |
scout-scratchpad-search |
Search durable scratchpad entries for the team |
scout-scratchpad-remember |
Create or update a scratchpad entry |
scout-scratchpad-forget |
Remove a scratchpad entry |
scout-project-profile-get |
Read the current SignalProjectProfile snapshot |
Serializers (backend/serializers.py)
SignalSourceConfigSerializer- Exposes
id,source_product,source_type,enabled,config,created_at,updated_at,status - Validates that
recording_filtersin config is a JSON object forsession_replay - Computes
statusfrom the clustering workflow or external data import state depending on the source
- Exposes
SignalTeamConfigSerializer- ModelSerializer for
SignalTeamConfig - Exposes
id,default_autostart_priority,created_at,updated_at
- ModelSerializer for
SignalUserAutonomyConfigSerializer- ModelSerializer for
SignalUserAutonomyConfig - Exposes
id,user(nested withid,uuid,first_name,last_name,email),autostart_priority,created_at,updated_at
- ModelSerializer for
SignalUserAutonomyConfigCreateSerializer- Plain Serializer for PUT requests on the user autonomy action
- Accepts
autostart_priority(optional, nullable)
SignalReportSerializer- Exposes
id,title,summary,status,total_weight,signal_count,signals_at_run,created_at,updated_at,artefact_count,priority,actionability,already_addressed,is_suggested_reviewer prioritycomes from the latestPRIORITY_JUDGMENTartefactactionabilitycomes from the latestACTIONABILITY_JUDGMENTartefact and supports both current (actionability) and legacy (choice) payloadsalready_addressedalso comes from the latest actionability artefactis_suggested_reviewer: list/detail annotate from the requesting user’s linked GitHub login against thesuggested_reviewersartefact. Alwaysfalsewhen there is nothing to review:failedreports, orreadywith latest actionabilitynot_actionable(even if the artefact names the user)
- Exposes
SignalReportArtefactSerializer- Exposes
id,type,content,created_at - Parses JSON text into structured content
- For
suggested_reviewers, enriches the stored GitHub-only payload with fresh Insights org-member data at read time
- Exposes
Analytics Events
All events use distinct_id = team.uuid and groups(organization, team). Per-signal events carry source_product, source_type, source_id — pivot on source_id to trace one signal.
Lifecycle (in order):
signal_data_source_entered/signal_data_source_summarized/signal_data_source_filtered— data-source pipeline only (pipeline.py)signal_emission_started—emit_signal()past validationsignal_emitted—emit_signal()after Temporal dispatch succeedssignal_assigned_to_report— grouping assigned the signal (+report_id,is_new_report,promoted)signal_report_reresearch_skipped— signal hit an already-researched report past the re-research cap, so no new run spawned (+report_id,signal_count,status,threshold). Fires per suppressed signalsignal_report_quota_paused— a quota gate observed the team's org over its self-driving credits limit (+stage:promotion|summary_entry|pre_repo_selection|pre_research|autostart|manual_create|task_create|implementation_run,enforced,report_id). See Billing limit enforcementsignal_report_started— report run began (+report_id,signal_count,run_count,source_products)signals_repo_research_started/signals_repo_research_completed— repo selection stage (+report_id,result:reused|selected|no_repo|failed, optionalfailure_reason:no_github_integration|agentic_activity_error)signal_report_completed— terminal per run (+result:ready|failed|pending_input|not_actionable, optionalfailure_reason)
Tracing one signal: filter on properties.source_id = <id> to follow it through the funnel, then pivot to properties.report_id from signal_assigned_to_report to see the report's lifecycle.
Telemetry is best-effort; failures are logged, not raised.
LLM Integration
Most direct LLM calls use Anthropic via the shared call_llm() helper in backend/temporal/llm.py, with model selection driven by SIGNAL_MATCHING_LLM_MODEL (default: claude-sonnet-4-5).
That said, not all “LLM-ish” behavior in Signals goes through call_llm() anymore:
- Grouping-time query generation / matching / specificity checks use
call_llm() - The buffer safety filter uses
call_llm() - The report safety judge uses
call_llm() - Eval-signal summarization uses
call_llm() - Repository selection runs via the sandbox agent flow, not
call_llm() - Agentic report research runs via
MultiTurnSessioninreport_generation/research.py, notcall_llm()
call_llm() (backend/temporal/llm.py)
A generic helper that abstracts the retry / validate / append-errors pattern used by direct LLM calls. It takes a system prompt, user prompt, validation function, and options like thinking, temperature, and retries.
Key behaviors:
- Retry with conversational self-correction: on validation failure, the full response content is appended as an assistant message, followed by the validation error as a user message
- JSON enforcement: for non-thinking calls, the helper pre-fills the assistant response with
{to discourage markdown fences; for thinking calls it strips common fenced-JSON wrappers - Extended thinking: when
thinking=Trueand the selected model supports it, enables Anthropic extended thinking with increased token budgets - Debug logging: in
DEBUG, logs raw failed responses to help tune prompts / validators
Grouping-time LLM calls
The architecture doc previously attributed these functions to backend/temporal/llm.py, but that file is only the helper. The grouping-specific call sites live in backend/temporal/grouping.py.
Search-query generation
Generates 1–3 cross-source search queries for a signal from different angles (feature/component, behavior/issue, impact), using recent signal type examples as context. Queries are truncated before embedding.
Match-to-report decision
Chooses between:
- matching the signal to an existing report candidate
- creating a new report candidate with title + summary
The validator ensures the returned signal_id and query_index are valid for the candidate set given to the model.
Match-specificity verification
A second grouping-time LLM check used before broadening an existing report too aggressively.
Safety filter (backend/temporal/safety_filter.py)
Per-signal safety classifier that runs in the buffer workflow before signals are flushed to object storage.
It classifies raw signal descriptions against a threat taxonomy including prompt injection, hidden instructions, encoded payloads, security weakening, data exfiltration, social engineering, and code injection.
Returns:
{"safe": true, "threat_type": "", "explanation": ""}{"safe": false, "threat_type": "...", "explanation": "..."}
If the provider returns an empty response, the signal is treated as unsafe with threat type provider_safety_filter.
This is the first line of defense; it prevents adversarial signals from consuming embedding / search / matching work.
Report safety judge (backend/temporal/report_safety_judge.py)
Report-level safety review that runs before repository selection and agentic research. It evaluates the underlying grouped signals for prompt injection or manipulation attempts that could steer a downstream coding agent toward malicious actions.
Returns {"choice": bool, "explanation": "..."} and stores the result as a safety_judgment artefact. Extended thinking is enabled.
Importantly, this judge currently assesses the signals, not a generated title/summary — those do not exist yet when the judge runs.
Agentic research outputs (backend/report_generation/research.py)
The old standalone summary / actionability judge files referenced in earlier docs no longer exist. Their responsibilities now live inside the multi-turn research flow and its persisted output models.
The research flow produces:
SignalFindingsignal_idrelevant_code_pathsrelevant_commit_hashesdata_queriedverified
ActionabilityAssessmentexplanationactionabilityalready_addressed
PriorityAssessmentexplanationpriority
ReportPresentationOutputtitlesummary
These are assembled into ReportResearchOutput, then persisted by run_agentic_report_activity.
A task_run artefact (type=research) is appended immediately after the MultiTurnSession starts (before any research turns), associating the sandbox Task with the report and attributing it to that task.
Autonomy & Auto-Start
The autonomy system allows Signals to automatically start a Tasks coding run when a report is immediately actionable.
Configuration
Autonomy is configured at two levels:
-
Team level (
SignalTeamConfig): Sets thedefault_autostart_prioritythreshold (P0–P4). Auto-created as a team extension viaregister_team_extension_signal. Managed viaGET/POST /v1/projects/:team_id/signals/config/. -
User level (
SignalUserAutonomyConfig): Per-user opt-in. A row existing means the user is opted in. Each user can optionally override the team priority threshold withautostart_priority. Managed viaGET/PUT/DELETE /v1/users/@me/signal_autonomy/.
The management command enable_signals_autonomy can set both in one shot:
python manage.py enable_signals_autonomy <team_id> <priority> <emails>
Auto-Start Flow
Runs inside maybe_autostart_implementation_task() in backend/auto_start.py, called after artefact persistence in both run_agentic_report_activity (the signals pipeline) and run_custom_signal_agent_activity (custom agents).
Guard clause — all must pass:
- Report actionability is
immediately_actionable - Report is not
already_addressed— which covers both a fix that has landed and one already in flight (an open PR, a recently active branch, an assigned / in-progress issue or agent task). Research is instructed to check for in-flight work and set the flag, so this gate is what keeps us off work a human or another agent already has going; the implementation prompt repeats the check as a pre-flight - Report has a
priority_judgment - Report has suggested reviewers
- No legacy
SignalReportTaskimplementation row exists for the report (checked inside aselect_for_updateon the report row, so concurrent evaluations can't double-start)
User selection via _resolve_autostart_assignee() in backend/auto_start.py:
- Map reviewer GitHub logins to Insights user IDs via social auth (preserving reviewer relevance order)
- Single query: fetch
Userobjects whose ID is in that list and who have aSignalUserAutonomyConfigrow (joined viaselect_related) - Walk candidates in reviewer order. For each user:
a. Verify team membership via
user.teams.filter(id=team_id).exists()b. Resolve their effective priority threshold: personalautostart_priorityif set, otherwise the team'sdefault_autostart_priorityc. Ifreport_priority_rank <= threshold_rank→ return that user - If no user matches → skip
Task creation:
Task.create_and_run(origin_product=SIGNAL_REPORT, ...)record_implementation_taskwrites the legacySignalReportTaskimplementation gate row (in the same transaction) and appends animplementationtask_runartefact- Errors are caught and logged but do not fail the report workflow
Priority Rank
P0 is the highest severity (rank 0), P4 is the lowest (rank 4). A report auto-starts only if report_priority_rank <= user_threshold_rank. So a team with default_autostart_priority=P4 will auto-start on any priority, while P0 will only auto-start for the most critical reports.
Task Tracking
Report ↔ task relationships are recorded as task_run artefacts (see SignalReportArtefact), one per associated run, with the run's purpose carried in the artefact's (product, type). The built-in pipeline writes product="signals" with type in {research, implementation, repo_selection}; custom agents supply their own identifier() pair.
Auto-start dedup is separate from this freeform log: maybe_autostart_implementation_task() (backend/auto_start.py) gates on a legacy SignalReportTask implementation row, checked inside the report-row select_for_update, so concurrent evaluations can't double-start. Both the auto-start and the manual tasks-API path go through record_implementation_task, which dual-writes that gate row and the implementation task_run artefact — the transitional arrangement until the backfill lets the gate move to artefacts (see SignalReportTask).
Eval-signal summarization (backend/temporal/emit_eval_signal.py)
Separate from report generation, the emit-eval-signal workflow uses call_llm() with extended thinking to turn an LLMA evaluation result into a signal-sized description plus significance score. Low-significance eval results are dropped before calling emit_signal().
Resetting self-driving state for local re-testing
The self-driving wizard (npx @hanzo/wizard … self-driving) enables signal sources, materializes and enables the scout fleet, and creates custom signals-scout-* skills for the project. To re-test a run from a clean slate without manually undoing each change, use the dev-only reset_signals_self_driving command (the practical inverse of enable_signals_autonomy; DEBUG-gated like cleanup_signals):
python manage.py reset_signals_self_driving --team-id 1 --yes \
--install-dir /path/to/your/test/project
For a fresh team the signals config tables are empty, so the reset deletes rather than disables — the next wizard run's sync call re-creates the canonical fleet (enabled) exactly as it would for a brand-new team. (Disabling instead would leave the fleet off on the next run, since the wizard's scout step only disables misfits and relies on the fresh default being enabled.)
Cleared for the team:
| What | Tables / artifacts |
|---|---|
| Signal sources | SignalSourceConfig (all rows) |
| Scout fleet config | SignalScoutConfig (all rows) |
| Custom scouts | LLMSkill rows whose name starts signals-scout- and are not seeded — i.e. not in the set of names carrying metadata.seeded_by == "signals_scout_harness" (covers the common case where the key is absent entirely) (cascades LLMSkillFile) |
| Scout run-state | SignalScratchpad, SignalProjectProfile, SignalScoutRun, SignalEmissionRecord |
| Emitted findings | SignalReport + artefacts + Datastore rows + Temporal workflows (delegates to cleanup_signals; skip with --keep-findings) |
| Wizard report | <install-dir>/insights-self-driving-report.md (only if --install-dir is given) |
| Wizard log | /tmp/insights-wizard.log → backed up to /tmp/insights-wizard-previous-<timestamp>.log then removed (override --wizard-log, skip --keep-log) |
Preserved: canonical scouts and the authoring-scouts companion, identified by metadata.seeded_by == "signals_scout_harness". That tag is the practical marker this DEBUG reset uses; it is not a perfect canonical test on its own — _scout_origin also requires the name to ship on disk, since duplicate_skill copies the tag verbatim — but the wizard authors custom scouts via llma-skill-create with no tag, so tag-only suffices here. The command does not touch SignalTeamConfig or SignalUserAutonomyConfig (autostart / per-user opt-in are set by enable_signals_autonomy, not the wizard); llm_analytics sources are gated by their SignalSourceConfig rows like any other source.
Billing limit enforcement
Self-driving bills a flat credit charge per report that ships an implementation PR (see backend/billing.py),
so a team whose org is over its signals_credits quota must stop generating new research and PRs — including flows already mid-pipeline.
The authority for "over quota" is the Redis quota-limiter flag (ee.billing.quota_limiting, resource signals_credits),
read through quota.is_team_signals_quota_limited() and bundled with the rollout flag by quota.self_driving_quota_gate().
The quota is org-level (the org's billing cap): usage from every team in the org counts against one shared limit.
When the org crosses it, the quota cron fans the limited flag out to every team token in the org, so the per-team check reads an org-wide verdict and all of an org's teams pause together.
Enforcement is feature-flag-gated for rollout (self-driving-quota-enforcement, org-keyed).
Gates always run and emit telemetry when the team is limited;
they only block when the flag is on, so the would-block volume is measurable before enablement (enforced=false events are dark-launch rows).
Every check fails open — a Redis or flag-read error lets work through rather than stalling the fleet — and the bypass is alertable via signals_quota_check_failed_open_total.
Gates, in pipeline order:
| Stage | Where | Behavior when limited (enforced) |
|---|---|---|
| ingestion | check_signals_quota_limited_activity (buffer flush) |
Batch dropped before safety filter / flush (pre-existing gate) |
| scout runs | run_signals_scout_activity + manual-trigger API |
Run skipped / trigger 429s (pre-existing gate) |
promotion |
assign_and_emit_signal_activity |
Signal still assigned, weighted, and emitted, but the report is not promoted — no summary run spawns; status left untouched |
summary_entry |
SignalReportSummaryWorkflow, before any work |
Workflow exits; report stays candidate |
pre_repo_selection / pre_research |
SignalReportSummaryWorkflow, between the heavy steps |
Workflow reverts the report in_progress → candidate and exits; an in-flight research activity is never interrupted |
autostart |
maybe_autostart_implementation_task (all callers) |
No implementation task is created — the step that would lead to the billable PR |
manual_create / task_create |
tasks facade create_task / create_and_run_task |
Creating a self-driving implementation task (e.g. "start work" from a report) returns 402 with a raise-the-limit message; create_pr=False sessions (research, repo selection) are exempt. task_create is the facade backstop behind auto-start, kept as its own stage so the manual path stays attributable |
implementation_run |
enforce_self_driving_run_quota (process-task workflow) |
A PR-opening (create_pr) run that hasn't recorded its PR yet re-checks every 5 minutes and cancels itself through the standard cancellation path |
The billable event re-evaluates the quota immediately.
When a self-driving-origin run records its first PR URL (agent report, PATCH, or GitHub webhook backstop), the tasks facade queues refresh_org_self_driving_quota (Celery), which recomputes the org's live signals_credits usage and re-runs the Redis limiter — so the PR that crosses the limit flips the flag within seconds instead of at the next 15-minute quota cron tick.
The cron remains the backstop.
One timing edge: the live count is keyed to the implementation run's creation day (UTC), so a PR recorded just after midnight by a run created before midnight falls in the previous day's window and does not move the live counters.
It reaches enforcement hours later, via that day's usage report; the charge itself still lands in the correct day.
A quota-cancelled run leaves only the report.
release_quota_cancelled_implementation removes the run's SignalReportTask gate row and implementation task_run artefact (which would otherwise permanently block re-implementation) and appends a system note artefact explaining the stop.
Reports that already shipped a billable PR (through a sibling run of the same task) are skipped entirely: that gate row is billing's evidence for the charge, and deleting it would re-bill the report on its next implementation and break refund eligibility.
Runs that already recorded a PR URL are never cancelled: the report is already billed, so finishing the run and its CI follow-ups costs nothing more.
The release path is deliberately not hardened against a PR landing concurrently with the cancel (or an earlier run's PR on the same task): a PR that slips through ships un-billed.
Enforcement exists to stop runaway generation, not to guarantee billing capture, so under-billing errs in the customer's favor.
A cancel that errors part-way is logged loudly (self_driving_quota_cancel_failed) rather than counted as a quota-check fail-open, and nothing is released:
a still-alive run gets the cancel retried on the next recheck, while a run the completion signal did reach keeps its implementation records until they are released manually.
The pause is blanket: billing-exempt reports pause too.
Every gate reads only the org-level quota verdict; none consults a report's billing_exempt_reason, so Insights-system reports (health checks, engineering analytics, exempt scout skills) — which never consume the quota — pause alongside billable work while the org is over its cap.
This is deliberate: a single org-level contract ("over the cap → self-driving pauses") stays simple to reason about and support, the paused work is Insights-generated and resumes organically once the limit lifts, and an org at its spend ceiling also stops consuming agent compute for non-billable reports.
Resume is organic, never automatic.
Nothing re-spawns paused work when the limit is raised or the billing period resets:
a candidate report re-promotes on its next matching signal (ingestion unmutes once the team is no longer limited),
and a ready report's implementation starts only when a later research cycle re-evaluates auto-start (or a user starts it manually).
Telemetry: every gate observation emits signal_report_quota_paused with stage, enforced, report_id (null at stages without one), team_id, and organization_id.
Promotion-stage events are emitted only when the signal would actually have promoted the report, so the volume measures withheld summary runs.
Data Types (backend/temporal/types.py)
| Type | Description |
|---|---|
EmitSignalInputs |
Workflow input: team_id, source_product, source_type, source_id, description, weight, extra |
BufferSignalsInput |
Buffer workflow input: team_id, pending_signals: list[EmitSignalInputs] (carried over on continue_as_new) |
TeamSignalGroupingV2Input |
Grouping v2 workflow input: team_id, pending_batch_keys: list[str] (carried over on continue_as_new) |
TeamSignalGroupingInput |
Legacy v1 entity workflow input: team_id, pending_signals: list[EmitSignalInputs] (carried over on continue_as_new) |
ReadSignalsFromS3Input |
Activity input: object_key |
ReadSignalsFromS3Output |
Activity output: signals: list[EmitSignalInputs] |
SignalCandidate |
Search result: signal_id, report_id, content, source_product, source_type, distance |
MatchedMetadata |
Metadata when matched to existing report: parent_signal_id, match_query, reason |
NoMatchMetadata |
Metadata when no match found: reason, rejected_signal_ids |
MatchMetadata |
Union type: MatchedMetadata | NoMatchMetadata |
ExistingReportMatch |
LLM decided signal matches existing report: report_id, match_metadata: MatchedMetadata |
NewReportMatch |
LLM decided signal needs new group: title, summary, match_metadata: NoMatchMetadata |
MatchResult |
Union: ExistingReportMatch | NewReportMatch |
SignalReportSummaryWorkflowInputs |
Summary workflow input: team_id, report_id |
SignalReportDeletionWorkflowInputs |
Deletion workflow input: team_id, report_id |
SignalReportReingestionWorkflowInputs |
Reingestion workflow input: team_id, report_id |
SignalTypeExample |
One example per (source_product, source_type) pair: source_product, source_type, content, timestamp, extra |
SignalData |
Signal fetched from Datastore: signal_id, content, source_product, source_type, source_id, weight, timestamp, extra |
Rendering Helpers
render_signal_to_text(signal, index=None) and render_signals_to_text(signals) in types.py provide a canonical text representation of signals for LLM prompts. All signal rendering in prompts goes through these helpers to ensure consistency. Each signal renders as:
Signal {index}:
- Source: {source_product} / {source_type}
- Weight: {weight}
- Timestamp: {timestamp}
- Description: {content}
Key Configuration
| Setting | Default | Description |
|---|---|---|
SIGNAL_WEIGHT_THRESHOLD |
1.0 |
Total weight needed to promote a report to candidate |
SIGNAL_MATCHING_LLM_MODEL |
claude-sonnet-4-5 |
LLM model for all signal operations |
MAX_RESPONSE_TOKENS |
4096 |
Base max tokens for LLM responses (thinking uses 3× for max_tokens, 2× for budget) |
| Embedding model | text-embedding-3-small-1536 |
OpenAI embedding model used for signal content |
| Task queue | VIDEO_EXPORT_TASK_QUEUE |
Temporal task queue for all workflows |
BUFFER_MAX_SIZE |
20 |
Max signals buffered in memory before flush to S3 |
BUFFER_FLUSH_TIMEOUT_SECONDS |
5 |
Max seconds to wait for buffer to fill before flushing |
| S3 prefix | signals/signal_batches/ |
Object storage path for signal batch files (cleaned up by S3 lifecycle policies) |
COORDINATOR_INTERVAL_MINUTES |
30 |
Signals agent coordinator poll cadence (Temporal schedule, SKIP overlap policy) |
MAX_RUNS_PER_TICK |
50 |
Hard cap on planned runs per coordinator tick (most-overdue-first, truncated after sort) |
SignalScoutConfig.run_interval_minutes |
1440 |
Per-scout default schedule in minutes (daily); due-check, no sampling (30–43200) |
SignalScoutConfig.run_cron_schedule |
None |
Optional project-local cron schedule (overrides the interval); null keeps the rolling interval |
SignalScoutConfig.emit |
True |
Per-scout emit gate — defaults emit-on; flip to False for dry-run (scout runs and logs, but emit_finding writes nothing) |
Testing refunds locally
How to exercise the PR refund flow (backend/billing.py, the refund action, the credited-path billing sync) against a fully local two-service setup:
- Billing service: follow "Developing locally" in the billing repo's README (boots on
http://localhost:8100). The dispute endpoint (POST /v1/signals/dispute-pr) must exist on the billing checkout. Dev mode auto-creates theLicenseandCustomerrows on first authenticated call — nothing to seed there. - Insights: start the stack with
BILLING_SERVICE_URL=http://localhost:8100visible to both the web backend and the celery worker (put it in.env.local, which wins over.env.development). The dev license auto-creates as…::license-so-secret, matching billing's defaultLICENSE_SECRET_KEY; a staleee_licenserow with a different secret is the classic JWT-auth failure — checkSELECT key FROM ee_license;. - Feature flag:
signals-pr-refundsmust exist, active at 100%, in the local self-capture project (create via UI or shell;sync_feature_flagsalso works but flips every flag). Without it the refund endpoints return 404. Backend picks it up within the ~90s SDK poll (or restart); the frontend needs a page reload. - Data:
python manage.py seed_refund_test_data --team-id 1seeds the full matrix — excluded-path (PR today), credited-path (PR yesterday), billing-exempt, and a no-PR exemption-command target. - Expectations: with an unseeded billing side the credited path legitimately returns
credit_amount_usd = "0.00"— that IS the success path (free plan → nothing to credit). Non-$0 outcomes ("15.00"with paid-tier usage) require seeding a paid inbox state on the billing side; the recipe, a insights-free curl smoke test, and how to verify the Stripe balance transaction live in the billing repo (internal):notes/testing-signals-disputes-locally.md. Inspecting the paid state via the insights billing page (/organization/billing) additionally requires organization owner — local dev users are typically onlyadministrator, so bumpOrganizationMembership.leveltoOWNER(15) first. Verifybilling_synced_at/credit_amount_usdonsignals_signalreportrefund(insights) and, for non-$0 outcomes, theCreditrow keyedsignals_pr_dispute:{billing_customer_id}:{refund_id}(billing). $0 outcomes persist nothing billing-side (credit_id: nullin the response) — the refund row is the whole record. Celery-side analytics events (signals_pr_refund_credit_issued/_failed) are dropped locally (ph_scoped_captureis cloud-gated); the sync itself is unaffected.
File Map
products/signals/
├── ARCHITECTURE.md # This file
├── backend/
│ ├── admin.py # Django admin for SignalReport + SignalReportArtefact
│ ├── api.py # emit_signal() entry point + source/org guards
│ ├── apps.py # Django app config
│ ├── models.py # SignalReport, SignalReportArtefact, SignalTeamConfig, SignalUserAutonomyConfig, SignalReportTask, SignalSourceConfig, SignalScoutConfig, SignalScoutRun, SignalScratchpad, SignalProjectProfile, SignalEmissionRecord
│ ├── serializers.py # DRF serializers for source configs, reports, artefacts, team config, user autonomy config
│ ├── utils.py # Compatibility re-exports for signal query helpers
│ ├── views.py # SignalViewSet, InternalSignalViewSet, SignalSourceConfigViewSet, SignalTeamConfigViewSet, SignalReportViewSet, SignalReportArtefactViewSet, SignalProcessingViewSet
│ ├── scout_harness/ # Headless Signals agent — see scout_harness/AGENTS.md
│ │ ├── AGENTS.md
│ │ ├── __init__.py # Public re-exports (LoadedSkill, sync helpers, …)
│ │ ├── runner.py # Per-run entrypoint; owns SignalScoutRun lifecycle + sandbox loop
│ │ ├── prompt.py # System prompt assembly (skill + scratchpad + profile + run history)
│ │ ├── skill_loader.py # Resolves signals-scout-* LLMSkill rows for a run
│ │ ├── lazy_seed.py # Canonical SKILL.md → LLMSkill sync (sync_canonical_skills)
│ │ ├── limits.py # DEFAULT_MAX_RUNTIME_S + ACTIVITY_SLACK_S + WORKFLOW_HARD_CEILING_S
│ │ ├── serializers.py # DRF serializers for runs / scratchpad / project profile
│ │ ├── views.py # SignalScoutRunViewSet, SignalScratchpadViewSet, SignalProjectProfileViewSet
│ │ ├── profile/
│ │ │ ├── builders.py # Deterministic builders for SignalProjectProfile inventory
│ │ │ └── schema.py # Dataclasses for the inventory payload shape
│ │ └── tools/ # Harness-internal tools the agent calls inside a run
│ │ ├── emit.py # emit_signal_* — pushes findings as cross_source_issue signals
│ │ ├── scratchpad.py # remember / forget / search_scratchpad — read/write/delete SignalScratchpad
│ │ ├── profile.py # project_profile_* — read SignalProjectProfile snapshot
│ │ └── runs.py # runs_* — read past SignalScoutRun rows for dedupe
│ ├── github_issues/ # Placeholder directory
│ ├── management/
│ │ ├── AGENTS.md
│ │ └── commands/
│ │ ├── analyze_report.py
│ │ ├── cleanup_signals.py
│ │ ├── clear_eval_data.py
│ │ ├── delete_all_signal_reports_for_team.py
│ │ ├── enable_signals_autonomy.py # Sets team default priority + opts in users by email
│ │ ├── export_session_video.py
│ │ ├── ingest_signals_json.py
│ │ ├── ingest_video_segments.py
│ │ ├── list_signal_reports.py
│ │ ├── parse_sandbox_log.py
│ │ ├── reingest_team_signals.py # Starts TeamSignalReingestionWorkflow for a team
│ │ ├── reset_signals_self_driving.py # Dev-only: undo a self-driving wizard run (configs, custom scouts, run-state)
│ │ ├── run_signals_scout.py # One-shot scout run; bypasses the coordinator
│ │ ├── select_repo.py
│ │ ├── signal_pipeline_status.py
│ │ ├── summarize_single_session.py
│ │ └── sync_signals_scout_skills.py # Force a canonical SKILL.md sync to LLMSkill rows
│ ├── report_generation/
│ │ ├── AGENTS.md # Documentation for the agentic report generation flow
│ │ ├── research.py # Multi-turn sandbox research orchestration + output schemas + research task_run artefact creation
│ │ ├── resolve_reviewers.py # Suggested-reviewer resolution and enrichment helpers
│ │ └── select_repo.py # Repository selection sandbox flow
│ ├── test/
│ │ ├── test_agentic_report_activity.py
│ │ ├── test_api.py
│ │ ├── test_resolve_user_id.py
│ │ ├── test_signal_report_api.py
│ │ └── test_signal_source_config_api.py
│ ├── migrations/
│ │ ├── 0001_initial.py
│ │ ├── 0002_signalreport_clustering_fields.py
│ │ ├── 0003_alter_signalreport_status_and_more.py
│ │ ├── 0004_alter_content_type.py
│ │ ├── 0005_signalreportartefact_report_idx.py
│ │ ├── 0006_signal_source_config.py
│ │ ├── 0007_backfill_signal_source_config.py
│ │ ├── 0008_alter_signalsourceconfig_source_product_and_more.py
│ │ ├── 0009_add_new_signal_report_statuses.py
│ │ ├── 0010_add_data_import_signal_source_choices.py
│ │ ├── 0011_add_error_tracking_signal_types.py
│ │ ├── 0012_signalreport_run_count_and_more.py
│ │ ├── 0013_signalreport_suggested_reviewers.py
│ │ ├── 0014_signalreportartefact_report_type_idx.py
│ │ ├── 0015_alter_signalsourceconfig_source_product_and_more.py
│ │ ├── 0016_signalautonomyconfig_alter_signalreportartefact_type.py # SignalTeamConfig, SignalUserAutonomyConfig, SignalReportTask
│ │ ├── 0017_add_resolved_signal_report_status.py
│ │ ├── 0018_alter_signalreportartefact_type.py
│ │ ├── 0019_alter_signalsourceconfig_source_product_and_more.py
│ │ ├── 0020_signaluserautonomyconfig_slack_notification_fields.py
│ │ ├── 0021_add_signals_scout_source.py # cross_source_issue source variant
│ │ ├── 0022_add_signal_scout_models.py # SignalScoutConfig, SignalScoutRun, SignalScratchpad
│ │ ├── 0023_signalscoutrun_summary.py # SignalScoutRun.summary
│ │ ├── 0024_signalprojectprofile.py # SignalProjectProfile
│ │ └── 0027_reshape_scout_config_per_scout.py # per-(team, skill) config + per-scout schedules (drops runs_per_tick/enabled_skill_names)
│ └── temporal/
│ ├── __init__.py # Registers Signals workflows and activities
│ ├── agentic/
│ │ ├── __init__.py # Sandbox env / user-resolution helpers
│ │ ├── scout_coordinator.py # SignalsScoutCoordinatorWorkflow + per-tick due-check activity
│ │ ├── scout_scheduler.py # RunSignalsScoutWorkflow + run_signals_scout_activity
│ │ ├── schedule.py # Temporal Schedule definition (cadence + SKIP overlap policy)
│ │ ├── report.py # Agentic report activity + artefact persistence
│ │ └── select_repository.py # Repository selection activity
│ ├── backfill_error_tracking.py # Backfill recent error tracking issues as signals
│ ├── buffer.py # BufferSignalsWorkflow + object-storage flush/backpressure activities
│ ├── datastore.py # Retry wrapper for InsightsQL / Datastore activity queries
│ ├── deletion.py # SignalReportDeletionWorkflow
│ ├── emit_eval_signal.py # EmitEvalSignalWorkflow — eval result → signal
│ ├── emitter.py # SignalEmitterWorkflow — per-signal backpressure bridge
│ ├── grouping.py # Legacy v1 workflow + active shared grouping implementation
│ ├── grouping_v2.py # Active grouping v2 workflow + pause/unpause support
│ ├── llm.py # Shared Anthropic helper + token limits / thinking config
│ ├── reingestion.py # SignalReportReingestionWorkflow + TeamSignalReingestionWorkflow
│ ├── report_safety_judge.py # Report-level safety judge activity
│ ├── safety_filter.py # Per-signal safety classifier activity
│ ├── signal_queries.py # Canonical InsightsQL helpers for fetch/search/soft-delete/wait
│ ├── summary.py # SignalReportSummaryWorkflow + report state transition activities
│ └── types.py # Shared dataclasses + signal rendering helpers
├── dags/
│ └── inbox_ranking/ # Dagster dags for the report-ranking model (daily modeling dataset) — see its README.md + AGENTS.md
├── skills/ # Signals skills — see skills/AGENTS.md
│ ├── AGENTS.md
│ ├── signals/ # Official Insights skill (published via insights_ai/dist): querying signals data
│ ├── inbox-exploration/ # Official Insights skill (published via insights_ai/dist): browsing the inbox
│ ├── signals-scout-general/ # Scout fleet: cross-product generalist (SKILL.md + emit.md + conventions.md)
│ ├── signals-scout-ai-observability/ # Scout fleet: AI observability anomaly watcher
│ ├── signals-scout-logs/ # Scout fleet: logs anomaly watcher
│ ├── signals-scout-error-tracking/ # Scout fleet: error tracking anomaly watcher
│ ├── signals-scout-revenue-analytics/ # Scout fleet: revenue anomaly watcher
│ ├── signals-scout-surveys/ # Scout fleet: surveys anomaly + theme-aggregation watcher
│ ├── signals-scout-observability-gaps/ # Scout fleet: structural-gap watcher (P3 recommendations)
│ └── signals-scout-csp-violations/ # Scout fleet: CSP violation watcher
└── frontend/ # Frontend components (not covered here)