Collect Claude compliance events with a feed-only credential. Resolve a file name or artifact title only through a separate, case-bound service, because the lookup scope can read far more user data than the activity collector needs.
Anthropic changed the shape of its Compliance API Activity Feed on September 24, 2026. File names, project document names and artifact titles are now always empty, null or omitted, including on activities recorded before the change. The event still carries the resource identifier needed for a separate metadata lookup, but that lookup requires a broader scope. (Claude Platform release notes)
That is not a parser patch. It is an authority boundary.
Keep the collector on the smaller scope
The activity collector should not gain user-data access merely because an analyst sometimes wants a human-readable name. Anthropic documents read:compliance_activities as sufficient for the Activity Feed. The separate read:compliance_user_data scope can read user chats, messages, files, projects, session metadata and transcripts, organization users and group members. (Compliance API access)
Give those responsibilities different processes and different credentials:
| Component | Trigger | Credential | Retained output |
|---|---|---|---|
| Activity collector | Scheduled or cursor-driven poll | read:compliance_activities | Raw event, cursor, normalized resource identifiers |
| Enrichment broker | Approved investigation request | read:compliance_user_data | Case receipt and the minimum resolved metadata |
The collector can forward events to a SIEM without possessing the higher-scope secret. The enrichment broker can remain behind an internal service boundary that checks organization, resource, purpose, approver and expiry before it touches the metadata endpoint.
An Admin API key can call the Activity Feed but cannot be granted other Compliance API scopes. A Compliance Access Key can carry the broader scope, and its scopes are immutable after creation. That makes separation straightforward: do not put a broad Compliance Access Key into the collector and promise to use only its smaller capability. Issue the collector the smallest credential the job accepts. (Compliance API access)

