Skip to content

Changelog

All notable changes to Huntable CTI Studio will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[7.9.0 "Europa"] - 2026-09-25

Added

  • Publisher-authored Sigma passthrough with enforceable provenance (2026-09-24): a deterministic, LLM-free scanner now finds complete Sigma mappings directly in articles.content, preserves the exact source substring and records article/source IDs, SHA-256, extraction offsets, license evidence and attribution. Source rules bypass generation and batch deduplication, receive reviewer-only similarity context, and remain immutable; edits happen on a generated copy. The queue adds a first-class local_review_only state and prominent provenance UI. Server-side single/bulk approval and the final PR preflight fail closed unless the destination repository explicitly allowlists the source license or a reviewer records source-specific permission; mixed PR batches are rejected before any Git/GitHub side effect. The schema migration and existing-article backfill are explicit, dry-run-by-default operator steps rather than startup mutations.
  • Catalog-driven model parameter compatibility and per-agent reasoning effort (2026-09-04): the per-agent Temperature / Top_P sliders rendered for every provider and model, so an operator could move a control that the OpenAI reasoning path dropped server-side and that the Codex adapter never sent -- and the lever that matters on current models, reasoning effort, was not configurable at all. Compatibility was a hardcoded name-prefix list that misclassified new families silently and did not know that Anthropic Opus 4.7+/4.8/5, Sonnet 5 and Fable 5 reject temperature outright (the Anthropic client sent it unconditionally). A new config/model_capabilities.json (keyed by model id: supports_temperature, supports_top_p, effort_levels, default_effort; tiers verified against the OpenAI per-model pages and the Anthropic effort doc on 2026-09-04) is now the source of truth, resolved by get_model_capabilities(provider, model) in provider_model_catalog.py; model_supports_variable_temperature() is a thin wrapper over it and the old prefix heuristics survive only as the fallback for unlisted ids, which get "sampling as before, no effort control" instead of an error. The v2 config contract gains an optional per-agent Effort (flat key {Agent}_effort, None = provider default; the twelve quickstart presets and the live DB row validate unchanged), the migrator lifts it, and PUT /api/workflow/config rejects a tier the resolved model does not list -- Codex tiers are live and have shipped names outside the documented vocabulary (ultra), so the contract pins the token shape and membership is checked against the catalog. llm_client.py sends reasoning_effort (OpenAI) or output_config.effort (Anthropic) only when configured and listed, omits temperature for models that reject it, and records the field in the forensic _provider_payload; LLMService resolves {Agent}_effort (extractors inherit ExtractAgent_effort) so existing call sites needed no signature changes; the Codex adapter keeps supportedReasoningEfforts / defaultReasoningEffort from model/list via list_model_details() and passes the configured tier as the turn/start.effort override. The OpenAI temperature-rejected retry now logs CAPABILITY DRIFT naming the model whenever the file claimed support, the production alarm for a stale value. GET /api/workflow/provider-options returns model_capabilities per provider, and the Agents page disables (never hides) an unsupported slider with a "Not supported by this model" note and renders an Effort select listing exactly the model's tiers after "Provider default", hidden when the model has none; the autosave carries {Agent}_effort and the server clears the override on the blank option. The daily update_provider_model_catalogs job diffs fetched ids against the capabilities file, prints a ⚠️ per gap, and the Celery task logs a WARNING and returns unclassified_models so the gap shows in the Scheduled Jobs UI; the refresh-model-context-windows skill now verifies existing entries' tier values, the part no API exposes. Docs: docs/llm/cloud-model-reference.md (new capabilities section, corrected Anthropic sampling claim), docs/reference/api.md, the scheduled-jobs report. Tests: tests/unit/test_model_capabilities_resolver.py, tests/config/test_workflow_config_effort.py, tests/unit/test_workflow_config_effort_validation.py, tests/services/test_llm_client_effort.py, tests/worker/test_update_provider_model_catalogs_task.py, additions to the Codex client, provider-options and catalog-script suites, and tests/playwright/agent_config_effort_capabilities.spec.ts.
  • Eval runs can be launched and polled over MCP (2026-09-03): the MCP server could read eval evidence and save diagnoses but could not start a run, so the edit-prompt -> run -> diagnose loop still needed the operator on the Agent Evals page. The launcher behind POST /api/evaluations/run-subagent-eval now lives in src/services/subagent_eval_launch_service.py: plan_subagent_eval resolves the active config, canonical subagent, committed URL set, server-side replicates, fixtures, DB rows and the extractor's provider and model, and writes nothing; launch_subagent_eval performs the writes, commit and staggered Celery dispatch the route always performed, with initiated_by in the snapshot extras (hash-excluded) and the audit payload. The route is a thin wrapper with an unchanged path and response shape, its dead use_active_config field is dropped and ignored on input, and the response's eval_record_id is now populated instead of null. A new MAX_EVAL_EXECUTIONS_PER_LAUNCH cap (default 100) is enforced before any write from both callers. Two MCP tools land on top. run_subagent_eval is a caller-attested write: with confirmed_by_user false it returns the plan with a billing line and writes nothing, it launches only on a fresh confirmed_by_user=true per call, checks the broker before writing, reports fixture-less and row-less URLs as skipped rather than running an extractor inside the MCP process, and audits evaluation.run_requested as service:mcp. get_subagent_eval_status is a read-only poll keyed by run label (v5139, v5139a) that returns the same progress and metrics as the HTTP status endpoint without an eval record id. The shared SUBAGENT_TO_EXTRACT_AGENT map in subagent_utils.py replaces three private CamelCase copies. docs/reference/mcp-tools.md no longer says that no MCP eval path bills tokens: diagnosis still spends nothing, launches bill the extractor's configured provider unless it is lmstudio. The live end-to-end launch also exposed that a desktop client runs the MCP server on the host with no broker URL, where Celery's config falls back to the compose-internal redis hostname; stdio_server.py now defaults REDIS_URL to redis://localhost:6379/0 for such runs, the same way it already assembles DATABASE_URL, so the broker preflight passes instead of failing closed.
  • CI checks became CI gates (2026-09-02): eleven workflows ran on every push and PR, and none was required -- main had enforce_admins set with zero required status checks, europa-dev had no protection at all, and tests.yml skips push events on main, so a direct push to main ran no tests and nothing stopped it. Branch protection on main now requires all fifteen existing check contexts (the six test tiers, Security Audit, Ruff, pinned versions, UX contract, dead code, mypy, CodeQL, Semgrep, secret scanning, MkDocs build) with strict up-to-date enforcement, admins included, force-push and deletion blocked. Four new gates land with this change. (1) Lockfile Up To Date runs uv lock --check: every other job installs with --frozen, which trusts uv.lock as-is and never compares it to pyproject.toml, so a stale lock -- easy to produce, since a bare uv run re-locks silently -- passed CI. (2) Paired Surfaces (scripts/check_paired_surfaces.py, pull requests only) fails when one side of a documented pair moves without the other: src/database/models.py without a scripts/migrate_*.py (migrations are never auto-applied, so the runtime falls back silently and the schema change ships inert), and src/prompts/ or workflow_config_schema.py without config/presets/ (the runtime reads the presets, not the prompt files). Escape labels no-migration-needed and no-preset-sync-needed cover the genuine exceptions; PR labels and the base ref are passed through env:, not interpolated into the shell. (3) Every committed Sigma rule must parse (tests/unit/test_committed_sigma_parses.py): the standalone fixture rules, and any complete literal example rule fenced in docs/contracts/*.md (today the contracts hold only output templates and fragments, which are skipped -- the scan exists so a literal example added later is checked automatically), go through the same SigmaCollection.from_yaml + condition parse the runtime applies, with a non-empty-corpus guard on the fixtures so the gate cannot go vacuous if their paths move. Motivated by a rule carrying the nonexistent |any modifier reaching the Huntable-SIGMA-Rules repo through PR #5 -- parse-level only, deliberately: a 2026-09-02 measurement of all 838 queue rules found zero that parse but fail to compile on Splunk, ES Lucene, ES|QL, or Kusto, so backend conversion was not added. (4) Coverage ratchet for src.web, src.workflows, and src.core, which had no floor at all (the existing gates cover src.services and src.utils only); floors are set at measured coverage less a small margin and are meant only to rise. The release scripts had to change with this, because they owned main's protection: release_lock.sh wrote required_status_checks: null with lock_branch: true, and release_unlock.sh deleted protection outright -- so the release merge, the one PR into main the checks exist to gate, ran with none, and the next lock would have erased the required list. Both scripts now apply a single committed payload, scripts/main_branch_protection.json, toggling only lock_branch; unlock keeps every required check and the force-push/deletion blocks in force, with a --allow-force-push flag reserved for the documented rollback path. tests/unit/test_main_branch_protection_contexts.py pins every listed context to a job name that still exists in .github/workflows and every PR-eligible job to the list, because GitHub matches required checks by context string and a renamed job silently un-gates itself -- or, on a locked-then-unlocked main, blocks every PR waiting for a check that no longer reports. The committed list carries 18 contexts (the 15 live ones plus Dependency Review, Lockfile Up To Date, and Paired Surfaces); the live protection picks them up the next time release_unlock.sh runs. europa-dev gets force-push/deletion protection without required checks, since direct pushes to the integration branch are the working model.
  • Real MITRE ATT&CK validation for Sigma attack.* tags (2026-09-02): the Huntable policy pass in sigma_validator.py only checked that tags were non-empty strings with an allowed character set, so a generated rule could cite a technique ID that does not exist, or one MITRE revoked years ago (attack.t1086 for PowerShell), and sail through. Every attack.* tag is now checked against a cached ATT&CK taxonomy: an unknown, revoked, or deprecated technique, sub-technique, group, software, mitigation, or campaign ID is a validation error with an actionable message (revoked IDs name their replacement, e.g. "replaced by T1059.001 (PowerShell) -- use 'attack.t1059.001'"; an unknown sub-technique names its existing parent), so the Phase 3 repair loop receives a concrete fix rather than silently accepting the tag. Unknown tactic names are warnings only, because ATT&CK v19 renamed Enterprise defense-evasion (now stealth / defense-impairment) while SigmaHQ and the Sigma spec still use the old name everywhere -- hard-failing that would reject correct community-style rules. The taxonomy lives in config/attack_taxonomy.json (2,535 objects across the Enterprise, Mobile, and ICS domains, ATT&CK v19.2) and is regenerated by the new scripts/build_attack_taxonomy.py, which reuses the STIX fetch from build_attack_platform_map.py and is run by the operator when MITRE ships a release (roughly twice a year), matching the existing platform-map cadence; it is deliberately not a worker job because the output is a committed config artifact. A missing or corrupt taxonomy file disables the check with one logged warning instead of failing every rule. Covered by tests/services/test_attack_taxonomy.py, tests/scripts/test_build_attack_taxonomy.py, and a new TestAttackTagTaxonomyValidation class in tests/test_sigma_validator.py that drives both SigmaValidator.validate_rule and the pySigma-backed validate_sigma_rule entry point with valid, unknown, revoked, and deprecated IDs.

Changed

  • Docs drift fixes from the 2026-09-25 doc true-up (2026-09-25): docs/llm/cloud-model-reference.md gains the claude-fable-5-1 row (added to the catalog 2026-09-02) and records the 128K max output for the Claude 5 models, verified against platform.claude.com, which clears that TODO marker. docs/development/ui-test-tiers.md now lists WF-CFG-PHANTOM-003 in the quarantine Playwright project instead of calling it empty. docs/development/testing.md drops the claim that CI reports quarantine counts, since no workflow does. mkdocs.yml adds four 2026-08 solutions/ records to not_in_nav, matching their siblings.
  • HuntQueriesExtract now has an explicit source-authority boundary (2026-09-24): LLM-captured query text remains available for analyst display, traceability, and evaluation, but every HuntQueries observable is marked content_fidelity: llm_best_effort and source_text_authoritative: false. Captured items with type: sigma are now always display-only, even if future metadata supplies an otherwise eligible logsource; non-Sigma backend queries with explicit target telemetry retain their existing generation path. The contract now enumerates every text consumer and requires the planned source_provided importer to scan articles.content directly for exact rule bytes rather than trusting LLM transcription.
  • GitHub PR submission no longer persists PATs in Git remote configuration (2026-09-24): SigmaPRService previously rewrote the customer rules repository's HTTPS origin to include x-access-token:<PAT>, leaving the credential in .git/config after every submission and across token rotation. HTTPS fetch, pull, and push operations now receive the PAT only through an invocation-scoped GIT_ASKPASS environment whose temporary script contains no secret. Before network operations, legacy credential-bearing HTTPS origins are rewritten to the same clean repository URL; SSH origins are untouched. Focused tests use temporary Git repositories to prove clean HTTPS remains clean, embedded credentials are removed, SSH remains unchanged, and neither the command arguments nor askpass script contain the PAT.
  • Backup freshness is visible in Settings (2026-09-03): the backup status API now exposes the newest backup timestamp, age, and a three-day stale threshold, and the Settings backup panel shows a distinct warning when the latest system backup is stale. The backup schedule and retention policy are unchanged.
  • Cloud model reference data is current and catalog-sourced (2026-09-03): verified the OpenAI and Anthropic model output limits against their live documentation, dated the OpenAI source capture, and added o1-pro and o3-pro to the application model catalogs so their documented context windows are sourced consistently.
  • Quickstart presets gain the four short rules they were missing from the standard (2026-09-03): the first preset pass applied only a four-item delta, so every variant still lacked the every-tactic rule, |windash, OriginalFileName, and the instruction boundary for article text (the LMStudio pair also lacked field order and the title rule). Because a preset's system text is the whole standard on that path, those gaps were live for every preset user. Each variant gained about 1,300 characters in its own structure (LMStudio 5.9k -> 7.2k, gpt-5/codex 8.0k -> 9.1k, main variants 10.0k -> 11.2k). The per-logsource field-name table was deliberately left out: it is 1,300 characters on its own, and the SigmaHQ validator layer now fails a wrong field name with the field named, which the repair prompt then fixes. The system message is not subject to the LMStudio user-prompt cap, so local runs are unaffected by the growth.
  • The in-app Sigma validator now enforces the rules repository's blocking SigmaHQ validator set (2026-09-03): validate_sigma_rule ran pySigma parsing plus the Huntable policy pass, neither of which checks field names, logsource membership, custom top-level keys, or the required id/status/level/description fields, so a rule with url|contains or ServerName under network_connection, category: proxy with product: windows, or service: sysmon without an EventID parsed cleanly, was approved in the queue, and failed the blocking CI in Huntable-SIGMA-Rules. pySigma-validators-sigmahq==0.21.0 (the version the rules repo pins; compatible with the app's pysigma 1.3.3) is now a dependency, and src/services/sigma_validation_blocking.yml mirrors the repository's .sigma/validation-blocking.yml (36 validators). After pySigma accepts a rule the set runs over it; every issue becomes an error prefixed SigmaHQ <Issue> (so the repair prompt sees it), the result carries metadata["sigmahq"], and a rule is only valid when the list is empty. Three details matter: the pipeline's own grounding keys (observables_used, platform, observable_attribution, ...; now one shared SIGMA_GROUNDING_METADATA_FIELDS used by the validator, the generation service and the workflow) never trip sigmahq_unknown_field, because the pipeline strips them before publication; validator instances are rebuilt per call, because duplicate_title and identifier_uniqueness remember every rule they have seen and a shared instance reported collisions between unrelated calls; and each validator runs in isolation, so one that crashes (pySigma 1.3.3's sigmahq logsource checks raise on list-of-map selections under service:) is reported as a HIGH issue instead of aborting or passing the rule. A missing plugin disables the whole layer loudly (metadata["sigmahq"]["available"] == False) rather than narrowing it. The Huntable policy pass also stopped rejecting list-of-map selections (selection_img: as - Image|endswith: / - OriginalFileName:), a valid Sigma form that the generation prompt's own example uses. Test fixtures that predated the standard (no id/status/level, network_connection without a product) were updated; the repair prompt's error map gained entries for the SigmaHQ issue names. The live images need a rebuild for the new package; until then the layer reports itself unavailable and validation behaves as before.
  • Sigma generation gives non-reasoning models a 4,000-token completion budget (2026-09-03): _call_provider_for_sigma capped every non-reasoning provider (LMStudio, Anthropic, local models) at max_tokens=800. A complete rule is roughly 250-400 tokens, so any article with more than two detectable behaviors came back truncated with finish_reason=length and the tail rules were lost or half-written. The budgets are now the named constants SIGMA_MAX_TOKENS_REASONING (10,000, unchanged) and SIGMA_MAX_TOKENS_STANDARD (4,000), with a regression test for the standard path. A new unit test (tests/unit/test_sigma_prompt_files.py) also pins each Sigma prompt file to the exact kwargs its caller formats it with, checks that a single format pass leaves no doubled braces, and fails if the bootstrap seed sigma_generation.txt drifts from sigma_generate_multi.txt.
  • Sigma generate, validate and enrich prompts share one rule standard (2026-09-03): the three prompt surfaces that shape what reaches Huntable-SIGMA-Rules disagreed with each other and with the rules repository's CI. Findings and evidence are in docs/development/sigma-prompt-audit-2026-09-03.md; the headline defects were (1) the generation prompt's network-abstraction section taught field names that do not exist in the SigmaHQ taxonomy (url, UserAgent, HttpMethod, ServerName, ALPN), which pySigma and the in-app validator accept and the repository's blocking sigmahq_invalid_fieldname / sigmahq_unknown_field validators reject, so such a rule could be approved in the queue and never merge; (2) underscored ATT&CK tactic tags (attack.defense_evasion) in the contract, the presets and 8 of the 18 published rules, which pySigma's ATT&CK validator flags; (3) the queue validate route's first-attempt prompt was an inline f-string with its own weaker rules, and both it and the repair prompt handed the model only the first 500 characters of the rule to fix, so the model repaired a fragment and invented the rest; (4) the enrichment prompt escaped its JSON schema for two format passes but is formatted once, so the model saw {{ ... }}, and the modal's default system prompt duplicated the file's directive block with a contradictory d5 (author) rule while the default instruction asked for "more comprehensive conditions" that directive d4 forbids. src/prompts/sigma_generate_multi.txt (and its byte-identical bootstrap seed sigma_generation.txt) now carries the full standard -- SigmaHQ field names per logsource, category-first logsource with service only alongside an EventID, hyphenated tactics with every tactic a technique belongs to, |windash and OriginalFileName resilience, ISO date, title/description/level/false-positive conventions, a grounded example in canonical field order, and an instruction boundary for article text. The new sigma_validate_single.txt (first attempt of POST /api/sigma-queue/{id}/validate), the rewritten sigma_repair_single.txt, and the restructured sigma_enrichment.txt mirror that standard instead of introducing rules of their own; the validate and enrich system prompts are now short constants in sigma_queue.py (SIGMA_QUEUE_VALIDATION_SYSTEM_PROMPT, DEFAULT_SIGMA_ENRICHMENT_SYSTEM_PROMPT, mirrored in queue.js), the retry preview cap is 8,000 characters in both the route and SigmaGenerationService._repair_rules, _sigma_rule_date() emits YYYY-MM-DD, and the 12 quickstart presets had their tactic underscores and date: format corrected inside the SigmaAgent prompt. The example rules embedded in the new prompts pass the rules repository's blocking validator set (pinned pysigma 1.5.0 / pySigma-validators-sigmahq 0.21.0). The three preset variants' network-abstraction block (about 12,000 characters of url/UserAgent/HttpMethod/ServerName/ALPN examples per preset) was then replaced with a 2,500-character block that uses only SigmaHQ proxy, dns_query, network_connection and firewall fields, and each preset's user scaffold now passes {author} and {date} so the model no longer has to guess them; that matters because with a preset applied the preset text is the whole standard and the prompt file is never read. Still not done: the in-app SigmaValidator still has no field-name or custom-key check, so queue validation remains advisory where the repository CI is blocking. The contract-guardian pass over this change also corrected the contract's description of the preset path (a quickstart preset's own system text is the entire rule standard and its user scaffold is the user message, so the prompt file is not used on that path and preset drift is live drift), replaced the stale 150-line copy of the old enrichment system prompt in workflow.html with the short constant, and switched the rule export in src/web/routes/ai.py from YYYY/MM/DD to ISO dates so exported rules match what generation now emits.

Removed

  • Legacy manual /generate-sigma endpoint, and the starvation it left behind (2026-09-03): POST /api/articles/{article_id}/generate-sigma (383 lines in src/web/routes/ai.py) had no caller anywhere in src/web/ -- no template, no JS, no dynamically built path -- and returned its rules to the caller instead of queueing them, so anything it generated billed a provider run and could never be reviewed. It was already half-removed: the 2026-08-10 dormant-subsystem cleanup dropped article_sigma_matches and named this endpoint as that table's sole writer, but left the generator standing. It was also the only writer of article_metadata["sigma_rules"], which starved its two remaining readers. POST /api/articles/{article_id}/sigma-matches -- still called from the article page -- read that key and therefore answered "No generated SIGMA rules found. Please generate rules first." for every article the workflow had actually produced rules for; it now sources this article's rows from sigma_rule_queue (rejected excluded, oldest first, unparseable YAML skipped with a warning) and assesses them for real. Verified against live data: article 481 yields its 7 queued rules with intact logsource and detection. The #sigma URL-fragment modal in article_detail.html, the other reader, is still metadata-backed and therefore still dead; it has no entry point in the UI either, and is tracked separately. Note for whoever picks that up: sigma_rules was empty (0 rows) when this landed, so novelty assessment returned 0 candidates regardless of source; the corpus is populated now (3,757 rules, fully embedded) -- see the git/runtime-os fix below for why ./setup.sh had left it empty.
  • Dead observables-mode "mark-reviewed" surface removed (2026-09-04): the observables annotation mode has been disabled since 2026-01 (its page ships with the feature flag off), leaving a half-implementation behind. POST /api/articles/{article_id}/mark-reviewed in src/web/routes/articles.py drove an agent-writable article.reviewed flag that no UI ever saved, and had zero callers; the same mode left a commented-out "mark observables reviewed" modal in article_detail.html with an orphaned markObservablesReviewed() JS function. Removed the route, the mark_article_reviewed MCP tool and its tool-list entry in src/huntable_mcp/tools/articles.py, the ACTION_ARTICLE_REVIEWED audit constant in src/services/audit_service.py, the route's classification in route_manifest.py, and the dead template block plus orphaned JS. docs/reference/mcp-tools.md renumbered its tool table from 37 to 36 entries. Regression guards in tests/api/test_removed_dead_endpoints.py and tests/unit/test_mcp_write_tools.py prove both the endpoint and the MCP tool stay gone.
  • Remaining observables-mode machinery and its training subsystem removed (2026-09-05): the previous day's cleanup removed the mode's "mark-reviewed" surface but left the mode itself and an entire dormant training pipeline standing. In article_detail.html, OBSERVABLE_MODE and its constants, the #article-content-plain surface and its applyContentVisibility()/updateModeUI() branching, the [data-annotation-mode] CSS gate, showObservableModal(), validateAndExtractObservableSelection(), submitObservableAnnotation(), and the three mobile touch-handler branches that called them are gone; setAnnotationMode() now only accepts huntability, and huntability's selection, modal, and mobile-touch paths are unchanged and re-verified in the browser. Deleted the subsystem this fed: src/services/observable_training.py, src/services/model_training.py (its only caller, shelling out to a Workshop/ training script tree that does not exist in this repo), src/web/routes/observable_training.py (GET /api/observables/training/summary, POST /api/observables/training/run), src/worker/tasks/observable_training.py and its explicit import in celery_app.py, src/web/templates/observable_training.html, and the /observables-training page route -- all four now 404. route_manifest.py drops both /api/observables/training/* entries plus a phantom /api/observables/evaluation/* entry orphaned since the 2026-08-10 observable_evaluation removal (docs/features/observable-evaluation.md updated to match: its "still-live" training section is now a removal note). The live DB had zero CMD/PROC_LINEAGE annotations and zero calls to either removed endpoint, confirming neither had a real caller. Eight tests/worker/test_*.py files carried a src.worker.tasks.observable_training sys.modules mock that predated this removal and are now trimmed to what still exists; tests/unit/test_target_blank_rel_noopener.py drops its observable_training.html exemption and lowers its known-links floor from 7 to 6.
  • Unreferenced mobile annotation manager removed (2026-09-16): src/web/static/js/annotation-manager-mobile.js (1,103 lines) still carried OBSERVABLE_MODE, PROC_LINEAGE and the observables touch handlers after the 2026-09-05 cleanup, and no template or script loaded it (the article page ships annotation-manager.js and mobile-simple.js; this is a desktop-only tool). Deleted, and the three mobile-only cases in tests/playwright/annotation_xss_regression.spec.ts went with it; the desktop escapeHtml regressions stay.

Fixed

  • Generated Sigma rules are no longer attributed to the article's publisher (2026-09-17): the SigmaAgent "AUTHOR PRESERVATION" instruction told the model to copy the author of a complete Sigma rule whenever the article itself published one. It keyed on what the article contained, not on whether the generated rule reproduced anything, so every newly written rule from a detection publisher was eligible to come back as author: The Hunters Ledger -- measured 2026-09-04 on queue #14, #15, #16 and #21, while sibling rules from the same execution said Huntable CTI Studio. The carve-out is removed from all four prompt sinks: both seed prompt files (still byte-identical), the ten quickstart presets that carried it (the two LMStudio presets never did), and the live active workflow config, saved through PUT /api/workflow/config/prompts as v66 with only that sentence changed (v65 had it; 14 older config versions still do). Because those older versions and any model can still emit a copied author, SigmaGenerationService now stamps SIGMA_RULE_AUTHOR on every generated rule after carrying the optional Sigma fields. Two generation-service tests pinned the old behaviour (a copied publisher author surviving, and no author when the model omitted one) and now assert the stamped author; new tests/unit/test_sigma_author_attribution_contract.py fails if any seed prompt or preset reintroduces the carve-out. docs/contracts/sigma-generate.md updated. Publisher-authored rules keep their attribution only through the planned source_provided import path.
  • Intra-batch Sigma dedup compares matched values, not condition syntax (2026-09-17): _detection_leaf_values tried to drop Sigma condition expressions with a four-string denylist (selection, condition, all of them, any of them) instead of skipping the condition key, so real conditions such as selection_image and selection_command were counted as detection values and inflated the union of every rule pair. Execution 25 (article 989) queued #19 and #21 -- the same wscript.exe-from-Windows-Temp detection from two generation groups -- at 0.50 overlap against a 0.8 threshold. The collector now skips the reserved condition and timeframe keys at the top of the detection block. That alone lifts #19/#21 only to 0.75 (#19 adds OriginalFileName), so the duplicate test (_batch_rules_are_duplicates) gains a containment arm: Jaccard >= 0.8, or the smaller value set fully contained in the larger with at least 3 values and Jaccard >= 0.6. Measured before choosing it: of the live queue's same-execution pairs it catches all three true duplicates that share tokens (#2/#7 and #23/#26, which the condition leak had also hidden at 0.67, plus #19/#21) and keeps the distinct #9/#10; across 1,187,088 SigmaHQ same-logsource pairs of distinct published rules it flags 94 versus 77 for Jaccard >= 0.8 alone, 135 for Jaccard >= 0.7 and 411 for pure containment. Near-duplicates spelled with different tokens (#24/#27 urlcache vs -urlcache) remain separate by design. docs/architecture/workflow-data-flow.md described the old rule (and a detection.selection-only scope the code never had) and is updated. New tests/workflows/test_batch_rule_dedup.py reproduces #19/#21 from the live YAML; its correctness cases fail against the previous code.
  • Submitted Sigma queue rules keep their YAML, stop matching themselves, and show their real title (2026-09-16): three defects surfaced on queue rows #962 and #984. (1) After a rule's PR merged and the customer repository synced, the corpus held its byte-identical cust-<yaml id> copy, so re-scoring the queued rule reported a 1.0 match against itself and hid genuine duplicates. Candidate retrieval (SigmaNoveltyService.retrieve_candidates) now skips that row on the exact-hash shortcut, the canonical-class path and the logsource-key fallback; customer_copy_rule_id() derives the id, and the queue's list, similar-rules and compare-similarity endpoints now pass the YAML id into scoring (they previously rebuilt the rule without it). Workflow generation was unaffected: a new rule has no customer copy yet. (2) rule_yaml was rewritten days after submitted_at: in view mode the queue's Validate and Similar Rules buttons re-save the modal text whenever it differs from the stored YAML, and no endpoint checked submission state. _apply_rule_yaml now guards the approve, reject and PUT /api/sigma-queue/{queue_id}/yaml paths: a YAML change on a row with pr_submitted or submitted_at returns 409 and nothing is committed (identical YAML stays a no-op); the reject path applies it outside its broad JSON-body fallback so the 409 is not swallowed. The queue's apply-validated and apply-enriched actions now show the server's reason instead of a generic error. (3) YAML edits never updated rule_metadata.title, so the queue header kept the generated title; every accepted edit now refreshes it from the new YAML when it parses with a title. Also corrected docs/features/sigma-rules.md: customer rules use the file_path prefix cust/, not customer/. New tests/unit/test_sigma_queue_submitted_rule_integrity.py and tests/integration/test_sigma_self_match_exclusion.py (real test database) fail against the previous behavior.
  • A manual trigger no longer discards a staggered eval run's pending execution (2026-09-16): the eval launcher commits every execution row up front and schedules their Celery tasks with increasing countdowns (up to EVAL_STAGGER_SECONDS + a 60 s throttle per step), so the tail of a large launch sits pending with a live task well past five minutes. The trigger eligibility check treated any such row as stuck, marked it failed and started a fresh execution; when the eval row's task then arrived, the worker's atomic claim (below) found it no longer pending and skipped it, so the eval data point was lost behind a misleading "stuck in pending" error. is_stuck_pending_execution now gives rows whose snapshot carries eval_run a window of the 5-minute base plus the latest countdown the launcher can schedule (eval_pending_stuck_after(), read from MAX_EVAL_EXECUTIONS_PER_LAUNCH at call time: about 1 h 44 min at the default cap). Inside that window the trigger is blocked with the existing "already has an active workflow execution" reason and POST /api/workflow/executions/trigger-stuck counts the row as skipped; a row that outlives it is still recovered by both paths, and the retention reaper still fails anything idle past RETENTION_STALE_EXECUTION_HOURS. Non-eval rows keep the 5-minute rule. Ingest auto-triggers are not exposed: they dispatch only for newly inserted articles, and eval rows point at pre-existing fixture articles. The trigger-stuck response wording now describes a start window rather than a fixed age. New tests in tests/unit/test_trigger_stuck_dispatch.py, tests/services/test_workflow_trigger_service.py and tests/integration/test_execution_claim.py fail against the previous predicate; the integration case reproduces the loss end to end on the test database.
  • The workflow worker claims its execution row atomically (2026-09-16): run_workflow logged the existing execution and then wrote status = "running" unconditionally, so two Celery tasks carrying one execution_id -- a trigger-stuck re-dispatch racing the original, a retry of a merely delayed row, worker prefetch across a restart -- both ran the graph and both wrote results into the same row (measured 2026-09-04 on execution 19: two fork-pool workers, one saw pending, the next saw running and proceeded; with 2 x 2 worker concurrency up to four can collide, and on a non-junk article that is double provider spend). The blind write is now a conditional UPDATE ... WHERE id = :id AND status = 'pending' with a rowcount check: exactly one caller moves the row to running; every other caller logs a skip and returns {success: false, skipped: true} without invoking the graph or touching the row. Gating on pending is safe because the two other status = "running" writes in the module (os_detection and rank_article nodes) re-affirm a row the function already owns, nothing resumes a running row, and restart-orphaned running rows are still failed by recover_orphaned_running_execution before a retrigger creates a new row. STUCK_PENDING_AFTER (5 min) stays as a dispatch heuristic for POST /api/workflow/executions/trigger-stuck and as the trigger path's definition of a lost row; the decision and its reasoning (the staggered eval tail can sit pending for minutes with a live task) are recorded next to the constant. New tests/integration/test_execution_claim.py runs two threads with separate sessions into one pending row against the test database and asserts exactly one graph invocation.
  • Two web routes that imported workflow code no longer fail at runtime (2026-09-04): the web image's venv is built with uv sync --frozen --no-default-groups (Dockerfile stage builder-web), so langgraph is not installed in cti_web -- and two routes imported src.workflows.agentic_workflow anyway, raising ModuleNotFoundError on the first request. POST /api/workflow/executions/trigger-stuck was also running the LangGraph pipeline in-process; it now re-dispatches each pending execution onto Celery via trigger_agentic_workflow.delay, so the worker runs the graph, and it audits each re-dispatch. summarize_rule_novelty moved out of agentic_workflow into src/services/sigma_novelty_service.py so the sigma-queue routes can reach it without pulling in the workflow dependency group; agentic_workflow re-exports it, so its own callers are unchanged. Behavior of the novelty classification itself is unchanged -- inconclusive still yields max_similarity=None rather than a confident 0.0.
  • Sigma sync, indexing and embeddings work again on a fresh setup (2026-09-04): the role-specific runtime split (f9a236b3) moved every service off the monolithic image, and the shared runtime-os base it introduced did not install git. sigma_sync_service shells out to git clone/git pull for the SigmaHQ corpus and sigma_pr_service drives the customer rules repo, so sigma sync failed inside the cli container on every setup after that commit -- and because startup_sigma_sync_and_index gates metadata indexing and embedding generation behind the sync's exit status, both were skipped. The call's 2>/dev/null discarded the real error, leaving only "Sigma sync failed (run manually...)", and the suggested manual command failed identically. A completed ./setup.sh therefore left data/sigma-repo empty, zero rows in sigma_rules, and no semantic search, rule similarity or MCP Sigma retrieval; Sigma PR submission from the web container was broken by the same missing binary. git is now installed in runtime-os (one shared layer for all seven role targets) and in the Dockerfile.prod runtime stage. _startup_sigma_cli replaces the three suppressions: stderr is appended to logs/sigma_index.log and its tail printed on failure, while stdout stays on the terminal so the long embedding job still shows progress. startup_ensure_runtime_directories now also creates test-results and data/diagnoses, two gitignored bind-mount sources whose absence on a fresh clone makes docker compose up fail with an OCI mount error. Contract tests pin git in both runtime images and require every relative bind-mount source to be tracked or scaffolded.
  • Codex subscription login uses the device-auth flow, and the status message says what's actually wrong (2026-09-03): docker compose exec workflow_worker codex login starts a browser-callback listener bound inside the container, which a host browser cannot reach, so the documented login command hung with no explanation. .env.example, both Codex docs pages, and the Settings panel now point at codex login --device-auth, which prints a URL and code to open in the operator's own browser; the Settings panel shows the command in a copy-able block instead of hiding it behind an "ask an administrator" line. POST /api/settings/codex/test (test_codex_subscription) stopped collapsing every failure into that same "ask an administrator" message: a logged-out app-server ({"account": null}) now reports "Codex is not logged in. Run: ..."; a login on the wrong auth mode reports "...not with a ChatGPT subscription. Run: ..."; an unreachable app-server keeps its own message -- each names the runnable command. o1-pro and o3-pro were also dropped back out of config/provider_model_catalog.json: the project's OpenAI allowlist (filter_openai_models_project_allowlist) already excludes both from every Settings/Workflow dropdown, so listing them in the raw catalog was inert.
  • Restore snapshots, CLI collection, workflow recovery, and Dev2 initialization are safer (2026-09-03): --force now skips only restore confirmation; both database restore paths create a pre-restore snapshot unless the operator explicitly passes --no-snapshot, including uploaded backups through the maintenance API. The lightweight CLI can now start collection without Playwright installed; a JavaScript-rendered source reports a clear instruction to use the Playwright-enabled ingestion worker instead of failing during import. A workflow row left running by a worker restart is marked failed on the next trigger attempt only after 30 minutes without an updated_at change, so a healthy slow run is not interrupted and a forced retrigger can proceed. Dev2 now mounts the standard init-scripts directory rather than a missing init.sql path; its deliberately limited service boundary is documented.
  • Sigma generation no longer truncates the prompt for cloud providers, and trims article content rather than instructions for local ones (2026-09-03): generate_sigma_rules applied the LMStudio context-window cap whenever the caller passed ai_model="lmstudio", and the workflow passes exactly that as a placeholder meaning "use the configured provider". So every workflow run on Codex, OpenAI or Anthropic cut the user prompt at 8,000-12,000 characters (Truncating prompt from 21850 to 12000 chars on every run in the worker log), and because the article sits at the top of the template the cut removed the tail of the standard: level calibration, false positives, metadata, output format, the example and the final check. Execution 3900 showed the effect once the persona bug stopped masking it: no --- separators, no author, date, references or false positives, technique-only tags. The cap now keys on the resolved provider (_resolve_sigma_provider, shared with the multi-rule call), and when a real local model needs trimming the article content is shortened and the prompt rebuilt ([Article content truncated ...]), so the instructions always survive. Known limit: the 13.5k template alone exceeds the local budgets (8,000-12,000 characters), so on LMStudio the content is trimmed to almost nothing; either raise the caps for modern local models or shorten the template.
  • A raw-text SigmaAgent record is the user template, not the persona (2026-09-03): parse_sigma_agent_prompt_data treated any plain-text prompt saved next to a sibling model key as a system persona, and that is exactly the shape reset-to-defaults and bootstrap write from sigma_generation.txt ({"prompt": <file>, "instructions": "", "model": "Not configured"}). The runtime therefore sent the whole 13.5k template unformatted as the system message, with literal {author} / {date} / {title} placeholders, and then the file again, formatted, as the user message. Execution 3899 showed the symptom: three of four rules came back with author: '{author}'. Now the placeholder check decides: text with at least one {identifier} is the user template (system falls back to DEFAULT_SIGMA_SYSTEM_PROMPT), text without placeholders is the persona, regardless of the model key. Every run on a raw-text record before this, including today's 3895, 3898 and 3899, was on the doubled prompt.
  • Out-of-class Sigma rules are re-homed to the group whose logsource they match instead of being dropped (2026-09-03): every generation group's call sees the whole article, so the model regularly emits a rule that belongs to a sibling group. _rule_logsource_matches_group treated that as an error and dropped the rule; on execution 3898 the network_connection call produced the article's best detection (a wscript.exe -> node.exe process_creation rule) and it was discarded while the process_creation group, whose observables did not include that command line, never saw it. _find_rehome_group now keeps the emitting group when the logsource fits, otherwise moves the rule to the first sibling group (never the full-content fallback) whose logsource hint matches, and drops it only when no group can own it. observables_used are still rebased against the emitting group (the indices are positions in that group's observable list); attribution, platform, telemetry and sigma_generation_group come from the home group, which records rehomed_from. Group summaries gain rehomed_rules and dropped_rules no longer counts re-homed rules. Regression tests mirror execution 3898.
  • Queued Sigma YAML no longer leaks generation_phase, and is written in SigmaHQ field order (2026-09-03): promote_to_queue kept a third private copy of the pipeline-key list, and that copy lacked generation_phase, so every queued rule carried it as a top-level key; the PR service commits queue YAML verbatim, so the rules repo's blocking custom_attributes / sigmahq_unknown_field check would have rejected each one (the in-app SigmaHQ layer showed it first: all 12 rules from the day's two runs failed on exactly that key). Promotion now uses the shared SIGMA_GROUNDING_METADATA_FIELDS (which gains generation_phase) through canonical_sigma_rule_dict, which also orders the remaining keys title, id, status, description, references, author, date, tags, logsource, detection, falsepositives, level instead of the service's construction order.
  • Generation prompt no longer credits rules to the article's publisher (2026-09-03): the first live run on the new prompt (execution 3898, Codex gpt-5.6-terra) set author: Microsoft Security Blog - Defender on rules from an article that contains no Sigma rule at all; the model read the author-preservation clause ("if the article contains a verbatim Sigma rule with a non-blank author") as "credit the source". The clause in sigma_generate_multi.txt, its seed, and the 10 preset variants that carry it now says the supplied author is used unless the article itself contains a complete Sigma rule in YAML with its own author, and that the article's author, publisher, blog or vendor name is never the rule author. The live SigmaAgent record was re-seeded.
  • The Workflow Config effective-prompt preview shows the SigmaAgent messages the backend really sends (2026-09-03): config.js carried a hand-copied LOCKED_SIGMA_USER_TEMPLATE (the pre-audit sigma_generate_multi.txt) and always showed it for SigmaAgent, and _effReadCurrentSystem fell back to the user text when no persona was saved, so a raw-text SigmaAgent record was rendered as both the system and the user message and a preset's own user scaffold was never shown. New GET /api/workflow/config/prompts/defaults/{agent_name} serves the on-disk user template (sigma_generate_multi.txt, stripped the way PromptLoader does) plus DEFAULT_SIGMA_SYSTEM_PROMPT; the preview fetches it once on first use, shows the DB user template when the record carries one (preset / raw-text, badge "editable · from DB") and the file otherwise ("LOCKED · code-owned"), shows the code default as the system message with a "default · code-owned" badge when no persona is saved, and documents the {author} / {date} placeholders. The old JS mirror is gone, so the preview cannot drift from the file again.
  • Evals2 item scorer reads RegistryExtract's real key/value_name fields (2026-09-03): item_candidates() for registry_artifacts read registry_hive/registry_key_path/registry_value_name, which RegistryExtract has not emitted since the 2026-05-12 schema change, so every registry item was silently dropped from matching and rows showed matched_count=0 with a passing actual_count -- a false green checkmark on the Agent Evals table despite correct extraction. Found via the first real mcp_agent eval diagnosis (execution 3866). Covered by updated cases in tests/services/test_eval_item_scorer.py and tests/api/test_subagent_eval_rescore.py.
  • Generated Sigma rules keep author, date, references, and falsepositives (2026-09-02): SigmaGenerationService.generate_sigma_rules rebuilt every validated rule into a dict with a fixed key list (title, description, id, tags, level, status, logsource, detection) and the review queue dumps its YAML from that dict, so those four standard fields were silently dropped from every rule on every source. The effect was invisible until the Hunter's Ledger detections pages were ingested: the workflow trace for execution 3865 shows the model emitted an author: line on all 12 raw rules (7 attempting the publisher's name from the embedded rules, 5 with the template default Huntable CTI Studio), yet all 4 queued rules carried none, so the SigmaGenerate AUTHOR PRESERVATION directive could never reach the queue. The rebuilt dict now carries author, date, modified, references, falsepositives, and fields when the model emitted them (SIGMA_OPTIONAL_RULE_FIELDS), coerced to JSON-safe values: yaml.safe_load turns an ISO date: 2026-08-03 into a Python date, which the JSONB sigma_rules column cannot store (execution 3889 failed on exactly that during verification), so dates become ISO strings and other scalars become strings. Queue promotion already keeps every non-grounding key, so these appear in rule_yaml with no further change.
  • Eval diagnosis UI now shows run signals, the help button says what it does, and the skill copies match (2026-09-02): the v2 diagnosis schema requires run_signals and the huntable-eval-diagnosis skill checks them first, but renderDiagnosisPanel in agent_evals.html never rendered them, so truncation, context pressure, contract compliance, finish reason, and token utilization were invisible in the Agent Evals execution modal. They now render as a Run Signals row ahead of root causes with the bad values highlighted (truncation detected, finish_reason of length/error, high context pressure, violated/partial compliance, utilization at or above 80%), and the "What it returns" table in docs/features/agent-evals.md lists the field. The execution-modal button was labeled Diagnose via MCP but only opened a help modal; it is now How to Diagnose, and the help copy is rewritten for the operator (the prompt first, with a Copy button, then what the agent does and when the one approved save happens) with the file-path pointers folded into a maintainers-only disclosure. The saved-diagnosis views (GET .../evals/{id}/diagnosis, .../diagnoses, and the MCP list_eval_diagnoses / export_diagnosed_eval_bundles loaders) now share one load_saved_diagnoses helper in eval_diagnosis_service.py that orders runs by the persisted created_at stamp instead of file mtime, so a restore or copy of data/diagnoses/ no longer reorders run history; the MCP loaders report _source_file as a bare filename instead of an absolute host path; and get_eval_diagnosis_context reports execution_id / article_id as integers to match the save and list tools. .agents/skills/huntable-eval-diagnosis/SKILL.md (the canonical copy) had drifted to a shorter version than its .claude/skills/ compatibility copy and is synced to the fuller text. Two stale doc lines fixed: docs/guides/authentication.md still listed "LLM-powered bundle diagnosis" as an audited HTTP route, and docs/brand-style-guide.md still described source healing as automatic repair. Covered by tests/unit/test_eval_diagnosis_run_signals_render.py (template structure), a created_at-over-mtime ordering test in tests/api/test_eval_diagnosis_api.py, a bare-filename test in tests/unit/test_mcp_eval_tools.py, and an id-coercion test in tests/services/test_eval_diagnosis_service.py. Also removed the MLOps Control Center's "System Online" pulse indicator and its four-cell stats bar (mlops.html) -- all four cells were hardcoded (3 / 3, Ready, Monitoring, --), never wired to a real status source, and unrelated to this fix beyond sharing the file.
  • The Hunter's Ledger source now ingests its /hunting-detections/ pages, and discovery-path articles keep their author (2026-09-02): the source only ingested /reports/ posts from the Atom feed, but the publisher's validated Sigma rules live on 56 /hunting-detections/<slug>-detections/ pages the feed never lists (a sample page carries 18 rules, every one with author: The Hunters Ledger; the matching report page carries none), so the SigmaGenerate AUTHOR PRESERVATION directive never fired on this source. Widening post_url_regex alone does nothing because the fetcher returns after a successful RSS tier unless archive_pages/max_archive_pages are set, and with no discovery.strategies URL discovery falls back to the homepage, which links to none of them (the site has no sitemap). The hunters_ledger stanza in config/sources.yaml now widens the regex, enables the archive supplement, and adds a single-page listing strategy over the detections index with a[href^="/hunting-detections/"][href!="/hunting-detections/"] (verified against the live index: 56 matches, no pagination). Index links omit the trailing slash while the page canonical includes it; the stored canonical_url comes from JSON-LD with the slash, and both the fetcher's RSS/archive merge and the processor's URL dedup strip trailing slashes, so no slash/no-slash duplicates are created. Two supporting fixes: ModernScraper._extract_article dropped authors, tags, and summary -- it extracted them from JSON-LD and selectors and then built the ArticleCreate without them, so every discovery-path article on every source was stored with no author; they are now carried through. And because the detections pages carry no author signal anywhere (JSON-LD BlogPosting without author, no meta author), the stanza adds meta[property='og:site_name']::attr(content) as a trailing author_selectors fallback so those articles match the feed-path attribution instead of being blank; report pages still get the person-level author from their TechArticle block. The live sources.id=42 row was updated with a targeted JSON merge rather than sync-sources, which rewrites the whole config and would have clobbered the operator's custom robots.user_agent. Known limit, unchanged by this work: the archive supplement re-fetches up to 50 discovered pages on every check (the _use_conditional flag is unused), which is the same behavior every archive_pages source already has.
  • ContentCleaner keeps indentation inside <pre> blocks, and the processor no longer re-cleans already-clean text (2026-09-02): html_to_text folded block boundaries correctly, but normalize_whitespace_keep_newlines then left-trimmed every line, so an indented Sigma rule or code sample published in <pre> came out flush-left and stopped being parseable YAML (on The Hunter's Ledger detections pages all 18 author: The Hunters Ledger lines survived while selection: landed at column 0 under detection:). Downstream, the SigmaGenerate AUTHOR PRESERVATION directive assumes a verbatim rule in the content, so the model had to re-serialize instead of reproduce. html_to_text now stashes each <pre> block verbatim before normalization and restores it afterwards; block-level descendants (<br>, per-line <div>) still break lines, tabs and highlighter <span> wrappers are preserved, and prose outside <pre> is byte-identical to before (pinned by a captured-output test). Fixing the cleaner alone would have been inert: ContentProcessor ran clean_html a second time on the text every producer had already cleaned, and with no <pre> left to protect, that pass stripped the indentation again before the row was stored. The processor now skips that redundant pass when the content carries no HTML markup (ContentCleaner.looks_like_html), and still cleans content that really is HTML. Forward-only: existing rows keep their old content, and eval-article rows and config/eval_articles_data fixtures are untouched. Verified against the live Hunter's Ledger page: 18 embedded rules now come out of clean_html as parseable Sigma YAML with nested detection blocks. Covered by 8 new cleaner tests in tests/test_content_cleaner.py and 2 processor tests in tests/test_content_processor.py.
  • JSON-LD article picker accepts TechArticle and prefers the block that names an author (2026-09-02): StructuredDataExtractor.find_article_jsonld (src/core/modern_scraper.py) returned the first JSON-LD block typed Article, BlogPosting, or NewsArticle and ignored every other block. The Hunter's Ledger report pages emit a bare BlogPosting followed by a TechArticle carrying author.name, so the person-level author was dropped on the scrape path (feed-path articles were unaffected because rss_parser uses the feed author). The picker now also accepts TechArticle, ScholarlyArticle, and Report, and when several article-typed blocks exist it prefers the one with a usable author name, then the one with articleBody, then document order -- so single-block pages behave exactly as before. An author field with no usable name (empty list, empty object, organization without a name) does not count. extract_from_jsonld is unchanged. Covered by 11 new cases in tests/test_modern_scraper.py, including the two-block Hunter's Ledger shape and the single-block regression guard.
  • WF-CFG-PHANTOM-003 un-quarantined: autosave dropped a stored agent_models key whenever its own last-known value was blank (2026-09-02): performAutoSave() in config.js only fell back to the stored value for a key that read back as '' (a provider/model round-trip miss, or a catalog fetch still in flight) when that stored value was itself a non-empty string -- so a key whose currently-known value happened to be '' (a genuinely unconfigured agent, or one whose real value hadn't reached this page's currentConfig snapshot yet) was dropped from the PUT payload entirely rather than sent as ''. The backend's agent_models merge masked this in practice (an omitted key keeps whatever the DB already has), but it meant the save payload could not stand on its own -- exactly what [WF-CFG-PHANTOM-003] asserts. Fixed by keying the fallback on whether currentConfig.agent_models has the key at all, not on the stored value being non-empty. Root-caused via a from-scratch Postgres+Redis+uvicorn environment mirroring tests.yml's ui job: a freshly-seeded config has every *_model/*_provider key stored as '', which reproduced the exact "stored key missing from the sent payload" failure deterministically (no concurrency needed) and is also the condition an in-flight catalog/provider fetch reduces a real value to at collection time. Verified with the restored spec run 2-up alongside every other non-excluded workflow spec (expanded_prompt_editor_save.spec.ts included) and repeated within-file at --workers=2. Un-quarantined: quarantined_workflow_config_phantom_003.spec.ts merged back into workflow_config_no_phantom_save.spec.ts, and playwright.config.ts's quarantineProject.testMatch restored to empty.

Security

  • Dependency pins bumped for pip-audit findings (2026-09-25): pip-audit -r requirements.txt --no-deps --disable-pip (a full resolve fails on a langchain-core conflict) flagged three pins. GitPython 3.1.58 -> 3.1.60 (PYSEC-2026-3785/3786/3787/3788/3837 fixed in 3.1.59, PYSEC-2026-3982/3984 fixed in 3.1.60; still zero import sites). mkdocs-material 9.7.6 -> 9.7.7 (PYSEC-2026-3864), in both requirements.txt and pyproject.toml. accelerate 1.13.0 -> 1.15.0 (PYSEC-2026-3804 / CVE-2026-69112 / GHSA-4j2p-28q2-5m79, path traversal via sharded-checkpoint weight_map entries in load_checkpoint_in_model / load_checkpoint_and_dispatch): the advisory lists no fixed version, but its text scopes it to "through 1.14.0" and PyPI reports no advisories against 1.15.0. A patched release existed, so the pin moved and no accepted-risk ignore was added. uv.lock regenerated with uv lock; only those three packages changed.

[7.8.1 "Europa"] - 2026-09-02

Removed

  • Sigma eval subsystem decommissioned (2026-08-19): the end-to-end Sigma rule eval scored generated rules against hand-authored ground truth in config/eval_articles_data/sigma/ground_truth.json, reporting detection-atom and logsource precision/recall. Operator review concluded the headline metric is not defensible. sigma_eval_scorer.score_sigma computes precision as fp = actual_atoms - expected_atoms over exact normalized-atom set equality, so any correct detection the ground-truth author did not happen to write down scores identically to a hallucination, and a generator choosing a tighter discriminator than the reference is penalized twice for the improvement. The benchmark therefore measures resemblance to one analyst's rule set rather than detection quality, capped at -- and actively penalizing exceeding -- that analyst's recall of their own domain. The 2026-08-16 arm-blind ground-truth protocol addressed contamination between the multi-extractor and one-shot arms, but a neutral referee measuring the wrong quantity is still measuring the wrong quantity, so expanding the corpus from 4 to the prescribed 15-20 articles would only have bought statistical power on that metric. Every remaining signal was already covered elsewhere: the logsource axis is largely downstream of extractor correctness (measured by Eval1/Eval2 against ground truth that does have a fact of the matter), and rule validity is independently enforced by SigmaGenerationService._validate_all_rules plus its Phase 3 repair loop. Removed sigma_eval_scorer.py, sigma_eval_service.py, sigma_evals.html, the /mlops/sigma-evals page, the four /api/evaluations/sigma-eval-* endpoints, the SigmaEvaluationTable model, config/eval_articles_data/sigma/, and 8 test/doc files. The sigma_evaluations table is dropped via scripts/migrate_drop_sigma_evaluations.py rather than retained, because its unqualified FK to agentic_workflow_executions was guarded by data_retention_service._EXECUTION_REFERENCE_TABLES; keeping the rows while removing the model would have left the shared retention job to hit a FK violation on the first aged execution it tried to purge. The Eval1/Eval2 extractor evals are untouched -- the workflow's sigma_eval override collapses to an identity ((not False) and X == X) for extractor runs and production, and tests/api/test_removed_dead_endpoints.py now pins both the removal and the survival of every subagent-eval route. Deploy note: run scripts/migrate_drop_sigma_evaluations.py as part of shipping this change, not after it. The retention guard is removed in the same commit, so any environment running this code with the table still present will raise a FK violation on the first aged execution the retention job tries to purge -- and that job is shared with article pruning and the extractor evals, so the failure would take retention down for Eval1/Eval2 data too. Applied to the local dev database on 2026-08-19 (9 rows).

Added

  • SIGMA enrichment supports the Codex subscription and bounded follow-up chat (2026-08-24): the queue enrichment modal now exposes the same opt-in, subscription-backed Codex provider and model catalog as agent configuration, without requiring an OpenAI API key. Each successful enrichment also persists its provider, model, and rule-scoped transcript. Start Chat reopens that context after a reload and replays it through the original provider (OpenAI, Anthropic, LM Studio, or Codex), so follow-up turns stay limited to revising the current SIGMA rule or discussing earlier turns. System instructions remain server-side, and the continuation guardrail refuses unrelated research, tool use, or external actions. Covered by focused API and Playwright tests.
  • Retention policy for workflow config history (2026-08-22): agentic_workflow_config had no pruning at all and grew with ordinary UI use. It is now a data_retention_service policy running from the existing scheduled maintenance job rather than inline on write, with a 60-day window and a floor of 2,000 revisions -- whichever keeps more wins. The floor is what does the work: this table is the undo history for the workflow config (the CmdlineExtract prompt was recovered from config id 7224), so the depth is set by how long a regression can plausibly go unnoticed, and age alone would have kept everything from a busy week and nothing from a quiet month. The busiest observed day wrote 632 rows, so a smaller floor could be exhausted by one afternoon of editing. Never deleted at any age: the active row, and any row cited by subagent_evaluations or -- while it still exists -- sigma_evaluations, which carry that eval's provenance. Windows are overridable via RETENTION_DAYS_WORKFLOW_CONFIG and RETENTION_MIN_WORKFLOW_CONFIG_REVISIONS in app_settings.
  • Agent prompt validation runs on load and badges the failing agent (2026-08-21): _collectPromptIssues (static/js/workflow/prompt-editor.js) already detected prompt-contract drift accurately -- verified across all seven extractors on 2026-08-19, where four passed clean, RegistryExtract and ServicesExtract reported one warning each, and a degraded CmdlineExtract seed reported seven naming exactly the contract sections its test-seed replacement had dropped. But it only ever ran when a human clicked Validate inside a panel three levels deep (Step 3 -> sub-agent -> Prompt panel -> Validate), so a prompt that had been broken for two days sat behind a button that would have flagged it instantly. The checks are reused unchanged; only the surfacing is new. refreshPromptWarningBadges() validates all nine prompt-bearing agents with no click and writes a pill onto the collapsed .sa-header and the owning step's .section-header, so an operator sees it without expanding anything. It is wired at the tail of renderAgentPrompts() -- the single funnel for initial load and every save, cancel and preset load -- so a badge cannot outlive the prompt it describes. Warnings are amber and never block saving or running; a hard-fail (missing system/role, instructions or json_example) is red. The Validate button and the badges now read through one shared _promptValueForValidation(), so the two can never disagree about what was validated. One deliberate exemption: an agent with no stored prompt is running the shipped default (default_agent_prompts.AGENT_PROMPT_FILES; rank_article() falls back to src/prompts/rank_article.txt at the call site, routes/ai.py), which is the baseline state and not drift -- RankAgent ships exactly this way today, so badging it would have put a permanent false alarm on every page load and taught the operator to ignore the badge, the precise failure this feature exists to prevent. Extraction sub-agents get no such exemption: an empty system/role there really does raise PromptConfigValidationError (services/llm_prompting.py). No new Tailwind classes were introduced, so the committed build artifact is unchanged. Covered by 5 tests in tests/ui/test_workflow_comprehensive_ui.py -- badges settled at load with no interaction, badge/button agreement, refresh through the renderAgentPrompts funnel, a degrade-and-restore case asserting amber for a stripped role body and red for a removed required key, and the exemption case pinning both halves (an unset non-extractor is silent, an emptied extractor is not). All five were confirmed to fail against the unwired code rather than passing vacuously.
  • Security response headers, including a two-tier Content Security Policy (2026-08-21): the app sent no security headers at all -- no CSP, no framing control, no MIME-sniffing control. Escaping is the primary defence for the DOM-XSS sinks in these templates, but it has been missed more than once, and nothing was behind it. A new SecurityHeadersMiddleware (src/web/security/headers.py) is registered outermost, so headers land on every response including authorization denials, error pages, and static assets -- the responses an unauthenticated attacker can reach. Enforced CSP carries only directives the app already complies with, so it cannot break a page: object-src 'none', base-uri 'self' (an injected <base> otherwise repoints every relative URL on the page), frame-ancestors 'none', and form-action 'self' (stops an injected form posting operator data off-origin). Alongside it: X-Frame-Options: DENY for older browsers, X-Content-Type-Options: nosniff, and Referrer-Policy: strict-origin-when-cross-origin. Report-Only carries the strict policy that cannot be enforced yet. The blocker is script-src: 26 inline <script> blocks (~22,000 lines across 19 templates) and 341 inline event-handler attributes. Nonces would authorise the blocks, but nothing authorises a handler attribute -- permitting onclick= necessarily permits an injected onerror=, which is exactly the payload the recent escaping fixes addressed. Real script-src coverage therefore requires migrating those handlers to addEventListener; Report-Only measures that surface rather than guessing at it. style-src deliberately retains 'unsafe-inline', because reporting every style= attribute would bury the script violations that matter. HSTS is intentionally not sent from the app: pinning max-age against a plain-HTTP dev host is a footgun with no benefit, and it belongs with the TLS-terminating deployment config. Covered by tests/unit/test_security_headers.py (11 tests), which pin that the enforced policy never grows a script-src/default-src/style-src directive without the Phase 2 migration, that Report-Only keeps constraining scripts so the measurement stays meaningful, and that the middleware is registered and outermost -- without that last pair, deleting the add_middleware call leaves every other test green while the app serves no headers at all.
  • /articles: a visible indicator when the filters panel is collapsed but a filter is active (2026-08-21): the only on-screen sign that a search or filter was active -- the Active: "<term>" Clear all chip -- lived inside the collapsible filters panel, so collapsing it (a preference sessionStorage remembers across visits) left a filtered list of, say, 279 of 7,741 articles with no visible reason why. Added #filters-active-badge, shown only while the panel is collapsed and a filter is active, reading "N filter(s) active - Clear"; it tracks the real open/closed DOM state from both the initial-load path and the header-click toggle, and the existing sessionStorage-remembers-collapse behavior is untouched. Covered by 3 new tests in tests/ui/test_articles_advanced_ui.py: no badge when unfiltered, badge appears on collapse-with-filter (the exact regression case), badge hides again on re-expand.
  • SIGMA Queue summary gets a fifth card for needs_review, its largest bucket (2026-08-21): #queueStats rendered exactly four cards -- Pending Review, Approved, Rejected, Submitted -- summing to 394 against a queue of 808 rules; needs_review (414 rules, 51%) was read by neither updateQueueStats() nor any stat card, so the largest single bucket had no one-click filter shortcut despite the status dropdown listing it. Added a Needs Review card wired to the same setQueueStatusFilter() pattern as its siblings, and widened #queueStats's grid to 5 columns without affecting the sibling 4-card Executions stats grid, which shares the same CSS class. Covered by a new test in tests/playwright/sigma_queue_lifecycle.spec.ts asserting the card renders, its count is numeric, and clicking it filters the queue.

Fixed

  • LLMGenerationService had no codex branch and would raise on a codex benchmark run (2026-08-31): _get_model_name() fell through to "template" for codex and _call_llm()'s dispatch raised ValueError(f"Unknown provider: {provider}") -- a second, duplicated provider-dispatch chain that drifted the moment codex was added to LLMService/llm_client.py (the chain the running app actually uses) without a matching update here. Reachability check (rg -n "llm_generation_service|LLMGenerationService" src/ tests/ scripts/) found exactly one consumer: scripts/benchmark_llm_providers.py, a dev-only tool, not the running app. Decision: delete rather than extend, removing the duplicate chain instead of adding a second place a future provider must be wired into. Deleted src/services/llm_generation_service.py and repointed the benchmark script at LLMService.request_chat(), which already dispatches openai/anthropic/lmstudio/codex correctly -- the script now also benchmarks codex, and force-enables LM Studio's workflow-scoped enablement toggle for itself since it benchmarks LM Studio directly, independent of that agent-config gate. Verified: rg returns no remaining hits, run_tests.py smoke (87 passed) and the full llm_service/llm_client test suite (100 passed) are green, ruff check is clean. Covered by 7 new tests in tests/scripts/test_benchmark_llm_providers.py pinning the LM Studio gate override, the codex availability gate (mirroring openai/anthropic, never raising), and request_chat call/response wiring; 6 of the 7 were confirmed to fail against the parent revision (still on LLMGenerationService) when run in an isolated worktree.
  • Sigma rule validation silently ran the wrong provider, reporting a provider/model pair that existed nowhere in config (2026-08-31): with SigmaAgent_provider set to codex, clicking Validate Rule reported Provider: Lmstudio | Model: gpt-5.6-sol and failed three times with "model is not loaded". Neither half was wrong on its own -- the model came from the config, the provider came from a fallback -- but they were resolved independently, so an unrecognised provider name was swapped for _first_enabled_provider() while the configured model name was kept. codex was simply absent from the validate path's allowlist, though it is a first-class provider in generation and enrichment and _call_traced_sigma_provider already had a working CodexAppServerClient branch. The result turned a config problem into a phantom failure attributed to a provider the operator never selected. Three gates needed it: the resolver's allowlist, the retry-loop dispatch, and an API-key guard keyed on provider != "lmstudio" that demanded a key for keyless Codex -- fixing only the first two moves the failure to "No Codex API key is configured". An unrecognised provider now raises and names itself instead of substituting, _first_enabled_provider can return codex (keyless, so invisible to the API-key scan), and both keyless providers read their enablement flag through the same AppSettings-then-env path. Verified end to end against the live app: the same rule now reports Provider: Codex | Model: gpt-5.6-sol and validates on attempt 1. Covered by 28 tests in tests/api/test_sigma_queue_validate_api.py, all 10 new ones confirmed failing against the parent revision.
  • The workflow config accepted provider/model pairs that can only fail at call time (2026-08-31): nothing validated that <Agent> belongs to <Agent>_provider -- not the PUT handler, not workflow_config_schema.py, not the DB -- which is how an agent could come to hold one provider's name and another's model (static/js/workflow/config.js documents an ExtractAgent stored as lmstudio with an OpenAI model). Such a pair is invisible until a run dies with a confusing "model not found" from the wrong provider. PUT /api/workflow/config now rejects a pair the catalog positively attributes elsewhere. Three properties make it safe rather than merely strict: openai and codex share one model namespace (Codex serves the OpenAI family, and the catalog has no codex key -- without this the live config and 3 of the 12 shipped quickstart presets would all be rejected); models absent from the catalog are never rejected, so LMStudio locals and new releases pass; and only pairs the request changes are checked, because autosave sends the entire blob and swallows its own 400 to the console, so whole-blob validation would silently discard unrelated edits and make the repair itself unsaveable. Ownership reads the catalog file as written rather than through load_catalog()'s dropdown display filters. Covered by 25 tests in tests/unit/test_workflow_config_write_safety.py and 10 in tests/services/test_provider_model_catalog.py, including a guard that all 12 shipped presets still import and per-agent coverage for the <Agent>_model key shape the 7 sub-extractors use.
  • POST /api/validate-model let a nonexistent codex model ID through as valid (2026-08-31): api_validate_model() ran is_valid_openai_chat_model() only for provider == "openai"; a codex request fell through with no model check at all, so the endpoint reported a made-up model like gpt-5.6-nonexistent as valid even though codex serves the same OpenAI model catalog (gpt-5.x series) that the openai branch already validates against. provider in ("openai", "codex") now shares that check, and the rejection message names the provider correctly ("Codex" vs "OpenAI") instead of always saying OpenAI. Covered by 11 new tests in tests/api/test_model_validation.py, including a codex/openai parity case pinning that both providers reject the same invalid model ID.
  • HTML page routes with a malformed request param now render the styled error page instead of a raw FastAPI JSON blob (2026-08-28): a non-integer path or query param (/articles/abc, /articles?per_page=abc) hit FastAPI's default RequestValidationError handling, which returns {"detail": [...]} regardless of whether the request came from a browser tab or the API -- an operator navigating to a bad URL saw raw JSON instead of the app's chrome. A new validation_error_handler follows the same request-path split the existing not_found_handler uses: /api/* requests keep FastAPI's structured JSON contract unchanged, everything else renders error.html with a 422. Covered by 3 new tests in tests/api/test_endpoints.py, including one pinning that the API contract (detail[0]["type"] == "int_parsing") is untouched.
  • /articles: a zero-result search no longer shows "Articles 1-0 of 0" (2026-08-28): the pagination header always rendered Articles {start}-{end} of {total}, and start_idx is computed as >= 1 even when total_articles is 0, so a search matching nothing read as the nonsensical "Articles 1-0 of 0" instead of communicating there were no results. The header now shows "No articles" when the total is zero. Covered by a new test in tests/api/test_endpoints.py.
  • /diags: Queue Status no longer hides collection_immediate or attributes every queue to the wrong worker (2026-08-28): GET /api/jobs/queues hardcoded an 8-queue literal that had drifted from celeryconfig.task_queues (9 entries) -- missing collection_immediate, the queue collect_from_source (the on-demand source-collection path) actually routes to, so a real backlog on the queue most likely to back up was invisible and the header chip undercounted at "8 Queues". The queue dict is now a comprehension over celeryconfig.task_queues directly, so a queue added or removed there can't silently drift out of what this endpoint reports; the "default" queue's Redis-key special case (llen("celery"), not llen("default")) is preserved but keyed off celeryconfig.task_default_queue instead of a string literal. priority_checks -- present in task_queues with zero task_routes entries and consumed by neither worker's -Q flags in docker-compose.yml -- was explicitly removed as dead configuration, with a comment recording why, rather than silently dropped from just the display. Separately, updateQueueStatus() attributed each queue's consumer by testing the Celery node name (celery@<hostname>) for the substring "workflow", which a container hostname never contains, so workflows always showed "General" while the Worker Status card 400px away correctly showed "Workflow Worker" for the same worker -- fixed by testing the queue name instead (name === 'workflows'), the same rule Worker Status already used, so the two cards can no longer disagree. Verified end-to-end, not mocked: stopped cti_worker, fired 3 real collect_from_source tasks, confirmed collection_immediate showed a live "3 Pending" on the page while cti_workflow_worker stayed up and workflows correctly attributed to "Workflow" in that exact state; restarted the worker and confirmed the backlog drained to 0. Covered by 2 new tests in tests/api/test_api_route_coverage_gaps.py, including one pinning set(queues.keys()) == set(celeryconfig.task_queues.keys()) as an explicit CI drift guard. Full run_tests.py api: 584 passed.
  • /diags: Job History attributes each row to its real task and queue instead of a UUID-substring guess (2026-08-28): the card promised "inferred worker/queue assignment", but /api/jobs/history never returned a task name, so getTaskQueueInfo() matched substrings (workflow, check_source, cleanup, ...) against the raw task UUID -- which can never contain them -- and every row fell to the same General · default, presented as derived fact. Root fix: celeryconfig.py now sets result_extended = True, so Celery itself stores the real task name in each celery-task-meta-* Redis entry going forward (no custom instrumentation). api_jobs_history reads that name and resolves queue by looking it up in celeryconfig.task_routes (falling back to task_default_queue for a real-but-unrouted name, "unknown" only when the name itself isn't recoverable -- e.g. a pre-migration entry within the 30-minute result_expires window), and derives worker_type from the queue via the same worker/queue mapping as the Queue Status fix above. The template deleted its substring ladder entirely and renders the server-provided fields; the card subtitle now reads "worker/queue from Celery's routing config" since it no longer is a guess. Verified with a genuinely fresh task (not a mock): triggered a real collect_from_source run, and both the API and the live page correctly showed src.worker.celery_app.collect_from_source / collection_immediate / General. Covered by 4 new tests in tests/api/test_api_route_coverage_gaps.py (routed task, workflow-queue task, unrecoverable name, unrouted-but-known name). Full run_tests.py api: 582 passed (before the Queue Status fix landed in the same session; 584 after both).
  • /diags: the false "TESSERACT missing" alarm is gone, and a genuinely degraded service now actually reddens the card (2026-08-28): check_tesseract_available() was probed in the web process, which has never had pytesseract installed -- it lives in the optional ingest extras group used only by cti_worker/CLI -- so the External Services card permanently showed a raw ModuleNotFoundError as a "missing" failure for a dependency this process was never meant to have; an operator learns to ignore a standing false alarm, which is how real ones get missed. api_services_health now catches ModuleNotFoundError on import pytesseract directly and reports status: "not_applicable" with a plain-English message, falling through to the real check unchanged if pytesseract is ever importable there. Two more defects in the same card: the top-level status was hardcoded "healthy" regardless of any component's real state (extracted into _compute_services_rollup_status(), now "unhealthy" when any service reports unhealthy/error/missing), and getHealthIcon/getHealthColor didn't handle "ok" (tesseract's own success status) or "missing", so even a working check silently rendered ? instead of a checkmark. Fixing the roll-up exposed a masking bug: updateServicesHealth()'s per-service breakdown was gated on data.status === 'healthy', harmless while that was always true, but once real, a genuine failure hid every service's individual status behind a bare "Unknown error" -- changed the gate to check for data.services being present instead, which still correctly falls through only on the route's actual catastrophic-failure path. Verified live: the real (unmocked) response now shows a clean not_applicable message and a green card; a mocked genuine tesseract: missing failure correctly turns the card red while still listing every service. Covered by 6 new unit tests (tests/unit/test_services_health_rollup.py), an updated test_health_reports_tesseract, and 2 new Playwright tests (DIAGS-TESSERACT-001/002).
  • /diags: the loading overlay is keyboard-trapped and focus-managed; auto-refresh honestly reflects what it covers (2026-08-28): building on the batch-overlay/button fix in a72c3a31, two gaps remained. The overlay had no focus management at all -- opening "Run All Health Checks" never moved focus into the dialog (aria-modal="true" against a background that stays fully tabbable) and never restored it on close; fixed with tabindex="-1" plus a keydown trap pinning focus on the overlay itself (it has no focusable descendants) for the run's duration, aria-busy="true" on the trigger, and focus restored to the trigger on completion. Separately, the five health cards (System Health, Database, External Services, Deduplication, Celery) are only ever revalidated by a manual click, but shared the toolbar's 5-second "Last updated" clock with the auto-refreshing job data, implying a freshness they didn't have; each card now carries its own "checked at" timestamp (turning amber after 5 minutes stale, re-evaluated live on each job-data tick with no new requests), and the toolbar clock is relabeled "Job data last updated" with a tooltip naming its actual scope. isAutoRefresh also derived from a hardcoded true literal rather than the checkbox when no stored preference existed yet; it now reads the checkbox's own state in that case. Verified live via JS: focus enters/traps/restores correctly around a real batch run; forcing an old timestamp flips the stale class; unchecking auto-refresh survives a real reload. Covered by 2 new Playwright tests (DIAGS-FOCUS-001, DIAGS-FRESH-001).
  • /workflow#executions: the Step filter can now select every step that actually occurs, including the failure step (2026-08-28): the dropdown offered 6 values while current_step contains 7 distinct non-empty values plus a 106-row empty-string bucket; os_detection (pipeline step 0, 58 rows) and context_length_check (a pre-flight gate failure, 2 rows -- the exact FAILED execution most worth filtering for) were both unreachable. Added both as real options (verified as genuine current_step values set in agentic_workflow.py, not typos). rank_article and similarity_search show 0 rows in the current dataset but are live workflow.add_node(...) steps, not dead code, so they're kept with a comment explaining why rather than silently dropped. The empty-string/NULL bucket gets an explicit, deliberate treatment rather than being ignored: an (unset) option using a __unset__ sentinel (the empty string was already taken by "All Steps"), translated server-side by _step_filter_condition() into current_step IS NULL OR current_step = '', applied at both the count and list query sites. Verified against the live dataset: os_detection -> 20 matches, context_length_check -> 2, __unset__ -> 99 including a current_step: null row. Covered by 2 new integration tests seeding real NULL/empty/named rows (tests/api/test_workflow_executions_unset_step_filter.py).
  • Keyword highlighting no longer fragments IOCs and hyphenated words mid-token (2026-08-28): a keyword match ending mid-word (e.g. "detection" matching inside "detections", or the [.] defang marker matching inside 45.153.34[.]132) was wrapped in a <span> covering only the matched substring, visually splitting the surrounding token -- 58 of 180 highlights on one test article sat mid-token, and the worst cases were exactly the strings a CTI reading pane exists to make legible. Fixed entirely in render_highlighted_content (the rendering layer, as diagnosed -- the underlying match data driving the Keyword Matches panel is untouched): each match's displayed span is widened to its natural token edge using a curated continuation-character set (alnum plus .-/:[]_@, covering dotted/defanged IPs, hyphenated compounds, paths, ports) before being wrapped, rather than an unconstrained "expand to nearest whitespace" (which was tried first and caught by the existing XSS-escaping test: it would swallow long punctuation-heavy runs, e.g. literal <span class="..."> appearing as escaped plain text, into the highlight). When two distinct matches land inside the same token (e.g. a filename split across two keyword hits) their natural bounds overlap and are rendered as one combined span rather than re-split at an arbitrary internal point. Also removed the px-1 padding and border from the highlight <span> so it reads via background and font-weight alone, per the second half of the fix. Verified on the article named in the original report: 175 highlighted spans (was 180 DOM spans before the merge-adjacent fix -- fewer spans over the same underlying matches, not fewer matches), 0 boundary violations checked programmatically. Covered by 5 new tests in tests/test_keyword_resolution.py, 3 of which were confirmed to fail against the pre-fix code (extracted from git history into an isolated module, no working-tree changes) before passing against the fix.
  • /settings: Scheduled Jobs shows a plain-English cron preview, marks the exact field a rejected cron names, and promotes the editable control over its own internals (2026-08-28): raw cron (0 2 * * *) sat one section below friendly time pickers for the conceptually identical Backup Configuration, with no preview, no next-run time, and no client-side validation; a rejected cron collapsed to a bare 422 {"detail": "Validation error"} with no way to tell which of four fields was wrong, even though validate_cron_expression already built a precise message that the route discarded. ScheduledJobsConfigError now optionally carries a job_id, attached wherever normalize_scheduled_job_config fails a specific job's cron or enabled value; the route returns {"detail": {"message": ..., "job_id": ...}} instead of the flat string, and the UI puts a red border, aria-invalid, and an inline error box on the exact offending field, with the toast naming the job by label. Each Cron Schedule field now shows a live plain-English preview and next-run time (a small client-side cron parser + a bounded brute-force next-occurrence finder, both preview-only -- the server stays authoritative), and saveScheduledJobs() validates client-side before the round-trip so an obviously malformed cron never reaches the network (still atomic: nothing persists either way). Card hierarchy inverted: Cron Schedule is now the first, largest element; Task/Queue/Registered name/Default cron moved into a collapsed native <details> disclosure. Verified live end-to-end, including the real 422 job_id attribution round-trip and a full valid save. Covered by 5 new backend tests (job_id attribution and omission) and manual Playwright-script verification of the preview/validation UI.
  • /sources: metadata columns align across cards, search matches the domain it displays, and action buttons no longer bury Configure/Stats behind an unlabeled menu (2026-08-28): three coupled findings on the 39-row Sources list. DOMAIN/LAST CHECK/FREQUENCY/LOOKBACK were content-sized per card, so a long domain (symantec-enterprise-blogs.security.com) pushed every later column out of alignment with its neighbors -- fixed with a fixed 4-column CSS grid plus ellipsis truncation. filterSources() matched only card.dataset.name (the source's display name), so searching securelist.com -- the exact domain text printed on the Kaspersky card -- returned 0 of 39 results; it now also matches a data-domain attribute, and the query is trimmed so pasted whitespace no longer misses. Collect Now, which starts a live scrape job, was the filled primary button on every card while the cheaper, more frequent Configure/Stats/Toggle Status sat two clicks behind an unlabeled ···; those three are now always-visible one-click buttons and Collect Now is a de-emphasized outline button pinned right, with a tooltip explaining why it's disabled for an inactive source. Verified live: all three fixes hold across the real 39-card dataset. Covered by 3 new Playwright tests in tests/playwright/sources_page.spec.ts.
  • /sources: status filter chips and the config modal are operable from the keyboard; each card shows the hunt score the list is sorted by (2026-08-28): the four header chips (37 Active, 2 Inactive, etc.) were <span>s with only an onclick -- 0 of 4 were focusable -- despite being the page's primary filter affordance; they now carry role="button" tabindex="0" aria-pressed and a keydown handler activating on Enter/Space, matching the /settings accordion reference pattern. The source config modal set aria-modal="true" against a background that stayed fully tabbable, never moved focus in on open, never trapped Tab, and never restored focus on close; fixed with a scoped focus trap, initial focus on the first field, aria-labelledby (replacing a stale aria-label), body scroll lock, and focus restored to the exact Configure button that opened it -- which surfaced a real bug in the shared modal-manager.js: it auto-registers any [id$="Modal"] element on its own DOMContentLoaded listener before a page's own registration runs, silently dropping a page's onClose callback unless forceUpdate: true is passed. Separately, the list header read "↓ Hunt Score" but no card displayed one (huntScoreShownOnCard: false on all 39 cards) -- the value already existed server-side in the route's own sort key (hunt_score_lookup, unused by the template) and is now rendered on every card, with the label restyled from purple (reading as a live sort control) to muted gray with a tooltip clarifying it's a fixed order. Verified live: chip Enter/Space toggles the filter and aria-pressed; the modal's Tab wraps correctly at both boundaries and Escape/Cancel both restore focus and unlock scroll; all 39 scores render in confirmed-descending DOM order. Covered by 3 new Playwright tests.
  • Opening Junk Filter Tuning issues one feedback request instead of one per chunk (2026-08-26): the modal called GET /api/feedback/chunk-classification/{article_id}/{chunk_id} once per chunk while rendering, unbatched and un-awaited -- 150 requests in a 224ms burst on article 7216, and 1,250 after a full analysis -- with essentially every one returning feedback: null, because chunk feedback is rare by nature. A new GET /api/feedback/chunk-classification/{article_id} returns all stored feedback for an article keyed by chunk id, newest-per-chunk, matching what the single-chunk route (ORDER BY created_at DESC LIMIT 1) would have returned for each chunk individually. The reduction runs in Python rather than SQL (no DISTINCT ON / window function) because per-article feedback volume is tens of rows at most, which keeps the query portable and the ordering contract obvious. Measured after the change: 1 request on a 60-chunk article and 1 on a 150-chunk article. The single-chunk route is retained -- submitFeedback() legitimately re-reads one chunk after a write -- and both paths now render through one shared renderChunkFeedbackIndicator(). Also fixes an adjacent bug: a threshold change rebuilds every chunk card via generateChunkDetails() and nothing put the indicators back, so moving the slider silently erased the fact that a chunk had been reviewed; the bulk call now runs on re-render too (verified 18 indicators survive a real slider move on article 4790). Before trusting the new route its output was compared against the single-chunk route across all 18 of that article's feedback rows -- zero mismatches -- plus the unfed-chunk and empty-article cases. Covered by 5 API tests in tests/api/test_feedback_bulk_api.py, including a newest-wins case proven to fail if the reduction keeps the wrong row.
  • Junk Filter Tuning dialog is usable by keyboard and screen reader (2026-08-26): five gaps. The close button contained only an SVG, so it announced as a bare "button" -- it now carries aria-label="Close Junk Filter Tuning" and an explicit type="button". The dialog advertised aria-label="Chunk debug" against a visible title of "Junk Filter Tuning", so a voice-control user saying the name they can see could not address it (WCAG 2.5.3 Label in Name); it now uses aria-labelledby pointing at the heading itself, which cannot drift from the visible text the way a duplicated string does. Focus was landing on the threshold slider -- not BODY as originally reported, but incidental: it was whatever ModalManager's first-input heuristic found -- and now goes deterministically to the close button, which also keeps the Correct/Incorrect buttons (they POST straight into the ML training corpus) off the opening focus position. Closing dropped focus to BODY, restarting a keyboard user's traversal from the top of the page; it now returns to whatever opened the modal. Background scroll was never locked; it now locks on open and restores the previous value on close, on both the Escape and close-button paths. ModalManager provides none of this -- it stacks, shows, and focuses the first input it finds, with no focus trap, focus restore or scroll lock -- so the modal wires them itself; docs/contracts/ui-designer.md now records that gap and the accessible-name rule so the next modal author does not assume otherwise. Covered by 4 tests in tests/playwright/chunk_debug_modal_polish.spec.ts, confirmed to fail against the pre-fix markup.
  • A near-duplicate SIGMA rule no longer suppresses the novel rules generated alongside it (2026-08-26): the queue-promotion gate in queue_sigma_rules computed a batch aggregate -- max_similarity = max(per-rule scores) -- and, when that single number cleared SimilarityThreshold, set queued_rules = [] before the per-rule loop ever ran. One near-duplicate therefore discarded its entire batch, including genuinely novel siblings, and nothing in the queue or the UI recorded why. It also defeated the fail-open routing added earlier for inconclusive comparisons: a rule the comparator could not assess is meant to reach the review queue flagged needs_review, but the batch gate dropped it first. The decision now runs through select_queueable_rule_indices, a pure function judging each rule on its own score, with the batch aggregate removed entirely. Semantics are otherwise unchanged and deliberately pinned: the boundary stays inclusive (>= threshold is a near-duplicate), an inconclusive comparison is still promoted, and a rule that is unscored without being flagged inconclusive is still suppressed. Suppressions are now logged with a count rather than passing silently. The help bubble and the slider caption both said "above this threshold" for what is an inclusive gate, and the help bubble implied batch-wide behaviour; both now state the boundary, the per-rule scope, and the inconclusive fail-open. Covered by tests/unit/test_agentic_workflow_queue_selection.py (7 cases including the exact-boundary and novel-sibling regressions) and 4 wording guards in tests/unit/test_workflow_help_bubbles.py.
  • Long chunk analyses report progress instead of an undifferentiated spinner (2026-08-26, corrected 2026-08-27): opening Junk Filter Tuning on a large article (7216: 825,777 chars / 1,250 chunks) took ~65 seconds behind a static "Processing article content..." message with no chunk count, no progress and no elapsed time -- indistinguishable from a hang. GET /api/articles/{id}/chunk-debug now publishes progress as it works and a new GET /api/articles/{id}/chunk-debug/progress serves a snapshot the loading modal polls once a second. The first attempt instrumented only the per-chunk loop, which is not where the time goes. Measured on 7216: a 64.9s run reported progress from t+28.5s to t+35.6s and nothing outside it -- 11% of the wall time. filter_content() (a full sklearn pass over all 1,250 chunks) runs before the loop and took ~28s uninstrumented, and response assembly afterwards took ~29s during which the client, which only overwrote its text while in_progress was true, sat frozen on "Processed 149 of 150" -- reading as a hang, the exact failure being fixed. Progress now initialises immediately after chunking (both totals are known there) and reports a phase: filtering -> analyzing -> finalizing, with the key cleared in the handler's finally so response assembly is covered and an error path cannot leave a dead run reporting as live. Re-measured: progress is live from t+1.8s to t+67.0s, 97% of the run, and the operator sees "Filtering 1,250 chunks..." -> "Analyzed 5 ... 144 of 150 chunks in this pass (1,250 in article)..." -> "Building results...". _set_chunk_debug_phase writes only to an existing key, since hset creates missing keys and a late phase write would otherwise resurrect a zeroed record and report a finished analysis as freshly started. Progress lives in Redis, not process memory: the web service runs multiple uvicorn workers, so a poll can land on a different worker than the analysis and would otherwise always read zero. Every progress operation is best-effort -- a Redis outage degrades the display, never the analysis -- with a 300s TTL. One Redis client is shared across the run rather than opened per chunk. The existing Cancel button and "operation continues" dismissal note are unchanged. Covered by 17 tests in tests/test_chunk_debug_utils.py. The helper-level ones pass just as happily with the reporting wrapped around the per-chunk loop only -- verified by re-introducing that exact regression, which left all of them green -- so four of them drive api_chunk_debug itself and pin where the calls sit: progress live while filter_content runs, still reporting while the response is assembled, and cleared on both the return and the error path. Four more in tests/playwright/chunk_debug_modal_polish.spec.ts stub the progress endpoint and assert what the loading modal actually displays for each phase, rather than inferring it from the poll requests firing -- which is how the original attempt was mistakenly reported as working.
  • The ML Model Performance panel refreshes with the threshold (2026-08-26): moving the Junk Filter Tuning slider re-fetched and correctly updated the KPI tiles, cost analysis, visualisation and chunk details, but left this panel showing the previous threshold's numbers. Measured on article 5772 going 0.7 -> 0.5, the panel kept reporting 30 matching / 83.3% / 6 mismatches while the payload said 36 / 100% / 0. The visible symptom was a self-contradiction: the panel claimed 6 mismatches while Show ML Mismatches -- reading ml_mismatch from that same live payload -- revealed none. The four values were emitted once inside the modal's innerHTML template literal with no id attributes, so updateChunkDebugResults() had no handle on them and never tried. Both render paths now write through a single updateMlPerformancePanel(); a second copy of the formatting is exactly how the two drifted apart, so the fix is one writer rather than two. The agreement colour band recomputes with the value (full className rewrite, so a panel going 100% -> 50% cannot carry both colours), and the header's "(based on N of M chunks)" caveat refreshes with the numbers it describes rather than captioning fresh figures with a stale sample. Verified live across 0.5 / 0.7 / 0.8 on article 5772 and covered by 5 tests in tests/playwright/chunk_debug_modal_polish.spec.ts, each confirmed to fail against the unwired code.
  • Settings: stored credentials are no longer returned to the browser, and an empty field no longer deletes one (2026-08-22): GET /api/settings/{key} returned setting.value verbatim and GET /api/settings returned every stored setting in one payload, so /settings loaded full plaintext secrets into type="password" inputs -- observed: a 164-char OpenAI key, a 93-char GitHub PAT, the Anthropic key and both Langfuse keys, all readable from devtools or any DOM dump. _SENSITIVE_KEYS and _is_sensitive_setting() already existed in that module but were wired only to the audit path. Both read routes now return value: null plus configured/hint for a matching key (value: null rather than the hint string, so a caller that passes settings[key] straight to a provider fails its own truthiness check instead of shipping eight characters of a key), POST /api/settings stops echoing the value back, and GET /api/settings/{key} sets Cache-Control: no-store to match its sibling. Masking makes every credential field load empty, which would have been catastrophic on its own: saveSettings() sent DELETE /api/settings/{key} for every empty field, with no way to tell "the user cleared this" from "the GET failed and blanked it" -- reproduced 2026-08-19 by stubbing fetch to reject for GITHUB_TOKEN, which took the field from 93 chars to 0 with showNotification never firing. The two are fixed together: every managed field records whether its load succeeded, and Save reads that record instead of inferring intent from emptiness -- typed value writes, an explicit Clear (confirmed through ModalManager) deletes, a failed load touches nothing and says so, and an empty secret after a good load means "leave it alone". The bulk provider-key payload gets the same treatment; sending '' for an untouched field would have overwritten the stored key just as destructively as the DELETE. Consumers that used to read keys out of /api/settings now let the server resolve them: sigma_queue enrich/validate and /api/test-{openai,anthropic}-key fall back to workflow_provider_options.resolve_provider_api_key(), and the GitHub connection test moved to POST /api/settings/github/test (which also retires a standing connect-src Report-Only violation from the browser calling api.github.com with the PAT).
  • Settings: unsaved changes are visible, partial saves report per section, and act-now buttons are distinguishable (2026-08-22): the page is ~5,500px tall with a single Save button at the bottom, no dirty-state indicator and no beforeunload guard, so whether anything was pending was unanswerable without scrolling and navigating away discarded edits silently. A sticky save bar now carries an unsaved-change count computed against a baseline captured once loading finishes, so editing a field back to its original value clears the flag rather than latching, and a beforeunload guard fires only when something is actually pending. saveSettings() runs seven sections with no rollback between them, and reported failure as a bare list of section labels that never said what did apply; it now records applied/failed/skipped per section into a persistent summary under the save bar (the toast times out in 5s; a partial save is not something to read in five seconds). Interleaved with the deferred fields were buttons that fire immediately -- Create Backup Now, Apply/Disable Backup Schedule, Refresh Scheduled Jobs, Launch MkDocs server, Export Annotations CSV, Restore from Backup/File, and the four Test connection variants -- styled identically to inputs that wait for Save; all 18 now carry a warning-coloured left bar explained by a legend beside the save bar.
  • /workflow#config: loading the page no longer saves the config back (2026-08-22): opening the config tab and touching nothing issued a PUT /api/workflow/config, which is why agentic_workflow_config reached 8,152 rows with exactly one active (632 rows on 2026-08-19 alone). Three independent causes: OSDetectionAgent_selected_os is an array rebuilt on every read and the dirty check compared it with !==, so it always reported changed; SigmaEmbeddingModel is stored by the server but has no config panel, so form state read it as undefined and always differed; and -- the one that fired three times per load -- applyAgentConfigs() writes stored values into the form via setAgentProvider(), which dispatches a real change event to rebuild dependent UI, and that reached the user-edit handler and scheduled a save. Arrays are now deep-compared, form state carries forward every agent_models key the server holds so the payload is complete on its own rather than depending on the backend's merge to repair it, and autosave refuses to schedule while stored config is being applied. A model select that reads back '' because it cannot represent the stored value (ExtractAgent is stored as provider lmstudio with an OpenAI model, so the option is absent from the list) is treated as the form having nothing to say rather than as the operator clearing the field -- performAutoSave already dropped empty model values, so calling that a change produced a write that could not express it.
  • /workflow#config: provider changes no longer throw before finishing (2026-08-22): onAgentProviderChange referenced an options variable that function never had (introduced in b482190b), so every provider change -- including the ones the loader makes for each agent on page load -- raised ReferenceError: options is not defined and abandoned the rest of the handler: the temperature capability UI, the sub-agent commercial inputs, the control bindings and the inheritance hints. Found while instrumenting the phantom-save path on the same function.
  • agentic_workflow_config.version is unique and race-free (2026-08-22): models.py has always declared version = Column(..., unique=True), but this database predates that attribute so the index was never built -- and _next_workflow_config_version() allocated with an unguarded SELECT max(version) + 1. Two writers read the same maximum and both committed it: 164 version numbers were shared by two or more rows, the worst by five, while the UI presents version as the config's identity and configVersionSearch looks configs up by it. Allocation now draws from a sequence, which cannot hand the same number to two callers however their transactions interleave; the advisory lock from 77506e68 stays as defence in depth, and the arithmetic remains as a fallback where the sequence is absent so this is safe to deploy ahead of the migration. scripts/migrate_workflow_config_version_unique.py renumbers the losers of each collision (the lowest id keeps the number it has always been addressed by; later rows move above the maximum and record their original in description), then creates the unique index and the sequence. id never changes, so sigma_evaluations.workflow_config_id and subagent_evaluations.workflow_config_id are untouched. Deploy note: the migration has not been run against the live database -- run it before relying on the constraint.
  • /articles: RegexHuntScore filter buckets no longer strand articles in a dead zone between adjacent buckets (2026-08-21): the five score buckets used inclusive-inclusive bounds against non-shared boundary labels (0-19, 20-39, ..., 80-100), so the open interval between adjacent buckets -- e.g. 19 < score < 20 -- matched neither; 113 of 7,741 articles were unreachable through any bucket, and Good (60-79) + Excellent (80-100) undercounted High Quality (60+) by 17. Separately, an article with no threat_hunting_score at all was defaulting to score 0 via .get(..., 0), incorrectly landing it in the bottom bucket. Fixed by relabelling the dropdown option values to share boundaries (0-20, 20-40, 40-60, 60-80, 80-100; visible labels unchanged) and extracting the bucket logic in pages.py into parse_threat_hunting_range() / score_in_threat_hunting_bucket() -- upper bound exclusive except the top bucket, None/missing score matches no bucket, malformed range now logged and ignored instead of silently swallowed by a bare except: pass. Verified against the live corpus: all five buckets now sum exactly to the loaded article count with no double-counting, and Good + Excellent == High Quality exactly. Covered by tests/unit/test_threat_hunting_range_buckets.py (17 tests): every score in a swept set (including the old dead-zone boundaries) lands in exactly one bucket, boundary scores go to the higher bucket, 100 is inclusive in the top bucket, None matches nothing, and combined-bucket reconciliation holds across the whole domain.
  • /articles: keyword chips expose the keyword as their accessible name, not the shared category hint (2026-08-21): every keyword chip carried title="{{ hint }}" -- the category hint, identical for every chip in a group -- and title supplies the accessible name, so a screen reader announced "Good discriminator" repeated across a row instead of the keywords distinguishing that article. The +N overflow chip was worse: same generic hint, not interactive, so the keywords it hid were unreachable from the list page entirely. Fixed in the keyword_chips macro (articles.html): removed title from the per-keyword chip so its accessible name is the visible keyword text, moved the category hint onto the (aria-hidden) color dot so it's still discoverable on hover, and added a sr-only group label per category for screen readers. The +N chip now gets title="{category} (hidden): kw1, kw2, ..." listing every hidden keyword. Covered by 2 new tests in tests/ui/test_articles_advanced_ui.py: chip accessible name equals the keyword text (no title override), and the overflow chip's title lists the hidden keywords.
  • /articles: concurrent toast notifications no longer occlude each other, and multi-line messages render their line breaks (2026-08-21): showNotification (base.html) appended every toast independently as fixed top-4 right-4, so concurrent toasts landed on top of each other -- only the last was legible until it timed out, and the toast covered the Settings nav link while visible. Multi-line error summaries (built by handleAdhocUrlSubmit) also collapsed into one run-on line, since \n was set via textContent into a container with no white-space: pre-line. Fixed by appending toasts to a flex-column #notificationStack container positioned below the 70px nav bar, and adding white-space: pre-line. Message assignment stays textContent/role="alert" -- unchanged. Verified live: 3 concurrent toasts render at non-overlapping y-ranges, and an injected \n renders as separate lines.
  • /articles/{id}: restored annotation passages render at body type size instead of a 25% step-down (2026-08-21): restored huntability annotations were wrapped in a span carrying text-xs font-medium (12px), sized for a short inline badge, while #article-content is text-base (16px) -- so a long annotated passage stepped down in size at its start and back up at its end, mid-paragraph. The same oversized class string was used in both the live-selection highlight path and all three truncated-long-selection fallback strings in article_detail.html. Dropped text-xs font-medium from all four, letting annotations inherit body type while background/border still carry the highlight signal; also switched the click-delegation selector from a now-stale class-string match to span[data-annotation-type], which is robust to the class change. Verified live: both annotations on a two-annotation test article now compute to the same 16px font-size as the surrounding body text.
  • SIGMA Queue: the "Showing rules from job" banner no longer renders on every plain visit (2026-08-21): the deep-link filter banner (#queueJobFilterBar) rendered on every visit to /workflow#queue, reading "Showing rules from job " with a trailing space and no ID, falsely implying the listed rules were a filtered subset. Root cause was CSS source order: a page-local .q-job-filter-bar { display: flex } rule was emitted after Tailwind's .hidden { display: none } in the page's <style> block, and both selectors share specificity (0,1,0), so the later rule won regardless of which class JS applied. Fixed by adding .q-job-filter-bar.hidden { display: none } (specificity 0,2,0), which wins independent of source order. The banner still renders correctly, with the right job ID, for a real ?jobId= deep link, and CLEAR FILTER still clears it. Covered by a new test in tests/playwright/sigma_queue_lifecycle.spec.ts.
  • /workflow#config: 41px of horizontal page overflow at 375px fixed (three separate root causes) (2026-08-21): at a 375x812 viewport, documentElement.scrollWidth exceeded clientWidth by 41px, pushing the Steps 2-5 collapse chevrons off-screen (no visible affordance that those sections expand) and clipping the tab bar's Agent Evals link mid-word. Three independent contributors, all fixed: (1) .section-meta spans used white-space: nowrap with no shrink limit, so long agent-list text pushed section headers past the viewport -- given overflow: hidden; text-overflow: ellipsis; min-width: 0 plus a max-width: 30vw at <=480px; (2) the tab bar (<nav class="-mb-px flex items-center justify-between">) laid out three tab buttons plus the Agent Evals link with no wrap and no shrink -- made horizontally scrollable in its own container instead of blowing out the page; (3) the actual root of the residual 41px: workflow-config-display.js's "Selected Models" list rendered items like CmdAttnPreprocessor:Enabled as one unbroken token with no spaces, which white-space: normal cannot wrap -- fixed by adding break-words to that <li>. Verified live at 375x812 after a hard reload: no scroll overflow, all six section chevrons on-screen and tappable, Agent Evals reachable within its own scrollable container.
  • /workflow#config and /workflow#executions: SIGMA Queue rule IDs no longer truncate; STEP cells expose their full value on hover (2026-08-21): the Queue's ID column was ~7px too narrow for its own 4-character content (2097), truncating every rule ID in the table with no title to recover the value by hover; the Executions STEP column had the same gap for any step not in getStepBadge()'s known label map (e.g. context_length_check). Fixed by widening the ID column's default width from 60px to 76px (DEFAULT_W in workflow.html; a user's own drag-resized width, persisted to localStorage, still takes precedence) and adding title="${escapeHtml(exec.current_step)}" to the STEP cell. Verified live: 5 different queue IDs render in full with no truncation and no new page overflow; STEP cells report their raw value (e.g. "generate_sigma") as title.
  • /workflow#config: 30 redundant /api/validate-model requests fired on every page load (2026-08-21): validateProviderModelCombination() fired its async server-side validation POST as a side effect whenever client-side validation passed, and it was called from 6 bulk/load-time loops that iterate every one of ~30 agents (applyProviderSelections, syncProviderVisibilityAndInputs, refreshAllProviderBlocks, the mainAgents provider-visibility loop, and both autosave pre-save validation passes) -- not only from a real user-driven change. Client-side validation is authoritative in those bulk passes; the async pass only matters for an actual edit. Added an opt-in skipAsync option threaded through validateProviderModelCombination() / updateAgentProviderVisibility() and set at all 6 bulk call sites; the genuine single-agent change paths (onAgentProviderChange, validateAgentModelOnChange) are unchanged. Verified live: a hard reload now issues 0 requests (down from 30); an explicit single-agent validation call still fires exactly 1. Covered by 2 new tests in tests/playwright/agent_config_validation.spec.ts.
  • /workflow: auto-refresh polling cadence now follows the active tab instead of the tab active at page load (2026-08-21): the auto-refresh timer computed its polling period once, at script-load time, from whichever tab was active then (setInterval(..., currentTab === 'executions' ? 10000 : 30000)), while its callback re-checked the tab on every fire -- so switching tabs changed what was polled but never how often. Loading on #config/#queue and switching to Executions polled at 3x-stale 30s instead of 10s; the reverse polled Executions-cadence 10s against the Queue, 3x the intended DB load. Replaced the fixed setInterval with a self-rescheduling setTimeout that reads currentTab fresh at each fire and re-arms with the matching delay, exposed as window.rearmAutoRefresh and called from switchTab() so cadence updates immediately on switch rather than waiting for the stale interval to fire first; clearTimeout before each re-arm prevents stacking a second timer. Verified live by measuring real poll timestamps: 6 polls over 55s at ~11s spacing after switching to Executions, 2 polls at ~31s spacing after switching to the Queue, no duplicate/overlapping requests. Covered by 3 new tests in tests/playwright/workflow_auto_refresh_cadence.spec.ts, including an anti-stacking check.
  • Site footer no longer shows a hardcoded, increasingly stale copyright year (2026-08-21): base.html hardcoded &copy; 2025 Huntable CTI Studio, rendered on every page, already a year stale and set to silently go stale again each January. Added _current_year() to src/web/dependencies.py, registered as the Jinja global current_year, computed server-side per render. Verified on the dashboard, /articles, and /workflow. Covered by tests/unit/test_footer_copyright_year.py (2 tests).
  • Settings page: unified disabled-button styling, password-toggle accessibility, backup button colors, status-tile clarity, and input precision/minimums (2026-08-21): a cluster of independent presentation and control-state issues on /settings, fixed together. Disabled buttons looked live: applyBackupCronBtn/disableBackupCronBtn kept full-saturation brand fills with cursor: progress -- the busy cursor, implying the action was running, the opposite of "unavailable" -- while /sources' Collect Now used the correct desaturated-grey + not-allowed treatment; the shared .settings-btn-*:disabled rule now matches that treatment app-wide, plus a tooltip on the cron buttons explaining why they're disabled. Icon-only password-reveal toggles had no accessible name: the 5 API-key visibility toggles had empty textContent, no aria-label, no aria-pressed; all 5 now expose aria-label="Show/Hide API key" and aria-pressed, synced on click. Backup Actions used five unrelated button colors with the safest action (Restore from File) the second most prominent (orange); rationalized to neutral for reads, brand purple for the safe write, red reserved for both destructive restore paths -- neither restore path's confirmation gate was touched. Contradictory backup-cron status tiles (Availability: Unavailable / Managed Backups: Enabled) had their reconciling sentence buried below the fold in small amber text; promoted to a note directly under the tile grid. Documentation section ambiguity between a raw nohup mkdocs serve command and a Launch button was resolved by making Launch the stated primary action and the command an explicit fallback, plus a copy button for the command. Threshold step mismatch: autoTriggerHuntScoreThreshold had step="1" against fractional hunt scores (50.3); changed to step="0.1". Retention accepted 0: dailyRetention/weeklyRetention/monthlyRetention all had min="0", silently allowing "keep nothing"; raised to min="1". Covered by 8 new tests in tests/playwright/settings.spec.ts.
  • /ml-model-performance's browser tab title matches the page (2026-08-21): ml_hunt_comparison.html set {% block title %}ML vs Hunt Scoring Comparison - Huntable CTI Studio{% endblock %}, a leftover from an earlier purpose of the template, while the page's own <h1> reads "Model Performance" -- so the tab, bookmarks, and history all showed a name the page no longer uses. Title now reads "Model Performance - Huntable CTI Studio", matching the <h1>. Verified live in-browser.
  • The Registry and Services extractor prompts pass validation again (2026-08-21): RegistryExtract and ServicesExtract were the two agents reported as carrying one warning each by the validation pass above -- both flagged Role/system missing expected token for VERIFICATION CHECKLIST (sec 12): "[ ]". The section had not been dropped, which is what the symptom suggests and what the task assumed: both prompts carry the checklist in full, with every item intact. What drifted is the - [ ] checkbox marker, which had degraded to plain - bullets, and _collectPromptIssues tests for the literal [ ] token, so a complete checklist rendered without checkboxes reads as a missing section. The fix is therefore a marker restore, not a section restore -- every changed line is exactly - X -> - [ ] X, verified mechanically across all 14 files by diffing the role strings rather than trusting the patch. The drift was present in all 12 quickstart presets and both src/prompts seeds simultaneously, so the presets were not the clean reference the task expected them to be; the authoritative text is docs/contracts/registry-extract.md and docs/contracts/services-extract.md, which both still carry the section with - [ ] at the position the envelope splits role from instructions. The live active config was reconciled in the same pass with a targeted jsonb_set on the two agent_prompts keys rather than a whole-config PUT, since that path read-modify-writes the entire blob and has clobbered sibling agents before; the other eight agents were verified byte-identical afterwards and only the role key moved within the two targets. Verified in-browser on /workflow#config: all seven extractors report clean and no badge renders, confirmed against a positive control that degraded a prompt in memory and made the step badge appear. Covered by tests/config/test_shipped_extractor_prompt_contract.py, which validates all 12 presets x 7 extractors plus the 7 seeds against the whole validator contract rather than the one token that broke -- the same class of drift hit CmdlineExtract with seven missing tokens at once, so guarding only sec 12 would test the symptom. The token lists are parsed out of prompt-editor.js instead of restated, so a token added to the validator is enforced here automatically; a non-vacuity guard pins the parsed lists, and it earned its keep by catching that EXTRACT_SUB_AGENTS lives in workflow.html, not the extracted JS modules. All 15 assertions were confirmed to fail against the pre-fix data in an isolated worktree.
  • Subresource Integrity restored on every remaining third-party script (2026-08-21): the Tailwind CDN removal left three siblings behind. hunt_metrics.html and scraper_metrics.html loaded https://cdn.jsdelivr.net/npm/chart.js unversioned with no integrity, and article_detail.html loaded html2pdf 0.10.1 with no integrity -- unverified third-party JS executing in an authenticated operator session, the same supply-chain exposure the Tailwind change was meant to close. ml_hunt_comparison.html was worse than unpinned: it paired an unversioned jsdelivr URL with a hash specific to Chart.js 4.5.1, so the integrity check would have started failing the moment upstream published a new latest, silently blocking the script and leaving a half-working page with no server-side signal. All four now use the pinned chart.js@4.5.1/dist/chart.umd.min.js (or the pinned html2pdf path) with integrity, crossorigin, and referrerpolicy, matching the one call site that was already correct. Verified in-browser: Chart.js 4.5.1 loads and all 5 hunt-metrics charts render, and html2pdf loads on article detail -- a wrong hash would have blocked them. Covered by tests/unit/test_security_headers.py, which fails on any external script lacking integrity and on any integrity hash pinned against an unversioned URL.
  • Stored XSS in the dashboard, hunt-metrics and article-detail chunk dialogs (2026-08-21): five inline innerHTML builders interpolated scraped, attacker-influenceable text into an HTML string without escaping. On / (dashboard.html:823-824, 848-850) and /hunt-metrics (hunt_metrics.html:459-476, 621-626) the value is article.title, copied verbatim out of JSON-LD headline / <meta content> during ingestion, returned raw by the dashboard and analytics JSON APIs, and repainted on a timer -- so a poisoned title executes with zero clicks in an authenticated operator session, and the app ships no CSP to backstop it. On /articles/{id} the value is a verbatim slice of articles.content reaching two modals: showRemovedChunksDialog (article_detail.html:5500, auto-opens after a filtered ranking run) and displayFeedbackComparisonModal (:10033-10034, both a body sink and a title="" attribute sink, inserted via insertAdjacentHTML). Because CSRF tokens here are client-minted request headers, injected JS can read one and drive admin-only endpoints as the operator. Every interpolation now routes through the shared global escapeHtml() (static/js/utils.js, loaded for every page by base.html); for the removed-chunks dialog the escape happens before \n -> <br> so intended line breaks survive while attacker markup does not. Escaping is applied uniformly rather than only to the obviously-tainted fields -- escapeHtml is a no-op on markup-free values (hex colours, fixed class strings, numbers) and is null/number-safe, so blanket escaping costs nothing and leaves no gap for the next field added to one of these widgets. Non-goal: no stored article content was sanitized or migrated; this is render-path only. Sibling of the highlight_keywords fix below, which closed the server-rendered arm of the same class of bug. Covered by tests/unit/test_template_escaping_contract.py (a scanner pinning every ${...} inside an .innerHTML template literal on the two dashboard pages, plus a non-vacuity guard so a broken regex cannot pass for free) and tests/playwright/chunk_dialogs_xss_regression.spec.ts (2 tests driving both dialog renderers directly with a combined body/attribute-breakout payload: no live nodes, no on* attributes, payload visible as inert text, line breaks preserved, and no handler fires).
  • The workflow Configuration tab is operable from the keyboard (2026-08-21): all six pipeline step headers on /workflow#config were <div>s carrying only an inline onclick, with no role, no tabindex and no aria-expanded, so no step section could be expanded without a mouse -- and the nested prompt accordions, which are keyboard-operable via the shared initCollapsiblePanels (base.html), sat inside sections a keyboard user could never open. The shared initializer was not reused, despite the task proposing it: these headers drive an accordion through scrollToStep, where state lives in .step-section.open, exactly one section is open at a time, and opening also aligns the scroll container and the left rail. initCollapsiblePanels expresses none of that. The six headers instead gained role="button", tabindex="0", aria-controls and aria-expanded, with matching role="region" / aria-labelledby on each body, and one delegated keydown handler in prompt-editor.js maps Enter/Space onto .section-header, .rail-item and .sa-header by calling click() -- reusing the existing inline handlers rather than duplicating their logic. preventDefault is required so Space does not scroll #config-content out from under the section scrollToStep is about to align, which in turn required a nested-control guard so Enter on the sub-agent help buttons keeps its native activation instead of toggling the panel. The rail and sub-agent headers already advertised role="button" while ignoring Enter, so they are fixed in the same pass. aria-expanded is synced from scrollToStep, the only live mutation path for .step-section.open. Focus is now visible on all three trigger types via an inset :focus-visible ring -- .step-section, .sa-item and .oc-rail all clip overflow, so an outward ring would be invisible. Covered by 5 tests in tests/ui/test_workflow_comprehensive_ui.py: button/region semantics across all six steps, a guard that the template's static aria-expanded values match the seeded .open classes, Enter and Space activation (asserting the accordion invariant, the aria sync and rail highlight), rail-item Enter, sub-agent-header Enter, and the help-button non-toggle case.
  • Tailwind is built locally instead of loaded from a CDN at runtime (2026-08-21): base.html loaded https://cdn.tailwindcss.com/3.4.17 on every page -- a 407KB unverified third-party compiler executing in an authenticated operator session, with no CSP anywhere in the app to backstop it, and Tailwind's own "should not be used in production" warning firing on every page load. The obvious minimal fix, adding integrity+crossorigin to match the Chart.js line below it, is not available: that host sends no access-control-allow-origin (verified against the live CDN), and SRI on a cross-origin script forces a CORS fetch, so the script would be blocked and every page would render unstyled. That exact change had already shipped on 2026-07-17 and was reverted three days later in cff9e7ba, which added tests/unit/test_base_template_external_resources.py to stop it recurring -- the guard's stated rationale ("dynamic") was the wrong reason for the right rule, and is corrected here to "CORS-incompatible". Tailwind now compiles to src/web/static/css/tailwind.css (74KB minified, committed) via tailwind.config.js and make css, using the pinned standalone CLI through npx rather than an npm devDependency, so no node_modules enters the production image and the check-pinned-versions CI gate is unaffected. darkMode: 'class' moved from the inline block in base.html into the build config. Two content-scanning traps, both silent: src/utils/keyword_resolution.py holds 59 Tailwind class strings that reach the DOM through render_highlighted_content, and no default content glob scans Python -- omitting ./src/**/*.py purges all 59 while every existing test still passes, because the tests assert on class attributes in rendered HTML rather than on CSS. Four call sites also assemble class names from a colour fragment (article_detail.html:7616-7617, sigma_similarity_test.html:302-303), which no scanner can see; both statusColor values come from closed chains, so the safelist pattern is exhaustive rather than precautionary. Verified across /ml-model-performance, /mlops/agent-evals, /mlops/agent-evals2, /articles, /settings, /workflow, and an article detail page (which carries the arbitrary-value and keyword-highlight concentration). No plugins are installed: prose classes appear in ~10 templates but are inert today, and adding @tailwindcss/typography would newly style pages this change is not meant to touch. Contract and command docs updated (docs/contracts/ui-designer.md, AGENTS.md); the old guidance to preserve the inline tailwind.config block would otherwise have told the next agent to restore something that no longer exists.
  • Stored XSS in the article body closed; keyword highlighting restored on the affected articles (2026-08-20): article_detail.html:602 renders {{ article.content|highlight_keywords(...)|safe }}, and highlight_keywords (src/web/utils/jinja_filters.py) short-circuited on the literal substring <span class= in the stored article text, returning that text verbatim into the |safe expression -- so the entire article body reached the DOM unescaped. It did the same whenever keyword metadata was absent (metadata is None), a second raw path not identified when the issue was filed. The reachability is ordinary rather than exotic: scraped text is entity-decoded during ingestion, so HTML written as &lt;span class="x"&gt; inside a code block in a CTI blog post lands in articles.content as literal <span class=. An attacker controlling any page reachable by the ingestion pipeline -- their own blog added as a source, a URL fed to POST /api/scrape-url, a crafted PDF/OCR ingest -- could pair that substring with a payload that executes in an authenticated operator's session, and because the CSRF token is a client-minted request header, injected JS can read it and drive admin-only endpoints as that user. Measured on the live database at fix time: 21 stored articles carried the trigger, 5 of them alongside raw <img>/<script> tags. Both raw returns are removed; every non-empty input now flows through render_highlighted_content, which html-escapes each non-match segment and emits only its own generated highlight markup -- so the "avoid nested spans" concern the heuristic existed to serve is satisfied structurally rather than by a substring guess. The heuristic was also silently disabling keyword highlighting for exactly those 21 articles, which now highlight for the first time; that is a visible change on those pages, not only a security fix. PRE-EXISTING, not introduced by v7.8.0 -- deliberately deferred from the v7.8.0 release security review because it touches the rendering path of every article page and warranted real coverage. Non-goal: no stored article content was sanitized or migrated; this is render-path only. Covered by tests/unit/test_jinja_filters_xss.py (9 cases across the metadata shapes, verified to fail against the pre-fix filter), a rewritten tests/test_jinja_filters.py case that had been asserting the vulnerable passthrough as correct behavior, tests/playwright/article_content_xss_regression.spec.ts (3 tests against a real rendered article: no live nodes, no on* attributes, raw response body inert, highlighting intact), and tests/unit/test_template_escaping_contract.py, which pins every Jinja autoescape bypass in the template tree -- both |safe sinks, {% autoescape false %}, and Markup() -- to a reviewed allowlist so a new unescaped sink cannot arrive unnoticed.
  • Eval scoring no longer grades a no-output extraction as 100% precision (2026-08-20): agent_evals2.html computed per-article precision as matched / (matched + extra) with a : 1 fallback, so an extractor that returned nothing was credited with precision 1.0. The server uses 0.0 for that degenerate case (evaluation_api.py per-config aggregates and eval_item_scorer.py), so the SYS.03 summary strip and the server-computed SYS.04 trend chart reported different numbers for identical rows -- 64.4% against 51-55% on the CommandLine dataset. Worse than the discrepancy, the metric rewarded silence: an extractor emitting zero items for every article averaged 100% precision, precisely the failure an eval harness exists to catch. Measured on the live corpus at fix time, 223 of 911 scored rows (24%) hit the degenerate branch, including windows_services at 3/3 -- a displayed 100% while producing nothing at all. Three client sites disagreed on the same row: the table cell rendered n/a, the summary strip counted 1.0, and the item-detail modal displayed 100% (0/0). All now route through a single scoredArticleMetrics() helper that returns 0 for an empty denominator and null for a genuinely unscored row (no ground truth), the latter staying out of every aggregate. The aggregation was additionally extracted to a pure aggregateScoredMetrics() so the headline number is testable without a DOM, and so membership and divisor are decided by one list rather than a filter condition duplicated from the helper. The reported dataset now reads 54.4%, matching the server. Recall's expected_count == 0 arm and the perfectRecall counter follow the same convention. Non-goal: no stored subagent_evaluations row or ground truth was changed; this is display/aggregation only, forward-only. Covered by tests/unit/test_evals2_item_scoring.py (11 tests, executing the template's JS under Node and pinning it against the Python convention case-by-case, including the live 10-row CommandLine dataset).
  • PUT /api/workflow/config refuses a config whose enabled extractors have no prompt (2026-08-19): the only gate was a dismissible browser confirm() that swallowed its own errors, so a config could be saved with an enabled extractor carrying no prompt at all -- producing a run that completes with zero observables and zero rules and reports no error. The route now runs the same scanners server-side and refuses, with allow_prompt_warnings as the explicit "Save Anyway" override. Narrowed twice during implementation: a settings-only autosave is not a prompt edit, and a disabled RankAgent's empty prompt is not a runtime defect (the latter present on the live config). Separately, generated Sigma rules now carry observable_attribution in rule_metadata -- an absent observables_used previously returned early, warned nothing, and left a failed tie-back byte-identical to a success; the key is stripped from the emitted YAML so it cannot reach pysigma. Covered by tests/api/test_workflow_config_prompt_validation_api.py, tests/unit/test_save_prompt_override_contract.py, tests/config/test_quickstart_preset_quality.py, and tests/workflows/test_agentic_workflow_helpers.py.
  • Articles are no longer silently dropped when re-scraping a known URL (2026-08-19): create_article's duplicate branch committed refreshed threat-hunting metadata and then handed the ORM row straight to _db_article_to_model. With expire_on_commit=True the commit expires every attribute, so reading .content issued an implicit SELECT from sync context and raised greenlet_spawn has not been called; create_article swallowed the error and returned None, dropping the article on every re-scrape of a known URL that carried a hunt score. The row is now refreshed after the commit, matching the created path and update_article. expire_on_commit=False was rejected because AsyncSessionLocal is a single shared factory and update_article documents a dependency on the expiring behavior. Covered by tests/integration/test_async_manager_duplicate_article.py, which needs a real async session -- a mocked session never expires attributes and the failure is specific to SQLAlchemy's asyncio greenlet bridge.
  • Diags page: escaped health-check errors, honest HTTP error detail, single batch overlay, visibility-gated auto-refresh (2026-08-20): six low-severity issues on /diags (src/web/templates/diags.html) fixed together. updateOverallHealthStatus was the one health-card updater still interpolating data.error straight into content.innerHTML -- every sibling updater already used escapeHtml()/textContent -- so a health endpoint (or a caught-fetch error message reflecting response content) returning markup in error would execute as DOM-XSS; now wrapped in escapeHtml(). runHealthCheck swallowed non-2xx responses into a bare data.error || 'Unknown error', discarding the HTTP status; it now checks response.ok and returns HTTP <status>: <detail> sourced from the body's error/detail/message field or statusText. "Run All Health Checks" fired five sequential calls that each independently toggled the single #loadingOverlay, so the overlay could flicker between checks and the trigger button stayed clickable mid-run, letting a second click start an overlapping batch; the click handler now owns the overlay for the whole batch (update*Health() calls take a suppressOverlay flag) and disables/re-enables the button around the run. The 5s auto-refresh setInterval kept polling in a backgrounded tab; a visibilitychange listener now clears it on hide and resumes (with an immediate refresh) on show, and the auto-refresh checkbox preference persists to localStorage across reloads. .diag-grid's minmax(420px,1fr) track forced horizontal overflow on any viewport narrower than ~452px; changed to minmax(min(420px,100%),1fr). All eight health/job-history card content regions gained aria-live="polite" so refreshed results are announced to assistive tech. Covered by tests/playwright/diags_health_check_regression.spec.ts (escaped-error-markup and single-overlay/button-disable regressions); the responsive-grid and aria-live changes were verified manually in-browser (420px viewport, screenshot, console-clean).

  • UI test runs can no longer leave the live workflow config corrupted (2026-08-20): the Playwright suite runs against the live dev app on :8001, so the 13 specs in the agent-config and workflow projects write the same agentic_workflow_config row the operator uses. Each restored its own mutation in a try/finally, but those restores run inside the test worker and most need a live page -- so none of them fire when a worker is killed, the global timeout trips, or the browser context dies. expanded_prompt_editor_save.spec.ts seeds a 403-byte hermetic CmdlineExtract prompt (role: "Hermetic test seed...") as part of a legitimate save-regression test; an interrupted run left that stub as the active production prompt, where it sat undetected from 2026-08-17 to 2026-08-19 (config rows 7224 -> 7949) degrading the observable class the ground-truth work is centered on. Reproduced deterministically by SIGKILLing a run mid-spec. Fixed by moving the restore anchor to the process level rather than retrofitting 13 specs, since a dead page defeats any of them: a new globalTeardown (tests/playwright/global-teardown.ts) restores a baseline in Node with no dependence on a surviving worker, and global-setup.ts captures that baseline after healing known corruption shapes from the canonical quickstart preset -- so damage from a previous run that died before teardown is never laundered into the baseline and faithfully restored. The two layers bound damage to at most one run; only SIGKILL of the Playwright process itself escapes teardown, and that is what heal-on-next-setup covers. TEST_SEED_MARKER now lives in workflow-config-snapshot.ts and the spec builds its seed from it, so a seed the detector cannot recognize is structurally impossible. Two implementation defects were caught by running it and are fixed: the detector flagged ExtractAgentSettings (a disabled_agents settings blob with no prompt key) as damaged, and the baseline was first written under test-results/ -- Playwright's outputDir, which it clears at run start, silently deleting the baseline between setup and teardown. The baseline now lives in git-ignored .playwright-state/. Healing is deliberately limited to CmdlineExtract from the canonical preset; other damaged prompts (currently RankAgent.prompt, empty) are reported every run but never auto-rewritten, since silently overwriting operator prompts is not a test harness's decision. Covered by tests/playwright/workflow_config_pollution_guard.spec.ts (17 tests: pollution detection, snapshot healing including the never-overwrite-a-healthy-prompt case, baseline persistence, post-run damage attribution, and an anti-drift guard failing any spec that hardcodes the seed literal). Verified across the full workflow + agent-config projects: 132 passed, 6 skipped.

  • Junk Filter Tuning modal: ten small display/labelling issues fixed (2026-08-22): the Content Reduction KPI tile bound to reduction_percent (a chunk-count ratio) instead of content_reduction_percent, overstating actual content removed by up to 41% relative (50.0% shown vs 35.4% actual on one test article) -- both were already computed server-side, only the wrong one was wired up. The threshold preset cards (0.5/0.7/0.8) were static, so after moving the slider the header readout and the highlighted card could disagree; a new syncThresholdPresetCards() keeps them in lockstep and the slider's onchange became oninput so the readout updates live while dragging, not just on release. A filter matching zero chunks (e.g. "Show ML Mismatches" on an article with none) left blank space indistinguishable from a rendering failure; it now shows an explicit "No chunks match the selected filter" message. Other fixes in the same pass: the ML Mismatch legend swatch was solid amber while the marker it represents is an outlined ring, now matched; the two adjacent "Confidence" labels (model probability vs. filter's own confidence field) read as a contradiction, now "Model confidence"/"Filter confidence"; 36 identical chunk headings and 72 identical feedback buttons carried no chunk number in their accessible name, now included; ML Model Performance / Chunk Visualization / Chunk Details showed sample-derived numbers with no indication they were a 12% sample during Partial Analysis, now noting "(based on N of M chunks)"; the 8 filter buttons had no pressed state or match count, now expose aria-pressed and a live (N) count per filter. Also demotes the nav brand lockup from <h1> to <div> and adds a skip-to-content link (base.html) so every page has exactly one <h1>, and themes the evalAccuracyChart/maeChart axes for the dark UI via a new --chart-tick token (previously black-on-near-black gridlines and sub-AA tick contrast), with the Y-axis domain now padded around the actual data range instead of pinned 0-100 against 80-90 data. Covered by 8 new tests in tests/playwright/chunk_debug_modal_polish.spec.ts, driving the modal's render functions directly with synthetic data; the Content Reduction fix was confirmed to fail against the pre-fix binding.
  • GPT-4o cost-rate literals consolidated and corrected (2026-08-22): the Junk Filter Tuning Cost Analysis panel derived its savings estimate from $5.00/$15.00 per 1M input/output tokens, hardcoded as six separate float literals across llm_optimizer.py plus one more in debug.py -- stale relative to OpenAI's current published pricing ($2.50/$10.00 per 1M, verified against developers.openai.com/api/docs/models/gpt-4o), free to drift apart from each other, and overstating displayed savings by roughly 2x. Consolidated into two module-level constants (GPT4O_INPUT_COST_PER_MILLION_TOKENS, GPT4O_OUTPUT_COST_PER_MILLION_TOKENS) that every call site now reads from, including a dead-code default parameter (calculate_filtered_costs) the original audit missed and a structural AST scan in tests/unit/test_llm_optimizer_cost_rate.py caught. Covered by 5 new tests pinning the corrected rates, proving the public cost-estimate API derives from them, and scanning both source files for a reintroduced stale literal.
  • Duplicate config-versions-models and model-versions/feedback-count fetches on page load (2026-08-22): /mlops/agent-evals's loadAggregateScores() and loadPreviousResults() each independently fetched /api/evaluations/config-versions-models for the same version set, issuing two identical requests per load; /ml-model-performance's loadInitialData(), refreshRetrainingStatus(), and refreshModelVersionHistory() each independently fetched /api/model/versions and /api/model/feedback-count, up to four duplicate requests. Both pages now share one in-flight fetch across their callers (_fetchConfigVersionsModels() with a per-load cache; _fetchVersionsAndFeedback() passed as a shared promise), and the version-history panel reuses the preloaded list for its default view instead of also hitting the paginated endpoint for data it already has. config_versions is an unbounded, unpaginated query-string param carrying the full id set; length-capped at 4000 chars server-side so an oversized request gets a clear 400 instead of failing opaquely at a proxy's header-size limit. Verified live: each page now issues exactly one request per endpoint per load (down from two and up to three respectively), no console errors, version history and KPI tiles still populate correctly. Covered by 2 new tests in tests/api/test_subagent_eval_metrics.py.
  • /articles: sorting by Annotation Count returned RegexHuntScore order, not annotation counts (2026-08-25): annotation_count was one of two keys _apply_article_sort (src/database/statements.py) routed through the JSON-stored threat_hunting_score as an approximation, on the stated reasoning that counts are attached post-query and therefore can't be sorted in SQL -- so the default no-filter listing and the source-filter fast path both ordered by hunt score while labelled "Annotation Count", and the corpus's most-annotated article (60 annotations, id 1426) never surfaced regardless of how far a user paged. The approximation was unnecessary: annotation_count is a plain COUNT(article_annotations) grouped by article, so it's added as a correlated scalar subquery (_annotation_count_expr()) and sorted directly, with the same hunt-score tiebreak real columns already use. Only the SQL fast path needed the fix -- the Python-side fallback used when a search term or score-range filter is active already sorts on the correctly-populated post-query metadata, which is why those two filter states were reported as already correct. Verified live: sort_by=annotation_count&sort_order=desc with no filters now returns article 1426 first, matching a direct ORDER BY COUNT(article_annotations) query exactly (confirmed for both directions and against a source-filtered case). Covered by tests/integration/test_article_annotation_count_sort.py (both sort directions checked against an independent COUNT oracle on a real Postgres scratch DB) and two rewritten cases in tests/unit/test_statement_builders.py that had pinned the old approximation as correct.
  • SIGMA fallback checkbox copy no longer implies full-article content replaces extracted observables (2026-08-25): the /workflow#config helper text for "Use Full Article Content (Minus Junk)" described the flag as swapping the extracted-artifact summary out for the full article, with observables "still included ... either way" -- language left over from an earlier version of the fallback path. It now describes what the flag does today: SIGMA reviews the junk-filtered article alongside the Sigma-eligible extracted observables for the current detection category, using the article to ground rules in the extracted evidence and surface additional relevant behavior the extractors didn't capture. tests/unit/test_workflow_sigma_fallback_copy.py updated to pin the corrected copy.
  • Article detail page: doubled URL-fragment handler and annotation-text console leak (2026-08-28): every /articles/{id} load registered two near-identical window.addEventListener('load', ...) blocks that each parsed location.hash and auto-opened the SIGMA/IOCs/ranking modals -- one via a named processUrlHash() (also reused by the hashchange listener), the other a dead second copy with a stale data.metadata.sigma_rules field lookup that never matched the real API shape. Every load logged "Setting up URL fragment handler..." and "Page loaded, checking URL fragments..." twice. Separately, restoring each stored annotation on load called addUserClassification()/highlightTextAtPosition(), which logged the first 100 characters of the annotated article text plus its length/offsets to the console on every restore -- a content leak, not just noise. Deleted the dead duplicate block and removed the text/offset-echoing console.log calls; annotations still restore correctly and exactly once (verified live: 2/2 annotations on article 5772). Covered by tests/unit/test_article_detail_fragment_handler.py.
  • target="_blank" links now consistently carry rel="noopener noreferrer" (2026-08-28): 5 of 7 such links across src/web/templates/ (article_detail.html x2, observable_training.html, settings.html x2) set no rel at all, and the other 2 (articles.html, sources.html) set rel="noopener" without noreferrer. Modern browsers imply noopener for target="_blank" by default so exploitability was already limited, but the inconsistency was a silent regression waiting on a future browser/extension change. All 7 now carry the full value; guarded by tests/unit/test_target_blank_rel_noopener.py, which scans raw template text (not just the parsed DOM) because the observable_training.html anchor is built inside a JS template literal.
  • Articles list: "Copy article content" silently failed off 127.0.0.1 (2026-08-28): copyArticleContent() called navigator.clipboard.writeText() with no fallback and one opaque failure message for every cause. That works on 127.0.0.1 (a secure context in a normal browser) but fails wherever navigator.clipboard is unavailable -- a plain http:// LAN address, or this repo's own Playwright/automation environment, where window.isSecureContext is false even at 127.0.0.1. Added a document.execCommand('copy') fallback with a cause-specific error when that also fails, verified end-to-end live (the automation environment's actual non-secure context exercised the real fallback path, not just a mock). Covered by tests/playwright/articles_copy_clipboard.spec.ts.
  • Model Performance page: HTTP errors were indistinguishable from empty results (2026-08-28): 11 of ml_hunt_comparison.html's 12 fetch() call sites went straight from await fetch(...) to await resp.json() with no resp.ok check. A FastAPI error body ({"detail": ...}) has no success key, so a 500 on /api/model/versions rendered "No model versions recorded yet." in muted grey -- indistinguishable from a genuinely empty result -- while the stat tile above still read the last-good count, and "Refresh Status" during a full backend outage looked like a successful refresh with stale tiles left unmarked. Added if (!resp.ok) throw ... before every .json() so failures route into the existing (previously dead-for-HTTP-errors) catch blocks; a genuine 200-with-zero-rows result still renders the plain empty state. Separately, executeRollback()'s error path read result.detail directly, and a FastAPI 422 puts an array of {msg, ...} objects there, so a validation failure rendered "Rollback failed: [object Object]"; added formatApiErrorDetail() to extract the actual message. All four behaviors regression-tested against the pre-fix revision (each failing test reproduces the original defect, including the literal [object Object] string) in tests/playwright/ml_hunt_comparison_error_handling.spec.ts.
  • Junk Filter Tuning: a kept chunk's reason no longer reads as though it was removed (2026-08-25): the modal's per-chunk reason field came straight from ContentFilter.filter_content()'s own reason string, which describes a whole-article filter pass rather than one chunk's decision -- so every kept chunk read "Content filtered successfully" (indistinguishable from "this chunk was filtered out") and every removed chunk read the equally uninformative "No huntable content found," naming no signal either way. src/web/routes/debug.py now builds the reason from the chunk's actual keep/remove outcome plus the top entry of the feature_contribution map already computed for the ML Details panel (e.g. "Kept - cmdline artifact count was the strongest signal"), falling back to a plain decision-only reason when the model exposes no feature importances. Verified live on article 5772: kept chunks read "Kept - ...", removed chunks read "Not kept - confidence stayed below threshold (...)". Covered by 5 new tests in tests/test_chunk_debug_utils.py.
  • Article detail page: duplicate "Article Content" heading removed from the page's heading outline (2026-08-28): the Article Content panel's <h2> and the annotation toolbar's inner <h3> both read "Article Content," rendering ~50px apart -- redundant visually and a duplicate entry for screen-reader users navigating by heading. The inner label is a toolbar caption, not an independent section, so it was demoted from <h3> to a plain <div> with identical classes/text; visual layout is unchanged. Verified live on /articles/5772: exactly one <h1-6> element reads "Article Content" (was 2). Covered by tests/ui/test_ui_flows.py::test_article_detail_has_single_article_content_heading, confirmed to fail against the pre-fix template before passing against the fix.
  • 404 pages no longer send operators to check database connectivity for a simple bad ID or URL (2026-08-28): a nonexistent article ID (/articles/99999999) and any unmatched route both rendered the generic "Something went wrong" title plus "please check your database connection and ensure all services are running properly" -- correct advice for a genuine 5xx, actively misleading for a routine 404. error.html now accepts optional title and show_db_hint context vars (defaults unchanged, so every existing 500/422 caller is unaffected); the article-detail 404 in src/web/routes/pages.py and the global not_found_handler in src/web/modern_main.py now pass title="Article Not Found" / "Page Not Found" with show_db_hint=False. Verified live: both 404 paths show the correct title with no database advice; /articles/abc (422) and a simulated genuine 500 both still show "Something went wrong" plus the hint, unchanged. Covered by 2 new API tests in tests/api/test_endpoints.py, 1 new assertion in tests/api/test_article_list_db_error_surfaces.py proving the hint survives on a real 500, and 2 pre-existing UI tests in tests/ui/test_ui_flows.py updated for the new titles.
  • Articles list: the #<id> badge no longer visually collides with a wrapped title on mobile (2026-08-28): at 390px, a long unbroken title token (e.g. a real article titled PHANTOMPULSE...) read as a single run-on with its adjacent #<id> badge (PHANTOMPULSE#4790). The mobile media query sets white-space: normal so titles wrap instead of truncating, but a single word has no space to break on, so it overflowed past its flex-item box with overflow: visible painting that overflow straight into the sibling badge -- the two elements' layout boxes never actually overlapped, only the painted text did. Added overflow-wrap: break-word; word-break: break-word to the same mobile-only .article-row__link rule so long tokens break mid-word instead of overflowing; desktop's single-line truncation is unchanged. Covered by tests/ui/test_ui_flows.py::test_mobile_title_does_not_overflow_into_id_badge, confirmed to fail against the pre-fix template (scrollWidth 468px vs. clientWidth 110px) before passing against the fix.
  • Article detail page: keyword category "points" now read as a per-match weight, not points the article earned (2026-08-28): the Keyword Matches panel showed cards like "Negative Indicators · -10 pts · No matches" -- -10 pts is a static per-category weight (src/utils/keyword_resolution.py), rendered in the exact position a reader expects this article's earned score, on an article that scored 99.9 despite the category contributing nothing. All 5 points_label constants (75 pts, 5 pts, 10 pts x2, -10 pts) now read "... pts each"; no template change needed since article_detail.html already renders points_label verbatim. Verified live on /articles/5772. Covered by 2 new unit tests in tests/test_keyword_resolution.py, one exercising the actual rendered panel_groups context rather than just the underlying constant.
  • Sigma similarity engine: not (not x) no longer wipes out a rule's entire atom set (2026-08-31): in sigma_atom_similarity/sigma_similarity/dnf_normalizer.py, _distribute_to_dnf's NotNode branch handled an AtomNode, AndNode, or OrNode child but let a NotNode child fall through to return []. Because AndNode distribution is a cross-product, one empty operand emptied the whole branch list, so a condition like selection and not (not filter1) produced an empty DNF: extract_positive_atoms returned an empty set and surface_score_from_dnf raised UnsupportedSigmaFeatureError("DNF has no branches (malformed or empty rule)") out of compare_rules. The failure was loud rather than silent -- the novelty layer routed the rule to needs_review, never to a truncated atom set -- but the message misdiagnosed a well-formed rule as malformed. NOT(NOT(x)) now collapses to x before any other NOT handling, matching the double-negation unwrapping the NOT(AND(...)) and NOT(OR(...)) sub-cases already did; odd nesting depth collapses to a single NOT. No backfill was required: all 3,780 rules in the live corpus were checked and none contain a nested NotNode, so sigma recompute-atoms is a no-op for the current corpus. Covered by 5 new tests in tests/sigma_atom_similarity/test_dnf_and_containment.py, each confirmed to fail against the pre-fix normalizer. The public replica dfirtnt/SIGMASimTest, which carries this engine verbatim and documents the old behavior as a known limitation, needs the same change.

[7.8.0 "Europa"] - 2026-08-19

Fixed

  • Externalized config snapshots are hydrated by every remaining consumer (2026-08-19): the 2026-08-16 hydration fix covered the eval-scoring paths but four others still read execution.config_snapshot as though it held the payload, when post-externalization it holds only {"snapshot_id": N}. Retry (POST /api/workflow/executions/{id}/retry) did config_snapshot.copy(), so a retried eval lost eval_run/subagent_eval/skip_*, silently became a normal run, and executed all seven extractors instead of the isolated target -- the same 7x provider spend the earlier fix removed from the workflow's own routing, reintroduced through the retry door. Age-based retention (purgeable_execution_ids) excluded eval runs via config_snapshot.contains({"eval_run": True}), which no longer matches any post-externalization row, leaving the eval corpus unprotected against the enabled daily cleanup_old_data job once those rows pass the 90-day window (measured on the live DB at the time of the fix: 135 externalized eval executions, 99 of them with no reference-table fallback, 0 yet old enough to be purged -- so the guard was defeated but nothing had been lost). exclude_evals=true on the executions list stopped excluding anything, and the list/detail API responses returned the bare pointer, so the executions UI fell back to default thresholds (?? 6.0, ?? 0.5) and dropped per-agent model names instead of showing what the run actually used. All four now hydrate; the two SQL-level filters test the referenced snapshot payload as well as the legacy inline JSON, and the list query eager-loads snapshot_record so hydration stays one query. Covered by tests/unit/test_execution_retry_snapshot_hydration.py (5) and two new externalized-snapshot cases in tests/integration/test_data_retention_service.py.
  • Subagent evals score to completion again after snapshot externalization (2026-08-16): since snapshot deduplication (commit 6e851943, 2026-08-11) an execution stores config_snapshot = {"snapshot_id": N} and keeps the real payload -- including subagent_eval, eval_run, and the skip_* flags -- on the externalized snapshot record. Several eval-path readers still read execution.config_snapshot raw, so subagent_eval came back None: the completion scorer (subagent_eval_service.update_subagent_eval_on_completion) returned early and left every evaluation row stuck pending (the v6887 cmdline symptom), and the workflow's own eval-routing ran all seven extractors instead of isolating the target subagent (7x provider cost). eval_bundle_service (MCP eval-bundle retrieval) likewise dropped agent_models/agent_prompts. All eval-flag reads now hydrate: a new _eval_snapshot accessor in agentic_workflow.py routes the eight in-workflow reads, the scorer hydrates directly, and eval_bundle_service gains _hydrated_config_dict (hydrate-or-raw so a legacy JSON-string snapshot still parses). Fixture content and skip-flags were already read from the hydrated config, so ground-truth integrity was never at risk. Covered by tests/unit/test_subagent_eval_snapshot_hydration.py, tests/workflows/test_agentic_workflow_helpers.py (externalized-snapshot isolation + _eval_snapshot), and tests/services/test_eval_bundle_service.py (externalized-snapshot bundle read). Pre-existing pending rows deleted via Clear Pending are not recoverable; re-run the eval after restarting the workers.
  • Agent-evals result polling survives a transient server outage (2026-08-16): a single failed poll -- e.g. the dev hot-reload restarting the web worker mid-run, surfacing as TypeError: Failed to fetch -- tore the whole poll loop down and painted a red "Error polling results" terminal state, abandoning a still-running eval. pollSubagentResults in agent_evals.html now treats a network throw and a bad HTTP status the same way: it shows a transient "Reconnecting to server" status and keeps retrying (~60s / 30 consecutive failures), resets the streak on any successful poll, and only surfaces a hard error after the cap. Covered by tests/ui/test_agent_evals_ui.py::test_poller_survives_transient_fetch_failures.
  • PUT /api/workflow/config refuses to write when no config row is active (2026-08-16): the autosave path intentionally sends only agent_prompts.ExtractAgentSettings, relying on the backend to forward-merge every other agent's prompt from the current active row. When _load_active_config_for_write found no active row -- the "still latent" gap noted in the 2026-08-11 advisory-locking fix -- the merge fell back to just the fields that one request sent, silently dropping the other 9 agent prompts from the new "active" row; every later partial autosave then forward-merged from that already-gutted row and could never restore what it didn't resend (confirmed via config rows 5396 and 7082-7108, the latter leaving the live extractor pipeline running with zero prompts and zero observables for hours). The endpoint now distinguishes a genuine fresh install (no config rows at all -- safe to seed defaults) from rows existing with none active (unexpected/racy -- returns 503 and refuses the write). Also fixed a masking bug found while adding this: the route's outer exception handler had no except HTTPException: raise guard, so every HTTPException raised inside it -- including this new 503 -- was being silently rewritten into a generic 500. Covered by tests/api/test_workflow_config_api.py::test_update_with_no_active_row_refuses_partial_write.
  • Per-group Sigma generation steers toward its own logsource category (2026-08-16): generate_sigma_node splits observables into per-logsource groups and discards any generated rule whose logsource.category doesn't match the group, but the prompt never told the model which category it was generating for -- so every group returned roughly the same process_creation spread and had most of it thrown away (measured: 35 generated / 10 kept, 71% dropped, with the registry_event and scheduled_task groups contributing zero rules each despite having observables). New _category_sigma_guidance in sigma_generation_service.py, appended to the per-group prompt the same way as the existing Linux platform guidance, states the group's target logsource.category/product and instructs the model not to emit rules for any other category. Covered by tests/services/test_sigma_generation_service.py::test_generate_sigma_rules_injects_category_guidance.
  • Evals2 item-level scoring restored for structured extractors, with dry-run-first historical repair (2026-08-16): the item scorer compared ground truth against generic item fields, so registry/service/scheduled-task/process-lineage/network/hunt-query results -- whose only generic field is a stringified Python dict in value -- always scored zero, and scoring only ran at completion, leaving historical completed rows permanently "count only". eval_item_scorer.score_items now builds a per-agent canonical identity from real schema fields (registry hive\key with hive-abbreviation canonicalization, service name, parent -> child lineage, defanged indicator, hunt-query text) and resolves multi-candidate items ground-truth-aware; both scoring call sites and the workflow completion path route structured items through it (subagent_eval_service, evaluation_api). New POST /api/evaluations/subagent-eval-rescore?subagent=<name|all>&apply=false repairs completed count-only rows from each record's retained output against the ground truth stored on the record (no disk reload, no ground-truth drift), never re-running a paid LLM and reporting scorable vs unrepairable-no-output per agent; it is dry-run by default and idempotent. Evals2 now distinguishes an unscored row (has ground truth, predates scoring) from a count only row (no ground truth), adds a dry-run-first Rescore Completed control, and clarifies that "Latest (all runs)" may combine records across runs and config versions. Covered by tests/services/test_eval_item_scorer.py (+11), tests/api/test_subagent_eval_rescore.py (6), and tests/ui/test_agent_evals2_rescore_ui.py (3).

Added

  • Clear Pending control on the Sigma evals page (2026-08-16): a Clear Pending button deletes Sigma evaluation rows stuck in pending via DELETE /api/evaluations/sigma-eval-clear-pending, mirroring the existing subagent-eval clear-pending control. The deletion is mandatorily audited (ACTION_EVAL_RECORDS_CLEARED, recording the deleted row ids). Covered by tests/api/test_sigma_eval_clear_pending.py and tests/unit/test_sigma_evals_page.py.
  • Role-specific Docker runtimes and a dedicated maintenance boundary (2026-08-12): Compose now targets purpose-built images for the web app, scheduler, ingest worker, workflow worker, and semantic tools rather than one monolithic runtime. The web UI no longer has Docker CLI/socket access; its backup and restore requests go to an internal-only, shared-token-authenticated maintenance service that allowlists fixed scripts and is the sole Docker-socket holder. setup.sh generates the required MAINTENANCE_API_TOKEN for new and retained .env files. Backup creation now shows a persistent running, success, or failure state in Settings instead of only a transient notification.
  • Queue deep-links to one workflow job (2026-08-10): /api/sigma-queue/list accepts a workflow_execution_id query param and narrows both the item set and the total to that job's rules. The executions tab's "N rule(s) queued for review" link now navigates to /workflow?jobId=<id>#queue, and the queue page reads jobId and filters to only that job's rules, showing a "Showing rules from job N" banner with a Clear filter button (clearQueueJobFilter) that removes the param and reloads the full queue. The filter is URL-driven only -- no manual entry field -- and survives queue refresh, pagination, and the status filter. Covered by tests/api/test_sigma_queue_list_api.py (2 new tests).
  • Subscription-backed Codex workflow provider (2026-08-08): Workflows can use a deployment-managed ChatGPT subscription through Codex app-server instead of an OpenAI API key. The provider is opt-in (WORKFLOW_CODEX_ENABLED), uses the shared codex_auth volume, exposes a non-inference connectivity check at POST /api/settings/codex/test, and creates an ephemeral read-only Codex workspace for each workflow inference request. The workflow provider picker lists the models visible to the managed Codex login.
  • Codex quickstart presets for the gpt-5.6 family (2026-08-10): three new full-config presets in config/presets/AgentConfigs/quickstart/ route every workflow agent to the Codex subscription provider — Quickstart-codex-gpt-5.6-sol.json, Quickstart-codex-gpt-5.6-terra.json, and Quickstart-codex-gpt-5.6-luna.json. Each is cloned from the OpenAI gpt-5 baseline with all 10 model-bearing agent sections retargeted to provider codex / the named model, and each passes the v2 schema round-trip plus the parametrized quickstart quality suite (clean import warnings, complete base prompts, deterministic temperatures, to-legacy prompt survival). Docs tables updated in config/presets/AgentConfigs/README.md and docs/getting-started/configuration.md.
  • Config version list: expand/collapse full version details in the restore modal (2026-08-10): every row in the config version list gains an Expand button that lazy-loads /api/workflow/config/version/{id} and renders the stored thresholds, agent models, and prompts through renderWorkflowConfigDisplay, with Collapse to tear the view back down. Companion Playwright coverage (workflow_config_versions.spec.ts) asserts the expand -> data -> collapse round-trip.

Removed

  • 8 dormant/dead DB subsystems, plus a 3-month-orphaned source-healing cleanup (2026-08-10): a system-health audit (Todoist 6h77r89HmgXhXhxV) found 8 tables that were either always empty or stopped receiving writes months ago. All 8 investigated by tracing actual write paths, not just row counts; mcp_write_confirmations confirmed working as designed (untouched); the other 7 confirmed deprecated-by-design and removed along with their backing code: chat_logs (dead since the /chat RAG Search UI removal), simhash_buckets (dedup moved onto articles.simhash/simhash_bucket columns), article_sigma_matches (only writer was the legacy manual /generate-sigma endpoint's persistence step, never called by the live autonomous workflow), eval_runs/eval_preset_snapshots (an abandoned Langfuse-experiment eval-run tracker scaffold), agent_evaluations (superseded by subagent_evaluations/sigma_evaluations; removed the orphaned /evaluations page tree it exclusively backed), url_tracking (HTTP conditional-GET caching scaffolded but never wired to the fetcher), and observable_model_metrics/observable_evaluation_failures (a complete pipeline behind a live API router with no UI trigger anywhere). Also finished an incomplete 2026-05-02 cleanup: sources.healing_exhausted/healing_attempts, the healing_events table (156 rows), and 7 SOURCE_HEALING_* app_settings keys were dropped from models.py but the migration that would apply it to prod was deleted before ever running -- live-orphaned on prod for 3 months. Applied via scripts/migrate_drop_dormant_subsystems.py (idempotent, existence-checked); pre-drop backup retained.

Changed

  • Langfuse trace lookup migrated off the deprecated GET /traces endpoint (2026-08-19): both trace-id resolvers called api.trace.list(session_id=..., order_by="timestamp.desc"), which Langfuse Cloud removes on 2026-11-16 -- after that date the workflow debug deep-links and the MCP eval-bundle export would have silently degraded to "no trace found" rather than failing loudly. A new shared find_trace_id_for_session in src/utils/langfuse_client.py resolves the same value through GET /v2/observations, filtering on the sessionId column and taking the first row (v2 always sorts startTime descending). Only to_start_time is bounded; from_start_time is deliberately left open, because debug links and eval-bundle exports can be requested for a run from any point in time and a rolling window would silently break lookups for older executions -- a live check confirmed the sessionId filter is selective enough to answer in under a second unbounded. EvalBundleService and get_langfuse_trace_id_for_session both now call the shared helper instead of duplicating the lookup inline. Two related v4 corrections ride along: score_langfuse_trace now calls client.create_score(...) (the v4 name; client.score(...) no longer exists), and the API-client build failure in _get_langfuse_api logs at warning rather than debug, since it is the only signal that both trace resolution and eval-bundle export have gone dark. Covered by tests/utils/test_langfuse_v4_api.py, tests/utils/test_langfuse_reset.py, and tests/services/test_eval_bundle_service.py.
  • Langfuse default host consolidated into one constant and corrected to the US region (2026-08-19): the fallback host was hardcoded in seven places, and five of them said https://cloud.langfuse.com (EU) while this deployment's project lives in the US region -- so any install that never set LANGFUSE_HOST explicitly pointed at the wrong Langfuse deployment, where the project's API keys do not authenticate. All call sites in langfuse_client.py, ai.py, evaluation_api.py, workflow_executions.py, and scripts/run_cmdline_count_eval.py now import a single LANGFUSE_DEFAULT_HOST, and the Settings placeholder and JS fallbacks in settings.html were corrected to match. docs/guides/langfuse-setup.md documents the region split and that EU and US keys are not interchangeable.
  • langfuse pinned to 4.14.4 (2026-08-19): bumped from 4.3.1 in pyproject.toml, requirements.txt, and uv.lock. tests/config/test_runtime_dependency_contract.py asserted the literal pin langfuse==4.3.1; since that test's actual contract is placement -- langfuse must be a base dependency, never in the workflow group, so the web image installs it -- it now matches on the langfuse== prefix and cannot drift on the next bump.
  • Faster, quieter startup schema reconciliation (2026-08-16): the idempotent ADD COLUMN IF NOT EXISTS ensures in create_tables now skip via a lock-free information_schema pre-check when the column already exists, so a healthy DB no longer queues each no-op ALTER for ACCESS EXCLUSIVE and waits out the 3s lock_timeout under contention (observed as ~9s added to every reload while an eval held subagent_evaluations); the lock_timeout safety net still bounds genuinely-new columns. Separately, schema drift limited to missing indexes/foreign keys now logs a single WARNING rather than a per-boot ERROR wall -- adding those FKs is commonly blocked by historical orphan rows that need an operator decision -- while app-breaking drift (missing tables/primary keys/columns) still logs a loud, itemized ERROR. Covered by tests/test_database.py::TestDatabaseManager::test_create_tables_precheck_skips_existing_add_columns and tests/unit/test_schema_drift.py::TestLogDriftNeverRaises::test_foreign_key_only_drift_is_a_single_warning, plus tests/database/test_async_manager.py::test_create_tables_precheck_skips_existing_add_columns and ::test_create_tables_precheck_failure_falls_back_to_all_ddl for the async create_tables used by the live web startup -- including the fallback that re-attempts every ADD COLUMN when the information_schema pre-check itself errors.
  • Removed misleading LM Studio embedding plumbing and corrected its documentation boundary (2026-08-08): LM Studio remains an optional local LLM provider, not an application or embedding dependency. Embeddings run locally through EmbeddingService and sentence-transformers. Removed the unused LMSTUDIO_EMBEDDING_URL / LMSTUDIO_EMBEDDING_MODEL Compose, setup, Settings, startup, and routing paths; deleted the dead /api/lmstudio-embedding-models endpoint and embedding-URL helpers/tests; and updated installation, configuration, Docker architecture, model-selection, and provider docs accordingly.

Added

  • Save-time prompt validation on the workflow config (2026-08-15): POST /api/workflow/config/prompts/validate scans the complete agent_prompts blob a Save is about to persist and returns human-readable warnings. The new _scan_missing_extractor_prompts reports enabled extractors that have no prompt at all — invisible to the existing preset scanner, which only inspects entries that are present, and the precise gap that let a prompt-less config save silently. Disabled extractors are exempt. The Save button runs the check before its PUT and, on warnings, requires explicit confirmation through a modal listing each issue; the check is advisory and never blocks a save if it errors. This endpoint is separate from /config/preset/validate, which also serves sub-agent presets that legitimately carry a single prompt. Covered by tests/unit/test_workflow_config_prompt_validation.py (9 tests).
  • Evidence-First Extractor Routing architecture proposal (2026-08-15): docs/architecture/evidence-first-extractor-routing.md and docs/superpowers/specs/2026-08-13-evidence-first-extractor-routing-build-spec.md describe routing evidence (not article-level platform) to specialized extractors via stable source blocks, deterministic lane detection, and bounded semantic adjudication. Linked from mkdocs.yml.

Changed

  • Sigma eval rejects a config with missing enabled-extractor prompts before dispatch (2026-08-15): POST /api/evaluations/sigma-eval/run now validates every enabled extractor's prompt body in the active config (extraction has no prompt-file fallback). A config missing or carrying invalid JSON for an enabled extractor is rejected with 422 and lists the offending agents, instead of launching a run that completes with zero rules and leaves its evaluation rows permanently pending. Disabled agents are exempted via ExtractAgentSettings.disabled_agents.
  • OS detection no longer overrides a low-confidence Unknown to Windows (2026-08-15): removed the platform-inference override that forced operating_system to Windows when Windows was the highest-similarity OS, and added a confidence != "low" guard so weak similarity evidence cannot bypass the classifier's evidence floor. Article-level platform metadata stays descriptive.
  • Subagent eval persistence extracted into a shared service (2026-08-15): update_subagent_eval_on_completion and its helpers moved from the workflow worker into src/services/subagent_eval_service.py, which the web-only image can import without LangGraph. The Evals2 pending-record backfill route now uses the shared service, so backfill completes on web images that lack the worker module. Workflow imports keep private backward-compatible aliases.

Fixed

  • Preset import no longer discards its own prompts 400 ms later (2026-08-15): importing a quickstart preset appeared to load, then showed an empty prompt panel — and saving from that state persisted a config with no extractor prompts, which made every workflow run complete with zero observables and zero rules while reporting success (all 7 sub-agents logged prompt not found in workflow config, skipping). applyPreset() loads prompt bodies into form state and then triggers autoSaveModelChange(); autosave deliberately transmits only ExtractAgentSettings, so its response could not contain the freshly imported prompts, and performAutoSave() replaced form state with that response wholesale. A new pendingPromptAgents set tracks prompt bodies held in form state but not yet persisted — populated by applyPreset / applySubAgentPreset, re-applied on top of the autosave response, and cleared by an explicit Save. Autosave still refuses to resurrect stale page-load copies; only genuinely pending edits are protected. Verified in-browser: without the fix 0 of 9 preset prompts survived the autosave, with it all 9 did. The server side was not at fault — /config/preset/to-legacy converts the preset correctly and _merge_agent_prompts already preserved siblings on a partial PUT.
  • Sigma generation no longer caps Codex runs at one rule, and Phase 4 expansion stops self-destructing (2026-08-15): three defects combined to drop most groundable TTPs from observable-rich articles. (1) _is_reasoning_model only recognized provider == "openai", so Codex-provider models fell through to the 800-token non-reasoning budget — and because max_tokens reaches Codex as a textual output-length instruction, that literally told the model to stop after roughly one rule; codex now shares the reasoning budget. (2) _build_expansion_prompt re-embedded the full article and the complete observable dump underneath the expansion header, ballooning past 75K chars; the expansion turn is observable-driven, so the observables section is now scoped to the uncovered categories (original 0-based indices preserved, keeping observables_used valid), the article excerpt is capped at EXPANSION_MAX_CONTENT_CHARS, and the whole prompt at EXPANSION_MAX_PROMPT_CHARS. (3) A single oversized JSON-RPC line tripped asyncio's 64 KiB StreamReader limit in the Codex adapter (Separator is found, but chunk is longer than limit), failing the turn; the subprocess now spawns with a 16 MiB buffer and an overrun surfaces as a CodexAppServerError rather than a bare ValueError. The Phase 4 try/except also records the failure reason in metadata.expansion_error instead of reporting a clean success, and the workflow's per-group merge carries that field onto sigma_generation_groups[].expansion_error in the execution record — a non-fatal expansion failure never reaches the group's error channel, so without it a group that lost every expansion rule was indistinguishable from a fully successful one.

Security

  • The Docker socket is now the maintenance service's alone, in every stack (2026-08-19): the role-specific runtime split removed the socket from web in docker-compose.yml, but docker-compose.dev2.yml, docker-compose.mobile.yml, and docker-compose.mobile-simple.yml still mounted /var/run/docker.sock into their web service, so the release's "the web UI no longer has Docker CLI/socket access" held only for the main stack. dev2 was the sharp case: it publishes web on a host port with AUTH_MODE unset (which resolves to disabled), putting an unauthenticated HTTP surface in the same container as host-root Docker access. The mount is removed from all three -- nothing in them needs it now that backup/restore is proxied through the token-authenticated maintenance service, and the residual docker exec callers were already inert in the main stack. tests/config/test_docker_compose_runtime_contract.py::TestDockerSocketBoundary now parametrizes over every docker-compose*.yml and fails if any service other than maintenance mounts the socket.
  • pip-audit follow-up: pypdf, pymdown-extensions, GitPython bumped to clear new advisories (2026-08-10): three advisory batches landed since the 2026-08-06 triage. pypdf 6.14.2 -> 6.15.0 closes CVE-2026-71852/71870 on the reachable PDF-parse path (src/web/routes/pdf.py); pymdown-extensions 11.0.0 -> 11.0.1 closes CVE-2026-67422 (docs rendering only); GitPython 3.1.57 -> 3.1.58 closes five GHSA advisories (still zero import sites, still flagged for removal). uv lock touched exactly these three stanzas and nothing else. Verified: pip check clean, pip-audit now reports only the already-recorded no-fix diskcache PYSEC-2026-2447, mkdocs build --strict green under the pymdown patch, src/web/routes/pdf.py imports under pypdf 6.15.0, quickstart quality suite 96/96.
  • SSO edge no longer trusts the client-supplied Host header (2026-08-07): deploy/sso/nginx.conf.template interpolated $host — which is the client's Host request header — into the port-80 → 443 redirect, the error_page 401 login bounce (rd=$scheme://$host$request_uri), and the Host / X-Forwarded-Host headers proxied to both oauth2-proxy and the app. A crafted Host: could therefore steer the post-authentication redirect target and control the hostname the upstreams saw. All six sites now use $server_name, which resolves to the configured __SSO_HOSTNAME__ and cannot be influenced by the request. The pinning rule is documented in the template header alongside the existing note that direct access to web:8001 must be blocked at the network layer for this edge to mean anything.
  • Observable-evaluation route constrains model_version and model_path before they reach Path() (2026-08-07): POST /api/observables/evaluation/run took both values from the request body and passed them through to the inference loader, where model_path flows into Path(...).exists() and model_version is joined into Workshop/models/<key>/<version>. Neither was validated. model_version is now matched against a plain-identifier charset ([A-Za-z0-9][A-Za-z0-9._-]{0,63}) and model_path must resolve() inside the Workshop/models root, checked with relative_to() — the same containment pattern the backup-restore path already uses. Both raise 400 rather than 500. observable_training.py separately re-raises its 500 with from None so an internal exception's traceback cannot reach the client.
  • Allure report container hardened (2026-08-07): docker-compose.allure.yml gains security_opt: no-new-privileges:true and read_only: true, and KEEP_HISTORY_LATEST drops from 10 to 1.
  • Scanner findings adjudicated and annotated in place (2026-08-07): five CodeQL alerts and two Semgrep findings were reviewed and confirmed as false positives against existing controls, then suppressed with the reasoning recorded at the call site — safe_fetch.py (py/full-ssrf: _is_safe_image_url re-validates scheme and resolved IP before every request and after every redirect hop), backup.py (py/command-line-injection: list-form subprocess.run, no shell=True, every argument pre-validated), two logger-credential-leak hits in llm_provider_clients.py (the logged values are a model name, message/token counts, and thresholds — the full-payload dump is separately gated behind isEnabledFor(DEBUG) and truncated), and one in test_content_cleaner.py (py/incomplete-url-substring-sanitization on a test assertion). Two follow-up commits were needed purely because the markers were initially placed where the scanners do not read them: CodeQL requires the annotation on its own line above the flagged call rather than trailing it, and the nosemgrep and codeql[...] markers had to share one comment.

Fixed

  • Operational retention windows are now 90 days (2026-08-11): source-check history and safe-to-purge workflow executions use the same 90-day default. The nightly cleanup job can be enabled through scheduled-job settings; it continues to retain evaluation and referenced executions regardless of age.
  • Workflow execution configuration snapshots are content-addressed (2026-08-11): new normal, evaluation, and HTTP retry executions store their immutable configuration once in agentic_workflow_execution_snapshots, keyed by the canonical SHA-256 snapshot hash, and retain only a reference on the execution row. The runtime hydrates that reference before workflow execution and preserves legacy inline JSON fallback. scripts/migrate_execution_snapshot_schema.py is report-only by default and provisions the existing-database table, nullable reference, index, and foreign key only under explicit --apply; then scripts/migrate_execution_snapshots.py reports eligible historical rows by default and backfills references only under explicit --apply, in 100-row batches to stay bounded on JSONB-heavy tables. Neither script deletes inline JSON. MCP retries remain on the legacy path until an async store variant is added. Covered by snapshot-store, trigger, runtime, MCP write, schema-plan, and bounded-backfill tests.
  • Workflow config saves now serialize safely (2026-08-11): PostgreSQL advisory locking serializes every active-config write path, preventing concurrent saves from allocating the same version or observing no active row mid-replacement. Autosave now submits only ExtractAgentSettings instead of replaying stale prompt bodies, while explicit Save preserves its complete-payload behavior and strips display-only model values from prompts. Fresh schemas declare agentic_workflow_config.version unique; the schema reconciler deliberately blocks applying that index to existing duplicate rows until an operator-approved cleanup. Unit regressions cover the lock, prompt merge, schema contract, and both UI payload paths.
  • Sigma validation now enforces real pySigma semantics (2026-08-11): validate_sigma_rule now parses generated and repaired YAML through pySigma and forces condition evaluation, rejecting unknown modifiers, malformed conditions, undefined selections, and invalid metadata types before the existing Huntable grounding and quality policy runs. The obsolete logsource-category allowlist no longer rejects valid Sigma categories such as webserver. Validation errors retain their pySigma error type for repair prompts and the UI now identifies the combined pySigma semantic and Huntable policy gates. Differential tests cover each boundary across generation, repair, and queue validation.
  • Trace-bundle export returns a plain Response instead of streaming a BytesIO (2026-08-10): GET /api/workflow/executions/{id}/export-trace-bundle was returning StreamingResponse(io.BytesIO(payload)), which makes Starlette advance one pretty-printed JSON line at a time through the threadpool and can leave browser downloads spinning for minutes on larger bundles. The payload is already materialized in memory, so the endpoint now returns it directly as Response with the same Content-Disposition attachment header.
  • sigma_enrich "Enrich Further" iteration-2 click retargeted to the prompt modal's confirm button (2026-08-10): the 2026-08-07 strict-mode fix replaced the submit with #enrichFurtherBtn, but by the time that click runs the ModalManager.prompt "Enrich Rule" modal is open on top — the button resolves yet is covered by the modal, so Playwright timed out waiting for it to become visible and the test failed (caught by the full-suite run). Submit now targets the prompt modal's own .confirm-btn via [id^="_prompt_"], the same pattern sigma_queue_lifecycle.spec.ts already used. sigma_enrich.spec.ts is 6/6 green and the full suite (5825 tests) passes.
  • Two Playwright specs failed on strict-mode selector ambiguity, not on product behavior (2026-08-07): in sigma_enrich.spec.ts, getByRole('button', { name: 'Enrich' }) matched both the page-level Enrich button and "Enrich Further", because role-name matching is substring-based; the iteration-2 assertion now clicks the ModalManager.prompt modal's confirm button ([id^="_prompt_"] .confirm-btn) — the initial #enrichFurtherBtn retarget was wrong because that button sits hidden behind the open prompt modal and can never be visible to the click (see the 2026-08-10 Fixed entry). In sigma_queue_lifecycle.spec.ts, the approve button was located by filtering #queueTableBody tr on hasText: String(queueId), which matches any row whose content contains that digit sequence — row 6 containing a "7" is enough to resolve two rows and trip strict mode. It now targets #queue-row-${queueId} by DOM id. Neither change alters what the tests assert.
  • lmstudio_chat_url_candidates dedupe rewritten as an explicit loop (2026-08-07): the previous one-liner relied on seen.add(candidate) returning None inside the comprehension's condition. It deduplicated correctly — the commit message overstates it as a repair — but a side effect in a comprehension predicate is exactly the construct linters flag and readers misread. Behavior is unchanged.
  • ruff format applied to 8 files flagged by the CI format check (2026-08-06): formatting only, no semantic change. The bulk of the churn is tests/unit/test_ground_truth_files.py, where the REACHABILITY_EXEMPTIONS tuple keys expand from single long lines to multi-line form; every exemption and its justification string is byte-identical.
  • UX contract audit findings resolved across all templates (2026-08-14): closed the failing categories from docs/audits/ui-contract-audit-2026-07-20.md. Added role="dialog"/aria-modal="true"/aria-label to 17 modal overlays lacking them (agent_evals, agent_evals2, article_detail, settings, sources, workflow, diags loading overlay) and aria-label to both workflow toggle switches. Removed margin+padding stacking on 5 elements (px-7→px-6, mb-7→mb-6, py-7→py-6, pb-8→pb-6). Replaced 57 em-dashes with -- and 27 ellipses with ... in visible text, JS strings, and comments (ASCII-only contract, Rule 11). scripts/check_ui_contract.py stays green; the grandfathered card-stack/bare-hex baseline debt in article_detail.html, agent_evals.html, and workflow.html is intentionally left in place and not in scope of this pass.
  • Retired agent_evals.html / workflow.html bare-hex debt and cleared lint findings (2026-08-14): finished the hex portion of the UX-audit baseline. Added six status/border color tokens to theme-variables.css (--status-danger-text, --status-warning-text, --status-info-text, --status-success-text, --border-amber; #10b981/#f59e0b already mapped to --action-execute/--border-warning) and replaced all 9 inline bare-hex colors in agent_evals.html (8) and workflow.html (1) with var(--token). Lowered scripts/check_ui_contract.py BASELINE hardcoded_hex for those two files to 0. Separately ran ruff --fix/ruff format on tests/integration/test_data_retention_service.py, src/services/execution_snapshot_store.py, and tests/unit/test_mcp_write_tools.py, and removed an unused sample_count parameter from register_version() in scripts/seed_model.py. ruff check/ruff format/vulture clean on the touched files; the article_detail.html card-stack (medium) item is intentionally deferred.

[7.7.0 "Europa"] - 2026-08-06

Changed

  • Data retention is real now: cleanup_old_data was a placebo task (2026-08-06): the Celery task logged "Cleaning up old data...", returned {"status": "success"}, and deleted nothing — the actual implementation lived on DatabaseManager.cleanup_old_data with zero callers. Enabling the scheduled job reported success while the tables it was supposed to prune kept growing. New src/services/data_retention_service.py implements age-based retention for source checks (180d), URL-tracking cache (90d), and unreferenced non-eval workflow executions (90d), plus stale-execution reaping: runs stuck in pending/running with no updated_at activity for 6h are marked failed with the reason recorded. Execution purges are guarded — eval runs and any execution referenced by the Sigma queue or an evaluation table are retained at any age, because sigma_rule_queue.workflow_execution_id is ON DELETE CASCADE and an unguarded age purge would silently take reviewable Sigma rules with it. Windows are read from app_settings (operator-tunable without a deploy, positive-integer validated with fallback). Deletes run in 5,000-row batches so a first pass against a long-unpruned table does not hold one transaction open across tens of thousands of rows. The task now supports dry_run and reports actual per-policy counts. DatabaseManager.cleanup_old_data (dead code) removed. Covered by tests/integration/test_data_retention_service.py, which builds a scratch database from the ORM models on purpose — the cascade and JSONB-containment rules cannot be reproduced against a mocked session.
  • Dependabot triage: 5 real bumps, and the 60 "high" alerts explained (2026-08-06): git push surfaced 77 open alerts (60 high) on the default branch. The multiplier is per-manifest duplication — every CVE is reported once each for uv.lock, requirements.txt, and pyproject.toml — but the deeper reason pip-audit and Dependabot disagreed is branch drift, not manifest/venv drift: Dependabot scans main, and europa-dev is 160 commits ahead with Pillow already at 12.3.0 (the exact first-patched version, ~33 alerts) and torch at 2.13.0 (1 low). Manifest pins and the installed venv were verified byte-identical for all eight flagged packages — there was no stale-pin problem to fix. Those alerts close when europa-dev merges; nothing to bump. Genuinely unfixed here, and bumped: GitPython 3.1.50 → 3.1.57 (10 alerts), pypdf 6.13.3 → 6.14.2 (4, and the reachable one — src/web/routes/pdf.py parses uploaded PDFs), cryptography 48.0.1 → 50.0.0 (1 high, not in the original triage — the advisory landed after the task was filed; transitive under Authlib/joserfc, i.e. the auth stack), langgraph-checkpoint-postgres 3.1.0 → 3.1.1, and pymdown-extensions 10.21.3 → 11.0.0 (docs rendering only). mcp needed nothing — already at 1.28.1, above the 1.27.2 fix. Reachability finding on GitPython: it has zero import sites in the entire repo and zero reverse-dependencies. sigma_pr_service shells subprocess.run(["git", ...]), not GitPython, so none of its 10 advisories were ever reachable; it was bumped anyway (cheap, clears the alerts) and flagged for removal separately. uv lock touched exactly the five stanzas and nothing else. Verified: pip check clean, pip-audit down to only the already-recorded no-fix diskcache (torch and Pillow no longer appear at all), mkdocs build --strict green under the pymdown major bump, the auth stack imports under cryptography 50, run_tests.py unit 4761 passed / 0 failed, and tests/api 512 passed. Four eval-config tests fail in a batched run and pass individually; that ordering pollution was confirmed pre-existing by reproducing it identically on the old pins before re-installing the new ones.
  • Eval diagnosis is now MCP + agent-skill only; the server-side LLM call is gone (2026-08-03): diagnosis previously POSTed a slim eval bundle plus the extractor contracts to a configured frontier model from the server, billing a provider API key on every click of the web Diagnose button (and on every diagnose_eval_bundle MCP call). That path is removed. EvalDiagnosisService no longer depends on LLMService or Langfuse tracing; it now assembles the evidence packet, strictly validates agent-authored diagnosis JSON, and atomically persists approved results. Two MCP tools replace diagnose_eval_bundle: get_eval_diagnosis_context (read-only) and save_eval_diagnosis (caller-attested, non-idempotent write). Portable .agents/skills/huntable-eval-diagnosis guidance and a Claude compatibility copy drive the context -> reason -> propose -> confirm -> save loop. Removed: POST /api/evaluations/evals/{execution_id}/diagnose, the web Diagnose action, and Settings -> Evals Diagnosis Agent provider/model configuration. Kept: the three read-only diagnosis GET endpoints, rendered diagnosis panels, and [dx N] badges. Saved files gain schema_version: eval_diagnosis_v2, source: mcp_agent, and authored_by; legacy provider/model attribution still renders. The replacement Diagnose via MCP help affordance shows a prompt for the selected execution and agent.

Fixed

  • 25 of 29 tables had silently lost their primary keys, foreign keys, and indexes; schema drift is now reconciled and detected (2026-08-06): create_all() defaults to checkfirst=True, so it skips any table that already exists and never reconciles that table's constraints or indexes — and reports success either way. Tables created by the hand-rolled scripts/migrate_*.py helpers therefore kept their columns and id sequences but never acquired what models.py declares. An audit of the live database found 18 tables with no primary key at all (including source_checks at 57k rows and sigma_rules at 3.7k), 19 of 22 declared foreign keys absent, and 36 live indexes against 254 declared, with 163 orphan rows across 6 relationships that a foreign key would have blocked or cascaded. Two pieces ship. scripts/migrate_reconcile_schema.py diffs models.py against the live catalog and emits the missing DDL: report-only by default, indexes built CONCURRENTLY, and primary keys, unique indexes, and foreign keys preflighted for duplicate ids, NULLs, and orphans — blocked rather than attempted when the data cannot support them. Foreign keys additionally require an explicit flag, because the remedy for an orphan is deleting rows and that is an operator decision, not a migration's. src/database/schema_drift.py is the detector, called from both create_tables() paths, because reconciling once fixes the symptom while the next hand-rolled migrate_* script reintroduces it just as quietly; it logs ERROR with the remediation command, diffs catalog-only (no table scans) so it stays cheap at startup, and log_drift never raises — a diagnostic that can take the app down is worse than the drift it reports. Startup deliberately does not apply DDL: both create_tables() paths already bound their idempotent ALTERs with a short lock_timeout because a pending ACCESS EXCLUSIVE request blocks all readers and can freeze the app, and CREATE INDEX CONCURRENTLY cannot run inside a transaction at all. Vector columns are excluded throughout — a B-tree over Vector(768) exceeds the 2704-byte row limit and breaks every INSERT (see migrate_pgvector_indexes.py); HALFVEC and SPARSEVEC are excluded for the same reason, but BIT was removed from that set after review, since PostgreSQL BIT is ordinary B-tree-indexable and excluding it would have silently skipped a legitimate declared index — the exact failure mode the detector exists to catch. Applied to production as 113 DDL statements: 16 primary keys, 84 indexes, 13 foreign keys. Verified live, the catalog went 14 → 30 primary keys, 36 → 137 indexes (the surplus over the 84 created is the index each new primary key brings with it), and 3 → 16 foreign keys. Covered by tests/unit/test_schema_drift.py, tests/unit/test_migrate_reconcile_schema.py, and tests/integration/test_schema_reconciliation.py, the last of which builds a scratch database reproducing the production defect — columns and id sequences present, no declared constraints or indexes — and exercises detection of each gap type, preflight blocking on orphans and duplicates, foreign keys withheld without the flag, idempotency on a second pass, and an applied foreign key actually rejecting a new orphan. That integration coverage is not redundant: both bugs found while building the reconciler (a vector-exclusion guard that was silently dead, and a crash on column drift) came from a one-off manual run, and the mocked unit tests passed clean through both. Its scratch database name is worker-scoped so parallel workers cannot drop it underneath each other, and it forces the sync driver because build_test_database_url returns TEST_DATABASE_URL verbatim when set, ignoring asyncpg=False. The drift mechanism and the reconciler are documented in docs/reference/schemas.md, under the Source Of Truth section where models.py is already declared canonical.
  • /api/articles/search was unreachable and is now the only route that can claim that path (2026-08-06): articles.py declares APIRouter(prefix="/api/articles") with a /{article_id} route, and routes/__init__.py includes articles.router before search.router. FastAPI matches in registration order, so /api/articles/search resolved to the id route and returned 422 int_parsing on the literal string "search" — api_search_articles in search.py had been dead code, unreachable from any client. The sibling literal paths /next, /previous, and /top were unaffected only because they are declared in the same router above /{article_id}; the cross-router one always lost. api_search_articles moved verbatim into articles.py as @router.get("/search") above the id route, matching how /next and /top already work. A first pass also added an :int path converter to every {article_id} declaration; that was reverted, because the converter changes the registered path template, and path templates are an authorization contract surface here — src/web/security/route_manifest.py and its tests key on /api/articles/{article_id}, so {article_id:int} broke test_destructive_article_routes_require_a_role. Enforcement itself was never at risk (the manifest rules glob on /api/articles/*), but diverging templates for a defense-in-depth win is a bad trade. The class of bug is instead closed by a test: tests/unit/test_route_shadowing.py resolves every literal path in the registered app against the real router and fails if the winner is not the route that declared it — catching cross-router ordering, which is the part no one can eyeball because it depends on the include_router sequence. Run across the whole app it reports no other shadowed route, and it was negative-controlled by re-registering the search route below /{article_id} and confirming it fails. No template JS or client called the endpoint, so no user-visible feature was silently broken. Verified live at :8001: the endpoint returns 200 with real results (50 matches for q=emotet) while /api/articles/{id}, /top, and /next still resolve. tests/api/test_article_list_db_error_surfaces.py gains the two cases its NOTE reserved — one asserting the path reaches the search handler, one asserting a failing list_articles surfaces a 500 rather than reporting zero matches.
  • Agent-authored eval diagnosis now fails closed and requires explicit save confirmation (2026-08-06): save_eval_diagnosis returns confirmation_required unless the caller attests that it obtained approval for that one write with confirmed_by_user=true; MCP annotations mark it writable, destructive, and non-idempotent so the host can enforce approval. The server records the attestation without claiming it can prove a human interaction. Each save must include the reviewed context packet's stable evidence digest; stale or cross-execution analysis is rejected. Persistence writes a durable hidden pending file, commits an attempted audit, atomically publishes, then commits terminal success; publication or terminal-audit failure removes the file and records failure when possible. Filename components are sanitized, unsupported agents are rejected, and strict schema validation rejects missing or unknown fields, type coercion, non-finite, fractional, or out-of-range values, empty evidence, and invalid priorities. The portable agent skill treats every context field as untrusted evidence, reports suspected prompt injection, proposes before saving, and requires fresh confirmation after validation or evidence failure.
  • Backup verification no longer passes an unrestorable backup; pruning can no longer delete every backup (2026-08-06): a coverage audit of the backup/restore system found the tests weighted towards parsing and config (_restore_common, BackupConfigManager, input_validation) while the code that produces and deletes backups was nearly untested — backup_database_v3.py had zero tests, backup_system.py had four (all volume-policy), and prune_backups.py had two. Three defects surfaced and were fixed. (1) apply_size_limit() in prune_backups.py evicted oldest-first until the total fell under max_size_gb with no floor, so a limit smaller than a single backup emptied the retention set entirely — reachable unattended, since backup_cron_service.py runs prune with --force --max-size-gb and the settings API validates only gt=0. The loop now stops at one remaining backup and warns instead. (2) validate_file_checksums() in verify_backup.py only ran when a checksum was recorded and downgraded a missing dump file to a warning, so verify_backup reported overall_valid: true for a backup with no database dump — including the {"errors": [...]}-only component shape that create_system_backup() writes when pg_dump fails. Missing file, zero-byte file, and a component declaring no filename are now hard failures. (3) validate_backup_structure() exempted docker_volume_* from the component-directory check but not database (also a file), emitting Component directory 'database' not found on every healthy system backup. Coverage went from 197 to 284 tests: retention math and dry-run semantics (tests/scripts/test_prune_backups.py), verification negatives including single-flipped-byte and truncated-dump detection (tests/unit/test_verify_backup_integrity.py), the producer side — gitignore filtering, always-include overrides, and a producer/verifier checksum contract (tests/unit/test_backup_system_manifest.py), the pre-restore snapshot and rollback path with a positive control proving the restore aborts before DROP DATABASE when the snapshot fails (tests/unit/test_restore_system_rollback.py), and an end-to-end round trip against a real PostgreSQL that seeds a scratch database, backs it up with the real backup script, verifies the artifact, restores it with the real restore function, and compares row counts and server-side content digests (tests/integration/test_backup_restore_roundtrip.py). The round-trip test also reproduces the dangling-FK case that motivated the NOT VALID rewrite and confirms the grandfathered constraint still rejects new violations; it runs only when the cti_postgres container is up, and every database it touches carries a backup_roundtrip_ prefix asserted before each create and drop.
  • Workflow execution configuration is now immutable (2026-08-04): normal executions stored an incomplete config_snapshot -- no agent_prompts (and therefore no ExtractAgentSettings.disabled_agents), no sigma_fallback_enabled, no auto_trigger_hunt_score_threshold. Because agent_prompts was part of run_workflow()'s completeness gate, that gate failed on every normal execution and the worker re-read get_active_config() at execution time, minutes or hours after dispatch. Four nodes also called get_active_config() directly: OS-detection adjudication, rank, Sigma generation, and similarity. Editing a prompt, model, or threshold therefore changed runs already queued or in flight, making results irreproducible and audit records inaccurate. New src/services/workflow_config_snapshot.py is the single builder: SNAPSHOT_CONFIG_FIELDS covers every behavior-affecting column (resolved prompts, models including per-agent *_provider keys, all five thresholds, all four toggles, config identity), stamps snapshot_schema_version, and hashes the canonicalized snapshot with SHA-256 into snapshot_hash. initiated_by is excluded from the hash so identical configuration triggered by different people compares equal; eval flags are hashed because they change behavior. Resolution and hashing happen before dispatch, persisted in the same transaction as the execution row. All four node lookups now read state["config"]; the only remaining get_active_config() calls are in run_workflow() at snapshot-resolution time. Legacy partial snapshots keep the old fallback behind snapshot_is_complete() and log an explicit non-reproducibility warning. The three eval snapshot builders in evaluation_api.py and both retry paths (HTTP route and MCP tool) route through the same builder; retries backfill only missing fields so their deliberate model refresh still wins, then re-hash. Three defects surfaced while writing the tests and were fixed: the snapshot aliased the ORM's live JSONB dicts (an in-place agent_prompts edit reached into a dispatched execution), config_id/config_version never resolved because the columns are named id/version, and the node accessors guarded on isinstance(state, dict) while LangGraph passes a pydantic WorkflowState -- which silently emptied the config on every real run. Covered by 33 tests in tests/services/test_workflow_config_snapshot.py.
  • Langfuse trace-bundle live-read compatibility (2026-08-02): the v2 Observations API defaults to core,basic, which omitted input/output/usage from eval-bundle enrichment even when those fields existed in live traces. The lookup now requests the required io, usage, and model field groups; the live audit confirmed 159 recent generations and preserved the cloud-run cost guardrail.

Added

  • MCP tools reachable from Docker MCP Gateway over streamable-HTTP (2026-08-06): the Gateway could not use this server at all. Huntable exposes MCP only over stdio, launched by scripts/run_mcp_server.sh, which shells out to docker compose run on the host — the Gateway is itself a container and has no such host. The Gateway does connect to remote streamable-HTTP MCP servers, so the same FastMCP instance is now servable over HTTP: new src/huntable_mcp/http_server.py wraps mcp.streamable_http_app() in a bearer-token ASGI middleware and adds an open /healthz for container and Gateway probes. Nothing about the tools changes — same registrations, same resources, same write risk tiers, execute_sql still permanently read-only. Transport selection moved into a shared src/huntable_mcp/cli.py so python -m src.huntable_mcp and the repo-root run_mcp.py take identical --transport stdio|http flags; stdio remains the default and .mcp.json is untouched. The endpoint fails closed: HUNTABLE_MCP_TOKEN must be set and at least 32 characters or build_app() raises before a socket is bound, because this transport puts execute_sql and the audited write tools on a TCP port. Token comparison is constant-time, the scheme is matched case-insensitively, and a 401 carries no WWW-Authenticate challenge so an unauthenticated caller learns nothing about token shape. Ships as the opt-in mcp compose profile (docker compose --profile mcp up -d mcp_http), published on 127.0.0.1 only, plus a Gateway server entry at config/mcp-gateway/huntable-server-entry.yaml and a setup guide at docs/guides/mcp-gateway.md. The entry was validated against the real CLI (docker mcp v0.43.1) by building a scratch catalog from it and inspecting the parsed result — remote type, streamable-HTTP endpoint, Authorization header, and the huntable.mcp_token → HUNTABLE_MCP_TOKEN secret binding all resolve; the scratch catalog was then removed. The documented commands are the ones that version accepts (catalog server add --server file://…), not the catalog import verb that no longer exists. Covered by 15 tests in tests/unit/test_mcp_http_server.py, weighted to the negative cases: no token, blank token, short token, missing header, wrong token, wrong scheme, near-miss tokens, and open health.
  • Routes -> service session-DI pattern (2026-08-04): the web layer built database sessions by hand in every endpoint and had exactly one Depends() across 37 route modules. src/web/dependencies.py now exports get_db_session (sync), get_async_db_session (async, backed by the async_db_manager singleton), and session_scope() for background work that outlives the request. src/web/routes/ml_hunt_comparison.py is the reference migration: six hand-built DatabaseManager() + try/finally blocks replaced by Depends(get_db_session), its one raw ORM query moved into ChunkAnalysisService.count_registered_model_versions(), and the async backfill switched to session_scope(). The import-lint rule is a ratchet test rather than a ruff banned-api rule (TID251 is global, and src.database is legitimate everywhere outside src/web/routes): tests/unit/test_route_db_layering.py AST-scans every route module -- catching in-function imports too -- against a 30-entry UNMIGRATED_ROUTE_MODULES allowlist, and separately fails if an allowlisted module is migrated but left on the list.

Changed

  • workflow.html Operator Console and expanded prompt editor extracted to a module (2026-08-04): the last extractable inline <script> block (former lines 2421-2871) moved verbatim to src/web/static/js/workflow/prompt-editor.js, dedented by the template's uniform 8-space indent and otherwise byte-identical. workflow.html drops 4,092 -> 3,646 lines, completing the extraction arc that started at 17,572. Loaded ahead of the inline shell and page.js to preserve DOMContentLoaded firing order, and kept a classic script so the global-scope contract with the remaining shell is unchanged.

  • MCP eval-retrieval tools (2026-08-01): added 4 new MCP tools in src/huntable_mcp/tools/evals.py -- get_eval_bundles_by_config (all completed bundles for a config run, optionally filtered by subagent), get_article_eval_bundle (bundle(s) for one article, with an optional workflow trace), get_workflow_execution_trace (a standalone workflow_execution_trace_v1 payload), and get_eval_run (a convenience entry point accepting a run label like v5139a, the recommended surface for the huntable-eval-retrieval skill). config_version accepts a bare integer or an operator label such as v5114a/v5114b to select a specific replicate run. Registered in stdio_server.py, documented in docs/reference/mcp-tools.md, unit-tested in tests/unit/test_mcp_eval_tools.py and tests/test_mcp_server_config.py.

  • claude-opus-5 added to the Anthropic model catalog (2026-08-01): registered in config/provider_model_catalog.json with its existing 1M-token MODEL_CONTEXT_TOKENS entry; pinned by a catalog-presence regression test.
  • Status-aware audit coverage for backup, model, embeddings, and evaluation routes (2026-07-28): privileged mutating endpoints on backup.py (create, restore, restore-from-file, cron add/remove), models.py (retrain, evaluate, rollback), embeddings.py (bulk rebuild, per-article embed), evaluation_api.py (run, run-subagent-eval, run-sigma-eval, clear-pending, backfill, export-bundle, diagnose), observable_training.py and observable_evaluation.py emitted no durable audit events, so a successful privileged mutation left no record of the actor. Note the route manifest already declared AuditRequirement.MANDATORY for /api/backup/* and /api/model/* while both files had zero audit call sites -- the guarantee was declared but unmet. Each path now records an attempted event before its non-transactional side effect and a terminal success/failure event after it, following the sigma_queue.py PR-submission precedent. Adds STATUS_ATTEMPTED plus backup.cron_updated, backup.cron_deleted, model.retrained, model.evaluated, model.rolled_back, embeddings.rebuild_requested, article.embedded, evaluation.* and observable_*.run_requested to the action vocabulary; the pre-reserved backup.created/backup.restored names gained emitters for the first time. New AsyncAuditService.record_out_of_band opens its own short-lived session for subprocess/worker-thread paths that have no caller-owned transaction to join; it is bounded by a 5s timeout and never raises, so a stalled or failing database costs an audit row rather than a hung or 500-ing privileged request. Sink choice follows path shape: eval record clear/backfill own a transaction and use record_mandatory so attribution is atomic with the mutation, while dispatch-only routes (Celery send_task, workflow trigger) commit best-effort because the side effect has already fired. Retrain and rollback capture ActorContext at the route boundary and hand it to the background thread so terminal events keep human attribution. Audit emission only -- nothing re-scrapes or mutates eval-article rows or config/eval_articles_data fixtures, pinned by a test asserting the eval helper never queries or writes anything but its own audit row. One consequence is recorded in code and in docs/guides/authentication.md: a full-database restore replaces audit_events too, so the pre-restore attempted row does not survive a successful restore; it is what remains when a restore fails or is killed partway.
  • Neutral eval-item state (2026-07-22): item-level subagent evaluation now supports a justification-bearing acceptable_items ground-truth list for genuinely defensible alternate readings. A neutral extraction is stored separately and excluded from both precision and recall denominators; it cannot duplicate an expected item, and schema tests require every neutral entry to carry a written justification. This is a score-comparability era boundary; re-baseline item-level metrics before comparing historical runs.
  • MCP eval bundle export and diagnosis tools (2026-07-21): added 4 new MCP tools (get_eval_bundle, diagnose_eval_bundle, list_eval_diagnoses, export_diagnosed_eval_bundles) in src/huntable_mcp/tools/evals.py so MCP clients can inspect full eval bundles, run LLM-powered diagnosis (mirroring the web Diagnose button), list saved diagnoses, and bulk-export diagnosed bundles by config version/subagent. Registered in stdio_server.py, documented in docs/reference/mcp-tools.md, unit-tested in tests/unit/test_mcp_eval_tools.py and tests/test_mcp_server_config.py.
  • Regression guards for already-shipped fixes (2026-07-17): a static test that every backup caller (the shell dispatchers plus src/cli/commands/backup.py) references only scripts that exist; an end-to-end contract test locking prune_backups.py --stats output to the /api/backup/list parser; API tests asserting src/web/routes/ai.py never logs key/token material; and unit tests pinning the auto-trigger hunt-score threshold default to 100 (opt-in) across WorkflowConfigResponse and WorkflowTriggerService.
  • cut-release skill: container rebuild check (Phase 2c) (2026-07-17): before running release_cut.py, the skill now diffs the release branch against main for paths outside the bind-mounted src/ volume (Dockerfile, docker-compose.yml, dependency/lockfiles, COPY'd packages) and asks the operator to confirm they rebuilt and re-tested against the rebuilt image -- closing the gap where a change only takes effect after docker compose build could ship untested.

Fixed

  • Playwright agent-config suite hardened against shared-dev-config drift and the ModalManager.confirm() gate (2026-08-01): agent_config_presets.spec.ts registered page.on('dialog', ...) to answer the preset-import confirmation, but that gate is a ModalManager.confirm() DOM modal (src/web/static/js/modal-manager.js), not window.confirm -- the handler never fired, so applyPreset() was never reached and assertions silently measured whatever the previous test left in the shared config. New confirmModal/acceptConfirmModal/dismissConfirmModal helpers (tests/playwright/helpers.ts) and every spec in the file now uses them. New workflow-config-snapshot.ts provides full-payload snapshot/restore for the shared dev workflow config, replacing a partial-field PUT that could null out agent_prompts/agent_models on restore (the row-5396 regression) when a concurrent PUT raced the active-config lookup; restore now verifies by read-back instead of trusting the PUT status code. Separately, expanded_prompt_editor_save.spec.ts now seeds a contract-valid CmdlineExtract prompt envelope before its save test instead of inheriting whatever the agent-config suite left behind (an empty stored prompt fails _collectPromptIssues() validation, so saveExpandedPrompt() never fires its PUT and the test timed out waiting for a request that would never happen), and snapshots/restores each agent's prompt around both tests via a retrying prompts-API read/write.
  • escapeHtml consolidated into /static/js/utils.js, fixing attribute-context escaping (2026-07-29): escapeHtml was redefined in seven templates plus components/similarity-display.js in three mutually incompatible variants -- the createElement/textContent trick (workflow.html, agent_evals.html, observable_training.html, sigma_ab_test.html, diags.html, similarity-display.js), a manual replace of only & < > (article_detail.html), and a full & < > " ' replace (settings.html). Only the last escapes quote characters, yet article_detail.html, agent_evals.html, workflow.html and similarity-display.js interpolate escapeHtml output directly into HTML attributes (data-scheduled-job-id="${...}", title="${...}", and roughly 20 value=/id= sites in workflow's model and prompt rendering), so any value containing a double quote escaped its attribute. New src/web/static/js/utils.js, loaded first from base.html, defines one canonical escapeHtml that escapes & < > " ' and is therefore safe in both text and attribute position; null and undefined become the empty string while escapeHtml(0) is now "0" rather than "". The eight duplicate definitions were deleted. similarity-display.js mattered most: its copy was declared at top level, so on the four pages that load it (sigma_ab_test, article_detail, sigma_similarity_test, workflow) it overwrote the shared global with the quote-unsafe version -- its <script> tags previously carried no cache-busting query string, so they gained ?v=20260729 to keep stale browser caches from re-shadowing the fix. utils.js is dual-mode -- browser globals plus module.exports -- because similarity-display.js is require()d in Node by tests/unit/test_similarity_display_normalization.py and re-exports escapeHtml, so the component resolves the browser global when there is one and loads the same shared file from disk otherwise; that keeps exactly one implementation rather than reintroducing a component-local copy. diags.html's formatTimestamp also moved into utils.js. base.html's inline formatDate had zero callers anywhere in templates or static JS and was deleted rather than relocated. The function-local const escapeHtml inside article_detail.html's documented fallback confirm-modal block was deliberately left alone -- it is function-scoped, so it cannot clobber the global. Verified in the browser at :8001 on all seven affected pages plus /articles/1426: the canonical implementation wins everywhere, quote escaping is live, formatTimestamp renders, every AGENTS.md contract-grade function on /workflow still resolves, and the config UI still renders its model and prompt containers.
  • A failing article query no longer renders as an empty article list (2026-07-29): AsyncDatabaseManager.list_articles wrapped its whole body in try/except Exception, logged, and returned [], so a database outage was indistinguishable from a legitimately empty result -- the web UI rendered its normal "no articles" state instead of an error, and the sync DatabaseManager.list_articles had always propagated, so the two managers disagreed. Reviewed all 13 swallow-after-log sites in async_manager.py individually against their callers rather than blanket-changing them, and split them by whether an empty list is meaningful data. Five now propagate: list_articles and list_articles_by_source (all 12 web callers already wrap them and turn the error into an HTTP 500 or error.html), get_article_annotations (most articles legitimately have zero annotations, so a swallowed failure was completely invisible), and get_articles_with_source_info / get_articles_without_embeddings -- the latter two mattered most operationally, because both retroactive-embedding tasks in celery_app.py report {"status": "success", "message": "No articles found without embeddings"} on an empty list, so a database error was reported as a clean run; both task handlers re-raise, so the failure is now loud. Six keep soft-empty on purpose and say so in code: get_source_quality_stats / get_source_hunt_scores (supplementary decoration on the sources page), get_performance_metrics (the database health endpoint must stay reachable to report degradation rather than 500 on the way to saying the database is unwell), and search_similar_annotations / search_similar_articles / search_articles_by_lexical_terms (RAG retrieval degrades to "no context" rather than failing the workflow that asked for it). Both halves are pinned by parametrized tests so a future edit cannot silently flip one, and route-level tests drive the real /articles page and /api/articles endpoint with a failing list_articles and assert a 500 plus the error template. Two methods, get_annotations_without_embeddings and get_annotations_by_ids, were found to have zero callers and were left untouched for the dead-code pass.
  • Dead duplicate JS function definitions removed from workflow.html (2026-07-28): four functions were declared twice at top level in the same <script> block, so the later declaration silently won and every edit to the earlier copy was a no-op. renderAgentModels and renderAgentPrompts each had a dead first copy targeting #agentModelsContainer / #agentPromptsContainer, DOM ids that no longer exist anywhere in the template; the live copies target the contract-grade #{agentprefix}-agent-model-container / -prompt-container ids, and every caller passes the live signature's argument. editPrompt and two of three escapeHtml copies were byte-identical duplicates. Removed 255 lines, deletions only. Verified in the browser at :8001: all AGENTS.md contract-grade functions still resolve, renderAgentModels reports arity 1 (confirming the correct copy survived), the model and prompt containers still render, and no console errors. Note for future dedup passes: a naive grep over these templates reports commented-out code as live duplicates -- the apparent article_detail.html collisions are inside /* */ blocks, and its two genuine duplicates are a deliberate documented fallback block that must not be deleted.
  • HTML-to-text no longer invents whitespace between inline elements (2026-07-27): ContentCleaner.html_to_text() called soup.get_text(separator=" "), which inserts a space between every text node. HTML gives inline elements no implicit whitespace -- real spacing lives in whitespace text nodes, which strip=False already preserved -- so that separator manufactured spaces absent from the document. It split any word a publisher wrapped in mid-token markup (<span>comman</span><span>d</span> -> comman d, Power<b>Shell</b> -> Power Shell, rund<a>ll32</a>.exe -> rund ll32 .exe) and detached trailing punctuation (<em>italic text</em>. -> italic text .), silently destroying the process names, file paths, and IOCs that detection engineering keys on. Found in the cloud.google.com UNC1549 Sigma eval fixture, whose Figure 9 caption stored as "reverse SSH comman d"; Google Cloud Blog exports from Google Docs and splits text across arbitrary <span> runs. Extraction now uses separator="". Since the separator was also the de facto source of block separation, the block-tag list expanded from 11 tags to the full block-level set (new _BLOCK_LEVEL_TAGS), each block is now marked on both sides (marking only the trailing side left the first block in a run glued to preceding text -- real nav markup produced "Google Cloud BlogJump to Content"), and <td>/<th> separate horizontally so minified IOC tables do not glue cell values together. Boundaries are marked with a sentinel rather than "\n" so adjacent markers from nested blocks fold into one break instead of opening a blank line between every pair of blocks; literal newlines inside <pre> carry no sentinel and survive untouched. Verified content-neutral on a real article: old and new code over the same fresh HTML differ by 26 characters, all of them invented spaces. Fix is forward-only -- the ~5,954 of 6,802 stored articles carrying the detached-punctuation signature keep their existing text until re-scraped, so content hashes, keyword matches, and hunt scores for re-fetched articles can differ from their predecessors. No eval fixture or ground truth was re-scraped or modified.
  • Langfuse trace coverage audit (2026-07-26): audited active provider call sites across workflow, eval diagnosis, Sigma routes, article-ranking routes, optimized ranking, vision extraction, and connection probes. Added missing generation/error observations, preserved provider usage metadata, propagated platform-adjudication article IDs, tagged connection tests separately, and added the dated coverage report plus regression tests. Live Langfuse verification remains pending because credentials were unavailable in the audit environment.
  • Sigma eval now runs against committed fixtures instead of the live DB row (2026-07-22): POST /run-sigma-eval built its execution snapshot via _sigma_eval_config_snapshot(), which set eval_run: True but never set eval_fixture_content -- so agentic_workflow.py fell through to article.content, the flat, possibly ASCII-stripped DB text, even after config/eval_articles_data/sigma/ fixtures were repaired (see the 2026-07-21 shared-URL reconciliation below). run_sigma_eval now resolves each URL's committed fixture via _load_static_eval_articles("sigma") and injects eval_fixture_content + eval_fixture_content_sha256 into the snapshot, mirroring run_subagent_eval; a URL with no committed fixture now hard-fails with 422 instead of silently scoring the DB row. This is a score-comparability era boundary for sigma evals -- prior sigma eval runs scored DB text, not the committed fixtures, so historical sigma scores are not directly comparable to runs after this fix.
  • Eval bundle article-text provenance now matches scored fixtures (2026-07-22): forensic eval bundles now prefer config_snapshot.eval_fixture_content for subagent eval executions and label the input source as eval_fixture, with the exported SHA-256 matching eval_fixture_content_sha256. Non-fixture executions still fall back to articles.content from Postgres. This closes a misleading bundle-export gap where inputs[].article_text could report mutable DB text the model never saw.
  • Eval fixture refresh now retains known-good URLs independently (2026-07-21): fetch_eval_articles_static.py no longer abandons an entire fixture set when one URL hits a bot wall, transient fetch failure, content-regression guard, or ground-truth-retention guard. Rejected URLs retain their existing fixture entry while healthy siblings refresh atomically; partial refreshes exit nonzero and report each retained reason. Refreshed the stale HuntQueries, WindowsServices, and NetworkIndicators fixture content with Unicode preserved. No ground truth changed; scores before and after this corpus refresh are not directly comparable.
  • Shared eval fixtures reconciled to one local text per URL (2026-07-21): aligned the eight previously divergent shared URLs without a network refresh, copying the source-faithful or better-cleaned local fixture into the lagging windows_services and sigma entries only after confirming it retained at least as much ground-truth support for every consuming set. No ground truth or expected counts changed. Eval scores for windows_services and sigma before and after this corpus correction are not directly comparable.
  • Content cleaning preserves Unicode detection signal (2026-07-21): ContentCleaner.clean_text_characters() no longer replaces all non-ASCII characters with spaces. Unicode PowerShell flag dashes, typographic quotes, and non-Latin report content are retained verbatim; non-breaking spaces still fold to ordinary spaces. Newly ingested or re-fetched articles can therefore have different content hashes, keyword matches, and hunt scores than ASCII-stripped predecessors.
  • Semantic button-class migration reverted after shipping regressions (2026-07-20): the three-commit sweep (2026-07-17/18) that replaced page-local and raw-Tailwind button styles with the global .btn-analyze/.btn-manage/.btn-execute/.btn-danger/.btn-secondary/.btn-sm classes shipped two real bugs alongside the restyle: the "Remove annotation" confirm button in article_detail.html lost its "Remove" label entirely (rendered blank), and the Huntable/Not-Huntable classification buttons each picked up a stray duplicated target emoji glued onto the tag. Reverted the button-class and CSS changes (17 templates plus the now-unused .btn-secondary/.btn-sm rules in theme-variables.css) back to their pre-2026-07-17 styling, fixing both regressions as a side effect. Left the unrelated work from the same commits untouched: the SourceSyncService source-init unification, the content_hashes table-drop migration, the ModalManager native-dialog replacement, and the skeleton-loading/empty-state partials.
  • Source initialization now uses the canonical async sync service (2026-07-17): cti init now limits SourceManager to loading, validating, and configuring source definitions, then persists them through SourceSyncService and AsyncDatabaseManager, the same implementation used by cti sync-sources. This removes the second source-to-database synchronization implementation and prevents the sync and async command paths from drifting again.
  • Create-Huntable-Agent skill name normalized to lowercase (2026-07-17): the skill's frontmatter name was Create-Huntable-Agent while its directory and every invocation reference used the lowercase create-huntable-agent, registering the skill twice under two case variants. Frontmatter now matches the canonical lowercase form.
  • Sync and async database managers unified on shared statement builders (2026-07-17): DatabaseManager (sync, CLI callers) and AsyncDatabaseManager (async, web callers) reimplemented ~20 shared queries by hand and had already drifted -- three different list_articles sort orders, filters supported on only one side, a duplicated archived filter, and a dead CLI search/export filter path that crashed because ArticleListFilter never carried the fields the CLI commands built (limit, author, tag). New src/database/statements.py defines each shared query shape exactly once as pure SQLAlchemy statement builders; both managers execute the same statement their own way (sync Session, async AsyncSession), so per-manager query drift is no longer possible. Intentional divergences (CLI default-sorts published_at, web default-sorts discovered_at; async's article-detail lookup includes archived rows, the CLI's does not) are now explicit function parameters instead of silently-diverged copies; execution-side behavior (async's error-swallowing-to-[], annotation-count injection, content deferral; sync's error propagation) is preserved. ArticleListFilter gained the full field union, fixing the CLI search/export crash; author/tag filtering now uses a JSONB containment cast (the JSON columns have no LIKE/contains operator in PostgreSQL). The hunt-score sort expression was hardened to extract via ->> instead of CAST(... AS VARCHAR), which previously crashed CAST AS NUMERIC on a JSON-null or quoted-string metadata value. Content-hash deduplication was also canonicalized onto articles.content_hash for both managers -- the legacy content_hashes ledger table was written only by the sync bulk-create path (312 rows against a 6,406-article corpus), so the CLI ingestion path had been deduplicating against roughly 5% of known content; a follow-up task tracks retiring the now-write-only ledger table. New parity drift-guard tests compile the statement each manager actually executes for the same filter and assert identical SQL.
  • Source config sync no longer crashes on the synchronous DatabaseManager (2026-07-17): SourceManager._sync_sources_to_db awaited list_sources/create_source/update_source/delete_source, but every live caller (cti init via get_managers) passes the synchronous DatabaseManager, so the first await raised TypeError: object list can't be used in 'await' expression. Dropped the awaits to match the DatabaseManager annotation and the sibling get_sources_due_for_check, and fixed the update branch to read check_frequency/lookback_days from the inner SourceConfig (loader-built SourceCreate silently drops the top-level kwargs). The cti sync-sources command (SourceSyncService + AsyncDatabaseManager) was unaffected.
  • HTML cleaner no longer guillotines article bodies on substring class matches (2026-07-17): ContentCleaner matched unwanted class/id patterns as substrings, so ad inside block-paragraph_advanced (Google Cloud TI) or bg-gradient-to-b/shadow-xl/leading-tight (NCSC UK, Tailwind) decomposed real body containers -- gutting a 34,834-char article to 589 chars and a 5,370-char body to 278 chars in production. Matching is now whole-token (split on whitespace and -/_).
  • CLI collect no longer crashes on a re-collected article and no longer drops the rest of the batch (2026-07-17): the CLI collection path (src/cli/commands/collect.py) deduplicated by content_hash only, so a re-fetched article whose canonical_url already existed but whose content changed (new hash) slipped past dedup and hit the uq_articles_canonical_url unique constraint; the resulting flush error then poisoned the session transaction and rolled back every genuinely-new article in the same bulk insert. collect.py now also fetches existing_urls and passes them to process_articles (matching the worker/API collector), and DatabaseManager.create_articles_bulk wraps each row insert in a begin_nested() savepoint so a single canonical_url conflict is recorded in the returned errors list and skipped instead of aborting the whole batch. New wiring test (tests/cli/test_collect_dedup_parity.py) and real-DB regression test (tests/integration/test_bulk_insert_canonical_url_resilience.py, verified to fail pre-fix).

Changed

  • workflow.html's page-level functions extracted to /static/js/workflow/page.js (2026-07-29): fourth and final slice of the workflow.html breakup. 1,633 lines -- the ModalManager compatibility shims (pushModal/popModal/isModalOpen), the similar-rules modal, queue filters, the URL/deep-linking utilities and popstate handling, the active-tab auto-refresh interval, the help modal, queued-rule navigation, live-execution SSE streaming, and page initialisation (initWorkflowConfigAgentAccordion, initializeTabs, initializeSaveButton, and the readyState init block) -- moved verbatim into a fourth versioned module. Byte-for-byte identical relocation. src/web/templates/workflow.html is now 4,092 lines, down from 17,572 -- a 77% reduction, and the main inline block that held roughly 345 functions is now a 118-line shell holding only the shared state the modules read (currentTab, the canonical agent-name and agent-list constants, executions, totalExecutions, queue) plus switchTab. page.js loads first among the four modules, which matters because it owns the only two DOMContentLoaded registrations on the page -- the ModalManager registration and the page-init block. Inline, those fired after the early <script> block's listener and before the two column-resize listeners at the end of the template; no other module registers a DOMContentLoaded handler, so loading page.js ahead of executions.js/queue.js/config.js preserves that firing order exactly. The <script> tags now carry a comment recording all three ordering constraints, so the arrangement is not silently reshuffled later. Verified in the browser at :8001: all four modules load in order, page init runs, all three tabs load and switch, the execution detail modal and the SIGMA rule preview modal both open and close, URL-parameter round-tripping works, the help modal renders and closes, both column-resize wrappers are still applied to window.loadExecutions/window.loadQueue, and the sub-agent fallback override from the config slice still resolves the same way. All 67 runnable Playwright agent-config tests and all 26 intelligence tests pass; workflow is 45 of 46, the exception being the same pre-existing, order-dependent expanded_prompt_editor_save.spec.ts:127 noted in the config entry, re-confirmed to fail identically on the unextracted template.
  • workflow.html's configuration functions extracted to /static/js/workflow/config.js (2026-07-29): third and largest slice of the workflow.html breakup (see the queue and executions entries below). 6,702 lines and 144 functions -- agent provider/model resolution and validation, the commercial model catalog, loadConfig/loadAgentModels/renderAgentModels, the prompt subsystem (load, render, edit, save, effective-prompt modal, version history and rollback), sub-agent enable/disable toggles, config presets and version restore, the sub-agent test modals, autosave and temperature-capability handling, change tracking and save-button state, and the config form wiring. src/web/templates/workflow.html is now 5,715 lines, down from 17,572 before this series -- a 68% reduction, with roughly 1,750 lines of JS left inline. Both extracted ranges are byte-for-byte identical to the removed lines. Unlike the first two slices this one is not a single contiguous range, and the reason is a load-order trap: the contiguous config region (formerly lines 3872-10530) assigns window.renderSubAgentCommercialInputs to the real implementation, and a separate "global fallback definitions" block near the end of the template (formerly lines 12127-12169) reassigns the same global to a simpler fallback. Inline, the fallback ran second and won -- confirmed in the live page, where window.renderSubAgentCommercialInputs.name is renderSubAgentCommercialInputsGlobal. Extracting only the contiguous region would have left the fallback running before the config module and silently handed the win to the real implementation, changing sub-agent provider-input rendering with no error to signal it. The fallback block therefore travels into config.js and is appended after the config region so it still runs last; the override is called out in the file so a later reader does not "tidy" it away. Post-extraction the live page still reports the fallback as the winner. Verified in the browser at :8001: all three tabs load and switch across the now four-way split (inline shell plus three modules), 3 agent-model containers and 9 prompt containers populate, currentConfig carries 17 keys, agentModels 41 and agentPrompts 10, and the contract-grade getWorkflowConfigBindingAudit() reports no unbound controls. All 67 runnable Playwright agent-config tests pass -- including agent_config_sub_agent_commercial_models.spec.ts, which exercises the overridden function directly -- plus all 26 intelligence tests and 45 of 46 workflow tests. The one workflow failure, expanded_prompt_editor_save.spec.ts:127, was confirmed pre-existing and order-dependent: it reproduces identically on the unextracted template once the agent-config suite has run against the shared dev database, and is tracked separately.
  • workflow.html's SIGMA queue functions extracted to /static/js/workflow/queue.js (2026-07-29): second slice of the workflow.html breakup (see the executions entry below). The 3,111-line // Queue Functions region (90 functions: bulk selection and bulk approve/reject/delete, queue load/render/sort/paginate, the from-scratch rule authoring flow, rule preview and YAML edit modal, approve/reject/submit-PR, LLM rule validation and its conversation modal, and the entire enrichment subsystem -- provider/model catalog, system-prompt editor, expanded editor, preset save/load/delete with drift tracking, diff generation, and iterative re-enrichment) moved verbatim into a second versioned static module; template is now 12,414 lines, down from 17,572 before this pair of extractions. Byte-for-byte identical relocation -- 2 insertions against 3,111 deletions. Load-order requirement mirrors the executions module: after the main inline block, which still declares the queue array this code reads, and before the sigma-queue column-resize block that wraps window.loadQueue at parse time. Unlike the executions slice, a substantial amount of queue state moved while its readers stayed inline -- currentRuleId, isEditMode, pendingPreviewId and queuePage are read by the similar-rules code, filterQueue, and checkAndTriggerPreview, and currentRuleId is also read by onclick="approveRule(currentRuleId)" handlers in the rule-modal markup at template line 3318. These remain global lexical bindings resolved at call time, so all of those references are unaffected; this was verified in the browser rather than assumed. The region also contains a load-time IIFE (initQueueSortHeaders) that binds click handlers to the eight .q-sortable[data-sort-key] headers, and two top-level document.getElementById lookups that attach click-outside handlers to #ruleModal and #enrichModal (elements at template lines 3307 and 3345) -- all three still run after their targets exist. Verified in the browser at :8001: no console errors, 50 rows against 707 total render with 8 resize handles, header sorting sets and toggles _qSortKey/_qSortAsc and re-renders, bulk selection populates and clears queueSelectedIds and shows the bulk bar, the preview modal opens and sets currentRuleId across the file boundary and closes both by button and by outside click, and window.loadQueue is confirmed to still be the resize wrapper. All 46 Playwright workflow tests and all 26 intelligence tests pass, the latter covering sigma_queue_lifecycle.spec.ts (approve/reject/stats) and the workflow similar-rule modal.
  • workflow.html's execution functions extracted to /static/js/workflow/executions.js (2026-07-29): first slice of the incremental effort to break up src/web/templates/workflow.html, a 17,572-line Jinja template holding roughly 345 inline JS functions across 9 <script> blocks -- effectively an unbundled, unreviewable SPA. The 2,054-line // Execution Functions region (37 functions: status/step badges, sort state and header rendering, loadExecutions/renderExecutions, the ~1,150-line viewExecution detail modal and its tab/sub-tab rendering, trace-bundle download, retry, the trigger-workflow modal, filtering, pagination, and the stuck/stale/cancel-all maintenance actions) moved verbatim into a new versioned static module; the template keeps a pointer comment at the old location, matching the escapeHtml/formatTimestamp convention established on 2026-07-29. Content is byte-for-byte identical to the removed lines -- 6 insertions against 2,054 deletions. Load order is the load-bearing detail: the new <script src> sits after the main inline block, because the extracted functions read executions and totalExecutions (top-level lets declared at template line 3788 and still inline), and before the column-resize block, which reads window.loadExecutions at parse time and monkey-patches it to pin column widths after each load -- extracting to a file loaded any earlier or later breaks one of those two. Three variables local to the region (executionSortState, _execPage, _execLimit) were confirmed to have no references outside it, and no sliced function is redefined elsewhere in the template, so relocating the block past other definitions cannot flip which declaration wins. Kept as a classic script rather than an ES module so the global-scope contract with the remaining inline code is unchanged. Verified in the browser at :8001: no console errors, 50 rows and the 540/1/439/100 stat tiles render, sorting rebuilds the header with indicators, the detail modal opens and closes, and window.loadExecutions is confirmed to still be the resize wrapper. All 46 Playwright workflow project tests pass, including the 26 in execution_detail_tabs.spec.ts and workflow_executions_pagination.spec.ts.
  • Workflow state is now runtime-validated (2026-07-23): WorkflowState is a strict Pydantic model accepted natively by LangGraph, rejecting missing, mistyped, and undeclared state channels at workflow boundaries. Its mapping-compatible API preserves existing node behavior while the state is incrementally migrated to attributes; the initial state now explicitly seeds all channels, including platform and CmdlineExtract intermediates.
  • Trusted-header authentication audit boundary documented (2026-07-23): successful login, logout, MFA, session, and failed-login events remain the upstream proxy / IdP's responsibility in trusted-header deployments. Operators should correlate those SIEM records with Huntable's X-Request-ID authorization-denial and application-mutation audits.
  • CmdlineExtract prompt: defanged-URL angle-bracket fidelity rule propagated (2026-07-21): synchronized the approved contract carve-out to the durable CmdlineExtract seed and all nine quickstart presets, then pushed it through the workflow prompt API. Enclosing angle brackets around a defanged URL in a command are stripped as source-rendering artifacts; hxxp:// and [.] remain verbatim.
  • CmdlineExtract contract: cd exclusion and continuation-character scope (2026-07-21): removed cd from valid built-ins and explicitly skips standalone cd <path> because it changes only process-local shell state and leaves no observable telemetry artifact; file-effect built-ins remain eligible. Scoped the continuation-character skip to a caret or PowerShell backtick at the end of a physical line, while preserving and extracting qualifying mid-token caret obfuscation. Mirrored the contract change to the drop-in. Zero GT delta: no cmdline ground truth contains cd, and existing caret-obfuscated schtasks items remain valid, so scores remain comparable.
  • Eval corpus is fixture-first and re-baselined (2026-07-21): subagent eval executions now carry the committed articles.json body in their immutable config snapshot and feed it to the extractor, rather than using mutable articles.content from the development database. Per-execution fixture SHA-256 metadata makes the scored input auditable. Performed one deliberate re-scrape of the non-Sigma eval corpus through ContentCleaner, preserving block newlines and reusing a single fetched snapshot for duplicate URLs. The refresh utility is atomic per fixture set: a failed URL leaves that set unchanged. This is a score-comparability era boundary; routine ingestion and DB refreshes do not refresh eval fixtures.
  • Tests that mutate workflow config are blocked against the dev app on :8001 (2026-07-21): tests/conftest.py now refuses to run agent_config_mutation-marked tests (PUT /config, prompt/preset endpoints) against the dev app, which would overwrite the operator's live configuration. The guard fires when the test targets 127.0.0.1:8001 without USE_ASGI_CLIENT=1; override with ALLOW_LIVE_DEV_SERVER=1. Also removed dead async_debugger/performance_profiler fixtures and skip_on_ci/skip_on_docker/skip_on_localhost helpers that referenced nonexistent tests.utils.test_environment symbols. Fixed run_tests.py docstring to reflect that no test web container exists.
  • Articles list density refreshed while preserving the legacy palette (2026-07-21): compacted the /articles triage list, filters, bulk-action bar, and pagination into a denser grid for faster scanning, while restoring the pre-redesign gray article cards, blue title links, muted keyword chips, and green/yellow/red RegexHuntScore badges. The row keeps full-row navigation, compact metadata, copy-content access, annotation counts, and mobile wrapping without the newer purple score ramp.

  • CmdlineExtract contract: defanged-URL angle brackets are stripped (2026-07-21): the FIDELITY section now carves out one exception to "preserve exactly as written" -- enclosing < > around a defanged URL inside a command line is a source-rendering artifact, not typed input, and is stripped; hxxp://, [.], and every other defanging convention are still preserved verbatim. Found during the CmdlineExtract eval-fixture audit: the contract's "preserve exactly / do not interpret rendering semantics" rules led three independent contract-compliant extractions to emit curl -o sh2.txt <hxxp://173.254.204[.]72/sh2.txt>, while ground truth stores the bracket-free curl -o sh2.txt hxxp://173.254.204[.]72/sh2.txt -- so a compliant model was being scored wrong for complying. Ground truth was already correct and is unchanged (zero GT delta across all five sinks), so eval scores remain comparable across this edit; only the contract and its drop-in mirror moved. Three other clarifications considered in the same audit (two-token subcommand scope, placeholder hard-list, .exe-suffix normalization) were deliberately NOT made: ground truth and contract-compliant extraction already agreed on all three, and the .exe variants stay distinct because CmdlineExtract models the typed command line, not resolved process execution.

  • LLM provider clients consolidated (2026-07-20): shared Anthropic retry handling, Retry-After parsing, workflow provider AppSettings lookup, and LMStudio chat fallback/model-name retry logic now live in src/services/llm_provider_clients.py. LLMService, SigmaGenerationService, /api/articles AI routes, and Sigma queue routes delegate to the shared helpers while preserving existing private wrapper methods used by tests and legacy patch points. Added focused regression coverage for Anthropic non-retryable and transient retry behavior, LMStudio URL fallback metadata, and malformed LMStudio success responses.

  • LLM service internals split into focused modules (2026-07-20): src/services/llm_service.py now composes prompt validation/constants, provider routing/context helpers, and provider HTTP clients from separate llm_prompting.py, llm_routing.py, and llm_client.py modules while preserving the public LLMService import surface and legacy patch targets. Added module-boundary regression tests so the split stays behavior-preserving for existing LLM, LMStudio, and prompt-parser callers.
  • Skeleton loading + empty-state partials added (2026-07-18): created src/web/partials/skeleton_loading.html (animated pulse skeleton grid) and src/web/partials/empty_state.html (icon + message + refresh button). Integrated into scraper_metrics.html, hunt_metrics.html, and analytics.html with JS toggle to hide skeleton and show content on successful fetch or display empty state on error.
  • UI design system contract audit (2026-07-21): added docs/audits/ui-contract-audit-2026-07-20.md auditing all 26 templates against docs/contracts/ui-designer.md. 5 rules pass (typography, dark mode, emoji, reserved classes, !important), 6 fail (card containers, hardcoded colors, ARIA, spacing, ASCII-only, modals). Top remediation priorities: 131 em-dashes/ellipses, 19 modal overlays missing role/aria-modal/aria-label, 57 hardcoded hex/rgba inline styles.
  • Native confirm()/prompt() replaced with styled ModalManager dialogs (2026-07-17–18): added ModalManager.confirm() and ModalManager.prompt() (src/web/static/js/modal-manager.js) as Promise-based methods that dynamically create styled confirmation and input modals using the project's theme-variable system. Migrated all 31 native confirm() and 13 native prompt() calls across all 9 templates (agent_evals, agent_evals2, article_detail, articles, ml_hunt_comparison, observable_training, settings, workflow) to the new API, with context-appropriate titles and button colors for destructive, warning, informational, and input actions. One sync function (promptForArticleId) was converted to async.

Removed

  • Dead specs/README.md removed (2026-07-21): deleted the orphan specs directory README that had no content after prior cleanups.
  • Dead Sigma service orphans removed (2026-07-20): removed sigma_behavioral_normalizer.py, sigma_extended_validator.py, and sigma_huntability_scorer.py plus their self-only tests after confirming they had no runtime imports. The active Sigma novelty path already uses sigma_atom_precompute.py and the external sigma_atom_similarity package for canonical atom extraction/comparison, and generated-rule validation continues through sigma_validator.py; current docs were corrected to stop advertising the deleted services as active pipeline components.
  • Confirmed-dead /api/eval/* + debug + demo endpoints removed (2026-07-18): removed 6 endpoints with zero callers (confirmed via repo-wide grep across templates, JS, tests, docs, scripts): POST /api/eval/hallucination, POST /api/eval/relevance, GET /api/eval/metrics, GET /api/eval/comparison (src/web/routes/evaluation.py); GET /api/test-route debug registration check (src/web/routes/debug.py); and GET /analytics/hunt-metrics-demo plus its 1036-line hunt_metrics_demo.html template. The /api/eval/* router had grown from 4 to 10 endpoints since the original dead-code scan -- the 6 surviving endpoints (history, agent-metrics, trends, os-detection-manual-results, observables-count-results, rank-agent-benchmarks) have live template callers in evaluations.html / agent_evaluation.html and were preserved. POST /api/test-hf-key was also kept: it has a direct test caller (test_ai_key_test_logging.py) and validates Hugging Face tokens. EvaluationTracker.compare_evaluations() remains in use by the /evaluations/compare UI route. Net: -1,118 lines. Added tests/api/test_removed_dead_endpoints.py as a regression guard asserting removed routes stay gone and surviving routes remain registered.
  • Legacy content_hashes ledger table retired (2026-07-17): follow-up to the sync/async dedup canonicalization onto articles.content_hash -- nothing read content_hashes anymore, and the ledger was write-only (312 rows vs 6,406 articles). Removed ContentHashTable from src/database/models.py, the create_articles_bulk writer and the schema-ensure PK DDL in src/database/manager.py, the delete_article cleanup in src/database/async_manager.py, the redundant ContentHashTable collision check and upsert in scripts/backfill_image_ocr.py (its now-unread ArticleUpdateIntent.upsert_content_hash field was also dropped), and the content_hashes cleanup step in scripts/maintenance/dedup_articles.sql. New scripts/migrate_drop_content_hashes_table.py drops the table (DROP TABLE IF EXISTS, idempotent) -- run it before/alongside this deploy: content_hashes.article_id has no ON DELETE CASCADE, so until the table is dropped, deleting one of the legacy articles that still has a row there will hit a foreign-key violation in delete_article() (now that its explicit cleanup delete is gone) and silently return False instead of True.
  • Unused HTMX/React/Babel payload removed from every page (2026-07-17): base.html loaded HTMX, React 18, and Babel standalone from CDN on every request with zero call sites anywhere in the codebase (grep-verified: no hx-* attributes, no ReactDOM.*/React.createElement, no text/babel usage across templates and static JS). Removed all three <script> tags, cutting ~1.5MB of dead JS per page load; Chart.js, the annotation system, and modal-manager.js were untouched. Browser-verified dashboard, workflow, settings, hunt-metrics, and articles pages render with no console errors and no unpkg/htmx/react/babel network requests.

Security

  • PUT /api/cron restricted to admin-only, closing a crontab command-injection primitive (2026-07-17): the endpoint passed the raw request body straight to crontab - with no validation, allowlisting, or schema check, so an operator-scoped identity (a lower trust tier than admin) could schedule arbitrary shell commands to run as the crontab owner. /api/backup/* and /api/settings* already gate equivalent-power admin capabilities to admin-only in route_manifest.py; /api/cron now matches that precedent instead of operator+admin. Regression coverage added to tests/api/test_cron_api.py and tests/api/test_route_family_authorization.py asserting an operator identity is rejected and admin still succeeds.
  • SSRF in /api/scrape-url closed via a shared DNS-pinning fetch helper (2026-07-17): _scrape_single_url validated the target URL once, then fetched with a raw httpx.AsyncClient(follow_redirects=True), leaving two bypasses of the codebase's own hardened OCR fetcher (vision_ocr_service._PinningBackend): a redirect bypass (a 302 to http://169.254.169.254/... was followed with no re-validation) and a DNS-rebinding TOCTOU (validation resolved DNS at check time, httpx re-resolved at connect time, so a low-TTL domain could flip public->private between the two checks). An inline codeql[py/full-ssrf] suppression had marked this a false positive. New src/utils/safe_fetch.py extracts vision_ocr_service's tested primitives (_PinningBackend, _is_safe_image_url) as the single source of truth: safe_fetch_text sets follow_redirects=False, re-validates every redirect hop's resolved IP, and connects through the pinning backend so neither bypass reaches the OS connector. Both fetch sites in scrape.py now route through it; UnsafeURLError maps to HTTP 400 (including on the pre-scraped title-only path, which previously swallowed all fetch errors). New tests/api/test_scrape_url_ssrf.py (redirect-to-metadata, redirect-to-loopback, DNS-rebind-to-private-IP, and a build_pinning_client guard proving the OS connector dials the validated IP, not the hostname) mirrors the pinning-test pattern in test_vision_ocr_service.py; a normal public URL still scrapes end-to-end.
  • Stored DOM-XSS in article annotations closed (2026-07-17): annotation-manager.js (alternativeHighlight, renderExistingAnnotation) and annotation-manager-mobile.js assembled container.innerHTML = beforeText + highlight.outerHTML + afterText, where beforeText/afterText were unescaped substrings of container.textContent -- decoded raw scraped article text -- re-parsed as HTML. An article containing literal markup like <img src=x onerror=...> executed when an annotation was created or when an existing annotation rendered on article page load; selectedText itself was already safe (set via .textContent on the highlight span). Both managers now escape beforeText/afterText through a class-scoped static escapeHtml (matching the existing pattern in similarity-display.js/observable-utils.js) before the innerHTML concatenation. Six new Playwright regressions (tests/playwright/annotation_xss_regression.spec.ts) confirm an XSS payload in the container text renders as inert text with zero live <img> nodes or onerror attributes, while the highlight itself still renders correctly.
  • CI: GitHub Actions pinned to commit SHAs with a lint gate (2026-07-17): every uses: in .github/workflows/*.yml now pins a full 40-char commit SHA (with a # vX comment so Dependabot's github-actions ecosystem still opens version-bump PRs), and a new lint.yml job fails the build on any Action not pinned to a SHA -- closing the mutable-tag supply-chain vector (the tj-actions/changed-files compromise class). Dependabot also gains a 7-day cooldown on all three ecosystems.
  • Side-effecting GET routes converted to POST / moved behind startup (2026-07-17): two GET routes mutated database state on every call but, being safe-method routes with no explicit manifest rule, resolved to AUTHENTICATED (any logged-in user, no role gate) and were inherently outside CSRF token coverage. GET /api/articles/{id}/sigma-matches (ai.py) wrote sigma_similar_cache into article metadata via update_article and force=true re-ran an expensive behavioral-novelty assessment; converted to POST so the existing /api/articles/* (ai/llm_optimized_endpoint) manifest rule applies ROLES (operator/admin) + CSRF REQUIRED, and the force=true recompute is no longer an unprivileged compute/DoS lever. The article_detail.html fetch was updated to method: 'POST' (the base.html CSRF shim auto-injects the token for same-origin POSTs). GET /api/workflow/config (workflow_config.py) lazily created a default AgenticWorkflowConfigTable row and seeded AppSettingsTable threshold on first call; the lazy-init logic was extracted into ensure_default_workflow_config() and moved to application startup (modern_main.py lifespan), leaving GET truly read-only (returns 404 if no config exists, reads threshold without writing, uses in-memory prompt defaults without persisting). The ensure_workflow_config_schema test fixture now seeds the config row the same way. New tests/api/test_safe_get_side_effects.py (5 checks) asserts GET sigma-matches returns 405 (no handler, no cache write) even with force=true, GET workflow/config returns 200 with fixture-seeded config, the route manifest classifies POST sigma-matches as CSRF REQUIRED + ROLES with no GET route registered, and GET workflow/config remains a safe method. Not the SRF-2 auth-bypass class (auth was already required) -- impact is low, but side-effecting GETs violate safe-method expectations and dodge CSRF.
  • SRI integrity+crossorigin added to external CDN resources (2026-07-17): pinned Tailwind CSS (3.4.17), Chart.js (4.5.1), and the Google Fonts stylesheet to versioned URLs with SHA-384 integrity hashes and crossorigin="anonymous" in base.html, closing the supply-chain vector where an attacker compromising a CDN could serve modified scripts.

[7.6.0 "Europa"] - 2026-07-10

Fixed

  • Enterprise SSO flagged as beta (2026-07-09): setup.sh/config.sh sso and deploy/sso/README.md now state up front that only login has been verified across all three providers (GitHub/Google/Entra) -- IdP group-to-role mapping has been validated with only one provider, and Google groups (service account + domain-wide delegation) and Entra groups (object GUIDs, not names) need extra provider-side setup the scaffold does not generate. No behavior change.
  • Settings backup list shows available backups again (2026-07-06): /api/backup/list screen-scrapes prune_backups.py --stats output and matched the section heading case-sensitively ("Recent Backups"); the restored script prints "Recent backups:", so the Settings page showed "No backups found." despite backups on disk. Parsing extracted into _parse_backup_list() with a case-insensitive heading match and regression tests pinned to the script's real output.
  • Backup restore detects psql statement errors and raises PostgreSQL shared memory default (2026-07-06): restore verification now runs psql with ON_ERROR_STOP=1, extracts ERROR/FATAL/PANIC lines from stderr even when psql exits 0, and connects test database create/drop commands to the postgres maintenance database. The local compose Postgres service now defaults POSTGRES_SHM_SIZE to 1g so pgvector/HNSW index restore tests do not fail with Docker's small shared-memory default. Added unit coverage for psql stderr detection and restore command wiring.
  • Browser extension docs: cross-link client-side OCR/Vision with server-side ingest OCR (2026-07-06): docs/guides/browser-extension.md's Image Text Extraction section and docs/features/image-ocr-ingest.md each documented one OCR pipeline with no reference to the other, leaving no explanation of which pipeline runs when or that markers inserted by one are recognized and skipped by the other (_parse_existing_ocr_urls in vision_ocr_service.py). Cross-linked both pages. Also replaced the stale, duplicate browser-extension/README.md (missing the OCR/Vision feature entirely) with a short pointer to the actively maintained docs/guides/browser-extension.md.

Security

  • SRF-2: GET reads on credential- and admin-scoped routes now require the same role as their write counterpart (2026-07-10): the route-authorization manifest (route_manifest.py) classifies GET requests against a separate rule table (SAFE_ROUTE_RULES) from mutating requests (UNSAFE_ROUTE_RULES); /api/settings*, /api/backup/*, /api/model/*, /api/observables/training/*, and /api/sigma-queue/* were role-gated (admin/rule_reviewer) on the write side but had no matching read-side entry, so GET fell through to the bare AUTHENTICATED default — any authenticated identity, regardless of role, could read live settings values (including provider API keys) via GET /api/settings. Added matching SAFE_ROUTE_RULES entries mirroring each write-side role gate, per the documented role model (docs/guides/authentication.md: admin owns "settings, credentials, ... backup/restore, model management"; rule_reviewer owns "Sigma queue review actions"). Also widened the settings-audit sensitive-key check (settings.py::_is_sensitive_setting) to reuse audit_service's broader key-name allowlist (adds password/credential/etc., previously only TOKEN/SECRET/API_KEY) so audit-log redaction can't silently miss a differently-named credential setting. Regression tests added to tests/api/test_route_family_authorization.py.
  • SRF-1: production trusted-header mode fails closed on an empty proxy allowlist (2026-07-06): with APP_ENV=production and AUTH_MODE=trusted_header, an empty AUTH_TRUSTED_PROXY_IPS let any direct client forge X-Huntable-Verified + identity headers and obtain admin (the peer gate in parse_trusted_identity() passes everything when the allowlist is empty). load_security_config() now aborts startup in that posture unless the documented break-glass ALLOW_INSECURE_PRODUCTION_TRUSTED_PROXY_OPEN=true is set for deployments whose direct access is blocked at the network level. Dev/test behavior unchanged. Production also now rejects non-literal allowlist entries (*, CIDR ranges) at startup — the peer gate is exact-match, so an unmatchable entry would otherwise cause a silent all-requests-denied outage (adversarial-review finding). The SSO golden path was updated in the same change so it keeps booting: config.sh sso production hardening now writes the override with a printed warning (the overlay's proxy container IP is dynamic; its isolation comes from removing the direct app port), and docs/guides/enterprise-sso.md, deploy/sso/README.md, docs/guides/authentication.md, and .env.example document both paths. Regression tests added to tests/unit/test_security_config.py. The optional identity.py peer-gate tightening remains an open operator decision (it would change the documented optional-peer-validation contract).

Added

  • NetworkIndicatorExtract sibling/boundary rules + drop-in prompt (2026-07-05): closed the extractor-standard compliance gap left when NetworkIndicatorExtract shipped in v7.2.0 — all six pre-existing extractor contracts (docs/contracts/{cmdline,proctree,registry,services,scheduled-tasks,huntquery}-extract.md) now list NetworkIndicatorExtract as a sibling with a reciprocal boundary rule mirroring the already-shipped language in the live seed prompts (src/prompts/*), so this is doc-sync, not an extraction-semantics change. Added the missing seventh drop-in prompt (docs/contracts/network-indicator-extract-dropin.md), registered it in the mkdocs nav, and linked it from the contract. New tests/unit/test_extractor_contract_sibling_sync.py (28 checks) pins the cross-listing mandate across contracts, seed prompts, drop-ins, and nav.
  • Image OCR Ingest feature page (2026-07-05): dedicated docs/features/image-ocr-ingest.md documenting the Tesseract ingest pre-pass — SSRF guard model (DNS-pinning httpcore backend, per-hop redirect re-validation, private/CGNAT/mapped-IPv6 rejection, byte/pixel caps), OCR_INGEST_ENABLED + per-source image_ocr_enabled tri-state mechanics, the five seeded news-source opt-outs, ocr_status vocabulary, scripts/backfill_image_ocr.py usage, and the /api/health tesseract block. Registered in the mkdocs nav and cross-linked from docs/index.md. New tests/unit/test_docs_structure_sync.py pins feature-page nav registration and docs/solutions/ frontmatter schema; tests/unit/test_audit_action_vocabulary.py pins the spec-reserved audit action names against future dead-code sweeps.
  • MCP ambient context resources (2026-07-05): registered read-only MCP resources so agents can attach current Huntable context without bespoke tool calls: huntable://sigma-queue/status exposes global queue status counts, huntable://sigma-queue/recent-rules exposes the ten most recent generated Sigma queue entries, and huntable://workflow/active-config exposes the active config id/version, thresholds, toggles, model assignments, and prompt agent names while intentionally omitting prompt bodies. Added focused resource contract tests plus stdio-server registration coverage; docs updated in docs/reference/mcp-tools.md.
  • Enterprise auth Chunk C completion: workflow trigger/retry audit + initiating-actor attribution (2026-07-04): closed the last deferred Chunk C task (Task 5). Human-initiated workflow trigger (POST /api/workflow/articles/{id}/trigger) and retry (POST /api/workflow/executions/{id}/retry) now record mandatory same-transaction workflow.triggered / workflow.retried audit events (audit-write failure rolls the mutation back and surfaces a 500 via the new WorkflowAuditError), and both persist a redacted-safe initiated_by snapshot (user_id/email/roles/auth_mode) under the execution's config_snapshot so worker-side attribution references the originating human without impersonation — service-originated triggers (ingest auto-trigger) carry no initiated_by and are attributed to the service by its absence. WorkflowTriggerService.trigger_workflow gained backward-compatible initiated_by/audit_event_factory parameters (the route builds the event; the service stays HTTP-free). Added the denied-request audit contract suite tests/api/test_audit_denials.py (401/403/unclassified each emit exactly one best-effort auth.request_denied; allowed requests emit none; denials never depend on audit storage) plus workflow trigger/retry atomicity tests in tests/api/test_audit_mutations.py. With MCP writes already service-attributed and no worker/scheduler code emitting audit events or internal HTTP calls, this completes Chunk C service-identity wiring; backup/model/embeddings/eval route audit remains the documented follow-up.
  • Risk-tiered MCP write tools (2026-07-03): the Huntable MCP server now exposes scoped, audited write tools for low-blast-radius actions while keeping execute_sql permanently read-only. Auto-executable tools cover workflow retry/cancel, source active-state toggling, article reviewed markers, and annotation create/update/delete; higher-risk Sigma queue mutations and article deletion create pending mcp_write_confirmations rows instead of applying production mutations through MCP. New audit persistence records MCP writes and confirmation requests in audit_events, with metadata redaction and focused regression coverage in tests/unit/test_mcp_write_tools.py. Docs updated in docs/reference/mcp-tools.md, README.md, and docs/index.md.
  • Enterprise auth, authorization, and audit foundation (2026-06-18): added trusted-header identity enforcement with a central route manifest, fail-closed authorization middleware, narrowed public health surface, role-gated high-risk route families, and durable database-backed audit events. Audit support includes the audit_events table, schema validation, redaction helpers, same-transaction settings/secret audit writes, best-effort authorization-denial audit, admin-only audit read/export/retention endpoints, and documentation for retention plus the DB-backed tamper-risk boundary. OAuth/OIDC and full mandatory-audit migration for every mutation family remain follow-up work.
  • Enterprise auth Chunks B & C: CSRF, mandatory audit, and SSO deployment (2026-06-25): completed the enterprise-auth foundation. Added signed, user-bound CSRF protection (src/web/security/csrf.py, CsrfMiddleware, a base-template token meta tag + same-origin fetch shim) gated by CSRF_ENABLED=auto with production fail-closed on a missing/weak SECRET_KEY. Migrated mandatory same-transaction audit to the sigma_queue, workflow (cancel/cleanup/stale), source-config, annotation create/delete, and scheduled-jobs route families via backward-compatible optional-session refactors in async_manager/services; added service-identity audit helpers for workers and AUDIT_RETENTION_DAYS wiring. Added an opt-in enterprise-SSO step to setup.sh and a config.sh CLI (sso / sso --disable / rotate-secret / set / show) that edits .env without re-running the installer, a ready-to-run nginx + oauth2-proxy reverse-proxy scaffold (deploy/sso/), a localhost login-gate variant for testing Entra/Google with no domain or TLS (deploy/sso/local/), and an Enterprise SSO setup guide (docs/guides/enterprise-sso.md). Backup/model/embeddings/eval audit remains documented follow-up.
  • Local Entra login-gate toggle (2026-06-25): ./config.sh entra on|off|status (delegating to deploy/sso/local/gate.sh) forces every visitor on a local Docker setup through the Entra login by un-publishing the cti_web host port via a committed docker-compose.entra-isolate.yml overlay (ports: !override []), so the oauth2-proxy gate on :4180 becomes the only entry point. The helper self-locates the running stack from the container's compose labels (works from any checkout), requires an interactive confirmation that a real login succeeds before it closes the port, and fails closed -- auto-reverting the port if the app is not reachable through the gate or if a stopped container would otherwise be misreported as gated. This is a login gate (authentication only); per-user roles remain the trusted-header RBAC path. config.sh's .env commands stay docker-free; only entra orchestrates containers.

Fixed

  • AGENTS.md solutions frontmatter description (2026-07-05): the Orientation table claimed docs/solutions/ frontmatter carries a tags key; none of the 11 solution files do and no code consumes one — corrected to the actual key set (title, date, module, problem_type).
  • setup.sh dropped interactively-entered OpenAI/Anthropic keys and the Redis password (2026-07-05): create_env_file substituted secrets into .env.example via sed on placeholder strings (your_openai_api_key_here, your_anthropic_api_key_here, your_secure_redis_password_change_this) that no longer exist in the template -- .env.example ships those keys as bare KEY= with no placeholder text, and has no REDIS_PASSWORD line at all, so the sed calls were silent no-ops and entered values never reached the generated .env. Switched to startup_set_env_key (mirroring the existing SECRET_KEY fix) so OPENAI_API_KEY, ANTHROPIC_API_KEY, and REDIS_PASSWORD are always written directly. Added tests/unit/test_setup_env_secret_substitution.py.

Removed

  • Dead-code cleanup: 8 orphan files + 49 zero-reference symbols (-2,203 lines) (2026-07-05): deleted eight modules nothing imports (cmdline_caliper, query_safety, sigma_novelty_detector, encoder_classifier, regex_windows, cmd_extractor, dspy_cmdline_module, text_highlight — mostly BERT/sklearn-era leftovers) plus 27 zero-reference class methods, 14 module functions, and 4 dead constants across 28 modules, each re-verified by fresh whole-repo grep before removal. The seven unreferenced audit ACTION_* constants were kept with a reserved-vocabulary comment (five are enumerated in the enterprise-auth auditability spec's stable action names; the two backup.* names await the spec's open backup-permission decision). Per-section test suite counts were identical before and after (smoke 85 / unit 4129 / api 469 / integration 39 passed, 0 failed); the stale sigma_novelty_detector row was dropped from docs/features/sigma-rules.md.
  • Domains + Products classification dimensions (Phase D) — removed as YAGNI cruft (2026-06-25): the entity-driven Domains/Products dimensions shipped in 7.5.0 (src/services/entity_dimension_classifier.py + config/domain_classification_kb.yaml / config/product_classification_kb.yaml) were computed in os_detection_node, stored in error_log.os_detection_result.{domains,products}, and rendered as static labels in the Platform Detection trace — but had zero functional consumer from day one (verified across the workflow, API serializers, the MCP server, exports, DB queries, Sigma generation, and downstream nodes: nothing read them for any routing, filter, grouping, or decision). Domain/product routing was never built (design open-question #4 unresolved) and not scheduled, so the producer was inert trace decoration that implied a capability the system did not have, plus a second parallel keyword scorer and two duplicated-token KBs to maintain. Excised as a clean unit — classifier service, both KB YAMLs, workflow wiring (agentic_workflow.py os_detection_node), the two trace-UI rows (workflow.html), the 6 unit tests, and the Playwright workflow_platform_dimensions spec — with no schema migration (the fields lived in the existing error_log JSONB). The curated KBs (42 domain + 19 product entries) remain recoverable from git history (commit 8c03d744) for a clean re-add if a real consumer is ever designed. Design/rationale: docs/superpowers/specs/2026-06-19-entity-driven-platform-classification-design.md (Phase D marked ⛔ REVERTED).

Changed

  • Sigma generation: network-observable abstraction guidance + reasoning-model token budget (2026-07-04): both Sigma generation prompts (sigma_generation.txt, sigma_generate_multi.txt) gain a "Network Observable Abstraction (no atomic IOCs)" section — atomic network values (full URLs/domains/IPs/User-Agents, JA3/JA4, cert hashes) are evidence only and must be generalized into durable behavior (|startswith/|endswith/|contains shapes) or discarded, never emitted as bare selection values. All 9 quickstart presets synced. Reasoning-model max_tokens for Sigma generation raised 4000 -> 10000 (sigma_generation_service.py). Landed alongside workflow execution-detail UI wiring (Langfuse debug links, trace/eval bundle export) in workflow_executions.py / workflow.html with Playwright coverage.
  • Platform Detection trace labels renamed to match the post-embedding reality (2026-06-25): the OS/platform classifier has used deterministic keyword-KB scoring (kb_scoring), not embeddings, since the 2026-06-19 entity-driven rewrite, so the trace's "similarity" wording was stale. Renamed the per-execution Platform Detection labels in workflow.html, workflow_executions.html, and article_detail.html: "View Platform Similarity Scores" -> "View Platform Score Distribution" and "Max Similarity" -> "Top Platform Score". Display text only; the underlying similarities / max_similarity data keys are unchanged (backward-compat). The Sigma rule-dedup "Max Similarity" (genuine cosine/atom similarity) was deliberately left untouched.
  • MkDocs nav curation — promote reference docs out of not_in_nav (2026-06-22): the v7.5.0 CI-unblock (commit 4ced3c10) parked 43 reference/internal pages in mkdocs.yml's not_in_nav and fully excluded docs/superpowers/. Reviewed all 43 against their content and promoted 15 genuinely user/developer-facing pages into the published nav: new Deployment (deployment/docker-architecture, deployment/technical-readout) and Operations (operations/ml-model-runbook, operations/verify-ml-versions-backup) sections; Architecture gains internals/architecture (diagrams) + architecture/agent-config-schema; Development gains development/ui-test-tiers + development/sigma-eval-system; Reference gains reference/prompt-mapping-table, reference/versioning, and reference/do-not (anti-patterns); Contracts gains NetworkIndicatorExtract as a peer plus a Drop-in Prompts subsection for the six *-extract-dropin.md copy-paste variants; and a top-level Brand & Style Guide. The remaining 28 stay out of nav (deprecated QA contracts, dated audits/plans, solutions/**, reports/**, adr/**). docs/superpowers/ moved from exclude_docs to a not_in_nav: superpowers/ glob, so the 16 design specs/plans now build and are reachable by URL but stay out of the menu; the six dead ../../../src|tests/… relative links in the three enterprise-auth specs were rewritten to absolute GitHub blob URLs (relative links from docs/ to source files don't resolve under MkDocs). python -m mkdocs build --strict exits 0 before and after.

[7.5.0 "Europa"] - 2026-06-22

Added

  • Linux-aware Sigma generation guidance (2026-06-19): a reviewed-Linux-pilot follow-up. The base Sigma-generation prompt is Windows-tuned, so a per-platform/logsource group tagged linux now receives an additive Linux guidance block (src/services/sigma_generation_service.py::_platform_sigma_guidance; no edit to the base prompt file, so the quickstart presets stay in sync) steering toward Linux process_creation fields (CommandLine/Image full path, no Windows-only fields), behavior-matching detection, and Linux ATT&CK tags (T1059.004/T1222.002/T1053.003/T1105). Validated on article 4441: the chmod rule went from CommandLine|contains: /tmp (matches almost every command, mis-tagged T1203) to CommandLine|contains: 'chmod 777 /dev/shm' tagged T1222.002. This is the §10 "split the prompt if Linux quality lags" promotion, triggered by the live pilot rather than guessed.
  • Platform Telemetry Expansion — platform-aware extractor routing & Linux Sigma (phase one) (2026-06-19): the agentic workflow is no longer Windows-only. Platform Detection (renamed from OS Detection) returns platform context instead of a hard pass/fail gate; extractors run only for the platforms they support, and the Windows-only extractors (RegistryExtract/ServicesExtract/ScheduledTasksExtract) are skipped on non-Windows evidence with structured reason_code/supported_platforms/detected_platforms records rendered in the per-execution trace. Every observable now carries platform + telemetry metadata (platform, platform_confidence, platform_rationale, telemetry_category, telemetry_confidence, logsource_hint). Linux command/process evidence generates backend-neutral Sigma with an explicit logsource plus generation_basis/detection_readiness metadata (kept out of the pySigma-validated YAML the same way observables_used is), and mixed Windows/Linux articles produce separate per-platform/logsource rule groups (a single combined rule is not phase-one behavior); macOS-only articles generate no macOS Sigma even under the full-content fallback. Linux-generated rules enter the same review queue distinguished by a platform badge, and Workflow Config gains a read-only platform capability matrix. Platform/telemetry fields flow through the existing execution/review APIs; new executions only (no backfill), legacy serializers unchanged. Proof: tests/workflows/test_platform_telemetry_phase_one.py (one test per acceptance bullet, every emitted rule validated through real pySigma) + workflow Playwright specs (workflow_platform_capability_matrix, workflow_platform_badge, workflow_platform_detection). Design: docs/superpowers/specs/2026-06-17-platform-telemetry-expansion-design.md; follow-ups in 2026-06-19-platform-telemetry-followups.md.
  • Entity-driven platform classification replaces the embedding OS detector (2026-06-19): the CTI-BERT/SEC-BERT embedding OS detector was measured non-discriminative for non-Windows content (0/4 on clearly-Linux articles, ~0.04 class margins, the Other/multiple tie-break pseudo-classes winning, and it classifies on the 512-token boilerplate head of raw pre-filter content) and is retired as the decider. New explainable hybrid: a deterministic entity-keyword knowledge-base gate (src/services/platform_classifier.py + config/platform_classification_kb.yaml) decides the common case for free, with cited evidence and margin-based confidence and an evidence floor that refuses to claim a platform on thin signal; an LLM adjudicator (src/services/platform_adjudicator.py, the configured gpt-4o-mini) narrows only the low-confidence / Unknown tail; and ATT&CK technique citations (src/services/attack_platform_signal.py + curated seed config/attack_technique_platforms.json, regenerable from MITRE x_mitre_platforms via scripts/build_attack_platform_map.py) reinforce KB-evidenced platforms only — reinforce-only because technique citations enumerate threat capabilities that span platforms and must not originate a verdict (live-validated: an originating signal forced a Unix-citing Linux article to multiple(linux,macos), degrading its observables to unknown and suppressing Linux Sigma). Adds the Domains + Products taxonomy dimensions (src/services/entity_dimension_classifier.py + config/domain_classification_kb.yaml / config/product_classification_kb.yaml) computed deterministically and surfaced in the per-execution Platform Detection trace (stored in error_log.os_detection_result, no schema change). The dead embedding OS-detection code paths (_detect_with_classifier/_detect_with_similarity/_load_classifier/_precompute_os_embeddings, ~143 lines) were removed; the embedding infra (_get_embedding/_load_model/train_classifier) is retained as it is still shared by huntable_windows_service and the OS-classifier trainer. Verified end-to-end by a live pilot (article 4441 → 3 Linux process_creation rules queued with Linux badges, Windows-only extractors skipped). Design: docs/superpowers/specs/2026-06-19-entity-driven-platform-classification-design.md.
  • Server-side image OCR during article ingest (2026-06-16): inline article images are now OCR'd locally with Tesseract (pytesseract + the tesseract-ocr/tesseract-ocr-eng binary added to both Dockerfile and Dockerfile.prod) during ingest, and the extracted text is folded into article.content as [Image OCR: <url>] blocks. Because the text lands in the article body before ContentProcessor cleaning, every downstream consumer — the Cmdline/ProcTree/HuntQueries/Registry/Services/ScheduledTasks extractors, Sigma generation, RAG search, and dedup — sees the image text with no consumer-side changes (the URL-header marker matches the existing [Image OCR:] regex in src/web/routes/scrape.py). New service src/services/vision_ocr_service.py runs an atomic pre-processing pre-pass (ocr_raw_articles) wired into all four source-ingest entrypoints — check_all_sources, check_source, collect_from_source, and the CLI collect (per-source, before the flatten) — each wrapped so an OCR failure can never break ingest. Disabled by default and inert until enabled: global env OCR_INGEST_ENABLED plus a per-source tri-state source.config['image_ocr_enabled'] (no schema migration). The image fetch is SSRF-guarded: a custom httpcore network backend resolves DNS once and pins the validated IP at connect time (defeats DNS-rebind), rejecting loopback/link-local/private/CGNAT/IPv4-mapped-IPv6 targets, with per-redirect-hop revalidation, trust_env=False, a byte cap, and a Pillow decompression-bomb guard; the surface had an adversarial security review during implementation and a clean independent security scan. Candidate images are filtered strictly and scoped to the selected main-content node (no sidebar/related images). Also adds: a retry-stable backfill script (scripts/backfill_image_ocr.py) that recovers each row's content_hash basis by comparison (never by content_hashes-row existence), upserts content_hashes, and is collision-safe; a tesseract block in /api/health and a worker-startup probe; and spec-§6 observability logging. Behavior-preserving refactor of ContentCleaner (extracted prepare_soup_for_selection + find_main_content_node, golden-snapshot pinned) lets the pre-pass inject into the same subtree the cleaner later selects. Design: docs/superpowers/specs/2026-06-15-image-ocr-ingest-design.md; 164 feature tests + smoke green.
  • AGENTS.md contract: "No hard LMStudio dependency" feature constraint (2026-06-15): added a Feature Constraints section to AGENTS.md requiring new features to work with cloud models alone (OpenAI / Anthropic). LMStudio (local inference) is an optional provider — many deployments are cloud-only and never run a local model server — so a feature must never require a local provider; local-only paths are gated behind capability checks (src/services/capability_service.py, src/utils/model_validation.py) and degrade gracefully when only cloud providers are configured.
  • Real-Tesseract OCR regression tests (2026-06-22): three unmocked-engine tests in tests/services/test_vision_ocr_service.py plus a checked-in golden fixture (tests/fixtures/ocr/known_text_sample.png) that prove the OCR path actually reads text — ocr_image_bytes recovers known tokens from a real PNG, ocr_article_images carries that text into a [Image OCR: …] content block end-to-end through the orchestrator with only the network fetch stubbed, and the /api/health Tesseract probe reports a real non-empty version on a true install. Every other OCR test mocks pytesseract, so none exercise the real binary; these guard against a silent Tesseract/pytesseract upgrade that degrades extraction and against a probe that disagrees with the install. All three auto-skip where the binary is absent and run in CI's Docker image (which installs tesseract-ocr).

Changed

  • Image OCR ingest switched on by default (2026-06-17): OCR_INGEST_ENABLED now defaults to true in docker-compose.yml for the web, worker, and cli services (was opt-in at initial ship, 2026-06-16). Five news sources with high image-to-signal noise ratios (general cybersecurity news outlets) were seeded with an explicit image_ocr_enabled: false override in config/sources.yaml to opt out individually; all other sources OCR by default. Design: docs/superpowers/specs/2026-06-17-image-ocr-global-default-source-override-design.md.
  • ATT&CK platform-signal map regenerated from the maintained source; version stamp fixed (2026-06-21): config/attack_technique_platforms.json regenerated from MITRE's full x_mitre_platforms taxonomy — 46-entry curated seed → 210 platform-discriminative (sub-)techniques (ATT&CK v19.1) used by the reinforce-only ATT&CK→platform signal. scripts/build_attack_platform_map.py was repointed from the deprecated mitre/cti bundle (which no longer embeds the x-mitre-collection version object, so the provenance stamp read mitre_attack_stix:unknown) to MITRE's maintained attack-stix-data repo, and the version lookup was extracted into a hardened _collection_version() that degrades to "unknown" — never the literal "None" — when the field is absent; _source now reads mitre_attack_stix:19.1. New tests/scripts/test_build_attack_platform_map.py locks version extraction + the platform-discriminative filtering doctrine. Design context: docs/superpowers/specs/2026-06-20-unified-keyword-platform-scoring-design.md.
  • Hunt scoring made platform-complete: high-fidelity macOS/Linux perfect discriminators (2026-06-21): the keyword hunt scorer (HUNT_SCORING_KEYWORDS, src/utils/content.py) was ~99% Windows, so genuinely-huntable non-Windows articles scored near-zero — with zero perfect discriminators the geometric formula caps an article at ~25, well below the 85 auto-trigger gate, so macOS/Linux articles were dropped before processing. Added validated low-FP non-Windows carriers as perfect discriminators (the 75-pt tier — the only one that can move the 85 gate): macOS osascript, do shell script, launchctl, LaunchAgents, LaunchDaemons, com.apple.quarantine, xattr, TCC.db, dscl, plutil, kextload, .plist (osascript/TCC.db promoted from good); Linux xmrig, memfd_create, chattr +i, proc/self/exe, history -c, rc.local, ld.so.preload, cron.d, plus base64 -d and chmod 777 (promoted from good after per-token context review). Generic admin tokens (systemctl/crontab/chmod +x/nohup/insmod/bashrc/dev/shm) stay good-tier to avoid benign-sysadmin false positives (chmod +x good vs chmod 777 perfect is deliberate). The shared perfect-discriminator set (92→114) also fixes the content/junk filter for free — macOS/Linux command chunks are now auto-preserved. Real-article re-score: stuck non-Windows articles lifted from ~8–24 to 70–91 (BlueNoroff 23.7→89.4, Bad Apples 12.3→85.0, PeerBlight 73.1→91.3). The 85-gate-vs-75-perfect-asymptote geometry remains the binding constraint for single/double-carrier articles; the configurable auto_trigger_hunt_score_threshold is the remaining lever (operator's call). Calibration spike + per-token FP analysis: spec §9.1–§9.4. Tests: tests/test_threat_hunting_scorer.py::TestNonWindowsHuntScoring (8, TDD) + content-filter/keyword consumers green. Counts: perfect 92→114, good 89→94 (docs updated).
  • Unified keyword/platform scoring — faceted registry as single source of truth (2026-06-22): completes the design in docs/superpowers/specs/2026-06-20-unified-keyword-platform-scoring-design.md. Phase 1: config/keyword_registry.yaml (591 faceted entries — each carries an optional hunt-scoring tier and/or platform tag) becomes the one source of truth; HUNT_SCORING_KEYWORDS is now derived from it (src/utils/keyword_registry.py), guarded by a byte-equal parity test against a committed snapshot (decision D-A). Phase 2: os_classification is computed at scoring time (build_os_classification) and stored in article_metadata at every hunt-score persistence site (ingest/scrape/pdf/reprocess/rescore), go-forward. Phase 3: os_detection_node consumes that precomputed verdict via detect_os(precomputed=...) instead of re-scanning — the Windows safety net, low-confidence LLM adjudication, Windows-similarity override, and ATT&CK reinforcement are all unchanged; legacy articles fall back to a fresh scan. Carrier drift-fix (§9.5): 7 platform-discriminative §9 carriers (do shell script/launchctl → macOS; memfd_create/chattr +i/proc/self/exe/rc.local/ld.so.preload → Linux) now feed OS classification — cross-platform carriers (xmrig/base64 -d/chmod 777) and KB-covered ones deliberately excluded to avoid mis-routing/double-counting; 6/27 carrier articles change verdict (3 rescued from Unknown). G4 cleanup (§14): config/platform_classification_kb.yaml deleted, classify_platforms/DEFAULT_KB_PATH retired, and the detect_os fallback repointed to project_platform — the registry is the sole platform vocabulary, so precomputed and fallback share one verdict. Phase 4 (single-pass scan-engine dedup) deliberately NOT built (§13 — the hunt scorer's word-boundary matcher and the platform classifier's substring matcher can't unify in one pass without breaking parity, for marginal gain on a low-traffic axis). Hunt-score + junk-filter parity preserved throughout (tiers untouched). Tests: tests/test_keyword_registry.py + detect_os/platform-classifier/workflow suites; 133+ regression green.
  • Low-confidence platform-adjudication prompt is now operator-configurable (2026-06-22): the LLM fallback (platform_adjudicator, which runs only when the deterministic registry classifier is low-confidence/Unknown) now uses the configurable OSDetectionAgent prompt as its system message — build_adjudication_messages/adjudicate_platforms gained a system_prompt param, and os_detection_node resolves config_obj.agent_prompts["OSDetectionAgent"] and threads it through _maybe_adjudicate_platform, defaulting to the built-in ADJUDICATION_SYSTEM. The strict-JSON output contract stays hardcoded in the user message so operator prompt edits can't break parsing. The OSDetectionAgent seed prompt was rewritten from the retired single-label format to the adjudicator's multi-platform contract — giving the previously-vestigial prompt a real purpose (it never fed the deterministic detector). Tests: tests/services/test_platform_adjudicator.py (custom system prompt used, blank→built-in default, passed to the LLM, seed aligns with contract).
  • Security dependency bumps (CVE remediation) (2026-06-22): bumped six packages flagged by pip-audit. Runtime (pyproject dependencies + requirements.txt): starlette 1.0.1->1.3.1 (CVE-2026-48817/48818/54282/54283 - request/response + multipart; FastAPI 0.136.3 requires only starlette>=0.46.0, so the bump is resolver-safe), pydantic-settings 2.14.1->2.14.2 (GHSA-4xgf-cpjx-pc3j), pypdf 6.13.0->6.13.3 (GHSA-jm82-fx9c-mx94), langsmith 0.8.9->0.8.18 (GHSA-f4xh-w4cj-qxq8). Test-only (pyproject test group, not mirrored into the runtime requirements.txt): pip 26.1->26.1.2 (PYSEC-2026-196), msgpack 1.1.2->1.2.1 (GHSA-6v7p-g79w-8964) - both transitive via pip-audit. Lockfile bumped surgically with uv lock --upgrade-package (only the six stanzas changed). torch/diskcache left as-is (no upstream fix, verified not reachable). pip-audit now reports 2 (those two) down from 11; full category suite green on the new pins (smoke/unit/api/integration/ui).

Removed

  • OS-detection embedding model config (CTI-BERT/SEC-BERT) for Platform Detection (2026-06-22): platform detection has been entity-driven since 2026-06-19, so the embedding model was never loaded for it — the config field was dead and the Workflow Config UI misleadingly showed a "Platform Detection Embedding Model" dropdown. Removed end-to-end: the Embeddings.OsDetection schema field + its OSDetectionAgent_embedding serialization (workflow_config_schema.py), the load/save mappings (workflow_config_loader.py), the v1→v2 build (workflow_config_migrate.py now strips the deprecated key from stored configs so they pass extra="forbid"), the two config-modal dropdowns + all save/preset/display JS (workflow.html, replaced with a "deterministic — no model" note), the vestigial workflow read (agentic_workflow.py now constructs OSDetectionService() with no model), and the embedding key from the 9 quickstart presets. Stored configs carrying the old field load cleanly (stripped, no validation error). Sigma similarity's embedding (Embeddings.Sigma) and the separate Huntable-Windows CTI-BERT classifier are unaffected. Docs updated (docs/features/os-detection.md rewritten to the entity-driven reality; agent-config-schema.md, concepts/agents.md, reference/api.md). Tests: full tests/config/ suite green (659).

Fixed

  • PDF export resolves theme color tokens before canvas capture (2026-06-22): exportArticleToPDF in src/web/templates/article_detail.html now reads the computed --*-print/keyword-highlight CSS custom properties into concrete color values up front (a resolveThemeColor helper feeding a pdfColors map) instead of handing raw var(--…) strings to html2canvas/html2pdf, which cannot resolve CSS custom properties at capture time. Previously the print container, title, content wrapper, the five keyword-highlight categories, and the html2canvas backgroundColor could capture with unresolved or blank colors. Regression: tests/playwright/article_detail.spec.ts [ARTICLE-006] stubs html2pdf and asserts no var( survives into the captured container/content/title styles or the canvas background, and that the export raises no error notification.
  • Test runner no longer accumulates Allure results without bound (2026-06-22): the global pytest addopts forced --alluredir=allure-results on every invocation (including ad-hoc pytest <path> runs) and nothing pruned the directory, so it grew to millions of files and filled the disk. Removed --alluredir from the global addopts (run_tests.py re-adds it itself only when a report is wanted), and tests_runner/runner.py now clears allure-results/ at the start of each run via a new _clear_directory_contents helper. Tests: tests/test_run_tests_artifact_cleanup.py (helper clears files/subdirs but keeps the directory; absent dir is a no-op; the global addopts carries no --alluredir).
  • Enterprise-auth adversarial-review fixes (2026-06-25): added value-level secret scrubbing to audit redaction so a credential embedded in a free-text string (e.g. a GitHub token in a git error message) cannot reach audit metadata; role-gated the destructive article routes (DELETE /api/articles/{id}, bulk-action, mark-reviewed) that were reachable by any authenticated zero-role user; tightened the route-manifest validator to flag any unsafe route silently downgraded to authenticated-only (explicit allowlist); and stopped the CSRF fetch shim from attaching the token to protocol-relative cross-origin URLs. Also fixed setup.sh silently discarding the generated SECRET_KEY (a dead sed placeholder) by writing it through startup_set_env_key.
  • Full-repo documentation audit (accuracy, relevancy, gap-fill) (2026-07-05): reviewed ~185 Markdown files (root, docs/**, historical dirs) against runtime code, tests, and git history; ~55 factual corrections across ~50 files (missing NetworkIndicatorExtract in agent enumerations and UI-contract tables, wrong API paths/HTTP codes, stale eval-set counts, dead code references, pre-v7.5.0 Platform-Detection-as-gate framing). Two cross-cutting findings: docs/index.md/reference/versioning.md still called image-OCR-ingest "disabled by default" though docker-compose.yml has defaulted OCR_INGEST_ENABLED=true since commit 464b3452 (2026-06-17, in this release's range) with no CHANGELOG record of the flip until now; and README.md/AGENTS.md described the removed autonomous source-healing service (source_healing_service.py, moved out of the codebase in v6.2.1) as a live automatic feature — corrected to describe the actual mechanism, the operator-invoked source-healing Claude Code skill. Deleted docs/guides/evaluate-models.md (documented scripts/eval_extract_agent.py/compare_evaluations.py, both removed in v6.2.0). Gap-filled two undocumented route modules (scheduled_jobs.py, observable_training.py) into existing pages. Deferred two follow-ups as Todoist tasks (Huntable CTI Studio project, Intake section): reciprocal NetworkIndicatorExtract boundary rules across the six pre-existing extractor contracts plus its missing drop-in prompt (extraction-semantics content requiring operator sign-off, not auto-generated); and a dedicated docs/features/image-ocr-ingest.md page. mkdocs build --strict: 0 warnings/errors throughout.

[7.4.1 "Europa"] - 2026-06-14

Changed

  • CmdlineExtract spec: detection-logic de-laning + Complete-Artifact Rule (heavy instance) + objective-only relevance gate (score-comparability era boundary) (2026-06-13): Cmdline iteration of the fleet-wide Complete-Artifact Rule ratified in docs/development/extractor-lane-partial-enablement-2026-06-12.md — the heavy instance (§7.2), the last extractor to de-lane. docs/contracts/cmdline-extract.md no longer defers commands that appear only inside detection logic to HuntQueriesExtract: detection/hunting/mitigation prose was already eligible (2026-06-04 relaxation), and now the rule/query bodies are too. Complete-Artifact Rule (heavy instance): a command matched inside a rule/query is extractable only when the matched value is the command ITSELF — a verbatim single-line invocation that independently satisfies POSITIVE SCOPE — never a predicate. Unlike the structured siblings (whose positive scope auto-gates fragments), a command line is a free-form string that detection logic stores mostly as fragments, so the guard is explicit and two-signalled: (1) matching operator discloses fidelity — exact match (Sigma default/|equals, KQL ==/=~, SPL exact) carries a full command and is EXTRACTABLE; fragment operators (|contains/|all/|startswith/|endswith/|re, KQL contains/has/matches regex, SPL wildcard/like/rex) carry a substring and SKIP; (2) value-shape fallback for ambiguous-operator vendor queries — the matched string must independently pass positive scope. Anchored on the existing ARGV "representation, not a command line" exclusion + the POSITIVE/1 "not assembled from fields or representations" rule, so it extends current logic rather than adding a new philosophy. The ARCHITECTURE CONTEXT HuntQueriesExtract boundary bullet is reworded from region-wide deference to type-discipline (don't emit the finished rule/query; a command inside it IS extractable under the rule). YARA stays excluded entirely (file-content signatures, not telemetry). Also drops the subjective "no detection-engineering value → SKIP" half of the DETECTION RELEVANCE GATE, keeping only the objective Sysmon-1/4688/EDR-observability requirement — parity with the ProcTree ruling, removing a model-variance source. Mirror edits applied to docs/contracts/cmdline-extract-dropin.md; doc footer date corrected to 2026-06-13. Eval ground truth re-audited under the new spec in the same session. Total extraction volume may rise under full enablement via multi-facet extraction — by design, not double-counting (see design doc §4).
  • ServicesExtract spec: detection-logic de-laning + Complete-Artifact Rule (score-comparability era boundary) (2026-06-13): Services iteration of the fleet-wide Complete-Artifact Rule ratified in docs/development/extractor-lane-partial-enablement-2026-06-12.md (§7.3 light instance). docs/contracts/services-extract.md no longer defers service artifacts that appear only inside detection logic to HuntQueriesExtract, and detection/hunting/mitigation prose describing a service is now a valid source — under partial agent enablement those deference rules were coverage holes. Complete-Artifact Rule (light instance): a service artifact matched inside a rule/query is extractable only when the matched value is a full service_name or image_path satisfying Gate 2; a ServiceName|contains: 'Mal' fragment or partial path already fails Gate 2 automatically. The ARCHITECTURE CONTEXT HuntQueriesExtract boundary rule is reworded from region-wide deference to a type-discipline rule (don't emit the finished-detection-logic artifact; service attributes inside such queries ARE extractable under the Complete-Artifact Rule). The "ONLY inside a YARA rule" exclusion is retained. Mirror edits applied to docs/contracts/services-extract-dropin.md. Eval ground truth re-audited under the new spec in the same session. Total extraction volume may rise under full enablement via multi-facet extraction — by design, not double-counting (see design doc §4).
  • ScheduledTasksExtract spec: detection-logic de-laning + Complete-Artifact Rule (score-comparability era boundary) (2026-06-12): ScheduledTasks iteration of the fleet-wide Complete-Artifact Rule ratified in docs/development/extractor-lane-partial-enablement-2026-06-12.md (§7.3 light instance). docs/contracts/scheduled-tasks-extract.md no longer defers scheduled-task identities that appear only inside detection logic to HuntQueriesExtract — detection queries and rules (Sigma, KQL, SPL, EQL, XQL, vendor hunting queries) are now a valid source when a TaskName condition carries a complete task name or task path as the matched value. Complete-Artifact Rule (light instance): a TaskName|contains:, |endswith:, |re:, or other partial-match condition carries a fragment and is still excluded — the existing positive-scope requirement for a full task name/path already gates this automatically. The boundary-rule bullet at ARCHITECTURE CONTEXT is reworded from a region-wide deference ("do not extract detection queries — HuntQueriesExtract owns those") to a type-discipline rule ("do not extract the finished-detection-logic artifact; task identities inside such queries ARE extractable under the Complete-Artifact Rule"). Mirror edits applied to docs/contracts/scheduled-tasks-extract-dropin.md. Also fixes a doc-internal contradiction in the confidence scoring range: 0.3-0.59 for "bare task_name only" corrected to 0.5-0.59, consistent with the < 0.5 fail-closed floor and the dropin. Eval ground truth re-audited under the new spec in the same session. Total extraction volume may rise under full enablement via multi-facet extraction — by design, not double-counting (see design doc §4).
  • RegistryExtract spec: detection-logic de-laning + Complete-Artifact Rule (score-comparability era boundary) (2026-06-12): Registry pilot of the fleet-wide Complete-Artifact Rule ratified in docs/development/extractor-lane-partial-enablement-2026-06-12.md (§7.3 light instance). docs/contracts/registry-extract.md no longer defers registry artifacts that appear only inside detection logic (Sigma/KQL/SPL/EQL/XQL/vendor hunting queries) to HuntQueriesExtract, and detection/hunting/mitigation prose grounded in the article is now a valid source — under partial agent enablement those deference rules were coverage holes (an enabled RegistryExtract extracted nothing from rule bodies even when no HuntQueriesExtract was running). New COMPLETE-ARTIFACT RULE: a registry artifact matched inside a rule/query is extractable only when the matched value is a full hive-rooted path; hive-less fragments under |contains/|endswith/|startswith/|re still SKIP (the existing hive-root positive scope is the gate), and value name/data only when literally present in the matched value. The "ONLY inside a YARA rule" exclusion is retained (operator ruling 2026-06-12: YARA is file-content signature territory, not telemetry detection logic). Mirror edits applied to docs/contracts/registry-extract-dropin.md. Eval ground truth re-audited under the new spec in the same session. Total extraction volume may rise under full enablement via multi-facet extraction (one source span can yield Cmdline + Registry + HuntQueries records) — by design, not double-counting. Two adjudication clarifications ratified during the same audit: (a) FIDELITY transport-noise exception — HTML-to-text whitespace artifacts and command-syntax escaping (PowerShell doubled backslashes, HKCU: drive-colon) are stripped, not preserved; (b) COUNT SEMANTICS — prose-enumerated value-name lists collapse into the parent key item unless a value name has its own dedicated statement.
  • ProcTreeExtract spec: structured telemetry + expanded verbs + objective-only relevance gate (score-comparability era boundary) (2026-06-12): resolves an internal tension in docs/contracts/proctree-extract.md where §POSITIVE/Valid Sources accepted raw telemetry (Sysmon EID 1 ParentImage/Image, EDR process-tree events), but rules 2 and 3 required parent+child+verb to appear in "the narrative text" and "the same sentence" — multi-line key-value telemetry blocks satisfied neither. Three coordinated edits ratified during a ProcTreeExtract eval-fixture audit: (1) added a top-level STRUCTURED TELEMETRY EXTRACTION section formalizing Sysmon EID 1 ParentImage/Image, Security 4688 Creator Process Name/New Process Name, and EDR process-tree records as explicit creation evidence WITHOUT a natural-language verb; a contiguous block of key-value lines describing one event is now treated as a single statement for rules 2 and 3, and the verification checklist + FINAL REMINDER updated accordingly. (2) Verb-list expansion — added present-tense forms (spawns/launches/executes/starts/creates/invokes/initiates, runs/ran) and multi-word phrases (creates a child process, spawned a process, launched a process); the existing "used / via / leveraged" implied-relationship SKIP is preserved (so uses <exe> to execute <exe> still SKIPs — deliberately deferred). (3) Detection Relevance Gate kept the objective telemetry-observability requirement (Sysmon EID 1, Security 4688, EDR process-tree) and dropped the subjective "no detection-engineering value, SKIP" clause that introduced model variance. Mirror edits applied to docs/contracts/proctree-extract-dropin.md. Schtasks.exe parent — clarified, not SKIPped: the doc rubric does NOT exclude schtasks.exe as a parent. If an article literally says "schtasks.exe spawned notepad.exe", the LITERAL TEXT EXTRACTOR principle requires extracting the pair; the seed's blanket "schtasks.exe is registration, not spawn" exclusion is an inference-based rule the doc never adopted, and is not adopted here either. Score-comparability impact: ProcTreeExtract eval results before/after this commit are not directly comparable — eval ground-truth re-audited under the new spec lands in a separate fixture-correction commit. Diagrams remain SKIPped; the confidence-score schema is unchanged (deferred — schema review required first). The seed (src/prompts/ProcTreeExtract) and the 9 quickstart presets are NOT yet propagated — that is a separate Step 7.5 decision; the seed currently extends the doc with five inference-based exclusions (enumerated non-.exe extensions, product/family/role names, schtasks.exe parent, DLL sideloading explicit, injection-disguised-as-spawn) that are mostly redundant restatements of doc rules but include the schtasks rule above which actively contradicts the doc. (4) Detection-logic and defensive-guidance sources opened (operator-ruled mid-audit, second ruling): the blanket NEGATIVE-scope exclusions for detection logic (Sigma/KQL/etc.) and defensive guidance are removed — detection, hunting, and mitigation sections are now valid lineage sources via BOTH descriptive prose ("rundll32.exe launches cmd.exe") and rule/query field conditions (Source.Process.Name + Target.Process.File.Name, ParentImage + Image), provided the rule/query targets process creation; child-only queries state no pair. This extends the CmdlineExtract 2026-06-04 precedent (hunt-prose eligibility) further than cmdline went — query bodies are eligible here, consistent with the telemetry-block principle (field schema is the creation evidence). The query/rule artifact itself remains HuntQueriesExtract's (carve-out mirrors RegistryExtract's reg.exe-command rule). Two new edge cases codify the session's adjudications: hunt-query lineage dedup (prose + body stating the same pair emit once) and distributive child-list prose ("Child processes (cmd.exe, powershell.exe) spawned by wsusservice.exe or w3wp.exe" → all four literal combinations). YARA stays excluded (encodes no process lineage); generic speculation with no tie to the observed intrusion stays excluded. Resulting GT deltas in the same audit: WSUS/Picus 2 → 4 (distributive prose), TeamCity/Fortinet 0 → 3 (java.exe -> schtasks.exe, java.exe -> cmd.exe, rundll32.exe -> cmd.exe — all from hunt/mitigation prose + query bodies).

Fixed

  • Article detail keyword highlights survive saved annotation restore (2026-06-12): saved huntability annotations now wrap the existing article DOM fragment instead of replacing it with plain text, preserving server-rendered keyword highlights inside annotated ranges. Automatic keyword overlap display now lets Perfect discriminator highlights supersede all other matches, while non-Perfect overlaps prefer the longer, more-specific match so compound LOLBAS executables such as cmd.exe stay intact instead of fragmenting into cmd + .exe.
  • Fallback cleanup: secret storage, SigmaSim labels, and agent-model inheritance (2026-06-12): removed client-side localStorage secret fallbacks for OpenAI/Anthropic/Langfuse/GitHub settings, scrubbed old secret keys from browser settings storage, and made enrichment flows read API keys only from server-side settings. Replaced stale SigmaSim "legacy/deterministic engine" UI wording with "precomputed atom path" / "on-the-fly atom path"; reworded Extract sub-agent model blanks as inheritance from ExtractAgent instead of "fallback" models; removed stale OS Detection LLM fallback display/docstrings. Documented retirement criteria for the remaining workflow-config/prompt compatibility readers so they stay explicit compatibility boundaries until persisted v1 shapes are retired. Fixed a chunk-debug modal Escape race where dismissing the loading overlay could reopen the result modal after the background request completed.
  • run_cli.sh / manage_allure.sh hardcoded the missing docker-compose v1 binary (2026-06-08): both scripts invoked docker-compose (the legacy standalone binary), which isn't installed on modern Docker Desktop setups that ship only the docker compose v2 plugin — so ./run_cli.sh … failed immediately with docker-compose: command not found, making the entire CLI unusable on those machines. Both now detect the available form (v2 plugin preferred, legacy fallback) the same way scripts/startup_common.sh and scripts/run_mcp_server.sh already do, erroring clearly only if neither exists.
  • MCP semantic search broken on Intel Mac (Could not load embedding model: 'NoneType' object is not callable) (2026-06-08): the MCP server launched via scripts/run_mcp_server.sh ran in the host venv, but torch/sentence-transformers have no macosx_x86_64 wheel and are intentionally excluded there — so the host import guard set SentenceTransformer = None and every query-time semantic tool (search_sigma_rules, search_unified, semantic search_articles) failed. The launcher now runs the server inside the Docker cli container (docker compose run --rm -T cli python run_mcp.py), where the Linux image always has the ML deps, so semantic search works on every platform; the in-container DATABASE_URL removes the host localhost-from-password assembly. Verified end-to-end: a real JSON-RPC handshake returns ranked Sigma rules at ~0.8 similarity. tests/test_mcp_launcher_contract.py updated to lock the Docker contract (static run --rm -T cli guard + no-host-venv assertion; integration handshake skips cleanly when Docker is unavailable, e.g. the GHA services:-based CI job). README, docs/quickstart.md, docs/reference/mcp-tools.md, and docs/features/semantic-search.md updated.
  • sigma index-embeddings OOM-killed mid-run persisted 0 of 3,728 rules (2026-06-08): the embedding indexer loaded every rule's full detection/logsource JSON up front and committed once at the very end, so an OOM kill (exit 137) on a memory-constrained host rolled back all completed work despite visible progress. Rewrote SigmaSyncService.index_embeddings to fetch PKs first, then load/encode/commit one chunk at a time (work is now durable and resumable — without --force a re-run embeds only still-NULL rows), with per-chunk gc.collect() + dropped locals to bound peak memory and a SIGMA_EMBED_RULES_PER_CHUNK env override (default 64). setup.sh (startup_sigma_sync_and_index) now detects low Docker memory (< ~10 GiB), passes a conservative chunk size, and retries once so a constrained first run self-completes instead of leaving partial coverage with only a warning. 9 tests in tests/services/test_sigma_sync_embeddings.py lock the contract (per-chunk commit count, mid-run-failure durability, env override + invalid-value fallback, resume-only-NULL); a mutation back to commit-once-at-end fails 3 of them.

[7.4.0 "Europa"] - 2026-06-11

Added

  • Langfuse: trace tags for workflow filtering (2026-06-11, commit 2afcbc2d): workflow traces now propagate execution_id:<id> and article_id:<id> tags, and LLM generation observations also propagate model:<model> via Langfuse propagate_attributes. This makes Langfuse dashboards filterable by workflow execution, article, and model without changing application behavior when Langfuse is disabled. Unit coverage updated in tests/utils/test_langfuse_v4_api.py.
  • Langfuse: sigma_repair_attempts score per trace (2026-06-11, commit 2645761a): the sigma repair loop retries each invalid rule up to 3 times, but the total attempt count was only visible by counting raw spans in Langfuse. Added score_langfuse_trace() and get_active_trace_id() to src/utils/langfuse_client.py; generate_sigma_node now calls score_langfuse_trace(name="sigma_repair_attempts", value=total_attempts) against the active trace after every sigma generation so model YAML-compliance trends are queryable over time and across model changes. The score call is a no-op when Langfuse is disabled or no trace is active — no behavior change otherwise. 9 unit tests for the new helpers + 3 workflow integration tests verifying the score is emitted / suppressed correctly.
  • Sigma enrich modal: active-config state indicator + in-place preset Save (2026-06-10, closes SigmaSim enrich-modal UX task): the AI-assisted rule enrichment modal (src/web/templates/workflow.html #enrichModal) let users load an enrichment preset and edit provider/model/system-prompt/instruction on top of it, but surfaced no indication of what the current active config was, and its Save always prompted for a brand-new name (save-as-new) — so editing a loaded preset and re-saving silently forked it. Added a #enrichPresetState indicator that shows Unsaved config / Preset: <name> · clean / Preset: <name> · modified, driven by an enrichLoadedPreset + enrichBaseline snapshot and a four-field drift comparison (provider, model, system prompt, instruction). Change/input listeners on all four controls — plus the programmatic system-prompt revert (cancelEnrichSystemPrompt) and expanded-editor save (saveEnrichExpanded) paths that don't fire input — keep the indicator live. saveEnrichmentPreset now defaults the name prompt to the loaded preset's name so confirming updates it in place via the existing upsert-by-name backend (POST /api/sigma-queue/preset/save); clearing the name still saves-as-new. Pure client-side state — no API routes or DB schema changed. 8 template-contract tests in tests/unit/test_enrich_preset_state_indicator.py; the existing loadPresetById model-set contract stays green.

Changed

  • Sigma dedup engine "semantic" → "atom" rename (2026-06-11, commits 76be4c24..e9cb2124): the behavioural-novelty / dedup engine is exact atom set-math (Jaccard × containment over canonical atom-identity strings) — no vectors, no embeddings, no ML — but was named "semantic" throughout, the single biggest source of confusion in the Sigma code. Renamed in seven reviewable slices, all behavior-preserving (3596 unit + 391 api tests green):
  • Distribution sigma_semantic_similarity → sigma_atom_similarity (76be4c24): dir, both Dockerfile COPYs, pyproject/uv-workspace metadata, requirements-sigma.txt, surgical uv.lock rename. The import package stays sigma_similarity (already neutral), so no import statements moved.
  • Precompute layer (b4818689): module sigma_semantic_precompute.py → sigma_atom_precompute.py; precompute_semantic_fields()/extract_semantic_fields() → precompute_atom_fields()/extract_atom_fields().
  • CLI (ed83cf19): sigma recompute-semantics → sigma recompute-atoms (no compat alias — the goal is to drop "semantic" entirely; operator scripts/docs updated).
  • Dead code (119a687d): removed sigma_semantic_scorer.py (an embedding-cosine fallback with zero importers, not part of the Jaccard×Containment pipeline).
  • Output contract (c78cd7cc): match-dict key semantic_details → atom_details; internal compare_precomputed_semantics/_semantic_fields_* → compare_precomputed_atoms/_atom_fields_*. The serializer and similarity-display.js keep an old-key read-fallback for rows persisted before the rename.
  • Engine labels (55148f64): the similarity_scores values "deterministic"/"legacy" → "precomputed"/"on-the-fly" at every producer. Because these are persisted in sigma_rule_queue.similarity_scores JSONB, old rows are mapped old→new on read via similarity_serialization.alias_engine_label() (mirrored in JS as aliasEngineLabel) — a retained read-time alias, not a destructive backfill. New alias unit test added.
  • UI (e9cb2124): badges "Deterministic Semantic Engine"/"Legacy Heuristic Engine" → "Precomputed Atom Set-Math"/"On-the-Fly Atom Set-Math".
  • Preserved deliberately: the genuine ML embedding/RAG "semantic search" (article search, article→rule matching, /api/search/semantic, MCP); is_sigma_similarity_available(); and the internal SIMILARITY_THRESHOLDS.deterministic/.legacy config-bucket keys (accessed by literal property, not similarity_scores labels).
  • Browser extension OCR/Vision placement + Sigma fallback copy (2026-06-11, commit dcf027f2): browser-extension article extraction now inserts [IMAGE:<src>] markers where article images appear, and send-time OCR/Vision output replaces those markers with [Image OCR: ...] blocks instead of appending all image text at the bottom. If an extracted image has no marker, the extension still appends that block as a fallback. The Agent config "Use Full Article Content (Minus Junk)" helper text now explains that sigma_fallback_enabled swaps the Sigma main content block to the junk-filtered full article while extracted observables, when present, remain included in the prompt. Regression coverage added in tests/unit/test_browser_extension_ocr_vision.py and tests/unit/test_workflow_sigma_fallback_copy.py.
  • Sigma novelty: single-extractor timing consolidation (2026-06-11, branch europa-7.2.1): SigmaNoveltyService previously routed live proposed-rule extraction through precompute_semantic_fields (which gates on a known canonical class) and candidates with missing stored atoms through the now-removed _sigma_compare_rules path. Both now use extract_semantic_fields(require_canonical_class=False), so rules with unknown telemetry classes (e.g. custom logsource) still score via live atom extraction rather than falling back to the legacy in-app Jaccard engine. sigma_semantic_precompute gains extract_semantic_fields as the primary entry point; precompute_semantic_fields becomes a strict-mode wrapper (require_canonical_class=True) preserved for the index-time write path. Removed: _sigma_compare_rules import, extract_atomic_predicates, the soft exe-field Jaccard fallback, and the dead candidate_canonical branch. recompute-semantics CLI command now clears stale semantic fields on unsupported rules (instead of leaving stale values) and runs a _clean_json_null_semantic_atoms pass to normalize JSON-null atom rows to SQL NULL. 83 unit tests green.

Fixed

  • Test isolation: Playwright trigger test no longer polls prod DB (2026-06-10, closes AgentReadyNow test-isolation task, commit 64c57164): tests/playwright/modal_stack_and_enter.spec.ts defaulted TEST_ARTICLE_ID to '68' (a real article). The "Enter key triggers primary button" test submits the trigger-workflow modal against the live :8001 server (prod DB), so every Playwright run minted a real agentic_workflow_executions row for article 68 "Intelligence Center" — the root cause of that article persistently reappearing in /workflow#executions (the 2026-05-24 test_workflow_trigger_smoke sentinel fix closed the API-smoke variant but left this second source open). Changed the default to '999999' (non-existent ID → trigger returns 404 before any DB write; modal still shows its message so the keybinding assertion holds). Static guard added (tests/unit/test_playwright_trigger_sentinel.py): asserts any Playwright spec that fills #triggerArticleId must default to a sentinel ≥ 999999, so this cannot recur. No Python-runtime or API change.
  • Prompt editor: extractor prompts render readably in the read-only view (2026-06-10, closes AgentReadyNow prompt-editor wall-of-text task): the read-only "System Prompt" box for JSON-config extraction agents (CmdlineExtract, ProcTreeExtract, RegistryExtract, ServicesExtract, ScheduledTasksExtract, HuntQueriesExtract) dumped the raw config JSON, where newlines inside the role/instructions strings are escaped (\n) and never break under whitespace-pre-wrap — one unbroken wall of text. renderSinglePrompt (src/web/templates/workflow.html) now renders the already-parsed templateData fields (Role / Task / JSON Example / Instructions, which carry real newlines) via a new renderExtractorConfigFields() helper, gated on !isEditing && isTemplateFormat && systemIsConfigJson (the system value looks like raw JSON). The edit <textarea> and the save round-trip stay bound to the raw promptParts.system JSON byte-for-byte (the backend still parses the same object on Save); Sigma and other plain-text / {system,user} agents keep the original renderer, so there is no regression. Display-only change — no API route or DB schema touched. 11 template-contract tests in tests/unit/test_workflow_extractor_prompt_readable_display.py; helper verified standalone (labels render, values HTML-escaped, empty fields omitted); smoke/unit/api/integration green (commit 01bc74ed). Browser check on the served build still pending per CLAUDE.md.
  • Cloud model catalog refresh + picker staleness (2026-06-05): The daily provider-model-catalog refresh (scripts/maintenance/update_provider_model_catalogs.py) had been a silent no-op for months — it read the API key from the bare env var OPENAI_API_KEY (empty in this deployment) instead of the live key persisted in AppSettings (WORKFLOW_OPENAI_API_KEY), so it fetched nothing and the catalog froze at gpt-5.1. Added resolve_api_key() (DB AppSettings first, env fallback, matching how the rest of the app resolves keys); the refresh now picks up new models automatically. Repopulated config/provider_model_catalog.json from the live API (gpt-5.2 → gpt-5.5-pro) and added their verified context windows to MODEL_CONTEXT_TOKENS (src/services/provider_model_catalog.py). Fixed two client-side cache bugs in src/web/templates/workflow.html: getCachedProviderCatalog() stored an updatedAt timestamp it never read, so a stale localStorage copy masked fresh models indefinitely — added a 6h TTL and made init prefer the always-current server-rendered catalog. Unified the Sigma enrichment modal's model picker onto the shared commercialModelCatalog instead of its own divergent fetch, so it stays in sync with every other picker. Regression tests added (tests/scripts/test_update_provider_model_catalogs.py, plus catalog/picker guards).

Changed

  • Sigma novelty: single atom-extraction path, retire in-app legacy scorer (2026-06-10): SigmaNoveltyService.assess_novelty no longer falls back to the in-app YAML re-parse scorer (compute_atom_jaccard / compute_logic_shape_similarity / _sigma_compare_rules). Every (proposed, candidate) pair now routes through compare_precomputed_semantics on atom sets from the sigma_similarity package — either stored index-time atoms (positive_atoms/negative_atoms columns) or live extract_semantic_fields() when a candidate lacks stored atoms. Candidates that cannot produce atoms are skipped instead of silently rescored by a second engine. New extract_semantic_fields(rule_data, *, require_canonical_class=True) in src/services/sigma_semantic_precompute.py is the shared primitive; index-time precompute_semantic_fields() keeps the strict canonical-class gate (require_canonical_class=True), while comparison-time extraction uses require_canonical_class=False so unmodeled telemetry classes can still be atom-compared (with canonical_class=None and logsource-key retrieval). Warning text updated from falling back to legacy similarity engine to semantic atom extraction unavailable. Files: src/services/sigma_novelty_service.py, src/services/sigma_semantic_precompute.py. Sigma rules comparison-path section updated.
  • Sigma similarity: Phase 5 cleanup — canonical-only API responses, drop embedding vestiges (2026-06-10, commit da3fd216): final phase of the rendering unification plan. serialize_similarity_match() (src/services/similarity_serialization.py) now emits canonical-only match dicts — the Phase-1 additive legacy aliases (similarity_score, similarity_breakdown) are retired; similarity-display.js normalizeSimilarityData() keeps reading them as a defensive adapter. Removed the misleading embedding_model request param, "behavioral-novelty-engine" label, and embedding-model dropdown from /sigma-similarity-test (the engine is behavioral atom set-math, not embeddings). Article-detail #sigma modal re-homed to canonical match.similarity / atom_jaccard (kept — still reachable via /articles/{id}#sigma). Templates: sigma_similarity_test.html, sigma_ab_test.html, workflow.html, article_detail.html. Tests: test_similarity_serialization.py, test_sigma_ab_test_api.py, test_sigma_similar_rules_api.py, test_similarity_renderer_unification.py (116 similarity tests green).
  • Sigma similarity: Phases 0–3 backend contract + shared ingress (2026-06-10, commits cfd0498e / e14a5425 / 9310f148 / e79d321a): Phase 0 locked the canonical match schema + single threshold table in similarity_serialization.py / similarity-display.js. Phase 1 added serialize_similarity_match() and wired all five live endpoints (sigma_similarity_test.py, sigma_ab_test.py, sigma_queue.py, ai.py /sigma-matches) through it. Phase 2 extracted classify_match_novelty() (sigma_novelty_service.py) as the single per-match classifier; ai.py now delegates instead of inlining thresholds. Phase 3 extended normalizeSimilarityData() to absorb containment / semantic_details.* and exported shared threshold constants. Phase 4 surface 1/4: /sigma-similarity-test retired the dead title/description/tags/signature NaN% block and renders via renderSimilarityDisplay() (e79d321a). /sigma-ab-test /compare converged onto the precompute atom extractor (eeceb925); pairwise classifier retired (cfd0498e, 329816c1).
  • Sigma similarity: Similar Rule modal + queue detail pane unified into shared components (Phase 4) (2026-06-10, commits 99cc024c / b85437b5): the "Similar Rule Details" modal was duplicated across workflow.html and workflow_executions.html with drifted behaviour (different HTML-escaping, z-index, missing ModalManager integration on the executions page) and showed only a bare Similarity: X% with no atom-diff / containment / logic-shape breakdown. Extracted showSimilarRuleDetails + closeSimilarRuleModal into src/web/static/js/components/similar-rule-modal.js (Phase 4 surfaces 2-3/4); removed the 362-line inline duplicate. The unified function renders the full breakdown via renderSimilarityDisplay(ruleData, {mode:'compact'}) and HTML-escapes every interpolated rule field (workflow.html previously did not — XSS hardening). Phase 4 surface 4/4: the queue detail pane retired buildSimilarityDetailHtml + mapSimilarityResponse/mapSimilarityResponseFromCache — the remap layer that required manual field translation after the 2026-06-05 canonical-format fix. The queue now feeds canonical matches directly into renderSimilarityDisplay({mode:'compact'}) via thin buildSimilarityConfigFrom{Response,Cache} builders; queue-specific chrome (YAML toggle, source-repo badge, footer) is retained around the shared component. Verified via template-contract and node-execution tests (192 unit assertions); browser verification of live modal open/stack behaviour is the documented post-merge step.
  • Lint: removed all ruff F841 unused-variable assignments (2026-06-10, closes AgentReadyNow F841 task): cleared 27 F841 violations across src/, tests/, and scripts/. The task's acceptance command ruff check --select F841 . --exclude .venv overrides the repo config's scripts/ exclusion, surfacing 7 violations CI never saw. All removals are behavior-preserving: dead bindings dropped while side-effecting RHS calls were kept (subprocess.run(check=True), asyncio.run/get_running_loop probes, DatabaseManager(), Celery task invocations, _seed_records/_stop_app_containers); unused as exc/as e/as observation stripped from except/with clauses; dead load/compute chains removed (manual_results eval-file load, executions_by_id batch-fetch, a duplicate LMStudio model-resolution block + its orphaned import os, the unused sigma_patterns regex list). processor.py:_detect_content_type content_lower (flagged as a possible logic gap) was removed rather than wired in — the detector is title-only by design and adding body-text rules would be a speculative behavior change. Scoped to F841 only: prior uncommitted WIP in async_manager.py / evaluation_api.py / test_database.py was left untouched. Verified: F841 clean, full ruff clean, smoke/unit/api/integration green (commit 9c59f0f9).
  • Sigma similarity: stored atom strings migrated to 3-slot format (2026-06-10, closes SigmaSim atom-identity subtask): commit f7ad0813 changed atom_identity() to emit the 3-slot field|modifier_chain|value form (dropping the redundant op slot that produced the confusing endswith|endswith display), but rules indexed before it still held the legacy 4-slot field|op|modifier_chain|value strings in positive_atoms/negative_atoms — a mixed corpus (audit: all 30,954 stored atoms across 2,947 rules were still 4-slot). Added scripts/migrate_sigma_atom_op_slot.py, an idempotent, dry-run-by-default operator tool that strips the redundant op segment in place — a pure string rewrite, not a re-parse, so it cannot change which atoms a rule has (zero risk to novelty/similarity scoring) and touches only the two atom columns. strip_op_slot() recognises all three legacy shapes in the live corpus — single-mod (endswith|endswith), multi-mod (contains|contains|all), and default-eq (eq||), including empty-field atoms — and leaves already-3-slot strings untouched. Applied live: 2,868 rows / 30,763 atom strings rewritten; post-migration audit confirms 0 legacy atoms remain and 30,954 total preserved. The affected count was independently cross-checked via SQL before apply. 21 unit tests cover the transform, list-handling, and idempotency (tests/scripts/test_migrate_sigma_atom_op_slot.py).
  • CmdlineExtract eval audit + contract relaxation (2026-06-04): Audited all 10 CmdlineExtract eval articles against the live contract and corrected counts plus item-level ground truth across every sink — config/eval_articles.yaml, config/eval_articles_data/cmdline/{articles,ground_truth}.json, the DB subagent_evaluations rows, and the OneDrive canonical xlsx. Populated two previously-empty placeholders (Bitter End → 31, RONINGLOADER → 2); notable corrections include Commented Kill Chain 22 → 56 (full Defender-kill appendix; PowerShell Set-MpPreference cmdlets excluded as out of process_creation scope) and Bitter End reconciled to 31 with curated handling of corrupted/obfuscated command blocks. Relaxed the CmdlineExtract contract (src/prompts/CmdlineExtract, deployed to agentic_workflow_config v4280): (1) removed the blanket defensive-guidance / hardening source prohibition so literal command lines in Monitor/Hunt/Recommendations prose are eligible if they pass positive scope; (2) widened the allowed-redaction list to analyst labels ([Username], [IP Address], [Hostname], <redacted>) with a disambiguation rule vs generic template slots. The detection-logic (Sigma/KQL/YARA) prohibition is retained. Regenerated the 9 quickstart presets so their embedded prompt matches the seed, and synced docs/contracts/cmdline-extract.md + cmdline-extract-dropin.md. Fixture + prompt-contract suites green (eval-sync, ground-truth, traceability/preset-sync).

[7.3.1 "Europa"] - 2026-06-08

Fixed

  • Intel Mac (macosx_x86_64) dependency resolution (2026-06-08): torch==2.11.0 dropped Intel Mac wheels; uv sync --frozen failed entirely on macOS 15 x86_64, blocking all test runs. Added PEP 508 platform markers (sys_platform != 'darwin' or platform_machine != 'x86_64') to torch, accelerate, and sentence-transformers in pyproject.toml and regenerated uv.lock. These packages are not needed for local dev or test runs on Intel Mac.
  • embedding_service / os_detection_service hard import of torch (2026-06-08): Both services imported torch at module top-level, causing ModuleNotFoundError on Intel Mac and blocking pytest collection of 35 test files. Wrapped imports in try/except ImportError with _TORCH_AVAILABLE guards; cuda.is_available() calls now short-circuit when torch is absent. Runtime inference paths are unaffected on platforms where torch is installed.
  • test_get_sigma_rule_by_id_returns_dict_for_existing_rule hard-fails on unmigrated DB (2026-06-08): Query against sigma_rules raised UndefinedTableError instead of skipping gracefully when the table did not exist. Wrapped the query in try/except so a missing table skips with a clear message rather than failing.
  • test_find_similar_content_with_chunks returns empty results (2026-06-08): The service pytest fixture patched generate_query_embedding only inside the RAGService() construction block; at test runtime the patch had expired, causing find_similar_chunks to raise silently and return []. Fixed by keeping the patcher alive for the full test via patcher.start()/yield/patcher.stop().

[7.3.0 "Europa"] - 2026-06-03

Security

  • pyjwt 2.12.1 -> 2.13.0 (PYSEC-2026-175/177/178/179) (2026-06-02): pip-audit flagged four known vulnerabilities in pyjwt 2.12.1, all fixed in 2.13.0. Transitive dep via mcp[cli] (sole parent — Authlib ships its own JOSE impl). Pinned PyJWT==2.13.0 in the pyproject.toml security-floor block and requirements.txt "Security pins" mirror (== per single-parent convention; >= reserved for multi-parent solver-conflict cases per AGENTS.md). uv.lock updated surgically (pyjwt stanza only), validated with uv lock --dry-run. Post-bump: pip check clean, pip-audit no known vulnerabilities, 160 MCP + auth tests pass.

Added

  • Sigma: automated customer-repo index cadence (daily Celery beat task) (2026-06-02, closes SigmaSim 6gmcc78): the customer-repo re-index — which keeps the dedup corpus in sync with the customer's deployed Sigma rules so generated rules are judged novel against what the customer actually has — is now an automated periodic task instead of a manual step. New index_customer_repo Celery task in src/worker/celery_app.py (Redis-locked against beat overlap / concurrent manual runs; reads the LOCAL repo working tree at SIGMA_REPO_PATH, no git-pull/remote dependency; force_reindex=False so metadata + embeddings skip already-indexed rules — a no-new-rules run is near-free) registered on a daily 04:30 beat schedule. Executes in cti_worker, which mounts the customer repo at /app/sigma-repo. Also fixed the cli service mount: it lacked ${HOME}/Huntable-SIGMA-Rules:/app/sigma-repo (web/worker/workflow_worker had it), so run_cli.sh sigma index-customer-repo silently no-op'd on a missing path — the mount is now added so manual runs work too. Verified end-to-end: the task resolves /app/sigma-repo in the worker and idempotently skips the 18 already-indexed rules. 3 task tests in tests/worker/test_index_customer_repo_task.py (lock-held → skip; missing repo → skip + lock released; happy path indexes with cust- prefix + force_reindex=False + releases lock). Operational: the daily beat entry activates on the next cti_scheduler restart (the task itself works now).
  • Model catalog: claude-opus-4-8 added (2026-06-02): Latest Opus generation, 1M context by default (no beta header required, per Anthropic 4.8 release notes). Registered in config/provider_model_catalog.json, DEFAULT_CATALOG, and MODEL_CONTEXT_TOKENS (1_000_000). Family-dedup filter handles future dated snapshots automatically — no allowlist or reasoning-prefix changes needed.

Removed

  • Model catalog: speculative GPT-5.2–5.5 entries removed (2026-06-02): Dropped gpt-5.2, gpt-5.2-pro, gpt-5.2-chat-latest, gpt-5.3-chat-latest, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano, gpt-5.4-pro, gpt-5.5, gpt-5.5-pro from config/provider_model_catalog.json and MODEL_CONTEXT_TOKENS. These IDs are not returned by OpenAI's /v1/models API — selecting them in the Workflow dropdown produced 404 model_not_found at request time (user-reported "GPT-5.5 Pro didn't work"). They were hand-added speculatively and would have been auto-stripped on the next Settings API-key save; this cleans them up proactively. The gpt-5* regex in PROJECT_OPENAI_ALLOWLIST remains unchanged — when OpenAI actually ships a 5.x successor, adding it to the catalog JSON alone will be sufficient. The test_gpt5_4_family_in_catalog regression test was replaced with test_opus_4_8_in_catalog. docs/llm/cloud-model-reference.md updated to drop the stale 5.2 rows and add the missing Anthropic table.
  • Sigma: dropped 5 of 7 write-only SigmaRuleTable embedding columns (2026-06-01, SigmaSim cleanup): SigmaRuleTable declared seven Vector(768) columns but only two are ever scored by the article→rule matching path (sigma_matching_service / rag_service, via the <=> cosine operator): embedding (whole-rule text) and logsource_embedding (the combined "signature" text — logsource + detection structure + detection fields, built by create_signature_embedding_text). The other five — title_embedding, description_embedding, tags_embedding, detection_structure_embedding, detection_fields_embedding — were write-only (repo-wide grep across src/, web templates/JS, and scripts found no readers, only writers + IS NULL coverage predicates), and detection_structure_/detection_fields_ stored a duplicate of the signature vector (sync assigned all three the same sig_emb). Removed the five columns from models.py; dropped via the established startup ALTER TABLE sigma_rules DROP COLUMN IF EXISTS … pattern in manager.py + async_manager.py (effective on next container restart). index_embeddings now encodes 2 texts per rule instead of 5 (whole-rule + signature) — a 60% cut in per-rule encode work — assigning only embedding and logsource_embedding. scripts/migrate_sigma_embeddings.py (a pre-existing, already-non-functional one-off that read section keys create_section_embeddings_text no longer returns and used the deprecated LMStudio client) was guarded with a deprecation exit pointing to sigma index-embeddings, and its SCRIPTS_AND_USAGE.md entry marked deprecated; not deleted. No behavior change to similarity scoring — the dropped vectors were never read. test_sigma_sync_embeddings.py updated to assert the 2-texts-per-rule contract; 41 sync + matching + mcp tests green.
  • Sigma novelty: dead canonical_text column and its generators removed (2026-06-01, cleanup from the "Similarity System bugs" Todoist triage): sigma_rules.canonical_text was write-only dead data — it had no readers anywhere in the codebase (templates, JS, SQL, services all grepped clean), and the generate_canonical_text method that populated it carried a latent typo (atom.get("ops", []) vs the Atom.op field) that always dropped the operator, so the column held a malformed flattened-text form that nothing consumed. This was the same dead-code finding that caused Item 3 of the audit follow-up to be skipped. Removed: the generate_canonical_text method and its only-caller helper _logic_to_string from sigma_novelty_service.py; the four writer sites (sigma_sync_service.py ×3 + docstring, sigma_commands.py + its --force help text); the canonical_text column from models.py; and the canonical_text references in the two historical scripts/migrate_sigma_* helpers (one of which called the now-deleted method). The column is dropped via the established startup ALTER TABLE sigma_rules DROP COLUMN IF EXISTS canonical_text pattern in manager.py + async_manager.py (same mechanism used for the qa_enabled / osdetection_fallback_enabled drops), taking effect on the next container restart. No behavior change — nothing read the column. 241 sigma + workflow tests green post-removal.
  • Sigma novelty: dead _normalize_aggressive / _normalize_conservative normalizers removed (2026-06-01, preserve-literal-tradecraft review): both methods (quote "→', path \→/, whitespace-collapse, plus an unimplemented argument-ordering TODO) had no caller — repo-wide grep showed only their own list-recursion — in sigma_novelty_service.py and a parallel dead copy in scripts/compare_sigma_rules.py. Beyond being dead, they embodied an "effect, not literal" normalization philosophy that is wrong for a tradecraft-hunting tool: quote char, path-separator style, whitespace, and argument ordering are literal attacker fingerprints worth preserving, not collapsing. The live comparison key (_atom_to_key) correctly normalizes only double→single backslash (a YAML/JSON encoding artifact). Removing the dead normalizers eliminates a loaded gun (code that could be wired into the comparison path later and silently start erasing literal tradecraft). No behavior change — the functions were unreachable.

Fixed

  • Sigma novelty: compute_atom_jaccard returns 0.0 (not 1.0) for two atom-less rules (2026-06-01): two rules with empty positive-atom sets were scored as a perfect match (1.0), treating rules with no behavioral signal as identical. They are incomparable, not duplicates — returning 0.0 routes them to NOVEL, consistent with the Item 11 atom-less guard. The original reproduction (two list-selection rules hitting empty sets) was already closed by bd71d9cc (Item 1) + Item 12; the residual was reachable only via filter-only/negative-only rules and the /sigma-ab-test diagnostic (no upstream atom-less guard). TDD regression tests in tests/services/test_sigma_novelty_service.py::TestComputeAtomJaccardEmptySets (two empty-set rules → 0.0; identical atom-bearing rules → still 1.0).
  • Sigma novelty: unassessable (atom-less) rules route to needs_review instead of silently enqueuing as confident novel (2026-06-01, fail-open-not-silent review): the atom-less guard in assess_novelty correctly fails open (a rule the extractor can't model is treated NOVEL and kept, not suppressed) — but it was failing silently. The guard emitted a free-text warnings: ["no_atoms_extracted…"], but summarize_rule_novelty dropped it (it reads only matches/total/behavioral), and the atom-less case has total_candidates_evaluated == 0, which the inconclusive rule (total > 0 and behavioral == 0) explicitly excludes. Net effect: the rule was enqueued status="pending", max_similarity=0.0 — indistinguishable in the queue/UI from a rule that was fully scored and genuinely found novel; a reviewer had no signal the engine couldn't assess it. Fix threads a machine-readable no_atoms_extracted flag through assess_novelty → assess_rule_novelty → summarize_rule_novelty, where inconclusive = no_atoms_extracted or (total > 0 and behavioral == 0). The atom-less case now resolves to comparator_inconclusive=True → both consumers (workflow enqueue + the /similar-rules recompute endpoint) route it to needs_review with max_similarity=None (unscored). Distinguishes the two total==0 cases: empty-corpus / nothing-to-compare (no flag) stays genuinely novel; proposed-rule-produced-no-atoms (flag set) is a failure-to-assess a human must see. Latent today (Item 12 drove the atom-less population to ~0) but the failure condition is now handled loudly whenever it occurs. Regression tests: test_no_atoms_extracted_is_inconclusive_not_silent_novel + test_empty_corpus_without_no_atoms_flag_stays_genuinely_novel (routing), test_atomless_rule_emits_no_atoms_extracted_flag (guard emits the flag). 253 sigma + workflow tests green.
  • Sigma queue inserter now validates rule_yaml is a Sigma dict before DB write (2026-06-01): Both queue-promotion paths (sigma_queue.py /add and agentic_workflow.py) previously allowed raw text — command-line snippets, JSON blobs, Python dict reprs — to be persisted in sigma_rule_queue.rule_yaml, silently producing rows the downstream Sigma pipeline rejects with HTTP 400. Corpus audit found 9 such rows (ids 24, 25, 28, 30, 31, 36, 42, 43, 67); all triaged (7 rejected, 2 annotated as already-submitted with clean PR content). Guard added at both sites: yaml.safe_load + isinstance(dict) + required-keys check (title, logsource, detection). API route returns HTTP 400 with the failure reason and a 120-char YAML preview; workflow path logs a WARNING and skips the rule. Article-existence check moved after YAML validation in the API route so callers get 400 (bad YAML) not 404 (no article) on invalid submissions. Seven regression tests in tests/api/test_sigma_queue_add_api.py cover the three real bad-data shapes from the corpus audit plus YAML-list, scalar, missing-keys, and valid-passthrough cases.

Added

  • Sigma canonical_class: web.proxy + network.dns + Windows DNS-Client fold (Coverage-Chain) (2026-06-02): web.proxy (category: proxy, no product — 55 rules) is the webserver sibling (overlapping cs-*/c-uri/c-useragent fields + keyword lists, de-risked by Conditional B) but kept a distinct class since proxy logs ≠ origin access logs (pinned by test_proxy_vs_webserver_are_distinct_classes). DNS was grouped by field schema, not source: network.dns covers SigmaHQ generic category: dns (cross-platform, 11) and zeek service: dns (5) because both use the query field (verified) and are therefore comparable; separately, the native Windows DNS-Client channel (service: dns-client, EID 3008, QueryName field — 6 rules) was folded into the existing windows.dns_query (Sysmon EID 22, also QueryName). The two DNS families are kept apart (query vs QueryName) — bridging them would need a FIELD_ALIAS_MAP entry and is left as a separate decision (pinned by test_network_dns_and_windows_dns_query_are_distinct_classes). 77 rules into the deterministic pool. 8 tests in tests/sigma_semantic_similarity/test_canonical_class.py (proxy resolution/comparability/webserver-distinctness; generic+zeek dns resolution/comparability; dns-client fold; network↔windows dns distinctness); 193 sigma_semantic_similarity tests green. Operational: live after image rebuild + recompute-semantics (same held deploy).
  • Sigma canonical_class: 3 PowerShell telemetry classes (Coverage-Chain) (2026-06-02): added windows.ps_script (Script Block Logging, EID 4104, ScriptBlockText), windows.ps_module (Module Logging, EID 4103, Payload), and windows.ps_classic_start (classic log, EID 400, Data) to CANONICAL_CLASS_REGISTRY. Kept as three separate classes, not merged — this is the deliberate inverse of the Option C registry consolidation: registry_set/add/delete merged because they share one Sysmon EID range and one field, whereas PowerShell's three categories are genuinely different telemetry sources (different EID, different field, different fidelity — 4104 carries the full deobfuscated script; classic 400's Data is limited). All corpus rules are category-only (no EventID in detection; verified) and use dict field selections, so atom extraction is unambiguous. Brings 223 previously-classless rules into the deterministic candidate pool (ps_script 178, ps_module 34, ps_classic_start 11). Not done (separate decision, flagged): cross-source PowerShell comparability — making a ScriptBlockText rule and a Payload rule for the same tradecraft compare would require field aliases (ScriptBlockText/Payload/Data → a canonical powershell.script) and carries false-merge risk; left as an explicit judgment call. 5 tests in tests/sigma_semantic_similarity/test_canonical_class.py (parametrized resolution, ps_script comparability, ps_script↔ps_module distinctness); 185 sigma_semantic_similarity tests green. Operational: live after image rebuild + recompute-semantics (same held deploy).
  • Sigma canonical_class: 7 Sysmon host-telemetry + macOS classes (Coverage-Chain) (2026-06-02): added macos.process_creation and six clean Sysmon-EID classes to CANONICAL_CLASS_REGISTRY — windows.process_access (EID 10), windows.pipe_created (EID 17+18), windows.create_remote_thread (EID 8), windows.driver_load (EID 6), windows.create_stream_hash (EID 15), windows.dns_query (EID 22). All field-based, each 1:1 with a single Sysmon EID — the same risk profile as the Option B image_load/network_connection adds, with no keyword ambiguity (verified against real corpus shapes: every rule uses dict field selections the extractor has always modeled — CallTrace|contains, PipeName, TargetImage|endswith, ImageLoaded|contains, QueryName|contains). macos.process_creation is the windows/linux sibling (category-only, no Sysmon) and is kept distinct from windows.process_creation — same category, different OS telemetry, so cross-OS comparison would be false-similar. Brings ~180 previously-classless rules into the deterministic candidate pool (macos 70, process_access 29, dns_query 27, pipe_created 20, create_remote_thread 15, driver_load 10, create_stream_hash 9), plus any Sysmon-EID-form rules carrying those EIDs. 17 tests in tests/sigma_semantic_similarity/test_canonical_class.py (parametrized category + EID resolution, process_access comparability, pipe↔driver mismatch, macOS resolves + macOS↔Windows distinct); 180 sigma_semantic_similarity tests green. Operational: live after image rebuild + ./run_cli.sh sigma recompute-semantics (same held deploy).
  • Sigma canonical_class: web.webserver (Coverage-Chain, unblocked by Conditional B) (2026-06-02): new canonical class in CANONICAL_CLASS_REGISTRY for SigmaHQ's category: webserver access-log family (tuple (None, "webserver", None, None) — webserver rules carry no product). This was the class deliberately deferred by Spike A (2026-06-01): webserver detections are predominantly keyword-list selections (XSS/SSTI/Log4j/path-traversal), and before keyword parity the precomputed extractor yielded empty atoms for them, so routing them to the deterministic path would have regressed the working on-the-fly keyword comparison. Conditional B (5514381b) closed that gap, verified here against four real corpus shapes — pure keyword (keywords: [...], condition keywords), keyword + not filter (Log4j; the filter keywords correctly become negative atoms), and mixed field+keyword (the Kemp CVE-2024-1212 shape: cs-method/cs-uri-stem field atoms and selection_keywords atoms both present). Brings 82 previously-classless webserver rules into the deterministic candidate pool. The cs-* fields are intentionally not added to FIELD_ALIAS_MAP — they resolve as-is (lowercased) and stay comparable rule-to-rule since both sides use the same SigmaHQ names. proxy (55 rules, sibling web category, overlapping fields but distinct telemetry) is not folded in — it stays in the long-tail Coverage-Chain item. Five tests in tests/sigma_semantic_similarity/test_canonical_class.py (resolution, Spike-A atom-less regression guard, two-keyword-rule comparability, mixed field+keyword, webserver↔process_creation mismatch); 163 sigma_semantic_similarity tests green. Operational: live after image rebuild + ./run_cli.sh sigma recompute-semantics (same held deploy as Conditional B / Options B/C).
  • Sigma canonical_class: windows.image_load + windows.network_connection (Coverage-Chain Option B) (2026-06-02): two new field-based canonical classes in CANONICAL_CLASS_REGISTRY — windows.image_load (category image_load + Sysmon EID 7; ImageLoaded/Signed/Signature fields) and windows.network_connection (category network_connection + Sysmon EID 3; DestinationIp/DestinationPort/Initiated/Protocol). Spike confirmed both extract comparable atoms (no keyword risk, unlike webserver). Brings 123 image_load + 66 network_connection previously-NULL-class rules into deterministic candidate retrieval. 6 resolution/comparability/mismatch tests added; 153 sigma_semantic_similarity tests green. Operational: live after image rebuild + recompute-semantics (same sequence as Option C).
  • Sigma canonical_class: registry + Windows file families consolidated (Coverage-Chain Option C, partial) (2026-06-01): CANONICAL_CLASS_REGISTRY now folds the fragmented Sigma registry categories (registry_set/registry_add/registry_delete) into windows.registry_event, and adds a new windows.file_event class covering file_event/file_delete/file_access/file_rename/file_change (+ Sysmon EIDs 11/23/26). These are one telemetry class each (same fields/EIDs); previously a registry_set rule and a registry_event rule for the same key were classified as different classes and never compared (false-NOVEL). Field aliases (TargetObject→registrypath, TargetFilename, Image→process.image) already existed, so the precomputed extractor produces comparable atoms — verified by spike. *.webserver was deliberately NOT added: Spike A (2026-06-01) showed the precomputed extractor yields empty atom sets for keyword-list selections (the XSS and SSTI webserver rules both extracted to []), so routing webserver rules to the precomputed path would regress the working on-the-fly keyword comparison (which models keywords post-Item-12). Webserver/proxy coverage is gated on the keyword-parity work (Conditional B / collapse-the-extractors). 11 new resolution + comparability tests in tests/sigma_semantic_similarity/test_canonical_class.py; 147 sigma_semantic_similarity tests green. Operational: sigma_semantic_similarity is COPY'd into the image, so this is live only after docker compose build + docker compose --profile tools build cli + up -d, then ./run_cli.sh sigma recompute-semantics repopulates canonical_class/positive_atoms for the newly-modeled ~442 registry+file rules (coverage 1,668 → ~2,110).
  • MAE chart nodes flag degraded eval runs in amber (2026-06-01): The "MAE by Config Version" chart on /mlops/agent-evals now colors each datapoint by run completeness. Green = full eval set, every record completed, no throttled/quota. Amber = any of: total_articles < eval_set_total (user ran a subset), completed < total_articles (failed/pending records), or throttled > 0 / quota_exceeded > 0 (rate-limited or quota-capped runs whose status='completed' masks the degraded MAE). Hovering an amber node surfaces a breakdown line like Quota exceeded: 30/32 runs. New top-level eval_set_total field on /api/evaluations/subagent-eval-aggregate (canonical count from config/eval_articles.yaml) drives the subset-run check; the field is populated even on the model-filter empty-result early-return so the chart can always compute coverage. Legend on the agent_evals page updated with green/amber swatches. Contract test added in tests/api/test_subagent_eval_metrics.py.

Fixed

  • Sigma sync: missing/empty rule date: no longer breaks customer-repo indexing (2026-06-02): SigmaSyncService.parse_rule_file defaulted a missing date: to "", and the date-conversion guard (if parsed["date"]:) skipped empty strings — so "" survived into the sigma_rules.date timestamp column. Because index_metadata inserts rules in a single batch, one dateless rule raised psycopg2.errors.InvalidDatetimeFormat: invalid input syntax for type timestamp: "" and rolled back the entire batch. Net effect: sigma index-customer-repo had never successfully indexed a customer rule set containing dateless rules — and 11 of the 18 rules in the live customer repo (${HOME}/Huntable-SIGMA-Rules) omit date:, so the customer re-index was fully broken. Fix normalizes date to a datetime or None (never ""): datetime is checked before date (subclass relationship), string dates are tried as ISO %Y-%m-%d (customer/LLM style) then SigmaHQ %Y/%m/%d, and anything missing/empty/unparseable → None. After the fix, sigma index-customer-repo indexed all 18 customer rules (16 classified under the upgraded engine — 15 windows.process_creation + 1 windows.dns_query; 2 unresolved bare-service: rules; corpus 3,728 → 3,746, all 18 embedded). Operational note for the index-customer-repo cadence: the command must run in a container that mounts the customer repo at the configured SIGMA_REPO_PATH — cti_web/worker mount ${HOME}/Huntable-SIGMA-Rules:/app/sigma-repo, but the cli service does not, so docker exec cti_web python -m src.cli.main sigma index-customer-repo is the working invocation today (not run_cli.sh). 6 regression tests in tests/services/test_sigma_sync_metadata.py::TestParseRuleFileDateNormalization (missing→None, empty→None, ISO/slash/native parsed, unparseable→None).
  • Sigma queue: unclassifiable logsources now flagged, not silent (extractor-alignment Finding B) (2026-06-02): SigmaAgent sometimes emits a process-behavior rule with a bare logsource: {product: windows, service: sysmon} (or service: security) carrying no category and no EventID — e.g. queue rows 193/198/199/200/201, which are well-formed rules with real tradecraft (excel→wmic→regsvr32; rundll32 payload loaders) that are really process_creation but written without the category. The canonical-class resolver correctly refuses to map bare service: sysmon (ambiguous across Sysmon EIDs 1–26 — guessing would be unsafe), so these silently fell to the weak windows|sysmon logsource_key fallback and got degraded dedup with no signal to a reviewer. They were effectively locked out of windows.process_creation — the 1,408-rule, best-covered dedup class — by a one-line logsource-formatting mistake. Fix follows the established "fail open, never silently" pattern (cf. no_atoms_extracted, comparator_inconclusive): summarize_rule_novelty now surfaces the already-computed canonical_class plus a derived logsource_unresolved boolean; both enqueue paths (agentic_workflow.py workflow generation + the /api/sigma-queue/add route) stamp them into rule_metadata and emit a WARNING when unresolved. Enqueue behavior is unchanged — the rule is still queued (these are real detections); the fix only removes the silence, making the degraded-dedup condition logged and queryable (rule_metadata->>'logsource_unresolved') so the current 5 rows and any future ones are findable and a SigmaAgent-prompt fix can target them. The resolver still never guesses a logsource. Three TDD unit tests in tests/unit/test_agentic_workflow_novelty_summary.py (resolved class surfaced; None → logsource_unresolved True; missing key → fail-open True); 40 blast-radius tests green (add-API guards, matching service, summarize). Root-cause prompt fix (prefer SigmaHQ category: over bare service:) tracked separately on SigmaSim Finding B.
  • Sigma canonical_class: EventCode now recognized as EventID (extractor-alignment Finding A) (2026-06-02): _extract_event_id_from_detection (the implicit-EventID extractor that drives Sysmon/EID-keyed canonical-class resolution) matched only EventID/EventId/event_id — not EventCode, the Windows EventLog / Splunk CIM field name for the same value. SigmaAgent emits EventCode for Splunk-backend rules (e.g. queue id 23: service: sysmon + EventCode: 22 + Image|endswith: \vbc.exe, a Sysmon DNS-query rule), so those rules resolved to the no-product/no-EID tuple → no canonical class → degraded dedup for rules our own pipeline generates. This surfaced from an extractor-alignment review (which telemetry domains our 6 extractor agents actually emit vs. canonical-class coverage): generated output is ~90% process_creation (long covered), so breadth was already ahead of need, but EventCode was a real correctness gap. Fix adds EventCode/eventcode/event_code to the recognized field names; value handling (single int, single-item list) is unchanged, so a multi-value EventCode list still yields no single id, mirroring EventID. The imported SigmaHQ corpus uses EventID exclusively (0 affected), so this is preventative there and corrective for generated/Splunk-targeted rules; also future-proofs any Sysmon-EID extractor. 10 tests in tests/sigma_semantic_similarity/test_canonical_class.py (parametrized EventCode/EventID alias extraction, single-item list, multi-value→None, and resolution of the queue-id-23 shape to windows.dns_query + EventCode: 1 → windows.process_creation); 203 sigma_semantic_similarity tests green. Operational: live after image rebuild + recompute-semantics (same held deploy).
  • Sigma novelty: precomputed extractor now models Sigma keyword-list selections (Conditional B / keyword parity) (2026-06-02, follow-up to Item 12 + Spike A): the precomputed atom pipeline (detection_normalizer → ast_builder → atom_extractor, the sigma_semantic_similarity package COPY'd into the image) dropped Sigma keyword-style selections — a list of bare scalars matched field-lessly against the raw event — at detection_normalizer._resolve_selection_content, which kept only dict items of a list ([item for item in raw if isinstance(item, dict)]). This is the exact mirror of the Item-12 bug already fixed in the on-the-fly extractor (SigmaNoveltyService.extract_atomic_predicates), and the two extractors disagreeing for keyword rules was the blocker Spike A surfaced when deferring *.webserver from Coverage-Chain Option C: XSS/SSTI/webserver keyword rules extracted zero atoms on the precomputed path and were misrouted as atom-less (NOVEL / no canonical_class). Fix: _resolve_selection_content now synthesizes one field-less contains block ({"|contains": str(value)}) per scalar list item, so each keyword becomes a real |contains|contains|<value> atom via the existing _parse_field_spec("|contains") → (field="", op="contains") path — matching the on-the-fly extractor's Atom(field="", op="contains") model. Bare non-list scalar selections (selection: "foo") still yield no atoms (parity — the on-the-fly extractor also ignores top-level scalars), and nested lists are skipped. Polarity/negation is handled by the condition tree (NotNode → negative atoms) exactly as for dict selections. This unblocks adding *.webserver/proxy classes to CANONICAL_CLASS_REGISTRY and is a prerequisite for collapsing the two extractors into one (SigmaSim 6gmgRRC44VCwQW93). Five TDD regression tests in tests/sigma_semantic_similarity/test_filter_and_atoms.py::TestKeywordListSelectionsProduceAtoms (pure keyword list, XSS keyword rule not atom-less, mixed dict+scalar list, keyword value case-folded, bare-scalar still no atoms); 158 sigma_semantic_similarity tests green. Operational: the package is COPY'd into the image, so this is live only after docker compose build + docker compose --profile tools build cli + up -d, then ./run_cli.sh sigma recompute-semantics repopulates positive_atoms/canonical_class for the keyword rules — same deploy sequence as Options B/C (currently held pending the parallel session).
  • Sigma novelty: atom_identity now preserves case for |cased atoms (2026-06-01, preserve-literal-tradecraft review): atom_identity in sigma_semantic_similarity/sigma_similarity/atom_extractor.py decided case-folding purely from the base operator (ci = op in _CASE_INSENSITIVE_OPS), ignoring the cased token in the modifier chain. So CommandLine|contains|cased: Mimikatz and …: mimikatz both lowercased to the same atom and collapsed — even though Sigma's |cased modifier forces case-sensitive matching. For a threat-hunting tool this erases a real tradecraft signal: a malware family or tool that consistently writes a specific casing is a fingerprint, and a |cased rule hunting it must stay distinct from a case-insensitive rule for the same token. Fix: ci = op in _CASE_INSENSITIVE_OPS and "cased" not in mod.lower().split("|") — case is preserved whenever cased is in the modifier chain. The default (no cased) still folds case, preserving the 2026-04-08 case-insensitive-matching fix. No corpus rule uses |cased today (0 rows), so no re-index is required; the fix is preventative for LLM-generated and customer-repo rules and takes effect on the next image rebuild. The legacy in-app path (_atom_to_key) was never affected — it does not case-fold at all. Four regression tests added in tests/sigma_semantic_similarity/test_regression_case_sensitive_atoms.py::TestCasedModifierPreservesCase (contains/endswith/eq cased preserve case; default contains still folds).
  • Sigma novelty: _polarity_for_selection_key no longer assumes filter-named selections are always negative (2026-06-01, follow-up to Item 12): the helper added in Item 12 copied a key.startswith("filter") fast-path heuristic from _extract_block_atoms but treated it as sufficient rather than as part of an outer-if check, stamping every keyword-list atom in a filter*-named selection as negative regardless of how the condition referenced it. SigmaHQ's "Remote File Copy" rule (7a14080d-a048-4de8-ae58-604ce58a795b) names a selection filter and references it POSITIVELY in the condition (tools and filter) — the @ and : scalars are positive refinements, not exclusions. Pre-fix the helper stamped them negative, breaking Jaccard math for any proposed rule that should have matched on those positive atoms. Fix removes the startswith("filter") shortcut entirely; polarity is now derived purely from the condition string (f"not {key.lower()}" in condition → negative; otherwise positive). One corpus rule re-polarized (7a14080d's @/: flipped negative → positive); collision query still returns 0 rows. Two regression tests added in tests/services/test_sigma_novelty_service.py::TestKeywordListSelectionsProduceAtoms: test_filter_keyword_list_positively_referenced_has_positive_polarity (Remote File Copy shape — would have caught the bug) and test_filter_keyword_list_negated_in_condition_has_negative_polarity (not filter shape, regression guard).
  • Sigma novelty: extractor now models Sigma keyword-list selections, eliminating residual exact_hash collisions (2026-06-01, Item 12 of docs/development/sigma-novelty-audit-followup-2026-06-01.md): extract_atomic_predicates previously handled only dict selections (field: value maps) and list-of-dict selections (Sigma "list of maps" / OR-of-selections) — Sigma's third shape, the top-level list-of-scalars (keyword-style, where the selection name is just a condition-label and each scalar is a field-less contains-match against the raw event), was silently dropped. The bug surfaced as 2 residual exact_hash collision groups (5 rules total) where the legacy extractor produced only the shared boilerplate atoms (Cs-method=GET + Sc-status=404 for two webserver_generic rules; Type=EXECVE for three linux/auditd/execve rules) and lost the per-rule keyword content that actually distinguishes XSS from SSTI, shutdown-cmd from history-file-ops, etc. Unlike the Item-11 atom-less pattern (latent because bd71d9cc's assess_novelty:280 guard catches it), this class was reachable in production — a new HTTP detection rule with the same Sc-status=404 filter + Cs-method=GET selection could be flagged a false DUPLICATE of XSS/SSTI by the existing engine. Fix: refactored extract_atomic_predicates to inline-dispatch per list item — dicts via the existing _extract_block_atoms, scalars via a new keyword branch that produces Atom(field="", op="contains", op_type="literal", value=str(scalar), polarity=…). Polarity logic factored into a new helper _polarity_for_selection_key so the keyword branch reuses the same condition-string parsing without going through the per-field path. sigma backfill-metadata gained a --force / -f flag matching the existing --force pattern in sigma index* commands; runs against all rules rather than only rows with NULL canonical_json. Corpus refresh: ./run_cli.sh sigma backfill-metadata --force recomputed canonical metadata for all 3,728 rules in 21 seconds. Post-refresh: 0 collision groups, 3,728 / 3,728 distinct exact_hash values, 0 NULL exact_hash rows (the 64 NULLed by Item 11 now produce keyword atoms and get real distinct hashes). The bd71d9cc atom-less guard remains as defense-in-depth though it now has no production rule that exercises it. Seven TDD-style regression tests in tests/services/test_sigma_novelty_service.py::TestKeywordListSelectionsProduceAtoms covering the primary contract, the XSS↔SSTI distinct-atoms proof, integer scalars (auditd initselection: [0, 6]), negated-in-condition polarity, mixed dict+scalar lists, the dict-only regression guard, and a direct assertion that the former Item-11 bug-shape rules no longer hit the atom-less guard. Four Item-11 test fixtures migrated from keywords: [...] to empty-dict selections to preserve their original guard-intent under the new code regime.
  • Sigma novelty: atom-less rules no longer produce hash-collision-prone exact_hash values (2026-06-01, Item 11 of docs/development/sigma-novelty-audit-followup-2026-06-01.md): generate_exact_hash now returns None when canonical_rule.detection["atoms"] is empty (signature widened from -> str to -> str | None), so the sigma_rules.exact_hash column is NULL for keyword-only Sigma detections the deterministic extractor cannot model. SQL NULL = NULL is false, so these rows can no longer collide as exact-hash duplicates in the database. Companion fix in retrieve_candidates: the exact-hash short-circuit branch now skips when the proposed hash is None, because SQLAlchemy translates column == None to SQL IS NULL, which would otherwise leak atom-less rows as false duplicates (the very pattern the upstream fix closes). The bd71d9cc assess_novelty atom-less guard remains in place as defense-in-depth. Corpus refreshed via a single SQL UPDATE that mirrors the new Python guard exactly (UPDATE sigma_rules SET exact_hash = NULL WHERE detection.atoms is null/empty array): 64 rows NULLed. Atom-count=0 collision groups dropped from 9 to 0. A residual 2 groups / 5 rules with non-empty but incomplete atom sets (HTTP webserver_generic XSS/SSTI rules and Linux auditd execve rules whose detection signal lives in unmodeled keywords: sections) was surfaced during verification and queued as Item 12 of the audit follow-up — the extractor needs to model keywords: arrays the way bd71d9cc modeled list-of-maps. Four TDD-style regression tests added in tests/services/test_sigma_novelty_service.py::TestExactHashAtomLessReturnsNone covering the primary contract, the happy-path regression guard, the two-rule collision-prevention proof, and the SQLAlchemy IS NULL leak guard.
  • Sigma novelty exact_hash collapsed list-of-maps rules into false duplicates (2026-06-01): extract_atomic_predicates only processed dict-valued selection blocks and silently skipped Sigma's list of maps idiom (selection: [{...}, {...}], an OR of indicator sets), yielding zero atoms. The empty canonical form then hashed to one shared exact_hash per logsource — collapsing 84 unrelated windows.process_creation rules (plus smaller groups) onto a single hash. Because the exact-hash short-circuit in assess_novelty runs before scoring and classify_novelty returns DUPLICATE on any exact_hash_match, a proposed list-of-maps rule could be flagged an exact duplicate of an unrelated rule and suppressed before reaching the queue. Two fixes: (1) extract_atomic_predicates now expands list-valued selections into per-map atoms via the new _extract_block_atoms helper; (2) a guard in assess_novelty returns NOVEL for any rule with no extractable atoms (in-app or precomputed), since an empty fingerprint cannot assert duplication. Stored exact_hash/canonical_json re-indexed across the corpus: multi-member hash groups 34→9, rules in collision groups 231→58, and provably-distinct collisions (different positive_atoms, same hash) 130→0 — the residual groups are atom-less rules now neutralized by the guard. Regression tests in tests/services/test_sigma_novelty_service.py: list-of-maps yields atoms, two different list rules produce distinct hashes, and an atom-less rule is never DUPLICATE.

[7.2.0 "Europa"] - 2026-05-29

Fixed

  • Eval bundle showed SSE-truncated messages instead of the verbatim wire copy (2026-05-28): EvalBundleService._extract_llm_call_data read attempt_entry["messages"] (the 3000-chars-per-message truncated copy written to conversation_log by agentic_workflow.py:1658 for the SSE live view) BEFORE result["_llm_messages"] (the byte-for-byte runtime copy stamped on last_result by llm_service.py:2868). When both were present, bundle viewers saw …-suffixed content while the live LLM had received the full payload — indistinguishable from real prompt truncation and a silent forensic-analysis hazard. Fix: read order swapped so _llm_messages wins; falls back to the truncated copy only for pre-instrumentation executions that lack it. Regression test (test_llm_messages_wins_over_truncated_attempt_messages) constructs an entry with both present and asserts the bundle returns the full content with no … suffix; companion test pins the legacy fallback path.
  • Eval version-compare aggregate silently discarded replicate attempts (2026-05-27): get_subagent_eval_compare computed aggregate MAE from the per-(url, version) "latest" map, which collapsed multiple attempts per article down to one row. This caused the Version Compare panel to report different aggregate metrics than the "MAE by Config Version" chart (which aggregates all rows). Additionally, when replicate runs shared an identical created_at timestamp (same enqueue batch), the strict-> comparator produced non-deterministic table rows across page refreshes. Fix: aggregates now operate on all attempts; per-article cell uses a deterministic tie-break (latest created_at, then highest id). Attempt count surfaced as attempts_a / attempts_b in the API response and rendered as an n=N badge in the UI. Regression test added.
  • chunk_content zero-overlap pairs on dense no-sentence-boundary content (2026-05-26): In sections with no sentence terminators (e.g. lists of SIGMA rule UUIDs), find_sentence_boundaries repeatedly returned the same boundary position as the previous chunk's end. The guard that prevented "overlap-only" stub chunks then reset start = chunks[-1][1], silently discarding the 200-char overlap and producing clean zero-gap seams (e.g. chunk 47 ending at 33195, chunk 48 starting at 33195). Fix: fall back to a hard character cut at start + chunk_size instead of resetting start; the overlap positioning is preserved. Only in the truly degenerate case (hard cut also inside the previous chunk) do we advance past it. Regression test added to tests/unit/test_content_filter_chunking.py.
  • Starlette 1.0 TemplateResponse API (2026-05-24): Starlette 1.0.x removed the positional (request, template, context) overload; all four call sites (pages.py, modern_main.py, workflow_ui.py, evaluation_ui.py) migrated to (request="...", context={...}) keyword form. Updated test_article_workflow_status_api.py to monkeypatch TemplateResponse from starlette.templating (the real call site) rather than the FastAPI re-export.

Changed

  • cmdline_attention_preprocessor precision overhaul and ATT&CK coverage expansion (2026-05-27): Removed nine over-broad STRING_ANCHORS (C:\, D:\, system32, appdata, programdata, ftp, ipconfig, tasklist, dllhost) and two REGEX_ANCHORS (HKLM/HKCU hive-root pattern; standalone /c//k cmd-flag pattern) that were topic-detectors rather than cmdline-density detectors — their presence caused high_likelihood_snippets to expand to near-full-article length, defeating the attention-shaping purpose. Closed multiple ATT&CK coverage gaps: added regsvr32 (T1218.010 Squiblydoo, previously entirely absent), comsvcs/lsass/mimikatz/sekurlsa/procdump (T1003.001 credential-access blind spot), DownloadFile/DownloadData/WebClient (completing .NET download-cradle coverage), mavinject (T1055.001), xwizard/presentationhost (T1218 proxy execution), and regex patterns for hh.exe (T1218.001), wsl (T1202), bash -c (T1202), at legacy scheduler (T1053.002), and tftp (promoted from string with word-boundary guard). Added cmd.exe caret-escape normalization: _normalize_for_matching() strips ^ from a copy of each line for anchor matching, closing the p^ow^er^sh^ell / c^er^tu^til obfuscation evasion class; original text is always preserved in snippets and full_article (HARD CONTRACT unchanged). Expanded NARRATIVE_VERBS with 15 prose-connector tokens (using, via, hosting, calling, leveraging, with, within, based, found, located, attempting, trying, able, known, running) to suppress false-positive R4 matches on sentences like "MSBuild.exe using the AppDomain feature". 31 new regression and coverage tests added; 80/80 passing.
  • Ingestion health Nominal threshold raised to ≥90% uptime (2026-05-26): Previously, any source with ≥1 consecutive failure triggered Degraded regardless of overall uptime. The status now derives from the weighted uptime score: ≥90% → Nominal, <90% → Degraded. The Critical threshold (≥ max(3, 20%) of monitored sources at ≥3 failures) is unchanged. A source fleet with minor isolated failures but ≥90% effective uptime now correctly shows Nominal.

Removed

  • ml_hunt_comparison_endpoint.py dead-code deletion (2026-05-24): Router module was never wired into routes/__init__.py and therefore never reachable at runtime. docs/ml-training/hunt-scoring.md updated to remove the "Legacy router still live at /ml-hunt-comparison" claim; the aggregation metrics section now carries a "Historical Reference — Not Runtime-Selectable" heading. Orphaned tests/test_ml_hunt_terminal.js (referenced the removed /api/ml-hunt-comparison/* URLs) also deleted.
  • osdetection_fallback_enabled zombie flag fully removed (2026-05-23): Field was hardcoded False since commit 9797f699 when the OS-detection fallback LLM agent was deprecated. Removed the SQLAlchemy column, schema default in workflow_config_schema.py, all 12+ route call sites in workflow_config.py, and references in 8 test files and scripts/apply_preset.py. Startup ALTER TABLE … DROP COLUMN IF EXISTS added to manager.py and async_manager.py. Migration deprecation warning retained in workflow_config_migrate.py for one release cycle to surface stale preset JSON.
  • QA agent subsystem fully removed (2026-05-23): RankAgentQA and all extractor QA agents deprecated and removed from schema, config, services (qa_agent_service.py, qa_evaluator.py), workflow, routes, and UI. qa_enabled and qa_max_retries columns dropped at startup. 3,960 tests passing.

Added

  • Drop-in extractor prompts for use outside Huntable (2026-05-29): Six standalone prompt files added under docs/contracts/ — cmdline-extract-dropin.md, proctree-extract-dropin.md, registry-extract-dropin.md, services-extract-dropin.md, scheduled-tasks-extract-dropin.md, huntquery-extract-dropin.md. Each is a transform of the matching pipeline contract: keeps the positive/negative scope, fidelity rules, edge cases, and examples matrix verbatim, but replaces the rigid {article_content} input with a flexible URL/text/PDF preamble (fail-closed — never synthesize from prior knowledge), replaces the strict JSON-only output with a readable Markdown table by default (JSON array on request), and strips sub-agent/sibling-extractor framing in favor of a short plain-English "what this does NOT cover" note. The whole prompt sits inside one fenced block so the mkdocs-material copy button grabs it in a single click. A !!! tip admonition under the H1 of every contract page links to its drop-in. Drop-ins are reachable by the inline link only; they are intentionally not nav-listed. As part of the same change, ScheduledTasksExtract was added to the Contracts nav in mkdocs.yml to clear a long-standing orphan-page warning.
  • Forensic instrumentation: wire-truth capture for extract agent calls (2026-05-28): Every run_extraction_agent invocation now stamps four new fields on last_result so bundle consumers can answer "what EXACTLY did the provider see" without code archaeology across the orchestration / dispatcher layers: _provider_payload_verbatim (the dict actually POSTed to the provider — captured inside _call_openai_chat, _call_anthropic_chat, and _post_lmstudio_chat so each provider's shape difference is preserved: Anthropic extracts system to a top-level key, OpenAI uses max_completion_tokens, LM Studio uses max_tokens); _provider_url (the actual endpoint URL hit, including LM Studio's resolved URL candidate); _post_augmentation_prompt_tokens (sum across all final messages, measured after every orchestration injection); _orchestration_injected_sections (an ordered list naming every boilerplate block the orchestration appended — title_url_header, task_line, output_format_specification, important_json_reminder, traceability_common, traceability_simple_value_footer / traceability_structured_identity_footer, cmdline_attention_snippets_section / proc_tree_attention_snippets_section, user_prefix, etc.). The eval bundle surfaces all four under llm_request with size discipline: runtime_messages_verbatim is a 4-key attestation (is_verbatim_wire_copy + source_field + source_sha256 + message_count), not a duplicate of the messages bytes; provider_payload_verbatim keeps the provider-specific envelope but its inner .messages is dehydrated to a SHA reference back to llm_request.messages. Net storage impact: llm_request envelope ~1.7 KB constant across all bundles in the v4203 eval set vs ~197 KB pre-fix. Twelve unit tests added in test_prompt_assembly.py (per-branch section coverage, preprocessor-on path, user_prefix path, provider-payload marker round-trip) and test_eval_bundle_service.py (dehydration shape, SHA cross-check, missing-inner-messages graceful handling, read-order regression, legacy fallback). Side discovery flagged for separate work: _post_lmstudio_chat's model-prefix retry closure ignores payload_retry and re-POSTs the original payload — instrumentation correctly records payload (wire truth); fix tracked separately.
  • n=N replicate badge in eval version compare table (2026-05-27): When an article has more than one attempt for a given config version, the per-article cell in the Version Compare table now shows n=N in muted text. The badge clarifies that the displayed value is the single latest attempt while aggregate MAE averages all N attempts.
  • Langfuse setup guide: "Why connect Langfuse" section (2026-05-27): Added a "Why connect Langfuse" section to docs/guides/langfuse-setup.md explaining the five concrete benefits: full prompt/response replay per agent call, one-click execution-to-trace navigation, retroactive eval enrichment, experiment/prompt-version tracking, and operational health visibility.
  • docs/development/add-extractor-agent.md (2026-05-26): Companion reference page for the Create-Huntable-Agent skill. Summarises all 7 integration layers, the 6 naming identifiers that must be consistent before writing any code, and the 5 highest-severity silent-failure pitfalls. Links to the interactive wiring-map HTML (create-agent-wiring-map.html) in the same directory. Nav entry added to mkdocs.yml.
  • MCP execute_sql and list_tables tools (2026-05-26): Two new read-only SQL tools registered on the MCP server. list_tables returns the full public schema (tables, columns, types, nullability, defaults) formatted for LLM consumption. execute_sql accepts a plain SELECT statement, strips comments, rejects any non-SELECT or multi-statement input, enforces SET TRANSACTION READ ONLY at the session level, and returns results as an ASCII table capped at 200 rows × 300 chars/cell. 42 unit tests in tests/unit/test_mcp_query_tools.py.
  • Playwright persistent browser profiles for WAF-gated sources (2026-05-26): PlaywrightScraper now accepts a user_data_dir parameter; when set, launch_persistent_context is used instead of a fresh browser, so cookies and localStorage (including WAF session tokens from OVH/nginx JS challenges) survive between collection runs. ContentFetcher resolves the profile directory at /app/logs/playwright-profiles/<source_identifier> when a source config has playwright_profile: true. Enables multi-run collection for JS-WAF-protected sources like Sekoia.io that require stateful cookie handshakes.
  • _pattern_based_classification degraded-mode unit tests (2026-05-24): tests/unit/test_content_filter_pattern_fallback.py — 6 tests covering the two fallback triggers: (1) self.model is None (ML model not loaded / sklearn unavailable) and (2) model.predict raising at runtime (corrupt model, feature shape mismatch). Verifies return type (bool, float), correct huntable/non-huntable classification on representative text, and confidence bounding to [0, 1] regardless of raw score.
  • Sigma novelty: canonical class coverage extended to registry, service, and scheduled-task telemetry (2026-05-23): CANONICAL_CLASS_REGISTRY in sigma_semantic_similarity/sigma_similarity/canonical_logsource.py previously covered only windows.process_creation and linux.process_creation — rules from RegistryExtract, ServicesExtract, and ScheduledTasksExtract fell through to the legacy logsource_key + top_k=20 path. Added windows.registry_event (category + Sysmon EIDs 12/13/14 + Security 4657), windows.service (category service_creation + System 7045/7036 + Security 4697), and windows.scheduled_task (taskscheduler service + Security 4698/4699/4700/4702). FIELD_ALIAS_MAP in both sigma_novelty_service.py and atom_extractor.py extended with the corresponding service/scheduled-task aliases so cross-field duplicate detection works inside the new classes. Known limitation documented in docs/contracts/extractor-standard.md: scheduled-task behavior observable across process_creation (schtasks.exe), file_event (\Tasks\ writes), registry_event (TaskCache keys), and security EID 4698 remains in separate canonical buckets and may produce false-NOVEL classifications for behaviorally equivalent rules — tracked separately as Option B. Operational note: existing rules indexed before this change have canonical_class=NULL for the new categories; run the recompute_semantic_fields CLI (src/cli/sigma_commands.py:251) to backfill.
  • scripts/repair_source_attribution.py (2026-05-23): Dry-run-safe maintenance script for correcting article/source mismatches. 3-way decision loop: archive (duplicate with correct source exists) → repoint (unambiguous host→source mapping) → review queue. 9-entry allowlist of benign cross-domain pairs. Rolls back on any error.
  • Post-restore source attribution integrity check in restore_database_v3.py (2026-05-23): check_source_attribution_integrity() queries article URL host vs source URL host after a restore, warns if mismatch count exceeds baseline (112) by more than 10%, and surfaces the result in the restore report dict.

Fixed

  • sigma_novelty_service.py snake_case field alias mismatch (2026-05-23): build_canonical_rule performed a case-insensitive FIELD_ALIAS_MAP lookup but did not strip underscores, so LLM-generated snake_case fields (parent_image, command_line) failed to match their PascalCase map keys (ParentImage, CommandLine). The mismatch caused identical rules that differed only in field-name convention to score atom_jaccard < 1.0 and be flagged as novel. Fixed by stripping underscores before the lookup. Regression tests added in tests/services/test_sigma_novelty_service.py.
  • OBS_TYPE_ORDER JS duplication (2026-05-23): OBS_TYPE_ORDER, filterObservablesForRule, and showObservableInfoModal were declared identically in all three templates (workflow.html, workflow_executions.html, sigma_queue.html). Extracted to src/web/static/js/components/observable-utils.js. Contract tests updated to assert the canonical order lives in the shared component and no template redeclares a local copy.
  • loadPresetById setTimeout race condition (2026-05-23): setTimeout(100) was leftover scaffolding from before populateEnrichModelDropdown became async. On slow catalog loads the timer fired before <option> elements were populated, silently reverting the preset model selection to the dropdown default. Replaced with a direct assignment immediately after the await, plus a console.warn guard when the resolved model is absent from the catalog. Regression guard added in tests/unit/test_enrich_preset_model_set.py (string-search) and a Playwright behavioral test in tests/ui/test_workflow_comprehensive_ui.py.
  • CodeQL path-traversal taint in evaluation_api.py (2026-05-23): _load_static_eval_articles constructed data_dir from a user-supplied query parameter without a path-confinement check. Added resolve() + prefix guard; resolved path is stored back into data_dir so all subsequent sub-paths are built from the sanitised value.
  • proc_tree_attention_preprocessor.py implicit string concatenation (2026-05-23): Multi-line regex strings in PROC_TREE_REGEX_PATTERNS (P1, P2) were implicitly concatenated — visually ambiguous and flagged by CodeQL. Wrapped in explicit parentheses.
  • eval_os_detection_manual.py stale detect_os arguments (2026-05-23): use_fallback and fallback_model kwargs removed from detect_os() call after the OS-detection fallback agent was deprecated.
  • sigma_validator.py and tests/ui/conftest.py bare ImportError suppression (2026-05-23): Replaced try/except ImportError with find_spec availability checks. Fixed find_spec("sigma.rule") raising ModuleNotFoundError when the sigma parent package is absent by guarding with find_spec("sigma") is not None first.
  • Unused imports removed (2026-05-23): get_backup_config_manager from backup_system.py, shutil from restore_database.py and verify_backup.py.

Changed

  • Hunt score negative-indicator penalty simplified (2026-05-26): The dual-mechanism penalty (hard cap from a 50% multiplier + secondary geometric deduction) was replaced with a single geometric_score(n, 15.0) call — consistent with how all other scoring categories (perfect, good, LOLBAS, intelligence) apply diminishing returns. Maximum penalty is now 15 pts, matching the documented intent in docs/ml-training/hunt-scoring.md.
  • Docs accuracy pass: QA removal, OS fallback removal, ml_hunt_score retirement (2026-05-23): 18 doc files updated to reflect v7.1.0+ state. QA agent references removed from agent-config-schema.md, qa-loops.md, qa-agent.md, qa-output.md, extractor-standard.md, agents.md, edit-prompts.md, api.md, prompt-mapping-table.md, schemas.md. OS detection LLM fallback tier removed from os-detection.md (toggle, environment variables, troubleshooting, training CLI). rescore-ml CLI command removed from cli.md and installation.md. ml_hunt_score aggregate references updated in chunking.md and huntables.md. backup-and-restore.md QA settings column removed. debugging.md log example updated. agent-evals.md stale TODO annotation removed.
  • seed_eval_articles.py cross-source URL dedup (2026-05-23): existing_urls query now spans all sources (removed .filter(source_id==…)), preventing the same article URL from being seeded twice under different source IDs.
  • ml_hunt_score field retired (2026-05-23): calculate_ml_hunt_score and update_article_ml_hunt_score removed from ChunkAnalysisService; store_chunk_analysis no longer writes ml_hunt_score. docs/ml-training/hunt-scoring.md updated to reflect retired metric.

[7.1.0 "Europa"] - 2026-05-22

Fixed

  • Test runner summary table showed FAILED for test types with zero test failures (2026-05-22): _print_combined_summary in tests_runner/cli.py derived per-type status from the subprocess exit code (passed bool), so a type that ran 1 test, passed it, but exited non-zero (e.g. fixture teardown crash or asyncio warning) displayed red FAILED while showing 0 in the Failed column — a contradictory and misleading table. Status is now count-driven: f > 0 → FAILED, p > 0 or passed → PASSED, otherwise → YELLOW ERROR (nothing ran, process failed). The overall row uses matching any_test_failures / any_process_errors booleans.
  • _UrlAwarePage.goto skipped navigations when only the query string changed (2026-05-22): The URL deduplication guard in tests/ui/conftest.py compared scheme, netloc, and path but ignored the query string. Clicking a filter that changed ?source=X to ?source=Y returned None without navigating, causing subsequent expect(page).to_have_url assertions to fail non-deterministically. Added current.query == target.query to the early-return guard.
  • UI tests for article filter checkbox and source dropdown were flaky under async form-submit (2026-05-22): test_title_only_search_filter used a 300 ms wait_for_timeout after clicking the checkbox; test_source_filter called wait_for_load_state then immediately asserted the URL. Both race with the JavaScript form.submit() that fires asynchronously after the DOM event. Fixed: checkbox test now calls wait_for_load_state("load"); source-filter test drops the explicit load wait and relies on expect(page).to_have_url(..., timeout=10000), which auto-retries until the URL matches.
  • uv.lock pin specs inconsistent with pyproject.toml (2026-05-22): The CVE pin tightening commits updated pyproject.toml specifiers to == but uv lock was not re-run, leaving uv.lock with the old >= lower-bounds for idna, mako, pillow, and pymdown-extensions. Lock file regenerated; specs now match (==3.16, ==1.3.12, ==12.2.0, ==10.21.3).
  • starlette CVE-2026-161 upgraded then reverted (2026-05-22): starlette was pinned to ==1.0.1 to address CVE-2026-161, then reverted to ==0.52.1 after the upgrade introduced a compatibility regression. The CVE has been explicitly ignored in pip-audit via [tool.pip-audit] ignore-vulns in pyproject.toml with a note that it is not exploitable in the local-only deployment model.
  • Enrich-button-disabled Playwright tests failing in CI (2026-05-22): test_enrich_button_disabled_when_no_api_key was unreliable in CI because the enrichment check reached the live /api/settings endpoint and depended on localStorage state. Fixed across three passes: (1) window.fetch overridden in page JS to prevent real network calls; (2) /api/settings intercepted with page.route() returning a deterministic empty-key response; (3) localStorage.setItem('enrichApiKey', '') injected via page.evaluate() so the disabled-state gate is server-independent.
  • CVE dependency pins tightened and supply-chain policy documented (2026-05-22): All CVE-fix pins in pyproject.toml converted from >= to == specifiers. Exceptions for transitive packages shared across multiple direct deps (e.g. langchain-core) documented in AGENTS.md Common Traps; uv.lock SHA-256 hashes provide supply-chain protection for those cases.

Added

  • retrain_with_feedback.py quality gate and seed-corpus fallback (2026-05-22): Retrain now writes to a staging path (/app/models/content_filter_staging.pkl) and only promotes to live after the curated-eval quality gate passes (recall_huntable ≥ 0.30, f1_huntable ≥ 0.30). A rejected retrain discards the staged model without touching the live one. When combined_training_data.csv is absent a new three-tier cascade falls back to models/seed_training_data.csv before entering bootstrap mode. Gate is skipped when only noisy train-split metrics are available (no curated eval set).

Changed

  • Docs accuracy pass: chunking, content-filtering, hunt-scoring, ml-features (2026-05-22): Four docs (docs/architecture/chunking.md, docs/features/content-filtering.md, docs/ml-training/hunt-scoring.md, docs/reference/ml-features.md) updated for accuracy: non-existent script references replaced with actual scripts, rescore-ml CLI command replaced with the real rescore command, duplicate section headings collapsed, v1 stats replaced with v3 production figures (0.89 F1, 20 features, 92 perfect discriminators), and total_chunks note added for the overlap-suppression fix. All editorial audit comments stripped.
  • SigmaAgent presets: behavioral instructions front-loaded into system message (2026-05-22): All quickstart presets restructured so the full GENERATION STRATEGY, REQUIRED FIELDS, AUTHOR PRESERVATION, and FAIL-SAFE blocks reside in the system message. The user template is now a minimal Threat Intel Input + observable placeholders (106-148 chars). Local presets (Gemma4B, Qwen3) use condensed RULES + inline example in system; gpt5 uses PRINCIPLES + REQUIRED FIELDS. System messages are 1882-3778 chars. Aligns SigmaAgent with the extractor-agent pattern and improves instruction retention on weaker local models.

  • ContentFilter train/serve feature-version skew surfaced as "ML processing failed" (2026-05-21): load_model() defaulted to feature_version="v1" regardless of what version the pkl was trained with. A v3-trained model received v1 (27-feature) vectors at inference time, causing a silent shape mismatch that only surfaced when the live chunk-debug endpoint called predict_proba. Fixed with a JSON sidecar (content_filter.pkl.meta.json) written by train_model() and read by load_model() to auto-align feature_version. Legacy pkls without a sidecar fall back to "v1" (historic training default) rather than the new "v3" default, so rollbacks to old models are safe. Backfill written for the live v23 model.

  • Chunk debugger and feedback-comparison view used v1 featurization against v2/v3 models (2026-05-21): api_chunk_debug in debug.py hard-coded extract_features() (v1, 27 features) for both the display feature table and the predict_proba input vector. api_get_feedback_comparison in models.py:917 had the same hard-code. Both now dispatch on content_filter.feature_version to call extract_features_v3(), extract_features_v2(), or extract_features() as appropriate — matching the vector shape the loaded model was trained with.
  • Model rollback returned 422 when primary pkl had been deleted from models/ (2026-05-21): api_model_rollback raised a 422 with a generic message whenever os.path.exists(version.model_file_path) was False, even when an identical copy existed under backups/models/. Now checks the backup path as a fallback before failing. ALLOWED_MODEL_DIRS in MLModelVersionManager updated to include both dirs.
  • chunk_content emitted spurious overlap-only tail chunks (2026-05-21): When find_sentence_boundaries returned the same boundary that ended the previous chunk, chunk_content produced a new chunk whose entire content was already contained in the previous chunk's tail (≤200 chars, exactly the overlap size). These overlap-only chunks had no model value, inflated chunk counts, and caused "chunk kept" vs "chunk removed" ratio drift in the UI. The loop now detects when end <= chunks[-1][1] and advances start past the stale boundary.
  • Retrain error message surfaced useless stderr noise instead of the failure reason (2026-05-21): The retrain subprocess's stderr carries Python-logging WARNING lines from libraries — not the actual failure. The error-surfacing code in models.py preferred result.stderr.strip() first, so WARNING: Found existing installation or chunk-length warnings were shown to the user instead of the actual ❌ Training failed line from stdout. Rewritten with explicit priority: RETRAIN REJECTED quality-gate message → ❌ stdout marker → real ERROR/Exception from stderr (skipping WARNING/Found) → last non-empty stdout line.

Added

  • ContentFilter v3 feature extractor (2026-05-21): New 20-feature extractor (extract_features_v3) aligned with the 6 ExtractAgent sub-agent contracts. Positive signals: cmdline_artifact_count, registry_hive_path_count, process_lineage_count, service_artifact_count, scheduled_task_count, hunt_query_count. Negative signals: yara_rule_indicator, suricata_rule_indicator, beacon_config_indicator, hash_count, atomic_ioc_density, educational_phrase_count, mitre_ttp_only_density, marketing_term_count. Discriminators: perfect_pattern_count, attacker_placed_path_count, technical_term_count, has_code_blocks. Aggregates: cmdline_density, extractor_signal_strength.
  • ContentFilter v2 feature extractor (2026-05-21): New 19-feature extractor (extract_features_v2) as an intermediate between v1 (27 features, train/serve skew, length leakage) and v3. Drops length-leakage features (char_count, word_count), redundant booleans, hunt_score (was None at training, real at inference), and acknowledgment_count (noisy). Expands vocab: technical terms 8→~50 (V2_TECHNICAL_TERMS), marketing terms 5→~30 (V2_MARKETING_TERMS). feature_version parameter on ContentFilter.__init__ selects which extractor is used at train and inference time; default is "v3".
  • eval_set_size in ML performance summary API (2026-05-21): GET /api/ml-model-performance/summary now includes eval_set_size (int | null), populated from outputs/evaluation_data/eval_set.csv via a proper csv.reader (the previous wc -l-style approach over-counted rows when quoted fields contained embedded newlines). The MLOps page uses this value to dynamically update the chart subtitle ("Model Performance on Test Set (N chunks)") instead of the previously stale hardcoded 160.
  • Retrain stdout/stderr forwarded to server log (2026-05-21): api_model_retrain now logs every line of the retrain subprocess's stdout at INFO and every stderr line at WARNING, giving operators a tail-able log trace without needing to run the script manually.
  • Recovery eval metrics path in retrain endpoint (2026-05-21): If the retrain subprocess exits cleanly but latest_version.evaluated_at is still None (subprocess eval step silently crashed), the route thread now runs ModelEvaluator in-process and saves the metrics before returning the success response. Prevents the "no accuracy shown" state that required a manual re-eval.

  • Junk filter did not stop the LLM pipeline when all content was filtered out (2026-05-20): junk_filter_node in agentic_workflow.py stored the is_huntable flag in the execution record but never checked it for routing. The conditional edge after junk filter used check_rank_agent_enabled, which only examines rank-agent config flags — so a fully-junk article still proceeded through rank, OS detection, and all extractor agents. Added an early-termination path: when filter_result.is_huntable is False the node calls mark_execution_completed with reason="no_huntable_content" and returns state with termination_reason=TERMINATION_REASON_JUNK_FILTER. A new routing function check_should_continue_after_junk_filter checks this flag first and routes to END; on the happy path it delegates to check_rank_agent_enabled unchanged. workflow.html gains a "Junk Filtered" termination badge and a describeTermination case that shows the confidence and threshold.

  • api_detect_os passed raw article content to the LLM when filter found no huntable chunks (2026-05-20): The fallback filter_result.filtered_content or article.content in src/web/routes/ai.py silently used raw content when filtered_content="" (all chunks junk), defeating the filter entirely. Replaced with an explicit is_huntable check that raises HTTP 422 (error: "no_huntable_content") before content reaches OSDetectionService. 6 API tests added in tests/api/test_detect_os_api.py.
  • OS detection error displayed [object Object] in the article detail toast (2026-05-20): The error handler in article_detail.html called errorData.detail || 'OS detection failed'. When the API returns a structured detail dict (as the new 422 does), the string coercion produced [object Object]. Fixed to unwrap detail.message when detail is an object.
  • KPI card labels in chunk analysis UI were ambiguous (2026-05-20): "Eligible Articles" label and "No eligible articles found" message in ml_hunt_comparison.html suggested articles were unavailable, not that processing was complete. Renamed to "Chunk Analysis Queue" / "All eligible articles processed".

Changed

  • SigmaMatchingService.compare_proposed_rule_to_embeddings → assess_rule_novelty (2026-05-20): The old name implied pgvector/embedding usage, but the Sigma deduplication path uses plain SQL candidate retrieval and a deterministic Jaccard×Containment formula — no embeddings, no cosine similarity. Renamed to assess_rule_novelty; old name preserved as a deprecated alias for call-site compatibility. Module docstring updated to document both embedding paths and prevent the recurring AI-agent misconception.
  • CTI-BERT NER extractor removed (2026-05-20): Deleted src/utils/ctibert_ner_extractor.py, removed the POST /api/articles/{id}/extract-iocs-ctibert endpoint, stripped the CTI-BERT card from article_detail.html, and removed the orphaned pages route reference.

Added

  • Active generation template shown for locked canonical prompts (2026-05-20): When a canonical agent prompt is locked (runtime-only, not editable via the UI), workflow.html now renders the active user prompt template inline so it remains readable even though the field is read-only.

Added

  • needs_review queue status for inconclusive novelty comparisons (2026-05-19): The Sigma queue comparator previously collapsed an inconclusive result (candidates evaluated, 0 behavioral matches) into max_similarity=0.0, making it indistinguishable from a confidently-scored zero. ~86% of queued rules were silently treated as novel and novelty suppression never fired. New summarize_rule_novelty() in src/workflows/agentic_workflow.py maps inconclusive outcomes to max_similarity=None (unscored) and routes those queue entries to a new needs_review status instead of pending. Two new columns (behavioral_matches_found, total_candidates_evaluated) on sigma_rule_queue carry the comparator evidence. The queue UI gains a yellow Needs Review badge and Approve/Reject actions on needs_review rows. The on-the-fly recompute guard in the list endpoint now skips rows that already have evidence columns set, preventing thrash.
  • Canonical summarize_sources() helper (2026-05-19): src/models/source.py now exports INTERNAL_SOURCE_IDENTIFIERS, is_internal_source(), SourceCounts, and summarize_sources(). All three counting sites (dashboard API, pages route, analytics API) and both templates (dashboard, sources) now use this single helper, so the "Active Sources" widget and the Sources stat chips are guaranteed to report identical numbers. Eliminates the duplicated inline identifier not in {...} filters and the Jinja2 namespace accumulator loop in sources.html.

Fixed

  • Diagnosis-count badge 404 (2026-05-19): agent_evals.html fetched /api/evals/diagnosis-counts while the route is mounted under the /api/evaluations router prefix at /api/evaluations/evals/diagnosis-counts. The !resp.ok guard in applyDiagnosisCounts swallowed the 404 silently so the [dx N] badge never rendered. Fixed the fetch URL. 7 new tests in tests/api/test_eval_diagnosis_api.py cover the endpoint contract and a URL-vs-router contract test that will catch any future prefix drift.
  • Enrich expanded overlay Escape key only fired when focus was inside the overlay (2026-05-19): The onkeydown attribute on #enrich-expanded-overlay only fires while the div holds focus; if the user typed into a textarea inside the panel the event never reached the overlay. Replaced with a document-level listener (_enrichExpEscHandler) added on open and removed on close, matching how ModalManager handles Escape for all other modals.
  • Loading an enrichment preset left the system-prompt display stale (2026-05-19): loadPresetById() wrote the preset's prompt into #enrichSystemPrompt (the hidden textarea) but never called _syncEnrichDisplay() / _enrichSPViewMode(). In view mode the user kept seeing the prior /prompt/latest text. The preset appeared to have no system prompt and an Edit → Cancel would discard it. Fixed by calling both sync functions after setting the textarea value. Regression test added to tests/ui/test_workflow_comprehensive_ui.py.
  • Enrich modal showed the original rule block twice after enrichment (2026-05-19): The comparison view inside #enrichResult already renders the original rule alongside the enriched version; the standalone #enrichOriginalSection block was not hidden when the result appeared, so the original rule appeared twice. Fixed by toggling hidden on #enrichOriginalSection when the result is shown and clearing it on modal reset.
  • All 6 observable types now routed through filterObservablesForRule (2026-05-19): The observable filtering/display code in workflow.html hard-coded cmdline, process_lineage, and hunt_queries. The three types added in 7.0 (registry_artifacts, windows_services, scheduled_tasks) were silently ignored — their items fell outside the flat-array index range, so observables_used indices for those types were never mapped and those observables never showed up in the Sigma queue preview panel. Refactored to drive all 6 types from a single OBS_TYPE_ORDER constant.

Changed

  • Documentation: Sigma validation/repair flow clarified (2026-05-17): The Sigma QA section of QA Loops was vague about where the LLM enters the validate→repair loop. It now states explicitly that pySigma validation is deterministic with no LLM (validate_sigma_rule in src/services/sigma_validator.py plus sigma_extended_validator.py's pySigma hard-fail gate), and that the iterative repair pass (_repair_rules in src/services/sigma_generation_service.py, up to max_repair_attempts_per_rule=3) reuses the SigmaAgent model/provider/temperature/top_p/seed and the same system prompt as generation — only the user prompt differs (sigma_repair_single/SigmaRepair vs sigma_generate_multi/sigma_generation), both funneling through _call_provider_for_sigma. Model Selection Sigma Generator section now notes the same model serves the repair pass (no separate repair model to select). Fixed a garbled sigma_repair_single.txt description in the prompt mapping table and clarified that SigmaRepair is a prompt-only config key.

Added

  • Committed MCP client config (2026-05-17): .mcp.json plus scripts/run_mcp_server.sh register the huntable-cti-studio MCP server for project-aware clients (e.g. Claude Code) with zero per-user setup. The launcher self-locates the repo from its own path and auto-selects the project virtualenv (.venv/venv, falling back to PATH python3), so it works regardless of cwd or shell activation. .mcp.json is force-tracked via a !.mcp.json negation appended to the existing *.json exception block in .gitignore.

Fixed

  • MCP "Server disconnected / Could not attach" (2026-05-17): MCP clients spawn the server in a clean environment and do not inherit an activated virtualenv, so the previously documented python3 run_mcp.py died with ModuleNotFoundError: No module named 'mcp' before the JSON-RPC handshake — surfacing only as a generic disconnect toast. The server code itself was healthy. Docs (README.md, docs/index.md, docs/quickstart.md, docs/reference/mcp-tools.md) now point clients at the committed .mcp.json / launcher and show the venv-explicit run command.
  • Stale workflow-status API tests (2026-05-17): tests/api/test_article_workflow_status_api.py (5 tests) was written for the old GET /api/articles/{id}/workflow-status contract. Commit 77be72ac added .order_by(id desc) to the completed-execution lookup and a latest_execution_id field to the response (consumed by article_detail.html), but the test's mocked DB session still stubbed query().filter().first() and asserted exact dicts without latest_execution_id. The unmocked .order_by() caused .first() to return a truthy MagicMock, so processed_with_current_config and the response shape were wrong and all 5 tests failed. Updated the session mock to the real query().filter().order_by().first() chain and the 5 assertions to the documented response shape. Endpoint code unchanged.

Added

  • source-healing skill (2026-05-18): New .claude/skills/source-healing/ implements an operator-invoked propose-and-confirm workflow for CTI sources that ingest 0 articles or accumulate recurring failures. Diagnoses using the same probes and symptom→fix playbook as the deprecated auto-healing service (aa3881da), but all actions require explicit operator approval before applying — no unattended Celery/LLM autonomy. Diagnosis reads SourceTable state directly via SQL; config writes use whole-object read-modify-write (sources.config is JSON, not JSONB).
  • scripts/seed_model.py (2026-05-17): New script seeds the junk-filter ML model from eval fixtures on fresh install or after database restore. Eliminates the "no trained model" startup error on clean environments. Documented in docs/ml-training/database-training.md and docs/index.md.

Fixed

  • CVE-2026-45134 (langsmith) (2026-05-18): pyproject.toml had a loose langsmith>=0.7.31 floor that allowed uv to resolve the vulnerable 0.7.32. Pinned to ==0.8.3 matching the existing requirements.txt constraint. Lock-file regeneration also reconciles 5 packages (authlib, click, mcp, redis, transformers) that had drifted behind their already-declared exact pins.
  • Source rows created as duplicates on DB-error paths (2026-05-16): Three compounding issues caused 39 duplicate sources rows: list_sources() swallowed DB errors and returned [], making the store appear empty and triggering fresh INSERTs; create_source() used a plain INSERT with no conflict guard; the sources table lacked a UNIQUE constraint on identifier despite unique=True in the ORM model. create_source() now uses pg_insert ON CONFLICT (identifier) DO UPDATE so syncs are idempotent; list_sources() propagates errors; _sync_to_db() gains a warning guard when it returns 0 rows against a non-empty YAML config. A companion SQL script deduplicated the 39 stale rows (re-homing 177 articles and 920 source_checks first) and applied the missing UNIQUE constraint.
  • WorkflowConfigV2 rejected and corrupted ExtractAgent prompt blocks (2026-05-17): ExtractAgent uses the 4-key seed envelope (role/task/json_example/instructions) which the Prompts-block validator treated as malformed; a new migrator strips stale ExtractAgent/ExtractAgentSettings entries from DB configs on load. QA and sigma services migrated from legacy role key to system for prompt-dispatch consistency. sigma_generation_service gains a generic dict serializer for non-process_lineage observable types and filters none/redacted stop-tokens in _infer_observables_used.
  • MLHuntScore TBD badge gave no actionable guidance (2026-05-17): Articles whose ML hunt score had not yet been computed displayed a static "TBD" badge with no explanation or action path. Replaced with a click-to-open fixed-position help popover that describes the correct Reprocess action (the earlier text incorrectly pointed to Junk Filter Tuning). Badge now live-polls until the score arrives. The article-detail workflow deep-link uses the latest_execution_id field added to GET /api/articles/{id}/workflow-status.
  • ContentFilter cache not cleared after retrain (2026-05-17): POST /api/models/retrain wrote the new model to disk but did not invalidate the in-process ContentFilter singleton, so the retrained model was only picked up after a service restart. Added a cache-clear call in src/web/routes/models.py after a successful retrain.
  • Stale hardcoded "Version 4.0.0" on System Health page (2026-05-17): diags.html contained a literal "Version 4.0.0" — three major versions behind — that bypassed the canonical src.__version__ mechanism. Removed the Version card from the diagnostics grid (tightened to 2 columns) and dropped the orphaned "version" literal from /health and /api/health JSON responses.
  • Redundant startup ALTER TABLE ADD PRIMARY KEY migrations (2026-05-17): async_manager.create_tables() emitted ALTER TABLE ADD PRIMARY KEY for sources, subagent_evaluations, and content_hashes on every startup. Base.metadata.create_all already creates those constraints on fresh installs, making the statements no-ops on healthy databases; on restore, however, pg_dump preserved both definitions and the duplicate constraint caused a fatal "multiple primary keys for table" error. Removed the three migrations; _restore_common.py retains a dedup filter as defense-in-depth.
  • Restore scripts missing skip_unsupported_sets=True (2026-05-17): restore_database_v3.py passed skip_unsupported_sets=True to filter_dump_lines; the other four callers (restore_database.py, restore_database_v2.py, restore_system.py, verify_backup.py) did not. Newer pg_dump versions emit directives like SET transaction_timeout that older psql clients reject with a fatal error, causing silent restore brittleness on version-mismatched environments. Standardized all five callers; added a parametrized contract test.

Changed

  • Docs: proctree-preprocessor.md updated for P4/P5 removal and shape-based T3 (2026-05-15): Pattern table and narrative suppression section now reflect the 7.0.1 changes: P4/P5 rows removed, strong/weak anchor list corrected (P3/P7/P8 strong only), internal index mapping documented. "Known Process Token List" section replaced with "Executable-Shape Heuristic" describing _EXE_SHAPE_RE / _PATH_INDICATOR_RE and the T3a/T3b trigger conditions. Extension guide updated: KNOWN_PROCESS_TOKENS reference removed; readers directed to _EXE_SHAPE_RE.
  • Docs: Source Auto-Healing references purged (2026-05-15): The feature was removed in 6.2.1 but references survived in docs/getting-started/configuration.md, docs/guides/add-feed.md, docs/index.md, and docs/internals/architecture.md. All removed. add-feed.md guidance simplified to a practical troubleshooting line.
  • Docs: api.md corrections (2026-05-15): Added POST /api/workflow/config/prompts/reset-to-defaults (selective per-agent prompt reset; body: { "agent_names": [...] }). Clarified CANONICAL_PROMPT_AGENT_NAMES -- sub-agent QA names (CmdLineQA, etc.) appear in workflow tracking but are not storable via the Prompts section API. Fixed /api/evaluation/* and /api/evaluation-ui/* references to their actual route prefixes (/api/eval/* and /evaluations/*). Source Healing endpoints block removed.
  • Docs: agent-config-schema.md (2026-05-15): Added ProcTreeAttentionPreprocessorEnabled to the Features key list.
  • Docs: minor cleanup (2026-05-15): Removed Europa from available moon names in versioning.md (used in 7.0.0); removed stale LangGraph sentence from extract-observables.md.

Removed

  • Active generation template display for locked canonical agents (2026-05-22): workflow.html previously rendered the active user prompt template in an amber locked-scaffold section when isLockedCanonicalPrompt && promptParts.user was truthy. The user template for SigmaAgent and RankAgent is now a minimal data-wiring block (Threat Intel Input + observable placeholders), not the full behavioral specification, so the inline display added visual noise without value. The conditional block and its three guard tests removed.

[7.0.1] - 2026-05-14

Changed

  • ProcTreeExtract attention preprocessor: P4/P5 patterns removed (2026-05-14): Removed PID/PPID (P4) and injection/hollowing (P5) regex patterns from proc_tree_attention_preprocessor.py. The preprocessor now anchors exclusively on structural lineage evidence (Sysmon fields, tree glyphs, explicit spawn verbs, parent/child label patterns). P4 was too broad -- bare PID mentions do not imply a process tree. P5 was causing false positives because injection/hollowing terminology appears without any process creation event. Removed the now-unreferenced KNOWN_PROCESS_TOKENS set. Updated strong/weak index sets to reflect new pattern numbering (P1=0, P2=1, P3=2-3, P6=4-6, P7=7, P8=8). Test test_injection_into_process renamed to test_injection_into_process_no_match and updated to assert injection-only text produces no snippets.
  • workflow.html extractor prompt validator: accept plain role-persona strings (2026-05-14): The JS-side validation for extraction sub-agent system prompts previously hard-failed on JSON parse errors, blocking saves for prompts that use the plain role-persona shape (a valid alternate form). Changed to treat a parse failure as the plain-role shape and return early without error. Added a defensive check for JSON-parsed scalars (quoted strings) which are also treated as plain-role.
  • settings.html backup section SVG icons (2026-05-14): Swapped the SVG icons used in the "Backup Components" and "Backup Actions" section headers to better match their semantics.

Fixed

  • TestWorkflowConfig active-version assertion (2026-05-14): test_workflow_config_active_version_roundtrip required exactly one active version and that it equal the just-saved version. This failed in environments where multiple active versions coexist (valid state). Relaxed to assert at least one active version exists and that the saved version appears among them.

Added

  • ProcTreeExtract v2.0 contract: four eval gaps closed (2026-05-14): Eval against wsusservice.exe -> cmd.exe -> cmd.exe -> powershell.exe exposed three gaps in the ProcTreeExtract extractor contract; a fourth gap (.lnk files) was identified during the fix. All addressed in src/prompts/ProcTreeExtract, all 9 Quickstart presets, and docs/contracts/proctree-extract.md:
  • Arrow-notation chains: new EDGE CASES rule stating that arrow notation (A.exe -> B.exe -> C.exe) is valid creation verb evidence; each adjacent pair is evaluated independently with all standard exclusion rules applied per-hop. Illustrated with the wsusservice.exe eval chain showing which hops are extracted vs. skipped.
  • Cross-chain deduplication: new COUNT SEMANTICS rule -- if the same (parent_image, child_image) pair appears as a hop in multiple chains in the article, emit it once; source_evidence references the first occurrence.
  • Self-referential hops: explicit rule that hops where parent_image == child_image are not process creation and are skipped.
  • Multi-hop json_example: replaced the single-item explorer.exe -> rundll32.exe example with a two-item example derived from services.exe -> svchost.exe -> svchost.exe -> powershell.exe, demonstrating the self-loop skip. Acts as the primary behavioral anchor for gpt-4o-mini at temperature=0.
  • .lnk file exclusion: Windows shortcut files are not process images and are never valid as parent or child; added to NEGATIVE EXTRACTION SCOPE.
  • Two new VERIFICATION CHECKLIST items: arrow-chain adjacency check and self-referential hop check.

[7.0.0 "Europa"] - 2026-05-12

Added

  • SigmaGenerate prompt contract (2026-05-12): New docs/contracts/sigma-generate.md defines the invariant rules for Sigma generation prompts (role, splitting logic, generic logsource, behavioral detection operators, ATT&CK tagging, severity calibration, required fields including observables_used) plus a Model Adaptation section with per-model-class overlays (Local, Claude standard/thinking, OpenAI instruction, OpenAI reasoning) and an adaptation decision table. Added to mkdocs.yml Contracts nav.
  • Tailored sigma generation prompts in 9 quickstart presets (2026-05-12): Replaced the identical 2622-char prompt across all quickstart presets with three contract-compliant variants -- Local (1991 chars, inline YAML example, format directive repeated, flat numbered rules) for LMStudio-Gemma4B and LMStudio-Qwen3; Standard (3971 chars, full 8-section strategy) for anthropic-haiku-4-5, anthropic-sonnet-4-6, openai-gpt-4o, openai-gpt-4o-mini, openai-gpt-4.1, openai-gpt-4.1-mini; Reasoning (2256 chars, condensed strategy + prominent reasoning-suppression directive) for openai-gpt-5. Each variant carries the same contract markers (observables_used, ATT&CK tactic+technique, generic logsource, behavioral operators, severity calibration, realistic false-positives, AUTHOR PRESERVATION).
  • ExtractAgent seed prompt (2026-05-12): Created src/prompts/ExtractAgent with the standard 4-key envelope (role/task/json_example/instructions). Documents the orchestration layer and sub-agent scope map. Required by TestNewlyRewrittenSeedEnvelopes test contract.

Fixed

  • CmdlineExtract json_example hallucination triggers (2026-05-12): json_example contained powershell.exe -NoP -W Hidden which eval 3802 showed leaked into real extraction output. Replaced with a clearly synthetic certutil placeholder command. TestHallucinationMitigation now passes.
  • HuntQueriesExtract count semantics assertions (2026-05-12): task field now includes "count must be the combined total across both categories"; instructions envelope fields updated to include both "count MUST equal len(queries)" and "EDR/SIEM hunt queries plus Sigma rules". All 9 quickstart presets re-synced.

Changed

  • Extractor agents upgraded to v2 contract standard (2026-05-12): All 6 extractor seed prompts (CmdlineExtract, ProcTreeExtract, RegistryExtract, ServicesExtract, ScheduledTasksExtract, HuntQueriesExtract) rewritten to the v2 16-section role/instructions split. Key behavioral changes: ARCHITECTURE CONTEXT boundary rules per agent; fail-closed confidence gating (below 0.5 = omit); traceability fields (value, source_evidence, extraction_justification, confidence_score) required on every item. All 9 quickstart presets and QA prompts regenerated.
  • RegistryExtract schema: split-hive fields replaced by single key field (2026-05-12): registry_hive + registry_key_path + registry_value_name replaced by key (full hive-rooted path verbatim, e.g. HKLM\SOFTWARE\...) and value_name. Hive abbreviations (HKLM, HKCU, etc.) are now preserved as-is rather than expanded. RegistryQA corrections schema updated to use key/value_name. _QA_AGENT_SPECS["RegistryExtract"] composite matcher updated accordingly; _REGISTRY_HIVE_ALIASES expansion removed from _norm_field.

Added

  • ProcTreeExtract attention preprocessor (2026-05-11): Add ProcTreeExtract attention preprocessor (parallel to CmdlineExtract). Default ON. Toggle via proc_tree_attention_preprocessor_enabled workflow config flag. Surfaces high-likelihood parent-child process spawn regions (Sysmon fields, tree glyphs, lineage verbs, PID/PPID pairs) as snippets before the full article, following the same contract and caller wiring as the cmdline preprocessor.
  • logscale platform in HuntQueriesExtract contract (2026-05-08): Added logscale as a first-class platform type in docs/contracts/huntquery-extract.md and all 9 quickstart presets. LogScale CQL uses hash-prefix tag fields (#event_simpleName=, #Vendor=, #repo=) that are unique to the platform; one verbatim occurrence is sufficient for identification. Updated the platform disambiguation strength table to include #event_simpleName=/#Vendor=/#repo= at tier 2. type enum in the output schema now lists logscale explicitly.
  • MCP tool tests (2026-05-08): tests/unit/test_mcp_articles_search.py (search_articles, search_articles_by_keywords) and tests/unit/test_mcp_sources.py cover the MCP tool registration, argument contracts, and mock-based happy-path behavior.
  • UX contract enforced in AGENTS.md (2026-05-08): Added UX Contract section to AGENTS.md with a condensed rule table (card classes, modals, colors, icons, font sizes, collapsible panels) and an explicit pre-flight checklist so AI agents have a single authoritative reference without opening the full ui-designer.md.

Fixed

  • RankAgentQA model display blank in Selected Models panel (2026-05-08): workflow-config-display.js split the RankAgent and RankAgentQA rendering into two separate if blocks; when agentModels.RankAgentQA was not explicitly set (the common case -- it inherits from RankAgent), the QA row was silently dropped. Merged into a single block: RankAgent presence triggers both rows, and the QA row explicitly passes rankModel/rankProvider as the display value.
  • Pytest hangs on slow tests (2026-05-08): Added --timeout=60 to pyproject.toml addopts. Any test that takes more than 60 s now fails with a timeout error instead of blocking the entire suite indefinitely.
  • Playwright e2e: networkidle never fires on persistent SSE connections (2026-05-08): tests/e2e/test_web_interface.py used page.wait_for_load_state("networkidle") on all navigations; the app's background SSE/polling connections prevent networkidle from ever settling, so every e2e test hung indefinitely. Switched to "load" and added 20 s default timeouts via an autouse fixture in tests/e2e/conftest.py.
  • pytest-asyncio crash after sync_playwright tests (2026-05-08): Playwright's sync API uses greenlets that leave the C-level asyncio running-loop thread-local set after yielding back to the main greenlet. Python 3.12+ _get_running_loop() returns the stale value, causing Runner.run() cannot be called from a running event loop on the next async test. Added an autouse fixture in tests/conftest.py (root) that calls asyncio.events._set_running_loop(None) after each test.

Changed

  • Docker: hot-reload removed from production compose target (2026-05-08): docker-compose.yml removed --reload and its --reload-exclude flags from the web service command. Auto-reload was development-only and caused spurious restarts on any .py write in production. Memory limits raised to 3 GB reservation / 3 GB limit; health check start-period increased to 30 s and timeout reduced to 10 s.
  • Smoke test snapshot string updated (2026-05-08): /mlops page smoke assertion updated from "Observable Training" to "Agent Evals" to match the current page heading.

Added

  • /mlops/agent-evals2 SYS.04 trend chart (2026-05-07): New "Evaluation Metrics Over Time" panel at the bottom of the item-level eval page plots macro-averaged Precision, Recall, and F1 across config versions for a single chosen model. Model dropdown is populated from a new GET /api/evaluations/subagent-eval-models?subagent={key} endpoint that lists models with at least one eval record for the selected subagent (sorted by config_count). Chart uses Chart.js with theme-aware colors from --chart-pink/--chart-blue-light/--chart-orange. Powered by mean_precision, mean_recall, mean_f1, scored_articles fields newly added to the /api/evaluations/subagent-eval-aggregate response, plus an optional ?model= filter that joins agent_models[<AgentName>_model] on the workflow config table to restrict to versions where the subagent used the chosen model. 6 new API tests in tests/api/test_subagent_eval_metrics.py.
  • Item-level cmdline ground truth -- 9 articles annotated (2026-05-07): config/eval_articles_data/cmdline/ground_truth.json populated with expected_items for DarkCloud, UNC1549 (Aerospace/Defense), Bitter (Proofpoint), TeamCity APT29, OneNote/IcedID (DFIR Report), BlurringLines (DFIR Report), Bumblebee/Akira (DFIR Report), RoningLoader (Elastic), and ScreenConnect (LevelBlue). Items were validated against the CmdlineExtract contract (wrapper-stripped where applicable, no bare/single-token commands, single-line only). The 4 pre-existing annotations from the initial feature commit (Huntress, ScreenConnect, DarkCloud, Bumblebee) were re-validated and corrected; the BlurringLines entry was later refined from 8 to 6 items to match the verbatim article content.
  • Tests for item-level scoring (2026-05-07): 9 unit tests in tests/services/test_eval_item_scorer.py cover score_items normalization (lowercase, whitespace collapse, IOC defang), edge cases (zero extraction, empty expected, dedup), and dataclass shape. 3 unit/regression tests in tests/unit/test_agentic_workflow_item_scoring.py cover _update_single_eval_record: the zero-extraction regression (no items returned still populates matched=0/missed=N/extra=0), happy-path partial match, and the no-ground-truth fallback (item-level fields stay None).

Fixed

  • UX contract compliance in workflow_executions.html and mlops.html (2026-05-08): Resolved remaining violations missed in the initial 7-template pass. workflow_executions.html: replaced ~15 details-panel body bg-gray-800 border border-gray-700 stacks with .card; fixed executionModal open bypassing ModalManager (was half-wired -- close used ModalManager, open did not); replaced similarRuleModal and ruleModal deprecated card containers; used .card-interactive for clickable similar-rule items; replaced #6366f1 resize handle hex with var(--purple-primary); fixed ✕ unicode to &#x2715; HTML entity. mlops.html: replaced #60a5fa with var(--step-1) in .icon-blue; moved hero-section font-size from style="" to locked Tailwind scale classes.
  • Eval bundle expected_items truthy check (2026-05-08): eval_bundle_service.py:212 used the same truthy guard that was just fixed for actual_items. Switched if subagent_eval.expected_items: to is not None for parity.
  • Eval bundle omitted actual_items for zero-extraction runs (2026-05-08): eval_bundle_service.py:214 used a truthy check (if subagent_eval.actual_items:) so actual_items=[] (model returned nothing) was silently dropped from the bundle, making it indistinguishable from actual_items=None (field never set). Switched to is not None so zero-extraction runs correctly include "actual_items": [] in the bundle's workflow section. Two unit tests added to TestBundleActualItemsTruthiness in tests/services/test_eval_bundle_service.py.
  • UX contract compliance -- 7 templates (2026-05-08): Resolved ~110 violations of the UI contract (ui-designer.md) across article_detail.html, agent_evals.html, workflow.html, articles.html, agent_evals2.html, sigma_queue.html, and ml_hunt_comparison.html. Changes: replaced deprecated bg-gray-800 border border-gray-700 rounded-lg card patterns with .card; migrated half-wired modal open/close to ModalManager.register/open/close; replaced hardcoded hex colors with CSS custom property tokens (var(--action-execute), var(--action-warning), etc.); moved style="font-size:..." to Tailwind locked scale (text-xs, text-[10px], text-sm); removed emoji from buttons/headings/option labels (replaced export button with inline Heroicon SVG); stripped manually authored role/aria-expanded/tabindex/aria-controls from collapsible panel template strings.
  • Item-level scoring skipped when model returned zero items (2026-05-07): _update_single_eval_record in src/workflows/agentic_workflow.py checked if actual_items is not None before scoring, but _extract_actual_items returns None when the agent's items array is empty -- conflating "no items field present" with "agent emitted nothing". Result: legitimate 0% recall runs silently became "count only" badges in /mlops/agent-evals2 instead of being scored as matched=0, missed=len(expected), extra=0. Fix coerces None to [] before calling the scorer so zero-extraction runs score correctly. SQL backfill applied to 6 historical records (BlurringLines, Bumblebee, LevelBlue ScreenConnect, three TeamCity runs).
  • Eval bundle LLM response truncated at 2000 chars (2026-05-07): _MAX_RESP_CHARS in src/workflows/agentic_workflow.py capped stored LLM responses at 2000 characters. For larger extractions (e.g. cmdline runs with 20+ commands) this cut the response mid-string after item ~7, breaking the diagnosis flow that reads the bundle's llm_response.raw_response.choices[0].message.content. Raised to 20000 to capture full responses; existing truncated runs in DB remain truncated until re-run.

Changed

  • docs/reference/api.md route prefix corrected (2026-05-07): Subagent eval endpoints were documented under /api/evaluation/... (singular) but the actual APIRouter prefix in src/web/routes/evaluation_api.py is /api/evaluations/ (plural). Fixed and added subagent-eval-models to the listing.

Fixed

  • Backup status/list endpoints no longer 500 when prune script is absent (2026-05-07): /api/backup/status and /api/backup/list called scripts/prune_backups.py via subprocess and raised HTTP 500 on any non-zero exit code, including the case where the script simply doesn't exist. Both handlers now check for the script before running it and return empty stats gracefully when it is unavailable.
  • auto_trigger_hunt_score_threshold change now persists through GET (2026-05-07): PUT /api/workflow/config wrote the updated threshold to the config table row but not to AppSettingsTable. Because GET /api/workflow/config always reads from AppSettingsTable, the new value was invisible until the next restart. Fixed by calling _save_threshold_to_settings() before the commit when creating a new config version.
  • Test suite aligned to sync route handlers (2026-05-07): list_queued_rules, list_workflow_executions, and get_similar_rules_for_queued_rule were converted from async def to def but 11 tests still awaited them, causing TypeError: object can't be awaited. Removed the await from all affected test calls. Also fixed test_enrich_validates_rule_id, which passed a bare MagicMock() as enrich_request; the enrich endpoint now validates the LLM provider before the DB lookup, so the mock's truthy .provider attribute triggered a 400 instead of the expected 404. Fixed by using EnrichRuleRequest(instruction="test") so the provider defaults to "openai" and the code reaches the rule-not-found check.

  • Sources list deduplication (2026-05-05): list_sources() used result.scalars().all() which in SQLAlchemy 2.x returns duplicate ORM objects when relationships are loaded (80 rows for 42 unique source IDs). Changed to result.unique().scalars().all(). Added a belt-and-suspenders dedup pass in the page route before sorting. Also fixed 13 failing Playwright tests: sources-page strict-mode violations caused by duplicate DOM cards, execution-detail race condition on JS context destruction during hash navigation (added second waitForLoadState + retry wrapper), and expanded-prompt-editor save assertion checking the wrong payload field (body.prompt vs body.system for non-extraction agents).

  • Execution detail WARNING badge now explains itself (2026-05-04): The WARNING badge on execution step tabs showed no reason text, requiring users to expand the Output section to infer why. Added a warnReason field to each step object with a short inline label rendered directly on the tab button (e.g. "nothing extracted", "agent produced nothing", "above duplicate threshold", "filtered as duplicate", "no rules generated"). Step 5 (Similarity) also had a status inversion bug: an empty results array (novel rule, happy path) showed warn instead of pass; fixed so only above-threshold matches trigger the warning. 10 new Playwright tests cover all warnReason labels and the similarity status logic.
  • parse_sigma_agent_prompt_data drops extraction-agent envelope when role is empty (2026-05-04): The extraction-agent format ({"role": ..., "task": ..., "json_example": ..., "instructions": ...}) was detected by parsed.get("role"), which is falsy for an empty string. This caused the entire JSON blob to fall through to the raw-text branch, setting template to the raw JSON string (which would then raise KeyError on the {} in json_example during str.format()). Fixed by detecting the format via key presence ("task" in parsed or "json_example" in parsed) instead of role truthiness. 5 new unit tests cover the empty-role case, sibling key interaction, and the {} brace safety contract.

Added

  • Diagnosis run history (2026-05-03): Re-running "Diagnose" on an eval bundle now accumulates results instead of replacing them. All historical runs for an execution are loaded on modal open (newest first) via a new GET /api/evaluations/evals/{execution_id}/diagnoses endpoint that returns an array of all saved diagnosis files. Each panel shows a "Run N" badge and a timestamp so individual runs can be compared. Five new API tests cover the empty-list, single-file, ordering, execution-id isolation, and all-runs-returned contracts.
  • Slim bundle mode for eval exports (2026-05-03): EvalBundleService.generate_bundle() accepts slim=True to strip redundant data (~60% token reduction for AI review). Removes raw_payload, raw_response, extraction_context.raw_result, and replaces long system prompt and article text in llm_request.messages with SHA-256 reference pointers to inputs[]. Bulk ZIP export (GET /api/evaluations/evals/export-bundles-by-config-version) gains a slim query parameter; slim ZIPs include a shared _prompts.json manifest that deduplicates identical prompts across bundles. UI: left-click on the bundle export button now defaults to slim; right-click exports full bundles. Seven new tests (6 unit in TestSlimTransform, 1 API) cover the transform, deduplication logic, short-message preservation, and end-to-end integration.
  • AI-powered eval bundle diagnosis (2026-05-03): New EvalDiagnosisService sends a slim eval bundle + the relevant extractor contract to a frontier LLM and returns a structured diagnosis (root causes, contract violations, recommendations). POST /api/evaluations/evals/{execution_id}/diagnose endpoint. "Diagnose" button added to the Agent Evals detail modal. Provider/model configurable via Settings page (Diagnosis Agent section) with LM Studio, OpenAI, and Anthropic support. 16 new unit tests cover contract loading, prompt construction, JSON parsing (including code-fence and preamble recovery), full diagnosis flow, and file persistence.

Changed

  • Admin API key auth removed from backup, cron, and sources endpoints (2026-05-03): The 2026-04-27 fix dropped RequireAdminAuth from one Settings handler because the page sends no X-API-Key; the same root cause silently broke restore-from-file, source toggle/collect, source-config edits, and the cron snapshot endpoint. App runs locally on 127.0.0.1 with no public exposure or multi-user model, so the key check was friction without a threat model. Removed RequireAdminAuth from all six backup endpoints (api_create_backup, api_get_backup_cron, api_update_backup_cron, api_delete_backup_cron, api_restore_backup, api_restore_from_file), both cron endpoints (api_get_cron, api_replace_cron), and all five sources endpoints (api_toggle_source_status, api_collect_from_source, api_update_source_min_content_length, api_update_source_lookback, api_update_source_check_frequency). src/web/auth.py and tests/unit/test_auth.py deleted. Regression test test_backup_endpoints_require_no_admin_auth in test_backup_cron_api.py now asserts no FastAPI Depends() defaults on any backup handler so re-introducing admin auth fails CI loudly instead of silently breaking the UI; test_replace_cron_requires_no_admin_auth updated to tolerate the deleted module via try/except ImportError.
  • Provider/model selector macro consolidation (2026-05-03): Extracted components/provider_model_macros.html with provider_model_grid() and temperature_sliders() Jinja2 macros. workflow.html (-296 lines) and settings.html now call the shared macros instead of duplicating provider/model HTML. Element IDs unified to kebab-case (diagnosis-provider instead of diagnosisProvider). JS functions onAgentProviderChange() and updateAgentProviderVisibility() shared across both pages. 22 regression tests in test_diagnosis_provider_macro.py and 122 in test_model_selector_layout_consistency.py verify the rendered macro output.
  • Deslop pass on eval diagnosis and API code (2026-05-03): Removed restating module-level constant comments, inline step-by-step comments in diagnose_bundle, stale "Strategy 2/3" labels in _parse_diagnosis_response, and three obvious action comments in the diagnose endpoint.

Fixed

  • DB restore aborts on dangling foreign-key references (2026-05-03): pg_dump drops FK constraints, COPYs data, then re-adds the constraints with ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY .... That ADD CONSTRAINT synchronously validates every row, so any orphan in the source DB (e.g. a sigma_rule_queue row whose workflow_execution_id was already pruned from agentic_workflow_executions) aborts the entire restore. Fixed by rewriting single-line FK ADD CONSTRAINT statements in the dump to append NOT VALID, which installs the constraint without revalidating existing rows; subsequent INSERT/UPDATE statements are still checked, only legacy bad rows are grandfathered. Verified end-to-end against a 234 MB production backup. Logic centralised in scripts/_restore_common.py::filter_dump_lines and applied across restore_database_v2.py, restore_database.py, restore_database_v3.py, restore_system.py, and verify_backup.py -- all four restore paths and the verification path get the same treatment.
  • Re-run from history: execution counter not refreshed (2026-05-03): rerunFromHistory() pre-selected articles programmatically but did not call updateRunButton(), leaving the "= N executions" hint stale. Fixed by calling updateRunButton() after the checkbox loop.

Added

  • Tests for get_subagent_eval_version_articles (2026-05-03): Five new @pytest.mark.api tests in test_eval_diagnosis_api.py cover the version-articles endpoint: basic URL return, empty version, None URL filtering, required response keys, and count/urls length consistency. _FakeQuery extended with distinct() to support single-column queries.
  • GET /subagent-eval-version-articles and GET /subagent-eval-compare endpoints (2026-05-03): get_subagent_eval_version_articles returns distinct article URLs for a specific config version (used by re-run from history). get_subagent_eval_compare returns per-article side-by-side scores for two config versions with improvement deltas and aggregate nMAE (used by the version comparison panel).
  • Version comparison and re-run from history UI (2026-05-03): Agent Evals page gains a collapsible "Compare Versions" panel below the MAE chart with per-article improvement/regression table. Each aggregate card gains a "Re-run" button that pre-selects the historical article set for re-evaluation. docs/features/agent-evals.md updated with sections for AI Diagnosis, Version Comparison, and Re-run from History.

[6.2.1 "Io"] - 2026-05-02

Added

  • Focused tests for eval bulk bundle export (2026-05-02): tests/api/test_evaluation_bulk_export.py adds two @pytest.mark.api tests covering export_bundles_by_config_version: (1) Langfuse is skipped by default (fetch_langfuse=False); (2) include_langfuse=True passes through. Both verify session cleanup via session.close().

Removed

  • Source auto-healing feature removed (2026-05-02): The entire source self-healing subsystem has been moved to a dedicated feature branch (dev-io-6.2.1-self-healing-sources) for further development. Removed: source_healing_service.py (1,368 lines), source_healing_coordinator.py, source_healing_config.py, healing_event.py model, healing_exhausted/healing_attempts columns from sources table, four API endpoints (/trigger-healing, /{source_id}/heal, /{source_id}/reset-healing, /{source_id}/healing-history), the healing panel UI in sources.html, the healing settings section in settings.html, the scheduled healing Celery task, and all associated tests and documentation (~7,400 lines total). The feature was incomplete and introduced complexity without production value; it will return when the design is validated on its feature branch.

Fixed

  • ./run_tests.py all-no-ui green again (2026-05-02): Suite went from 425 failures + 59 errors to 0/0 across 2949 active tests. Root causes: (1) tests/worker/conftest.py mocked celery and celery.schedules but not celery.signals, breaking worker-test imports after src/worker/celery_app.py added from celery.signals import worker_process_init for fork safety; an autouse fixture also strips OPENAI_API_KEY / ANTHROPIC_API_KEY / CHATGPT_API_KEY per worker test so a _import_celery_app() re-import never trips assert_test_environment() on keys monkeypatched by earlier tests. (2) tests/e2e/ (Playwright sync_api) was leaving the pytest-asyncio session Runner in a "running" state, so every subsequent async test crashed with Runner.run() cannot be called from a running event loop -- all-no-ui now ignores tests/e2e/ by path and excludes the e2e marker. (3) src/web/routes/sources.py::api_sources_list typed filter_params: SourceFilter without Depends(), so FastAPI treated it as a body parameter on a GET and returned 422 -- changed to Depends() so query params parse normally. (4) src/services/workflow_provider_options.py::_probe_lmstudio had only the localhost -> host.docker.internal fallback; added the symmetric host.docker.internal -> localhost direction so the workflow's reachability gate works on the host. (5) tests/test_web_application.py defined a local async_client fixture hardcoded to localhost:8001 that shadowed the conftest fixture, bypassing USE_ASGI_CLIENT=1 -- removed. (6) tests/conftest.py::test_database_session now falls back to TEST_DATABASE_URL env when test_environment_config is None (the upstream TestContext / TestEnvironmentConfig imports fail silently because those names no longer exist in tests/utils/test_environment.py).

[6.2.0 "Io"] - 2026-04-30

Added

  • RegistryQA corrections.removed[] explicit schema (2026-04-30): src/prompts/RegistryQA removed[] entries now specify {registry_hive, registry_key_path, registry_value_name, reason} instead of a [...] placeholder. Gives the model a concrete shape to emit and enables the per-agent identity filter (todo 001) to match removal entries against extracted items by their natural composite key.
  • Eval bundle surfaces corrections_applied and pre_filter_count (2026-04-30): EvalBundleService._extract_qa_results now populates corrections_applied and pre_filter_count at the curated qa_context level when present on _qa_result, so eval dashboards can read filter metadata without spelunking into extraction_context.raw_result. The qa_agent_map is extended to include RegistryExtract, ServicesExtract, and ScheduledTasksExtract -- their QA results were previously silently dropped from bundles.

Fixed

  • Traceability contract tests now cover all 6 QA prompts (2026-04-30): MIGRATED_QA_AGENTS in test_subagent_traceability_contract.py previously only listed RegistryQA, ServicesQA, ScheduledTasksQA, leaving CmdLineQA, HuntQueriesQA, and ProcTreeQA unchecked by test_preset_qa_prompt_synced. All six are now parametrized; drift between any QA prompt and its embedded preset copy will fail the suite.
  • Regression fixture and no-feedback-injection lock for QA v1 behavior (2026-04-30): Two tests added to TestQACorrectionsApplication: (1) test_qa_fail_does_not_inject_previous_feedback walks all request_chat call args and asserts no user message contains "PREVIOUS FEEDBACK", directly pinning the v1 single-shot behavior; (2) test_article_11_regression_fixture pins the exact item identities and QA response from production bundle eval_bundles_v2599_cmdline.zip / article_11_*.json and asserts the bad item is filtered, count drops to 2, and _llm_attempt == 1.

Added

  • exclude_evals filter on workflow executions list (2026-04-30): GET /api/workflow/executions accepts exclude_evals=true to hide executions whose config_snapshot["eval_run"] is true. Surfaces in the UI as an "Excl. Evals" toggle pill in the execution filter bar, so production runs are not buried by eval-triggered executions.
  • Workflow execution link in Sigma rule preview (2026-04-30): The rule preview modal now shows a clickable "Execution #N" link when workflow_execution_id is populated on the rule, navigating directly to the execution detail view.
  • warnings key in assess_novelty() return dict (2026-04-30): When the semantic precompute path throws, assess_novelty() now includes a non-empty "warnings" list in its return dict (e.g. ["semantic_precompute_failed: falling back to legacy similarity engine"]). Absent on clean runs. Makes engine degradation visible in execution traces rather than silently swallowing the fallback.

Fixed

  • Null-guard hardening in extraction QA pipeline (2026-04-30): Several dict.get() calls in llm_service.py used the get(key, default) form, which does not protect against the model emitting {"key": null}. Fixed with or guards throughout the QA result consumption path: status, items/cmdline_items, command in correction entries, and value in extracted items. Four regression tests added.
  • Zero-similarity matches stripped from similar_rules (2026-04-30): compare_proposed_rule_to_embeddings is called with threshold=0.0 so max_similarity can be computed from all candidates. Before this fix, entries with similarity=0.0 (no semantic overlap) were included in similar_rules and shown in the rule preview. Both agentic_workflow.py (queue storage) and ai.py (sigma-generate API) now filter these out.
  • Silent except: pass promoted to logger.warning in sigma novelty service (2026-04-30): Three bare exception suppression blocks in sigma_novelty_service.py (semantic precompute failure, exact-hash DB lookup, canonical_class query) now emit logger.warning with exc_info=True so degradation is visible in logs and traces.
  • Restore pipeline: 4 reliability gaps fixed (2026-04-30): scripts/restore_system.py and restore_database_v2.py hardened: (1) hardcoded DB credentials replaced with os.getenv(POSTGRES_*) calls to match restore_database.py; (2) --force now skips only the interactive confirmation prompt -- pre-restore snapshots are always created so API callers still get a rollback point; (3) pgvector extension set up after CREATE DATABASE so restoring a backup made before pgvector was installed does not break Sigma similarity search; (4) verify_restore() now checks connectivity and table existence only, not article_count > 0, so restoring to an empty database correctly returns success.
  • Extension-scraped articles no longer show blank gaps (2026-04-30): _scrape_single_url in scrape.py was applying re.sub(r"\s+", " ", ...) whitespace normalization to the normal scrape path but not the pre_scraped_content branch used by the browser extension. Articles submitted via the extension preserved literal newlines around image elements, rendering as blank lines in the article viewer. Normalization now applied to both paths.
  • Eval bundle ZIP filenames unique across same-version re-runs (2026-04-30): export_eval_bundle in evaluation_api.py appended _{record.id} to ZIP entry filenames to prevent UserWarning: Duplicate name when two executions for the same article and config version exist in the same bundle.
  • Agent evals: execution IDs visible on zero-result modal cards (2026-04-30): Zero-result cells in the agent evals comparison table now show "No X found from execution #N" in the modal, matching the non-zero "Found N X from execution #N" format. Execution ID labels removed from table cells (modal-only).
  • Agent evals: Prev button disabled on initial chart load (2026-04-30): The MAE chart window defaulted to the last 25 items, leaving the Prev button active on first load even though the user had not navigated anywhere. Window now starts at index 0 so Prev is correctly disabled.

Added

  • HuntQueriesExtract count contract convergence (2026-04-30): HuntQueriesExtract historically emitted query_count while every other extractor emitted count, which forced test_subagent_traceability_contract.py to skip it from MIGRATED_EXTRACT_AGENTS. Prompt, workflow envelope builder, eval API, and count-extraction logic all converged to count as the canonical field. query_count remains a readable legacy alias for one release to keep cached/in-flight subresults countable. HuntQueriesExtract is now included in the standard traceability contract test. Six regression tests added to test_agentic_workflow_helpers.py pinning the new priority order.
  • N+1 batch-fetch in eval API (2026-04-30): get_subagent_eval_results in evaluation_api.py fetched one AgenticWorkflowExecutionTable row per eval record in a loop. Replaced with a single .filter(...id.in_(...)) bulk query before the loop; executions_by_id dict is used for O(1) lookup per record.
  • EvalBundleService null-name guard (2026-04-30): Langfuse generations whose name attribute is None (not missing) triggered an AttributeError on .lower() during bundle assembly. Fixed with getattr(gen, "name", None) or "". Regression test added.
  • Quickstart preset: claude-haiku-4-5 (2026-04-30): Quickstart-anthropic-haiku-4-5.json added as a lower-cost Anthropic option. Prompts tuned for Haiku: direct, explicit, fewer competing rules.

Changed

  • Model catalog: claude-opus-4-7 added, claude-3-haiku-20240307 retired (2026-04-30): provider_model_catalog.json gains claude-opus-4-7; claude-3-haiku-20240307 removed (superseded by claude-haiku-4-5-20251001, which already has a quickstart preset). Users selecting the old haiku model ID will need to update to the new one.
  • Observable confidence badges now include "Confidence" label (2026-04-30): Confidence score chips on the workflow observable panel now render as "X% Confidence" instead of a bare percentage, reducing ambiguity when multiple numeric fields appear side by side.

Removed

  • Orphaned extract-observables route and ExtractAgent prompt removed (2026-04-30): POST /{article_id}/extract-observables in ai.py had no callers in any template, route, or test — it was a dead endpoint left over from when extraction ran as a single unified LLM call. LLMService.extract_behaviors(), LLMService.extract_observables(), and _EXTRACT_BEHAVIORS_TEMPLATE deleted from llm_service.py; all active extraction goes through run_extraction_agent(). TestExtractObservablesHardFail (3 tests) removed from test_llm_service_agents.py; stale test_extract_observables_api.py entry removed from TEST_INDEX.md. The ExtractAgent prompt editor removed from the workflow UI (extract-agent-prompt-container, LOCKED_EXTRACTAGENT_USER_TEMPLATE, and the render call) since the prompt was only consumed by the deleted route. The ExtractAgent model/provider/temperature config is retained — sub-agents still fall back to it when no per-agent model is set. Prompt key stripped from all 8 quickstart presets and from workflow_config_loader.py.
  • Orphaned CLI eval pathway deleted (2026-04-29): eval_runner.py, langfuse_eval_client.py, and the four agent evaluator classes (ExtractAgentEvaluator, RankAgentEvaluator, SigmaAgentEvaluator, OSDetectionEvaluator) plus BaseAgentEvaluator were removed. None had active callers in routes or tasks; they were a parallel Langfuse-backed eval system superseded by the UI pathway. eval_preset_snapshot_service.py and seven scripts/eval_*.py runner scripts deleted for the same reason. POST /api/eval/run endpoint removed (pointed users to the now-deleted scripts). EvaluationTracker.compare_evaluations() retains its comparison logic, inlined as a standalone _compare_metrics() function. The UI pathway (evaluation_api.py, SubagentEvaluationTable) is the only eval system remaining.

Added

  • Eval dataset maintenance guide (2026-04-29): Agent Evals gains a Maintaining the Eval Dataset section covering how to add articles (dump snapshot, update YAML), remove articles (edit both files; DB cleanup is automatic at startup), update expected counts (YAML only), and a quick checklist. Complements the existing config/eval_articles_data/README.md snapshot mechanics doc.
  • Canary-pattern regression tests for py/stack-trace-exposure (2026-04-29): New tests/api/test_stack_trace_exposure_regression.py (7 tests, ~4 s) plants uniquely-recognizable strings inside mocked exceptions and recursively asserts those strings never appear in HTTP response bodies. Covers _scrape_single_url, export_eval_bundle, _parse_and_validate_rule, api_get_lmstudio_models, api_get_lmstudio_embedding_models, and api_services_health. Designed to fail loudly if any future change reintroduces f"...{str(e)}" or detail=str(e) patterns in HTTP responses.
  • Regression tests for huntability scorer nonlocal fix (2026-04-29): tests/services/test_sigma_huntability_scorer.py gains test_commandline_present_exceeds_floor and test_no_commandline_returns_floor. The existing test_score_commandline_specificity only checked 0.0 <= score <= 1.0, which passed silently even while _score_commandline_specificity always returned 0.3 (the no-commandline floor). New tests pin the correct invariant: score must exceed 0.3 when CommandLine fields are present, and must equal 0.3 when they are absent.

Fixed

  • Huntability scorer always returned 0.3 for commandline specificity (2026-04-29): _score_commandline_specificity in sigma_huntability_scorer.py contains a nested find_commandlines function that sets has_commandline = True when a CommandLine key is found. Without nonlocal has_commandline, the assignment created a local variable inside the closure — the outer has_commandline stayed False, and the method always fell through to return 0.3 (the no-commandline floor), regardless of what the detection actually contained. Added nonlocal has_commandline to fix the closure binding. Affected every rule with CommandLine fields; scores were systematically underweighted for commandline-heavy detections.
  • Smart-quote normalization in span evaluation was a no-op (2026-04-29): span_normalization.py used raw strings r'["""“”]' and r"[\'\'‘’]" for the quote-normalization regex. In raw strings “ is 6 literal characters, not U+201C — the character class matched the backslash and ASCII digits rather than the Unicode smart-quote code points. Converted to regular strings so “, ”, ‘, ’ are interpreted as their Unicode code points. Smart curly quotes in extracted spans now normalize to straight ASCII quotes as intended.
  • POSIX character class [[:print:][:space:]] silently wrong in Python (2026-04-29): scripts/maintenance/fix_corrupted_articles_batch.py and fix_incomplete_articles.py used re.sub(r"[[:print:][:space:]]", "", ...) to strip printable characters. Python's re does not support POSIX bracket expressions — the pattern silently compiled as a character class containing the literal ASCII characters [, :, p, r, i, n, t, s, a, c, e, ]. Non-printable corruption characters were not removed. Replaced with r"[ -~\s]" (printable ASCII U+0020–U+007E plus whitespace), which matches the intended behavior. The CodeQL py/regex/duplicate-in-character-class alert on these files was a symptom of this bug (duplicate chars in the malformed class).
  • Two more py/stack-trace-exposure leaks in service health (2026-04-29): health.py Langfuse flush exception handler returned f"Flush failed: {str(flush_exc)}"; outer Langfuse exception handler returned str(langfuse_exc). Both now return static error strings ("Flush failed", "Langfuse check failed") with full exception detail captured via logger.warning(). Discovered while writing the regression test suite.
  • CodeQL static analysis cleanup -- 14 alert groups resolved (2026-04-29): Systematic pass across all open py/ CodeQL alerts. Changes are correctness-preserving dead-code removals unless noted otherwise. Groups by rule: (1) py/multiple-definition — removed shadowed variable assignments in sigma_coverage_service.py, sigma_sync_service.py, analytics.py, sigma_queue.py, migrate_pgvector_indexes.py, run_tests.py, and conftest.py; (2) py/unreachable-statement — removed dead code after return in actions.py, rss_parser.py, run_ai_tests.py, integration/conftest.py; (3) py/repeated-import — removed 30+ inline import re / import json / import asyncio statements already present at module top-level across llm_service.py, agentic_workflow.py, ai.py, evaluation_api.py, rss_parser.py, modern_scraper.py, processor.py, sigma_matching_service.py, content.py, content_filter.py; promoted pathlib.Path to top-level import in test_provider_model_catalog.py; (4) py/unused-import — removed three unused imports from test_ai_cross_model_integration.py (GPT4oContentOptimizer, SigmaValidator, HybridIOCExtractor); (5) py/unused-local-variable — removed 11 unused assignments across backup.py (force), evaluation.py (model_version), sigma_novelty_service.py (canonical_text), sigma_extended_validator.py (sigma_rule → _), sigma_agent_evaluator.py (expected_rules), model_training.py (start_pos, end_pos), sentence_splitter.py (sentencizer), llm_optimized_endpoint.py (article_url), celery_app.py (duplicates_filtered), langfuse_client.py (suppress), cli/commands/backup.py (result); (6) py/unused-global-variable — removed ENVIRONMENT_UTILS_AVAILABLE from run_tests.py, _SUBAGENT_TO_SA_BLOCK from test_workflow_comprehensive_ui.py, _ARTICLE_DICT_KEYS_CONSUMED from test_mcp_article_get_contract.py, METADATA_KEY_TO_CATEGORY from keyword_resolution.py; (7) py/commented-out-code — removed 14-line disabled pytest-html block from run_tests.py; (8) py/ineffectual-statement — added # codeql[py/ineffectual-statement] suppression on five await task statements inside contextlib.suppress() blocks in ioc_extractor.py and async_debug_utils.py (idiomatic async cleanup, not dead code). Skipped py/unnecessary-pass (group 7), py/empty-except (group 12), and py/cyclic-import (group 15, architectural).

Changed

  • Model-based context window lookup (2026-04-29): LLMService._get_context_limit() now checks a MODEL_CONTEXT_TOKENS catalog in provider_model_catalog.py before falling back to the WORKFLOW_CLOUD_CONTEXT_TOKENS env-var default. All supported OpenAI (gpt-4o/mini 128k, gpt-4.1 family 1M, o-series 128-200k) and Anthropic (claude-3.x 200k, claude-2.x 100-200k) models have explicit entries. Selecting a model in the UI automatically uses its correct context budget without manual env-var tuning.
  • Snippet budget cap in CmdlineExtract preprocessor (2026-04-29): Dense articles that produce 300+ attention snippets can fill the entire context window and displace article content (article_2068: 0/7 extracted with preprocessor ON vs 6/7 OFF). run_extraction_agent now caps the snippet section to 25% of the context budget, trimming from the end (earliest/highest-signal snippets preserved). cmdline_attention_preprocessor.process() gains an optional max_snippets hard-cap parameter.
  • QA MaxRetries default reduced to 1 (2026-04-29): QAConfig.MaxRetries default changed from 5 to 1 to avoid runaway QA retry loops on extraction failures.
  • Documentation (2026-04-29): Agent Evals gains a Concurrency Throttle section explaining the concurrency_throttle_seconds field (default 5 s, range 0-60 s), the stagger formula countdown(N) = N x (0.2 s base + throttle_seconds), the dispatch window estimate, and why time-spreading dispatches prevents TPM 429 rate limits on fan-out runs.

[6.1.1 "Io"] - 2026-04-28

Added

  • Vision LLM proxied through backend (2026-04-28): POST /api/vision/extract new endpoint accepts an image data-URL and a provider name, resolves the API key from DB settings / env, and forwards the request to OpenAI (gpt-4o) or Anthropic (claude-sonnet-4-6). The browser extension no longer stores or transmits API keys; callVisionLLM in background.js now calls the backend proxy instead of hitting cloud providers directly. vision-api-key field removed from popup.html / popup.js and from chrome.storage.local.
  • Image fetch moved to background service worker (2026-04-28): fetchImageAsDataURL is now a dedicated handler in background.js (action: fetchImageAsDataURL). Both OCR and Vision LLM paths in popup.js send a chrome.runtime.sendMessage to the background instead of injecting a content script into the active tab, which resolves MV3 scripting.executeScript permission issues.
  • OCR block append-on-revisit (2026-04-28): When the browser extension re-submits a URL that already exists in the database, _scrape_single_url extracts [Image OCR: ...] blocks from the new pre_scraped_content and appends any blocks not already present to the stored article, rather than discarding the submission. Returns a "Article updated with N OCR block(s)" message.
  • Force-scrape hash dedup short-circuit (2026-04-28): _scrape_single_url now checks content_hash uniqueness before attempting an insert when force_scrape=True, returning the existing article instead of hitting the IntegrityError constraint.
  • ContextLengthExceededError fail-fast (2026-04-28): New exception class in llm_service.py. When an API call is rejected with context_length_exceeded, the retry loop re-raises immediately instead of retrying. The workflow graph catches it, stores context_length_exceeded: True in subresults[agent].raw, and continues to remaining subagents.
  • Infra-failure detection marks executions as failed (2026-04-28): _extraction_is_infra_failure() in agentic_workflow.py inspects extraction_result.subresults after the graph finishes. If every non-skipped subagent returned an infra error (LMStudio not ready, context overflow, missing key, broken prompt config), run_workflow() sets has_error = True and records a clear failure message, so the execution appears failed in the UI instead of completed with zero items.
  • Context-overflow and infra-not-ready flags in eval API (2026-04-28): get_subagent_eval_results and get_execution_commandlines in evaluation_api.py now return context_length_exceeded and infra_not_ready boolean fields per result, populated by the new _execution_has_context_overflow and _execution_infra_not_ready helpers.

Fixed

  • ReDoS in OCR regex (2026-04-28): Two-layer fix for CodeQL py/polynomial-redos. Alert #503: re.findall input capped at 200k chars. Alert #518: both quantifiers in the pattern bounded ([^\]]{0,2000}, [^\[]{0,10000}) so per-attempt scan cost is O(1) regardless of input length; input cap also lowered to 50k for defence-in-depth.
  • Error messages no longer leak internal details to HTTP clients (2026-04-28): f"...{str(e)}" patterns in ai.py, sigma_queue.py, and capability_service.py replaced with static strings; full exception details continue to be logged server-side. Addresses CodeQL py/stack-trace-exposure findings.
  • codex-mini removed from OpenAI model allowlist (2026-04-28): codex-mini and codex-mini-latest removed from model_validation.py and provider_model_catalog.py. The codex- prefix no longer passes the fallback allowlist check. Quickstart preset Quickstart-openai-codex-mini.json deleted.
  • Langfuse session URL uses /sessions/ path (2026-04-28): get_workflow_debug_info now builds {host}/project/{project_id}/sessions/{session_id} when a project ID is available, replacing the earlier /traces/{trace_id} logic that required a resolved trace ID.
  • Duplicate import re in sigma_validator.py (2026-04-28): Three redundant inline import re statements inside clean_sigma_rule removed; the module-level import is sufficient.

[6.0.4] - 2026-04-27

Added

  • Quickstart preset: gpt-4o-mini (2026-04-27): Quickstart-openai-gpt-4o-mini.json added as a lower-cost OpenAI option. All agents use gpt-4o-mini; thresholds match the gpt-4o preset.

Fixed

  • LMStudio health-check gate (2026-04-27): run_workflow() now probes LMStudio reachability before starting any workflow that uses an LMStudio provider. If the server is unreachable the execution is marked failed immediately with a clear "LMStudio is not reachable" error rather than running for several minutes and failing at the first LLM call. Gate is skipped entirely for non-LMStudio providers so OpenAI/Anthropic runs are unaffected.
  • Eval spinner stuck after completion (2026-04-27): The progress spinner on the Agent Evals page was revealed when an eval run started but never individually hidden when the run finished (the surrounding #evalStatus panel was toggled but the spinner #evalSpinner was not). All four terminal states (static success, trigger error, polling done, polling error) now explicitly hide the spinner.
  • Source collection User-Agent and crawl politeness (2026-04-27): HTTPClient now sends an honest Huntable-CTI-Studio/1.0 User-Agent instead of a fake Chrome string. Per-source crawl delays are enforced via configure_source_robots() — scrapers pass their YAML robots_config block and the client sleeps for crawl_delay seconds after each successful GET. All 26 source entries in config/sources.yaml updated with the honest identifier. Default check_frequency changed from 1 hour (3600) to 4 hours (14400) across SourceConfig, DB model, and async_manager; sources that intentionally poll faster keep explicit overrides.

Removed

  • AutoTriggerHuntScoreThreshold removed from agent config (2026-04-27): Field was never exposed in the UI and had no effect on workflow behavior. Removed from ThresholdConfig, all 8 quickstart presets, the loader, and the migration shim. ThresholdConfig's extra="forbid" now rejects the key on import, preventing stale presets or DB records from silently carrying it forward.

[6.0.3] - 2026-04-27

Added

  • ScheduledTasksExtract sub-agent wiring (2026-04-27): Full layer-by-layer wiring for the ScheduledTasksExtract / ScheduledTasksQA sub-agent pair. All 8 quickstart presets now include a ScheduledTasksExtract block (model/provider mirrored from ServicesExtract per preset). config/eval_articles.yaml gains a scheduled_tasks key with 5 CTI article URLs. config/eval_articles_data/scheduled_tasks/ stub directory created for eval result snapshots. workflow.html exposes the agent config block and evaluation UI; agent_evals.html and subagent_evaluation.html include the new subagent in selector lists. tests/config/test_scheduledtasks_wiring.py adds 46 tests covering schema, loader, migration, subagent utils, UI-ordered round-trip, prompt files, preset files, eval data, and default prompts. Five related test suites updated to include ScheduledTasksExtract/QA in their agent lists and fixtures.
  • process_lineage eval set expanded (2026-04-27): Three non-zero articles added to the process_lineage eval corpus: ELPACO-team ransomware intrusion (expected=4), From Zero to Domain Admin (expected=2), and Lunar Spider near-two-month intrusion (expected=2). All three are DFIR Report articles with explicit prose parent-child spawn sentences validated against the proctree-extract contract. Set now has 13 entries (8 non-zero).

  • Create-Huntable-Agent skill: ScheduledTasksExtract lessons incorporated (2026-04-27): .claude/skills/Create-Huntable-Agent/SKILL.md updated with two new Common Pitfalls learned from ScheduledTasksExtract wiring. Pitfall #10 -- empty Prompt.prompt / QAPrompt.prompt in quickstart presets: existing Critical callout was not enough to prevent empty prompts shipping; elevated to a named pitfall with symptom description and explicit TestPresetFiles test requirement. Pitfall #11 -- structured extractor value field: agents that use domain identity fields (task_name, task_path, etc.) instead of a generic value field would previously trigger a PromptConfigValidationError before any LLM call, causing 100% MESSAGES_MISSING in evals; now documented with the has-domain-fields exception. Layer 6 gains QA temperature uniformity note (0.1 across all presets) and preset description freshness warning. Verification Step #7 documents the test_preset_export_comparison.py diff utility.

  • Langfuse query client refactored to observations.get_many (2026-04-27): eval_bundle_service.py replaced the nested MockResponse fallback chain (session_id -> trace_id -> unfiltered scan) with a clean _get_langfuse_api().observations.get_many(trace_id=..., type="GENERATION") call. langfuse_client.py gains _get_langfuse_api() helper that constructs a LangfuseAPI low-level client from the same credentials as the high-level singleton. Duplicate dead-code check removed (not generations or not hasattr(generations, "data") after an earlier early-return guard).

Fixed

  • Langfuse public key format validated before API call (2026-04-27): api_test_langfuse_connection in ai.py now rejects public keys that do not start with pk-lf- before constructing any API client -- catches accidentally pasted OpenAI keys or other credential confusion. Returns a clear message pointing to Langfuse -> Settings -> API Keys. Langfuse singleton in langfuse_client.py is now reset automatically when LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, or LANGFUSE_HOST are saved in Settings (single update or bulk). Three regression tests added to test_langfuse_connection.py; settings reset tests added to test_settings_api.py.
  • Settings save "Failed to save: backup configuration, cron editor" (2026-04-27): Two independent root causes. (1) POST /api/backup/cron carried RequireAdminAuth (returns 401 on missing X-API-Key), but the Settings page Save button sends no API key header -- every save silently failed. Fixed by removing RequireAdminAuth from api_update_backup_cron; destructive backup routes (create, restore, delete) retain auth. (2) PUT /api/cron propagated CronUnavailableError (raised by crontab on macOS sandbox / no Full Disk Access) as an unhandled exception (503). Fixed by catching CronUnavailableError and returning {"success": True, **service.get_snapshot()} -- get_snapshot() already handles the unavailable case and returns cron_available: False. Regression tests added to test_backup_cron_api.py (test_update_backup_cron_requires_no_admin_auth) and test_cron_api.py (test_replace_cron_returns_success_when_cron_unavailable, test_replace_cron_requires_no_admin_auth).
  • Workflow queue stat cards now clickable and show global counts (2026-04-27): The /workflow#queue stat cards (Pending Review / Approved / Rejected / Submitted) were cosmetic-only -- they had hover styling but no click handler, and their counts were computed from the current fetched page rather than the full database. Two bugs fixed: (1) updateQueueStats() previously counted status values from the in-memory queue array (one paginated page), so filtering by status=pending would show 12 pending / 0 approved instead of global totals. Fixed by adding a status_counts field to the /api/sigma-queue/list response, populated by a single GROUP BY status aggregation query that always runs against the full table regardless of the active filter. (2) Stat cards are now interactive -- clicking any card sets the dropdown to that status and reloads; clicking the same card again clears the filter. Cards carry data-filter-status, role="button", and keyboard (Enter/Space) support for accessibility. The active filter card is highlighted with a purple outline via the q-stat-card--active CSS class. The dropdown filter options (pending / approved / rejected / submitted) were already correct SIGMA-queue statuses and were not changed. Covered by tests/api/test_sigma_queue_status_counts.py (7 tests) and updated tests/api/test_sigma_queue_list_api.py shape assertions.

[6.0.2] - 2026-04-26

Fixed

  • Source count inconsistencies across /health, /sources, and /build-dashboard (2026-04-26): /health reported 41 sources (bare COUNT(*) in get_database_stats()), /sources badges showed 39 (Jinja loop excluded manual and eval_articles), the /sources JS filter counter showed "X of 40" (hidden manual card included in querySelectorAll), and /build-dashboard Quick Overview showed "Active Sources 39" from a separate unguarded count. Root cause: three independent code paths computed source counts with different exclusion logic. Fix: (1) get_database_stats() in async_manager.py now filters _INTERNAL_SOURCE_IDENTIFIERS = ("manual", "eval_articles") from both total_sources and active_sources DB counts; (2) api_dashboard_data() in dashboard.py applies the existing _EXCLUDED_HEALTH_IDENTIFIERS constant (already used for uptime) to the active_sources and total_sources Python-side counts; (3) sources.html JS filterSources() changes its querySelectorAll from .source-card to .source-card:not([hidden]) so the permanently-hidden manual card is not counted in the filter total. All four surfaces now agree on 39 as the canonical source count.

[6.0.1] - 2026-04-24

Added

  • Quickstart preset: LM Studio Gemma 3 4B IT (2026-04-24): Quickstart-LMStudio-Gemma4B.json added for minimal local testing with Gemma 3 4B IT via LM Studio. All sub-agents share the same model; QA disabled; MinHuntScore set to 97.0 for signal-focused evaluation runs.
  • MCP tool: get_queue_rule (2026-04-24): New get_queue_rule(queue_number) tool in the Huntable MCP server. Returns full YAML, status, similarity scores (top 10), reviewer notes, PR URL, and source article for any sigma queue item by its integer queue number. Matches the Queue #N IDs shown by list_sigma_queue. Covered by tests/unit/test_mcp_get_queue_rule.py.
  • Agent evals: results comparison legend (2026-04-24): Collapsible "How to read this table" panel added above the results comparison table in /mlops/agent-evals. Documents cell layout (actual count, signed delta, expected column), badge color thresholds (exact/close/miss/throttled), and MAE/nMAE chart metric definitions. MAE chart header now shows inline nMAE band definitions (green <=0.20, yellow 0.21-0.50, red >0.50).
  • Agent evals reference doc (2026-04-24): docs/features/agent-evals.md added explaining results table layout, badge color key, MAE/nMAE metric definitions, and alert icons. Linked from mkdocs.yml.

Fixed

  • Settings master Save omitted source auto-healing and other subsections (2026-04-24): masterSave() in settings.html refactored from isolated per-section try/catch (which silently swallowed failures) to a runSection() error-collection pattern. All six subsections -- workflow providers, auto-trigger threshold, backup config, source auto-healing, cron editor, scheduled jobs -- now run atomically under the master Save button. Failures accumulate in a failures[] array; a single error toast lists every failed section name. saveSourceHealingSettings, saveScheduledJobs, and saveCronEditor gained a silent mode for orchestrated calls. Root cause documented in docs/solutions/ui-bugs/settings-master-save-omits-source-healing-20260424.md.
  • GET /api/settings response now sets Cache-Control: no-store (2026-04-24): Endpoint switched from returning a plain dict to a JSONResponse with Cache-Control: no-store. Prevents browsers from serving a stale settings body across refreshes after a save, which masked persisted changes. Frontend fetch('/api/settings') call updated with { cache: 'no-store' } to match. Test updated to assert the response header.
  • auto_trigger_hunt_score_threshold excluded from configs_identical check (2026-04-24): update_workflow_config compared all threshold fields to detect no-op updates but omitted auto_trigger_hunt_score_threshold. A threshold-only edit was silently dropped. Field now included in the comparison. Regression test added to test_workflow_config_api.py.
  • Workflow trigger idempotency checked config_version but not config_id (2026-04-24): The completed-run deduplication filter in WorkflowTriggerService matched on (article_id, config_version) only. Two different configs at the same version number would collide and block re-processing. Filter now includes config_id in the tuple.
  • Collapsible panel headers too tall; content overlapped on expand (2026-04-24): Keyword Matches and Article Metadata panel headers in article_detail.html reduced from py-6 to py-3. Filters/Search/Sorting header in articles.html reduced from p-6 pb-4 (with stray mb-4) to py-3 px-6. Expanded content divs for all three panels now carry a top margin (mt-4 / mt-6) to clear the header row.
  • MLOps module cards not full-card clickable (2026-04-24): M-01 (ML vs Hunt Comparison) and M-02 (Agent Evaluations) cards converted from div.module-card + inner <a> to a.module-card so the entire card surface is the link target. Inner <a> buttons converted to <span> to avoid nested interactive elements.
  • Sources: healing UI hidden when source auto-healing is disabled (2026-04-24): Heal Now, Reset, History, and NEEDS ATTENTION elements are all suppressed when source_healing_enabled=false in settings. healing_enabled flag injected into the sources template from SourceHealingConfig.load().enabled; a HEALING_ENABLED JS constant guards startHealingStatusPolling(). Previously the buttons always rendered and clicked through to a disabled-service error.
  • Article trigger button renamed to "Reprocess" (2026-04-24): "Send to Workflow" button in article_detail.html renamed to "Reprocess" with an explanatory tooltip clarifying it bypasses the idempotency gate. Pairs with the workflow trigger idempotency fix so the UI label matches the actual behavior.
  • Workflow architecture docs corrected (2026-04-24): docs/concepts/agents.md and docs/concepts/pipelines.md updated to describe the pipeline as early-exit gates rather than a branching graph, matching the actual LangGraph implementation.

Removed

  • gpt-5.3-codex removed from model catalog (2026-04-24): Entry dropped; gpt-5.3-codex-spark remains.

[6.0.0] - 2026-04-23 "Io"

Added

  • Eval concurrency throttle (2026-04-23): Added concurrency_throttle_seconds field (default 5 s, 0–60 s) to SubagentEvalRunRequest so each article dispatch is staggered beyond the internal DB-race floor, preventing TPM rate limits on fan-out runs. UI exposes a Concurrency Throttle (s) input on the Agent Evals page with localStorage persistence. Status text shows the estimated dispatch window so operators can predict total run time before clicking RUN.
  • Eval throttle-run detection in results and aggregates (2026-04-23): _execution_is_throttled() checks both error_message (terminal 429) and the error_log conversation entries (where a throttled extractor stamps errors on otherwise-completed runs with actual_count=0). Per-article result rows now carry a throttled flag and a warning chip; aggregate cards show a throttled count with a warning badge so operators can distinguish calibration failures from rate-limit noise without inspecting raw logs.
  • GPT-5 family and codex-mini-latest in model catalog (2026-04-23): gpt-5 and gpt-5-mini added to DEFAULT_CATALOG in provider_model_catalog.py. filter_openai_models_project_allowlist now auto-passes any gpt-5* model via regex so future GPT-5 releases only need a catalog entry. codex-mini-latest added to the explicit allowlist. New quickstart presets: Quickstart-openai-gpt-4.1.json, Quickstart-openai-gpt-4o.json, Quickstart-openai-gpt-5.json, Quickstart-openai-codex-mini.json.
  • ESC key closes source healing history panel (2026-04-23): keydown listener added to sources.html that calls closeHealingPanel() on Escape if the panel is open and no modal is in front. Playwright regression test added to modal_escape_key.spec.ts.
  • Browser extension: Vision LLM extraction mode (2026-04-23): popup.js now supports three extraction modes (OCR / Vision LLM / Hybrid) selected via a new dropdown. Vision LLM mode sends the image to OpenAI (GPT-4o) or Anthropic (Claude Vision) via callVisionLLM in background.js (the service worker, unaffected by extension-page CSP). Hybrid mode tries Vision LLM first and falls back to OCR if no API key is present. API key and provider persist in chrome.storage.local. popup.html updated with mode selector, vision config panel (provider dropdown + password-masked API key field), and Anthropic option.
  • Source healing: trafilatura probe and platform auto-detection (2026-04-23): Two new concurrent probes added to SourceHealingService._run_probe_batch(). The trafilatura probe fetches the source URL and extracts clean article text via trafilatura.extract(); the result is injected into the LLM context as ground truth for inferring correct body_selectors. The platform detection probe fingerprints Ghost (x-ghost-cache-status header, content="ghost" meta tag) and Substack (CDN hint) sites and immediately returns a known-good feed URL on first-round attempts, bypassing LLM entirely. UA rotation (Accept/Accept-Language/Accept-Encoding headers) added as an automatic 403 retry step. trafilatura==1.12.2 added to pyproject.toml and requirements.txt.
  • LM Studio model fetch TTL cache in workflow UI (2026-04-23): loadLMStudioModels() now caches its last successful fetch for 15 seconds (LM_STUDIO_REFETCH_TTL). Repeated loadConfig() calls within the TTL window reuse the cached result instead of probing the local inference server again, eliminating duplicate 5-second LM Studio polls on settings changes.

Fixed

  • Browser extension: OCR hang fixed (Tesseract.js v5 blob-worker + MV3 CSP) (2026-04-23): Tesseract.js v5 defaults to wrapping the worker in a blob URL (workerBlobURL: true); the blob URL's self.location.href evaluates to the blob prefix, so WASM file paths resolve to empty strings and XHR fails silently. Fixed by passing workerBlobURL: false plus explicit workerPath: chrome.runtime.getURL('worker.min.js') and corePath: chrome.runtime.getURL(''). Chrome MV3 CSP updated with 'wasm-unsafe-eval' for WASM execution. Tesseract worker, WASM binary, and Emscripten glue files (SIMD + fallback) vendored into the extension and listed in web_accessible_resources. displayImageList() rewritten from innerHTML template literals to DOM API (createElement / textContent) to eliminate XSS risk from page-sourced img.alt.
  • CI: Enforce Pinned Versions check no longer fails on pyproject.toml (2026-04-23): The grep-based check ([><=~]) matched every TOML key = value = sign, making every run fail. Replaced with an inline Python script scoped to requirements.txt only; allows >= with an inline comment (for security-floor transitive pins). Removed unused from typing import Literal import in src/utils/input_validation.py that caused Ruff F401 failures in Dependabot PRs. pytest-playwright pinned from >=0.7.0 to ==0.7.2.
  • SSRF protection on scrape endpoint (2026-04-23): _scrape_single_url() in src/web/routes/scrape.py now calls validate_url_for_scraping() before fetching, blocking private/loopback/link-local IP ranges and non-HTTP(S) schemes. Returns HTTP 400 on validation failure.
  • model_training: CodeQL shell-injection false positive resolved (2026-04-23): Added re.match(r"^[\w.\-]+$", version) guard in train_cmd_extractor_model() to satisfy CodeQL's taint tracking. The guard is defense-in-depth -- subprocess list form already prevents injection, but CodeQL did not track the hardcoded-map and no-shell=True constraints. # codeql[py/shell-command-constructed-from-input] suppression comment added on the subprocess.run call.
  • CodeQL path-injection false positives suppressed in backup restore (2026-04-23): Added # codeql[py/path-injection] inline suppression comments to src/web/routes/backup.py lines 354-355. Both lines are the path-traversal defense -- backup_path.resolve() and backup_dir.resolve() feed directly into .relative_to() which raises on escape. CodeQL flagged the input side of the sanitization check without tracking that validation occurs on the next line.
  • run_tests.py "failed" count false-positive on xfail summary (2026-04-23): The failed regex r"(\d+)\s+failed\b" matched inside xfailed summaries, inflating failure counts. Added a lookahead (?=\s*,|\s+in\b) so the pattern only fires on the standard N failed in / N failed, form. Covered by tests/test_run_tests_parsing.py.

  • UI test tier commands and feature-area slicing (2026-04-19): run_tests.py now supports ui-smoke, ui-fast, and ui-full tiers, plus --serial for disabling pytest xdist parallelism and --area for pinning the Node Playwright run to one feature project. Added docs/development/ui-test-tiers.md and linked it from docs/index.md. tests/playwright.config.ts now groups specs into feature-area projects and keeps quarantined suites out of the default run unless CTI_INCLUDE_QUARANTINE=1.

  • Inline prompt Validate regression coverage (2026-04-19): Added tests/unit/test_inline_validate_button_visible.py to lock in the inline workflow prompt-editor behavior where the Validate button remains visible outside edit mode and validateAgentPrompt() falls back to stored prompt content when the textarea is not mounted.
  • Opt-in production smoke tests (prod_smoke marker) (2026-04-18): Added prod_smoke pytest marker and guard for opt-in read-only smoke checks against a non-test DATABASE_URL. When ALLOW_PROD_SMOKE=1 is set, pytest requires explicit -m prod_smoke marker expression to proceed; runs without this flag are rejected. Normal test environment guard is skipped for prod_smoke runs. Enables safe live-data validation without forcing all CI runs to connect to non-test infrastructure.
  • --playwright-only test-runner flag (2026-04-18): python3 run_tests.py ui --playwright-only runs only the Node.js Playwright section (tests/playwright/*.spec.ts) and skips pytest entirely. Mirrors the existing --skip-playwright-js so both UI sections (pytest tests/ui/ and Node.js tests/playwright/) can now be run independently. Help text in run_tests.py and docs/development/testing.md updated with the new "UI Test Sections" breakdown.
  • Validate button enhancement (2026-04-18): QA prompts now validated client-side in the expanded editor (checks role, system, instructions, evaluation_criteria non-empty); save is blocked on structural hard-fail errors with user-facing error messages. New GET /api/workflow/config/validate endpoint dry-runs the full Pydantic schema plus all agent prompts server-side. "Validate All" button added to config footer to validate entire workflow before submission. Prevents silent failures from misconfigured prompts burning retries.
  • Workflow config v1→v2 migration & agent normalization (2026-04-18): Backward-compatibility layer _normalize_v2_strict() added to handle legacy CmdlineQA key normalization across three schema sections (Agents, QA.Enabled, Prompts). ServicesExtract and ServicesQA added to full agent schema. AGENT_DISPLAY_NAMES centralized as single source of truth for Python/JS display name consistency. Conflict resolution: canonical "CmdLineQA" survives, legacy "CmdlineQA" silently dropped. Comprehensive test coverage in test_cmdline_qa_v2_shim.py (209 lines, 12 scenarios).
  • Source self-healing executive overview doc (2026-04-17): docs/source-self-healing.md added as a user-facing explanation of the auto-healing pipeline covering what it can/cannot fix, the 5-round diagnostic cycle, safety features, and dispatch triggers. Cross-links to the detailed architecture doc.
  • Extractor Contract runtime validators (2026-04-17): _validate_qa_prompt_config() and _validate_extraction_prompt_config() added to llm_service.py. QA validator hard-fails if a prompt is missing role/system, instructions, or has an empty evaluation_criteria list; fires at retry 1 for fast feedback. Extraction validator enforces role/system presence. Both replace silent fallback behavior that previously allowed misconfigured prompts to reach the LLM and produce empty or malformed output.
  • Workflow config API consistency tests and UI/API parity Playwright spec (2026-04-17): test_workflow_config_active_version_roundtrip (smoke, test_endpoints.py) and TestWorkflowConfigConsistency (integration_full, test_workflow_config_api.py) verify that the active config version and thresholds are consistent across /api/workflow/config, /api/workflow/config/versions, and /api/workflow/config/version/{version}. Playwright spec workflow_config_ui_api_parity.spec.ts asserts UI displays the same version and threshold values as the API.
  • 98 new prompt contract regression tests (2026-04-17): tests/config/test_recent_prompt_changes.py (44 tests) locks in QAAgentCMD seed structure, quickstart preset standard-envelope compliance, QAEnabled flags, absence of user-turn content in system prompts, and Qwen3 QA prompt completeness. tests/config/test_parse_prompt_parts_regression.py (54 tests) locks in the parsePromptParts UI fix via Node.js subprocess testing plus preset contract coverage; includes an explicit sentinel that will fail if the broken behavior is reintroduced.
  • Auto-started test containers for stateful runs (2026-04-15): run_tests.py now checks for both required local test containers (cti_postgres_test and cti_redis_test) before stateful runs. If either is missing, it starts postgres_test and redis_test from docker-compose.test.yml, then waits for both to report healthy. scripts/test_setup.sh and tests/RUN_API_TESTS.sh follow the same two-container startup path.
  • Healing progress UI on sources page (2026-04-15): Source cards with active healing now show a live "Healing: Round N/M" badge that polls every 5 seconds. Opening the healing history panel renders a progress bar (rounds completed vs. max), status badge (starting / in_progress / healed / exhausted), and a completion banner (success or failure with round number). New events get a brief flash animation. The GET /api/sources/{id}/healing-history endpoint now returns status, current_round, max_attempts, and healing_exhausted fields derived from event history and healing config.
  • Langfuse v4 SDK compliance tests (2026-04-15): TestLangfuseEvalClientV4Api (5 tests) verifies create_experiment and create_trace call start_observation (v4) and never the deprecated start_span (v3), that session_id flows through TraceContext, and that the removed update_trace method is not called. Guards against regressing to the v3 pattern.
  • UI Stability Contracts in AGENTS.md (2026-04-15): Documents contract-grade DOM IDs, JS function signatures, agentPrefix token map, CSS variable conventions, and the spec-required change policy for the sources/workflow UI. Prevents silent breakage from renames that span DOM IDs, onchange bindings, and API calls simultaneously.

Removed

  • UI test diet: 952 -> 257 active tests (2026-04-19): Four-phase audit and prune of the combined pytest UI + Playwright TS test suite. Phase 1: cross-referenced pytest vs Playwright coverage and deleted 22 duplicate pytest tests where TS specs had equivalent or better assertions (sources, dashboard, settings, jobs, collapsible panels). Phase 2: parametrized trivial input variations in test_workflow_comprehensive_ui.py (5 panel toggles -> 1, 3 tab navs -> 1, 4 stat displays -> 1) and stripped 35 permanently-skipped test.skip definitions from agent_config_*.spec.ts and other TS specs. Phase 3: rebalanced tiers -- moved 3 slow interactive tests to @pytest.mark.slow, removed @ui_smoke from a 3s-wait test, relocated test_top_level_pages_smoke_ui.py (httpx, not Playwright) from tests/ui/ to tests/smoke/. Phase 4: formally quarantined workflow_executions.spec.ts, observables_plain.spec.ts, observables_exact_selection.spec.ts with documented blockers. Final cut dropped 34 Python files and 21 TS specs, trimming remaining files to keep only high-value regression and smoke tests. Python UI files: 46 -> 11. TS spec files: 50 -> 32. Total lines removed: ~18,000. All non-UI suites pass (smoke 71, unit 2005, api 259, integration 43).
  • Unused @openrouter/sdk frontend dependency (2026-04-19): Removed @openrouter/sdk from package.json and package-lock.json; it was not used by the current UI or test runner code.
  • In-app RAG Chat UI (2026-04-18): Removed the /chat page, /api/chat/* endpoints, the React RAGChat component, src/web/routes/chat.py, src/web/templates/chat.html, scripts/migrate_rag_presets.py, and the RagPresetTable SQLAlchemy model. Also removed the ENABLE_RAG environment variable and the rag_enabled() template helper -- retrieval is now always on (required by the Huntable MCP server). setup.sh no longer prompts to disable RAG; it now only prompts whether to generate Sigma embeddings now or later. Conversational retrieval has moved to the Huntable MCP server (see docs/reference/mcp-tools.md). RAGService and all MCP/CLI//search uses of it are unaffected. Existing databases retain a dormant empty rag_presets table; run scripts/drop_rag_presets_table.sql to clean it up. Deleted 104 chat-specific tests (6 files) and 3 chat-nav Playwright cases.

Fixed

  • UI test stability: modal state isolation across class-scoped page (2026-04-20): Fixed 8 failing tests across three UI test files caused by open modal state leaking between tests when the class-scoped Playwright page reuses a single browser tab and goto() deduplication skips reloads for same-path URLs. Added autouse yield fixtures to TestSigmaEnrichUI, TestSigmaEditorValidation, and TestWorkflowQueueRegressions that forcibly hide ruleModal and enrichModal via page.evaluate() in teardown. Fixed to_have_class("hidden") assertions that failed because Playwright Python does exact string match on the full class attribute; replaced with not_to_be_visible(). Fixed test_enrich_button_disabled_during_enrichment where time.sleep() inside the route handler blocked Playwright's event loop thread -- moved the sleep into a daemon thread so the loop stays free for subsequent API calls. Fixed test_similar_rule_detail_shows_repo_origin_badge to click the Similarity tab before interacting with its panel (tabbed modal starts with only the first panel visible).
  • Doc/test-doc cleanup for retired RAG chat UI (2026-04-20): Removed stale references to the deleted in-app RAG Chat page from LM Studio docs and test inventory docs. Current docs now describe semantic search for in-app retrieval and MCP for conversational retrieval, matching the v5.5.0 removal of /chat.
  • Quickstart Sigma-rule UI pointer (2026-04-19): Step 6 of docs/quickstart.md pointed at /articles/${ARTICLE_ID}#sigma, but the article-detail page has no id="sigma" element, so the anchor link went nowhere. Updated to direct users to http://localhost:8001/workflow#executions and click View on the matching execution row, which is where validated Sigma YAML, logs, and similarity matches actually surface. Also notes that the article page carries extracted observables but not the Sigma rules.
  • Inline workflow prompt validation works before Edit (2026-04-19): The per-agent prompt panel in workflow.html now renders the Validate button in both read-only and edit states, keeps the validation result container mounted in both modes, and falls back to stored agentPrompts[agentName].prompt content when the live textarea is absent. This fixes the regression where Validate only worked after clicking Edit. Also marked mobile-specific UI/Playwright tests skipped by request to keep them out of active runs.
  • Tests: remove CodeQL false positives in test helpers (2026-04-19): Cleaned up test-only warnings by removing unused locals (stripped, _result, original_init), using try/except/else to avoid potentially uninitialized variables, and chaining assertion failures with from exc to preserve tracebacks.
  • Model context window estimation substring collision (2026-04-18): LLMService.estimate_model_max_context() parameter bracket matching was case-sensitive and order-dependent; check for "3b"/"2b" now happens AFTER "32b"/"30b" checks to prevent gpt-4o-mini-2025-07-18 from matching the "2b" substring fallback. Fixes context window capping in extraction agents when using models with multi-digit suffixes. Unit test test_estimate_model_max_context.py covers collision scenarios.
  • Prompt UI: sub-agent System Prompt shows full config JSON (2026-04-17): parsePromptParts in workflow.html extracted only the role string for sub-agents using the standard Extractor Contract format, leaving task/json_example/instructions invisible. Fixed to show the complete JSON envelope in the System Prompt display box. Save functions updated to accept full-JSON edits and to stop injecting user_template (which the backend never reads from the DB for any agent). Affects CmdlineExtract, ProcTreeExtract, HuntQueriesExtract, RegistryExtract, ServicesExtract, and ExtractAgent panels in the Workflow Config UI.
  • QAAgentCMD seed: ASCII compliance and missing instructions key (2026-04-17): src/prompts/QAAgentCMD violated the project ASCII-only rule with Unicode emoji in evaluation_criteria (replaced with [PASS]/[WARN]/[FAIL]). Added the missing instructions key that _validate_qa_prompt_config requires; its absence would have caused a hard-fail on any QA-enabled run using the seed as the live prompt.
  • Workflow UI hygiene cleanup (2026-04-16): Removed two stray prompt-save console.log(...) calls from workflow.html and cleared committed trailing whitespace in docs/templates so git diff --check stays clean before pushing dev-io.
  • Healing UI polish on sources page (2026-04-15): Persistent badges now show exhausted/healed/paused states on source cards (not just during active runs); "Heal Now" shows an optimistic "Starting..." badge while the backend event lands; Reset no longer clears active filters; history panel preserves expanded LLM reasoning and scroll position across poll ticks.
  • Articles page content length display (2026-04-14): Articles list showed "Content: 0 characters" for all articles because load_content=False deferred the content field for performance but calculated length from the empty string. Now uses batch SQL char_length() query to fetch actual content lengths without loading full article text, mirroring the annotation count pattern.
  • Unified inline prompt card styling (2026-04-18): Unified display styling across all inline prompt cards (collapsed prompt blocks in configuration panel) with Version History modal styling. Consistent CSS class usage (.prompt-block, .prompt-info, .expand-icon), matching color variables, spacing, and hover states. Eliminates visual inconsistency where inline cards used generic styles while modals used dedicated classes. Affects all agent prompt configurations in the Workflow Config UI.
  • Fixed 13 failing unit tests in test_agents.py (2026-04-18): JSON parsing in test fixtures was failing on empty string handling due to json.loads("") raising JSONDecodeError. Fixed by adding fallback logic for empty/whitespace-only input strings in test helper. Root cause: test data strings that should parse to empty objects were being treated as invalid JSON. Comprehensive fix ensures all 13 scenarios in test_agents.py now pass.
  • Fixed expanded prompt editor save race condition (2026-04-18): Eliminated race condition in saveExpandedPrompt() where DOM relay through hidden textarea caused timing issues between read and write. Now passes prompt value directly to the save function instead of reading from the DOM after mutation, ensuring atomic save operations. Added Playwright regression test expanded_prompt_editor_save.spec.ts to prevent re-introduction.
  • Section 2 Playwright crashes with ENFILE: file table overflow (2026-04-18): tests/playwright.config.ts used workers: undefined locally, which defaulted to half the CPU count. With 50+ specs in parallel, allure-playwright writing result JSON files simultaneously exhausted the macOS file table. Capped local workers at 4; CI stays at 2.

Changed

  • Quickstart presets: full Extractor Contract compliance (2026-04-17): All three quickstart presets (Quickstart-LMStudio-Qwen3.json, Quickstart-anthropic-sonnet-4-6.json, Quickstart-openai-gpt-4.1-mini.json) migrated to the standard 4-key envelope. HuntQueriesExtract.Prompt.prompt rewritten from old format (objective/exclusions/output_format) to role/task/json_example/instructions. ExtractAgent.Prompt.prompt rewritten across all three presets — Sonnet/OpenAI had user-turn template content baked into instructions (now removed; that scaffold is code-owned in _EXTRACT_BEHAVIORS_TEMPLATE); Qwen3 had a plain text string. All six QAEnabled flags set to false across all three presets (including RankAgent). Qwen3 HuntQueriesExtract.QAPrompt and ProcTreeExtract.QAPrompt filled (were empty stubs with evaluation_criteria: []).
  • HuntQueriesExtract and ExtractAgent seed files standardized (2026-04-17): src/prompts/HuntQueriesExtract fully rewritten to the standard 4-key envelope; Sigma scope absorbed from the previously deleted SigExtract agent; includes 5-agent Architecture Context block. src/prompts/ExtractAgent genericized as supervisor seed. Both pass ASCII-only validation, have non-empty instructions, and clear _validate_extraction_prompt_config.
  • Source healing schema normalization (2026-04-15): Automatic config rewriting layer (_normalize_proposed_config) bridges LLM prompt drift and scraper reader expectations. Hoists wp_json from discovery.strategies to top-level config.wp_json where the fast path reads it; renames listing.selector → listing.post_link_selector and injects missing listing.urls from source URL. Each normalization logged at INFO with human-readable notes; healing events table preserves both raw LLM proposal (actions_proposed) and normalized result (actions_applied). Prevents silent no-ops from structurally-plausible configs landing in wrong locations. System prompt updated with canonical examples showing exact required keys for each strategy type. Added logger.warning in modern_scraper.py when wp_json appears under strategies (previously silent pass). Includes 8 new unit tests covering hoisting, key renames, passthrough, and merge semantics. Resolves VMRay Blog 8-round futility loop (2026-04-14) where LLM-proposed WP JSON config landed in discovery.strategies and produced zero articles every validation.
  • LMStudio context length detection (2026-04-14): Trust api_models_endpoint reported context windows with 90% safety margin instead of rejecting values >65536 as "theoretical max". Added explicit context detection for extraction agents when provider is LMStudio, preventing fallback to generic 4096 caps.
  • Command-line extraction v1.2 (2026-04-14): Updated CmdlineExtract and CmdLineQA prompts with stricter single-line literal matching, explicit wrapper handling rules (cmd/COMSPEC only, PowerShell never stripped), and chain validation requiring at least one non-trivial component. Excludes bare commands and reconstruction from multi-line sources.

[5.3.0 "Callisto"] - 2026-04-14

Added

  • ServicesExtract sub-agent (2026-04-13): New extraction sub-agent for Windows services artifacts (service_name, display_name, binary_path, start_type, operation_type). Full-stack integration: schema, config pipeline, migration, services, routes, UI templates, presets, eval data (config/eval_articles_data/windows_services/), and wiring tests. Includes ServicesQA validation agent.
  • Unified traceability schema across all extract sub-agents (2026-04-13): All five extract sub-agents (Cmdline, ProcTree, HuntQueries, Registry, Services) now emit a uniform per-item traceability envelope: value, source_evidence (verbatim source paragraph), extraction_justification (which prompt rule fired), and confidence_score (numeric 0.0-1.0). Replaces the prior split of raw_text_snippet / confidence_level on some agents and ad-hoc fields on others. Runtime _traceability_block in llm_service.py enforces the same contract. ProcTreeExtract migrated to the shared template envelope (role / user_template / task / json_example / instructions) alongside Registry and Services.
  • DB migration script for traceability field rename (2026-04-13): scripts/migrate_prompts_to_traceability_fields.py refreshes stored prompts in agentic_workflow_config.agent_prompts for the five migrated agents on existing installs. Idempotent (content-derived; no migrations table), --dry-run and --include-inactive flags, defaults to active-row only to preserve the version-history audit trail.
  • Cross-agent traceability contract tests (2026-04-13): tests/config/test_subagent_traceability_contract.py (85 tests) locks in the unified field schema across prompts, presets, runtime block, and migrated QA agents. Catches drift when any one of those is edited in isolation.
  • Sortable column headers on SIGMA Queue table (2026-04-13): Click any column header (ID, Article, Rule Title, Obs Used, Similarity, Status, Created) to sort ascending/descending with arrow indicators. Actions column excluded. Client-side sort on the current page, matching the scraper_metrics pattern.
  • Sigma signal refinement loop diagram + workflow UI smoke test (2026-04-13): Added docs/diagrams/sigma-signal-refinement-loop.svg and a workflow subpage content smoke test (tests/ui/test_workflow_subpage_content_smoke_ui.py) to catch template-level regressions before they hit users.
  • Release branch lock/unlock scripts (2026-04-13): scripts/release_lock.sh and scripts/release_unlock.sh wrap gh api calls to toggle GitHub branch protection on main between releases. Lock sets lock_branch=true, enforce_admins=true, allow_force_pushes=false, allow_deletions=false; unlock is idempotent against 404 "Branch not protected". Release flow: unlock -> merge dev-io -> lock.
  • Cross-field soft matching solution doc (2026-04-12): docs/solutions/logic-errors/sigma-cross-field-soft-matching-zero-similarity-2026-04-12.md documents the root cause, rejected embedding fallback approach, and the three-function allow-list + extractor + soft-Jaccard fix. Cross-linked from the 2026-04-08 case-sensitive atom matching solution doc as the downstream fallback.

Fixed

  • Fork-safe Celery DB pool + eval submission reliability (2026-04-13): 3+ simultaneous eval workflow executions were corrupting the Postgres connection pool at os_detection with "lost synchronization with server" errors, leaving subagent_evaluations rows stuck in pending. Root-cause fix: worker_process_init handler in src/worker/celery_app.py disposes cached DB engines and clears DatabaseManager._engine_cache / _session_cache after each Celery fork (forked children were inheriting parent TCP sockets and interleaving bytes under concurrency). Also: pool_recycle=3600 on the sync engine for parity with AsyncDatabaseManager; staggered eval batch submissions via Celery countdown (EVAL_STAGGER_SECONDS, default 0.2s); _mark_pending_subagent_evals_as_failed helper called from the outer run_workflow exception handler and from trigger_agentic_workflow's retry-exhaustion path so orphan rows are reconciled instead of stuck. Tests for fork-safety handler, staggered submission countdown, and orphan reconciliation edge cases.
  • Cross-field soft matching for SIGMA similarity (2026-04-12): Rules detecting the same executable (e.g., \rundll32.exe) via different SIGMA fields (Image vs CommandLine) previously scored 0% Jaccard because atoms never intersected. Added value-based soft matching across process-executable fields (Image, CommandLine, ParentImage, ParentCommandLine, OriginalFileName) with 50%-dampened partial credit. Applied to both deterministic (precomputed atoms) and legacy (YAML re-parsed) comparison paths. Frontend and backend now filter out 0% matches from similar-rules display. Includes interactive visualization at docs/diagrams/similarity-engine-explained.html.
  • Queue table shows 0.0% similarity until manual re-search (2026-04-09): run_similarity_search computed max_similarity from threshold-filtered matches only — when the best match was below threshold (e.g. 13% vs 50% threshold), the filtered list was empty and max_similarity was stored as 0.0. Now computed from ALL candidates before filtering. Also stores top-10 unfiltered matches in similar_rules so the queue entry has data without a manual re-search.
  • Similarity search UI says "SigmaHQ Repository" even when user repo is searched (2026-04-09): The backend already queries both SigmaHQ and customer rules (cust-*) in SigmaRuleTable, but modal titles, loading messages, and empty states across 6 templates all said "SigmaHQ Repository". Updated to "indexed repositories" and added repo-origin badges (Your repo / SigmaHQ) to match list items in sigma_queue.html and workflow.html. Updated capability_service.py to explain that the customer repo is not indexed rather than just saying "SigmaHQ only". Added 37 unit/regression tests guarding against reintroduction of SigmaHQ-only language.

  • SIGMA PR submission fails in Docker (2026-04-08): Volume mount used relative path ../Huntable-SIGMA-Rules which broke when the repo lived outside the project's parent directory; switched to ${HOME}/Huntable-SIGMA-Rules. Also hardened _resolve_default_base_branch() to check local branches as fallback when remote info is unavailable inside Docker, preventing incorrect master fallback.

  • Sigma Similarity Search returns zero results (2026-04-08): Two bugs in atom_extractor.py caused zero behavioral overlap for all queued rules: (1) FIELD_ALIAS_MAP lookup was case-sensitive — LLM-generated rules using lowercase/snake_case fields (image, command_line) resolved to different namespaces than SigmaHQ's PascalCase atoms (process.image, process.command_line); (2) value comparison was case-sensitive despite Sigma's contains/endswith/startswith being case-insensitive by spec. Added case-insensitive field resolution, operator-aware value folding, and a runtime normalizer in sigma_novelty_service.py for transition compatibility. See solution doc.
  • UI test suite correctness (2026-04-06): Aligned 18 failing Playwright tests with the actual DOM. Key fixes: (1) test_collect_now_button — force-enable disabled buttons before click (source.active=False in test env renders them non-interactive); (2) test_rag_chat_ui — corrected loading indicator text (Searching threat intelligence database), Send button disabled-state validation, rows=1 attribute, missing a[href='/chat'] nav link (lives on /articles), and llm_provider assertion (openai — chat reads provider from /api/settings, not localStorage); (3) test_diagnostics_advanced_ui / test_health_checks_advanced_ui — to_have_class(re.compile(r"\bhidden\b")) for multi-class elements; (4) test_articles_advanced_ui — expect_navigation + JS eval to bypass _UrlAwarePage same-path dedup for sort-reset navigation.
  • UI test class-scope isolation (2026-04-06): test_chat_loading_state and test_chat_displays_selected_model_name moved to fresh_page (function-scoped) to prevent JS add_init_script interceptors and mocked routes from bleeding into subsequent class-scoped tests. Added .first to locators that accumulate duplicate matches across class-scoped test sequences.

Changed

  • Real scraper metrics from source_checks (2026-04-13): Replaced hardcoded avg_response_time (250ms) and error_rate (2.5%) with real values queried from the source_checks table over a 7-day window. Uses PERCENTILE_CONT(0.5) for median response time to avoid outlier skew from Playwright/healing retries. Per-source error_rate and median response wired from the same table. Renamed misleading "Articles Today" column to "7d Count" to match the actual window. Added client-side column sorting with ascending/descending toggle and sort indicators on the analytics scraper metrics table.
  • Workflow toggle-switch sizing normalized (2026-04-13): All workflow toggle switches standardized to w-11 h-6 for visual consistency.
  • OpenAI model catalog narrowed to workflow allowlist (2026-04-12): Workflow and Settings model dropdowns now show only the 6 chat/reasoning models the CTIScraper pipeline uses (gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4.1-mini, o3-mini, o4-mini). Added PROJECT_OPENAI_ALLOWLIST and filter_openai_models_project_allowlist() in src/utils/model_validation.py as a policy layer separate from the existing is_valid_openai_chat_model capability check. Applied at all three choke points: catalog load (provider_model_catalog.py), /api test-key route (ai.py), and the daily Celery refresh writer (update_provider_model_catalogs.py). Pruned config/provider_model_catalog.json from 39 → 6 OpenAI entries. Added 10 tests (7 unit, 3 service-layer).
  • Documentation trueup (2026-04-06): Weekly doc audit — added RegistryExtract to sub-agent lists in 3 docs (agents, first-workflow, workflow-data-flow diagram); corrected FeatureFlags inventory in agent-config-schema; fixed "LangFuse" → "Langfuse" casing and 3.x → 4.x version refs across 3 docs; removed Ganymede from available moon names (already used for v5.2.0); removed duplicate note paragraph and StackEdit metadata from workflow-data-flow.

Added

  • RegistryExtract sub-agent (2026-04-03): New extraction sub-agent for Windows registry artifacts (persistence keys, config changes, defense evasion). Full-stack integration: schema, config pipeline, migration, services, routes, UI templates, presets, eval data, and 36 wiring tests. Split-hive output schema (registry_hive + registry_key_path) for Sigma registry_event compatibility. Includes RegistryQA validation agent.
  • Registry eval articles (2026-04-03): 4 eval articles (Xloader v6/v7, Matanbuchus 3.0, ZeroTrace, CrystalX RAT) with expected counts in config/eval_articles.yaml and static snapshots in config/eval_articles_data/registry_artifacts/.

Fixed

  • Test button ignored per-agent provider/model (2026-04-03): test_sub_agent_task called run_extraction_agent() without passing provider, model_name, temperature, or top_p — every sub-agent's Test button used ExtractAgent's provider instead of the per-agent config.
  • RegistryExtract missing from getCurrentModelForAgent (2026-04-03): Prompt header showed "Model: Not configured" despite a model being selected.
  • LMStudio provider detection (2026-04-03): loadAgentModels() hardcoded a 5-agent map; replaced with getAgentConfigs().
  • Old presets rejected new agent on import (2026-04-03): Added _backfill_ui_ordered_sub_agents() to inject disabled defaults before strict validation.
  • Live view missing data for sub-agents (2026-04-03): Added per-agent incremental commits and message truncation (3000/2000 chars).
  • Langfuse output key mismatch (2026-04-03): Replaced dead event_ids/registry_keys with registry_artifacts in llm_service.py.

Added

  • Source-check distributed lock (2026-04-02): check_all_sources Celery task acquires a Redis SET NX EX lock on startup (default TTL 90 min, env SOURCE_CHECK_LOCK_TTL_SECONDS). Overlapping invocations skip gracefully with status: skipped rather than running concurrent DB/scrape storms. Lock is released in a finally block with compare-and-delete to prevent a different owner from releasing it.
  • Healing error detail propagation (2026-04-02): LLM HTTP, timeout, and generic exceptions in SourceHealingService._analyze_with_llm now include an error_detail key in the returned dict; run_healing_round surfaces this in the HealingEvent.error_message when no validation summary is available. The healing history UI distinguishes validation-fetch summaries from plain error messages, showing a Details: label and red colouring for the latter.
  • Score parser patterns 2b/2c (2026-04-02): LLMService score extraction adds Score: N/10 and generic N/10 patterns before the tail-scan fallback, improving compatibility with custom ranking prompts.
  • Tests (2026-04-02): test_source_check_task_lock.py (Celery task lock acquire/skip/release), test_source_healing_service.py::TestHealingErrorDetails (error-detail propagation), test_source_healing_coordinator.py (coordinator integration), test_healing_history_status_ui.py (Playwright UI for healing history).

Changed

  • pytest config consolidated (2026-04-02): [tool.pytest.ini_options] block added to pyproject.toml mirroring tests/pytest.ini; pyproject.toml is now the authoritative pytest config source.
  • run_tests.py security tests (2026-04-02): USE_ASGI_CLIENT=1 and asyncio_default_test_loop_scope=session now apply to RunTestType.SECURITY runs (previously API-only), so security tests in tests/api/ can use patch() mocks against the in-process app.
  • asyncio.run() in Celery test-agent tasks (2026-04-02): Replaced deprecated asyncio.get_event_loop().run_until_complete() with asyncio.run() in test_sub_agent_task, test_rank_agent_task, and test_sigma_agent_task.
  • async_manager URL normalisation (2026-04-02): AsyncDatabaseManager automatically upgrades postgresql:// URLs to postgresql+asyncpg:// so environments that omit the driver suffix still connect via asyncpg.

Changed

  • Dashboard ingestion health scoring (2026-04-01): uptime replaced with a severity-weighted health score. Sources with ≥3 consecutive failures are critical; 1–2 failures are warning (weighted 0.5×). manual and eval_articles identifiers excluded from monitoring. API response now includes status (nominal/degraded/critical), label, monitored_sources, healthy_sources, warning_sources, critical_sources. Dashboard ring and status badge consume the server-supplied status rather than a client-side uptime threshold. _compute_ingestion_health shared between dashboard.py and metrics.py.
  • CI: Python Playwright UI job (2026-04-01): tests.yml gains a ui job that runs the comprehensive @pytest.mark.ui suite in CI (postgres + redis services, DB schema bootstrap, live web server, playwright install --with-deps chromium, python run_tests.py ui). The optimised suite (~15–20 min locally) runs within a 40-minute CI timeout.
  • UI test suite speed-up (2026-04-01): Five-part optimisation — class-scoped page fixture with _UrlAwarePage dedup proxy eliminates redundant same-URL navigations; ~130 blanket wait_for_timeout sleeps removed or reduced; 76 performance/mobile/a11y tests tagged @pytest.mark.slow and excluded from default runs (--include-slow opt-in); --timeout=60 per-test guard injected automatically; -n 2 parallelism enabled by default for UI/E2E runs via pytest-xdist.
  • MLOps frontend redesign (2026-03-30): Agent Evals and ML vs Hunt Comparison pages rebuilt with distinctive panel-based layouts, custom typography (Space Mono / IBM Plex Sans / Barlow Condensed), instrument-style section labels, entrance animations, and refined status badges. All JS functionality preserved.
  • Gitignore (2026-03-30): Broadened *.pkl glob replaces specific model-file pattern; removed tracked content_filter.pkl binary.
  • Content filtering docs (2026-03-30): New "Defining Huntable for Your Use Case" section explains that the default labeling convention is behavioral-TTP-focused and documents how to rebuild the eval set when changing definitions.

Added

  • Tests (2026-04-01): Unit tests for _compute_ingestion_health (degraded/critical classification, eval_articles/manual exclusion, inactive-source filtering) in tests/unit/test_dashboard_health_metrics.py; UI test for server-supplied status driving ring color and status badge via Playwright route intercept in tests/ui/test_dashboard_health_status_ui.py.
  • ML model rollback (2026-03-30): POST /api/model/rollback/{version_id} restores any prior model version — copies the versioned .pkl artifact to the live path, flips is_current in DB, clears the ContentFilter lru_cache, and runs a background chunk re-score. New is_current column on ml_model_versions with incremental migration. Retrain script now saves versioned artifacts (content_filter_v{id}.pkl) and populates eval_* metrics from the training test-split when the curated eval set is unavailable.
  • Version history pagination and search (2026-03-30): GET /api/model/versions accepts optional ?page=&limit=&version= for server-side pagination; unpaginated mode preserved for the chart. UI panel shows 5 versions per page with Prev/Next controls and version-number search.
  • Tests (2026-03-30): 10 unit tests (test_ml_model_versioning_rollback.py) for activate_version / set_version_artifact; 16 API tests (test_model_rollback_api.py) covering rollback endpoint, paginated versions, and version search.

Fixed

  • Uvicorn reload crash (2026-03-30): --reload watched all files including .pkl model artifacts, causing cascading restarts during retrain. Added --reload-include '*.py' and --reload-exclude for models/*, tests/*, scripts/*, *.pkl in both docker-compose.yml and docker-compose.dev2.yml.
  • run_sync false positive (2026-03-30): Substring match "running event loop" in str(e) incorrectly caught "no running event loop" from background threads. Replaced with explicit boolean check.
  • Connection pool bloat (2026-03-30): Throwaway AsyncDatabaseManager instances in retrain script and routes now use pool_size=2, max_overflow=0 instead of the default 20+30.
  • Retrain error reporting (2026-03-30): Retrain endpoint now surfaces the last stdout line when stderr is empty, instead of showing a blank error.
  • Eval chart empty (2026-03-30): Fixed import path (utils. → src.utils.) for ModelEvaluator in retrain script; added training test-split fallback so eval_* fields are always populated after retrain.
  • Chart overlapping lines (2026-03-30): Precision line hidden behind Accuracy when values are identical; added dashed line style, distinct point shapes (rectRot, triangle), and interactive legend with usePointStyle.
  • Bootstrap train_test_split (2026-03-30): ContentFilter.train_model falls back to non-stratified split when any class has <2 samples.

Fixed

  • UI tests / test runner (2026-03-27): test_workflow_queue_table_layout_ui targets #tab-content-queue .q-table-wrap (queue scroll) instead of .overflow-x-auto, which matched hidden enrich <pre> nodes; assertions use q-cell-article / q-cell-title CSS ellipsis like executions tests; article links get a title attribute in workflow.html. run_tests.py no longer forces pytest-xdist -n auto for ui (opt in with --parallel). All tests/ui Playwright waits: networkidle → load (Diagnostics auto-refresh and similar pages never reach network idle). test_diagnostics_advanced_ui job history header/toggle assertions aligned with template; collapsible hidden checked via classList token.

Changed

  • Documentation (2026-03-27): MkDocs nav adds Source healing (internals). API reference documents GET /sigma-queue, GET /api/embeddings/stats (sigma_corpus), and README clarifies seeded source count vs DB-first runtime. Agent orientation links to repo-root AGENTS.md / README.md via GitHub URLs so mkdocs build --strict resolves. Testing strategy documents serial-by-default pytest UI and --parallel.

[5.2.0 "Ganymede"] - 2026-03-26

Key updates

  • MCP and RAG observability: Read-only huntable_mcp for articles, sources, SIGMA queue metadata, and workflow visibility; get_stats and GET /api/embeddings/stats expose SigmaHQ sigma_corpus embedding coverage; semantic search and MCP paths hardened (pgvector binds, thresholds, chunk→article fallback, stable Article IDs in search results).
  • Source auto-healing and ingestion: Multi-round healing with audit trail and per-source controls; Langfuse from Settings; v3 deep probes (RSS, sitemap, WordPress API, JS-render cues); Zscaler ThreatLabz Playwright source (zscaler_threatlabz); Red Canary removed from default config/sources.yaml; coordinator, fetcher, and scraper improvements. See docs/internals/source-healing.md.
  • Operator console: Executions tab tactical q-* styling; MLOps control center redesign; SIGMA /sigma-queue, enrich and edit ergonomics, table layouts, executions and config-version pagination; workflow manual trigger force bypasses auto-trigger threshold with clearer API errors.

Added

  • Source auto-healing v2 (2026-03-26): Multi-round healing, persisted audit trail, and per-source enable/run/stop controls in the Sources UI.
  • Source auto-healing: Langfuse and coordinator (2026-03-26): Langfuse host/project API keys from Settings for optional healing traces; healing coordinator and LLM robustness; extended fetcher, modern scraper, and Playwright URL discovery for healing probes.
  • Sources: Zscaler ThreatLabz (2026-03-26): Playwright-based source zscaler_threatlabz (paginated author listing); Red Canary entry removed from default config/sources.yaml.
  • Workflow manual trigger force (2026-03-26): POST /articles/{id}/trigger accepts force=true to skip RegexHunt auto-trigger threshold; API surfaces specific WorkflowTriggerService failure reasons; article detail Send-to-Workflow and workflow modals pass force=true; documented in API reference.
  • MLOps control center (2026-03-26): MLOps page redesigned for layout parity with the operator tactical console pattern.
  • Documentation (2026-03-26): Source auto-healing architecture reference and cross-doc updates; see docs/internals/source-healing.md and related guides.
  • Tests (2026-03-26): Unit coverage for WordPress JSON extraction, healing coordinator behavior, Langfuse reset path, and codex-mini validation.
  • Source auto-healing v3: deep diagnostic probes (2026-03-26): Healing LLM now receives rich probe data instead of bare HTTP status codes — RSS content analysis (item count, empty-feed detection), sitemap discovery with post-URL sampling, WordPress JSON API detection, JS-rendering detection (HTML size vs visible text), and blog page link extraction. Working source configs are included as reference examples. Validation fetches are recorded as source checks so subsequent rounds see what happened. Updated system prompt with diagnostic playbook and platform capability documentation. See docs/internals/source-healing.md.
  • Tests (2026-03-20): SIGMA queue similar-rules canonical_class / logsource_key response contract (tests/api/test_sigma_similar_rules_api.py); compare_proposed_rule_to_embeddings filter metadata passthrough and failure empty-metadata (tests/services/test_sigma_matching_service.py); assess_novelty result includes canonical_class key (tests/services/test_sigma_novelty_service.py).
  • Tests (2026-03-20): Smoke DB checks tests/smoke/test_mcp_rag_smoke.py (embedding stats, lexical search, sigma_rule_queue count; optional slow Sigma semantic when vectors exist). RAG unit coverage for Sigma bracket-vector bindparams and find_unified_results partial_errors. API contract tests/api/test_embeddings_stats_contract.py for sigma_corpus on GET /api/embeddings/stats.
  • API GET /api/embeddings/stats (2026-03-19): Response now includes sigma_corpus (SigmaHQ sigma_rules totals and RAG embedding coverage), via RAGService.get_embedding_coverage(). Chat UI and embed stats CLI print the same block. Distinct from the AI sigma_rule_queue.
  • MCP get_stats (2026-03-19): Reports SigmaHQ rule row count and RAG embedding coverage (get_sigma_rule_embedding_stats on AsyncDatabaseManager); hints when sigma_rules is empty or embeddings are missing (sigma index-metadata / sigma index-embeddings). Tests: tests/database/test_async_manager.py::test_get_sigma_rule_embedding_stats.
  • Documentation (2026-03-19): MCP tools reference — all nine huntable-cti-studio tools, parameters, and Article ID vs list-rank note.
  • MCP server (2026-03-19): Read-only Model Context Protocol entrypoint for articles, sources, SIGMA queue/metadata, and workflow visibility (python3 run_mcp.py or python3 -m src.huntable_mcp). Config/tests: tests/test_mcp_server_config.py.
  • Tests (2026-03-19): tests/test_async_manager_source_filter_compat.py (SourceFilter/minimal payloads vs DB managers); sigma enrich API coverage (test_sigma_enrich_api.py); compare API regression for fenced YAML with } in field values; tests/ui/test_sigma_similarity_origin_badge_ui.py (repo-origin badge in similar-rule detail).
  • Tests (2026-03-20): tests/ui/test_sigma_queue_preview_edit_persists_after_refresh_ui.py — YAML edits survive loadQueue() on /workflow?previewId=…#queue with mocked list API (regression for checkAndTriggerPreview + in-modal edit; standalone /sigma-queue shares the same JS but Playwright bootstrap is tracked separately if needed).

Fixed

  • SIGMA queue preview: edit session vs auto-refresh (2026-03-20): Periodic loadQueue() (e.g. every 30s on Workflow queue tab and standalone queue page) re-ran previewRule() via checkAndTriggerPreview(), resetting edit mode and discarding in-progress YAML edits. Deep-link reopen is skipped while the same rule is open in edit mode (workflow.html, sigma_queue.html).
  • SIGMA queue page route (2026-03-20): Registered GET /sigma-queue to render sigma_queue.html (template and UI tests expected this URL; previously only /api/sigma-queue/* existed).
  • MCP import shadowing (2026-03-20): Renamed package src/mcp/ → src/huntable_mcp/ so the PyPI mcp SDK (mcp.server.fastmcp) is not shadowed by a top-level mcp package. Stdio app module: stdio_server.py. Run: python3 -m src.huntable_mcp or run_mcp.py.
  • Sigma MCP / RAG vector search (2026-03-19): find_similar_sigma_rules now passes query embeddings as pgvector bracket strings ([0.1,…]) like search_similar_articles, not Python lists with Vector bindparams (asyncpg could fail silently → empty lists). Rows read via result.mappings() / signature_sim. Failures raise with traceback; find_unified_results uses asyncio.gather(..., return_exceptions=True) and returns partial_errors; MCP search_unified prints them.
  • Article semantic search fallback (2026-03-19): When chunk/annotation search returns no rows (threshold or sparse article_annotations embeddings), find_similar_content falls back to article-level find_similar_articles with threshold 0 (best matches by vector distance), then returns top‑k so LSASS-style queries can still surface articles when chunk scores are weak; no extra similarity cut on the fallback leg.
  • MCP stdio + get_stats (2026-03-19): Logging forced to stderr only (stdio_server.py, src.huntable_mcp.__main__) so stdout stays JSON-RPC-clean. get_stats uses per-section try/except so a failing query still returns other lines (never an empty body). Instructions note chunk vs keyword semantics for article search.
  • Sigma semantic search (RAG + MCP) (2026-03-19): find_similar_sigma_rules no longer drops all rows when every score is below threshold — returns top-top_k matches with meets_threshold on each rule. MCP search_sigma_rules / server instructions clarify get_stats includes Sigma counts and empty-corpus vs low-score; unified search empty message points to get_stats.
  • MCP article IDs (2026-03-19): search_articles, search_articles_by_keywords, and search_unified output now includes Article ID (articles.id) and states that list rank is not the ID; get_article docstring clarified. _article_db_id() prefers chunk article_id over id (chunk rows may use id for chunk PK). Tests: tests/unit/test_mcp_article_db_id.py.
  • SIGMA AI enrichment (2026-03-19): User prompt now injects toggles_json and author_value (Settings sigmaAuthor or default) so the model does not fail as “missing inputs”. LLM JSON envelope parsed with JSONDecoder.raw_decode so updated_sigma_yaml containing } is not truncated. Empty updated_sigma_yaml on pass returns 400 with a clear message.
  • SIGMA compare / similar-rules (2026-03-19): _extract_yaml_block hardened for CRLF markdown fences and extraction when prose wraps YAML (see tests/api/test_sigma_ab_test_api.py).
  • Sources / DB managers (2026-03-19): Listing sources tolerates minimal SourceFilter-style payloads without crashing DatabaseManager / AsyncDatabaseManager.

Changed

  • Test runner (2026-03-20): python3 run_tests.py ui --skip-playwright-js runs pytest tests/ui/ only and skips the npx playwright test tests/playwright/ phase (most UI wall time). Documented in docs/development/testing.md.
  • RAG / tests (2026-03-20): Shorter docstrings and log lines for Sigma embedding search and chunk→article fallback; sqlalchemy.text imported at module scope in rag_service.py. Smoke/API/RAG/sigma-matching tests: less boilerplate (module docs, skip messages, fixture comments).
  • Executions tab: tactical console redesign (2026-03-18): Executions tab restyled with q-command-strip, q-cmd-btn, q-table, q-cell-*, and q-actions-cell CSS classes — matching the SIGMA Queue tactical operations console aesthetic. Replaces Tailwind utility classes with themed CSS variable system for buttons, filters, stat cards, table rows, and action buttons. Execution detail and trigger workflow modals use the q-modal-* / q-form-* class system.

Fixed

  • Agents workflow: SIGMA Enrich original rule (2026-03-18): Enrich modal on /workflow queue preview now reads rule YAML from #ruleYamlCode instead of the first <code> in the modal (which could be an observable line). Restores correct “Original Rule” and downstream comparison/validation.
  • Agents workflow: queue & executions tables (2026-03-18): SIGMA Queue and Executions tables use table-fixed, truncated Article (and queue Rule Title) with full text on hover, and a reserved Actions column with right padding so Preview/Approve/Reject and View/Trace stay visible without horizontal scroll at typical viewport widths.
  • pytest-playwright + xdist (2026-03-18): Per-worker Playwright --output under test-results/playwright-<worker> so delete_output_dir no longer races (OSError 66 on test-results).

Added

  • UI tests (2026-03-18): test_workflow_enrich_original_rule_regression.py, test_workflow_queue_table_layout_ui.py, test_workflow_executions_table_layout_ui.py for the above behaviors.

  • Sources: Add Ctrl-Alt-Intel Threat Research as an RSS-first ingestion source (ctrl_alt_intel_blog) (2026-03-18).

  • AGENTS.md (2026-03-19): User Request Playbooks — add source (config/sources.yaml, sync-sources --no-remove, ingestion health JSON), Postgres read-only queries (docker exec … psql), SQL snippets, article_metadata.training_category for training filters.

Changed

  • UI test comment cleanup (2026-03-16): Removed redundant and verbose comments from health page, collapsible sections, dashboard, and error-handling UI tests. Fixed duplicate docstring in test_health_check_navigation.
  • Workflow config: step-section accordion (2026-03-15): Only one step section (OS Detection, Junk Filter, LLM Rank, Extract Agent, Generate SIGMA, Similarity Search) can be expanded at a time. Clicking a section header or rail item closes others. Queue step removed from config tab (no configurable parameters).
  • Docs: RAG vs workflow sigma similarity (2026-03-13): Clarified in rag-search.md and sigma-rules.md that RAG sigma retrieval uses embeddings (cosine similarity); workflow duplicate detection uses deterministic engine (Jaccard × Containment − Filter) when sigma_semantic_similarity is installed, else legacy (70/30). Fixed malformed SQL and embedding sections in sigma-rules.md; updated LM Studio references to local sentence-transformers for sigma embeddings.
  • Docs: generate-sigma, agents (2026-03-13): Updated generate-sigma.md and concepts/agents.md to describe behavioral novelty for similarity (not embeddings-only), local sentence-transformers for sigma indexing, and capabilities check for troubleshooting.
  • Sources page UI refresh (2026-03-14): Consolidated page styles, stats bar, filter bar, 2-column grid layout, card styling (health indicators, status badges, method badges), improved contrast and dropdown actions.
  • Dependency security pinning (2026-03-14): Pinned requirements.txt and requirements-test.txt to safety-mcp recommended secure versions. Critical: pypdf 6.7.5→6.8.0 (insecure fix), black 26.3.0→26.3.1 (CVE-2026-32274). Also: beautifulsoup4, playwright, sqlalchemy, asyncpg, psycopg2-binary, alembic, fastapi, starlette, uvicorn, celery, redis, scikit-learn, joblib, click, rich, pydantic-settings, reportlab, python-dateutil, charset-normalizer, numpy, GitPython, bandit.
  • CI: Node.js 24–compatible actions (2026-03-14): Bumped actions/checkout to v6, actions/setup-python to v6, actions/upload-artifact to v6 to resolve Node.js 20 deprecation warnings and prepare for GitHub Actions' June 2026 Node 24 default.

Added

  • Security audit in CI and LG workflow (2026-03-14): New security-audit CI job runs pip-audit and safety check on every push/PR. Added pip-audit to requirements-test.txt. LG workflow and skill now explicitly run both tools. tests/TESTING.md corrected: Security row documents run_tests.py security (pytest marker) vs dependency audit (pip-audit, safety check).
  • Config versions pagination and search (2026-03-14): GET /api/workflow/config/versions now accepts page, limit, and version query params. Returns total, page, and total_pages for pagination. Version filter is exact integer match; non-integer input returns empty list (no 500). Restore-by-version modal: search input, Search/Clear buttons, Prev/Next pagination when total > 20.
  • Executions page pagination (2026-03-14): GET /api/workflow/executions now supports page and limit (default 50, max 200). Response includes page, total_pages, limit. Executions tab: Prev/Next pagination when total > 50.

Changed

  • Dependencies and pip-audit CI (2026-03-26): requests==2.33.0, pypdf==6.9.2 (CVE-2026-25645, CVE-2026-33699). security-audit job adds pip-audit ignores for CVE-2026-33231 (nltk) and CVE-2026-4539 (pygments) until fixed releases exist on PyPI.

[5.1.0 "Callisto"] - 2026-03-13

Key updates

  • Similarity search — logic and UI: The Sigma Queue and similarity comparison now use a deterministic semantic engine when the sigma_semantic_similarity package is installed. Logic: canonical telemetry class, DNF normalization, Jaccard overlap, containment factor, and filter penalties—no embeddings. Precomputed atoms (canonical_class, positive_atoms, negative_atoms, surface_score) are stored during indexing for fast novelty checks. UI: clearer similarity display, improved contrast, and expandable panels for similar rules.
  • Workflow config: Slider controls for thresholds, temperature, and top_p; config display improvements.
  • Settings: Auto-trigger hunt score threshold control.
  • Docs: AGENTS.md, README, and reference docs overhaul for agent orientation and contributor onboarding.

Added

  • Sigma tests integrated with run_tests.py (2026-03-13): Moved sigma_semantic_similarity/tests/ to tests/sigma_semantic_similarity/; unit tests now run via python3 run_tests.py unit and CI. Added requirements-sigma.txt to CI unit job. New tests for |all modifier (list values: |all → AND, else OR). Per pytest-run-tests-integration rule.
  • Sigma deterministic semantic precompute (2026-03-13): Precompute and persist canonical_class, positive_atoms, negative_atoms, surface_score during SigmaHQ indexing. Eliminates recomputation during novelty comparison. New columns via scripts/migrate_sigma_semantic_precompute.py. Indexing pipeline and sigma index-metadata compute these when sigma_similarity is installed. CLI: python3 -m src.cli.main sigma recompute-semantics backfills existing rules. Novelty service uses precomputed atoms when available (pure set math); deterministic mode removes top_k limit and filters by canonical_class.
  • Cron CLI and API (2026-03-12): cron CLI subcommands (show, replace) to view and replace the current user's crontab; /api/cron/* endpoints for snapshot and replace; Settings UI for backup cron schedule. BackupCronService used by CLI and API. Tests: tests/api/test_cron_api.py, tests/cli/test_cron_cli.py; Playwright settings spec updated.
  • Comprehensive test coverage improvements (2026-03-10): Added 38 new tests across API, UI, and Playwright suites with full cleanup and restoration:
  • 15 workflow config API tests (CRUD operations, validation, prompts, versions) - tests/api/test_workflow_config_api.py
  • 8 preset lifecycle API tests with import/export and save/restore - tests/api/test_workflow_preset_lifecycle.py
  • 6 agent config UI smoke tests (<5s runtime) - tests/ui/test_agent_config_smoke_ui.py
  • 9 sources page UI smoke tests (<5s runtime) - tests/ui/test_sources_smoke_ui.py
  • 1 Playwright test for importing real preset files with automatic config restoration - tests/playwright/agent_config_presets.spec.ts
  • All new tests integrated into run_tests.py and GitHub CI workflows
  • Total test count: 595 tests → 633 tests across 106 files
  • Test coverage roadmap (2026-03-10): Created TEST_COVERAGE_ROADMAP.md documenting:
  • Current test coverage analysis across all test types
  • 18 recommended new test files (~110-155 additional tests)
  • 3-sprint implementation plan prioritized by impact
  • Detailed test case specifications for SIGMA Queue, Analytics, Article Detail, Observable Training, Evaluation Comparison, Backup/Export, Settings, Search, Mobile, Error Recovery, RAG Chat, and Performance
  • Testing standards and best practices (markers, database usage, cleanup requirements)
  • Integration test full-system confidence (2026-03-10): New integration tests for critical paths: (1) test_workflow_execution_integration.py — workflow run with real DB and mocked LLM; (2) test_trigger_agentic_workflow_eager_touches_db in test_celery_state_transitions.py — Celery task (eager) + DB; (3) test_ingestion_via_create_article_real_db in test_rss_ingestion_persistence.py — ingestion via AsyncDatabaseManager.create_article. Run integration with integration marker only; docs clarify integration vs lightweight and system integration web requirement. SKIPPED_TESTS.md updated with Integration subsection and annotation-persistence skip rows.
  • Solutions documentation (2026-03-10): docs/solutions/ added for institutional knowledge; first entry documents async fixture teardown / "Future attached to a different loop" in integration tests (test_annotation_persistence.py), with symptoms, attempted fixes, and current skip/workaround. MkDocs nav includes Solutions.
  • Sigma GitHub repo setup in setup.sh (2026-03-09): ./setup.sh now prompts to clone or create the Sigma rules repo (../Huntable-SIGMA-Rules) for PR submission. Creates repo at github.com/new first, then enters owner/repo; clones or creates local rules structure on failure. Default prompt your-username/Huntable-SIGMA-Rules. Post-install reminds user to add GitHub PAT in Settings. Non-interactive: set SIGMA_GITHUB_REPO=owner/repo. Docs updated (configuration, generate-sigma, sigma-rules, DOCKER_ARCHITECTURE).
  • Sigma path standardization (2026-03-09): SIGMA_REPO_PATH standardized on sigma-repo (single path for Docker and local). Removed Docker vs Local branching in SigmaPRService. Settings UI placeholder and helper text simplified.

Changed

  • Eval articles: repo-first, no network fetch (2026-03-11): Eval article snapshots are committed in config/eval_articles_data/{subagent}/articles.json. Setup seeds them into the DB at startup; no run of fetch_eval_articles_static.py required for normal install. Docs and Agent Evals UI updated: install uses committed copies; fetch/dump scripts are for maintainers when adding URLs. See Installation → Agent evals and config/eval_articles_data/README.md.
  • Documentation (MDU) (2026-03-10): Changelog, README, and docs verified; MkDocs build OK. Unlinked doc pages (plans/, deployment/, operations/) remain intentionally outside nav.
  • Test runner integration and coverage (2026-03-10): Pytest tests integrated into run_tests.py per category (smoke, unit, api, integration, ui); ongoing tests must be runnable via python3 run_tests.py <category>. Unit coverage gates in CI verified: src.services + src.utils ≥39%, src.services ≥45%, src.utils ≥20%.

Fixed

  • Test database credential security (2026-03-10): Removed 5 hardcoded test database passwords (cti_pass) from integration tests. All tests now read password from POSTGRES_PASSWORD environment variable (sourced from .env), eliminating credential exposure in code. Fixed files:
  • tests/integration/conftest.py (3 instances)
  • tests/integration/test_celery_state_transitions.py (1 instance)
  • tests/integration/test_workflow_execution_integration.py (1 instance)
  • UI test speed (2026-03-10): Pytest UI defaults: PLAYWRIGHT_SLOW_MO=0, video only when PLAYWRIGHT_VIDEO=1 or PLAYWRIGHT_VIDEO_DIR. TypeScript Playwright: fullyParallel: true, 2 workers in CI. Docs: testing.md (UI test speed), web-app-testing.md (slow_mo/video).
  • Cloud LLM keys stripped at test startup (2026-03-10): run_tests.py removes OPENAI_API_KEY, ANTHROPIC_API_KEY, and CHATGPT_API_KEY from the process environment at startup (unless ALLOW_CLOUD_LLM_IN_TESTS=true). Tests and the app under test never see these keys, so no test run hits commercial cloud APIs. Keys in .env are not loaded into the test process; stripping applies to shell-exported keys. Guard still runs for ui, e2e, and all (would block if keys remained). Docs: testing.md API Key Safety updated.
  • Test container auto-start (2026-03-09): run_tests.py auto-starts test containers for API and UI tests (in addition to integration, e2e, all). API tests skip the cloud LLM key guard when CHATGPT_API_KEY is set, so ./run_tests.py api runs without ALLOW_CLOUD_LLM_IN_TESTS=true.
  • LMStudio made optional (2026-03-09): LMStudio is no longer the implicit default LLM provider. New workflow configs default to an empty provider (""); existing DB configs are unaffected. workflow_lmstudio_enabled now defaults to False (opt-in). Provider fallback removed from LLMService — a missing provider raises a clear RuntimeError instead of silently routing to LMStudio. SigmaMatchingService now uses EmbeddingService(model_name="intfloat/e5-base-v2") for query embeddings (same model as sigma index-embeddings) instead of LMStudioEmbeddingClient. The LMStudio model auto-loader in the agentic workflow is skipped unless at least one agent is configured to use lmstudio.

Fixed

  • Agent evals historical results table (2026-03-04): When evals ran multiple times against the same config version, a first-run failure hid subsequent successful runs. The table now shows each run as a separate column (v40a, v40b, v40c, …) so all runs are visible.

Added

  • Sigma observables_used (2026-03-05): LLM prompts include observables_used instruction; sigma_generation_service parses and strips observables_used from LLM YAML before validation; workflow and sigma_queue UI filter rule preview to show only observables used by that rule; unit and UI tests.
  • LLM API errors in subresults (2026-03-05): Agentic workflow promotes error/error_type/error_details to top-level in subresults for failed LLM calls; unit tests for storage and log_llm_error.
  • Agent evals setup documentation: Installation guide now includes an "Agent evals setup (optional)" section: one-time run of scripts/fetch_eval_articles_static.py so "Load Eval Articles" and subagent evals have article content. config/eval_articles_data/README.md has a "New installs" callout with a link to the docs. Agent Evals UI shows a setup hint when no eval articles are found (command + doc reference).
  • LM Studio URL configuration in Settings and setup: LM Studio server and embedding URLs can be set in both ./setup.sh (optional prompt) and Settings UI (Workflow Provider Configuration → LM Studio). Values are stored in DB and synced to os.environ so chat, sigma index, and embeddings use them. App startup loads these from DB into env when present.
  • LM Studio embedding URL fallbacks: Embedding client and URL helpers try multiple candidates (primary from env, then localhost, host.docker.internal, or when primary is a specific IP) so sigma index and similarity work when the host/IP changes (e.g. Docker vs host, different network).
  • Similarity search diagnostic when no rules found: When Similarity Search returns no matches, the modal shows a setup hint: suggest running ./run_cli.sh sigma sync then ./run_cli.sh sigma index (or python3 -m src.cli.main sigma sync / sigma index), or python3 scripts/migrate_sigma_to_canonical.py if logsource_key is missing. API returns diagnostic (total_sigma_rules, rules_with_logsource, logsource_key) when matches are empty.
  • Sigma compare endpoint: Compare endpoint now cleans both rule inputs with clean_sigma_rule() before parsing so markdown-wrapped or prose-prefixed LLM output parses; clearer 400 message when YAML remains invalid.
  • CLI service (Docker): cli service in docker-compose now has extra_hosts and LMSTUDIO_EMBEDDING_URL / LMSTUDIO_EMBEDDING_MODEL so ./run_cli.sh sigma index can reach LM Studio on the host.
  • migrate_sigma_to_canonical.py: Fixed storing logsource_key as string (unpack tuple from normalize_logsource) so DB has product|category format.

Changed

  • ProcTree eval articles: config/eval_articles.yaml process_lineage list reduced to the nine documented URLs (thedfirreport + picussecurity); removed huntress, blurring-the-lines, and localhost entries. Expected counts set from documented ground truth (2, 0, 0, 0, 0, 0, 2, 1, 1).
  • Preset layout: Workflow preset JSONs consolidated under config/presets/AgentConfigs/. Tracked quickstart presets moved from repo root presets/ to config/presets/AgentConfigs/quickstart/. Private presets (gitignored) go in config/presets/private/. Root presets/ folder removed. build_baseline_presets.py now normalizes JSON in quickstart/ instead of presets/. Docs and README updated.

Added

  • LLM provider model catalog refresh at setup and start: ./setup.sh and ./start.sh now run the provider model catalog refresh after services are up so users see the current OpenAI/Anthropic model list immediately instead of waiting for the daily 4:00 AM Celery run. Documentation updated (SCRIPTS_AND_USAGE.md, installation.md, configuration.md).
  • Claude Sonnet 4.6 in default and live provider model catalog.
  • Preset prompt merge: Script and preset for filling workflow presets from repo prompts
  • scripts/merge_prompts_into_preset.py — merges src/prompts contents into a preset JSON's agent_prompts
  • presets/AgentConfigs/anthropic-no-lmstudio-prompts.json — Anthropic-focused preset with all agent prompts populated
  • Workflow config binding audit test (2026-02-22): Added Playwright coverage for /workflow#config to audit visible mutable controls for label/binding metadata and verify prompt panel consistency (including OS Detection / Rank / SIGMA prompt panels).
  • Documentation Overhaul: Complete reorganization and enhancement of MkDocs documentation
  • Rewrote index.md with marketing lead, problem/solution statement, and role-based navigation
  • Enhanced quickstart.md with clear outcome statements
  • Fixed architecture.md schema inaccuracies (removed tier column, corrected hunt_score reference)
  • Moved 17 historical/spec files to docs/archive/
  • Moved 6 testing docs to docs/development/
  • Removed redundant docs/README.md and docs/DOCUMENTATION.md
  • Fixed cross-references (WORKFLOW_DATA_FLOW.md link to DEBUGGING_TOOLS_GUIDE.md)
  • MkDocs Enhancements:
  • Added copy button feature for all code blocks (content.code.copy)
  • Integrated StackEdit for browser-based Markdown editing
  • Added custom JavaScript and CSS for StackEdit integration with modal instructions

Changed

  • Documentation sync (mdu) (2026-02-23): Updated README, installation, and local-run docs to match current start.sh behavior (MkDocs build/server runs automatically when mkdocs.yml exists; no run_mkdocs.sh prompt flow), and fixed the duplicated 8888 port note in docs/quickstart.md.
  • Anthropic model list: Filtered to one main/latest per family (e.g. one Sonnet 4.5, one Haiku 4.5) via regex-based family key; datestamped variants excluded when a main or -latest variant exists. Implemented in src/utils/model_validation.py (filter_anthropic_models_latest_only) and applied in catalog load, AI route, and maintenance script.
  • OpenAI model list: Filtered to chat-only, latest only (no -YYYY-MM-DD dated variants). New filter_openai_models_latest_only in src/utils/model_validation.py; applied in catalog load, AI route, and maintenance script.
  • Provider model catalog docs: SCRIPTS_AND_USAGE.md documents when the catalog is refreshed (setup, start, daily) and the per-provider filtering; installation.md and configuration.md note the refresh at setup/start.
  • Documentation Structure: Reorganized navigation to match proposed structure with Advanced section
  • File Organization: Consolidated testing and historical documentation into appropriate subdirectories
  • Documentation sync (mdu) (2026-02-17): README and docs verified; broken archive links fixed; MkDocs build confirmed.
  • Workflow config UI binding normalization (2026-02-22): /workflow#config now applies a consistent frontend binding/label normalization layer across static and dynamically rendered controls (provider/model selectors, toggles, prompt editors), adds a deterministic window.getWorkflowConfigBindingAudit() helper, and hardens autosave toggle Playwright tests against invalid local provider/model startup state.

[5.0.0 "Callisto"] - 2026-01-15

Added

  • Stabilized Agentic Workflow and Evaluation Datasets: Production-ready agentic workflow system with comprehensive evaluation framework
  • Complete evaluation dataset management for testing and validation
  • Stable workflow execution with improved error handling and retry logic
  • Enhanced evaluation metrics and reporting
  • Advanced SIGMA Rule Similarity Searching: Enhanced similarity search algorithm for SIGMA rule matching
  • Behavioral novelty assessment combining atom Jaccard (70%) and logic shape similarity (30%)
  • Improved detection predicate overlap analysis
  • Structural similarity matching for detection logic patterns
  • Service mismatch and filter difference penalties for accurate matching
  • AI-Assisted SIGMA Rule Editing and Enrichment: Intelligent rule improvement system
  • AI-powered rule enrichment with context-aware improvements
  • Iterative rule editing with LLM feedback
  • Article context integration for better rule quality
  • Support for multiple LLM providers (OpenAI, Anthropic, Claude, LMStudio)
  • Raw LLM response display for transparency
  • Provider indicator badges in enrichment interface
  • GitHub SIGMA Rule Repository Integration: Complete GitHub integration for rule submission
  • Automated PR creation for approved SIGMA rules
  • SigmaPRService for repository management
  • Configurable repository paths and authentication
  • Branch creation, commit, and PR automation
  • GitHub PR Configuration in Settings page
  • Submit PR functionality from SIGMA Queue interface
  • Docker volume mounting for repository access

Changed

  • SIGMA Similarity Algorithm: Replaced cosine similarity with behavioral novelty assessment
  • Atom Jaccard measures overlap of detection predicates (field/operator/value combinations)
  • Logic shape similarity measures structural similarity of detection logic (AND/OR/NOT patterns)
  • Updated similarity threshold help text to explain new algorithm components
  • Agentic Workflow Stability: Improved workflow reliability and error recovery
  • Enhanced checkpointing and state management
  • Better retry logic for failed steps
  • Improved evaluation dataset handling

Fixed

  • Rule Enrichment Modal: Fixed enrichment interface issues
  • Improved provider detection and display
  • Better error handling for enrichment failures
  • Enhanced raw response display
  • LMStudio Integration: Fixed LMStudio enrichment functionality
  • Fixed syntax error in sigma_queue.py (duplicate else clause)
  • Fixed prompt template KeyError by escaping JSON braces in sigma_enrichment.txt
  • Added empty response handling for LMStudio API calls
  • Improved error sanitization for HTTPException details
  • Enhanced LMStudio URL fallback logic and connection error handling
  • Added finish_reason logging for debugging empty responses
  • Verified LMStudio connectivity and model availability

[6.1.0 "Io"] - 2026-04-27

Historical note: This section preserves the backlog that accumulated under Unreleased from 2025-12-16 through 2026-02-17 and was labeled 6.1.0 by release PR #79 on 2026-04-27. Entry dates record implementation timing rather than the release chronology.

Added

  • Infra guard: last-line circuit breaker and bundle illegal-state detection (2026-02-02): Enforce invariant that LLM is never invoked with empty messages
  • Circuit breaker in request_chat and defense-in-depth in _call_openai_chat, _call_anthropic_chat, _post_lmstudio_chat
  • Bundle builder: when messages==[] and status==completed, set infra_failed=True and ILLEGAL_STATE_MESSAGES_EMPTY_BUT_COMPLETED warning
  • PreprocessInvariantError classified as infra_failed in eval_runner, agentic_workflow, langfuse_eval_client
  • Eval bundle unit tests: illegal state positive/negative cases (test_eval_bundle_service.py)
  • Workflow Executions table sorting and filtering (2026-02-02): Sortable column headers (ID, Article, Status, Current Step, Ranking Score, Created); step filter dropdown; article ID filter with Apply button; API params sort_by, sort_order, step; API and UI tests
  • Cursor rule: Langchain workflow provider-agnostic (2026-02-02): New rule .cursor/rules/langchain-workflow-provider-agnostic.mdc enforcing that workflow/LLM changes work regardless of model/provider (lmstudio, openai, anthropic)
  • Cursor rule: Agent config test confirmation (2026-02-02): New rule .cursor/rules/agent-config-test-confirmation.mdc requiring explicit user approval before running or creating tests that mutate active agent configs
  • Cmdline Attention Preprocessor documentation (2026-02-02): New feature doc and workflow diagram updates
  • Agent config preset: OS Detection fallback coverage (2026-02-17): Presets now capture and restore all OS Detection fallback fields
  • Toggle osdetection_fallback_enabled included in preset export/apply and persisted in workflow config (DB column, API request/response)
  • Temperature and Top_P for OS Detection fallback LLM: OSDetectionAgent_fallback_temperature, OSDetectionAgent_fallback_top_p in AGENT_CONFIG and UI; collected/applied via presets
  • Migration script scripts/migrate_add_osdetection_fallback_enabled.py adds the new column to existing databases (run via docker-compose exec web python3 scripts/migrate_add_osdetection_fallback_enabled.py or with reachable DATABASE_URL)

Removed

  • Root cleanup (2026-02-04): Removed obsolete root files; moved dev scripts to scripts/
  • Deleted: gittree.txt, run_tests_standardized.py, tmp_workflow_script9_updated.js
  • Moved: audit_selectors.py, verify_provider_fix.py → scripts/ (paths updated for new location)
  • Ollama support (2026-02-04): Removed remaining Ollama/tinyllama code and references
  • ai.py: ollama/tinyllama now return 400 "Use LMStudio for local LLM"
  • llm_generation_service: removed ollama/tinyllama provider
  • article_detail.html, llm_optimized_js: removed tinyllama display branches
  • tests: test_health_page OLLAMA→LMSTUDIO; test_ai_* Ollama→LMStudio
  • docs: TEST_INDEX, TESTING, AI_TESTS_README, archive
  • LangSmith support (2026-02-04): Removed vestigial LangSmith references; tracing uses Langfuse only
  • Removed LANGSMITH_API_KEY from .env.example, docker-compose.yml
  • Renamed API field uses_langsmith → uses_langfuse in workflow debug info
  • Updated docs (config, deployment) to document Langfuse env vars instead

Changed

  • Docs: deprecated agent references (2026-02-02): Removed/updated references to RegExtract, EventCodeExtract, SigExtract
  • observables.md: Active types only (cmdline, process_lineage, hunt_queries); deprecated registry_keys, event_ids noted
  • index.md, huntables.md: Extract Agent list updated (no registry, event IDs)
  • schemas.md: extraction_counts clarified with legacy note
  • WORKFLOW_DATA_FLOW.md: subresults example and diagram updated to active sub-agents only
  • architecture.md: EventID → Event ID (scoring keyword)
  • Documentation sync (mdu) (2026-02-17): README and docs verified; duplicate [Unreleased] in CHANGELOG merged; MkDocs build confirmed.
  • Documentation true-up (2026-02-02): Aligned docs with current architecture and removed features
  • README, index, quickstart: 7-step agentic workflow (OS Detection first), 6 services, no LangGraph server
  • Callisto.md: 6 services (postgres, redis, web, worker, workflow_worker, scheduler); removed langgraph-server, ollama; workflow runs in Celery
  • Kepler.md: Added historical note; langgraph-server/ollama removed in Callisto
  • DOCKER_ARCHITECTURE: Added workflow_worker; clarified worker vs workflow_worker
  • BACKUP_AND_RESTORE: Removed ollama_data from volumes
  • OS_DETECTION: Step 0 (first), not 1.5; removed AI Assistant Modal reference
  • api.md: Observable Training marked inactive
  • DATABASE_QUERY_GUIDE: Deprecation note for training_category; removed chosen/rejected from example queries
  • agentic_workflow.py: Fixed top-level docstring step order

Removed

  • Duplicate eval article 62 (2026-02-02): Removed article 62 from cmdline/process_lineage eval config (duplicate of 602 "Blurring the Lines"); deleted scripts/eval_bundles/under_5113.json; updated docs and test docstring to reference 602
  • test_backup_restore.py (2026-01-30): Removed integration tests for backup/restore scripts (calculate_checksum, validate_backup_directory, validate_backup_file); references removed from tests/TEST_INDEX.md and tests/TESTING.md
  • Chosen/Rejected Article Classification (2026-01-27): Deprecated and removed article-level chosen/rejected/unclassified classification
  • Removed /api/articles/next-unclassified and POST /api/articles/{id}/classify
  • Bulk action supports only delete; chosen/rejected/unclassified actions removed
  • Removed classification modal, filters, badges, and training_category-based counts from articles list and dashboard
  • Search no longer accepts classification query param; dashboard top articles no longer include classification
  • Docs and tests updated: AGENTS.md, api.md, DO_NOT.md, TECHNICAL_READOUT, SIGMA_DETECTION_RULES, MANUAL_CHECKLIST, skip reasons

Fixed

  • Security: Removed PyPDF2 dependency (2026-02-04): Removed PyPDF2==3.0.1 from requirements-pinned.txt to eliminate infinite loop vulnerability (CVSS 6.2). Package was unused; codebase uses pypdf==6.6.2 for PDF processing. No patched version available for PyPDF2.
  • Security: Removed aiohttp dependency (2026-02-04): Removed aiohttp from requirements to eliminate 7 Dependabot alerts (1 high, 3 moderate, 3 low). Package was only used in unused test utility tests/e2e/mcp_orchestrator.py; codebase uses httpx for HTTP requests. Deleted unused orchestrator file.
  • Security: jaraco.context path traversal vulnerability (2026-02-04): Updated jaraco.context from 6.0.1 to 6.1.0 to fix CVE-2024-XXXXX path traversal vulnerability (CVSS 8.6) affecting versions >= 5.2.0, < 6.1.0
  • Max Similarity 0% displayed as N/A (2026-02-02): When max_similarity was 0, templates used rule.max_similarity ? ... : 'N/A'; in JavaScript 0 is falsy so it showed N/A. Fixed: use typeof rule.max_similarity === 'number' so 0 displays as "0.0%" in workflow, workflow_executions, sigma_queue, and article_detail templates. Added UI test test_max_similarity_zero_displays_as_percent.
  • Sigma rule preview modal edits not persisted (2026-02-02): Edits in the rule preview modal were not used when clicking Validate, Enrich, or Similarity Search. Fix: getCurrentRuleYamlFromModal() returns current modal content (textarea in edit mode, DOM in view mode); save-before-validate; enrich uses current modal YAML; similar-rules saves first; validate API reads rule_yaml from request body.
  • Agent prompt save display reverting (2026-02-02): Saved agent prompts (all agents/sub-agents) could revert to the previous version in the UI until the user rolled back to "latest." Root cause: loadAgentPrompts (from initial loadConfig) could complete after a save and overwrite agentPrompts with stale data. Fix: track lastPromptSaveAt and lastSavedPromptAgent; when loadAgentPrompts completes within 3s of a save, preserve the saved agent's data instead of overwriting.
  • Agent Evals MAE chart left y-axis label (2026-02-01): "Normalized MAE (nMAE)" label stayed off-screen unless scrolled fully left. Added a sticky left column so the label remains visible when the chart is scrolled horizontally.
  • sources.yaml parse error (2026-01-29): Fixed YAML indentation for sekoia_io_blog list item (was 3 spaces under sources, causing "expected block end, but found block sequence start" at line 452)
  • Retraining Complete panel broken data elements (2026-01-28): GET /api/model/retrain-status used latest_version.training_samples (AttributeError—model has training_data_size). Fixed: use training_data_size; derive evaluation_metrics from eval_confusion_matrix; write minimal training_samples/feedback_samples/annotation_samples when DB enrichment fails.
  • CommandLine / Hunt Queries eval parity with Process Lineage (2026-01-27): (1) Subagent-eval model filtering only included cmdline and process_lineage — hunt_queries and HuntQueriesExtract/HuntQueriesQA added so eval runs for Hunt Queries filter models correctly. (2) _extract_actual_count for hunt_queries now explicitly uses query_count, then count, then len(queries/items) so completion handler gets the right actual count from subresults["hunt_queries"].
  • Hunt Query eval jobs stuck pending (2026-01-27): (1) Runs with subagent "hunt_queries" create eval records with subagent_name="hunt_queries", but the workflow completion handler only looked for hunt_queries_edr and hunt_queries_sigma — completion handler now also finds/updates "hunt_queries". (2) Eval runs that skip SIGMA mark the execution completed inside check_should_skip_sigma_for_eval and return "end" without going through the post-graph block that calls _update_subagent_eval_on_completion — that block only runs when the graph returns to run_workflow and we re-query execution; the skip-sigma path now calls _update_subagent_eval_on_completion immediately when marking completed so eval records are updated. (3) Polling no longer clears the current run's execution map.
  • Agent Eval MAE/nMAE spike at some config versions (2026-01-27): Subagent-eval aggregate nMAE could explode when mean expected count was 0 or very small (e.g. many articles with expected_count: 0). Normalized MAE now uses divisor max(mean_expected_count, 1.0) and is capped at 1.0 so the metric stays in [0, 1]; raw MAE is unchanged. Excluded runs 7666 and 7667 from Agent Evals results (bad runs with 130+ actual counts on many articles).
  • UI Test Stabilization (2026-01-27): Fixed and adjusted failing UI tests for articles and article-detail flows
  • test_article_workflow_execution_redirect: Route handler now calls route.continue_() so workflow requests are not blocked; added assertion for workflow trigger or redirect to /workflow
  • test_content_length_display: Locator updated to match article metadata Content: N characters (regex) instead of generic "Content:", which was matching hidden "High-Value Detection Content:" elements
  • test_ml_hunt_score_tbd_state: Assertion made robust—when any TBD badges exist, at least one must have a tooltip (title attribute)
  • test_pagination_with_sorting: Skipped—pagination links in articles template do not preserve sort_by/sort_order
  • test_bulk_action_mark_as_chosen, test_bulk_action_reject, test_bulk_action_unclassify: Skipped—bulk toolbar currently has only Delete; Mark as Chosen/Reject/Unclassify are not in toolbar
  • test_classification_badge_display: Skipped—classification badges (Chosen/Rejected/Unclassified) are not shown on article list cards

Added

  • Cmdline attention preprocessor (2026-02-02): Optional preprocessor for Windows command-line extraction that surfaces LOLBAS-aligned anchors earlier in the LLM prompt. Toggle in Workflow Config (Cmdline Extract agent); integrated into agentic workflow and llm_service. New cmdline_attention_preprocessor.py; config field cmdline_attention_preprocessor_enabled.
  • LG workflow Vulture step (2026-01-30): Added vulture dead-code detection to .cursor/rules/lg-workflow.mdc hygiene; run vulture src scripts before commit.
  • Prompt Tuning subagent (2026-01-30): Cursor subagent .cursor/agents/prompt-tuning.md for autonomous commandline extractor prompt tuning — runs cmdline evals, examines bundles/traces for count mismatches, proposes and applies model/provider/temperature/top_p/prompt and QA changes, iterates until nMAE ≤ 0.2 or 25 runs, then summarizes.
  • LG workflow rule and test coverage (2026-01-29): Cursor rule .cursor/rules/lg-workflow.mdc defines "lg" as commit + push + full hygiene (security, deps, docs, changelog). Rule updated so the agent must run the full workflow through push to main (no hand-off); hygiene order: changelog, docs, deps, security; fallback to /usr/local/bin/git if wrapper breaks. Run_tests: added _get_agent_config_exclude_env() so --exclude-markers agent_config_mutation sets CTI_EXCLUDE_AGENT_CONFIG_TESTS=1; unit tests in tests/test_run_tests_parsing.py (TestAgentConfigExcludeEnv) and tests/test_database.py (test_db_article_to_model_sets_url_from_canonical_url) for env wiring and DatabaseManager Article.url from canonical_url.
  • compare-sources CLI (2026-01-29): New command to compare production DB source settings with config/sources.yaml — reports sources only in YAML, only in DB, and field-by-field differences (active, url, rss_url, check_frequency, lookback_days, min_content_length, rss_only). Run via ./run_cli.sh compare-sources; optional standalone script scripts/compare_sources_db_vs_yaml.py for use outside Docker with DATABASE_URL
  • Multi-Rule SIGMA Generation with Phased Approach (2026-01-26): Enhanced SIGMA rule generation to support multiple rules per article
  • Refactored generation into 4 phases: multi-rule generation, validation, per-rule repair, artifact-driven expansion
  • Added defensive parsing for multiple rules (handles --- separators, markdown code blocks, multiple title: entries)
  • Implemented artifact-driven expansion phase that generates additional rules for uncovered logsource categories
  • Added rule-scoped logging with rule_id, generation_phase, repair_attempts[], and final_status tracking
  • Created sigma_generate_multi.txt prompt explicitly supporting multi-rule output with --- separators
  • Renamed sigma_feedback.txt to sigma_repair_single.txt for clarity
  • Added per-rule max attempts (max_repair_attempts_per_rule) to prevent one pathological rule from consuming all attempts
  • Updated workflow to pass extraction_result to enable expansion phase based on extracted observables
  • Fixed parsing to handle markdown code blocks for backward compatibility with existing model outputs
  • Addresses issue where only 1 rule was generated despite multiple observable types (cmdline + process_lineage)

Changed

  • sources.yaml RSS vs scraping (2026-01-29): Updated source config so new installs get known-good RSS/scraping preferences — header comment documents RSS vs scraping and per-source fallbacks; Sekoia: set rss_url to category feed (was null with rss_only: true); Group-IB kept scraping-only with comment; VMRay and Microsoft Defender Endpoint: comments added for alternate feed URLs if primary fails
  • Observables Mode Disabled (2026-01-22): Disabled Observables annotation mode, marked as inactive for future release
  • Hidden Observables Mode button in article detail page (preserved in comments)
  • Disabled Huntability Mode button (non-clickable)
  • Hidden Observable Training card from MLOps page (preserved in comments)
  • Added inactive notice banner to Observable Training page
  • All observables code preserved with "INACTIVE: Planned for future release" comments
  • Huntability annotation system remains fully functional
  • Annotation Creation Fix (2026-01-22): Fixed annotation creation API errors
  • Fixed URL template syntax in JavaScript (changed {{ article.id }} to ${this.articleId})
  • Fixed missing used_for_training field handling in database manager
  • Improved error handling to surface actual database errors instead of generic messages

Removed

  • MLOps Operational Checklist Panel (2026-01-21): Removed Operational Checklist panel from MLOps page
  • Removed checklist UI component and associated CSS classes
  • Cleaned up unused .mlops-checklist and .mlops-gear-icon styles

Changed

  • Workflow UI Button Label (2026-01-21): Updated "Generate Commands" button to "Generate LMStudio Commands" for clarity
  • Modal Management Improvements (2026-01-21): Enhanced prompt history modal cleanup and registration
  • Improved modal close handling through ModalManager
  • Better timing for modal registration and cleanup
  • Removed promptHistoryModal from hardcoded modal list (now dynamic)

Added

  • Restore from Backup Feature (2026-01-15): Added restore functionality to settings page
  • Restore from Backup button in Backup Actions section
  • Modal dialog with backup selection UI
  • Support for both system and database backup restoration
  • Snapshot creation option in restore flow
  • /api/backup/restore endpoint for backup restoration
  • Progress indicators and error handling

Fixed

  • Article Detail Page UI Improvements (2026-01-15): Fixed mobile annotation instructions overlap and button positioning
  • Removed "Mobile Annotation" instructions banner that was cluttering the article detail page
  • Added top padding (pt-20) to article-content divs to prevent button overlap with article text
  • Applied padding fix to both article-content and article-content-plain elements

Added

  • SIGMA PR Submission Feature with GitHub Integration (2026-01-15): Complete GitHub PR submission system for approved SIGMA rules
  • SigmaPRService for submitting approved rules via GitHub PRs
  • Auto-stash uncommitted changes before PR creation
  • Configure Git remote authentication with GitHub token
  • GitHub PR Configuration section in Settings page
  • Submit PR button on SIGMA Queue pages
  • Mount SIGMA repository as Docker volume
  • Support configurable repo path, token, and Git credentials via UI
  • Handles branch creation, commit, and PR creation with error handling
  • Repository Comparison in A/B Test (2026-01-14): Added repository comparison functionality to SIGMA A/B test interface
  • Compare generated rules against external repository rules
  • Enhanced similarity search with repository context
  • Improved rule matching accuracy
  • Raw LLM Response Display (2026-01-14): Added collapsible section in enrichment modal to show complete raw LLM response
  • Raw response displayed below enriched rule YAML in collapsible panel
  • Shows unprocessed LLM output before markdown code block removal
  • Toggle button with proper ARIA attributes for accessibility
  • Available in both SIGMA queue and workflow execution views
  • LLM Provider Indicator (2026-01-14): Added provider indicator badge in enrichment modal header
  • Displays current LLM provider (OpenAI, Claude, LMStudio) with icon
  • Dynamically detects provider from workflow config or settings
  • Updates automatically when modal opens
  • Rule Validation Feature (2026-01-14): Added LLM + pySIGMA validation for queued rules
  • Validation button in rule preview modal
  • Combines LLM-based rule improvement with pySIGMA validation
  • Up to 3 retry attempts with error feedback
  • Success/failure indicators with detailed error messages
  • Apply validated rule button to update rule YAML
  • QA Top_P Parameter Support (2026-01-14): Added top_p (top-p sampling) parameter for all QA agents
  • Top_P input fields for RankAgentQA, CmdLineQA, ProcTreeQA, HuntQueriesQA
  • Values saved to workflow config and persist across saves
  • Applied to all QA agent LLM calls

Changed

  • Preset System QA Top_P Values (2026-01-14): Updated preset system to include QA top_p values
  • Presets now configure top_p for all QA agents
  • Consistent QA behavior across preset configurations
  • Workflow Help Text (2026-01-14): Updated workflow help text to mention 'Use Full Article Content' option
  • Clarified when full article content is used in workflow execution
  • Improved user guidance for content selection
  • Button Text Updates (2026-01-14): Updated button labels for clarity
  • 'Cosign Similarity Search' and 'Check Similar Rules' → 'Similarity Search'
  • More intuitive button naming for similarity search functionality
  • Collapsible Panel Initialization (2026-01-14): Improved collapsible panel initialization with requestAnimationFrame
  • Uses double requestAnimationFrame to ensure DOM is fully updated before initialization
  • Clears initialization markers on re-render to prevent duplicate handlers
  • Better handling of dynamically added content (QA prompts, agent configs)
  • Prevents panel toggle issues after config changes

Removed

  • SIGMA QA Agent Toggle (2026-01-14): Removed SIGMA QA agent toggle and configuration
  • QA validation not applicable for SIGMA rule generation
  • Removed QA toggle, model selector, and badge from SIGMA Agent panel
  • Removed JavaScript references to SIGMA QA functionality
  • SIGMA Agent now runs without QA validation

Fixed

  • Rule Preview Modal from Execution View (2026-01-15): Fixed rule preview modal opening from execution view
  • Changed 'View Queued Rule' links in execution modal to open rule preview modal
  • Replaced navigation to /workflow#queue with previewQueuedRule() function call
  • Added full rule preview modal with edit, approve, reject, enrich, and similarity search
  • Implemented event listeners attached after modal content insertion
  • Made previewQueuedRule globally accessible via window object
  • Modal matches SIGMA Queue preview functionality
  • Workflow Config Page Model Selection Resets (2026-01-15): Fixed model selection dropdowns resetting to default when provider changes
  • Rank Agent model selection now preserves user selection across provider changes
  • Extract Agent (Supervisor) model selection now preserves user selection across provider changes
  • CmdlineExtract Agent model selection now preserves user selection across provider changes
  • All model dropdowns now read current DOM value first, then fallback to config
  • Prevents loss of unsaved model selections during UI re-renders
  • LM Studio Error Message Display (2026-01-14): Fixed incorrect LM Studio availability warning appearing when OpenAI or Anthropic is selected
  • Error message now only displays when LM Studio is actually selected as the provider
  • Fixed for both Rank Agent and Extract Agent model selectors
  • Provider state now read from DOM to update dynamically when provider changes
  • Live Execution View QA Results (2026-01-14): Fixed confusing QA result display issues
  • Fixed QA results showing "QA failed without feedback" when verdict is "PASS"
  • QA results with "pass" verdict now correctly show "QA passed successfully" summary
  • Fixed duplicate QA results appearing for same agent (CmdlineExtract + CmdLineQA)
  • Improved QA result deduplication by tracking mapped agent names
  • QA results now include step context to show which workflow step they belong to
  • Live Execution View Step Progression (2026-01-14): Fixed misleading step progression display
  • Added step completion tracking for extract_agent node
  • Step completion events now emitted when steps finish
  • LLM interactions and QA results include step context to clarify which step they belong to
  • Fixed ranking score appearing after extract_agent step change (now appears immediately when available)
  • Workflow Config Selected Models Display (2026-01-14): Fixed Rank Agent not appearing in Selected Models list
  • Fixed provider-agnostic model retrieval using getActiveAgentModelValue() instead of direct DOM access
  • Rank Agent now appears correctly for all providers (LMStudio, OpenAI, Anthropic)
  • Also fixed Extract and SIGMA agents to use same provider-agnostic approach
  • Article Detail Page JavaScript Errors (2026-01-14): Fixed critical JavaScript syntax error and function availability issues
  • Fixed unclosed template string in displaySigmaRuleDetails function causing "Unexpected token ';'" syntax error
  • Moved critical functions (setAnnotationMode, copyArticleContentToClipboard) to early script block for immediate availability
  • Fixed JavaScript else block alignment in navigateToNextUnclassified function
  • Changed article content background from dark:bg-gray-950 to dark:bg-gray-800 for better visibility
  • All buttons and annotation capabilities now working correctly
  • Resolved all "Uncaught ReferenceError" and "Uncaught SyntaxError" console errors
  • Database Restore with pgvector Extension (2026-01-14): Fixed restore operations to automatically enable pgvector extension for SIGMA similarity search
  • Restore scripts now explicitly enable pgvector extension after database creation
  • Ensures vector embeddings and similarity search features work correctly after restore
  • Updated both restore_database.py and restore_system.py scripts
  • Added verification steps in backup/restore documentation

Added

  • Queued Rule Preview Modal (2026-01-14): Added comprehensive rule preview and management from execution view
  • Modal displays queued SIGMA rule details including YAML, similarity scores, and metadata
  • Inline YAML editing with save functionality via PUT /api/sigma-queue/{id}/yaml
  • Approve/reject actions with review notes support
  • Similarity search integration with loading indicators
  • Improved event listener attachment for dynamically rendered rule links
  • Modal accessible from execution detail view queued rules list
  • Live Execution View Step Context (2026-01-14): Enhanced event display with workflow step context
  • LLM interactions now show which workflow step they belong to (e.g., [extract_agent])
  • QA results include step context for better event grouping
  • Step completion events displayed when steps finish
  • Better visibility into execution flow and event ordering
  • Agent Status Indicators (2026-01-14): Added enabled/disabled badges to Selected Models list in workflow config
  • Green "Enabled" badge for active agents
  • Gray "Disabled" badge for inactive agents
  • Status reflects Rank Agent toggle, Extract sub-agent toggles, and QA agent checkboxes
  • Badges support dark mode styling
  • OS Detection Fallback in Selected Models (2026-01-14): Added OS Detection Fallback to Selected Models list
  • Appears when fallback model is configured and toggle is enabled
  • Uses provider-agnostic model retrieval for all providers
  • Displays with "Enabled" badge when configured
  • Bulk Proctree Eval Update Script (2026-01-12): Added scripts/update_proctree_expected_counts.py for bulk updating process_lineage expected counts from YAML config
  • Updates all SubagentEvaluationTable records matching articles in config
  • Recalculates scores for completed evaluations
  • Supports both article_id and URL-based lookups

Changed

  • SIGMA Similarity Algorithm (2026-01-14): Replaced cosine similarity with behavioral novelty assessment for SIGMA rule similarity search
  • Similarity now calculated as weighted combination: 70% atom Jaccard + 30% logic shape similarity
  • Atom Jaccard measures overlap of detection predicates (field/operator/value combinations)
  • Logic shape similarity measures structural similarity of detection logic (AND/OR/NOT patterns)
  • Service mismatches and filter differences apply penalties that reduce similarity
  • Updated similarity threshold help text to explain new algorithm components
  • Removed embedding model selector from workflow config (no longer needed for similarity search)
  • Backend still accepts SigmaEmbeddingModel for backward compatibility but UI no longer sends it
  • Proctree Eval Expected Counts (2026-01-12): Updated process_lineage expected counts in config/eval_articles.yaml
  • Article 68: 2 → 1
  • Article 62: Added (4)
  • Article 762: 7 → 2
  • Articles 985-989: Added (0, 1, 0, 0, 0)
  • Article 1523: 4 → 2
  • Updated 309 SubagentEvaluationTable records with new expected counts
  • Recalculated scores for all completed evaluations
  • Top_P Control for All Agents (2026-01-07): Added per-agent Top_P (top-p sampling) parameter control
  • Top_P input fields for all agents: RankAgent, ExtractAgent, SigmaAgent, all sub-agents (CmdlineExtract, SigExtract, EventCodeExtract, ProcTreeExtract, RegExtract), and all QA agents
  • Top_P values are saved to workflow config and persist across saves
  • Top_P values are read from config and passed to LMStudio API calls
  • Test functions now use Top_P from saved config (requires save before testing)
  • Added debug logging for Top_P values throughout the pipeline
  • Type conversion handles JSONB string/number values correctly
  • Active Workflow Config panel displays Top_P for selected agent
  • Clickable Eval Results (2026-01-02): Added clickable result cells in evaluation results table
  • Click any completed result to view extracted commandlines in a modal
  • Modal displays all commandlines with numbered list and article link
  • API endpoint: /api/evaluations/execution/{execution_id}/commandlines
  • Sticky Expected Column (2026-01-02): Made "Expected" column sticky in pivot view for better visibility
  • Auto-scroll to Latest (2026-01-02): Results table now auto-scrolls to show latest config versions on load
  • Aggregate Eval Scoring (2025-12-27): Added comprehensive aggregate scoring per workflow config version
  • Mean Score: Average deviation across all eval articles
  • Mean Absolute Error (MAE): Average absolute deviation
  • Mean Squared Error (MSE): Squared deviation metric
  • Perfect Match Percentage: % of articles with exact match (score = 0)
  • Score Distribution: Breakdown of scores by range (0, ±1-2, ±3+)
  • API endpoint: /api/evaluations/subagent-eval-aggregate with config version grouping
  • UI display: "Aggregate Scores by Config Version" section in agent evals page
  • Color-coded MAE display (green/yellow/red based on threshold)
  • Comprehensive Source Coverage (2025-12-26): 11+ major security sources now operational for threat intelligence collection
  • Subagent Evaluation System (2025-12-26): Complete evaluation framework for testing extractor subagents
  • Evaluation articles stored in config/eval_articles.yaml with expected observable counts
  • SubagentEvaluationTable database table for tracking evaluation results
  • UI at /mlops/agent-evals for running and viewing evaluations
  • Scoring system: perfect score is 0 (exact match), shows deviation from expected count
  • Color-coded results: green (0), yellow (±1-2), red (±3+)
  • Eval Workflow Optimizations (2025-12-26):
  • Skip OS Detection, Rank Agent, and SIGMA generation for eval runs to save time
  • Filter out SigmaAgent models during eval runs to prevent loading unnecessary 30b model
  • Eval runs terminate after extractor agent completes
  • Clear Pending Records (2025-12-26): Added button to delete pending evaluation records from UI
  • OS Detection fallback LLM (2025-12-18): Now supports cloud providers (OpenAI, Anthropic)
  • Provider selector (2025-12-18): Added for OS Detection fallback model configuration
  • Fallback model (2025-12-18): Respects provider selection and uses appropriate input type
  • Current Configuration display (2025-12-18): Now shows selected models with their providers (filtered by enabled status)

Changed

  • Eval Articles Config (2026-01-02): Updated config/eval_articles.yaml with 13 articles for cmdline extractor
  • Added new articles: Trustwave/LevelBlue, Fortinet Darkcloud, Recorded Future, Elastic RONINGLOADER
  • Updated expected counts based on actual extraction results
  • Fixed Trustwave→LevelBlue URL redirect issue (article ID 1474)
  • Expected Counts (2026-01-02): Updated Recorded Future article expected count from 0 to 2 (actual: 6 found, but 2 expected after review)
  • Test Architecture (2026-01-02): All "Test with Custom ArticleID" buttons now dispatch to worker tasks
  • Maintains separation: web server handles requests, worker handles LLM processing
  • Test tasks load prompts from database (same source as UI)
  • Consistent with production workflow architecture
  • Prompt Loading (2026-01-02): Test tasks now use active prompts from database instead of files
  • Matches exactly what's shown in UI
  • All test buttons use same prompt source as production
  • Eval Articles (2025-12-27): Removed BleepingComputer article from cmdline extractor eval set (reduced from 9 to 8 articles)
  • Collapsible Panels Refactor (2025-12-16): Refactored all collapsible panels to use global initCollapsiblePanels() system in base.html
  • Entire panel header is now clickable (not just caret icon)
  • Added keyboard support (Enter/Space) and proper ARIA attributes for accessibility
  • Updated panels: articles.html (filters), workflow.html (12 panels), article_detail.html (keyword matches), diags.html (job history), scraper_metrics.html (source performance), hunt_metrics.html (keyword analysis)

Fixed

  • Top_P Parameter Handling (2026-01-07): Fixed Top_P values not being passed correctly to LMStudio
  • Added explicit float conversion for Top_P values from JSONB config (handles string/number types)
  • Fixed test functions to read and pass Top_P from config
  • Fixed Rank Agent test to override top_p_rank after LLMService initialization
  • Ensured Top_P is always sent as float to LMStudio API payload
  • Fixed Top_P collection in collectAllAgentConfigs() and form submit handlers
  • Test Endpoint Refactoring (2026-01-02): Moved test agent endpoints to Celery worker tasks for proper separation of concerns
  • Test tasks now run in cti_workflow_worker instead of cti_web container
  • Added async task status polling endpoint /api/workflow/config/test-status/{task_id}
  • UI now polls for test results instead of blocking
  • Prompt Testing Script (2026-01-02): Added flexible script for testing prompts against LMStudio models
  • scripts/test_prompt_with_models.py: Test prompts with wildcard model selection
  • Supports single/multiple articles, all eval articles, multiple models
  • Tab-completable model selection with wildcard support
  • Results saved to JSON file
  • Shared Prompt Parsing (2026-01-02): Added parse_prompt_from_config() helper with JSON repair logic
  • Handles malformed JSON from UI edits
  • Used by all test tasks for consistency
  • JSON Parsing (2026-01-02): Added repair logic for malformed JSON in database prompts
  • Handles unquoted string values in user_template field
  • Provides clear error messages when repair fails
  • Eval Workflow Boolean Handling (2025-12-27): Fixed skip flags (skip_os_detection, skip_rank_agent, skip_sigma_generation) to handle both boolean and string "true"/"false" values from JSONB config_snapshot
  • Eval Workflow Execution (2025-12-27): Fixed eval workflows not skipping OS detection due to string boolean values in config_snapshot
  • Config Snapshot Parsing (2025-12-27): Added JSONB parsing fallback for config_snapshot when it's not already a dict
  • Source Configuration Fixes (2025-12-26): Resolved RSS and web scraping issues for multiple CTI sources
  • Sekoia.io: Switched to web scraping with proper article discovery
  • VMRay Blog: RSS URL corrected, quality filters adjusted
  • Splunk Security Blog: Web scraping configuration updated
  • Assetnote Research: Switched from broken RSS to web scraping
  • CrowdStrike Intelligence Blog: Web scraping selectors improved
  • Corelight Bright Ideas Blog: Atlas framework selectors added
  • Group-IB Threat Intelligence: RSS URL corrected
  • Red Canary Blog: RSS quality filters optimized
  • RSS Parser Enhancements (2025-12-26): Improved quality filtering for RSS-only sources with configurable word/content limits
  • Dashboard Metrics (2025-12-26): Excluded manual source from failing sources metrics to show accurate CTI source health
  • API Improvements (2025-12-26): Failing sources API now filters out system-generated manual source
  • Eval System Fixes (2025-12-26): Fixed config merge to preserve nested dicts (agent_models, agent_prompts, qa_enabled) when merging config_snapshot
  • Eval Record Updates (2025-12-26): Fixed eval records not updating when workflow execution status is already 'completed'
  • API Endpoint Bug (2025-12-26): Fixed indentation bug in subagent-eval-results endpoint that caused incorrect result filtering
  • Model provider dropdowns (2025-12-18): In workflow configuration now respect Settings selection
  • Deselected providers (2025-12-18): In AL/ML Assistant Configuration no longer appear in agent workflow config page
  • Provider options (2025-12-18): Are dynamically filtered based on WORKFLOW_*_ENABLED settings from /api/settings
  • LMStudio Context Window Commands panel (2025-12-18): Now hidden when no LMStudio providers are selected in workflow config
  • LMStudio Context Window Commands panel visibility (2025-12-18): Now checks for actual model selection (not just provider)
  • Model dropdowns (2025-12-18): Now only show models from selected provider (LMStudio dropdowns only contain LMStudio models)
  • Model fields (2025-12-18): Are cleared when provider changes to prevent cross-provider model selection
  • Sub-agent and QA agent model dropdowns (2025-12-18): Check provider before populating
  • OS Detection fallback (2025-12-18): Now persists correctly (only saves when toggle is checked)
  • LMStudio model selection (2025-12-18): Fixed being cleared unnecessarily when provider dropdowns refresh
  • Selected Models display (2025-12-18): Now filters by QA enabled status (Rank/Extract/SIGMA only show if QA enabled)
  • LangFuse Session Tracking: Fixed workflow debug links to properly associate traces with sessions in LangFuse
  • Corrected trace ID storage: now uses 32-character trace_id instead of 16-character span id
  • Added explicit span.update_trace(session_id=...) call required for LangFuse 3.x OpenTelemetry integration
  • Session pages now properly display all workflow traces grouped by execution
  • Debug buttons now link directly to session view: sessions/workflow_exec_{execution_id}
  • Added comprehensive LangFuse debugging documentation to DEBUGGING_TOOLS_GUIDE.md and WORKFLOW_DATA_FLOW.md
  • Articles Page Dark Mode: Darkened filter panel, dropdowns, article cards, and button bar using CSS variables (--color-bg-card, --color-bg-panel)

Changed

  • Fixed Navigation Bar: Top navigation bar is now fixed/sticky and remains visible when scrolling down pages.

  • Complete Icon System Redesign: Replaced all emoji icons with custom SVG icons matching a cohesive design system

  • Brand Logo: H monogram with shield outline and crosshair elements (38px in nav, deep navy background #1a1a2e)
  • Navigation Icons: Created 7 custom icons (Articles, Sources, Analytics, MLOps, Diags, Agents, Settings) at 23px
  • Page Title Icons: All destination pages now use matching 63px icons in page headers
  • Design System: Deep navy backgrounds with purple/white theme (#8B5CF6, #A78BFA, #C4B5FD) for consistent brand identity
  • Icon Concepts: Articles (document with text lines), Sources (hub with connected nodes), Analytics (bar chart with trend), MLOps (neural network), Diags (hexagonal diagnostic frame), Agents (central hub with nodes), Settings (gear with 8 teeth)

Fixed

  • Annotation Usage Immutability: Enforced usage field immutability in AsyncDatabaseManager.update_annotation() to prevent modification of annotation usage (train/eval/gold) after creation. Service layer now raises ValueError if usage change is attempted, which is converted to 422 HTTP response at API layer.

Changed

  • Smoke runner now excludes ui/slow markers by default and enforces subprocess timeouts without relying on pytest-timeout.
  • Pytest config registers all UI markers and uses function-scoped asyncio loops to prevent teardown loop reuse errors; warnings from pydantic v2 deprecations are silenced in tests.
  • Langfuse trace handling caches session trace IDs and persists them into workflow execution logs for direct trace URLs; Langfuse trace links now include session filters plus search metadata.
  • Langfuse spans now prefer span.id when present, log missing trace IDs, and log persisted trace IDs for executions.
  • SQLAlchemy models now import declarative_base from sqlalchemy.orm.
  • Misc workflow UI and test updates, including model provider/debug link fixes.
  • Workflow debug info now returns direct Langfuse trace URLs with host/project metadata to avoid search-only links.
  • Workflow LLM provider enable flags now default to enabled when a key is present, with env fallbacks.
  • Documentation true-up: README and docs index now match current compose services, ports (8001/2024/8888), and LMStudio defaults; Getting Started and Docker Architecture aligned to pgvector Postgres + containerized CLI; port guide updated and run_cli.sh now passes args directly to python -m src.cli.main.
  • Settings: hid an unsupported workflow provider and API key UI until support is implemented.

Fixed

  • LMStudio Model Display & RAG Results: LMStudio responses now report the actual model returned by the API (e.g., DeepSeek variants) and Sigma similarity search uses typed vector bindings to avoid asyncpg syntax errors; vector indexes rebuilt to allow embedding writes and restore RAG retrieval.
  • Chunk Debug Gaps: ContentFilter.chunk_content now advances the next chunk start based on the previous chunk end minus the configured overlap (and always moves forward when a chunk is shorter than the overlap), so sentence-boundary trimming cannot skip characters and the chunk debugger never shows gaps.
  • Workflow debug modal normalizes Langfuse links to direct /traces/{id} targets, preventing ?search regressions.

Security

  • API Key Exposure: Removed scripts containing hardcoded API keys from version control
  • scripts/eval_observables_count_multiple_models.py and scripts/get_full_extract_results.py
  • Added to .gitignore to prevent future commits
  • Scripts remain on disk for local development but are no longer tracked

Changed

  • Navigation Bar Alignment: Centered primary navigation links with balanced spacing while retaining the brand anchor.
  • Dashboard Header: Removed the “Huntable CTI Studio & Workbench” title for a leaner landing header.
  • Articles Page Filters: Filters panel is now collapsible with state persistence; removed classification filter to streamline search inputs.
  • Articles Page UI Simplification: Removed classification filter, bulk classification actions, and classification display badges to streamline article management.
  • Article Detail Page UI Cleanup: Consolidated action buttons layout and removed observables help modal for streamlined interface.
  • Sources Page UI Cleanup: Removed quality metrics display sections (total articles, rejected/chosen/unclassified counts, average hunt scores) to simplify source cards.
  • Settings Page: Removed Ollama model references (llama3.2:1b, tinyllama:1.1b) from recommended models list
  • Scraper Metrics Page: Removed "Article Ingestion Analytics" section to streamline the interface
  • Workflow Executions Retry UI: Default retry action remains async and is labeled “Retry”; synchronous “Retry (Wait)” is now hidden unless Debug mode is enabled on the Executions tab.

  • Settings Page UI Cleanup: Moved Langfuse Configuration into Agentic Workflow Configuration panel, removed API Configuration panel, and removed SIGMA Rule Configuration panel

  • Workflow Configuration Page: Removed all recommendation statements from help modals
  • Agent Workflow Pages: Removed all "🧪 Test with Article 2155" buttons and all recommendation statements

Fixed

  • Backup System Critical Bug: Fixed automated system backup failures that were creating empty backup files
  • Root cause: Hardcoded /app/backups path didn't exist in containers
  • Solution: Made backup directory configurable and added proper Docker exec calls
  • Result: System backups now create valid database backups with actual data
  • Sources Page Conflicting Metrics: Fixed sources showing "0 articles collected" while displaying quality metrics
  • Root cause: Article counting query filtered archived == false but articles had archived = NULL
  • Solution: Updated query to count articles where archived IS NULL OR archived = false
  • Result: Sources now display accurate article counts matching their quality metrics
  • Database Restore Functionality: Fixed restore script to work with Docker containers
  • Added proper environment variable passing and host specifications
  • Restore operations now work correctly with containerized database

Added

  • Provider Model Catalog Service: Added config/provider_model_catalog.json, src/services/provider_model_catalog.py, and scripts/maintenance/update_provider_model_catalogs.py to centrally manage OpenAI/Anthropic curated model lists with both CLI and API accessors.
  • UI Tests for Dashboard Functionality: Added comprehensive UI tests for dashboard features
  • test_article_volume_charts_display: Verifies Article Volume section displays daily and hourly charts with proper canvas dimensions
  • test_high_score_articles_section_display: Tests High-Score Articles section shows 10 cards with proper navigation links
  • test_copy_urls_button_functionality: Validates Copy URLs button copies article URLs to clipboard with success notifications
  • test_run_health_checks_navigation_and_execution: Tests Run Health Checks button navigation and automatic/manual check execution
  • test_agents_navigation_to_workflow_page: Verifies Agents button navigates to workflow/AI assistant page
  • test_article_ai_assistant_button_functionality: Tests AL/ML Assistant button on article pages opens modal correctly
  • OS Detection Agent OS Selection: Added OS selection checkboxes to OS Detection Agent configuration
  • Options: Windows, Linux, MacOS, Network, Other, All
  • Windows enabled by default; other options disabled (stub implementation)
  • Selections stored in agent_models.OSDetectionAgent_selected_os array
  • Auto-saves when OS selection changes
  • QA Max Retries Configuration: Added qa_max_retries field to workflow configuration
  • Configurable maximum QA retry attempts (1-20, default: 5)
  • Added database migration script scripts/migrate_add_qa_max_retries.sh
  • UI field in QA Settings panel on workflow config page
  • PDF Upload Manual Source: Added automatic creation of manual source for PDF uploads
  • Previously failed with "Manual source not found in database" error
  • Now creates manual source on-demand if it doesn't exist
  • Added scripts/ensure_manual_source.py utility script

Changed

  • Provider Test Buttons Refresh Models: Settings “Test API Key” buttons now call provider model listings (OpenAI, Anthropic), persist refreshed catalogs, and update Workflow dropdowns instantly via /api/provider-model-catalog with local caching.
  • Merged Health Checks and Diagnostics Pages: Combined /health-checks and /diags into single comprehensive diagnostics page
  • New page at /diags includes all job monitoring, health checks, and ingestion analytics
  • Removed redundant /health-checks page and route
  • Updated dashboard navigation to use merged diagnostics page

Removed

  • Workflow Executions Visualization Panel: Removed the LangGraph state machine panel and toggle from the Executions tab (UI and tests).
  • Evaluations UI: Removed the Evaluations navigation entry and disabled evaluation UI routes.
  • Complete Ollama Integration Removal: Removed all Ollama code, references, and documentation
  • Removed Ollama Docker service and configuration
  • Removed Ollama API endpoints and health checks
  • Removed Ollama UI options from settings and article detail pages
  • Removed Ollama methods from LLM generation service
  • Removed Ollama test files and test references
  • Cleaned up all Ollama environment variables and configurations
  • Updated available AI models to exclude Ollama options

Fixed

  • Web Server Import Error: Fixed ImportError preventing web application startup
  • Removed non-existent test_scrape module from route imports in src/web/routes/__init__.py
  • Removed test_scrape.router registration that was causing circular import error
  • Web container now starts successfully and serves requests on port 8001
  • Database Migration: Fixed missing qa_max_retries column in agentic_workflow_config table
  • Created migration to add column with default value of 5
  • Resolved SQL errors when querying workflow configuration
  • Test Infrastructure Cleanup: Added skip decorators to tests requiring separate infrastructure
  • Skipped external API integration tests (test_ai_real_api_integration.py) that make real calls to OpenAI/Anthropic/Ollama
  • Skipped workflow execution tests (workflow_executions.spec.ts) requiring Celery workers
  • Skipped workflow save button tests (workflow_save_button.spec.ts) requiring isolated config environment
  • Prevents production data modification and external API costs in single-instance setup
  • Test Runner Help Documentation: Updated run_tests.py help output to clearly indicate test safety for single-instance environments
  • Added "SAFE for single-instance" labels to tests that can run without external infrastructure
  • Added "LIMITED" labels to tests with some functionality skipped due to infrastructure requirements
  • Updated examples to recommend safe test types for single-instance usage
  • Transparent Docker Auto-Selection: Added automatic Docker/localhost context selection based on test requirements
  • New --context auto option (now default) automatically chooses execution environment
  • UI/API/integration tests automatically run in Docker containers
  • Unit/smoke tests run locally when dependencies are available
  • Eliminates need for users to manually specify --docker for different test types

Added

  • File Organization Structure: Implemented standardized file organization for temporary scripts, reports, and utilities
  • Created directory structure: utils/temp/ (temporary scripts), scripts/ (reusable utilities), outputs/ (reports/exports/benchmarks)
  • Moved 84 temporary scripts from root level to appropriate directories
  • Updated .gitignore to properly handle temporary files while tracking utils/temp/ scripts
  • Added file organization guidelines to CONTRIBUTING.md and AGENTS.md
  • Organized scripts by purpose: scripts/maintenance/ (fix scripts), scripts/testing/, scripts/analysis/

Fixed

  • Documentation: LangGraph Debug Button Behavior: Corrected documentation to accurately reflect debug button functionality
  • Fixed docs/LANGGRAPH_INTEGRATION.md and docs/LANGGRAPH_QUICKSTART.md to state that debug button opens LangFuse traces (post-execution viewing), not Agent Chat UI
  • Clarified that step-into debugging requires manual setup with LangFuse session view or Local Agent Chat UI
  • Updated API response example to show actual LangFuse trace URL format
  • Added notes about trace availability (only exists if execution ran with LangFuse tracing enabled)
  • Browser Extension Manual Source Creation: Fixed duplicate key violations when creating manual source from browser extension
  • Changed manual source lookup from name-based (LIKE '%manual%') to identifier-based (identifier='manual') to match unique constraint
  • Implemented atomic INSERT ... ON CONFLICT DO NOTHING pattern using PostgreSQL's native upsert for race condition handling
  • Added proper IntegrityError handling for both identifier conflicts and primary key sequence issues
  • Fixed missing required database fields (consecutive_failures, total_articles, average_response_time, created_at, updated_at) in manual source creation
  • Added retry logic with fresh session queries to handle concurrent requests
  • Applied same fix to PDF upload endpoint for consistency
  • Fixed PostgreSQL sequence sync issue that was causing primary key conflicts

Added

  • LMStudio Context Window Command Generator: Added button on workflow config page to generate terminal commands for setting context windows
  • Collects all selected LLM models from workflow configuration
  • Excludes BERT and text encoder models (embedding models)
  • Generates commands using scripts/set_lmstudio_context.sh script
  • Includes unload command to clear existing models before loading
  • Commands displayed in modal with copy-to-clipboard functionality
  • Configurable context length (default: 16384 tokens)

Fixed

  • Disabled Sub-Agents Execution: Fixed issue where disabled sub-agents were still being executed
  • Added disabled agents check to langgraph_server.py workflow execution path
  • Fixed duplicate try block that was preventing disabled check from working
  • Disabled agents now properly skipped with empty results instead of executing
  • Added comprehensive logging to track disabled agent configuration reading
  • Workflow Config UI Improvements:
  • Made all agent prompts collapsible and collapsed by default on workflow config page
  • Fixed model display mismatch where dropdown selection didn't match prompt display
  • Prompts now read current model from dropdown instead of cached config value
  • Model updates immediately refresh prompt displays
  • LMStudio Context Script: Updated set_lmstudio_context.sh to only unload specific model being loaded
  • Prevents unloading all models when loading multiple models sequentially
  • Checks if model is already loaded and unloads only that specific instance
  • Handles model identifiers with suffixes (e.g., :2, :3) correctly
  • Allows multiple models to remain loaded simultaneously
  • QA Agent Toggle Logic: QA Agents can no longer be enabled when their corresponding subagent is disabled
  • Added updateQAStateForSubagent() function to sync QA checkbox state with subagent enabled status
  • QA checkboxes are automatically disabled and unchecked when subagent is disabled
  • Visual feedback added with opacity and cursor styling for disabled QA toggles
  • Logic applies on page load, config sync, and manual toggle changes

Added

  • Comprehensive UI Test Coverage: Added 17 new comprehensive UI test files covering all major pages (544 tests total)
  • test_workflow_comprehensive_ui.py: 89 tests for workflow configuration, executions, and queue management
  • test_articles_advanced_ui.py: 74 tests for advanced search, filtering, sorting, pagination, bulk actions, and classification modal
  • test_article_detail_advanced_ui.py: 21 tests for article detail page features
  • test_analytics_comprehensive_ui.py: 47 tests for analytics pages (main, scraper metrics, hunt metrics)
  • test_sources_comprehensive_ui.py: 39 tests for source management, configuration, and adhoc scraping
  • test_settings_comprehensive_ui.py: 39 tests for backup, AI/ML config, API config, and data export
  • test_chat_comprehensive_ui.py: 57 tests for RAG chat interface, message display, article/rule results, YAML modal
  • test_dashboard_comprehensive_ui.py: 37 tests for dashboard widgets, charts, and quick actions
  • test_pdf_upload_advanced_ui.py: 19 tests for PDF upload functionality
  • test_health_checks_advanced_ui.py: 20 tests for health check monitoring
  • test_diagnostics_advanced_ui.py: 21 tests for system diagnostics page
  • test_jobs_advanced_ui.py: 19 tests for job monitoring
  • test_cross_page_navigation_ui.py: 15 tests for navigation and deep linking
  • test_error_handling_comprehensive_ui.py: 16 tests for error scenarios
  • test_accessibility_comprehensive_ui.py: 22 tests for keyboard navigation, ARIA, and screen reader compatibility
  • test_performance_comprehensive_ui.py: 18 tests for page load and rendering performance
  • test_mobile_responsiveness_ui.py: 30 tests for mobile layout and touch interactions
  • All tests integrated into pytest suite and run via run_tests.py ui wrapper
  • Tests follow existing patterns and use Playwright with pytest fixtures
  • Subagent Evaluation Pages: Added dedicated evaluation pages for ExtractAgent subagents
  • Created subagent evaluation pages at /evaluations/ExtractAgent/{subagent_name} for CmdlineExtract, SigExtract, EventCodeExtract, ProcTreeExtract, and RegExtract
  • Added subagent links section on ExtractAgent evaluation page with cards for each subagent
  • Subagent pages show purpose, evaluation history, and link back to parent agent
  • SIGMA Test Pages Navigation: Moved SIGMA A/B Test and SIGMA Similarity Test links from main navigation to Evaluations page
  • Links now appear as cards on the Evaluations page alongside agent evaluation cards
  • Removed from main navigation bar for cleaner interface
  • Generator Error Handling: Enhanced Langfuse trace cleanup to suppress generator protocol errors
  • Generator errors during Langfuse cleanup no longer fail workflows
  • Added comprehensive error suppression in trace context managers
  • Generator errors are logged as warnings but don't propagate as workflow failures
  • LMStudio Error Message Formatting: Improved error message display for context length issues
  • Context length errors now show formatted messages instead of raw JSON
  • Error detection distinguishes between genuine "busy" conditions and other errors
  • Better user experience with actionable error messages
  • Playwright Test for Error Messages: Added test to verify error message formatting
  • Tests verify formatted error messages are displayed correctly
  • Ensures "busy" errors don't appear for context length issues

  • Workflow Executions observability toggle: Added toggle for showing extract observable counts in the executions table

  • Execution API now exposes an extraction_counts map populated from extraction_result.subresults or the merged observable list, covering Cmdline, ProcTree, Reg, Signature, and EventID sub-agents
  • The toggle inserts CmdLine#/ProcTree#/Reg#/Signature#/EventID# columns immediately after the ranking score so teams can quickly see which telemetry families produced observables

Fixed

  • ML Comparison Chart Enhancements: Added zoom and scroll controls for evaluation metrics chart
  • Horizontal scrolling to view all model version history
  • Zoom in/out controls with preset levels (5, 10, 15, 20, 30, 50, all versions)
  • Fixed legend and chart title remain centered during scroll/zoom operations
  • Default view shows latest models with ability to scroll left for history
  • Enhanced Backup/Restore Verification: Comprehensive verification for critical configuration data
  • Backup metadata now tracks ML model versions, agent configs, and source configurations
  • Restore verification checks all critical tables (ml_model_versions, agentic_workflow_config, agent_prompt_versions, app_settings, sources, source_checks)
  • Verification compares restored counts against backup metadata for data integrity confirmation
  • Added documentation for source configuration precedence (database vs. YAML)
  • Source Configuration Precedence: Database values now take precedence over sources.yaml after initial setup
  • Application only syncs from sources.yaml for brand new builds (< 5 sources)
  • Restored database configurations are preserved and not overwritten by sources.yaml on container rebuilds
  • Added DISABLE_SOURCE_AUTO_SYNC environment variable to disable YAML sync entirely
  • Database is authoritative source for source settings (active status, lookback_days, check_frequency) after initial setup

Fixed

  • Generator Errors Failing Workflows: Fixed "generator didn't stop after throw()" errors causing workflow failures
  • Generator errors from Langfuse cleanup are now suppressed and logged as warnings
  • Workflows complete successfully even when Langfuse trace cleanup encounters generator protocol issues
  • Fixed error handling in ranking node to detect and suppress generator errors
  • LMStudio "Busy" False Positives: Fixed incorrect "busy" error detection for context length issues
  • Error detection now distinguishes between genuine connection failures and context length errors
  • Context length errors show appropriate error messages instead of misleading "busy" messages
  • Improved error detection logic to check for chained exceptions and prioritize original errors
  • Context Length Configuration: Fixed context length mismatch causing 400 errors
  • Updated LMStudio context length override from 16384 to 4096 to match actual configured value
  • Content truncation now works correctly with actual context length
  • Ranking tests now succeed without context length errors
  • Test Article IDs: Updated all test buttons to use article 2155 instead of 1427
  • RankAgent test button updated
  • All sub-agent test buttons (CmdlineExtract, SigExtract, EventCodeExtract, ProcTreeExtract, RegExtract) updated
  • Playwright test updated to use article 2155
  • OS Detection Display Logic: Fixed workflow continuation display when OS is detected as Windows
  • UI now correctly shows "Continue" when Windows is detected even if initial detection was "Unknown"
  • Fixed OS detection threshold logic to use 50% similarity with clear winner detection
  • Workflow state correctly reflects continuation decision
  • Redis Validation Blocking Smoke Tests: Made Redis validation non-blocking for smoke tests
  • Smoke tests now execute even if Redis connection fails
  • Redis validation failures logged as warnings but don't block test execution
  • Fixed smoke test path to use tests/smoke/ directory instead of marker-based discovery
  • IndentationError in ContentFilter: Fixed critical syntax error preventing web service startup
  • Corrected indentation of sklearn imports in try/except block
  • Web container now starts successfully and serves requests
  • Workflow Configuration Section Order: Reordered configuration panels to match workflow execution
  • OS Detection Agent panel now appears before Junk Filter panel
  • UI order now matches actual workflow step sequence (Step 0: OS Detection → Step 1: Junk Filter)
  • Workflow Order in Configuration UI: Fixed workflow overview to show correct 7-step order
  • Added OS Detection as Step 0 (was missing)
  • Renumbered all subsequent steps: Junk Filter (1), LLM Ranking (2), Extract Agent (3), Generate SIGMA (4), Similarity Search (5), Queue (6)
  • Updated step count from 6 to 7 steps in description
  • Workflow execution order matches UI display
  • Duplicate Placeholder Options in Model Selectors: Fixed duplicate placeholder options in all agent model selector dropdowns
  • Removed hardcoded placeholder options that conflicted with buildOptions() function
  • Fixed Rank Agent model selector duplicate placeholder
  • Fixed all 6 QA model selectors (Rank QA, CmdLine QA, Sig QA, EventCode QA, ProcTree QA, Reg QA)
  • All dropdowns now display single placeholder option correctly
  • Duplicate Model Entries in Dropdowns: Fixed duplicate model entries (e.g., "Mistral7" and "Mistral7:2") in all model selector dropdowns
  • Added normalization to remove numbered suffixes (:2, :3, etc.) from model IDs
  • Deduplication now prefers base model names (without suffix) over numbered instances
  • Applied to all dropdowns: Rank Agent, Extract Agent, Sigma Agent, OS Detection fallback, all sub-agents, and all QA model selectors
  • LLMService Context Length Detection: Improved context length handling to trust detected values when reasonable
  • Now uses model-specific context limits based on model size (1B: 2048, 3B: 4096, 7B/8B: 8192, 13B/14B: 16384, 32B: 32768)
  • Trusts detected context when between 4096 and model max (with 10% safety margin)
  • Only uses very conservative caps when detection is unreliable or too small
  • Respects environment variable overrides completely
  • Fixes issue where models loaded with 16384 context were only using 1536 tokens
  • Added Playwright tests to verify no duplicate models appear in dropdowns
  • LLMService Context Length NameError: Fixed NameError: name 'MAX_SAFE_CONTEXT_NORMAL' is not defined in Rank Agent testing
  • Removed all references to MAX_SAFE_CONTEXT_NORMAL and MAX_SAFE_CONTEXT_REASONING constants
  • Updated rank_article, extract_behaviors, and extract_observables methods to use consistent model-specific context detection
  • Fixed logger statements to use new context detection variables
  • All methods now use the same improved context length logic

Added

  • Help Circles for All Agents: Contextual help buttons with detailed information for all agent and sub-agent model selectors
  • Help circles added to Rank Agent, Extract Agent, SIGMA Agent, and OS Detection Agent model selectors
  • Help circles added to all sub-agents: CmdlineExtract, SigExtract, EventCodeExtract, ProcTreeExtract, RegExtract
  • Comprehensive help text explaining each agent's purpose, configuration options, and recommendations
  • Consistent help UI pattern matching Junk Filter Threshold help button
  • OS Detection Fallback Model Toggle: Added toggle button to enable/disable fallback model selection for OS Detection Agent
  • Toggle switch positioned next to "Fallback Model (Optional)" label
  • When disabled, fallback model dropdown is disabled and value is cleared
  • When enabled, user can select a custom fallback LLM model
  • Toggle state persists with workflow configuration
  • Matches existing toggle UI pattern used for QA agents
  • Workflow Model Loader: Added utils/load_workflow_models.py utility script
  • Reads active workflow configuration from database
  • Loads all configured models with 16384 context tokens
  • Verifies each model can be loaded before workflow execution
  • Helps prevent context length errors in production workflows
  • Workflow Context Fix Documentation: Added docs/WORKFLOW_CONTEXT_FIX.md
  • Troubleshooting guide for context length errors
  • Instructions for loading models with proper context
  • Prevention strategies and troubleshooting tips

Changed

  • Test Wrapper Configuration: Updated test runner to exclude infrastructure and production data tests by default
  • run_tests.py now automatically excludes infrastructure, prod_data, and production_data markers
  • conftest.py auto-skips infrastructure and production data tests during collection
  • Added prod_data and production_data markers to pytest.ini
  • Tests requiring test infrastructure or production data access are now skipped automatically
  • Workflow Configuration UI Reorganization: Improved organization and accessibility of workflow configuration
  • Converted "Other Thresholds" section to collapsible "Junk Filter" dropdown panel at top of configuration
  • Moved Similarity Threshold from "Other Thresholds" to SIGMA Agent panel (under SIGMA Agent model selector)
  • Moved QA model selectors from Extract Agent panel to their respective sub-agent panels:
    • CmdLine QA Model → CmdlineExtract sub-agent panel
    • Sig QA Model → SigExtract sub-agent panel
    • EventCode QA Model → EventCodeExtract sub-agent panel
    • ProcTree QA Model → ProcTreeExtract sub-agent panel
    • Reg QA Model → RegExtract sub-agent panel
  • Improved UI consistency with agent panel collapsible pattern
  • Source health checks: check_all_sources now uses the hierarchical ContentFetcher (RSS → modern scraping → legacy) with safer bookkeeping for articles/errors, improving health metrics and logging across all sources.
  • Source scraping configs: Updated selectors and discovery for Assetnote Research (verified RSS URL and Webflow containers), Picus Security Blog (HubSpot body selectors), and Splunk Security Blog (AEM containers) to improve extraction coverage.

Current Status & Next Steps

  • Assetnote, Picus, and Splunk remain failing due to JS-rendered/anti-bot content; selectors are in place but static fetch still returns empty. Next steps: add headless/JS-capable fetch (Playwright) or alternative article API path; retest and adjust min_content_length as needed.
  • Group-IB, NCSC UK, MSRC: still blocked (403/SPA/placeholder feeds). Next steps: investigate API/back-end endpoints or headless rendering; consider temporary deactivation if access remains blocked.

Removed

  • OS Detection QA Agent: Removed QA validation system for OS Detection Agent
  • Removed QA retry loop and evaluation logic from OS Detection workflow node
  • Removed OS Detection QA toggle, model selector, and badge from UI
  • Removed JavaScript references to OS Detection QA functionality
  • OS Detection now runs without QA validation (single-pass detection)
  • Description Field: Removed optional description textarea from workflow configuration form
  • Removed description field from workflow.html and workflow_config.html
  • Removed JavaScript references to description field
  • Backend continues to use default description values when not provided

Fixed

  • QA Agent Indentation Error: Fixed syntax error in qa_agent_service.py
  • Corrected indentation of code block inside with trace_llm_call() statement
  • Resolved "expected an indented block after 'with' statement" error

Improved

  • LMStudio Busy Error Handling: Enhanced error messages and user experience when LMStudio is busy or unavailable
  • Updated LangFuse client to provide informative error messages when generator errors occur (often indicates LMStudio busy)
  • Enhanced test agent endpoints (test-subagent, test-rankagent) to detect LMStudio busy conditions
  • Added user-friendly error messages with retry options in test agent modal
  • Frontend now shows "Wait and Retry" button when LMStudio is detected as busy
  • Improved error detection for timeout, connection, and overload scenarios

Added

  • Workflow Agent Config Subpages Test: Playwright test suite to verify workflow agent configuration subpages remain visible
  • TypeScript Playwright test (tests/playwright/workflow_tabs.spec.ts) with 10 test cases
  • Python pytest wrapper (tests/ui/test_workflow_tabs_ui.py) integrated into UI test suite
  • Verifies all three subpages (Configuration, Executions, SIGMA Queue) are accessible and functional
  • Tests tab navigation, hash URL routing, and content visibility
  • Prevents regression where tabs disappear after UI changes
  • Modal Interactions UI Test: Comprehensive test suite for modal behavior (tests/ui/test_modal_interactions_ui.py)
  • 20 test cases covering Escape key closing, click-outside closing, and Cmd/Ctrl+Enter submission
  • Tests all modals: result, source config, execution, trigger workflow, rule, classification, custom prompt, help
  • Ensures consistent modal UX across the application
  • Comprehensive Test Documentation Update: Complete audit and update of test inventory
  • Updated tests/TEST_INDEX.md with all 100+ test files across 9 categories
  • Documented 28 UI test files (383+ tests), 12 API test files (123+ tests), 19 E2E test files (120+ tests), 25 integration test files (200+ tests)
  • Added CLI tests (3 files), Utils tests (4 files), Workflows tests (1 file), and Smoke tests (2 files)
  • Updated test statistics: 1200+ total tests, 900+ active tests (75%+), 205 skipped (~17%), 32 failing (~3%)
  • Updated tests/TESTING.md and tests/README.md to reflect comprehensive test coverage
  • Agentic Workflow Test Coverage: Comprehensive integration tests for the agentic workflow and LangGraph server
  • LangGraph Server Tests: tests/workflows/test_langgraph_server.py covering chat logic, input parsing, and node transitions
  • Comprehensive Integration Test: tests/integration/test_agentic_workflow_comprehensive.py simulating full "happy path" run with Article 1427
  • Full Workflow Simulation: Verifies end-to-end flow: Chat -> ID Parse -> OS Detect -> Junk Filter -> Rank -> Extract -> Sigma -> Queue
  • State Verification: Ensures correct state transitions and database updates at each step
  • OS Detection Agent: Operating system detection for threat intelligence articles
  • Embedding-based detection using CTI-BERT or SEC-BERT models
  • Configurable embedding model selection (CTI-BERT, SEC-BERT)
  • Configurable LLM fallback model for low-confidence cases
  • Integration into agentic workflow (Step 1.5, after ranking, before extraction)
  • Workflow continues only if Windows detected; otherwise gracefully terminates
  • AI/ML Assistant modal with OS detection functionality
  • Manual testing script: test_os_detection_manual.py
  • SpaCy-Based Sentence Splitting: Improved sentence boundary detection for content chunking
  • Replaced regex-based sentence splitting with SpaCy's sentencizer component
  • Better handling of abbreviations (Dr., CVE-, IOC, APT, etc.) and technical content
  • Improved chunk boundaries: 100% sentence boundary accuracy (up from 75%)
  • Eliminated mid-sentence breaks and fragmented sentences
  • Applied to ContentCleaner.extract_summary(), ContentFilter.chunk_content(), and chat sentence extraction
  • Fallback to regex if SpaCy unavailable for backward compatibility
  • SIGMA A/B Testing Interface: Interactive web UI for comparing SIGMA rule similarity search logic
  • Side-by-side rule comparison with real-time YAML validation
  • Separate embedding and LLM model selection dropdowns
  • Detailed similarity breakdown by section (Title, Description, Tags, Signature)
  • LLM reranking with explanation and model attribution
  • Semantic overlap analysis showing literal detection value matches
  • Debug view showing exact embedding text used for comparison
  • LocalStorage persistence for Rule A and Rule B textareas
  • Combined Signature segment (logsource + detection) for improved similarity matching
  • Enhanced SIGMA Similarity Search: Improved embedding-based similarity calculation
  • Removed "Title: " prefix from title embeddings to focus on semantic content
  • Combined logsource and detection into single "Signature" segment (87.4% weight)
  • Updated similarity weights: Title 4.2%, Description 4.2%, Tags 4.2%, Signature 87.4%
  • LMStudio Model Selection: Database-driven model selection for embedding and LLM operations
  • Embedding model dropdown filtered to only show embedding models
  • LLM model dropdown filtered to only show chat models
  • Model selection persists through Settings page configuration

Changed

  • OS Detection Similarity Logic: Improved decision-making for embedding-based OS detection
  • High confidence (>0.8): Prefer top OS unless gap to second is < 0.5%
  • Prevents false "multiple" classifications when one OS is clearly dominant
  • Updated SEC-BERT model name: nlpaueb/sec-bert-base (was incorrect placeholder)
  • Suppressed harmless transformers warnings about uninitialized pooler weights
  • SIGMA Title Embeddings: Removed "Title: " prefix to improve semantic similarity accuracy
  • SIGMA Section Embeddings: Combined logsource, detection_structure, and detection_fields into single "Signature" segment
  • Similarity Calculation: Updated weights to reflect combined Signature segment

Fixed

  • LLM Model Dropdown: Fixed duplicate ID conflict causing dropdown to stop working
  • Embedding Model Selection: Fixed similarity search to use selected embedding model instead of default
  • LLM Reranking Model: Fixed to use selected LLM model from dropdown instead of default

[4.0.0 "Kepler"] - 2025-11-04

Added

  • Agentic Workflow System: Complete LangGraph-based workflow orchestration for automated threat intelligence processing
  • 6-Step Automated Pipeline: Junk Filter → LLM Ranking → Extract Agent → SIGMA Generation → Similarity Search → Queue Promotion
  • LangGraph State Machine: Stateful workflow execution with conditional routing and error handling
  • Workflow Configuration: Configurable thresholds (min_hunt_score, ranking_threshold, similarity_threshold, junk_filter_threshold)
  • Execution Tracking: Complete audit trail with agentic_workflow_executions table tracking status, steps, and results
  • State Management: TypedDict-based state management with intermediate results stored at each step
  • Conditional Logic: Smart routing based on LLM ranking scores (threshold-based continue/stop)
  • LangFuse Integration: Full observability and tracing for workflow execution and LLM calls
  • Celery Integration: Asynchronous workflow execution via Celery workers
  • Workflow Trigger Service: Automated triggering for high-scoring articles
  • API Endpoints: /api/workflow/trigger, /api/workflow/executions, /api/workflow/config
  • Workflow UI: Complete web interface for monitoring executions, configuring thresholds, and triggering workflows
  • Extract Agent: Specialized agent for extracting telemetry-aware attacker behaviors and observables
  • Rank Agent: LLM-based scoring agent for SIGMA huntability assessment
  • Sigma Agent: Automated SIGMA rule generation with validation and retry logic
  • Similarity Integration: Automatic similarity matching against existing SigmaHQ rules
  • Queue Management: Automatic promotion of unique rules to review queue
  • Agent Prompt Version Control: Complete version control system for agent prompts with history tracking and rollback
  • Prompts are viewable and editable from workflow config page (/workflow#config)
  • Prompts start as read-only with Edit button to enable editing
  • Version history modal shows all previous versions with timestamps and change descriptions
  • Rollback functionality to restore any previous prompt version
  • Change descriptions optional field when saving prompt updates
  • Database table agent_prompt_versions tracks all prompt changes with workflow config version linking
  • API endpoints: /api/workflow/config/prompts/{agent_name}/versions (GET), /api/workflow/config/prompts/{agent_name}/rollback (POST)
  • Database Schema Migration: Migration script to fix agent_prompt_versions table schema alignment with SQLAlchemy model
  • Renamed columns: prompt_text → prompt, version_number → version, config_version_id → workflow_config_version
  • Added missing instructions column for ExtractAgent instructions template support
  • Updated column types and indexes to match model expectations
  • RAG (Retrieval-Augmented Generation) System: Complete conversational AI implementation
  • Multi-Provider LLM Integration: OpenAI GPT-4o, Anthropic Claude, and Ollama support
  • Conversational Context: Multi-turn conversation support with context memory
  • Synthesized Responses: LLM-generated analysis instead of raw article excerpts
  • Vector Embeddings: Sentence Transformers (all-mpnet-base-v2) for semantic similarity search
  • RAG Generation Service: src/services/llm_generation_service.py for response synthesis
  • Auto-Fallback System: Graceful degradation between LLM providers
  • RAG Chat API: POST /api/chat/rag endpoint with conversation history
  • Frontend RAG Controls: LLM provider selection and synthesis toggle
  • Professional System Prompt: Cybersecurity analyst persona for threat intelligence analysis
  • Source Attribution: All responses include relevance scores and source citations
  • RAG Documentation: Comprehensive RAG system documentation in docs/RAG_SYSTEM.md
  • Allure Reports Integration: Rich visual test analytics with pie charts, bar charts, and trend graphs
  • Dedicated Allure Container: Containerized Allure Reports server for reliable access
  • Interactive Test Dashboard: Step-by-step test visualization for debugging and analysis
  • Enhanced Test Reporting: Comprehensive test execution reports with ML/AI debugging capabilities
  • Visual Test Tracking: Professional test reporting system for development and CI/CD pipelines
  • Allure Management Script: ./manage_allure.sh for easy container management
  • Unified Testing Interface: New run_tests.py for standardized test execution
  • Docker Testing Support: Added --docker flag for containerized test execution
  • Virtual Environment Documentation: Comprehensive guide for venv-test, venv-lg, and venv-ml
  • Testing Workflow Guide: Complete documentation for different execution contexts and test categories
  • Comprehensive Test Suite: Fixed 5 high-priority test modules with 195 new passing tests
  • ContentFilter Tests: ML-based filtering, cost optimization, and quality scoring (25 tests)
  • SigmaValidator Tests: SIGMA rule validation, error handling, and batch processing (50 tests)
  • SourceManager Tests: Source configuration management and validation (35 tests)
  • ContentCleaner Tests: HTML cleaning, text processing, and metadata extraction (30 tests)
  • HTTPClient Tests: Rate limiting, async handling, and request configuration (38/39 tests)
  • Supporting Classes: FilterResult, FilterConfig, ValidationError, SigmaRule, SourceConfig, ContentExtractor, TextNormalizer, RateLimiter
  • Dependencies: Added scikit-learn and pandas for ML-based content filtering
  • Test Documentation: Updated SKIPPED_TESTS.md with current test status and progress tracking
  • Test Coverage: Dramatically improved from 27 to 222 passing tests (722% increase)
  • Test Infrastructure: Enhanced test reliability and maintainability with comprehensive supporting classes

Changed

  • Workflow Config UI: Enhanced agent prompts section with edit/view toggle and version history access
  • Prompt Update API: Now saves version history automatically on each prompt update
  • Version History Modal: Improved text readability with larger font sizes, better contrast, and enhanced formatting
  • Font size increased from text-xs to text-sm
  • Added borders and improved padding for better visual separation
  • Increased max height for better content visibility
  • Enhanced line spacing and word wrapping
  • RAG Architecture: Upgraded from template-only to full LLM synthesis
  • API Response Format: Enhanced with LLM provider and synthesis status
  • Frontend Configuration: Added LLM provider selection and synthesis controls
  • Documentation: Updated README, API endpoints, and Docker architecture docs

Fixed

  • Database Schema Mismatch: Fixed agent_prompt_versions table column names to match SQLAlchemy model
  • Version History Display: Improved readability of prompt and instructions text in version history modal
  • Database Migration: Created migration script 20250130_fix_agent_prompt_versions_schema.sql for schema alignment
  • SIGMA Generation Quality Restoration: Fixed deteriorated SIGMA rule generation that was producing malformed rules
  • Reverted uncommitted prompt simplification in src/prompts/sigma_generation.txt that removed critical guidance
  • Restored detailed SIGMA Rule Requirements and Rule Guidelines explaining separation of detection vs tags
  • Fixed LMStudio model configuration mismatch (1B → 8B model) in .env and docker-compose.yml
  • Implemented dynamic context window sizing based on model size (1B: 2.2K, 3B: 10.4K, 8B: 26.8K chars)
  • Optimized retry prompts to remove wasteful article content repetition (~500 token savings per retry)
  • Fixed issue where MITRE ATT&CK tags were incorrectly placed in detection/selection fields
  • Temperature correctly set to 0.2 for deterministic SIGMA YAML generation
  • OpenAI API Integration: Proper API key handling and error fallback
  • Conversation Context: Fixed context truncation and memory management
  • Response Quality: Improved synthesis quality with professional formatting
  • Test Suite Reliability: Fixed 5 major test modules with comprehensive supporting class implementations
  • ContentFilter Logic: Fixed ML-based filtering, cost optimization, and quality scoring algorithms
  • SigmaValidator Logic: Fixed rule validation, error handling, and batch processing
  • SourceManager Logic: Fixed source configuration management and validation error handling
  • ContentCleaner Logic: Fixed HTML cleaning, Unicode normalization, and text processing
  • HTTPClient Logic: Fixed rate limiting, async/await issues, and request configuration

Security

  • None (security hardening was completed in previous versions)

Removed

  • Redundant UI Cleanup: Removed redundant "Save Configuration" button from settings page

Technical Details

  • Database Migration: PostgreSQL migration script updates table schema while preserving existing data
  • Version Control: Each prompt update creates a new version linked to workflow config version
  • UI Improvements: Enhanced modal display with better typography and spacing
  • Embedding Model: all-mpnet-base-v2 (768-dimensional vectors)
  • Vector Storage: PostgreSQL with pgvector extension
  • Context Management: Last 4 conversation turns for LLM context
  • Response Times: 3-5 seconds (OpenAI), 4-6 seconds (Claude), 10-30 seconds (Ollama)
  • Fallback Strategy: Template → Ollama → Claude → OpenAI priority order
  • Workflow Architecture: LangGraph state machine with PostgreSQL checkpointing
  • Agent System: Extract Agent, Rank Agent, and Sigma Agent orchestrated via LangGraph
  • Observability: LangFuse integration for workflow and LLM call tracing

[3.0.0 "Copernicus"] - 2025-10-28

Added

  • SIGMA Rule Similarity Search: Advanced similarity matching between generated SIGMA rules and existing SigmaHQ rules
  • Weighted Hybrid Embeddings: Enhanced embedding strategy combining title, description, tags, logsource, and detection logic
  • Interactive Similar Rules Modal: UI modal showing similar SIGMA rules with coverage status (covered/extend/new)
  • Embed Article Button: One-click embedding generation for articles with async Celery task processing
  • Coverage Classification: Automatic classification of rule matches as "covered", "extend", or "new"
  • Article Embedding Status: Real-time tracking of article embedding status with disabled button tooltips
  • Enhanced Sigma Generation: Added MITRE ATT&CK technique extraction and tagging to SIGMA rule generation
  • PostgreSQL Vector Index: Efficient vector similarity search using pgvector extension

Changed

  • Embedding Model: Enhanced to use all-mpnet-base-v2 (768-dimensional vectors)
  • Sigma Sync Service: Updated to generate weighted hybrid embeddings for better semantic matching
  • Article Detail UI: Enhanced modal with dynamic button states based on embedding status
  • Sigma Matching Service: Improved similarity search with proper SQL parameter binding

Fixed

  • SQL Syntax Errors: Fixed mixing SQLAlchemy named parameters with psycopg2 format
  • PostgreSQL Index Size: Removed B-tree index on embedding column exceeding size limits
  • Pydantic Model: Added embedding, embedding_model, and embedded_at fields to Article model
  • NumPy Array Truth Value: Fixed ambiguous truth value when checking embedding existence
  • Article Embedding API: Proper handling of list-like embeddings with length validation

Technical Details

  • Vector Similarity: Cosine similarity with configurable threshold (default 0.7)
  • API Endpoints: /api/articles/{id}/embed, /api/sigma/matches/{article_id}, /api/generate-sigma
  • Async Processing: Celery workers for background embedding generation
  • Database: Article and Sigma rule embeddings stored in PostgreSQL with pgvector

[Pre-3.0.0 Changes]

Fixed

  • Navigation UI: Removed vertical divider borders between navigation items that were overlapping text
  • SIGMA Generation Quality Restoration: Fixed deteriorated SIGMA rule generation that was producing malformed rules
  • Reverted uncommitted prompt simplification in src/prompts/sigma_generation.txt that removed critical guidance
  • Restored detailed SIGMA Rule Requirements and Rule Guidelines explaining separation of detection vs tags
  • Fixed LMStudio model configuration mismatch (1B → 8B model) in .env and docker-compose.yml
  • Implemented dynamic context window sizing based on model size (1B: 2.2K, 3B: 10.4K, 8B: 26.8K chars)
  • Optimized retry prompts to remove wasteful article content repetition (~500 token savings per retry)
  • Fixed issue where MITRE ATT&CK tags were incorrectly placed in detection/selection fields
  • Temperature correctly set to 0.2 for deterministic SIGMA YAML generation

Added

  • Full GitHub Hygiene Audit (LG): Comprehensive security and quality audit completed
  • Dependency Security: All 269 dependencies audited with pip-audit - no CVE vulnerabilities found
  • Enhanced Security Posture: Comprehensive .gitignore, secure env configuration, proper credential handling
  • RAG (Retrieval-Augmented Generation) System: Complete conversational AI implementation
  • Multi-Provider LLM Integration: OpenAI GPT-4o, Anthropic Claude, and Ollama support
  • Conversational Context: Multi-turn conversation support with context memory
  • Synthesized Responses: LLM-generated analysis instead of raw article excerpts
  • Vector Embeddings: Sentence Transformers (all-mpnet-base-v2) for semantic similarity search
  • RAG Generation Service: src/services/llm_generation_service.py for response synthesis
  • Auto-Fallback System: Graceful degradation between LLM providers
  • RAG Chat API: POST /api/chat/rag endpoint with conversation history
  • Frontend RAG Controls: LLM provider selection and synthesis toggle
  • Professional System Prompt: Cybersecurity analyst persona for threat intelligence analysis
  • Source Attribution: All responses include relevance scores and source citations
  • RAG Documentation: Comprehensive RAG system documentation in docs/RAG_SYSTEM.md

Changed

  • RAG Architecture: Upgraded from template-only to full LLM synthesis
  • API Response Format: Enhanced with LLM provider and synthesis status
  • Frontend Configuration: Added LLM provider selection and synthesis controls
  • Documentation: Updated README, API endpoints, and Docker architecture docs

Fixed

  • OpenAI API Integration: Proper API key handling and error fallback
  • Conversation Context: Fixed context truncation and memory management
  • Response Quality: Improved synthesis quality with professional formatting

Technical Details

  • Embedding Model: all-mpnet-base-v2 (768-dimensional vectors)
  • Vector Storage: PostgreSQL with pgvector extension
  • Context Management: Last 4 conversation turns for LLM context
  • Response Times: 3-5 seconds (OpenAI), 4-6 seconds (Claude), 10-30 seconds (Ollama)
  • Fallback Strategy: Template → Ollama → Claude → OpenAI priority order

Added

  • Allure Reports Integration: Rich visual test analytics with pie charts, bar charts, and trend graphs
  • Dedicated Allure Container: Containerized Allure Reports server for reliable access
  • Interactive Test Dashboard: Step-by-step test visualization for debugging and analysis
  • Enhanced Test Reporting: Comprehensive test execution reports with ML/AI debugging capabilities
  • Visual Test Tracking: Professional test reporting system for development and CI/CD pipelines
  • Allure Management Script: ./manage_allure.sh for easy container management
  • Database-Based Training System: Refactored ML training from CSV to PostgreSQL database storage
  • Chunk Classification Feedback Table: New database table for storing user feedback on ML predictions
  • Auto-Expand Annotation UI: Automatic 1000-character text selection for optimal training data
  • Length Validation: Frontend and backend validation for 950-1050 character annotations
  • Training Data Migration: Script to migrate existing CSV feedback to database
  • Enhanced API Endpoints: Updated retraining API with database integration and proper version tracking
  • Usage Tracking: used_for_training flag to prevent duplicate data usage
  • Real-Time Feedback Count: API endpoint showing available training samples from database

Changed

  • Training Data Source: Now uses database tables instead of CSV files
  • Annotation Requirements: Enforces 950-1050 character length for training data quality
  • Retraining Workflow: Synchronous execution with complete results returned
  • Model Version Display: Shows proper version numbers and training sample counts
  • Error Handling: Improved error messages for missing training data

Fixed

  • JavaScript Infinite Loops: Fixed auto-expand functionality causing repeated errors
  • Modal Recreation Issues: Prevented infinite loops in annotation modal updates
  • API Response Format: Consistent response structure for retraining endpoints
  • Training Sample Counting: Accurate count of available feedback and annotations
  • Version Information Display: Proper model version and accuracy reporting

Technical Details

  • Database Schema: Added chunk_classification_feedback table with proper indexes
  • API Updates: Modified /api/model/retrain and /api/model/feedback-count endpoints
  • UI Improvements: Streamlined annotation modal without manual adjustment controls
  • Test Updates: Updated unit tests for database-based training system
  • Documentation: Added comprehensive database training system documentation

Added

  • Chunk Deduplication System: Database unique constraint prevents duplicate chunk storage
  • Chunk Analysis Tests: Comprehensive test suite verifying deduplication and data integrity
  • ML-Powered Content Filtering: Machine learning model for automated chunk classification with RandomForest
  • Interactive Feedback System: User feedback collection for continuous model improvement and retraining
  • Model Versioning System: Track model performance changes with database-backed version history
  • Confidence Tracking: Huntable probability tracking for consistent before/after comparisons
  • Model Comparison Interface: Visual comparison of model versions showing confidence improvements
  • Feedback Impact Analysis: Modal showing how user feedback improved model confidence on specific chunks
  • Automated Model Retraining: One-click model retraining with user feedback integration
  • ML Feedback API Endpoints: RESTful APIs for model versioning, comparison, and feedback analysis
  • Essential Regression Tests: 3 critical tests for ML feedback features to prevent breakage
  • Automated Backup System: Daily backup scheduling with cron jobs (2:00 AM daily, 3:00 AM weekly cleanup)
  • Backup Retention Policy: 7 daily + 4 weekly + 3 monthly backups with 50GB max size limit
  • Intelligent Backup Detection: API automatically detects automated backups by analyzing backup frequency
  • Backup System Integration: Fixed database backup integration using existing backup_database_v3.py
  • Backup Verification: Added comprehensive backup testing with test database restore validation
  • Security Hardening: Removed hardcoded credentials and moved to environment variables
  • Enhanced .gitignore: Added comprehensive .gitignore with Docker and security exclusions
  • Environment Variables: Updated docker-compose.yml to use environment variables for credentials
  • Backup Status API: Fixed backup status parsing to show accurate size and last backup information
  • Redundant UI Cleanup: Removed redundant "Save Configuration" button from settings page

Fixed

  • Chunk Analysis Duplicates: Fixed bug where chunks were stored twice (duplicate entries) for same article/model
  • ML Prediction Optimization: Eliminated redundant predict_huntability() calls (50% reduction from 2x to 1x per chunk)
  • List Backups API: Fixed parsing to show all numbered backups (1-10) instead of just the first one
  • Backup List Display: Corrected multi-line backup entry parsing to extract names and sizes properly
  • Database Backup: Fixed database backup to include actual data (1,187 articles, 35 sources)
  • Backup Size Display: Corrected backup size display from 29.9 GB to actual 0.03 GB
  • Volume Mount: Added scripts volume mount to Docker web container
  • API Arguments: Removed invalid --type argument from backup API calls
  • Status Parsing: Fixed backup status parsing to extract correct backup names and sizes
  • Container Permissions: Resolved Docker socket permission issues for backup operations

Security

  • Credential Removal: Removed hardcoded passwords from docker-compose.yml and backup scripts
  • Environment Variables: All sensitive configuration now uses environment variables
  • Security Scanning: Comprehensive security audit with no critical vulnerabilities found
  • Dependency Updates: All dependencies verified secure with latest versions
  • Threshold Selector: Added confidence threshold slider to Chunk Debug modal with 3 preset levels (0.5, 0.7, 0.8)
  • Real-time Threshold Updates: Implemented dynamic threshold changes with immediate API calls and UI updates
  • User Feedback System: Added feedback mechanism to Chunk Debug modal for ML model improvement
  • Model Retraining: Added retraining button to update ML model using collected user feedback
  • Enhanced Statistics Cards: Added unique IDs to statistics cards for reliable real-time updates
  • Dynamic Chunk Visualization: Updated chunk visualization to reflect threshold changes in real-time
  • Article Detail Page Readability: Enhanced article content readability with black text for maximum contrast
  • Dark Mode Support: Improved dark mode support for keyword highlights and user annotations
  • Enhanced Annotation System: Updated JavaScript annotation classes for consistent dark mode styling
  • LLM Integration: Added LLM integration with template fallback for RAG chat responses
  • Ollama Parallelism: Increased Ollama parallelism to handle multiple concurrent AI endpoints

Changed

  • Chunk Debug Modal: Enhanced with threshold selector, real-time updates, and user feedback system
  • ML Model Integration: Improved model loading and retraining capabilities with user feedback
  • Statistics Display: Fixed statistics cards to update dynamically with threshold changes
  • Chunk Visualization: Updated to reflect threshold changes in real-time
  • Keyword Highlighting: Updated highlight_keywords filter to support dark mode with proper contrast
  • User Annotations: Enhanced annotation spans with dark mode classes for better visibility
  • Content Display: Improved article content text contrast and readability across themes
  • Chat Interface: Updated UI message from "LLM disabled" to "AI-powered responses enabled"
  • Ollama Configuration: Increased OLLAMA_NUM_PARALLEL from 1 to 3 and OLLAMA_MAX_LOADED_MODELS from 1 to 2

Fixed

  • Statistics Cards: Fixed statistics cards not updating when threshold slider changes
  • Chunk Visualization: Fixed chunk visualization not reflecting threshold changes
  • Threshold Selector: Fixed null reference errors in threshold update functions
  • Readability Issues: Resolved low contrast issues in article detail page content display
  • Dark Mode Compatibility: Fixed keyword highlights and annotations to work properly in dark mode
  • Visual Consistency: Ensured consistent styling across light and dark themes
  • LLM Resource Contention: Fixed Ollama timeout issues caused by multiple AI endpoints competing for resources
  • Chat Interface Status: Removed hardcoded "LLM disabled" message and implemented proper status display

[Previous Releases]

  • SIGMA Conversation Log: Enhanced SIGMA rule generation UI to display the full back-and-forth conversation between LLM and pySigma validator
  • Shows each attempt with prompts, LLM responses, and validation results
  • Collapsible sections for long content to improve readability
  • Color-coded validation feedback (green for valid, red for invalid)
  • Visual indicators for retry attempts vs. final attempt
  • Detailed error and warning messages from pySigma validator
  • Unified Testing Interface: New run_tests.py for standardized test execution
  • Docker Testing Support: Added --docker flag for containerized test execution
  • Virtual Environment Documentation: Comprehensive guide for venv-test, venv-lg, and venv-ml
  • Testing Workflow Guide: Complete documentation for different execution contexts and test categories
  • Comprehensive Test Suite: Fixed 5 high-priority test modules with 195 new passing tests
  • ContentFilter Tests: ML-based filtering, cost optimization, and quality scoring (25 tests)
  • SigmaValidator Tests: SIGMA rule validation, error handling, and batch processing (50 tests)
  • SourceManager Tests: Source configuration management and validation (35 tests)
  • ContentCleaner Tests: HTML cleaning, text processing, and metadata extraction (30 tests)
  • HTTPClient Tests: Rate limiting, async handling, and request configuration (38/39 tests)
  • Supporting Classes: FilterResult, FilterConfig, ValidationError, SigmaRule, SourceConfig, ContentExtractor, TextNormalizer, RateLimiter
  • Dependencies: Added scikit-learn and pandas for ML-based content filtering
  • Test Documentation: Updated SKIPPED_TESTS.md with current test status and progress tracking

Removed

  • Vestigial Fields: Removed unused tier and weight fields from source management (all sources had identical default values, no logic utilized these fields)

Added (Previous)

  • Source Config Workspace: Interactive tab for editing source metadata, filtering, crawlers, and selectors with local regex testing
  • SIGMA Rule Generation: AI-powered detection rule generation from threat intelligence articles
  • pySIGMA Validation: Automatic validation of generated SIGMA rules for compliance
  • Iterative Rule Fixing: Automatic retry mechanism with error feedback (up to 3 attempts)
  • Rule Metadata Storage: Complete audit trail of generation attempts and validation results
  • Source Management Enhancements: Individual source refresh and check frequency configuration
  • CISA Analysis Reports: New threat intelligence source for CISA cybersecurity advisories
  • Group-IB Threat Intelligence: Content-filtered source for threat intelligence research
  • Non-English Word Analysis: Advanced keyword analysis for threat hunting discriminators
  • Enhanced Keyword Lists: Updated perfect and good discriminators based on analysis
  • Performance Optimizations: Faster LLM model (Phi-3 Mini) for database queries
  • GitHub Actions CI/CD pipeline with security scanning
  • Comprehensive security policy and contributing guidelines
  • Enhanced .gitignore with security-focused patterns
  • Environment variable configuration template
  • Automated dependency vulnerability scanning

Changed

  • Test Coverage: Dramatically improved from 27 to 222 passing tests (722% increase)
  • Test Infrastructure: Enhanced test reliability and maintainability with comprehensive supporting classes
  • Database Chatbot: Switched from Mistral 7B to Phi-3 Mini for faster query processing
  • Keyword Scoring: Enhanced threat hunting discriminators based on non-English word analysis
  • Source Configuration: Improved content filtering and threat intelligence focus
  • Updated all dependencies to latest secure versions
  • Removed hardcoded credentials from configuration
  • Improved code documentation and type hints
  • Enhanced security practices and guidelines

Fixed

  • Test Suite Reliability: Fixed 5 major test modules with comprehensive supporting class implementations
  • ContentFilter Logic: Fixed ML-based filtering, cost optimization, and quality scoring algorithms
  • SigmaValidator Logic: Fixed rule validation, error handling, and batch processing
  • SourceManager Logic: Fixed source configuration management and validation error handling
  • ContentCleaner Logic: Fixed HTML cleaning, Unicode normalization, and text processing
  • HTTPClient Logic: Fixed rate limiting, async/await issues, and request configuration
  • Iteration Counter Bug: Fixed off-by-one error in SIGMA rule generation attempt counting
  • SQL Query Safety: Enhanced query validation and safety checks
  • Content Filtering: Improved non-English word detection and filtering
  • Documentation Accuracy: Fixed README.md to accurately reflect disabled readability scoring feature
  • Fixed potential SQL injection vulnerabilities
  • Updated cryptography library to latest version
  • Removed debug prints and sensitive TODOs
  • Implemented proper environment variable handling

Security

  • Enhanced input validation for SIGMA rule generation
  • Improved query safety validation for database chatbot
  • Updated cryptography library to latest version
  • Removed debug prints and sensitive TODOs
  • Implemented proper environment variable handling

[2.0.0 "Tycho"] - 2025-08-28

Added

  • PostgreSQL Database: Replaced SQLite with production-grade PostgreSQL
  • Async/Await Support: Full async support with FastAPI and SQLAlchemy
  • Connection Pooling: Efficient database connection management
  • Background Tasks: Celery worker system for async operations
  • Redis Caching: High-performance caching and message queuing
  • Docker Containerization: Production-ready container orchestration
  • Content Quality Assessment: LLM-based quality scoring system
  • TTP Extraction Engine: Advanced threat technique detection
  • Modern Web Interface: HTMX-powered dynamic UI

Changed

  • Architecture: Complete rewrite with modern async architecture
  • Performance: 10x improvement in concurrent operations
  • Scalability: Horizontal scaling support
  • Security: Enhanced security features and practices
  • Monitoring: Built-in health checks and metrics

Deprecated

  • SQLite database support
  • Old CLI interface
  • Legacy web interface

Removed

  • Old architecture components
  • Deprecated APIs and endpoints
  • Legacy configuration formats

Fixed

  • Database locking issues
  • Memory leaks in long-running processes
  • Connection timeout problems
  • Rate limiting inconsistencies

Security

  • Input validation for all endpoints
  • SQL injection protection
  • XSS protection
  • Rate limiting implementation
  • CORS configuration
  • Environment variable configuration

[1.2.3] - 2024-12-10

Fixed

  • SQL injection vulnerability in search functionality
  • Memory leak in RSS parsing
  • Connection timeout issues
  • Rate limiting bypass

Security

  • Updated dependencies with security patches
  • Enhanced input validation
  • Improved error handling

[1.2.2] - 2024-11-25

Added

  • Enhanced logging system
  • Better error reporting
  • Configuration validation

Fixed

  • RSS feed parsing issues
  • Database connection problems
  • Memory usage optimization

[1.2.1] - 2024-11-15

Added

  • Content deduplication
  • Source health monitoring
  • Basic web interface

Changed

  • Improved RSS parsing accuracy
  • Better error handling
  • Enhanced logging

Fixed

  • Memory leaks in content processing
  • Database connection issues
  • File handling problems

[1.2.0] - 2024-10-30

Added

  • RSS feed support
  • Content extraction
  • Basic database storage
  • CLI interface

Changed

  • Improved content parsing
  • Better source management
  • Enhanced error handling

[1.1.0] - 2024-09-15

Added

  • Basic web scraping functionality
  • Source configuration
  • Simple data storage

Changed

  • Improved performance
  • Better error handling

[1.0.0] - 2024-08-01

Added

  • Initial release
  • Basic web scraping
  • Simple data collection
  • Basic CLI interface

Migration Guides

Upgrading from 1.x to 2.0

  1. Database Migration: Export data from SQLite and import to PostgreSQL
  2. Configuration: Update to new environment variable format
  3. Dependencies: Install new requirements
  4. Docker: Use new docker-compose configuration

Upgrading from 1.1 to 1.2

  1. Database: Backup existing data
  2. Configuration: Update RSS feed configurations
  3. Dependencies: Update to latest versions

Release Notes

Version 2.0.0

This is a major release with significant architectural improvements. The new async architecture provides better performance, scalability, and reliability. The addition of PostgreSQL, Redis, and Docker makes Huntable CTI Studio production-ready.

Version 1.2.3

Security-focused release addressing critical vulnerabilities and improving overall stability.

Version 1.0.0

Initial release with basic functionality for web scraping and data collection.


Support

For support and questions: - Issues: GitHub issue tracker - Documentation: Project README and docs - Security: See the security policy for security issues


Note: This changelog follows the Keep a Changelog format. All dates are in YYYY-MM-DD format.

Last reviewed: 2026-09-01