RunReveal Investigations & Case Management: What the Workspace Actually Shows

Start With What Is Actually There
The pitch for RunReveal's Investigations feature is easy to repeat: an alert fires, a case opens, an AI agent does first-pass triage, a human picks it up.
I could write that section from the docs in ten minutes.
Instead, everything below comes from pulling the real investigations, artifacts, alerts and audit trail out of a live workspace and reading what's there.
The first thing the data shows is that the pitch and the reality don't line up by default. The existing investigations here were all opened by hand, from the console or through the API, not by the platform.
That gap is the most useful thing in this article, so I'll start there.
Why an Alert Did Not Become a Case
An alert and an investigation are separate objects in RunReveal, and nothing in the alerts table points at an investigation. A real alert row has 26 columns (detection ID, run ID, the raw query, the results, the notification names it went to) and none of them is an investigation ID:
SELECT * FROM alerts -- one real row, trimmed
detectionName : test-alert-frequent-runquery
recordsReturned : 1
results : [40]
severity : low
notificationNames : ["default-email"]
-- no investigationID column exists on this table at all
The link goes the other way, through a separate lookup. The workspace audit log shows the console calling an investigations_by_alert endpoint whenever someone opens an alert, which is how the UI answers "is there a case for this?" In SQL terms you can't join alerts to investigations.
You have to ask the API.
As for why no case opened: every detection in this workspace has an empty settings object (settings: {}), apart from a Sigma rule, whose settings hold only its YAML.
None of them has AI triage or auto-investigation turned on.
Automatic case creation is something you opt into per detection. It isn't a default that every alert goes through. So a detection wired to email, like the one that fired here, sends its email and stops. If you're evaluating RunReveal and expecting cases to show up on their own, check that setting on each detection first.


Case Study: Running a Scenario End to End
Reading existing cases only shows so much, so I ran a full scenario live: write a detection, run it on real data, open a case from the result, triage it, add indicators, and check whether cross-case correlation actually finds anything.
I ran the detection SQL directly in Explorer rather than saving it as a scheduled detection, detections created through the API.
Step 1: the detection fires, and it looks bad
The scenario is a classic one: alert on a burst of admin password resets, which is what a mass account takeover or a rogue admin looks like in Google Workspace. The obvious first draft counts resets per admin over the last hour:
SELECT actor['email'] AS admin, count() AS resets
FROM logs
WHERE sourceType = 'google-workspace-alerts'
AND eventName = 'Admin password reset'
AND receivedAt >= now() - INTERVAL 1 HOUR
GROUP BY admin
HAVING resets >= 5
→ admin: "" resets: 4646 password resets in 60 minutes by an admin the data can't even name.
On a real team, that result at 3am gets somebody paged. So I opened a high-severity investigation for it, the same way an auto-triage detection would.
Step 2: triage finds the real story
The empty admin was the first clue. On this source, the normalized actor map and srcIP column are empty on every single row. The admin's email only exists inside rawLog, at data.actorEmail. Google Alert Center also gives each alert its own alertId, so I grouped on that instead:
SELECT JSONExtractString(rawLog, 'data', 'actorEmail') AS admin,
JSONExtractString(rawLog, 'alertId') AS google_alert_id,
JSONExtractString(rawLog, 'metadata', 'severity') AS google_severity,
min(eventTime) AS happened_at,
count() AS copies_ingested_last_hour
FROM logs
WHERE sourceType = 'google-workspace-alerts'
AND eventName = 'Admin password reset'
AND receivedAt >= now() - INTERVAL 1 HOUR
GROUP BY admin, google_alert_id, google_severity
→ admin: <redacted> google_alert_id: f4a61ff7-... google_severity: HIGH
happened_at: 2026-09-04 03:56:55 copies_ingested_last_hour: 46
There were never 46 resets. There was one, twenty days earlier, and RunReveal ingested the same Google alert 46 times in the last hour.
Zooming out to the whole source:
SELECT eventName, count() AS rows,
uniqExact(JSONExtractString(rawLog, 'alertId')) AS distinct_alerts
FROM logs WHERE sourceType = 'google-workspace-alerts' GROUP BY eventName
→ Activity Rule 44,811 rows 1 alert
→ Suspicious login 19,851 rows 1 alert
→ Admin password reset 21,999 rows 2 alerts
total 86,661 rows 4 real Google alerts
The pattern is consistent. The alerts poller keeps fetching the newest alert again, roughly every 80 seconds (about 46 times an hour), until Google produces a newer one. Then the newer one gets the same treatment. The Activity Rule alert was re-ingested for six weeks, the Suspicious login alert for three, and the password reset alert is still being re-ingested as I write this.
Step 3: indicators and cross-case correlation, working
With the conclusion written up as a note, I added the admin's email as an email indicator on the new case and ran IOC lookup on it. It came back with the new case and also an earlier, closed case, classified false positive, involving the same admin and the same kind of alert:
ioc_lookup(type="email", value="<redacted>")
→ ARTICLE TEST - Burst of admin password resets open high (unclassified)
→ <earlier case, same admin> closed high false_positive
This is what the feature is for.
An analyst (or the triage agent) opening the new case immediately sees that this same entity was already investigated and closed as a false positive. Here it's the right hint. It only works because both cases used the same lowercase email type, as covered in the IOC section below.
What this scenario actually proved
The naive detection would page someone on a 20-day-old event, about 46 times an hour, for as long as nothing newer comes in.
Any rule on this source keyed on actor['email'] or srcIP can never match anything, because those fields are always empty. The real values are only in rawLog.
The enabled Sigma rule for Activity Rule events on this workspace has zero matches in the detections table, even though 44,811 rows with that exact eventName exist. Its logsource says product: google_workspace, and a likely explanation is that this doesn't map to the google-workspace-alerts source, which would make it another rule that stays silent for structural reasons, like the one in Previous article
It explains an earlier puzzlePrevious article example of one detection run returning 11 rows was 11 copies of the same Activity Rule alert.
The investigation workflow itself did its job. Case, note, indicator and correlation all behaved as designed, and the triage is what caught the data problem.
The practical takeaway: For any polled alert source, group or deduplicate on the upstream alert's own ID before you count anything, and check that the normalized fields you filter on are actually populated before you trust a rule built on them. SELECT countIf(actor['email'] != '') FROM logs WHERE sourceType = '<source>' takes two seconds and would have caught this before it was a detection.