This follows the same issuance-before-rotation principle as our API key governance method: a credential is safe only when its allowed job was defined before it was minted. Rotation reduces the lifetime of a mistake. It does not repair an over-broad role.
Treat missing names as the contract
A resilient handler does not synthesize a display name, recover one from an old cached event, or fail the whole ingestion page because filename is absent. It records the event as evidence and labels the display metadata unresolved.
The Activity object has stable top-level evidence: an activity ID, timestamp, organization identifiers, actor union and activity type. Type-specific resource IDs can appear alongside those fields. Anthropic explicitly recommends passing through unknown type and actor.type values while ignoring unexpected fields, because the feed contains hundreds of activity types and can grow. (Activity Feed guide)
The normalizer in the reference gate keeps that contract small:
const resourcePrefixes = [
["file", "claude_file_"],
["project_document", "claude_proj_doc_"],
["artifact_version", "claude_artifact_version_"],
];
function resourceKindFromId(value) {
return resourcePrefixes.find(([, prefix]) => value.startsWith(prefix))?.[0] ?? null;
}
function requiredString(value, name) {
if (typeof value !== "string" || value.length === 0) {
throw new TypeError(`${name} must be a non-empty string`);
}
return value;
}
export function normalizeActivity(raw) {
const resources = [];
for (const [field, value] of Object.entries(raw)) {
if (typeof value !== "string" || !field.endsWith("_id")) continue;
const kind = resourceKindFromId(value);
if (kind) resources.push({ kind, id: value, sourceField: field });
}
return {
id: requiredString(raw.id, "activity.id"),
createdAt: requiredString(raw.created_at, "activity.created_at"),
organizationId: raw.organization_id ?? null,
type: requiredString(raw.type, "activity.type"),
actor: { type: requiredString(raw.actor?.type, "activity.actor.type") },
resources,
};
}Notice what is missing: no required filename, no required title, and no closed enum that rejects a new event or actor type. Raw events should still be retained under the organization's evidence policy, but the normalized operational view should not depend on fields the provider says are absent.
Authorize enrichment as a new action
Metadata lookup is a new privileged action, not a continuation of collection. The gate should therefore bind the exact lookup before the higher-scope service runs it.
The minimum authorization record contains:
- the source activity ID;
- the organization ID from that activity;
- the exact resource ID present on the activity;
- an investigation or case ID;
- a bounded purpose from an allowlist;
- the approver or policy decision that authorized the lookup;
- an expiry time; and
- the required scope, recorded as evidence rather than inferred later.
The reference implementation produces an immutable intent receipt and never carries the secret in that object:
const receipt = {
activityId: event.id,
organizationId: event.organizationId,
resourceId: resource.id,
resourceKind: resource.kind,
caseId: request.caseId,
purpose: request.purpose,
approvedBy: request.approvedBy,
expiresAt: request.expiresAt,
requiredScope: "read:compliance_user_data",
};
return {
status: "authorized",
intentId: stableHash(receipt),
receipt,
};Validate the source event
Require the activity, organization and exact resource identifier. If the requested resource is not present on the event, return
unavailable; do not accept a caller-supplied replacement.Validate the case
Require a case, allowed purpose, approver and unexpired authorization. Match the event's organization and activity type against policy before a higher-scope call is possible.
Execute outside the collector
Send only the authorized intent to the enrichment broker. The broker obtains its secret from its own runtime and calls the matching metadata endpoint.
Retain the minimum result
Keep the lookup receipt, provider request ID, terminal state and the name or title the investigation needed. Do not copy the whole user-data response into a general event index.
This boundary also gives security teams a clean negative test: the collector should fail if it attempts a user-data endpoint. A working feed plus a denied enrichment call is evidence that the credential separation is real.
Reconcile every lookup
An HTTP success is not enough. The returned resource must match the identifier in the authorized intent, and failure states must remain distinct so the operator does not convert uncertainty into absence.
The reference gate uses these terminals:
observed: a successful response carries the authorized resource ID;scope_denied: a403permission_errorsays the runtime does not hold an accepted scope;unavailable: a resource-specific404means the object was deleted or never existed;uncertain: a retryable transport, rate-limit or server condition prevented read-back; anddivergent: the provider returned a different resource or an unexpected response contract.
Anthropic says clients should branch on HTTP status plus error.type, not message text. A 429 waits for retry-after without advancing the cursor. 502, 503, 504 and 529 retry with backoff, while a 500 follows x-should-retry. (Compliance API errors)
if (response.status === 403 && response.errorType === "permission_error") {
return { ...base, status: "scope_denied", retry: false };
}
if (response.status === 404) {
return { ...base, status: "unavailable", retry: false };
}
if ([429, 502, 503, 504, 529].includes(response.status)) {
return { ...base, status: "uncertain", retry: true };
}
if (response.body?.id !== intent.receipt.resourceId) {
return { ...base, status: "divergent", reason: "resource_id_mismatch" };
}
This is the evidence pattern behind intent, acceptance and observation receipts. A request proves what the system meant to inspect. A response proves only what came back. Reconciliation proves whether the two refer to the same resource.
Do not retry unavailable as though deletion were a transient outage. Anthropic states that a name cannot be resolved after the file, project document or artifact is deleted, or when the activity has no matching ID. The correct output is a retained unavailable state, not an endless queue. (Activity Feed guide)
Commit cursors after durable storage
The least-privilege split is incomplete if the collector can silently skip evidence. Activity Feed pages are newest first, return 100 entries by default and accept up to 5,000. Their cursors are opaque and must be passed back unchanged. (Activity Feed guide)
For catch-up reads toward the present, Anthropic's integration pattern persists first_id and later passes it as before_id. For an older backfill, it persists last_id and passes it as after_id. In either direction, the durable cursor moves only after every activity in the covered page has been stored. (Integration patterns)
export function nextCursor(page, storedActivityIds, mode, previous) {
const stored = new Set(storedActivityIds);
const complete = page.data.every((activity) => stored.has(activity.id));
if (!complete) return previous;
return mode === "backfill"
? page.last_id ?? previous
: page.first_id ?? previous;
}The metadata broker does not control this cursor. Collection remains complete even when every enrichment request is denied, unavailable or deferred. That independence is the operational payoff of the split.
Release the boundary, not only the parser
The dependency-free reference gate for this article passed 18 of 18 local tests. The cases cover missing names, ignored legacy names, unknown activity and actor types, absent resource IDs, incomplete authorization, wrong organization, unapproved activity and purpose, expiry, authorized intent, scope denial, deletion, retryable uncertainty, resource mismatch, successful observation and both cursor directions.
It did not call Anthropic and is not evidence of a production deployment. It is a testable starting contract. Before release, a company should still prove all of these in its own environment:
- the collector secret cannot reach user-data endpoints;
- the enrichment secret exists only in the broker runtime;
- policy rejects a resource ID not carried by the source activity;
- every lookup produces an intent and result receipt;
- only
uncertainresults enter a bounded retry queue; - a deleted resource stays
unavailable; - a mismatched success becomes
divergent; and - the collection cursor advances only after durable page storage.
The key design choice is simple: preserve a useful, low-scope evidence stream by default. Spend higher authority only on a named investigation that can justify it.
FAQ
Does the Claude Activity Feed require read:compliance_user_data?
No. Anthropic documents read:compliance_activities as sufficient for an audit pipeline that reads only the Activity Feed. Metadata and content endpoints need a broader accepted scope.
Why are Claude Compliance API filenames and titles empty?
Anthropic removed file names, project document names and artifact titles from Activity Feed records on September 24, 2026. The fields are empty, null or omitted even on activities recorded before that date.
Does a claude_*_viewed event prove a person saw the content?
No. It means a Claude app loaded the content. Loads can happen at different moments across clients, can occur in the background and are not deduplicated.
Can a deleted file name be recovered from an activity?
Not through the documented metadata lookup after deletion. Preserve the activity and its resource ID as evidence, then classify the enrichment result as unavailable rather than retrying indefinitely.




