# Per-File Diff JSON Contract
This document defines the shape of the optional per-file `diff` field carried on the agent completion payload submitted to `PATCH /api/tasks/:id/complete`. Plugin maintainers across the six Stride plugin repos (`stride`, `stride-copilot`, `stride-gemini`, `stride-codex`, `stride-opencode`, `stride-pi`) write against this contract; the Stride server validates against it and the review queue UI renders it.
The field is **optional**. Legacy plugin versions that do not emit it produce a working review with no diff panel (no crash, no error). New plugin versions emit it so reviewers can approve or reject without leaving Stride.
## Field location
The completion payload gains a `changed_files` array. Each entry is an object describing one file the agent touched.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | Yes | Repo-relative file path (e.g., `lib/foo.ex`). Matches the path the plugin would put in `actual_files_changed`. |
| `diff` | string | No | Unified-patch text for this file, truncated to the rule below. Omit for binary files (see below) and for legacy emit paths that do not capture diffs. |
Example completion payload fragment:
```json
{
"actual_files_changed": "lib/foo.ex, lib/bar.ex, assets/logo.png",
"changed_files": [
{"path": "lib/foo.ex", "diff": "--- a/lib/foo.ex\n+++ b/lib/foo.ex\n@@ -1,3 +1,4 @@\n defmodule Foo do\n+ @moduledoc \"Foo\"\n end\n"},
{"path": "lib/bar.ex", "diff": "--- a/lib/bar.ex\n+++ b/lib/bar.ex\n@@ -10,7 +10,7 @@\n def call(x), do: x\n- def old(x), do: x\n+ def new(x), do: x + 1\n"},
{"path": "assets/logo.png", "diff": "[binary file — no diff captured]"}
]
}
```
`changed_files` coexists with `actual_files_changed` (the existing comma-separated string). The server keeps `actual_files_changed` as the canonical file list for backward compatibility; `changed_files` adds structured per-file content alongside it.
## Encoding
`diff` is **unified-patch text** — the same format produced by `git diff` with no special flags. The `diff` value represents whatever change exists between `` and the agent's working tree at completion time, regardless of commit state — committed-since-base, staged-but-uncommitted, and modified-but-unstaged changes all surface in the same patch.
Plugins SHOULD generate the per-file diff for tracked changes with the working-tree-relative form:
```bash
git diff --
```
For untracked new files that the agent created but never committed, plugins SHOULD enumerate them with `git ls-files --others --exclude-standard` and synthesize a unified-patch entry per file. The standard idiom is `git diff --no-index --no-color /dev/null `, which emits a normal new-file unified patch (with `--- /dev/null` / `+++ b/` headers) for text files and the `Binary files /dev/null and b/ differ` sentinel for binary files (use the binary placeholder string for the latter — see the **Binary files** section below).
> **Why the working-tree form, not `..HEAD`?** The earlier contract specified `git diff ..HEAD -- `, which captures committed history only. Agents that complete a task without committing first (a common Claude Code pattern when work fits in a single conversation) produced empty snapshots under that form. The working-tree-relative form captures the agent's full working state at completion time and matches what the reviewer would see if they `git diff`'d the agent's branch locally.
Rules:
- UTF-8 encoded.
- Includes the standard `--- a/` (or `--- /dev/null` for new files) / `+++ b/` headers and one or more `@@ ... @@` hunks.
- Line endings are preserved as the plugin captured them; the server does not normalize.
- No syntax highlighting, no HTML, no ANSI color codes — plain text only.
- A single path that's both committed-since-base AND further modified in the working tree appears exactly once in the snapshot, with a diff that reflects the final working-tree state.
## Truncation
The `diff` value for any single file MUST NOT exceed **500 lines** (counting newlines in the captured patch text). Plugins are responsible for truncating before sending; the server applies a defensive backstop and rejects payloads where any per-file `diff` exceeds the cap.
When a plugin truncates, it appends this exact marker on its own line at the end of the truncated content:
```
[diff truncated at 500 lines]
```
The truncation marker is the source of truth across all plugins and the UI. The review queue panel renders a "view full diff in repo" affordance when it sees this marker; do not vary the string.
Example truncated diff:
```diff
--- a/lib/big_module.ex
+++ b/lib/big_module.ex
@@ -1,500 +1,500 @@
... 498 lines of patch content ...
[diff truncated at 500 lines]
```
## Binary files
Plugins MUST NOT attempt to produce a unified-patch diff for binary files (images, compiled artifacts, fonts, etc.). For each binary file the agent touched, emit a `changed_files` entry with `path` set and `diff` set to this exact placeholder:
```
[binary file — no diff captured]
```
The UI renders this placeholder as a "binary file changed" notice with no preview. Detection of "binary" is the plugin's responsibility — `git diff --numstat` reports tracked binary files with `-\t-` as the line counts. For untracked new files, plugins can detect binary content by sniffing the `Binary files ... differ` line that `git diff --no-index --no-color /dev/null ` emits in place of a unified patch. `file --mime-encoding` can disambiguate edge cases.
Untracked new files that the plugin captures via the synthesized new-file patch (see the **Encoding** section above) appear in the snapshot with a `--- /dev/null` / `+++ b/` header; untracked binaries use the placeholder string above just like tracked binaries.
## Backward compatibility
- The `changed_files` field is **optional** on the completion payload. Payloads that omit it continue to validate.
- Per-file `diff` is optional within each `changed_files` entry. A plugin MAY emit `{"path": "lib/foo.ex"}` with no `diff` if it could not capture one (e.g., the file was deleted, the base ref was unavailable, the diff exceeded a sanity cap). The UI renders "no diff available" for such entries.
- Server-side validation accepts entries with or without `diff`; only malformed values (non-string `diff`, missing `path`, oversized per-file `diff`) are rejected.
- Older plugin versions that emit only `actual_files_changed` continue to work; the review queue shows the file list with no inline diff panel content.
## Transport encoding (optional, edge-filter safe)
Some deployments sit behind an edge request filter (a CDN/WAF layer in front of the app) that inspects request bodies and rejects any body whose text resembles attack syntax. A raw unified diff of ordinary source code can trip such a filter, so the upload is dropped before it reaches the app and `changed_files` silently stays empty (D61).
To avoid that, `PUT /api/tasks/:id/changed_files` **also** accepts a transport-encoded envelope in place of the raw array. The envelope wraps the **same JSON array** (the `[{"path", "diff"}]` shape above), encoded so the body carries no recognizable source text:
```json
{ "changed_files": { "encoding": "base64", "data": "" } }
```
- `encoding` MUST be `"base64"` or `"gzip+base64"` (gzip the JSON array, then base64 the gzipped bytes).
- `data` MUST be the encoded JSON array; once decoded it is validated and stored **identically** to the raw shape — all rules above (per-file 500-line cap, binary placeholder, `path` required) apply to the decoded entries.
- This is **purely additive**. The raw array (`{"changed_files": [...]}`) and the bare top-level array body continue to work unchanged; plugins only need the envelope when an edge filter would otherwise reject the raw diff.
- The server bounds the encoded and decoded sizes and returns the standard `completion validation failed` (422) envelope for invalid base64, an unsupported `encoding`, a decompression failure, or a payload that does not decode to a JSON array.
Plugin maintainers SHOULD prefer the encoded form for the diff upload when targeting an edge-filtered deployment, and SHOULD surface a failed upload (a non-2xx response) rather than discarding it silently.
## What plugin maintainers MUST emit vs MAY emit
**MUST**, when emitting `changed_files`:
- An array (possibly empty) of objects, each with a string `path`.
- Truncation applied per-file at 500 lines, with the exact marker string above.
- The exact binary-file placeholder string for binary entries.
**MAY** (per-plugin discretion):
- Omit `changed_files` entirely on legacy code paths.
- Omit `diff` on individual entries where capture failed or was skipped.
- Skip `changed_files` for tasks the plugin classifies as "no diff worth showing" (docs-only edits, generated-file-only changes).
## See also
- [`PUT /api/tasks/:id/changed_files`](api/put_tasks_id_changed_files.md) — the dedicated upload endpoint and **sole writer** for this field (as of W777/W778).
- [`PATCH /api/tasks/:id/complete`](api/patch_tasks_id_complete.md) — the completion endpoint; **no longer writes `changed_files`** — any value in the completion body is silently ignored. Sending it for backwards compatibility is tolerated.
- `lib/kanban_web/components/review_diff_panel.ex` — the review queue component that consumes this field.
- `lib/kanban/tasks/completion_validation/changed_files.ex` — server-side validator (`changed_files` validation added in W720; extracted from `completion_validation.ex` in W1953 and still reached through `CompletionValidation.validate_changed_files/1`).