Anatomy of a Real Case
Here is a closed investigation as get_investigation returns it, with identifying values redacted.
It's worth seeing the whole shape once, because a few fields don't behave the way their names suggest:
{
"title": "<redacted>",
"status": "closed",
"severity": "high",
"classification": "false_positive",
"tags": ["google-workspace", "admin-action"],
"created_by": "token:Testing",
"assignee": "Testing",
"created_at": "2026-09-11T10:07:27.740424Z",
"detected_at": null,
"responded_at": null,
"triaged_at": "2026-09-11T11:15:32.560355Z",
"resolved_at": "2026-09-11T11:15:32.560355Z"
},
"metrics": { "investigateSeconds": 0, "resolveSeconds": 4084.819931 }Two things jump out.
First, created_by is token:Testing and the assignee is just Testing.
When a case is created with an API token, the token's name stands in for a person. Name your tokens after the automation or the human who owns them, because that name is what shows up as the case owner.
Second, look at triaged_at and resolved_at. They are the same value down to the microsecond. That matters for MTTR, which gets its own section below.
The timeline: four artifact types, only two you can write
Every investigation carries a timeline of artifacts. Across the real cases in this workspace I found four types:
investigation_update: written by the system when status or classification changes. The meta field records field, oldValue and newValue.
note: free text you add.
indicators: structured entities you add, which is what IOC lookup searches.
resolution: written by the system when the case is closed, carrying the closing comment.
You can only create note and indicators yourself. The other two are the platform's own record of changes. Also, don't assume the API returns the timeline in order.
On the scenario case, get_investigation listed the indicator before a note that was created 150 milliseconds earlier, so sort on created_at yourself if order matters. And an open case returns metrics: {}, not zeros. The metrics object only fills in once a case closes. One detail from the real indicators artifact: its meta holds an array, not a single value, so one artifact can carry several entities at once:
{
"type": "indicators",
"comment": "actorEmail from the source alert",
"meta": { "indicators": [ { "type": "email", "value": "<redacted>" } ] }
}
Notes vs. Indicators, and the Casing Trap, Straight From the Audit Log
The split between notes and indicators is well designed. Notes are prose for humans and IOC lookup can't see them.
Indicators are structured and are the only thing cross-case correlation searches.
A note saying "the attacker used admin@example.com" is invisible to every other investigation.
The same email in an indicators artifact is not.
The API is strict about the type string, and the docs and UI write the types as "Note" and "Indicators" with capitals.
What makes this concrete is that RunReveal's own audit log keeps every failed attempt, with the error message attached. Here is every rejected artifact type from one session of working out what the API actually wants, pulled straight from the log:
SELECT JSONExtractString(rawLog, 'supplemental_data', 'error_message')
FROM logs
WHERE sourceType = 'runreveal-audit'
AND eventName = 'investigation_add_artifact'
→ invalid artifact type: Note
→ invalid artifact type: NOTE
→ invalid artifact type: Note_Artifact
→ invalid artifact type: ARTIFACT_TYPE_NOTE
→ invalid artifact type: artifact_note
→ invalid artifact type: Indicator
→ invalid artifact type: indicator
→ invalid artifact type: IOC
→ invalid artifact type: iocNine rejections. The only two values that work are lowercase note and lowercase plural indicators.
There's a side lesson here too: that audit event is logged the same way whether the call succeeded or failed. Of 14 investigation_add_artifact events, 9 were errors. If you count that event to mean "artifacts added," you'll be off by more than half. Filter on the error flag in supplemental_data first.
The practical takeaway: Anything that writes to investigations automatically (a SOAR playbook, an MCP tool, a triage script) should hardcode type: "note" and type: "indicators", and should put every entity someone might pivot on into an indicators artifact, not just mention it in a note.
IOC Lookup: Tested, Not Assumed
IOC lookup is what makes cross-case correlation work, and the auto-triage agent calls it for every entity it sees. The docs describe it as an exact type match plus a partial, case-insensitive value match. I ran four lookups against a real email indicator in the workspace:
type value (search) classification result
────────── ────────────────────────── ─────────────── ──────────
email <DOMAIN, IN CAPITALS> (any) 1 match
Email <domain, lowercase> (any) null
user <the full email address> (any) null
email <domain, lowercase> true_positive null
Each row shows something specific:
Row 1: the value match really is partial and case-insensitive. Searching just the domain in capitals still found the full lowercase address.
Row 2: the type match is not just exact, it's case-sensitive. Email with a capital E found nothing. The docs don't say this, and it's the same trap as the artifact types.
Row 3: types are separate silos. The same address filed under user instead of email can't be found by an email lookup, and the reverse is true too. If one analyst files an address as email and another files it as user, correlation between their cases quietly breaks.
Row 4: the classification filter works. The only match was a false-positive case, so a true_positive filter correctly came back empty.
Worth deciding as a team before you have any cases: Pick one lowercase type per kind of entity (email for addresses, ip, domain, hash, user for account names that are not emails) and write it down. IOC lookup will not fix inconsistency for you. It will just return nothing.
MTTR: What the Four Stages Show on Real Cases
RunReveal breaks mean time to resolution into four stages:
Detect (activity to alert),
Respond (case created to first human engagement),
Investigate (triage start to close),
Resolve (creation to close).
Here is what two real closed cases report:
case detected_at responded_at investigateSeconds resolveSeconds
case A null null 0 63303.08
case B null null 0 4084.82
I checked case B's resolve Seconds by hand: 10:07:27.740 to 11:15:32.560 is 4,084.82 seconds, so that number is exactly closed minus created, as documented.
The other three stages are less useful on these cases:
detected_at and responded_at are null on both. Nothing about a hand-opened case gives the platform a detection time, so the Detect and Respond stages have nothing to measure.
investigateSeconds is 0 on both, because triaged_at gets stamped at the same moment as resolved_at when you close a case. Close a case without first moving it through a triage status, and the Investigate stage records zero seconds no matter how long you actually spent on it.
In practice, for any case opened by hand and closed directly,
Resolve is the only one of the four stages carrying real information. The other three only fill in when cases come from alerts and move through the agent-triage and investigating statuses on the way to closed. If your MTTR dashboard shows a suspiciously perfect 0-second Investigate time, this is why.
Two Gaps in the Timeline
Classification set at close leaves no trail
In one case, the classification was changed on its own before closing, and the timeline has an investigation_update artifact for it (field: classification, oldValue: "", newValue: false_positive).
In another, the classification was set as part of the close action, and the only artifact is the resolution, whose meta records status changing from open to closed and nothing else. The case is classified false positive, but the timeline never says when or by whom. If you rely on the timeline as an audit trail for classifications, set the classification as its own step before closing.
Closed cases still take notes, and classification does not follow them
One case was closed as false positive. The next day a note was added to it, while it was still closed, that reads in full: "This is a true positive but not a threat." The note was accepted, the case stayed closed, and the classification still says false_positive. Nothing flags the contradiction.
That note is a real analyst hitting a real gap. The classification field has three values (true_positive, false_positive, inconclusive) and no benign-true-positive option. The detection caught exactly what it was meant to catch, a real admin password reset, which just turned out to be authorized. Plenty of SOCs track that as its own category because it's what tells you whether to tune a rule or leave it alone. Here, you have to pick one of the three and put the nuance in a note that IOC lookup and the classification filter will never see.

