What SOC 2 evidence can an auditor ask for when a company uses Claude Enterprise?
The short answer
In our reading, an auditor can ask for three lists when a company uses Claude Enterprise: every person who left and the date of the first removal record for that departure (CC6.2), every administrative setting change and who made it (CC8.1), and a count of Compliance API requests for each API key (CC7.2). The script on this page builds all three in DuckDB.
What this covers, and what it does not
The Compliance API covers Claude Enterprise. For Claude Console and API-key workloads, the Activity Feed records administrative and resource events but no prompt or response text. Claude Code sessions authenticated with a Console API key, run through Amazon Bedrock, Google Cloud or Microsoft Foundry, or run as Claude Code cloud sessions are not captured (Anthropic: Compliance API FAQ (read 2026-10-31)). Recording starts when the Compliance API is first turned on and is not backfilled (Anthropic: Query the Activity Feed (read 2026-10-31)). Anything outside that coverage needs its own evidence.
What you will have at the end
A Python script, soc2_claude_evidence.py, that reads your Claude Activity Feed records, an HR leaver list and a roster of people allowed to change Claude settings. It runs five input checks first, then builds the three lists and prints each with its period, its filters, the run time, its row count and a SHA-256 of its rows. A reader with the same input and the same script gets the same row count and the same fingerprint. It also writes each list as a CSV. The full detail is in a PDF: Download the full guide (PDF).
The records we ran it on are generated: a seeded stand-in, not a real organization’s data and not a measurement of anything. No real organization’s feed is public. The field names come from Anthropic’s published Activity object, so the script is built to read a real pull. We have not run it on one.
This page produces evidence. It does not say a control is satisfied or that anyone will pass an audit. We name controls by id (CC6.2, CC8.1, CC7.2) and do not reproduce the AICPA’s text. Which lists an auditor asks for is the auditor’s call: these three are our reading of what Claude Enterprise adds to a SOC 2 request, not an official list.
What you will need
- The Compliance API turned on for your organization, and a key that can read the Activity Feed: a Compliance Access Key or an Admin API key with the
read:compliance_activitiesscope (Anthropic: Query the Activity Feed (read 2026-10-31)). - An HR leaver list: each person’s work email and last day of work.
- A roster of the people your change process allows to change Claude settings.
- If you can get one, an export of your Claude Enterprise members: the people who held a seat during the period.
- Python 3 and the
duckdbmodule.
Step 1. Pull the feed for the whole period, once, as JSON lines
The Activity Feed returns each activity with an id, a created_at, an actor and a type (Anthropic: the Activity object (read 2026-10-31)). Activities are queryable within 1 minute and retained for 6 years (Anthropic: Query the Activity Feed (read 2026-10-31)). Anthropic treats the feed as at-least-once, so a retry can deliver a record twice, and it tells you to deduplicate on id (Anthropic: Delivery guarantees and completeness (read 2026-10-31)). It also says to keep the starting cursor, the final last_id, the record count and the run time of a pull, so you can show how the file was made.
The script reads a file; it does not call the API. We did not call the real API, because we have no organization to call it on. Save the pull as one JSON object per line, in the shape Anthropic documents.
Step 2. Load it into DuckDB
DuckDB’s read_json takes format='newline_delimited' and an explicit columns list; a key missing from a record becomes NULL and a key not listed is left out (DuckDB: loading JSON (read 2026-10-31)). That means a field your pull lacks does not stop the load. Emails are lower-cased and trimmed on the way in, so Ana.Ruiz@Corp.Example in HR and ana.ruiz@corp.example in the feed are the same person.
con.execute(f"""
CREATE TABLE raw AS SELECT * FROM read_json('{acts}', format='newline_delimited', columns={{
'id':'VARCHAR', 'created_at':'VARCHAR', 'type':'VARCHAR',
'actor':'STRUCT(type VARCHAR, email_address VARCHAR, user_id VARCHAR, api_key_id VARCHAR)',
'{TARGET_FIELD}':'VARCHAR', 'deleted_user_id':'VARCHAR', 'user_id':'VARCHAR'}})""")
con.execute(f"""
CREATE TABLE activities AS SELECT id, type,
try_cast(replace(created_at, 'Z', '') AS TIMESTAMP) AS ts,
actor.type AS actor_type, lower(trim(actor.email_address)) AS actor_email,
actor.user_id AS actor_user_id, actor.api_key_id AS api_key_id,
lower(trim("{TARGET_FIELD}")) AS target_email, coalesce(deleted_user_id, user_id) AS target_user_id
FROM raw""")
Removal records name the removed user in different ways. Anthropic’s API reference gives org_user_deleted (a user was removed from the organization) a deleted_user_email and a deleted_user_id, and gives scim_user_deleted only a user_id (Anthropic: Query compliance activities, API reference (read 2026-10-31)). The script reads the email from TARGET_FIELD, which is deleted_user_email unless you change it. When a record carries only an id, the script looks up the email through actor.user_id on that person’s other activity. The reference does not say these ids are the same, so that part is our assumption. A person who removed themselves (org_user_left) is matched on their own email. The script prints how many removal records name no email it can match. Open one of your own removal records to check.
Step 3. Run five input checks before building anything
A list built from an incomplete feed is a risky kind of evidence, because it looks complete. The script runs five checks first, and each one names the planted fault that trips it:
- Duplicate activity ids (a duplicated record).
- Records with no id or no readable
created_at(a missing timestamp). - Days in the period with no records at all (a day with no records).
- HR leaver rows with no email or no last day (a NULL in the HR file).
- Admin roster rows with no email (a NULL in the roster).
If any fails, all three lists are printed with INCOMPLETE in their heading. This is the empty-day query:
gaps = [str(r[0]) for r in con.execute(f"""
SELECT d::DATE FROM generate_series(DATE '{p0}', DATE '{p1}', INTERVAL 1 DAY) t(d)
WHERE d::DATE NOT IN (SELECT ts::DATE FROM activities WHERE ts IS NOT NULL) ORDER BY 1""").fetchall()]
The script flags duplicate ids but does not remove them. Remove them before you run it (keep the first record for each id), or the lists are marked INCOMPLETE and the counts include the repeat.
One caution: Anthropic says activity volume is not a completeness check, because the number of _viewed events depends on how each app loads content (Anthropic: Delivery guarantees and completeness (read 2026-10-31)). A day with no records of any kind is a different signal, but a small organization can have a quiet day. Treat a failure as a question to answer, not proof of a gap.
Step 4. CC6.2: every leaver, and the days until the first removal record
The population starts from the HR list: every person whose last day falls in the period. For each departure, the script finds the first removal record (org_user_deleted, scim_user_deleted or org_user_left) credited to it, counts the days from the last day of work to that record, counts what the person did after the grace period (one day, set by GRACE_DAYS) and before the removal, and counts what the account did after the removal. Any use after the removal makes the row ACTIVE AFTER REMOVAL: a removal that did not take, a re-invite or a re-provisioning by the directory sync. Each removal record is credited to one departure only: the latest HR last day on or before it, or, for a removal that came before every last day, the earliest last day it precedes by at most 30 days (REMOVAL_WINDOW_DAYS). So a person who left, was removed, came back and left again is two rows, and the first removal never counts for the second departure. Use of the account between the first removal and the second departure belongs to the second stint, so it is not counted against the first row.
A person with no removal record stays in the list. If the feed or your members export shows they had a Claude account, the row reads NO REMOVAL RECORD. If neither does, it reads CHECK: NO CLAUDE ACTIVITY SEEN: that is either someone who never had Claude, or a seat holder who never used it and was never removed, and the feed alone cannot tell the two apart. Without a members export (MEMBERS_FILE left as None), clear each CHECK row against your seat list; each one counts as follow-up until you do. With a members export, the script can tell them apart: a seat holder is NO REMOVAL RECORD, and a leaver in neither the export nor the feed is left out, counted on an [info] line and written to cc6_2_not_members_not_seen.csv. Starting from HR, not from the removal records, is what stops the person who was never removed from dropping out.
sql = f"""
WITH credited AS (
SELECT r.email, r.ts, coalesce(
(SELECT max(l.last_day) FROM leavers l WHERE l.email = r.email AND l.last_day <= r.ts::DATE),
(SELECT min(l.last_day) FROM leavers l WHERE l.email = r.email
AND l.last_day - INTERVAL {REMOVAL_WINDOW_DAYS} DAY <= r.ts::DATE)) AS last_day
FROM removals r WHERE r.email IS NOT NULL),
rem AS (SELECT email, last_day, min(ts) AS removed_at FROM credited
WHERE last_day IS NOT NULL GROUP BY 1, 2),
nxt AS (SELECT email, last_day,
lead(last_day) OVER (PARTITION BY email ORDER BY last_day) AS next_last_day
FROM leavers WHERE email IS NOT NULL),
-- after the grace period and before the removal; and after the removal. When the same person
-- has a LATER departure in HR, use after the removal belongs to that later stint, not this row.
after AS (SELECT l.email, l.last_day,
count(a.id) FILTER (WHERE a.ts::DATE > l.last_day + INTERVAL {GRACE_DAYS} DAY
AND (r.removed_at IS NULL OR a.ts < r.removed_at)) AS n_grace,
count(a.id) FILTER (WHERE r.removed_at IS NOT NULL AND a.ts > r.removed_at
AND n.next_last_day IS NULL) AS n_removed
FROM leavers l JOIN nxt n ON n.email = l.email AND n.last_day = l.last_day
LEFT JOIN rem r ON r.email = l.email AND r.last_day = l.last_day
LEFT JOIN activities a ON a.actor_email = l.email AND a.actor_type = 'user_actor'
GROUP BY 1, 2)
SELECT l.email, l.last_day, r.removed_at::DATE AS first_removal_record,
date_diff('day', l.last_day, r.removed_at::DATE) AS days_to_removal,
af.n_grace AS actions_after_grace, af.n_removed AS actions_after_removal,
CASE WHEN r.removed_at IS NULL AND l.email IN (SELECT email FROM had_account) THEN 'NO REMOVAL RECORD'
WHEN r.removed_at IS NULL THEN 'CHECK: NO CLAUDE ACTIVITY SEEN'
WHEN af.n_removed > 0 THEN 'ACTIVE AFTER REMOVAL'
WHEN date_diff('day', l.last_day, r.removed_at::DATE) > {GRACE_DAYS} THEN 'REMOVED LATE'
ELSE 'removed in time' END AS status
FROM leavers l
LEFT JOIN rem r ON r.email = l.email AND r.last_day = l.last_day
LEFT JOIN after af ON af.email = l.email AND af.last_day = l.last_day
WHERE {in_period} {population_filter}
ORDER BY l.last_day, l.email"""
Step 5. CC8.1 and CC7.2, and a fingerprint on each list
CC8.1 lists every record of an administrative change type in ADMIN_CHANGE_TYPES (nine types, including claude_organization_settings_updated and org_sso_toggled) with who made it, marked NOT ON ROSTER when a person who is not on your roster made it. An [info] line lists every activity type in the feed that no list uses, with its count, so a change type missing from ADMIN_CHANGE_TYPES shows up there instead of dropping out without a word. CC7.2 counts every compliance_api_accessed record, which Anthropic generates for each Compliance API request, by actor.api_key_id, with the first and last day for each key (Anthropic: Correlate with your SIEM (read 2026-10-31)). Each list is printed with its period, filters, run time, row count and the SHA-256 below, computed over every row of the list, in the order written to the CSV:
def fingerprint(rows):
body = "\n".join("|".join("" if v is None else str(v) for v in r) for r in rows)
return hashlib.sha256(body.encode()).hexdigest()
The fingerprint does not prove the rows are right. It lets anyone who re-runs the script on the same file confirm they got the same rows, and it shows if a list changed after it was handed over.
Run 1: nine planted faults, and the script finds them
We ran the script on 2026-10-31 on generated records with nine faults planted: one record duplicated; every record on 2026-08-19 removed; a person who left on 2026-08-12 and was never removed; a person removed nine days after their last day; a settings change made by someone not on the roster; a person (u31) who left on 2026-07-21, was removed, came back and left again on 2026-09-25 without being removed; an HR leaver who never had Claude (u41); a seat holder who never used Claude in the period and was never removed (u42); and a person removed on time whose account acted again ten days after their last day (u30). The script reports every one: two through the input checks, and seven as rows that need follow-up in the lists. It marks all three lists INCOMPLETE and prints RESULT: EXCEPTIONS FOUND. It exits with code 0 unless you pass --strict, which exits 1 so a pipeline can stop.
python3 soc2_claude_evidence.py --plant
Output from our run 1
input : GENERATED records (not a real organization), seed 221, plant=True
members : none (MEMBERS_FILE not set)
== Input checks (records read: 636; outside the period, left out: 0)
[FAIL] duplicate activity ids: 1 (trips on: a duplicated record)
[ok ] records with no id or no readable created_at: 0
[FAIL] days in the period with no records: 1 (trips on: a day with no records)
[ok ] HR leaver rows with no email or no last day: 0
[ok ] admin roster rows with no email: 0
empty days: 2026-08-19
[info] configured activity types never seen in this feed: org_data_residency_updated, org_compliance_api_settings_updated, claude_user_role_updated, scim_user_deleted, org_user_left
[info] activity types in the feed that no list uses: claude_chat_created 271, claude_chat_viewed 218, sso_login_succeeded 91
[info] removal records that name no email the script can match: 0
[info] no MEMBERS_FILE: a leaver with no Claude activity seen stays in CC6.2 as CHECK: NO CLAUDE ACTIVITY SEEN; clear those rows against your seat list
== CC6.2: cc6_2_leavers ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters : HR leavers with last day in period; removal types ['org_user_deleted', 'scim_user_deleted', 'org_user_left'], each credited to one departure; grace 1 day(s)
run at : 2026-10-31 12:38:10 UTC
rows : 9
sha256 : f0cd8893d04a2f565f6736327b68dce3b82dabd966ad98a76fba71713a691a2b
u30@example.test | 2026-07-10 | 2026-07-10 | 0 | 0 | 1 | ACTIVE AFTER REMOVAL
u31@example.test | 2026-07-21 | 2026-07-21 | 0 | 0 | 0 | removed in time
u32@example.test | 2026-08-01 | 2026-08-01 | 0 | 0 | 0 | removed in time
u33@example.test | 2026-08-12 | - | - | 9 | 0 | NO REMOVAL RECORD
u34@example.test | 2026-08-23 | 2026-09-01 | 9 | 0 | 0 | REMOVED LATE
u35@example.test | 2026-09-03 | 2026-09-04 | 1 | 0 | 0 | removed in time
u41@example.test | 2026-09-15 | - | - | 0 | 0 | CHECK: NO CLAUDE ACTIVITY SEEN
u42@example.test | 2026-09-20 | - | - | 0 | 0 | CHECK: NO CLAUDE ACTIVITY SEEN
u31@example.test | 2026-09-25 | - | - | 0 | 0 | NO REMOVAL RECORD
== CC8.1: cc8_1_admin_changes ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters : types 9 listed in ADMIN_CHANGE_TYPES
run at : 2026-10-31 12:38:10 UTC
rows : 13
sha256 : f1db3675bc919eddbfaa03c8f50b43f21515ab8468a73ecd15ce1e118ad8f55c
2026-08-25 16:43:27 | org_ip_restriction_updated | user_actor | u12@example.test | NOT ON ROSTER
== CC7.2: cc7_2_compliance_api_requests ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters : type compliance_api_accessed; grouped by actor.api_key_id
run at : 2026-10-31 12:38:10 UTC
rows : 3
sha256 : 41a1c40d9f6e939fbbfb3c2fe05c518eca1955982a41bd11805e9038343088d1
apikey_audit01 | 12 | 2026-07-04 | 2026-09-19
apikey_legal03 | 14 | 2026-07-12 | 2026-09-28
apikey_siem02 | 12 | 2026-07-01 | 2026-09-26
== Result
input checks : FAILED, the populations above are not complete
follow-up : 7 leaver or admin-change row(s) need an explanation
RESULT: EXCEPTIONS FOUND
The result
| Person | Last day | First removal record | Days to removal record | Actions after grace | Actions after removal | Status |
|---|---|---|---|---|---|---|
| u30 | 2026-07-10 | 2026-07-10 | 0 | 0 | 1 | ACTIVE AFTER REMOVAL |
| u31 | 2026-07-21 | 2026-07-21 | 0 | 0 | 0 | removed in time |
| u32 | 2026-08-01 | 2026-08-01 | 0 | 0 | 0 | removed in time |
| u33 | 2026-08-12 | none | none | 9 | 0 | NO REMOVAL RECORD |
| u34 | 2026-08-23 | 2026-09-01 | 9 | 0 | 0 | REMOVED LATE |
| u35 | 2026-09-03 | 2026-09-04 | 1 | 0 | 0 | removed in time |
| u41 | 2026-09-15 | none | none | 0 | 0 | CHECK: NO CLAUDE ACTIVITY SEEN |
| u42 | 2026-09-20 | none | none | 0 | 0 | CHECK: NO CLAUDE ACTIVITY SEEN |
| u31 | 2026-09-25 | none | none | 0 | 0 | NO REMOVAL RECORD |
The first row is u30: removed on their last day, yet the account acted again ten days later. Without the after-removal count, this row would read removed in time. The leaver who kept access (u33) is the next row to look at. HR says their last day was 2026-08-12. The feed has no removal record for them, and nine actions by their account after the grace period. Without the HR list as the starting point, this person would not appear in any list built from removal records. Next is u31, who has two rows. The removal on 2026-07-21 is credited to the first departure, so it cannot also stand for the second one, which reads NO REMOVAL RECORD. Last are u41 and u42. Neither appears anywhere in the feed, so without a members export the script cannot tell the person who never had Claude from the seat holder who was never removed. Both stay in the list as CHECK rows, and the result counts them. The same run found the empty day (2026-08-19) and the duplicate id, so it marked all three lists INCOMPLETE rather than hand over lists that look finished.
The same records with a members export
Here is the run with a members export. With generated records, the --members flag stands in for setting MEMBERS_FILE: the script uses the members list it generated, which holds every seat holder, u42 included. The seat holder u42 is now NO REMOVAL RECORD. The leaver who never had Claude, u41, is in neither the export nor the feed, so the script leaves them out and counts them on the [info] line.
python3 soc2_claude_evidence.py --plant --members
Output from our run 3
input : GENERATED records (not a real organization), seed 221, plant=True
members : generated/members.csv
== Input checks (records read: 636; outside the period, left out: 0)
[FAIL] duplicate activity ids: 1 (trips on: a duplicated record)
[ok ] records with no id or no readable created_at: 0
[FAIL] days in the period with no records: 1 (trips on: a day with no records)
[ok ] HR leaver rows with no email or no last day: 0
[ok ] admin roster rows with no email: 0
empty days: 2026-08-19
[info] configured activity types never seen in this feed: org_data_residency_updated, org_compliance_api_settings_updated, claude_user_role_updated, scim_user_deleted, org_user_left
[info] activity types in the feed that no list uses: claude_chat_created 271, claude_chat_viewed 218, sso_login_succeeded 91
[info] removal records that name no email the script can match: 0
[info] HR leavers in the period not in MEMBERS_FILE and not seen in the feed, left out of CC6.2: 1 (listed in evidence-out/cc6_2_not_members_not_seen.csv)
== CC6.2: cc6_2_leavers ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters : HR leavers with last day in period, in MEMBERS_FILE or seen in the feed; removal types ['org_user_deleted', 'scim_user_deleted', 'org_user_left'], each credited to one departure; grace 1 day(s)
run at : 2026-10-31 12:38:14 UTC
rows : 8
sha256 : c77472b522af5884206215d9217d86200b822543a9a19b6f82af4d292e37669e
u30@example.test | 2026-07-10 | 2026-07-10 | 0 | 0 | 1 | ACTIVE AFTER REMOVAL
u31@example.test | 2026-07-21 | 2026-07-21 | 0 | 0 | 0 | removed in time
u32@example.test | 2026-08-01 | 2026-08-01 | 0 | 0 | 0 | removed in time
u33@example.test | 2026-08-12 | - | - | 9 | 0 | NO REMOVAL RECORD
u34@example.test | 2026-08-23 | 2026-09-01 | 9 | 0 | 0 | REMOVED LATE
u35@example.test | 2026-09-03 | 2026-09-04 | 1 | 0 | 0 | removed in time
u42@example.test | 2026-09-20 | - | - | 0 | 0 | NO REMOVAL RECORD
u31@example.test | 2026-09-25 | - | - | 0 | 0 | NO REMOVAL RECORD
== CC8.1: cc8_1_admin_changes ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters : types 9 listed in ADMIN_CHANGE_TYPES
run at : 2026-10-31 12:38:14 UTC
rows : 13
sha256 : f1db3675bc919eddbfaa03c8f50b43f21515ab8468a73ecd15ce1e118ad8f55c
2026-08-25 16:43:27 | org_ip_restriction_updated | user_actor | u12@example.test | NOT ON ROSTER
== CC7.2: cc7_2_compliance_api_requests ** INCOMPLETE: see input checks **
period : 2026-07-01 to 2026-09-30 (UTC, inclusive)
filters : type compliance_api_accessed; grouped by actor.api_key_id
run at : 2026-10-31 12:38:14 UTC
rows : 3
sha256 : 41a1c40d9f6e939fbbfb3c2fe05c518eca1955982a41bd11805e9038343088d1
apikey_audit01 | 12 | 2026-07-04 | 2026-09-19
apikey_legal03 | 14 | 2026-07-12 | 2026-09-28
apikey_siem02 | 12 | 2026-07-01 | 2026-09-26
== Result
input checks : FAILED, the populations above are not complete
follow-up : 6 leaver or admin-change row(s) need an explanation
RESULT: EXCEPTIONS FOUND
Run 2: the clean records, and the script passes
The same script on the same seed with --plant left off. Planting changes the random draws, so the seed gives a somewhat different set of records (different removal lags and request counts), not the run 1 records minus nine faults. All five input checks pass, all six leavers show removed in time, the 12 administrative changes are all by people on the roster, and three API keys are listed with their request counts. The script prints RESULT: NOTHING TO FOLLOW UP. The script prints every leaver row, but prints an administrative change only when it is off the roster; all rows go to the CSV files in evidence-out/.
python3 soc2_claude_evidence.py
Output from our run 2
input : GENERATED records (not a real organization), seed 221, plant=False members : none (MEMBERS_FILE not set) == Input checks (records read: 628; outside the period, left out: 0) [ok ] duplicate activity ids: 0 [ok ] records with no id or no readable created_at: 0 [ok ] days in the period with no records: 0 [ok ] HR leaver rows with no email or no last day: 0 [ok ] admin roster rows with no email: 0 [info] configured activity types never seen in this feed: org_data_residency_updated, org_compliance_api_settings_updated, claude_user_role_updated, scim_user_deleted, org_user_left [info] activity types in the feed that no list uses: claude_chat_created 247, claude_chat_viewed 231, sso_login_succeeded 92 [info] removal records that name no email the script can match: 0 [info] no MEMBERS_FILE: a leaver with no Claude activity seen stays in CC6.2 as CHECK: NO CLAUDE ACTIVITY SEEN; clear those rows against your seat list == CC6.2: cc6_2_leavers period : 2026-07-01 to 2026-09-30 (UTC, inclusive) filters : HR leavers with last day in period; removal types ['org_user_deleted', 'scim_user_deleted', 'org_user_left'], each credited to one departure; grace 1 day(s) run at : 2026-10-31 12:38:12 UTC rows : 6 sha256 : 205c80ffd44e5c4f2ff1bc5aa23a0ee4b04d01bfccabf52a173bd2036a77a75e u30@example.test | 2026-07-10 | 2026-07-10 | 0 | 0 | 0 | removed in time u31@example.test | 2026-07-21 | 2026-07-22 | 1 | 0 | 0 | removed in time u32@example.test | 2026-08-01 | 2026-08-02 | 1 | 0 | 0 | removed in time u33@example.test | 2026-08-12 | 2026-08-13 | 1 | 0 | 0 | removed in time u34@example.test | 2026-08-23 | 2026-08-24 | 1 | 0 | 0 | removed in time u35@example.test | 2026-09-03 | 2026-09-04 | 1 | 0 | 0 | removed in time == CC8.1: cc8_1_admin_changes period : 2026-07-01 to 2026-09-30 (UTC, inclusive) filters : types 9 listed in ADMIN_CHANGE_TYPES run at : 2026-10-31 12:38:12 UTC rows : 12 sha256 : 130d23c31ac62a4e2945a224f4a2676889d58119f5219bcc8b354fa485889a54 == CC7.2: cc7_2_compliance_api_requests period : 2026-07-01 to 2026-09-30 (UTC, inclusive) filters : type compliance_api_accessed; grouped by actor.api_key_id run at : 2026-10-31 12:38:12 UTC rows : 3 sha256 : 0d2f7741405614fbdde88468ed46c2aa03e20c4171d7f72e0b41ead0e23689ae apikey_audit01 | 16 | 2026-07-01 | 2026-09-24 apikey_legal03 | 14 | 2026-07-07 | 2026-09-28 apikey_siem02 | 10 | 2026-07-12 | 2026-09-30 == Result input checks : all passed follow-up : 0 leaver or admin-change row(s) need an explanation RESULT: NOTHING TO FOLLOW UP
Every row count and SHA-256 above is repeatable: the records are seeded, so the same script gives the same fingerprints (the run time line changes). “Nothing to follow up” says only that the input passed its checks and that no leaver row and no settings change made by a person needed an explanation. It does not test API keys or changes made by a directory sync, and the [info] lines list the activity types the script never saw in this feed and the ones no list uses. It does not say a control is satisfied.
Run it on your own data
To run this on your own organization, save your Activity Feed pull as one JSON object per line, and save your HR leaver list, your list of people allowed to change Claude settings and your export of Claude Enterprise members as CSV files with a header row. At the top of the script, set ACTIVITIES_FILE, LEAVERS_FILE, ADMINS_FILE and MEMBERS_FILE to those file paths, PERIOD_START and PERIOD_END to your audit period, and LEAVER_EMAIL_COL, LEAVER_DATE_COL, ADMIN_EMAIL_COL and MEMBERS_EMAIL_COL to your CSV column names. If you have no members export, leave MEMBERS_FILE as None and clear every CHECK: NO CLAUDE ACTIVITY SEEN row against your seat list. If your removal records hold the removed user's email in a field other than deleted_user_email, set TARGET_FIELD to that field's name. Change nothing else.
We tested those steps on a separate set of files: different file names, different column names, mixed-case emails, a NULL email in the HR file and in the roster, a removal field named differently from the demo, a members export, and the faults and NULLs listed above planted (all but the missing-timestamp fault), including a person removed on time whose account acted again afterwards, a person who left twice and was removed only the first time, a seat holder who never used Claude and was never removed, and an HR leaver who never had Claude. The script caught every one we planted and matched the same person across different email capitalization.
Download soc2_claude_evidence.py
Download the full guide (PDF)
Method
- Input: generated Activity Feed records, written by the script as JSON lines in the Activity object’s shape, with a fixed seed. Fields used:
id,created_at,type,actor.type,actor.email_address,actor.api_key_id(Anthropic: Activity object (read 2026-10-31)), plusdeleted_user_email,deleted_user_idanduser_idon removal records (Anthropic: Query compliance activities, API reference (read 2026-10-31); see step 2). - Period: 2026-07-01 to 2026-09-30, UTC, inclusive. CC8.1 and CC7.2 use only records inside it, and the script reports how many records fell outside. CC6.2 looks for removal records and later actions in the whole file, because a removal can come after the period ends.
- Lists: CC6.2 starts from the HR list (kept to members and people seen in the feed when a members export is given); CC8.1 and CC7.2 start from the feed. Rows are never silently dropped: a failed input check marks the list INCOMPLETE, and a left-out leaver is counted and listed.
- Matching: on email, lower-cased and trimmed. Anthropic recommends
actor.user_idas the primary join key because it does not change when an email changes (Anthropic: Correlate with your SIEM (read 2026-10-31)). We joined on email because an HR leaver list usually carries emails. If yours carries your identity provider’s id, join on that. - Roster and types are written out at the top of the script (
ADMIN_CHANGE_TYPES,REMOVAL_TYPES), so you can disagree with them. The script also prints any configured type it never saw in the feed.
Limits
- This is not a client result. The records are generated. The run shows the script finds planted faults. It says nothing about any real organization or about how often such faults happen.
- We did not call the Compliance API. Real pulls can differ from the documentation: other field names for the removed user, other activity types for removals, and records for service accounts.
- The three lists are our reading, not an official list from the AICPA or an auditor. Your auditor decides what they test, and a different scope can need different lists. The criteria text is not reproduced here.
- Coverage: only what the Compliance API records, from the day it was first turned on. For Claude Console and API-key workloads, the Activity Feed records administrative and resource events but no prompt or response text. Claude Code sessions authenticated with a Console API key, run through Amazon Bedrock, Google Cloud or Microsoft Foundry, or run as Claude Code cloud sessions are not captured (Anthropic: Compliance API FAQ (read 2026-10-31)).
- Without a members export, the feed cannot tell an idle seat holder from someone who never had Claude. Both read
CHECK: NO CLAUDE ACTIVITY SEENand count as follow-up until you clear them against your seat list. With an export, the result is only as complete as the export: a seat holder missing from it who never used Claude in the period is left out. - Each removal is credited to one departure, the latest HR last day on or before it. A removal that came before every last day counts only if it is at most 30 days early, so a person removed more than 30 days before their last day shows as
NO REMOVAL RECORDand needs a look. A person who left twice within a few days may need the two rows checked by hand. - Use after a removal is counted only for a person’s last departure in the HR list. For an earlier departure of someone who came back, that use is taken to be the second stint, so a removal that did not take before the rehire is not flagged.
- Id-only removal records are matched through
actor.user_id, which assumes the ids are the same. The script prints how many removal records it could not match. - Settings changes: the part of Anthropic’s page we read does not list which settings each change type altered. The CC8.1 list shows who changed something and when, not what the old and new values were.
- A removal record is not proof of logout. It shows the user was removed. The script counts later actions by the same account as a second signal, but a person can also use an API key that is not tied to their name.
- The empty-day check can fire on a quiet day, and cannot see a day that has some records but not all.
Tested on
Tested on
- Platform
- DuckDB 1.5.6, Python 3.14, run from Python with the
duckdbmodule - Edition
- Open-source DuckDB, in-memory
- Date
- 2026-10-31
- Dataset
- Generated records, seed 221 (not a real organization). Field names from Anthropic’s Activity object (read 2026-10-31)
- Code
- soc2_claude_evidence.py
When this is not enough
- You need a quarter of lists, not one run. The script is a point-in-time pull. An auditor may ask for the lists for each period, so keep each pull’s parameters and the lists as they were produced.
- Claude is used outside the Compliance API’s coverage. Those uses need evidence from the platform they run on.
- The control is more than a list. Who approved the access, the policy, the review and the sign-off sit in your ticket and HR systems, not in the feed.
- Legal and audit advice are not here. Whether a list is enough is for your auditor.
Control Tower for Claude
Control Tower for Claude runs in your own Azure subscription.