Skip to content

0.14.0 — scoped signal entities, a migration command, and a generation hot path

Choose a tag to compare

@behemothsecurity behemothsecurity released this 14 Oct 09:36
· 14 commits to development since this release
0091a0d

0.14.0 — scoped signal entities, a migration command, and a generation hot path

14 October 2026. Minor on 0.13.0.

Two things need action after upgrading. objective.signals[].entities values are now stage-scoped, so Hostname becomes Host::Hostname — and opentide migrate vocab-scope does that for you. response.responders becomes a configuration enum you declare in deployment.toml, so a value that no instance could make valid under 0.13.0 now validates.

Documentation and export generation are dramatically faster. A corpus that previously exceeded a one-hour CI job timeout now completes, which makes the deploy stage reachable again.

Install

pip install opentide==0.14.0
export OPENTIDE_REPO_ROOT=/path/to/detection-repo
opentide migrate vocab-scope --apply
opentide generate schemas templates vocabs snippets
opentide validate --strict

Breaking

  • objective.signals[].entities is stage-scoped. An authored value is <stage>::<key>. Seven of the 28 entity keys are defined under two stages — Account, User, Authentication, Token, Permissions, Email and API Call — so a bare key name could not say whether a signal keyed on a host account or a cloud account. That is a real detection distinction: different telemetry, a different data contract, often a different platform. The enum carried 28 values where the vocabulary defines 35; it now carries all 35. Stage identifiers are capitalised to Host, Network and Cloud, matching the convention threat.surface already uses. Vocabulary entry keys are unchanged, so the signal.entities::1.0 pin is unaffected. See RFC 0005.

  • response.responders is a configuration enum, not a vocabulary. The shipped responders vocabulary had keys = [] while both rule revisions pinned the field to it, so every authored value failed validation and no instance could clear it — vocabularies are canonical and not locally extensible. One corpus reported 483 unclearable findings from two values.

    The alert handling team is per-organisation, so it belongs in configuration. Declare your teams in deployment.toml:

    default_responders = "CSIRC"   # must stay ABOVE every [[...]] block
    
    [[responders]]
    name = "CSIRC"
    description = "Computer Security Incident Response Capability — first line"
    
    [[responders]]
    name = "CATCH"
    description = "Cyber Threat Analysis and Hunting — escalation"

    The generated schema offers those names in the editor with their descriptions, and validate rejects anything else. Declaring none leaves the field unconstrained — a fresh install must never carry a constraint it cannot satisfy, which is the defect being fixed. [deployment] default_responders supplies the team for a rule that names none; it shipped in the template and in the spec but was read by no code at all, so the documented fallback never actually happened. The vocabulary file is removed.

Changed

  • pip install opentide now ships the MCP server. mcp moves from the optional [mcp] extra into base dependencies. A bare install previously omitted it, so opentide mcp — a first-class CLI command — opened by telling you to install an extra first. The [mcp] extra is kept as an empty alias, so pip install opentide[mcp] and any editor or agent configuration naming it keep working unchanged.

Added

  • opentide migrate vocab-scope re-prefixes authored values for every stage-scoped field, not just the new one. Three properties make it safe on a real corpus:

    • Nothing is guessed. A bare key defined under several stages is reported with its candidates and left untouched; only an implied single-stage prefix or a stage-casing fix is applied. Exits 2 while anything is left for a human, so CI cannot mistake a partial migration for success.
    • Only the declared field is touched. Values are located by path through the document, not by leaf key name — a Sentinel platform block also carries a key called entities, holding entity mappings, and a name-based rewrite would corrupt it.
    • Formatting survives. Each value is replaced over the exact span its scalar occupies, so comments, block scalars, key order, quoting style and flow sequences are preserved. Every file is re-parsed and compared against the plan before it is written.
  • generate --help names the four positional phases. schemas, templates, vocabs and snippets are accepted, but Typer renders the sub-app's help string rather than the callback docstring — which already named them — so the only place the list appeared was an error string you had to trigger deliberately. Naming the cheap phases is the documented remedy for a stale editor schema after a contract change, so the right command was previously unguessable.

  • generate reports what each phase costs, and names the slowest. A run that overran a CI job timeout emitted no phase boundary and no timing, so a killed job gave no way to tell whether it was 10% or 90% through — which is why the 0.13.0 report could not narrow its own blocker down. Timing is recorded even when a phase raises.

Fixed

  • Documentation and export generation no longer rescan the catalogue per object. childs() walked every model on every call, while all of its callers are per-object: the table exporter per row, the documentation catalogue per signal, and relations_downstream() recursively at every level. Cost therefore grew with the square of the corpus. It is now a reverse index built once per catalogue generation — 68× faster on a 1497-object corpus. This one function is the root cause behind both a generate docs phase that exceeded a one-hour CI job timeout and a generate exports phase measured at 395s.
  • A relation could be attributed to the wrong object. childs() matched a model id against reference values with a substring test, so a short non-UUID id matched a longer one containing it: o-1 matched a signal referencing o-186, returning 113 spurious children. Comparison is now by equality. UUID-shaped ids could not trigger this; any other id scheme could.
  • validate and generate agreed to disagree. get_vocab_entry() stripped the stage prefix unconditionally before lookup while is_valid() checked the raw value first, so a repository could be green on validate and still produce degraded documentation. Both now resolve through one raw-first helper. Three Security::* surface keys resolved to a different entry under the stripping path and three more missed entirely; all six are reachable now.
  • check_status resolved a status by name, not by strategy, so renaming a configured deployment status silently took the wrong branch. It now resolves the strategy, and raises typed errors in place of a bare Exception.
  • opentide mcp advised an install that could not help. With mcp unimportable, both mcp start and setup mcp told you to run pip install 'opentide[mcp]' — but that extra is now an empty alias, so following the advice installed nothing. They name the real remedy, and tell an incomplete environment apart from an incompatible mcp 2.x.
  • The docs advertised a release they did not install. Four pages named 0.12.1 as the latest release beside their own pip install 'opentide==0.14.0' block. That is #406, closed at 0.8.0 and silently regressed through 0.13.0: the release tests covered the release commands, and nothing covered the version claims. Every claim, pin and PyPI link outside the historical release log is now checked against the newest changelog entry on every test run.
  • The test suite could rewrite the repository under test. Git tests ran git with cwd= set to a temporary repository but inherited the environment, and GIT_DIR overrides cwd. When git push exported GIT_DIR to its hooks and a hook ran the suite, the fixtures committed and checked out branches in the real repository — producing a commit that deleted 890 files. The environment is now sanitised for every test, with a collection-time guard so the hazard cannot return silently. This affects contributors, not released behaviour.

Verify

opentide migrate vocab-scope            # dry run; names every rewrite and every ambiguity
opentide migrate vocab-scope --apply
opentide generate schemas templates vocabs snippets
opentide validate --strict

The generated objective schema should carry 35 stage-prefixed entities values, with Host::Account and Cloud::Account both present and distinct:

python - <<'PY'
import json, pathlib
schema = json.loads(pathlib.Path(".opentide/schemas/objective.1.0.schema.json").read_text())
enum = schema["$defs"]["DetectionSignal"]["properties"]["entities"]["items"]["enum"]
print(len(enum), "values;", sum("::" in v for v in enum), "stage-prefixed")
print("Host::Account" in enum, "Cloud::Account" in enum)
PY

Thanks

The defect report behind most of this release was measured end to end on a 1276-object corpus with the full pipeline run on a throwaway project. Two of its items were retracted on review — one by the reporter, one by us — and that is exactly what a good report looks like.