Policy replay answers one question before you commit a policy change: would this
maintainer.yml have decided differently on a session that already happened? You hand
it a session id and a candidate policy. It re-runs the policy decisions that session
recorded in its audit trail against the candidate, and returns the decisions diff.
It is a dry run. It executes nothing, re-triggers no GitHub event, and changes neither the session nor your stored policy.
What it re-decides
A session's audit trail records two kinds of decision with most of their inputs stored beside them, so replay can decide them again:
| Recorded event | What was decided | What replay answers |
|---|---|---|
policy.gate.refused |
The policy refused the agent a capability (for example, a tool it was not allowed to use). | allowed or refused under the candidate. |
escalation.fired |
An escalation was routed to a channel by its category. | The channel under the candidate: email-immediate or digest, with the routing reason in detail. |
A session.escalated row is the session's outcome rather than a decision with inputs, so
it comes back as context, always unchanged.
Some limits follow from what the trail stores:
- The original policy is not rebuilt. Only its hash was recorded, so "before" is the outcome the session actually recorded, and "after" is the candidate's answer.
- An agent's own request for an immediate email is not replayed. When the agent asked
for an escalation to be emailed straight away rather than held for the digest, replay
decides it without that request, so it can come back as
digestandchanged: trueeven under a candidate identical to your current policy. This is a known gap. - The model is not re-run. Replay tells you which decisions flip. It cannot tell you what the agent would have done next if one had.
security,licenseandclaalways route toemail-immediate. No candidate changes that, so those rows never show as changed.- Decisions made outside the session are out of reach: CI policy decisions, triage
verdicts, dispatch escalations, hand-offs and CodeRabbit enqueue decisions. If the
session's trail carries any, their kinds are listed in
unsupported_kindsinstead of being guessed at.
Replay reads whatever the session has recorded when you call it, so replay a session that
has finished to see all of its decisions. Find session ids in the dashboard's
Audit log, from GET /v1/sessions, or with the MCP
session list action. They look like ses_….
Where to run it
All three doors run the same check, so a candidate each of them accepts gets the same diff from each. They differ only in how long a candidate may be: 65,536 characters over REST and the dashboard, 64,000 over MCP.
Dashboard
Open the Audit log, open a session, and choose Replay with new policy. Paste the
candidate maintainer.yml and choose Run replay. The panel lists every replayed
decision marked Changed or Unchanged, with a count of the decisions that would
change and the candidate's policy hash.
REST
POST /v1/sessions/:id/replay with a JSON body holding the candidate as text. The
candidate is a whole maintainer.yml, not a fragment: without version: 1 it fails to
parse. This one keeps direct_mention in escalate.always and drops hostile_tone:
# candidate.yml
version: 1
escalate:
always: [direct_mention]
curl -X POST "https://api.developerz.ai/v1/sessions/$SESSION_ID/replay" \
-H "Authorization: Bearer $DEVELOPERZ_PAT" \
-H "Content-Type: application/json" \
--data "$(jq -n --rawfile yml candidate.yml '{yml: $yml}')"
$DEVELOPERZ_PAT is a personal access token (dev_pat_…) that holds read:sessions.
jq --rawfile sends the file as one JSON string. The body accepts exactly one field,
yml: non-empty, at most 65,536 characters. Any other field is refused.
MCP
The session tool, action replay, with the session id and the candidate:
{
"jsonrpc": "2.0",
"method": "tools/call",
"id": 1,
"params": {
"name": "session",
"arguments": {
"action": "replay",
"session_id": "ses_…",
"yml": "version: 1\nescalate:\n always: [direct_mention]\n"
}
}
}
Over MCP, yml is capped at 64,000 characters, slightly below the REST limit.
Scope
read:sessions, on both REST and MCP. REST serves replay as a POST because it takes a
body, but it is a read: it changes nothing, so it needs no write scope. See
Scopes.
What it returns
{
"session_id": "ses_…",
"policy_hash": "…",
"decisions": [
{
"audit_event_id": "…",
"kind": "escalation.fired",
"at": "2026-10-01T09:14:03.000Z",
"category": "hostile_tone",
"changed": true,
"original": "email-immediate",
"replayed": "digest",
"detail": "routine"
}
],
"changed_count": 1,
"unsupported_kinds": []
}
| Field | Meaning |
|---|---|
session_id |
The session you replayed. |
policy_hash |
A hash of the candidate policy, so a diff is tied to the exact input that produced it. |
decisions |
One entry per replayed decision, in the order the session recorded them. |
decisions[].kind |
policy.gate.refused, escalation.fired or session.escalated. |
decisions[].at |
When the original decision was recorded. |
decisions[].capability |
The refused capability, on policy.gate.refused rows. |
decisions[].category |
The escalation category, on escalation.fired rows. |
decisions[].original |
The outcome the session recorded. |
decisions[].replayed |
The outcome under the candidate. |
decisions[].changed |
true when the two differ. |
decisions[].detail |
Extra context, such as the reason an escalation was routed where it was. |
changed_count |
How many decisions would change. |
unsupported_kinds |
Decision kinds found in the trail that replay cannot re-decide. Normally empty. |
The MCP action returns the same object.
When it refuses
The candidate is validated before the session is looked up, with the same parse and
semantic checks as POST /v1/repos/:owner/:repo/validate.
| REST | MCP | Why |
|---|---|---|
400, detail: "invalid .maintainer.yml: parse failed" or "… semantic validation failed", with the validator's messages in errors |
an error result carrying the same error sentence and errors |
The candidate does not parse, or parses into a policy that validation refuses. Fix the YAML. |
400, detail: "invalid replay body" |
the call's input is refused as invalid | REST: the body is missing yml, carries another field, or yml is over 65,536 characters. MCP: yml is missing or over 64,000 characters, or session_id is not a well-formed ses_… id. |
404 |
an error result naming the session | No such session on your account. Another account's session gets the same answer, so a session you cannot read is never confirmed to exist. Over REST a malformed id gets this answer too; over MCP it is refused as invalid input (the row above). |
Errors are problem documents, like every other REST error.