From d1f80c4514910582c25c91f9cd4ec666ab7b8832 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 30 Jul 2026 16:23:50 +0100 Subject: [PATCH 01/12] feat(signals): add scout lifecycle status with reason-scoped pauses --- .../models/activity_logging/activity_log.py | 3 + posthog/settings/web.py | 2 + .../0075_signalscoutconfig_status.py | 54 +++++ .../0076_scout_config_status_constraint.py | 30 +++ .../backend/migrations/max_migration.txt | 2 +- products/signals/backend/models.py | 138 ++++++++++++- .../backend/scout_harness/serializers.py | 52 ++++- .../backend/test/test_scout_harness_api.py | 46 +++++ .../signals/backend/test/test_scout_status.py | 188 ++++++++++++++++++ .../signals/frontend/generated/api.schemas.ts | 46 ++++- .../signals/frontend/generated/api.zod.ts | 4 +- services/mcp/src/api/generated.ts | 47 ++++- services/mcp/src/generated/signals/api.ts | 4 +- .../tool-schemas/scout-config-update.json | 2 +- .../signals-scout-config-update.json | 2 +- 15 files changed, 608 insertions(+), 12 deletions(-) create mode 100644 products/signals/backend/migrations/0075_signalscoutconfig_status.py create mode 100644 products/signals/backend/migrations/0076_scout_config_status_constraint.py create mode 100644 products/signals/backend/test/test_scout_status.py diff --git a/posthog/models/activity_logging/activity_log.py b/posthog/models/activity_logging/activity_log.py index f166ab1d4a97..258fa2d68de4 100644 --- a/posthog/models/activity_logging/activity_log.py +++ b/posthog/models/activity_logging/activity_log.py @@ -335,6 +335,7 @@ class Meta: "SignalScoutConfig": { "run_interval_minutes": "run interval (minutes)", "emit": "emit findings", + "pause_reason": "pause reason", }, "OAuthApplication": { "_provisioning_config": "provisioning config", @@ -759,6 +760,8 @@ class Meta: # Run bookkeeping, not user intent — keep it out of change detection even when it # rides along with a real change (belt-and-suspenders with signal_exclusions above). "last_run_at", + # Companion timestamp that rides along with every logged `status` change. + "status_changed_at", # Reverse relations auto-managed by FK creates, not user-initiated config changes. "runs", ], diff --git a/posthog/settings/web.py b/posthog/settings/web.py index e7a6528df141..7dd8dae0cdf0 100644 --- a/posthog/settings/web.py +++ b/posthog/settings/web.py @@ -549,6 +549,8 @@ def static_varies_origin(headers, path, url): # path (drf-spectacular generates the x-spec-enum-id from the same tuples). # --- Model class paths (ChoiceField x-spec-enum-id hashes) --- "SignalReportRefundReasonEnum": "products.signals.backend.models.SignalReportRefund.Reason", + "ScoutConfigStatusEnum": "products.signals.backend.models.SignalScoutConfig.Status", + "ScoutConfigPauseReasonEnum": "products.signals.backend.models.SignalScoutConfig.PauseReason", "EngineeringAnalyticsPRStateEnum": "products.engineering_analytics.backend.facade.contracts.PRState", "QuarantineModeEnum": "products.engineering_analytics.backend.facade.contracts.QuarantineMode", "CITestRunnerEnum": "products.engineering_analytics.backend.facade.contracts.CITestRunner", diff --git a/products/signals/backend/migrations/0075_signalscoutconfig_status.py b/products/signals/backend/migrations/0075_signalscoutconfig_status.py new file mode 100644 index 000000000000..9432a82dbbd3 --- /dev/null +++ b/products/signals/backend/migrations/0075_signalscoutconfig_status.py @@ -0,0 +1,54 @@ +from django.db import migrations, models + + +def backfill_paused_by_user(apps, schema_editor): + # Rows disabled before `status` existed can only have been switched off by a human: + # nothing system-driven ever wrote `enabled` until this field shipped. + SignalScoutConfig = apps.get_model("signals", "SignalScoutConfig") + # `_default_manager`, not `objects`: the model's Meta routes the default manager to the + # unscoped `all_teams`, so the historical model carries no `objects` attribute. + SignalScoutConfig._default_manager.filter(enabled=False).update(status="paused_by_user") + + +class Migration(migrations.Migration): + dependencies = [ + ("signals", "0074_signalreport_charts"), + ] + + operations = [ + migrations.AddField( + model_name="signalscoutconfig", + name="status", + field=models.CharField( + choices=[ + ("active", "Active"), + ("pending_pause", "Pending pause"), + ("paused_by_system", "Paused by system"), + ("paused_by_user", "Paused by user"), + ], + db_default="active", + default="active", + max_length=20, + ), + ), + migrations.AddField( + model_name="signalscoutconfig", + name="pause_reason", + field=models.CharField( + blank=True, + choices=[ + ("no_output", "No output"), + ("ignored", "Ignored"), + ("repeated_failures", "Repeated failures"), + ], + max_length=20, + null=True, + ), + ), + migrations.AddField( + model_name="signalscoutconfig", + name="status_changed_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.RunPython(backfill_paused_by_user, reverse_code=migrations.RunPython.noop), + ] diff --git a/products/signals/backend/migrations/0076_scout_config_status_constraint.py b/products/signals/backend/migrations/0076_scout_config_status_constraint.py new file mode 100644 index 000000000000..7003d8c4bea8 --- /dev/null +++ b/products/signals/backend/migrations/0076_scout_config_status_constraint.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + +from posthog.migration_helpers import AddConstraintNotValid, ValidateConstraint + + +class Migration(migrations.Migration): + # NOT VALID add and VALIDATE must not share one transaction, or the ADD's ACCESS + # EXCLUSIVE lock is held through the validation scan. + atomic = False + + dependencies = [ + ("signals", "0075_signalscoutconfig_status"), + ] + + operations = [ + AddConstraintNotValid( + model_name="signalscoutconfig", + constraint=models.CheckConstraint( + condition=( + models.Q(enabled=True, status__in=("active", "pending_pause")) + | models.Q(enabled=False, status__in=("paused_by_system", "paused_by_user")) + ), + name="scout_config_enabled_matches_status", + ), + ), + ValidateConstraint( + model_name="signalscoutconfig", + name="scout_config_enabled_matches_status", + ), + ] diff --git a/products/signals/backend/migrations/max_migration.txt b/products/signals/backend/migrations/max_migration.txt index dc8fe29c24ec..9917dcd774ac 100644 --- a/products/signals/backend/migrations/max_migration.txt +++ b/products/signals/backend/migrations/max_migration.txt @@ -1 +1 @@ -0075_alter_signalsourceconfig_source_product +0076_scout_config_status_constraint diff --git a/products/signals/backend/models.py b/products/signals/backend/models.py index c4fd1c12033e..570dda3fd429 100644 --- a/products/signals/backend/models.py +++ b/products/signals/backend/models.py @@ -1,6 +1,6 @@ import logging from collections import defaultdict -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, cast from django.contrib.postgres.fields import ArrayField @@ -1096,6 +1096,43 @@ class SignalScoutConfig(ModelActivityMixin, TeamScopedRootMixin, UUIDModel): # ModelActivityMixin only logs deletes when this is set. activity_logging_on_delete = True + class Status(models.TextChoices): + """Lifecycle states a writer deliberately moves a scout between. + + Deliberately small: only states that change what the scheduler does belong here. + Windowed assessments (engagement, cold-start newness, a failing-but-not-tripped + streak) are derived at read time, never persisted as a status. + """ + + ACTIVE = "active", "Active" + # Warned by a system writer: will be paused on a set date unless something changes. + # Still scheduled. A state rather than a notification so the sweep that sets it is + # idempotent and any human touch has something concrete to clear. + PENDING_PAUSE = "pending_pause", "Pending pause" + PAUSED_BY_SYSTEM = "paused_by_system", "Paused by system" + # A human switched the scout off. No system writer may resume or re-pause it. + PAUSED_BY_USER = "paused_by_user", "Paused by user" + + class PauseReason(models.TextChoices): + """Why a system writer paused (or warned) a scout. + + Each value also identifies the writer that owns the pause: a system writer may only + clear or overwrite a pause carrying its own reason (`transition_status_by_system`), + which is what keeps independent pause mechanisms from undoing each other. + """ + + NO_OUTPUT = "no_output", "No output" + IGNORED = "ignored", "Ignored" + REPEATED_FAILURES = "repeated_failures", "Repeated failures" + + # The `status` side of the `enabled` dual-write: a scout in one of these statuses is + # scheduled by the coordinator. `pending_pause` still runs; the warning is not a pause. + RUNNABLE_STATUSES = (Status.ACTIVE, Status.PENDING_PAUSE) + + # How long a scout is treated as provisional after creation or a human re-enable, during + # which system writers should leave it alone (`in_cold_start_grace`). + COLD_START_GRACE = timedelta(days=14) + # `objects` (TeamScopedManager) inherited from TeamScopedRootMixin stays fail-closed for # explicit user code. `all_teams` is the unscoped sibling for Django framework internals # (admin changelist queryset, related-object access, prefetch_related) that must not @@ -1114,7 +1151,33 @@ class SignalScoutConfig(ModelActivityMixin, TeamScopedRootMixin, UUIDModel): # row when it discovers a scout skill on a participating team, so a user authoring # `signals-scout-foo` gets a row (on the default schedule) on the next tick. skill_name = models.CharField(max_length=200) + # Derived from `status` (`enabled = status in RUNNABLE_STATUSES`), but kept as a real + # column because the coordinator filters on it at SQL level and the warehouse mirrors it. + # `save` reconciles the pair for writers that only set one side, and the + # `scout_config_enabled_matches_status` constraint makes a queryset update that flips one + # without the other fail loudly instead of leaving the scheduler and the UI disagreeing. enabled = models.BooleanField(default=True, db_default=True) + # Source of truth for the scout's lifecycle. Two of the four states pause scheduling; who + # set the pause is the state itself (`paused_by_user` vs `paused_by_system`), not a + # side-channel field, because the two must behave differently: the system may resume its + # own pauses but must never touch a human's. + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.ACTIVE, + db_default=Status.ACTIVE, + ) + # Set only alongside `pending_pause` / `paused_by_system`; see `PauseReason`. + pause_reason = models.CharField( + max_length=20, + choices=PauseReason.choices, + null=True, + blank=True, + ) + # When `status` last changed. Bookkeeping that rides along with the logged status change + # itself, so it is excluded from activity logging. Null until the first transition; + # `created_at` anchors the cold-start grace window for rows that never transitioned. + status_changed_at = models.DateTimeField(null=True, blank=True) # Dry-run vs emit. Defaults emit-on so 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. @@ -1180,8 +1243,81 @@ class Meta: default_manager_name = "all_teams" constraints = [ models.UniqueConstraint(fields=["team", "skill_name"], name="unique_scout_config_per_team_skill"), + models.CheckConstraint( + condition=( + models.Q(enabled=True, status__in=("active", "pending_pause")) + | models.Q(enabled=False, status__in=("paused_by_system", "paused_by_user")) + ), + name="scout_config_enabled_matches_status", + ), ] + def save(self, *args: Any, **kwargs: Any) -> None: + """Keep the `enabled` / `status` pair consistent for writers that only set one side. + + `status` is the source of truth, but callers that predate it (fixtures, ad-hoc + scripts, the config API's `enabled` field) still write `enabled` alone. When the pair + disagrees at save time, resolve toward whichever side the caller touched: `status` + wins when `update_fields` names it without `enabled`; otherwise the `enabled` value is + taken as the intent (True resumes any pause, False records a user pause). + """ + update_fields = kwargs.get("update_fields") + if self.enabled != (self.status in self.RUNNABLE_STATUSES): + fields = set(update_fields) if update_fields is not None else None + if fields is not None and "status" in fields and "enabled" not in fields: + self.enabled = self.status in self.RUNNABLE_STATUSES + touched = {"enabled"} + else: + self.status = self.Status.ACTIVE if self.enabled else self.Status.PAUSED_BY_USER + self.pause_reason = None + touched = {"status", "pause_reason"} + if not self._state.adding: + self.status_changed_at = timezone.now() + touched.add("status_changed_at") + if fields is not None: + kwargs["update_fields"] = fields | touched + super().save(*args, **kwargs) + + def transition_status_by_system( + self, new_status: "SignalScoutConfig.Status", *, pause_reason: "SignalScoutConfig.PauseReason" + ) -> bool: + """Apply a system-driven status transition under the reason-scoped ownership rule. + + `pause_reason` names the calling writer (an inactivity sweep passes `no_output`, a + failure breaker `repeated_failures`) as well as the reason recorded on a pause. The + rule: a system writer may never touch `paused_by_user`, and may only move a scout + whose current pause carries its own reason, so independent pause mechanisms cannot + clear or overwrite each other's state. Saves and returns True when the transition + applies; returns False without writing when it is refused or a no-op. + """ + if new_status == self.Status.PAUSED_BY_USER: + raise ValueError("Only a user write may set paused_by_user.") + if self.status == self.Status.PAUSED_BY_USER: + return False + if self.status != self.Status.ACTIVE and self.pause_reason != pause_reason: + return False + recorded_reason = None if new_status == self.Status.ACTIVE else pause_reason + if new_status == self.status and recorded_reason == self.pause_reason: + return False + self.status = new_status + self.pause_reason = recorded_reason + self.status_changed_at = timezone.now() + self.enabled = new_status in self.RUNNABLE_STATUSES + self.save(update_fields=["status", "pause_reason", "status_changed_at", "enabled", "updated_at"]) + return True + + def in_cold_start_grace(self) -> bool: + """True while the scout is provisional and system writers should not evaluate it. + + Anchored on `created_at`, re-anchored by the most recent move into a runnable status + (a human re-enable grants a fresh window). Time-based only; a consumer that also + wants a minimum-runs floor applies that on top, since the floor differs per writer. + """ + anchor = self.created_at + if self.status in self.RUNNABLE_STATUSES and self.status_changed_at is not None: + anchor = max(anchor, self.status_changed_at) + return timezone.now() < anchor + self.COLD_START_GRACE + def _get_before_update(self, **kwargs: Any) -> "SignalScoutConfig | None": # ModelActivityMixin's prior-state lookup goes through `objects` (the fail-closed # TeamScopedManager). Edits from Django admin / the coordinator / a shell run with no diff --git a/products/signals/backend/scout_harness/serializers.py b/products/signals/backend/scout_harness/serializers.py index f1cae01ed0aa..18e15405b7b7 100644 --- a/products/signals/backend/scout_harness/serializers.py +++ b/products/signals/backend/scout_harness/serializers.py @@ -1884,7 +1884,31 @@ class SignalScoutConfigSerializer(serializers.ModelSerializer): ) enabled = serializers.BooleanField( read_only=True, - help_text="Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator.", + help_text=( + "Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. " + "Derived from `status`: true for `active` and `pending_pause`, false for the paused statuses." + ), + ) + status = serializers.ChoiceField( + choices=SignalScoutConfig.Status.choices, + read_only=True, + help_text=( + "Lifecycle status. `active`: runs on its schedule. `pending_pause`: still running, but " + "flagged by the system to pause soon unless something changes (any config edit clears it). " + "`paused_by_system`: paused automatically, see `pause_reason`; set `enabled=true` to resume. " + "`paused_by_user`: switched off by a person and never resumed automatically." + ), + ) + pause_reason = serializers.ChoiceField( + choices=SignalScoutConfig.PauseReason.choices, + read_only=True, + allow_null=True, + help_text=( + "Why the system paused (or warned) this scout: `no_output` (it emitted nothing over the " + "evaluation window), `ignored` (its output received no human engagement), or " + "`repeated_failures` (consecutive failed runs). Null unless `status` is `pending_pause` " + "or `paused_by_system`." + ), ) emit = serializers.BooleanField( read_only=True, @@ -1936,6 +1960,8 @@ class Meta: "description", "scout_origin", "enabled", + "status", + "pause_reason", "emit", "run_interval_minutes", "run_cron_schedule", @@ -1976,7 +2002,11 @@ class SignalScoutConfigUpdateSerializer(serializers.ModelSerializer): enabled = serializers.BooleanField( required=False, - help_text="Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator.", + help_text=( + "Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. " + "Setting false records a user pause (`status` becomes `paused_by_user`, which the system " + "never overrides); setting true resumes the scout from any pause." + ), ) emit = serializers.BooleanField( required=False, @@ -2018,6 +2048,24 @@ def update(self, instance: SignalScoutConfig, validated_data: dict) -> SignalSco field in validated_data and validated_data[field] != getattr(instance, field) for field in schedule_fields ): validated_data["schedule_changed_at"] = timezone.now() + # This serializer is the human write path, so it moves `status` through `enabled`: + # false is a user pause the system must never override, true resumes from any pause. + # An edit that doesn't touch `enabled` still clears a pending pause — a human tending + # the config is exactly the signal the warning exists to detect. + if "enabled" in validated_data: + target = ( + SignalScoutConfig.Status.ACTIVE + if validated_data["enabled"] + else SignalScoutConfig.Status.PAUSED_BY_USER + ) + elif instance.status == SignalScoutConfig.Status.PENDING_PAUSE: + target = SignalScoutConfig.Status.ACTIVE + else: + target = None + if target is not None and target != instance.status: + validated_data["status"] = target + validated_data["pause_reason"] = None + validated_data["status_changed_at"] = timezone.now() return super().update(instance, validated_data) class Meta: diff --git a/products/signals/backend/test/test_scout_harness_api.py b/products/signals/backend/test/test_scout_harness_api.py index 9a2a380f8ff3..4be59a9bbbf6 100644 --- a/products/signals/backend/test/test_scout_harness_api.py +++ b/products/signals/backend/test/test_scout_harness_api.py @@ -1355,6 +1355,51 @@ def test_partial_update_changes_schedule_emit_and_records_enabled_by(self) -> No assert config.run_interval_minutes == 60 assert config.enabled_by_id == self.user.id + def test_partial_update_disable_records_a_user_pause(self) -> None: + config = SignalScoutConfig.objects.create(team=self.team, skill_name="signals-scout-foo") + + response = self.client.patch(self._detail_url(str(config.id)), data={"enabled": False}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["status"] == "paused_by_user" + config.refresh_from_db() + assert config.status == SignalScoutConfig.Status.PAUSED_BY_USER + + def test_partial_update_enable_resumes_a_system_pause_and_clears_the_reason(self) -> None: + config = SignalScoutConfig.objects.create( + team=self.team, + skill_name="signals-scout-foo", + enabled=False, + status=SignalScoutConfig.Status.PAUSED_BY_SYSTEM, + pause_reason=SignalScoutConfig.PauseReason.NO_OUTPUT, + ) + + response = self.client.patch(self._detail_url(str(config.id)), data={"enabled": True}, format="json") + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["status"] == "active" + assert body["pause_reason"] is None + config.refresh_from_db() + assert config.status == SignalScoutConfig.Status.ACTIVE + assert config.pause_reason is None + assert config.enabled is True + + def test_partial_update_without_enabled_clears_a_pending_pause(self) -> None: + config = SignalScoutConfig.objects.create( + team=self.team, + skill_name="signals-scout-foo", + status=SignalScoutConfig.Status.PENDING_PAUSE, + pause_reason=SignalScoutConfig.PauseReason.NO_OUTPUT, + ) + + response = self.client.patch(self._detail_url(str(config.id)), data={"run_interval_minutes": 60}, format="json") + + assert response.status_code == status.HTTP_200_OK + config.refresh_from_db() + assert config.status == SignalScoutConfig.Status.ACTIVE + assert config.pause_reason is None + def test_partial_update_slack_destination_is_project_scoped_and_round_trips(self) -> None: config = SignalScoutConfig.objects.create(team=self.team, skill_name="signals-scout-foo") other_team = Team.objects.create(organization=self.organization, name="other") @@ -1702,6 +1747,7 @@ def test_create_disabled_config_does_not_stamp_enabled_by(self) -> None: config = SignalScoutConfig.objects.get(team=self.team, skill_name="signals-scout-fresh") assert config.enabled is False assert config.enabled_by_id is None + assert config.status == SignalScoutConfig.Status.PAUSED_BY_USER def test_create_upserts_existing_config(self) -> None: self._make_skill("signals-scout-fresh") diff --git a/products/signals/backend/test/test_scout_status.py b/products/signals/backend/test/test_scout_status.py new file mode 100644 index 000000000000..1479cdcd10db --- /dev/null +++ b/products/signals/backend/test/test_scout_status.py @@ -0,0 +1,188 @@ +from freezegun import freeze_time +from posthog.test.base import BaseTest + +from parameterized import parameterized + +from products.signals.backend.models import SignalScoutConfig + +Status = SignalScoutConfig.Status +Reason = SignalScoutConfig.PauseReason + + +class TestScoutStatusTransitions(BaseTest): + def _config( + self, + status: SignalScoutConfig.Status = Status.ACTIVE, + pause_reason: SignalScoutConfig.PauseReason | None = None, + ) -> SignalScoutConfig: + return SignalScoutConfig.objects.create( + team=self.team, + skill_name="signals-scout-foo", + enabled=status in SignalScoutConfig.RUNNABLE_STATUSES, + status=status, + pause_reason=pause_reason, + ) + + @parameterized.expand( + [ + ("warn_active_scout", Status.ACTIVE, None, Status.PENDING_PAUSE, Reason.NO_OUTPUT, True), + ("pause_active_scout", Status.ACTIVE, None, Status.PAUSED_BY_SYSTEM, Reason.NO_OUTPUT, True), + ( + "owner_escalates_warning", + Status.PENDING_PAUSE, + Reason.NO_OUTPUT, + Status.PAUSED_BY_SYSTEM, + Reason.NO_OUTPUT, + True, + ), + ( + "owner_resumes_own_pause", + Status.PAUSED_BY_SYSTEM, + Reason.REPEATED_FAILURES, + Status.ACTIVE, + Reason.REPEATED_FAILURES, + True, + ), + ( + "other_writer_cannot_escalate", + Status.PENDING_PAUSE, + Reason.NO_OUTPUT, + Status.PAUSED_BY_SYSTEM, + Reason.REPEATED_FAILURES, + False, + ), + ( + "other_writer_cannot_overwrite_pause", + Status.PAUSED_BY_SYSTEM, + Reason.NO_OUTPUT, + Status.PAUSED_BY_SYSTEM, + Reason.IGNORED, + False, + ), + ( + "other_writer_cannot_resume", + Status.PAUSED_BY_SYSTEM, + Reason.NO_OUTPUT, + Status.ACTIVE, + Reason.REPEATED_FAILURES, + False, + ), + ( + "user_pause_blocks_system_pause", + Status.PAUSED_BY_USER, + None, + Status.PAUSED_BY_SYSTEM, + Reason.NO_OUTPUT, + False, + ), + ("user_pause_blocks_system_resume", Status.PAUSED_BY_USER, None, Status.ACTIVE, Reason.NO_OUTPUT, False), + ( + "same_state_is_a_noop", + Status.PENDING_PAUSE, + Reason.NO_OUTPUT, + Status.PENDING_PAUSE, + Reason.NO_OUTPUT, + False, + ), + ] + ) + def test_system_transition_ownership_rule( + self, + _name: str, + start_status: SignalScoutConfig.Status, + start_reason: SignalScoutConfig.PauseReason | None, + new_status: SignalScoutConfig.Status, + writer_reason: SignalScoutConfig.PauseReason, + expect_applied: bool, + ) -> None: + config = self._config(status=start_status, pause_reason=start_reason) + + applied = config.transition_status_by_system(new_status, pause_reason=writer_reason) + + assert applied is expect_applied + config.refresh_from_db() + if expect_applied: + assert config.status == new_status + assert config.pause_reason == (None if new_status == Status.ACTIVE else writer_reason) + assert config.enabled is (new_status in SignalScoutConfig.RUNNABLE_STATUSES) + assert config.status_changed_at is not None + else: + assert config.status == start_status + assert config.pause_reason == start_reason + assert config.status_changed_at is None + + def test_system_writer_may_never_set_paused_by_user(self) -> None: + config = self._config() + with self.assertRaises(ValueError): + config.transition_status_by_system(Status.PAUSED_BY_USER, pause_reason=Reason.NO_OUTPUT) + + +class TestScoutStatusEnabledReconciliation(BaseTest): + def test_create_with_enabled_false_records_a_user_pause(self) -> None: + config = SignalScoutConfig.objects.create(team=self.team, skill_name="signals-scout-foo", enabled=False) + + assert config.status == Status.PAUSED_BY_USER + assert config.status_changed_at is None + + def test_enabled_only_save_derives_status(self) -> None: + config = SignalScoutConfig.objects.create(team=self.team, skill_name="signals-scout-foo") + + config.enabled = False + config.save(update_fields=["enabled"]) + + config.refresh_from_db() + assert config.status == Status.PAUSED_BY_USER + assert config.status_changed_at is not None + + def test_enabled_only_save_resumes_a_system_pause(self) -> None: + config = SignalScoutConfig.objects.create( + team=self.team, + skill_name="signals-scout-foo", + enabled=False, + status=Status.PAUSED_BY_SYSTEM, + pause_reason=Reason.NO_OUTPUT, + ) + + config.enabled = True + config.save(update_fields=["enabled"]) + + config.refresh_from_db() + assert config.status == Status.ACTIVE + assert config.pause_reason is None + + def test_status_named_alone_in_update_fields_derives_enabled(self) -> None: + config = SignalScoutConfig.objects.create(team=self.team, skill_name="signals-scout-foo") + + config.status = Status.PAUSED_BY_SYSTEM + config.pause_reason = Reason.NO_OUTPUT + config.save(update_fields=["status", "pause_reason"]) + + config.refresh_from_db() + assert config.enabled is False + + +class TestScoutColdStartGrace(BaseTest): + def test_grace_follows_creation_then_expires(self) -> None: + with freeze_time("2026-07-01T00:00:00Z"): + config = SignalScoutConfig.objects.create(team=self.team, skill_name="signals-scout-foo") + with freeze_time("2026-07-10T00:00:00Z"): + assert config.in_cold_start_grace() is True + with freeze_time("2026-07-16T00:00:00Z"): + assert config.in_cold_start_grace() is False + + def test_human_reactivation_grants_a_fresh_window(self) -> None: + with freeze_time("2026-06-01T00:00:00Z"): + config = SignalScoutConfig.objects.create(team=self.team, skill_name="signals-scout-foo", enabled=False) + with freeze_time("2026-07-15T00:00:00Z"): + config.enabled = True + config.save(update_fields=["enabled"]) + with freeze_time("2026-07-20T00:00:00Z"): + assert config.in_cold_start_grace() is True + + def test_a_paused_scout_does_not_re_anchor_on_status_change(self) -> None: + with freeze_time("2026-06-01T00:00:00Z"): + config = SignalScoutConfig.objects.create(team=self.team, skill_name="signals-scout-foo") + with freeze_time("2026-07-15T00:00:00Z"): + config.transition_status_by_system(Status.PAUSED_BY_SYSTEM, pause_reason=Reason.NO_OUTPUT) + with freeze_time("2026-07-20T00:00:00Z"): + assert config.in_cold_start_grace() is False diff --git a/products/signals/frontend/generated/api.schemas.ts b/products/signals/frontend/generated/api.schemas.ts index df848ae12e3c..61215d9e6172 100644 --- a/products/signals/frontend/generated/api.schemas.ts +++ b/products/signals/frontend/generated/api.schemas.ts @@ -1754,6 +1754,35 @@ export const ScoutOriginEnumApi = { Custom: 'custom', } as const +/** + * * `active` - Active + * * `pending_pause` - Pending pause + * * `paused_by_system` - Paused by system + * * `paused_by_user` - Paused by user + */ +export type ScoutConfigStatusEnumApi = (typeof ScoutConfigStatusEnumApi)[keyof typeof ScoutConfigStatusEnumApi] + +export const ScoutConfigStatusEnumApi = { + Active: 'active', + PendingPause: 'pending_pause', + PausedBySystem: 'paused_by_system', + PausedByUser: 'paused_by_user', +} as const + +/** + * * `no_output` - No output + * * `ignored` - Ignored + * * `repeated_failures` - Repeated failures + */ +export type ScoutConfigPauseReasonEnumApi = + (typeof ScoutConfigPauseReasonEnumApi)[keyof typeof ScoutConfigPauseReasonEnumApi] + +export const ScoutConfigPauseReasonEnumApi = { + NoOutput: 'no_output', + Ignored: 'ignored', + RepeatedFailures: 'repeated_failures', +} as const + /** * Read shape for a per-(team, skill) scout config. * @@ -1768,8 +1797,21 @@ export interface SignalScoutConfigApi { readonly description: string /** Where this scout came from: `canonical` for a scout PostHog ships and maintains (seeded from `products/signals/skills/`), or `custom` for one a team hand-authored on this project. Use it to badge built-in vs custom scouts instead of a hardcoded name list. Defaults to `custom` if the skill is not currently present on the team. */ readonly scout_origin: ScoutOriginEnumApi - /** Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. */ + /** Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. Derived from `status`: true for `active` and `pending_pause`, false for the paused statuses. */ readonly enabled: boolean + /** Lifecycle status. `active`: runs on its schedule. `pending_pause`: still running, but flagged by the system to pause soon unless something changes (any config edit clears it). `paused_by_system`: paused automatically, see `pause_reason`; set `enabled=true` to resume. `paused_by_user`: switched off by a person and never resumed automatically. + * + * * `active` - Active + * * `pending_pause` - Pending pause + * * `paused_by_system` - Paused by system + * * `paused_by_user` - Paused by user */ + readonly status: ScoutConfigStatusEnumApi + /** Why the system paused (or warned) this scout: `no_output` (it emitted nothing over the evaluation window), `ignored` (its output received no human engagement), or `repeated_failures` (consecutive failed runs). Null unless `status` is `pending_pause` or `paused_by_system`. + * + * * `no_output` - No output + * * `ignored` - Ignored + * * `repeated_failures` - Repeated failures */ + readonly pause_reason: ScoutConfigPauseReasonEnumApi | null /** Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing. */ readonly emit: boolean /** @@ -1836,7 +1878,7 @@ export interface SignalScoutConfigCreateApi { * Editable schedule, enablement, and emit posture for one scout config. */ export interface PatchedSignalScoutConfigUpdateApi { - /** Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. */ + /** Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. Setting false records a user pause (`status` becomes `paused_by_user`, which the system never overrides); setting true resumes the scout from any pause. */ enabled?: boolean /** Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing. */ emit?: boolean diff --git a/products/signals/frontend/generated/api.zod.ts b/products/signals/frontend/generated/api.zod.ts index 1f3c9e81f4d6..a1763eed2909 100644 --- a/products/signals/frontend/generated/api.zod.ts +++ b/products/signals/frontend/generated/api.zod.ts @@ -433,7 +433,9 @@ export const SignalsScoutConfigUpdateBody = /* @__PURE__ */ zod enabled: zod .boolean() .optional() - .describe('Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator.'), + .describe( + 'Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. Setting false records a user pause (`status` becomes `paused_by_user`, which the system never overrides); setting true resumes the scout from any pause.' + ), emit: zod .boolean() .optional() diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 54ed78b7c240..ae153a7e276c 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -53112,7 +53112,7 @@ export namespace Schemas { * Editable schedule, enablement, and emit posture for one scout config. */ export interface PatchedSignalScoutConfigUpdate { - /** Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. */ + /** Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. Setting false records a user pause (`status` becomes `paused_by_user`, which the system never overrides); setting true resumes the scout from any pause. */ enabled?: boolean; /** Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing. */ emit?: boolean; @@ -61836,6 +61836,36 @@ export namespace Schemas { base_version?: number; } + /** + * * `no_output` - No output + * * `ignored` - Ignored + * * `repeated_failures` - Repeated failures + */ + export type ScoutConfigPauseReasonEnum = typeof ScoutConfigPauseReasonEnum[keyof typeof ScoutConfigPauseReasonEnum]; + + + export const ScoutConfigPauseReasonEnum = { + NoOutput: 'no_output', + Ignored: 'ignored', + RepeatedFailures: 'repeated_failures', + } as const; + + /** + * * `active` - Active + * * `pending_pause` - Pending pause + * * `paused_by_system` - Paused by system + * * `paused_by_user` - Paused by user + */ + export type ScoutConfigStatusEnum = typeof ScoutConfigStatusEnum[keyof typeof ScoutConfigStatusEnum]; + + + export const ScoutConfigStatusEnum = { + Active: 'active', + PendingPause: 'pending_pause', + PausedBySystem: 'paused_by_system', + PausedByUser: 'paused_by_user', + } as const; + /** * One finding the run emitted, paired with the inbox report (if any) its signal grouped into. * @@ -62550,8 +62580,21 @@ export namespace Schemas { readonly description: string; /** Where this scout came from: `canonical` for a scout PostHog ships and maintains (seeded from `products/signals/skills/`), or `custom` for one a team hand-authored on this project. Use it to badge built-in vs custom scouts instead of a hardcoded name list. Defaults to `custom` if the skill is not currently present on the team. */ readonly scout_origin: ScoutOriginEnum; - /** Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. */ + /** Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. Derived from `status`: true for `active` and `pending_pause`, false for the paused statuses. */ readonly enabled: boolean; + /** Lifecycle status. `active`: runs on its schedule. `pending_pause`: still running, but flagged by the system to pause soon unless something changes (any config edit clears it). `paused_by_system`: paused automatically, see `pause_reason`; set `enabled=true` to resume. `paused_by_user`: switched off by a person and never resumed automatically. + * + * * `active` - Active + * * `pending_pause` - Pending pause + * * `paused_by_system` - Paused by system + * * `paused_by_user` - Paused by user */ + readonly status: ScoutConfigStatusEnum; + /** Why the system paused (or warned) this scout: `no_output` (it emitted nothing over the evaluation window), `ignored` (its output received no human engagement), or `repeated_failures` (consecutive failed runs). Null unless `status` is `pending_pause` or `paused_by_system`. + * + * * `no_output` - No output + * * `ignored` - Ignored + * * `repeated_failures` - Repeated failures */ + readonly pause_reason: ScoutConfigPauseReasonEnum | null; /** Whether the scout writes findings to the inbox. False = dry-run: it runs and logs but emits nothing. */ readonly emit: boolean; /** diff --git a/services/mcp/src/generated/signals/api.ts b/services/mcp/src/generated/signals/api.ts index f00eb5e65218..be8de1163679 100644 --- a/services/mcp/src/generated/signals/api.ts +++ b/services/mcp/src/generated/signals/api.ts @@ -637,7 +637,9 @@ export const SignalsScoutConfigUpdateBody = /* @__PURE__ */ zod enabled: zod .boolean() .optional() - .describe('Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator.'), + .describe( + 'Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. Setting false records a user pause (`status` becomes `paused_by_user`, which the system never overrides); setting true resumes the scout from any pause.' + ), emit: zod .boolean() .optional() diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-update.json index d6d2ff4512ad..fffdfff9eab8 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/scout-config-update.json @@ -6,7 +6,7 @@ "type": "boolean" }, "enabled": { - "description": "Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator.", + "description": "Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. Setting false records a user pause (`status` becomes `paused_by_user`, which the system never overrides); setting true resumes the scout from any pause.", "type": "boolean" }, "id": { diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-update.json index d6d2ff4512ad..fffdfff9eab8 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/signals-scout-config-update.json @@ -6,7 +6,7 @@ "type": "boolean" }, "enabled": { - "description": "Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator.", + "description": "Whether this scout runs on its schedule. Disabled scouts are skipped by the coordinator. Setting false records a user pause (`status` becomes `paused_by_user`, which the system never overrides); setting true resumes the scout from any pause.", "type": "boolean" }, "id": { From e250efca8832ebb2e76ea51a0a6e51ca46c90659 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 30 Jul 2026 16:23:53 +0100 Subject: [PATCH 02/12] feat(signals): stamp who last moved a scout's status --- .../models/activity_logging/activity_log.py | 4 ++- .../0075_signalscoutconfig_status.py | 14 +++++++++++ products/signals/backend/models.py | 25 +++++++++++++++++-- .../backend/scout_harness/serializers.py | 2 ++ .../backend/test/test_scout_harness_api.py | 1 + .../signals/backend/test/test_scout_status.py | 12 +++++++++ 6 files changed, 55 insertions(+), 3 deletions(-) diff --git a/posthog/models/activity_logging/activity_log.py b/posthog/models/activity_logging/activity_log.py index 258fa2d68de4..6c3a1f4906f6 100644 --- a/posthog/models/activity_logging/activity_log.py +++ b/posthog/models/activity_logging/activity_log.py @@ -760,8 +760,10 @@ class Meta: # Run bookkeeping, not user intent — keep it out of change detection even when it # rides along with a real change (belt-and-suspenders with signal_exclusions above). "last_run_at", - # Companion timestamp that rides along with every logged `status` change. + # Companion bookkeeping that rides along with every logged `status` change; the + # activity log entry itself already carries who and when. "status_changed_at", + "status_changed_by", # Reverse relations auto-managed by FK creates, not user-initiated config changes. "runs", ], diff --git a/products/signals/backend/migrations/0075_signalscoutconfig_status.py b/products/signals/backend/migrations/0075_signalscoutconfig_status.py index 9432a82dbbd3..ce77b2eda935 100644 --- a/products/signals/backend/migrations/0075_signalscoutconfig_status.py +++ b/products/signals/backend/migrations/0075_signalscoutconfig_status.py @@ -1,3 +1,5 @@ +import django.db.models.deletion +from django.conf import settings from django.db import migrations, models @@ -50,5 +52,17 @@ class Migration(migrations.Migration): name="status_changed_at", field=models.DateTimeField(blank=True, null=True), ), + migrations.AddField( + model_name="signalscoutconfig", + name="status_changed_by", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), migrations.RunPython(backfill_paused_by_user, reverse_code=migrations.RunPython.noop), ] diff --git a/products/signals/backend/models.py b/products/signals/backend/models.py index 570dda3fd429..deb81fb1b3a4 100644 --- a/products/signals/backend/models.py +++ b/products/signals/backend/models.py @@ -1178,6 +1178,21 @@ class PauseReason(models.TextChoices): # itself, so it is excluded from activity logging. Null until the first transition; # `created_at` anchors the cold-start grace window for rows that never transitioned. status_changed_at = models.DateTimeField(null=True, blank=True) + # Who last moved `status`, when a human did (through the config API). Null for system + # transitions, unattributed writes, and rows whose status never changed. The enum already + # says whether a pause is human or system; this adds WHO for human actions and tells a + # human re-enable apart from a system resume. Who last edited anything else on the row + # stays the activity log's job. `db_constraint=False` because posthog_user is a hot table + # and adding the FK constraint would lock it; integrity is app-level only, like the + # constraint-free path recommended for hot-table FKs. + status_changed_by = models.ForeignKey( + "posthog.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + db_constraint=False, + ) # Dry-run vs emit. Defaults emit-on so 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. @@ -1270,7 +1285,10 @@ def save(self, *args: Any, **kwargs: Any) -> None: else: self.status = self.Status.ACTIVE if self.enabled else self.Status.PAUSED_BY_USER self.pause_reason = None - touched = {"status", "pause_reason"} + # An enabled-only write carries no actor, so the attribution stamp is cleared + # rather than left pointing at whoever made the previous transition. + self.status_changed_by = None + touched = {"status", "pause_reason", "status_changed_by"} if not self._state.adding: self.status_changed_at = timezone.now() touched.add("status_changed_at") @@ -1302,8 +1320,11 @@ def transition_status_by_system( self.status = new_status self.pause_reason = recorded_reason self.status_changed_at = timezone.now() + self.status_changed_by = None self.enabled = new_status in self.RUNNABLE_STATUSES - self.save(update_fields=["status", "pause_reason", "status_changed_at", "enabled", "updated_at"]) + self.save( + update_fields=["status", "pause_reason", "status_changed_at", "status_changed_by", "enabled", "updated_at"] + ) return True def in_cold_start_grace(self) -> bool: diff --git a/products/signals/backend/scout_harness/serializers.py b/products/signals/backend/scout_harness/serializers.py index 18e15405b7b7..c1f893636316 100644 --- a/products/signals/backend/scout_harness/serializers.py +++ b/products/signals/backend/scout_harness/serializers.py @@ -2063,9 +2063,11 @@ def update(self, instance: SignalScoutConfig, validated_data: dict) -> SignalSco else: target = None if target is not None and target != instance.status: + request = self.context.get("request") validated_data["status"] = target validated_data["pause_reason"] = None validated_data["status_changed_at"] = timezone.now() + validated_data["status_changed_by"] = getattr(request, "user", None) return super().update(instance, validated_data) class Meta: diff --git a/products/signals/backend/test/test_scout_harness_api.py b/products/signals/backend/test/test_scout_harness_api.py index 4be59a9bbbf6..67c2a5289a01 100644 --- a/products/signals/backend/test/test_scout_harness_api.py +++ b/products/signals/backend/test/test_scout_harness_api.py @@ -1364,6 +1364,7 @@ def test_partial_update_disable_records_a_user_pause(self) -> None: assert response.json()["status"] == "paused_by_user" config.refresh_from_db() assert config.status == SignalScoutConfig.Status.PAUSED_BY_USER + assert config.status_changed_by_id == self.user.id def test_partial_update_enable_resumes_a_system_pause_and_clears_the_reason(self) -> None: config = SignalScoutConfig.objects.create( diff --git a/products/signals/backend/test/test_scout_status.py b/products/signals/backend/test/test_scout_status.py index 1479cdcd10db..89d9b5c2ae6b 100644 --- a/products/signals/backend/test/test_scout_status.py +++ b/products/signals/backend/test/test_scout_status.py @@ -116,6 +116,18 @@ def test_system_writer_may_never_set_paused_by_user(self) -> None: with self.assertRaises(ValueError): config.transition_status_by_system(Status.PAUSED_BY_USER, pause_reason=Reason.NO_OUTPUT) + def test_system_transition_clears_the_human_attribution_stamp(self) -> None: + # Without the clear, a system pause would keep pointing at whichever human made the + # previous transition and read as their action. + config = self._config() + config.status_changed_by = self.user + config.save(update_fields=["status_changed_by"]) + + config.transition_status_by_system(Status.PAUSED_BY_SYSTEM, pause_reason=Reason.NO_OUTPUT) + + config.refresh_from_db() + assert config.status_changed_by is None + class TestScoutStatusEnabledReconciliation(BaseTest): def test_create_with_enabled_false_records_a_user_pause(self) -> None: From 1f8a331271ddc423fa0b85b3d66413f6b6088ba9 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 30 Jul 2026 16:23:55 +0100 Subject: [PATCH 03/12] fix(signals): harden scout status primitive per review --- products/signals/ARCHITECTURE.md | 6 +- products/signals/backend/admin.py | 19 +++- .../0076_scout_config_status_constraint.py | 30 ------ .../backend/migrations/max_migration.txt | 2 +- products/signals/backend/models.py | 91 ++++++++++++------- .../backend/scout_harness/serializers.py | 5 +- .../backend/test/test_scout_harness_api.py | 15 +++ .../signals/backend/test/test_scout_status.py | 39 +++++++- .../references/lifecycle-and-testing.md | 2 + 9 files changed, 136 insertions(+), 73 deletions(-) delete mode 100644 products/signals/backend/migrations/0076_scout_config_status_constraint.py diff --git a/products/signals/ARCHITECTURE.md b/products/signals/ARCHITECTURE.md index 9ce45c8d12ec..241e1b1cb4f6 100644 --- a/products/signals/ARCHITECTURE.md +++ b/products/signals/ARCHITECTURE.md @@ -542,7 +542,11 @@ Per-scout binding for the headless **Signals agent**: one row per `(team, skill_ | ---------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `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`. `False` pauses just this scout. | +| `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` / `ignored` / `repeated_failures`. Also names the writer that owns the pause: a system writer may only clear or overwrite a pause carrying its own reason (`transition_status_by_system()`). Null outside `pending_pause` / `paused_by_system`. | +| `status_changed_at` | DateTime (nullable) | When `status` last changed. With `status_changed_by`, anchors the cold-start grace window (`in_cold_start_grace()`) that system writers honor before evaluating a scout. | +| `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. | | `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. | diff --git a/products/signals/backend/admin.py b/products/signals/backend/admin.py index 85e1a3d19a11..8300a4d69073 100644 --- a/products/signals/backend/admin.py +++ b/products/signals/backend/admin.py @@ -91,6 +91,7 @@ class SignalScoutConfigAdmin(admin.ModelAdmin): "team_link", "skill_name", "enabled", + "status", "emit", "run_interval_minutes", "run_cron_schedule", @@ -98,10 +99,24 @@ class SignalScoutConfigAdmin(admin.ModelAdmin): "updated_at", ) list_display_links = ("id",) - list_filter = ("enabled", "emit") + list_filter = ("enabled", "status", "emit") search_fields = ("id", "skill_name", "team__name", "team__organization__name") raw_id_fields = ("team", "created_by", "enabled_by") - readonly_fields = ("id", "created_at", "updated_at", "last_run_at") + # The status cluster is read-only here: admin's lifecycle control stays the `enabled` + # checkbox (the model's save() derives the status pair from it), and a hand-edited status + # or attribution stamp would bypass the transition rules the API and system writers + # enforce. Read-only also keeps `status_changed_by` off the default user `