# The Involut contract

Involut reads three files from a `.review/` folder. Any agent that can write a file can
feed it — no SDK, no MCP, nothing to integrate against.

```
.review/
  material.json   ← written by the emitter (`rounds`). Nothing else touches it.
  cards.json      ← written by the agent. The emitter never touches it.
  notes.json      ← written by you, through the viewer. Agents read it; nobody else writes it.
  blobs/          ← the "before" copy of each binary file
```

Three files rather than one on purpose: each has exactly one owner, so none can clobber
another and there is no race when they are written at the same time.

They are also the loop of [PHILOSOPHY.md](PHILOSOPHY.md) as plumbing: the material is what
the agent produced, the cards are its confession, the notes are the human talking back. The
metacognitive loop, as three JSON files.

## The rule that outranks everything

> **Augmentation may only ADD, never HIDE.**
> No card may be the only way to reach a change. The material is always whole and always
> reachable. If you don't trust the notes, drop to Raw and you have everything.

That is why material and cards are separate lists, and why cards only *reference* material
instead of containing it. If an agent writes no cards at all, Involut is still a decent diff
viewer and you have lost nothing.

## material.json — level 0, mandatory

```json
{
  "contract": 1,
  "source": {
    "kind": "git",
    "id": "my-game@a3f9c21",
    "root": "/home/you/projects/my-game",
    "request": null
  },
  "generated": "2026-08-30T16:40:00-03:00",
  "material": [
    { "ref": "src/auth/session.ts", "status": "modified", "binary": false,
      "diff": "@@ -12,6 +12,9 @@\n+const IDLE_MS = 30 * 60_000;\n…", "hash": "3f9a1c",
      "commits": ["feat(auth): sessions expire after 30 idle minutes"] },

    { "ref": "assets/hero.png", "status": "modified", "binary": true, "diff": null,
      "hash": "b2e4d0", "size": { "w": 1200, "h": 630 },
      "before": "blobs/assets_hero.png.a3f9c21.png", "before_size": { "w": 1200, "h": 630 } }
  ]
}
```

`status` is `added · modified · deleted · renamed`.

`source.kind` names what produced the material; here it is `git`.

`source.request` is always `null` here: the emitter does not know what you asked for. The
agent writes that at the top of `cards.json` (below), the file it owns. Without it the
`unrequested` signal cannot exist.

## cards.json — levels 1 and 2, optional

```json
{
  "contract": 1,
  "request": "make sessions expire after half an hour of inactivity",
  "cards": [
    {
      "id": "c_1",
      "title": "Sessions now expire after 30 idle minutes",
      "intent": "behavior",
      "minutes": 3,
      "refs": ["src/auth/session.ts", "src/middleware/auth.ts"],

      "why": "Idle time, not absolute time: a long form must not log you out while you type.",
      "skipped": "The expiry is checked on every request, not cached. Fine now, wasteful at scale.",

      "unsure": {
        "about":       "Where the idle timer lives",
        "chose":       "in the session store",
        "because":     "Every entry point sees the same clock, background jobs included.",
        "alternative": "in the middleware — you'd have to remember to add it to each new route."
      },

      "unrequested": null,

      "might_break": [
        "The mobile app refreshes tokens every 45 minutes. It will be logged out silently on the first idle gap."
      ],

      "question": {
        "when":    "before",
        "text":    "What do you think this might have broken?",
        "options": ["The mobile app", "Long-running exports", "Nothing, it's additive"],
        "answer":  "The first two: the app's refresh cadence is longer than the new expiry, and an export past 30 minutes loses its session at the end."
      },

      "render":  "tree",
      "payload": { "before": [...], "after": [...] },
      "continues": null,

      "marks": [
        { "ref":  "src/auth/session.ts",
          "at":   "idle > IDLE_LIMIT",
          "text": "The whole change is this comparison. Everything else is plumbing." }
      ]
    }
  ]
}
```

`request` is what the human asked for, in their words — the yardstick every `unrequested`
is measured against. Optional: leave it out when there was no request, as when an agent is
handed a repository to go over.

### What each field encodes

