Activity episodes
One unit of work produces evidence in several event families. A prompt, four file changes, a review finding, a CI job, and a deployment are all separate records, and they target different entities. An activity episode is the durable key that says those records belong to the same stretch of work, so a reader can rebuild it and score it as one thing.
What an episode is
Section titled “What an episode is”An activity episode is a Layer-1 grouping: a named span of work that owns a set of events. It is identified by an opaque string carried at metadata.activityEpisodeId on every event in the span.
It is deliberately not an event-schema field. spec/event.schema.json contains no activityEpisodeId; the dotted-action pattern and open metadata object already accept it. That keeps episodes additive — an SDK, store, or verifier that has never heard of episodes still reads, hashes, and verifies these records correctly, because nothing about the envelope changed.
The episode id is opaque to the protocol. Nothing enforces a prefix. The fixture below uses a host-chosen aep_…; the Claude Code adapter derives ep_<sessionId>. Only equality matters.
Opening an episode
Section titled “Opening an episode”episodeStartedTemplate emits the canonical opening event:
actionis alwaysactivity.episode.started. The template owns it, so a caller cannot drift onto a near-miss string and silently split a read model.targetis{ type: "activity_episode", id }. The episode is a first-class evidence entity, so evidence-graph edges can point at it directly.purposedefaults tochange_provenanceandretentiontosecurity_1y.authSessionId,authContextId,domain, andstartReasonland in template-reserved metadata.
The event answers three questions a downstream group-by cannot answer on its own: when the episode opened, who opened it, and why. startReason is the human-legible cause — deploy_requested, incident_response, scheduled_job. Without an opening event you can still group events by key, but you have no record of the episode’s own beginning, and an episode whose first surviving event is a deploy.deployed looks identical to one whose earlier events were never recorded.
Emitting the opening event is optional. Stamping the key is what actually makes the group-by work; activity.episode.started makes the group explainable.
The stamp is applied last
Section titled “The stamp is applied last”The grouping key is only trustworthy if the code being audited cannot rewrite it. Every path that stamps activityEpisodeId applies it after caller-supplied metadata.
caller metadata ↓ merge — template-reserved keys wintemplate metadata (authSessionId, authContextId, domain, startReason) ↓ stamp — unconditional assignmentmetadata.activityEpisodeId = input.activityEpisodeId ↓ withRiskSignals — only when riskSignals was suppliedmetadata.riskSignals = normalizeRiskSignals(signals)The checked fixture hands the template a hostile caller: metadata that claims activityEpisodeId: 'aep_caller_supplied' and a cheap riskSignals: { operationType: 'read' } envelope for what is actually an irreversible production release.
{ "beforeStamping": { "actor": { "type": "service", "id": "svc_release_bot" }, "action": "activity.episode.started", "target": { "type": "activity_episode", "id": "aep_release_2026_08_09_01" }, "metadata": { "domain": "release", "releaseId": "rel_4471", "riskSignals": { "operationType": "read" }, "activityEpisodeId": "aep_release_2026_08_09_01", "startReason": "deploy_requested" }, "id": "evt_episode_started_01", "occurredAt": "2026-08-09T10:00:00.000Z", "scope": { "tenantId": "org_acme", "environment": "production" }, "purpose": "change_provenance", "retention": "security_1y" }, "afterStamping": { "actor": { "type": "service", "id": "svc_release_bot" }, "action": "activity.episode.started", "target": { "type": "activity_episode", "id": "aep_release_2026_08_09_01" }, "metadata": { "domain": "release", "releaseId": "rel_4471", "riskSignals": { "operationType": "destructive", "reversibility": "irreversible", "envCriticality": "production", "dataVolume": 0, "fanOut": 3, "referenceCount": 0 }, "activityEpisodeId": "aep_release_2026_08_09_01", "startReason": "deploy_requested" }, "id": "evt_episode_started_01", "occurredAt": "2026-08-09T10:00:00.000Z", "scope": { "tenantId": "org_acme", "environment": "production" }, "purpose": "change_provenance", "retention": "security_1y" }, "proof": { "action": "activity.episode.started", "actionIsEpisodeStarted": true, "targetIsTheEpisode": { "type": "activity_episode", "id": "aep_release_2026_08_09_01" }, "callerClaimSurvivesWithoutStamp": true, "stampOverwritesCallerClaim": true, "callerClaimAbsentAfterStamping": true, "unrelatedCallerKeysPreserved": { "domain": "release", "releaseId": "rel_4471" }, "activityEpisodeIdUnshadowable": true, "directStamp": { "operationType": "destructive", "reversibility": "irreversible", "envCriticality": "production", "dataVolume": 0, "fanOut": 3, "referenceCount": 0 }, "callerMetadataUnmutated": "read" }, "riskHiddenByTheShadowAttempt": { "callerClaimScore": 0.05, "callerClaimLevel": "low", "stampedScore": 1, "stampedLevel": "critical" }, "sealedEvent": { "schemaVersion": "2026-06-10", "action": "activity.episode.started", "riskSignals": { "dataVolume": 0, "envCriticality": "production", "fanOut": 3, "operationType": "destructive", "referenceCount": 0, "reversibility": "irreversible" }, "activityEpisodeId": "aep_release_2026_08_09_01" }}activityEpisodeIdUnshadowable is true: the stamped id survives, the caller’s substitute does not. unrelatedCallerKeysPreserved shows the stamp is targeted rather than a wipe — domain and releaseId come through untouched. The same ordering protects the risk envelope, and riskHiddenByTheShadowAttempt measures what the substitution would have concealed: 0.05 / low from the caller’s claim against 1 / critical from the observed signals.
Key insertion order in the printed object is incidental. Metadata keys are sorted during redaction before canonicalization, which is why sealedEvent.riskSignals comes back alphabetized. Two events with the same metadata values hash identically regardless of the order a host built them in. See hash chain.
There are two enforcement styles, and the difference matters when you are debugging:
- Templates and the provenance recorder overwrite.
buildTemplateassignsmetadata.activityEpisodeIdafter the merge; the recorder’sbuildEventspreads caller metadata first and then setssessionIdandactivityEpisodeId. A caller’s value is discarded silently. - Governed changes reject.
mergeVeritioMetadatatreatsactivityEpisodeIdas a reserved context key alongsideauthSessionId,traceId,changeId, and others. Supplying one in caller metadata throwsmetadata.activityEpisodeId is reserved by Veritiorather than being quietly replaced. Governed changes fail closed because the same metadata feeds Change, Trace, and Explain projections.
Neither mode lets application code choose which episode its evidence lands in. That is the point: the episode boundary is set by the capture boundary, not by the code under audit.
Why the group-by is necessary
Section titled “Why the group-by is necessary”The obvious objection is that the evidence graph should already connect these records. It does not, and the reason is structural rather than incidental.
A provenance session’s events target different entities by design. agent.prompt.recorded and agent.tool.called target the agent_session. change.files.changed targets a source_tree. review.approval.recorded targets a pull_request. deploy.deployed targets an environment or release. Several of those entities are shared: two concurrent sessions editing the same repository produce file-change events against the same source tree, and a pull request accumulates reviews from humans and agents alike.
So an edge from a shared entity does not identify the session that produced a record. Traversal from the shared node fans out to everything that ever touched it, and traversal from the session node stops wherever a record was attached to a shared entity instead of a session-private one. The stamped key sidesteps traversal entirely: WHERE metadata.activityEpisodeId = ? is exact, cheap, and does not depend on the graph being complete.
The recorder stamps metadata.sessionId for the same reason and at the same point in the merge. sessionId scopes to one agent session; activityEpisodeId can span several — a release episode that opens before an agent session starts and closes after a deployment lands.
Deriving an id with no shared memory
Section titled “Deriving an id with no shared memory”Agent capture makes this harder than it looks. Claude Code invokes its hooks as separate short-lived processes. SessionStart, each PostToolUse, and Stop run in different processes with no shared heap, and any process may be the first to run after state is lost.
episodeIdOf solves that by deriving the id from something every process already has:
export function episodeIdOf(sessionId: string): string { return `ep_${sanitize(sessionId)}`}sanitize replaces every character outside [A-Za-z0-9_-] with _.
hook process 1 SessionStart session_id=sess_a7f2 → ep_sess_a7f2hook process 2 PostToolUse session_id=sess_a7f2 → ep_sess_a7f2hook process 3 Stop session_id=sess_a7f2 → ep_sess_a7f2The adapter resolves the id by a fixed precedence: persisted session state first, then the opt-in VERITIO_ACTIVITY_EPISODE_ID override, then episodeIdOf(session_id). Persisted state wins so the id stays frozen once the session has begun; the override only takes effect on the first SessionStart, before state exists, and exists to thread several sessions into one operator-defined episode.
The derivation is a function, not a hash. Because sanitize is lossy, two session ids that differ only in punctuation — a/b and a_b — map to the same episode id. Claude Code session ids are UUID-shaped, so this does not arise there, but a capture adapter for a source with punctuation-bearing ids should derive its own id rather than assume episodeIdOf is injective.
Rolling an episode into one score
Section titled “Rolling an episode into one score”rollupEpisodeRisk consumes steps, not events. Building the steps is exactly the group-by:
records WHERE metadata.activityEpisodeId = 'ep_sess_a7f2' ↓ scoreRiskSignals(metadata.riskSignals) per recordsteps [{ occurredAt, score, action? }, …] ↓ rollupEpisodeRisk(steps, DEFAULT_RISK_POLICY){ score, level, peak, velocityScore, stepCount, policyVersion }Under veritio.reference.v1 the rollup sorts steps by occurredAt, tracks peak as the highest single step score, and accumulates momentum with a 0.5 decay per elapsed 60-second window. velocityScore is that maximum momentum divided by a normalizer of 3.0, clamped to [0,1]. The episode score is the maximum of peak and velocityScore — and of frequencyScore too when the policy configures frequency rules, which the default policy does not.
Only peak is grouping-independent. velocityScore, stepCount, and every frequency rule are functions of which steps were placed in the same bucket, which is why a wrong or missing key changes the answer rather than merely losing a label:
- Key absent. The record cannot be selected into any episode. It never becomes a step,
stepCountis short, and a burst of moderate individual scores never compounds into velocity. The episode looks calmer than the work was. - Key split across two ids. One stretch of work becomes two episodes. Momentum restarts at the split, and a frequency rule that needed six qualifying steps inside a window sees three and three.
- Key over-merged. Unrelated concurrent work shares an id. Momentum accumulates across activity that never interacted, and the rollup reports velocity nobody generated.
The rollup makes no attempt to detect any of these. It scores the steps it is given. Correct grouping is an input invariant, which is why the key is stamped at the capture boundary and made un-shadowable there.
Privacy and parity
Section titled “Privacy and parity”activityEpisodeId is non-PII by construction. It is a stable synthetic identifier — a sanitized session id or an operator-chosen release label — and it must not match Veritio’s redaction key pattern (password|secret|token|api_key|authorization|email|phone|ssn), because a match would replace the value with [redacted] and destroy the join. That constraint is on the key name, which is fixed. Redaction is by key name only, so it will not rescue a badly chosen value: an episode id built from a user’s email address would travel intact into canonical bytes and hashes. Keep episode ids opaque and derived from identifiers that are already safe to store. See redaction.
Parity is uneven, and the split is worth stating precisely:
- The template layer has full parity.
episodeStartedTemplate(TypeScript),activity_episode_started_template(Python), andEpisodeStartedTemplate(Go) all emitactivity.episode.started, target the episode entity, and stampmetadata.activityEpisodeIdafter caller metadata through their shared template builder. - The reserved-key rejection has full parity. All three SDKs raise on a caller-supplied
metadata.activityEpisodeIdin the governed-change path, with the samemetadata.activityEpisodeId is reserved by Veritiomessage. - The recorder and capture stamp are TypeScript only. The provenance recorder that stamps
sessionIdandactivityEpisodeIdonto every downstream event exists only in TypeScript, as does the Claude Code capture adapter that derives the id. This is a documented parity obligation, not a design choice: a Python or Go provenance recorder or capture adapter must reproduce the same post-caller stamp and the same derivation before it can be considered at parity.
What an episode does and does not prove
Section titled “What an episode does and does not prove”A complete episode shows that a set of records was captured under one grouping key and, with an opening event, when and why that group began. Combined with a verified chain it shows those records have not been altered since they were appended.
It does not prove that the episode is complete. Events emitted by code paths that were never instrumented, or by a process that crashed before its hook ran, leave no trace of their absence — the key groups what was recorded, and a chain proves nothing was changed, but neither can attest to work that was never captured. It also does not prove causation: sharing an episode means sharing a capture boundary, not that one step caused another. Causal claims belong on explicit edges.
Continue with risk scoring for how a single step’s signals become a score, agent events for the recorder that stamps the key on every event a session emits, or changes, activities, and revisions for the governed-mutation path that treats the key as reserved.