The Audit Log Is the Real Usage Record, With a Catch
Every investigation action lands in the workspace's own audit source (sourceType runreveal-audit), which you can query like any other log. That's handy if you want to know how a team really uses cases rather than how they say they do:
SELECT eventName, count() FROM logs
WHERE sourceType = 'runreveal-audit' AND eventName ILIKE '%investigation%'
GROUP BY eventName ORDER BY count() DESC
→ investigation_metrics 50
→ investigation_list 33
→ investigations_alert_status 21
→ investigation_facets 18
→ investigation_get 14
→ investigation_add_artifact 14
→ investigation_create 3
→ investigation_close 3
→ investigation_ioc_lookup 1It already tells a story: people look at the metrics widget far more often than they open cases.
But investigation_create didn't match the number of cases in the investigation list, and neither did investigation_close.
The raw rows explain it: some audit events are ingested twice, with identical payloads including the nanosecond timestamp, about 30 seconds apart. Counting distinct events instead of rows:
SELECT count() AS raw_rows,
uniqExact(JSONExtractString(rawLog, 'timestamp'), eventName) AS distinct_events
FROM logs WHERE sourceType = 'runreveal-audit'
→ raw_rows 9683 | distinct_events 8035 (17% duplicates)Across the whole workspace's audit history, 17% of rows are duplicates of another row. That's not specific to investigations, but it's where I caught it, because the investigation counts were small enough to check against reality. Anything that counts audit events, whether a dashboard, a usage report or a detection that fires on N actions in a window, needs to deduplicate on the payload timestamp first or it will overcount.
Two more limits worth knowing before you build on this source:
The audit payload has no investigation ID. It records who did what and when, but not which case. You can't rebuild one investigation's history from the audit log alone.
For API-token actions, actor_email is the token's name (here, Testing), not an email address. The user agent (curl vs. a browser) is the quickest way to tell automation from a person clicking.
The practical takeaway: Wrap any count over runreveal-audit in uniqExact(JSONExtractString(rawLog, 'timestamp'), eventName) instead of count(), and exclude rows where supplemental_data.error is "true" if you want successful actions only.
Auto-Triage: How It Is Documented to Work
An alert from a triage-enabled detection auto-creates an investigation, tagged auto-created and alert, and the agent moves it to agent-triage.
The agent runs IOC lookup on every entity involved. That's the same endpoint tested above, with the same exact, case-sensitive type matching.
It writes an analysis note. For a true positive it adds indicators artifacts and hands off by moving the case to investigating. For a false positive it closes the case itself with a classification.
A detection's own notes field goes straight into the agent's prompt. An investigation's notes don't. The agent only sees them if it fetches the case during its run. Put playbook guidance on the detection, not in the case.
------------------------------------------------------Dean------------------------------------------------
Next Part covers the other half of RunReveal's AI story: scheduled agents that go looking for problems on their own, instead of reacting to alerts that already fired.


Comments