| field | what it is | why it is shaped this way |
|---|---|---|
| `refs` | pointers into material | cards never contain the change, they point at it |
| `intent` | `structure · behavior · cleanup · assets · risk` | groups by why it was done, not by where |
| `minutes` | time to understand it | feeds the daily budget; overestimate slightly |
| `why` | why it is built this way | outcome language, not technical |
| `skipped` | shortcuts and omissions | this is in no diff anywhere |
| `unsure` | where I wasn't confident | admitting doubt doesn't flatter me, so it can be believed |
| `unrequested` | what crept in unasked | kept apart from `why` so it doesn't get diluted |
| `might_break` | concrete sentences, not a score | a sentence you can judge; a score you must trust |
| `question` | `when: "before"` or `"after"` | before, you predict and learn; after, you only revise |
| `render` | `code · tree · graph · image · schema · table` | which drawing to use |
| `continues` | id of an earlier card | cards are never edited and never deleted |
| `marks` | a sentence pinned to one line of the diff | the comment sits where the eye is, not above the fold |

### What each `render` draws, and the `payload` it needs

| render | payload | draws |
|---|---|---|
| `code` (or none) | — | the diff, file by file |
| `tree` | `{ "before": [{ "name", "depth" }], "after": [...] }` | two indented hierarchies side by side: route trees, component trees, folders |
| `graph` | `{ "graph": { "nodes": [{ "id", "label?", "state?" }], "edges": [{ "from", "to", "label?", "state?" }] } }` | a layered drawing, sources left; `state` is `added · removed · changed` and colours the node or edge |
| `schema` | `{ "schema": { "before": [{ "name", "type?", "note?" }], "after": [...] } }` | one row per field with what happened to it |
| `table` | `{ "table": { "columns": [...], "rows": [[...]] } }` | rows and columns, for changes best read as data |
| `image` | optional `{ "frames": [{ "name", "x", "y", "w", "h" }] }` | before and after of a binary; with `frames`, only the frames of the sheet whose pixels changed, each zoomed |

`frames` is what turns a sprite sheet into a change you can see: the sheet is the file, the
frame is the change. Export the frame rectangles from whatever drew the sheet and the viewer
compares the pixels of each one.

**There is no `level` field.** The detail knob derives from `intent` and then learns from
you. If the level came from here, whoever produced the work would decide how much
attention it deserves — exactly what we don't want.

### Marks: a comment on one line

A mark is `{ ref, at, text }`. `ref` is one of the card's files; `at` is a substring of one
line of that file's diff, exactly as it reads in `material.json`; the viewer pins `text`
under the first line that contains it, and opens the file's fold so the mark is seen. A mark
whose `at` matches nothing is still shown, at the top of the file, quoting what it looked
for — a comment that finds no line is a fact about the diff, not something to drop.

### `null` and empty mean different things

- `null` → **I didn't fill this in.** The provider doesn't support the field, or didn't bother.
- `[]` or `""` → **I filled it in and there was nothing.**

The difference matters. `"might_break": []` means "I looked and found nothing", and you get
to hold that against me when something breaks. `"might_break": null` means I never looked.

## notes.json — your voice, optional

While you read, you can leave a note on any card or file: a question, an objection, an
instruction — "split this", "why not X?", "add a test here". The viewer writes them to this
file; the next agent you talk to reads them.

```json
{
  "contract": 1,
  "notes": {
    "c_1": {
      "title": "Sessions now expire after 30 idle minutes",
      "refs": ["src/auth/session.ts", "src/middleware/auth.ts"],
      "text": "The mobile app refreshes every 45 minutes — handle it, don't document it.",
      "at": "2026-09-02T18:10:00-03:00"
    },
    "file:assets/hero.png": { "title": "assets/hero.png", "refs": ["assets/hero.png"],
                              "text": "This image is hand-made. Don't regenerate it.", "at": "…" }
  }
}
```

Keyed by the id of what you were looking at: a card id, or `file:<path>` for a file no card
claimed. `title` and `refs` are copied in so a note makes sense without the viewer open.

- **You own it.** Agents read it and never write it. Clearing a note's text deletes it.
- A note is a request anchored to a change, not a verdict. Nothing in Involut grades it.
- To hand them over, tell the agent: *"read `.review/notes.json` and address every note"*.
  The skill tells it what to do with each one.

## Privacy

`.review/` goes in `.gitignore`. It holds your full code, the agent's notes, and a record
of what you understood and what you didn't. It does not belong in a shared repository.


Copyright © 2026 Leonel Salvo. AGPL-3.0-only; see [license](LICENSE.txt).
