Timeline¶
A timeline is a projection of the fact log, never a stored state. Nothing is appended to an event table as you work, so the chronology cannot drift from the facts — and merging needs no special logic, because a union of facts is a union of events.
for entry in cv.timeline():
print(entry.when, entry.kind, entry.title, entry.salience)
cyvest timeline case.json
cyvest timeline case.json --key-only --time asserted
Two clocks¶
Every fact carries two timestamps, and they answer different questions:
| Field | Meaning |
|---|---|
occurred_at |
when the world moved — the email was sent, the process ran |
asserted_at |
when the analysis moved — the feed answered, the analyst decided |
The caller picks the axis:
cv.timeline(time="occurred") # default: the story of the incident
cv.timeline(time="asserted") # the story of the investigation
Reading the asserted axis is how you answer "what did we know, and when did we know it?" —
including the uncomfortable case where a signal about Monday's event only arrived on Friday.
When a fact has no occurred_at, the occurred axis falls back to asserted_at: an unknown
event time is better approximated by the moment it was recorded than dropped. The entry says so —
entry.dated is False — so a consumer building an incident chronology rather than an
investigation log can keep the dated facts only.
Dating facts¶
occurred_at is not a field you fill on the timeline; it is a field of the fact, under the name
each family gives it:
| Fact | Field | Meaning |
|---|---|---|
| finding | occurred_at |
when the activity the finding describes happened |
| evidence | captured_at |
when the material was captured |
| signal, relation | observed_at |
when the source observed it |
| decision | occurred_at |
when the analyst decided |
from datetime import datetime, timezone
cv.finding(
"link-clicked",
"`jdoe` opened the landing page from the mail",
verdict="NOTABLE",
tactic="initial-access",
occurred_at=datetime(2026, 8, 7, 10, 2, tzinfo=timezone.utc),
)
A finding that describes an activity may also name the ATT&CK tactic it demonstrates
(cyvest.Tactic, fourteen Enterprise tactics in kebab-case: initial-access, execution,
lateral-movement…). The timeline entry carries it; the score ignores it. A neutral, factual event
of the incident — a login, a download that turned out legitimate — is a dated finding with verdict
INFO: it shows in the chronology and weighs nothing.
Through cyvest_record, a model dates any of those five operations with occurred_at (ISO 8601
UTC) and names a finding's tactic. An update that omits them keeps the previous values: a
re-assertion that said nothing about when does not erase a date it never disputed.
Salience is derived, not declared¶
There is no "importance" field to fill in, which is precisely what keeps it honest. Salience is read from the report:
| Salience | Earned by |
|---|---|
KEY |
any decision — a human act is always worth showing; a finding or a signal whose weight reaches policy.salience_threshold |
NOTABLE |
a fact that moved the number without reaching the threshold; the first signal on an observable, which is when it entered the picture; a dated finding whatever its weight — a chronology claim is what the timeline exists to show |
BACKGROUND |
everything else |
Filter to the spine of the investigation:
from cyvest.enums import Salience
cv.timeline(min_salience=Salience.KEY)
The default already hides the noise
min_salience defaults to NOTABLE, so a plain cv.timeline() shows signals, findings and
decisions that mattered — not every observable and relation you created. Pass
min_salience=Salience.BACKGROUND to see the complete log.
Because salience comes from the report, changing the policy changes what the timeline emphasises — without touching a single fact.
Verdict changes¶
The moments that matter most are usually the transitions: when an observable became suspicious. Reconstructing them means replaying the store fact by fact, so it is opt-in:
cv.timeline(track_verdict_changes=True)
This adds verdict_change entries — NOTABLE → MALICIOUS — each referencing the fact that caused
the transition, all at KEY salience.
It costs a full replay
The sweep is O(#facts × (V+E)): the report is recomputed at every prefix. Fine for a case,
not for a dashboard over a thousand investigations.
Transitions are exact as long as facts are not rewritten. A re-asserted fact carries its final
seq, so its earlier value is not in the log; per-fact history is deferred to a later version.
What ends up in the timeline¶
Every fact type contributes, described in the terms an analyst would use — subject to the salience filter above, which is why observables and relations rarely show up unaided:
| Kind | Title |
|---|---|
signal |
VirusTotal → MALICIOUS |
finding |
the finding's name, or its rule id — with its tactic when it states one |
decision |
ALLOWLISTED · Corporate sandbox |
relation |
extraction → obs:url:… |
evidence |
the evidence title, or its type |
observable |
url https://evil.test |
verdict_change |
NOTABLE → MALICIOUS (opt-in) |
Each entry carries subject_key and refs, so a timeline row can be followed back into the graph
or into explain, plus dated and tactic as described above.