Skip to content

Run temporal queries

Use temporal queries to see how stored threat intelligence changes over time. ZettelForge 2.8.0 records automatic supersession as a SUPERSEDES edge from the newer note to the older note. You can list those changes, inspect one note's timeline, and choose whether recall returns current or historical notes.

In progress

Automatic supersession creates note-to-note history, but it does not populate actor or other entity timelines. In the verified example below, mm.store.get_entity_timeline("intrusion_set", "apt28") returns []. Native timeline_analysis synthesis is also in progress; ZettelForge 2.8.0 falls back to direct_answer when you request that format.

Prerequisites

  • You use ZettelForge 2.8.0.
  • You completed the Quickstart.
  • You use the SQLite backend, which is the default backend.
  • You have two related notes in the same domain.

The examples use a fictional APT28 scenario for training. They do not make a current threat-intelligence claim.

Set these variables in the terminal where you run the examples:

export ZETTELFORGE_BACKEND=sqlite
export ZETTELFORGE_ENRICHMENT_ENABLED=false

This keeps the task on the verified SQLite path and skips background LLM enrichment. Recall still uses the configured local embedding model.

Store an older report, then store a newer report that shares the same actor and replaces part of the assessment.

from zettelforge.memory_manager import MemoryManager

mm = MemoryManager()

note1, status1 = mm.remember(
    content="APT28 used Cobalt Strike for C2 in January 2026 campaigns targeting EU governments.",
    source_type="report",
    source_ref="vendor-report-jan-2026",
    domain="cti",
)

note2, status2 = mm.remember(
    content="APT28 shifted to Sliver C2 in March 2026, replacing Cobalt Strike in EU operations.",
    source_type="report",
    source_ref="vendor-report-mar-2026",
    domain="cti",
)

print(status1, status2)
print(mm.store.get_note_by_id(note1.id).links.superseded_by == note2.id)

The verified run prints:

created created
True

The second remember() call detects the shared actor, marks the older note as superseded, and creates the graph edge. You do not configure the supersession check per call.

2. List changes after a timestamp

Query the active store that belongs to mm:

changes = mm.store.get_changes_since("2000-01-01T00:00:00")

for change in changes:
    print(
        f"[{change['timestamp']}] "
        f"{change['from']} --{change['relationship']}--> "
        f"{change['to']}"
    )

Each result contains these keys:

Key Type Meaning
timestamp str The edge's created_at value.
from str The source node as "type:value".
relationship str SUPERSEDES or a TEMPORAL_* relationship.
to str The target node as "type:value".

The SUPERSEDES result points from note2 to note1: the newer stored assertion replaces the older one. Pass consistently formatted ISO-8601 values because the SQLite query compares the stored timestamp text with your input.

3. Inspect the note timeline

timeline = mm.store.get_entity_timeline("note", note2.id)

for entry in timeline:
    print(f"[{entry['timestamp']}] {entry['edge']['relationship']} -> {entry['to_entity']}")

entity_timeline = mm.store.get_entity_timeline("intrusion_set", "apt28")
print(entity_timeline)

The note timeline contains one SUPERSEDES edge to the older note. The entity timeline prints [] in ZettelForge 2.8.0 because the automatic write path records replacement between stored assertions, not between actor nodes. This note-level model preserves which source assertion changed instead of rewriting the actor as if the earlier evidence never existed.

4. Recall current and historical notes

recall() hides superseded notes by default. Set exclude_superseded=False when you need the historical record.

current_notes = mm.recall(
    query="How has APT28 tooling changed since January 2026?",
    domain="cti",
    k=10,
)

history = mm.recall(
    query="How has APT28 tooling changed since January 2026?",
    domain="cti",
    k=10,
    exclude_superseded=False,
)

print(f"Current notes: {len(current_notes)}")
for note in history:
    status = "SUPERSEDED" if note.links.superseded_by else "CURRENT"
    print(f"[{status}] {note.content.raw[:72]}")

The verified run returns one current note and both notes in history. The phrases changed and since classify this query as temporal. See Retrieval policies for the policy weights and the complete temporal keyword list.

5. Generate an answer from the recalled history

Use the verified direct_answer format:

result = mm.synthesize(
    query="Timeline of APT28 tool changes in 2026",
    format="direct_answer",
    k=10,
)

print(result["format"])
print(result["synthesis"]["answer"])

The first line prints direct_answer. The generated answer depends on your configured LLM provider. Check result["synthesis"]["confidence"] before you use the answer; the controlled run with the mock provider returned a zero-confidence fallback.

Do not request timeline_analysis in ZettelForge 2.8.0. That request returns the direct_answer format instead of a timeline-shaped result.

If you are creating the notes from a report, pass its publication date during ingest. Follow Ingest a news report for the verified remember_report(..., published_date=...) workflow.

Working API calls

Goal API
List store-backed temporal changes mm.store.get_changes_since(iso_timestamp)
Inspect note supersession history mm.store.get_entity_timeline("note", note_id)
Include superseded notes in recall mm.recall(..., exclude_superseded=False)
Check whether a note is superseded note.links.superseded_by
Generate an answer mm.synthesize(..., format="direct_answer")