# Changelog All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and the project aims to follow [Semantic Versioning](https://semver.org/) from 1.0 onward. ## [Unreleased] ### Tutorial mode - **Tours.** The console has a welcome tour and one tour for each page. The welcome tour starts after the first sign-in, or on the first visit with authentication off. It shows the sidebar, the page time filter, the search, the bell, the Ask soc-ai button and the help menu. The tour of a page starts on the first visit to the page, after the welcome tour when both apply. Two tours never run at the same time. A step dims the page and draws an outline around one control, with a card beside it. A step whose control is not on the page is skipped. A tour with no control on the page does not start. - **The help menu.** The "?" button in the top bar opens a menu: Tour this page, Tour soc-ai from the start, Keyboard shortcuts and Open the guide. The menu also turns the tours on again. - **Turn off tours.** The last step of a tour and the Skip panel offer "Turn off tours". No tour then starts by itself. The help menu still starts one. - **Automated browsers.** No tour starts by itself when `navigator.webdriver` is true, when the URL has `?tour=off`, or when localStorage `soc-ai.tours.off` is `1`. The help menu still starts a tour, so an automated check can open one on purpose. - **The tour state.** Migration 0065 adds the column `users.tours_state`. It holds the opt-out and the version of each tour that the user saw, so the first sign-in holds across browsers. New routes: `GET /api/v1/me/tours` and `PUT /api/v1/me/tours`. A bearer token reads and writes the state of the user who created it. With no user behind the request, both routes answer 401 `no_session`, and the console keeps the state in the browser. A write with a bad tour id, a bad version or more than 64 tours answers 400 `invalid_tours_state`. The body is capped at 8 KiB. - All tour text is in `frontend/src/tours/content.ts`, for review in one place. ### Demo - **Every page shows content.** The hosted demo and `docker-compose.demo.yml` hold one story on an estate of 24 machines. An attack chain runs from a downloaded executable to lateral movement and an upload to cloud storage, with benign noise around it. The Hosts page, the host and entity pages, leads in every state, the analytic hits, a held analytic, a closed grid gap with its review hits, the Status panels, the runbooks and a cancelled backtest now show data. - **One anchor for every timestamp.** The demo fixture file moves to version 2. Every timestamp in it is written against one anchor, and the demo moves the anchor to the start instant. The last day and the last week hold data on any day the demo starts. - **The build.** `scripts/demo/build_story.py` writes `soc_ai/demo/fixtures.json` from `scripts/demo/recorded_runs.json` and `scripts/demo/story.py`. Two leak gates run before the write. `tests/test_demo_story.py` fails when the committed file is stale. - **The mock grid.** `scripts/demo/mock_es.py` answers the host page, the same-flow reads, the citation drawer and the alert pass. A time range now limits the alerts it lists. - **The banner** reads "Demo. Some runs are recordings from a test grid. A script generated the rest. Nothing here is live." - **No background work on the demo.** The demo runs no lead hunt and no reaper loop. The seed runs after the startup reaper, so the hunt in progress stays in progress. ### Event time - **Event timeline.** The investigation page and the hunt page have a new section, Event timeline. The section shows when the run is finished and two or more of its documents have an event time. It lists the documents of the run in the order of their event time, in UTC. A new wave starts after a gap of more than 30 minutes. The section states the gap between two waves. Each row gives the time, the dataset, the two ends, the rule or the request, and a link to the document. soc-ai reads the times from the documents that the run already holds. The page makes no new grid query. The section counts the cited documents that have no stored time. A file hash, a CVE id or a rule UUID is not a document, so the section does not count it. - **The list of a finding investigation.** An investigation of one hunt finding lists the documents of that finding and the documents that its verdict cites. Before this fix, the list held the documents of every finding of the hunt. A prod run about web exploitation showed an SSH session of another finding as a wave of its own. An older investigation that judged the whole hunt keeps its full list. - **Stories in time order.** The verdict summary, the hunt narrative and the Oracle summary keep the bottom line first. Then they tell the events in time order, with UTC times. They name each wave. They give each count its unit, and a window when the record states one. They never call an earlier event a consequence of a later event. The investigation of a hunt finding reads the documents of that finding in time order, with the waves named. When the finding cites no document with an event time, the list gives the other documents of the hunt in time order with no waves. - **A time does not resolve a citation.** The writers give the time of each record that they cite. The citation check ignores a date in a citation, because every alert holds its own date. An id with a time after it is still checked against the documents that the run read. - **The Oracle wire check.** On the tool path, the check of each Oracle request looks for a learned host or user name outside the fixed system prompt and the tool definitions. The new time rule put common words such as "exploit" and "hunt" in the prompt. A host with such a name stopped each adjudication on the tool path. - **Rule prevalence waves.** The rule prevalence tool reads the fire times of a burst that is longer than 30 minutes. It names each wave with its start, its end and its fire count. A rule that fired in three waves on one day no longer reads as one burst. The step on the page names the waves too. ### Hunt findings - **Each investigation judges its own finding.** An investigation of a hunt finding decides on the claim of that finding. The rest of the hunt is context. On 2026-10-24 the investigation of an SSH finding gave its verdict on the exploit. It named the SSH login only as context. A lead promotion still judges the hunt as a whole. - **A true positive needs an attack.** A login, a probe or a transfer that happened is not a true positive by itself. When the evidence does not show if the activity is an attack or administration, the verdict is needs more info. - **The anchor is a document of the finding.** The run anchors on a document that the finding cites. The documents of the other findings and of the lead anchor it only when the grid holds no document that the finding cites. - **Reuse needs the same subject.** An investigation of an alert no longer answers for a finding. Two findings of one hunt never share an investigation. A finding that cites no document has no subject fingerprint. A tool call is not a document. Such a finding gets its own investigation. On 2026-10-24 one hunt linked four different findings to one alert investigation. - **A link between leads goes in the narrative.** The hunt prompt and the objective of a lead hunt ask for it there. A threat finding whose title names leads becomes an observation. No investigation opens for it. A lead whose hunt found only such a link does not close as clean. The lead page states the reason "The hunt linked this lead to other leads." - **The time pattern goes in the narrative.** The hunt prompt asks for the waves, their times and the gaps in the narrative. It rules out a finding whose only claim is a wave, a time span or a count. A scheduled hunt wrote "Exploit campaign held one wave on one host pair" as a threat finding, and the finding got an investigation of its own. A finding about an attack in one wave is still a finding. - **The investigation page names the finding.** A new finding investigation reads "Subject: F2" with the title of the finding. The hunt panel reads "Context: hunt". An older finding investigation keeps its old labels, because it judged the whole hunt. The tip of the hunt chip on the Investigations list now says that the subject is one finding of the hunt or the whole hunt. The page still names the finding after an admin deletes the hunt. - **The Oracle reads the claim.** The Oracle case of a finding investigation names the title and the hosts of the finding. The Oracle judges that claim. The redaction learns each host of the claim, so a host name reaches the Oracle as a label in the claim and in the other fields. ### Re-run of a finding investigation - **Re-run.** The investigation page of a hunt finding shows Re-run investigation when the run is not running. Re-run starts a new investigation of the same finding. Before this change, a finished needs_more_info run of a finding could not run again. Request more info refused it, and the hunt page linked only to the finished run. - **The route.** `POST /api/v1/hunts/{hunt_id}/findings/{ordinal}/investigate` takes the optional JSON body `{"rerun": true}`. The route then starts a new run over a run that ended. A running run of the finding still answers with `existing: true`. Two presses at the same time start one run. Without the body, the route keeps its idempotent answer. - **The new run.** The new run judges the claim of the finding. It keeps the subject and the synthetic-evaluation marker of the hunt. The hunt page links the finding to the new run. The run writes nothing to Security Onion. - **The old run.** The old run stays as a record. Its page names the new run. Its Re-run, Override verdict and actions are off. The needs-you count and the bell leave it out. The hunt counts the finding as investigated again when the new run ends. - **Analyst verdict.** Re-run asks first when an analyst verdict is on the run. The verdict of the new run then replaces the analyst verdict on the hunt page. The investigation of a finding has no Deep re-run and no Request more info. On a run that the page calls stuck, Re-run states that the server still runs the investigation. - **Hints.** The 409 `hunt_kind_no_rerun` hint names Re-run on the investigation of the finding. For a lead run, the hint names Resolve in chat. The bulk Re-investigate skips a lead run with the new reason `lead_kind`, and the list names Resolve in chat. ### Host history gate - **Hunt findings.** The host history gate reads every document that a finding cites. A malware alert or an attack alert among them keeps the true positive. The documents of other findings and of the lead do not count. Before this change, the gate read only one document of the hunt. That document was often a Zeek record. When the grid holds no document that the finding cites, the gate still reads only the anchor document. That anchor is a document of another finding or of the lead. - **Web attack evidence.** A true positive also stays when the summary or the citations name web attack evidence. Examples are a web shell, a path traversal, `/etc/passwd`, a file inclusion, a command injection, an SQL injection and remote code execution. Evidence of a type does not count when a rule name in the alert history of the host names the same type. The word "exploit" and a CVE number do not keep a true positive. The alert history of a host contains them too. - **The summary.** When the gate changes a true positive to needs more info, the summary keeps the text of the model. The summary starts with three sentences that state the change. The investigation page shows the reason under Post-validator override. Before this change, the gate replaced the summary with a fixed text. - **The Oracle.** The gate does not run on an Oracle verdict. An Oracle true positive that changes the local verdict must still cite evidence beyond the alert. The Oracle reads the change of the gate at the start of the local summary. On 2026-10-29 the gate changed two Oracle true positives on hunt findings back to needs more info. ### Host names - **A connection document names one end.** An endpoint agent writes one document for each connection and for each DNS lookup. Such a document has two ends. `t_host_summary` now reads the `host.name` value of the document as the name of the agent's own end only. The `network.direction` value tells that end: `egress` is the source and `ingress` is the destination. On a DNS lookup document, the agent's end is the end that is not on port 53. When the document does not tell the end, no end gets the name. Before this fix, a host got the name of an agent that had a connection or a DNS lookup with it. This happened to hosts with an agent and to hosts without one. The tool also reads an address in the form `::ffff:192.0.2.1` as the IPv4 address `192.0.2.1`. - **The tool checks the name against the machines in the store.** `t_host_summary` now checks each `host.name` value against the machines in the store. The tool does not use a name when the store puts the address on one machine and the name on a different machine. The field `evidence.hostname_withheld` then gives the address of the other machine. An address that the store does not know keeps the name. A container address that two agents report is a machine of its own in the store. Such an address now gets no agent name. - **The host logs come from the right machine.** The `host_logs` block reads the documents of the host by its address and by its resolved name. A wrong name gave the block the agent, the planes and the counts of a different machine. On a live grid, the target of a test attack showed the process events of the operator's own host. A hunt then read those events as the target's events. ### Earlier conclusions - **Lead hunts.** A lead hunt reads the leads that closed as promoted in the last 7 days when the new lead names their subject. It reads at most 3 leads. Each lead line names up to 3 entities. The entities that the new lead also names come first. For each lead the hunt reads the title, the verdict and the first summary sentence of up to 3 investigations. On a lab grid, four leads formed on one host in 21 hours. Each hunt started with no knowledge of the conclusion before it. - **Scheduled hunts.** A scheduled hunt reads the findings of the last complete run of its objective. It also reads up to 2 older runs from the last 7 days. Each finding line gives the type, the title, the hosts, up to 3 document ids and the verdict of its investigation. soc-ai skips partial runs, runs that read nothing and runs of the synthetic evaluation. On a lab grid, one run named the test workspace behind an exploit. A run two days later could not name the driver. - **Claims to check.** The hunt reads each earlier conclusion as a claim to check. The block names no lead, hunt or investigation id, so the hunt cannot cite one. The citation check removes any citation that the hunt did not read. The Objective step on the hunt page shows the block and its source records. The objective does not change, so a scheduled hunt keeps its comparison with the last run. - **Redaction.** With redaction in fail-closed mode, soc-ai drops a block that holds a name the redaction cannot remove. The hunt then starts without the block, and the Objective step states this. - **Investigations of the findings.** The investigation of a hunt finding reads the hunt narrative, and the narrative can repeat an earlier claim. The prompt states that such a claim is not evidence. ### Lead hunts - **The tool name in the evidence block.** The evidence block of a lead hunt now names the tool `t_get_event_raw`. Before this fix, it named `get_event_raw`. The hunt agent has no tool with that name. ### Held leads - **The lead page states the reason.** When a threat finding of a lead hunt has no verdict, the lead waits for the analyst. The lead page now states "A threat finding of the hunt has no verdict." The lead history records the hold once for each hunt. On a lab grid, one investigation of a hunt finding stopped at its 900 s limit. Its lead then waited with no reason on its page. Re-run the investigation of that finding on the hunt page, or decide on the lead. - **Subject findings only.** The settle rule reads only the investigations of findings that are subjects by the current rule. The hunt page already counted only these. Before this change, a failed run on an older observation entry held the lead. - **A reused investigation settles the lead.** An investigation of another hunt with the same subject can answer for a finding. The hunt page now names that investigation on the finding, and the lead settles on its verdict. Before this fix, the finding kept its failed run, so the lead stayed held on each re-run. ### Auto-Investigate retries - **Retry limit.** Auto-Investigate retries a failed alert two times after a short wait. After the last short retry, the alert runs again once each 6 hours. A run fails when it ends in an error or a pipeline fallback. The first retry waits 15 minutes, and the second waits 30 minutes. A verdict on the alert resets its count. The setting `auto_triage_retry_limit` sets the number of short retries, from 0 to 10. The skip reasons count the alerts that wait and the alerts at the limit. The Status page counts the failed alerts that wait to retry. Bulk Investigate of selected alerts and Re-run ignore the limit. - **Alarm.** Five failed runs with the same error in two hours open one alarm in the bell. The alarm names the error. Each hour, one of the failed alerts runs again as a test. A verdict on one of the failed alerts closes the alarm. The other alerts with that error then get their retries back. - **Pause.** Five failed runs in a row with no verdict pause continuous Auto-Investigate for 15 minutes. Each failed run after that doubles the pause, up to one hour. A run that reaches a verdict ends the pause. Bulk Investigate still runs during a pause. The Status page names the pause and the error. After the pause ends, the Status page measures a missed run from the end of the pause. - **Fallbacks in the bell.** The bell shows a pipeline fallback as a failed triage. A dismissed fallback leaves the bell. The bell then shows the older verdicts in its place. - **Unexpected cancels.** A run that stops with an unexpected cancel fails only its own alert. The batch goes on to the next alert. - **The reason.** On 2026-10-27 the model gateway refused the triage schema for 11 hours. The scheduler ran two alerts 109 times each. The bell showed each failed run as a needs_more_info verdict, and no alarm named the fault. ### Dependencies - `opentelemetry-api` 1.45.1. pip-audit found CVE-2026-114603 in 1.39.1. The lock drops `importlib-metadata` and `zipp`, which the new version no longer needs. ## [1.6.0] - 2026-10-21 ### Dependencies - `aiohttp` 3.14.5 and `certifi` 2026.10.19. pip-audit found four CVEs in aiohttp 3.14.3 and one advisory in certifi 2026.4.22. - `virtualenv` 21.14.12, `packaging` 26.3 and `distlib` 0.4.4. pip-audit found five CVEs in virtualenv 21.14.1. The newer virtualenv needs packaging 26. - GitHub Actions: `docker/build-push-action` 7.4.0, `docker/login-action` 4.6.0, `docker/setup-qemu-action` 4.4.0, `docker/metadata-action` 6.2.0, `docker/setup-buildx-action` 4.4.1 and `astral-sh/setup-uv` 10.3.0. The docker actions move to the Node 24 runtime. The workflows use no input that these versions removed. ### Operator workflow (2026-10-20) - **Act on a waiting action from the list.** The Investigations list of runs with a waiting action shows each action on its row, with the write named on its button and Dismiss. Both use the routes of the action card. A write on a run that a newer run replaced answers 409. Select rows and press Dismiss actions to dismiss their waiting actions with one confirm. The result line names each row and offers Undo for the press. - **One action rule.** The action card, the list row, the needs-you count and the execute route read one rule. A write is off on a replay and on a superseded run. A write is off on a promoted finding or lead, and on a document that a promotion uses as its anchor when the document is not a Security Onion alert. A write is off on a run with no alert, on a comment with no case, and in the demo. A write is also off when it disagrees with the analyst verdict on the alert. Each case states its own reason. The execute route refuses a write against the analyst verdict with 409 `analyst_verdict`. New fields on `GET /api/v1/investigations/{id}`: `blockedReason` on each action, `writeBlockedReason`, `alertCases` and `alertCasesComplete`. - **The anchor of a real alert.** The anchor rule refused every Security Onion write on a document that a promotion uses as its anchor. On the range, lead 17 cites a real Suricata alert, and the execute route refused "Escalate to case" on the investigation of that alert. soc-ai now asks the grid whether the alert feed holds the anchor document, with the alert filter of the Alerts page and no time window. A real alert takes the write, the ack and a new run with writes on. Telemetry keeps the refusal, and its reason now reads "A promoted lead uses this document as its anchor. The document is not a Security Onion alert." One search answers a page of rows, and the answers stay in memory for 6 hours. When the grid does not answer, the write is off with the new code `anchor_unread`. The execute route, `POST /api/v1/alerts/ack-events`, `POST /api/v1/hunt` and request-more-info then answer 503. Bulk re-hunt skips the run with the same reason. `POST /api/v1/hunt` over a telemetry anchor of a lead now runs with Security Onion writes off, like `POST /investigate`. - **The needs-you count holds actions that can run.** The count, the badges, the bell and the pending filter leave out an action that no person can execute. The run page keeps it with its reason. On the range, 8 of the 9 counted actions were on promoted findings. - **The cases of an escalate.** The escalate card and the row confirm name and link each case that the alert is already on. A press then opens no second case and records the action as done. The scope of an escalate adds `cases` and `casesComplete`. - **Same flow.** An Alerts group names the other groups on its network flow: an alert of each group with the same community id within 1 hour of the other, or an alert of each group between the same two hosts within 5 minutes of the other. A community id repeats across days for fixed-port traffic such as NTP, so the community id link has a time bound too. The chip warns when the verdicts are opposite. A report shows the same block, and Show in Alerts opens the row of each sibling. The Alerts screen asks for it with `GET /api/v1/alerts?links=true`. The read costs at most two searches and stays one minute for each window. When a size limit cuts the read, the count reads "at least" and the list says that it can be partial. New fields: `siblingsPartial` on each row, and `partial`, `windowFrom` and `windowTo` on the new route `GET /api/v1/alerts/siblings`. - **The case of an escalated alert.** The escalate confirm and the escalated marker on a row link the Security Onion case that the alert is on. - **Bulk decisions on leads.** The Hunts page selects leads. One press dismisses, reopens or hunts the selection. The confirm states the count and the skips. A bulk hunt starts at most 3 hunts. Undo stays for 2 minutes. New routes: `POST /api/v1/hunts/leads/bulk-dismiss`, `bulk-reopen`, `bulk-dismiss-again` and `bulk-hunt`, and `GET /api/v1/leads/bulk-hunt-plan`. - **Cancel a backtest.** `POST /api/v1/backtest/{id}/cancel` stops the run after the alert in flight. The run keeps the results it finished and reads "Cancelled after N of M alerts." - **Check one stranded escalation.** "Check Security Onion now" on the Status page re-reads one escalation claim and settles it when Security Onion confirms the case. It writes nothing to Security Onion. The result links the case it found. New route: `POST /api/v1/escalations/check`, with the field `case_url`. - **Generated password banner.** Every page shows a banner while the account still uses the generated password. The banner opens Change password. ### Dogfood fixes (2026-10-19) A dogfood on the attack range and on a fresh install with authentication on found 141 defects. The range sensor was down from 2026-10-15 to 2026-10-19, and many defects came from that real outage. Migration 0064 adds the `grid_gaps` table. **Grid gaps** - soc-ai records a grid gap when the grid stays unreadable for 3 minutes. It closes the gap when the grid answers again. The ledger survives a restart. - The sweep also records a gap when most hosts of a plane go dark in the same hours. One quiet host among busy hosts still gets its departure. - The sweep does not score the hours of a gap or the settle margin after it. The new setting `grid_gap_settle_minutes` sets the margin. The default is 60. - A profile stores a gap hour as missing. The baseline no longer learns the outage as zero. - A hit inside a gap does not count toward the daily hit limit of its analytic. - A closed gap stays in the bell for 24 hours. The Dashboard, Alerts, Hunts and the host page show a strip for each gap in the window. The Alerts empty state no longer says "The grid is quiet" over a gap. - The general chat and a hunt get one fact line for each gap in their window. - New route: `GET /api/v1/grid/gaps`. - An operator can record a gap that soc-ai did not see: `soc-ai grid-gaps add --start --end`, or `POST /api/v1/grid/gaps` as an admin. `soc-ai grid-gaps list` prints the ledger. - "Review N hits in this gap" lists the hits that a gap wrote. soc-ai selects a hit when its event window lies inside the gap and it is not a spike. The drawer states the reason under each hit it does not select. The operator can change each selection. One confirm marks the selected hits benign with the reason `grid_gap`. The marks have no undo. - `POST /api/v1/grid/gaps/{id}/hits/benign` requires `ids`. A body without `ids` answers 422. A selected id that left the gap list answers 409. - The hold card of an analytic says how many of the hits that broke its limit fall inside a gap. It offers **Return to live** when the gap hits alone broke the limit. A spike inside a gap does not count toward the offer. The card still finds the hold after the next sweep marks those hits shadow. - The lead page leaves benign observations out of its weight. **Verdicts and writes to Security Onion** - Auto-acknowledge never acts on an alert that carries an analyst verdict other than false positive. The guard covers a re-run and the cluster fan-out. - A re-run reads the analyst verdict. Re-run, Deep re-run and Request more info ask first when an analyst verdict stands on any run of the alert. The confirm names that verdict. - The investigation page and the Alerts row name an analyst verdict that stands on another run. - A new run from a backtest replay asks first. The confirm says that the new run becomes the live verdict of the alert. - Bulk Re-investigate skips an alert that carries an analyst verdict. - An override on a superseded run gets 409. The console turns off Re-run and Override on a locked record and says why. - An Alerts row opens the newest complete run of its alert. - The Acknowledge button and the escalate confirm state what one press writes, with the caps. A second press names what is already done. - The rule prevalence window ends at the alert time. **Hunts, leads and analytics** - The Analytics tab and the Status page show a failed or blind sweep. They no longer show a green "live" over failed runs. - An observation finding is no longer an investigation subject. The outcome counts threat findings over all findings. A card names an investigation that it shares. - A lead that soc-ai closed on the verdicts of its hunt says so. - Every unread shadow hit counts and lists on every surface. **Hosts and entities** - The entity page reads every key of a machine. An address and a name of one machine show the same counts. - The host page lists the investigations of the machine. `GET /investigations` takes `entity`. - The Users card reads Windows logons. - The Connections rate divides by the window hours that hold records. It counts the part of each edge bucket inside the window, and it leaves out the hours of a grid gap. The 7d rate divided by 192 hours on a 168-hour window. `GET /dossiers/{ip}/activity` adds `window_end`. - Every relative age reads one rule. The host header, the facts and the entity page read "30h ago" where the lists read "1d ago". An age from 60 days reads in months. - The host header reads "last grid event". The entity page reads "last soc-ai record". - A stale traffic pattern shows its stored sentence. It read "a traffic pattern". - The Role filter says that a guessed role also counts in "weak guess" or "old guess". - The bulk note and its Undo show the role label. They showed the role identifier. **Sign-in, Settings and Status** - An admin cannot reset their own password in Settings. The new command `soc-ai users reset-password ` recovers a locked-out admin. `soc-ai users list` prints the accounts. - The sign-in page states the real transport. With authentication off, it says so. - Number fields no longer clip their values. A field now has room for its spinner. - Data sources shows the grid in its first row, with the state of the health probe and the event index pattern. The blocklist row names the feeds that the config loads. - The Notification webhook section links to Settings > Notifications. It said "above". - API keys turns off Set and states the reason when CONFIG_SECRET_KEY is not set. `GET /config/api-keys` adds `can_store` and `store_hint` to each row. - The blocklist remedy in Diagnostics names the feeds and shows its command as code. - Each setting shows DEFAULT, ENV or DB. "Use the default" removes a database override. - A model check that meets only transport errors reads "could not measure" and raises no alarm. - The Status page shows the dependencies and each scheduled job. - "Danger Zone" is now "Connections". It shows the current values and tests an edited value. - An API token has a name. Revoke asks first. - New doctor rows: `admin password` and `backups`. An empty alerts feed is WARN. **Dashboard, shell and chat** - The needs-you count leaves out actions on backtest replays. - The Dashboard, Hunts, Investigations and host page keep their window and filters in the URL. - A custom window means the same hours on every page. - A chat turn that times out after its tools returned writes a partial answer. - Chat answers link addresses, leads and investigations. - Dialogs stay centred and fit the screen. - `/` opens the Dashboard. An unknown page address opens the console not-found page. **Text** - The console, the backend operator strings, the docs and the in-app guide follow ASD-STE100. - The timeline no longer shows prompt text or internal labels. It names the trigger of a deeper investigation. Stored text that the timeline cannot read stays as it is. - Toasts show at the bottom centre of every page. ### Swarm triage experiment (2026-10-08) The experiment tests whether a team of agents triages an alert better than the one investigator, at a cost the owner accepts. The design record is `docs/dev/specs/2026-10-08-swarm-triage-experiment-design.md`. Production keeps the default. - **One setting selects the investigator strategy.** `investigator_strategy` takes `single`, `spend`, `ensemble`, `advocates` or `planes`. The default is `single`, and the loop runs as before. The environment variable is `INVESTIGATOR_STRATEGY`. The override store accepts the setting live. The Settings page does not show it, because it is an experiment setting. - **The loop fills one result object.** The investigation loop hands the pipeline one `LoopResult`. Each strategy fills the same object. The steps before and after the loop do not change, and the gates do not change. - **Two advocates and a judge.** The `advocates` strategy runs a threat advocate and a benign advocate in parallel. Each advocate is the investigator of the loop, with a mandate and its own limits. Each advocate writes a brief: a claim, the records that support it, the strongest counterpoint, a confidence word and the gaps. A judge with no tools writes the report from the alert context and the two briefs. - **One worker for each plane.** The `planes` strategy runs one worker for each evidence plane of the alert. A worker can use the tools of its plane only. A lead with no tools writes the report from the briefs. - **The judge cites only the briefs.** The pipeline removes from the report each document id that no brief carries. The report counts the removed ids. The gates then check each citation that is left. - **Hard budgets.** Each agent has its own limits. The agents of one strategy share `agent_tool_calls_limit`. An agent that reaches its limit writes its brief from the evidence it has. An agent that fails returns an empty brief with the error, and the judge reads it. - **Every agent is counted.** Each agent emits the tool, model and usage events of the loop. Two new events, `swarm_agent_started` and `swarm_agent_finished`, record the role, the calls and the tokens of each agent. The audit trail accepts them, and the timeline gives them a title. The report names the strategy when it is not `single`. - **The vote counts each sample.** The self-consistency vote emitted no usage event for its extra samples, so a run with three samples read at the cost of one. Each sample now emits a usage event. - **A run cut at the budget votes.** When the vote re-runs the loop, a run that reaches its tool budget votes with the partial verdict that one more call with no tools writes. The first run votes the same way. Only a run that raises or ends with no verdict is dropped. Before, the vote dropped each run cut at the budget, so an ensemble at 8 tool calls often ran as a single run. The `self_consistency_vote` event states `requested`, `voted`, `partial` and `dropped`. - **A backtest replays a fixed list.** `POST /api/v1/backtest` takes `alert_ids`, a list of 1 to 200 alert ids. The run replays those alerts in that order and reads the analyst label of each from the grid. Each backtest status lists the ids it replays, so the next strategy can replay the same alerts. ## [1.5.3] - 2026-10-15 1.5.3 is the detection ladder release. It holds the work from 2026-10-01 to 2026-10-14. The release note is [docs/releases/1.5.3.md](docs/releases/1.5.3.md). The work started with a fleet of twelve browser and API dogfood agents on 2026-10-01. The sections below hold what they found and what changed after. ### Security (2026-10-15) - **pydantic-ai-slim 1.107.7.** The lock moves `pydantic-ai-slim` and `pydantic-graph` from 1.107.0 to 1.107.7. The new version closes PYSEC-2026-4283 to PYSEC-2026-4291. The dependency audit of the first 1.5.3 release run found them. The pin stays `>=1.0,<2`. ### Alert verdicts follow the time filter (2026-10-14) - **Runs inside the page window decide the verdict of an alert group.** Everything on a page follows the time filter of that page. The verdict counts of a group reached back seven days past a 24-hour window. The alerts API now counts only the runs made inside the window. It names the worst of them as the window verdict. - **A group with no run in the window keeps its older verdict.** The Alerts row marks that verdict "inherited" and states its age. The Dashboard outcome panel states how many groups carry an inherited verdict. - **The Dashboard and the Alerts list read one verdict.** The verdict filter, the verdict sort, the "untriaged" count and the "Needs decision" chip read the verdict that the Dashboard breakdown counts. A tile opens a list that holds the same groups. The row pill and the confidence column show that verdict too. - **A custom window reaches the server as a custom window.** The console sent the two bounds without `range=custom`, and the verdict counts read the default 24 hours. The console now sends the marker. The server also reads the two bounds alone as a custom window. A bound without an offset reads in `so_timezone`. The grid query reads it in that zone too. - **A backtest replay does not decide a window verdict.** The standing verdict already skipped a replay. The verdict counts of a group now skip it too. - **The alerts API adds four fields.** `GET /api/v1/alerts` adds `windowVerdict`, `windowConf`, `windowInvId` and `verdictAt` to each group. `verdictCounts`, `worstVerdict`, `worstConf` and `worstInvId` now read the runs of the window only. `verdict` keeps its meaning: the standing verdict of the rule. ### Host page window (2026-10-14) The owner set a rule for every page. The time filter of a page filters everything on it. A number that the filter does not change says its scope in words on the screen. A hover title is not a label. - **The host page shows every open lead on the host.** The page read the newest 50 leads of all hosts and kept the leads on this host. A host lead older than those 50 did not show in the first lines or in the leads list. The server now filters the leads by host before the row limit. The filter reads every address and every name of the machine. - **The lead counts read the whole set.** `GET /leads/count` gives the total for a filter. The first lines and the leads heading on the host page and on the Hunts page use this total. The heading said at most 50 before. When the list holds fewer leads than the total, a line under the list says so. - **Each count says its scope.** The leads heading says "all open" on the Needs decision and In progress tabs, and "all time" on the Closed and All tabs. The first lines of the host page say "all open leads" beside a lead and "last 7 days" beside a verdict count. - **The sweep counts say their window.** The event count in the host header, the Events columns of the Addresses table and the connection counts under Why? say "last 14 days". The number is the "Dossier window (days)" setting. `GET /dossiers/{ip}` gives it as `lookback_days`. - **`GET /leads` takes `entity` and the status `open_or_hunting`.** A blank `entity` gets the answer 422 `entity_required`. ### Page windows (2026-10-14) The owner set a rule for every page. The time filter of a page filters everything on it. A part that the filter does not filter states its scope in visible words. A tooltip is not a label. - **The Needs-you tile states the scope of each line.** Leads read "all open" and shadow hits read "all unread". Alert groups read "last 7 days", and verdicts and actions read "last 30 days". The total says that it is the sum of the lines below. With no work, the tile names every scope in one sentence. - **The shadow-hit tile reads "all unread".** Its link reads "Read the last 7 days on Hunts", because the hits block on Hunts lists 7 days. - **The recent list on the Dashboard obeys the page window.** It listed the newest runs of any age. An empty window now reads "No investigations in the last 15 minutes." View all opens the same window. The running tile keeps its scope words while Auto-Investigate runs. - **The tuning nudge states its two scopes.** Alert counts cover the last 7 days. Verdict counts cover all investigations. The summary route sends the window as `volume_window`. - **The Hunts header counts the whole window.** The header, the section badge and the type chip counted one page of 100 rows, so a window of 140 hunts read "100 hunts". The new route `GET /hunts/totals` counts every hunt in the window, per type. A line under the table states how many hunts of the window the table shows. When the totals do not load and the page is full, the counts read "100+". - **The Needs-you strip on Hunts states its scope.** Shadow hits read "all unread" and leads read "all open". The hit link reads "Read the last 7 days", because the hits block lists 7 days. - **Bulk Investigate names its window.** The header sweep reads the last 24 hours, whatever the list window is. The button, the progress line and the summary now say "last 24 hours". The console sends the range to the server. A selection names no window. ### Notifications (2026-10-14) - **The bell panel accounts for every unit of the badge.** In production the badge read 309 and the panel listed 12 rows, all shadow hits. A "Needs you" block now sits above the events. It states each part of the count with its scope, and links to its list. Examples are "300 shadow hits are unread, all unread" and "2 verdicts need more info, last 30 days". The words match the Needs-you tile on the Dashboard. The parts add up to the badge. A part the server could not read says so. - **The panel lists events only.** The block holds the leads and the shadow hits, so the panel and the Notifications page do not show them again as rows. `GET /notifications` takes `events_only=true` for this. - **Show more loads more rows.** `GET /notifications` takes a `limit` from 1 to 200. The default stays 12. The cap of each source grows with the limit, so a larger limit returns more rows. The panel and the Notifications page load 20 more rows for each click. - **Dismiss all replaces Clear all.** The confirm step states what happens. Dismiss all hides every event up to the moment the panel loaded. This includes the events that the panel did not load. It marks every unread shadow hit up to that moment as read on the server. Leads, alert groups with no verdict, verdicts that need more info and recommended actions are decisions, and they stay. An altered audit record stays too, because the server marks that notice undismissible. - **Dismiss all marks only the hits it named.** The confirm step names a count of shadow hits, and the call sends that count. A sweep can commit a hit after the count was read. When the write would mark more hits than the count, the server marks none and answers 409 `shadow_hits_changed`. The panel then asks the analyst to check the new count. - **A new route marks the shadow hits read.** `POST /hunts/shadow-hits/read-all` takes a `before` time. It marks every unread shadow hit born at or before that time as read, in one write, and writes a `shadow_hits_read` audit record. A hit born later stays unread. - **Each notification row carries its time.** The `at` field holds the moment the row describes, as ISO-8601 UTC. `GET /notifications` takes `after`, the time of the last Dismiss all, and leaves out the dismissible rows up to it. Hidden rows then take no place in the limit. - **The Notifications page header states the loaded count.** It said "the 10 newest". It now says how many items loaded and whether more exist. ### Dashboard true positives (2026-10-13) - **The outcome breakdown counts a group under its worst verdict.** A group took the verdict of the newest run of its rule. In production, the rule "GPL SHELLCODE x86 setuid 0" held one true positive and five later false positives on other sources. The breakdown read "True positive 0", and the true-positive tile read 1. The breakdown now uses the worst verdict, which the Alerts row already shows as a chip. - **The Alerts list filters and sorts by the same verdict.** The "True positive" tile opens the Alerts list with that filter. The list now holds the group that the tile counted. - **A run in flight and a pipeline fallback do not hide a verdict.** A group with a running investigation counts under the verdict it holds. A group whose newest run fell back still names an older true positive. ### Owner's pass 2026-10-07 - **One chat on every page.** The chat bubble on the investigation, host and hunt pages and the Dashboard chat panel are now one Ask soc-ai panel. The panel slides in from the right. It has the same width and header on every page. The button at the right end of the top bar opens it. The label names the object of the page, for example "Ask soc-ai about this host". The panel keeps one thread for each scope, states the scope at the top and has a switch to the general thread. The key `c` opens the panel, and Esc closes it. The Dashboard slot holds a card with the three starter questions. A lead page has a chat now, on the new routes `GET`, `POST` and `DELETE /api/v1/hunts/leads/{id}/chat`. The setting `general_chat_enabled` is now labelled "General chat". - **The peer diagram and the hunt map use a graph library.** Both diagrams now draw through one `Graph` component on React Flow 12.12.0, with a d3-force layout. Each label shows whole, and no label overlaps another. The host page puts the host in the centre and its peers on a ring. The size of a peer and the width of its line show the connection events. A peer that an alert names is red. The hunt map joins each finding to the hosts it names, and a threat finding draws red lines. A node opens its page, and a hover card states its facts. "Reset view" fits the diagram to the panel. The investigation graph uses the same component and loads on demand. The owner asked two questions: "What is the difference between hunt and promote?" and "What is the user flow?" This section holds what changed on the screen. - **A lead offers one verb.** The lead button reads "Hunt this lead". The lead page states the verb under its actions: "Hunt this lead. The hunt investigates what it finds and brings back the verdicts." The hover states what a hunt returns. It also states that a hunt takes no action in Security Onion. - **"Open an investigation" is the manual control on a finding.** The hunt page shows it on a finding with no investigation. "Promote" is gone from the lead page, the lead row and the lead bar of the hunt page. The lead bar offers "Dismiss lead". The API routes keep their names. - **A link to an investigation reads "View investigation".** "Open investigation" was one article away from "Open an investigation". - **The lead timeline names the act.** The store keeps the decision word `promoted`. The timeline reads "dan opened an investigation on this lead", and the chip reads "investigation opened". The refusal of a closed lead uses the same words. - **A flow strip names where each object sits.** The alert drawer, the investigation page, the lead page and the hunt page carry one line under the header. The line names each step of the flow and its state: done, current, next or not yet. A done step links to its object. The next step names the control that moves the flow, for example "Hunt this lead", "Acknowledge" or "Re-run investigation". The line wraps on a narrow window. - **The chain of each object.** An alert reads Alert, Investigation, Verdict, Action in Security Onion. A lead reads Hit, Lead, Hunt, Investigations, Verdicts, Actions. A manual hunt starts at its objective. The Investigations step counts the findings with a finished investigation, for example "2 of 3 done". A failed run names "Re-run investigation" and never shows a verdict. - **The strip reads the hunt investigation fields.** It reads `findingsInvestigated` and `findingsTotal` on the hunt. It reads `investigationId`, `verdict`, `investigationStatus` and `pendingActions` on each finding. An older server sends the `investigation` block of a finding, and the strip reads that block. - **The landing page is the Overview.** The page title reads "Overview". Under the Needs you tile one sentence states the flow: "Alerts and sweeps come in. soc-ai triages, hunts, and investigates what the hunts find. You decide in Needs you. Your actions go to Security Onion." The "How this flows" link follows it. The tile shows no ellipsis while the count loads. - **The chart draws both paths.** The "How this flows" chart draws the alert path beside the sweep path. Both paths end in an investigation, a verdict and an action in Security Onion. The chart uses the words of the controls: "Hunt this lead", "Open an investigation" and "hit". It defines the two verbs. The drawer title is "How this flows", the name of the link. `tests/test_hunting_flow_chart.py` keeps the console copy and the docs copy identical. - **The guide states the flow.** `docs/WEBUI_GUIDE.md` opens with a section, "The flow". It names the alert path, the sweep path, the one queue and the two verbs. `docs/HUNTING.md` states hunt and open an investigation in two sentences in its lead section. Both state the rule: a hunt is not finished until its findings are investigated. - **The tags have their own column.** The chips of a row sat beside its title. Each title started at a different place, and the list looked jagged. The chips now sit in one Tags column at the end of the row. The column has one width and one order on the Investigations list, the Alerts list, the Hunts table and the Dashboard recent list. The order is type, hunt subject, hunt kind, depth, synthetic evaluation, backtest replay, handled counts, dismissed, superseded, related runs and chat count. The title cell holds the title only, and the whole title wraps in it. Each table fits a window of 1280 pixels. A narrower window scrolls the table sideways. - **The console shows the whole text.** The owner banned the ellipsis. A title, a rule name, a host name, an address and a notice no longer stop at "…". The text wraps in its cell. The console has no `truncate` class, no line clamp and no middle cut of a rule name. A loading line reads "Loading" or "Reading the leads" beside a spinner. A count in progress reads "counting". A sentence names the whole investigation ID, and the About panel names the whole commit. - **The backend sends the whole text.** Titles, reasons, errors, doctor lines, bell lines and webhook bodies no longer stop at a character count with "…". A progress line such as "Searching" has no dots. A text that goes to the model keeps its bound, and the cut ends in "[truncated]". A database column or an external API keeps its bound too, with no ellipsis. - **Two tests hold the ban.** `frontend/src/noEllipsis.test.ts` reads every console string. It fails on "…", on "..." and on a class that draws an ellipsis. `tests/test_no_ellipsis.py` reads every backend string. It fails on a string that ends in "..." or holds "…". Five model prompts keep a placeholder, and the test names each one with the reason. ### Hunts investigate their findings (2026-10-07) - **A hunt investigates its findings.** After the report lands, the hunt opens one investigation for each finding that names an entity. A visibility gap, a failed query and a clean observation are not investigated. The actor is `system:hunt`. The investigation runs on the document the finding cites, else on the evidence of the lead. A finding with no document reads failed with that reason. The first range re-hunt reported a threat finding with two hosts and no citation, and a citation rule left it uninvestigated. - **The lead pages read the hunt by its verdicts.** The lead list and the lead page label a hunt with the counts the hunt page prints, "2 of 2 findings are threats". A lead the hunt closed on those verdicts reads the hunt tail in its flow strip. - **The hunt reads Investigating until the last one ends.** Two investigations run at a time, the highest severity first. The findings section reads "Investigating 2 of 3 findings". A failed investigation counts as ended. A cancel of the hunt stops its investigations. - **The cap is a setting.** `hunt_findings_investigate_max` caps one hunt at 20 by default. A finding past the cap reads "Over the cap, open by hand." `hunt_findings_investigate` turns the phase off. Both apply live from the Config screen. - **One investigation per subject.** A running or complete investigation of the same alert or the same subject from the last 7 days is reused. A re-hunt of a lead opens no second investigation for an unchanged finding. A failed run, a fallback and a backtest replay are never reused. - **The outcome reads the verdicts.** A hunt reads "2 of 3 findings are threats" or "No threat confirmed". The bell reads "Hunt finished: 2 of 3 findings are threats". - **The lead closes itself.** When every finding of a lead hunt has a verdict, the lead closes as promoted in the hand of `system:hunt`, with each verdict recorded. A finding with no verdict leaves the lead to the analyst. - **Each finding card shows its investigation.** The card shows the verdict, the confidence word, the actions that wait and a link. The API carries `findingsInvestigated` and `findingsTotal` on the hunt, and `investigationId`, `verdict`, `confidence`, `investigationStatus` and `pendingActions` on each finding. Migration 0063 adds the columns. - **The usage report counts them.** `soc-ai usage` lists the entry "Hunt finding investigation" and prints `hunt findings investigated: N`. ### Dogfood 2026-10-06, the investigation page - **The ack card states its scope.** "Acknowledge alert" acknowledged every unacknowledged alert of the rule from the last 7 days on one click. The card now states the scope before the click, for example "Acknowledge 9 alerts of this rule from the last 7 days, on 2 hosts." A confirm step follows, with "Acknowledge this alert only". The receipt states the count. The group ack leaves an alert with a true positive or a needs more info verdict open. The new route `GET /investigations/{id}/actions/{index}/scope` returns the plan, and the execute route takes `{"scope": "group" | "alert"}`. - **Every write asks first.** Escalate and Dismiss on a recommended action, the settled bar and the Alerts list Escalate have a confirm step. The escalate confirm says "Open one Security Onion case for this alert". The Alerts list confirm names the count of cases. - **A dismissal is stored.** Dismiss goes to the server with the actor and the time. A reload and a second analyst see it. The card offers "Undo". The new routes are `POST /investigations/{id}/actions/{index}/dismiss` and `.../restore`. - **An executed action names who and when.** The card reads "Executed by auto-ack at 03:12" or "Executed by at