Problem
Any project member with signal_scout:write can pause or resume any scout in the project, and so can any agent acting under that member's credentials. There is no per-scout control over who may do it.
This week an agent running under a team member's account paused most of the canonical scout fleet in one bulk action, in under a second. Nobody noticed for several days, and the activity log recorded the account but not which agent or why. Resuming the fleet then tripped the enabled-scout cap, so five scouts stayed off until the cap was raised.
Context threads (internal): #team-self-driving, MCP analytics thread.
Scouts are becoming the thing other systems depend on (the MCP tool-calls scout is the planned detector for surges like the one discussed in the second thread). A scout that can be switched off by any passing agent is not something you can build on.
Proposal
A per-scout toggle: "Only owners and admins can pause or resume this scout".
When it is on, changing the scout's lifecycle needs the caller to be one of:
- an organization admin or owner (
effective_membership_level >= ADMIN), or
- an owner of the scout: the skill's
LLMSkillOwner rows, plus the acting user resolve_scout_acting_user_id resolves (the author, or enabled_by / created_by for a canonical scout).
Everyone else gets a 403 with a reason that names who can do it, and the UI shows the switch disabled with the same reason. The same check applies to every write path, since they all land on SignalScoutConfigViewSet.partial_update: the roster switch, the detail page "Resume it" button, the MCP scout-config-update tool, personal API keys and OAuth tokens.
Canonical scouts have no skill owners today, so for them the lock means admins plus whoever last enabled the scout. That is the case that bit us, and it is the right default: nobody "owns" the canonical fleet except the people who chose to turn it on.
What the lock covers
| Field or action |
Locked? |
Why |
enabled (the paused_by_user / resume transition) |
yes |
the whole point |
emit |
yes |
emit=false is a silent pause: the scout runs and writes nothing |
destroy |
yes |
deleting is the strongest pause |
| the lock field itself |
yes |
otherwise anyone unlocks then pauses |
| schedule, model, network, destinations, notes, schema |
no |
already have their own gates, and none of them silences a scout outright |
System writers are unaffected. The inactivity sweep, repeated-failure trip and canonical retirement still move status through transition_status_by_system, which already refuses to touch a paused_by_user row. The lock is about people and agents, not the scheduler.
Who can turn the lock on or off
Same set as above: admins and owners. Turning it on is a one-way ratchet for everyone else, which is what makes it useful.
Activity log
Both the lock flip and a refused attempt should be visible:
- log the lock field change like any other config change, through the existing
SignalScoutConfigContext
- on a 403 from the lock, emit a
signals_scout_lifecycle_change_refused metric with the auth kind (session, personal API key, OAuth, sandbox) so we can see how often agents hit it
Now that record_agent_intent puts the agent's stated intent on activity entries, a pause that does get through should read as "paused by via : " in the scout's paused-by line and in the activity describer. That is a separate small change but belongs alongside this one, since the incident's hardest question was "which agent did this".
Implementation sketch
Backend, products/signals/backend/:
SignalScoutConfig: add lifecycle_locked = BooleanField(default=False, db_default=False) (name open, see below). Non-blocking AddField with db_default, same shape as auto_pause_exempt.
scout_harness/views.py: a _assert_can_change_locked_lifecycle(config) helper reusing the admin / author / owner resolution that assert_can_grant_scout_write_scopes already does for write_scopes. Call it from partial_update when the request touches enabled, emit or the lock field and the row is locked, and from destroy. Do the check inside the existing select_for_update block so a concurrent unlock cannot race it.
scout_harness/serializers.py: expose the field on SignalScoutConfigSerializer and SignalScoutConfigUpdateSerializer with help_text that explains who it restricts, since that text becomes the MCP tool description.
activity_logging.py: nothing new if the field flows through changes_between; confirm it is not on an exclusion list.
mcp/tools.yaml: update the scout-config-update description so an agent that gets a 403 knows to stop and ask the owner rather than retry. Regenerate with hogli build:openapi.
Frontend, products/signals/frontend/inbox/components/config/scouts/:
ScoutConfigControls.tsx: disabledReason on the enabled switch when locked and the viewer is not an owner or admin. The API should return a can_change_lifecycle boolean per config so the UI does not re-derive the rule.
ScoutDetailHeader.tsx: same on "Resume it"; add the lock toggle to the settings panel next to the auto-pause exemption, with a lock badge in the header when it is on.
ScoutsRoster.tsx: lock badge in the row, and a filter value for "locked".
activityDescriber.tsx: render the lock flip.
Skills: authoring-scouts and exploring-scouts mention that a locked scout refuses enabled changes from non-owners, and tell the agent to leave a scout note or ping the owner instead.
Tests: extend test_scout_status.py (or test_scout_harness_api.py) with parameterized cases for owner / admin / member / PAK / sandbox token against a locked and an unlocked scout, covering enabled, emit, destroy and the lock field.
Out of scope
- A project-wide "lock every scout" default. Worth a follow-up once the per-scout version exists; it is one more field on the team extension row.
- Bulk-pause rate limiting or a confirmation step for agents pausing more than N scouts in one session. Different mechanism, possibly more valuable, tracked separately.
- Restricting who may create scouts. Already gated on
llm_skill editor access.
Open questions
- Field name.
lifecycle_locked, protected, or restrict_lifecycle_changes. The UI label is decided ("Only owners and admins can pause or resume this scout"); the API name is not.
- Should a locked scout also refuse non-session callers? The incident was an agent acting as an admin. If the account is an admin, the owner/admin lock alone does not stop it. A stricter option: when locked, the lifecycle can only change from an interactive session, and API keys, OAuth tokens and MCP callers get a 403 that says to do it in the app. That would have stopped the bulk pause outright. Cost: it also blocks a person driving PostHog AI in the app if that path is not session-authenticated. Leaning towards shipping owner/admin first and adding "session only" as a second level of the same toggle if agents keep hitting it.
- Owner list trust.
LLMSkillOwner is writable by any skill editor, which is why resolve_scout_acting_user_id refuses to use it for identity. For a pause lock the exposure is smaller (an editor adds themselves as owner, then pauses), but it exists. Acceptable for v1, or should a locked scout also freeze its owner list to owners and admins?
- Canonical scouts with no
enabled_by. A canonical scout the sync seeded on, never touched by a person, has no owner and no enabled_by. Under the lock only admins can pause it. That seems right, but flagging it.
Problem
Any project member with
signal_scout:writecan pause or resume any scout in the project, and so can any agent acting under that member's credentials. There is no per-scout control over who may do it.This week an agent running under a team member's account paused most of the canonical scout fleet in one bulk action, in under a second. Nobody noticed for several days, and the activity log recorded the account but not which agent or why. Resuming the fleet then tripped the enabled-scout cap, so five scouts stayed off until the cap was raised.
Context threads (internal): #team-self-driving, MCP analytics thread.
Scouts are becoming the thing other systems depend on (the MCP tool-calls scout is the planned detector for surges like the one discussed in the second thread). A scout that can be switched off by any passing agent is not something you can build on.
Proposal
A per-scout toggle: "Only owners and admins can pause or resume this scout".
When it is on, changing the scout's lifecycle needs the caller to be one of:
effective_membership_level >= ADMIN), orLLMSkillOwnerrows, plus the acting userresolve_scout_acting_user_idresolves (the author, orenabled_by/created_byfor a canonical scout).Everyone else gets a 403 with a reason that names who can do it, and the UI shows the switch disabled with the same reason. The same check applies to every write path, since they all land on
SignalScoutConfigViewSet.partial_update: the roster switch, the detail page "Resume it" button, the MCPscout-config-updatetool, personal API keys and OAuth tokens.Canonical scouts have no skill owners today, so for them the lock means admins plus whoever last enabled the scout. That is the case that bit us, and it is the right default: nobody "owns" the canonical fleet except the people who chose to turn it on.
What the lock covers
enabled(thepaused_by_user/ resume transition)emitemit=falseis a silent pause: the scout runs and writes nothingdestroySystem writers are unaffected. The inactivity sweep, repeated-failure trip and canonical retirement still move
statusthroughtransition_status_by_system, which already refuses to touch apaused_by_userrow. The lock is about people and agents, not the scheduler.Who can turn the lock on or off
Same set as above: admins and owners. Turning it on is a one-way ratchet for everyone else, which is what makes it useful.
Activity log
Both the lock flip and a refused attempt should be visible:
SignalScoutConfigContextsignals_scout_lifecycle_change_refusedmetric with the auth kind (session, personal API key, OAuth, sandbox) so we can see how often agents hit itNow that
record_agent_intentputs the agent's stated intent on activity entries, a pause that does get through should read as "paused by via : " in the scout's paused-by line and in the activity describer. That is a separate small change but belongs alongside this one, since the incident's hardest question was "which agent did this".Implementation sketch
Backend,
products/signals/backend/:SignalScoutConfig: addlifecycle_locked = BooleanField(default=False, db_default=False)(name open, see below). Non-blockingAddFieldwithdb_default, same shape asauto_pause_exempt.scout_harness/views.py: a_assert_can_change_locked_lifecycle(config)helper reusing the admin / author / owner resolution thatassert_can_grant_scout_write_scopesalready does forwrite_scopes. Call it frompartial_updatewhen the request touchesenabled,emitor the lock field and the row is locked, and fromdestroy. Do the check inside the existingselect_for_updateblock so a concurrent unlock cannot race it.scout_harness/serializers.py: expose the field onSignalScoutConfigSerializerandSignalScoutConfigUpdateSerializerwithhelp_textthat explains who it restricts, since that text becomes the MCP tool description.activity_logging.py: nothing new if the field flows throughchanges_between; confirm it is not on an exclusion list.mcp/tools.yaml: update thescout-config-updatedescription so an agent that gets a 403 knows to stop and ask the owner rather than retry. Regenerate withhogli build:openapi.Frontend,
products/signals/frontend/inbox/components/config/scouts/:ScoutConfigControls.tsx:disabledReasonon the enabled switch when locked and the viewer is not an owner or admin. The API should return acan_change_lifecycleboolean per config so the UI does not re-derive the rule.ScoutDetailHeader.tsx: same on "Resume it"; add the lock toggle to the settings panel next to the auto-pause exemption, with a lock badge in the header when it is on.ScoutsRoster.tsx: lock badge in the row, and a filter value for "locked".activityDescriber.tsx: render the lock flip.Skills:
authoring-scoutsandexploring-scoutsmention that a locked scout refusesenabledchanges from non-owners, and tell the agent to leave a scout note or ping the owner instead.Tests: extend
test_scout_status.py(ortest_scout_harness_api.py) with parameterized cases for owner / admin / member / PAK / sandbox token against a locked and an unlocked scout, coveringenabled,emit,destroyand the lock field.Out of scope
llm_skilleditor access.Open questions
lifecycle_locked,protected, orrestrict_lifecycle_changes. The UI label is decided ("Only owners and admins can pause or resume this scout"); the API name is not.LLMSkillOwneris writable by any skill editor, which is whyresolve_scout_acting_user_idrefuses to use it for identity. For a pause lock the exposure is smaller (an editor adds themselves as owner, then pauses), but it exists. Acceptable for v1, or should a locked scout also freeze its owner list to owners and admins?enabled_by. A canonical scout the sync seeded on, never touched by a person, has no owner and noenabled_by. Under the lock only admins can pause it. That seems right, but flagging it.