# OpenRepro evidence log: hash recipe (v1)

This document describes how an OpenRepro attempt's evidence log is chained and hashed, so it can be checked in any language. `openrepro_verify.py` in this folder is a reference implementation that uses only the Python standard library.

## What this recipe proves, exactly

A log that passes this recipe is **internally consistent** (every event hashes to what it claims and chains to the one before it), its **artifacts are the files the log names** (byte for byte), and its **report refers to this log** (`report.json` cites the final hash and event count). Checked against an externally pinned anchor, it also proves that the log head and `report.json` are the anchored ones (`--anchors` pins `head_hash`, `events` and `report_sha256`; it says nothing about `report.md` or `report.html`). Checked against an externally pinned release manifest, it proves that every representation the manifest pins (`report.json`, `report.md`, `report.html` and, for a bundle argument, the bundle) is the released one; a representation the manifest does not list is not covered.

It proves nothing about what the events mean. It does not re-judge a claim, re-check a quote against the paper, re-check an observed value against its artifact, or re-derive the report from the log. That semantic re-derivation lives in the engine's `repro verify`, which needs the engine, the artifacts and the paper text (which bundles do not include). Neither verifier re-runs any code.

## Files in an attempt directory

| File | What it is |
|---|---|
| `log.jsonl` | The append-only event log: one JSON object per line, UTF-8, each line ending in `\n`. |
| `artifacts/` | Files named by events (command output, environment snapshots, and so on). |
| `report.json` | The report computed from the log. It cites the log's final hash and event count. |

## Event shape

Every line is an object with exactly these members:

| Member | Type | Meaning |
|---|---|---|
| `seq` | integer | Position in the log, starting at 0. |
| `ts` | string | UTC time, ISO 8601 with milliseconds and `Z`. |
| `kind` | string | Event type, e.g. `tool.shell`, `claim.registered`, `job.finished`. |
| `actor` | string | Who caused the event: `harness`, or the agent or operator that drove the run. |
| `data` | object | Event payload. `data.artifacts`, when present, is a list of `{name, sha256, bytes}`. |
| `prev` | string | Hash of the previous event; 64 `0` characters for event 0. |
| `hash` | string | This event's hash (lowercase hex SHA-256). |

## Canonical form

Each line is the event serialized as canonical JSON, with these rules:

- Object keys are sorted by Unicode code point, at every level.
- There is no whitespace: separators are `,` and `:`.
- Non-ASCII characters are written as raw UTF-8, not as `\u` escapes.
- Strings are escaped as `\"`, `\\`, `\n`, `\r`, `\t`, `\b` and `\f`. Other characters below U+0020 become `\u00XX` with lowercase hex; nothing else is escaped.
- Numbers follow Python's `json` module: integers as digits, and floats as their shortest round-trip representation (e.g. `0.1`, `1.0`, `1e-05`). `NaN`, `Infinity` and `-Infinity` never occur; a verifier must reject a line (or a report) that contains one.

This is exactly `json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)`.

## Event hash

`hash` is the SHA-256 of the line's UTF-8 bytes with the top-level member `"hash":"<hash>",` removed.

Because keys are sorted, the top-level `hash` member always sits between `data` and `kind`, so removing that exact byte string is enough. No re-serialization is needed. Equivalently, it is the SHA-256 of the canonical form of the event without its `hash` member.

## Chain rules

1. `seq` of line *i* is *i*.
2. `prev` of line 0 is `0000…0000` (64 zeros). The `prev` of every later line equals the `hash` of the line before it.
3. Every line's `hash` matches the recipe above.
4. The **final hash** (the `hash` of the last line) is a fingerprint of the whole attempt. Editing, removing, reordering or inserting any event changes it.

## Artifacts

For each `{name, sha256}` in any event's `data.artifacts`, the file `artifacts/<name>` exists and its SHA-256 equals `sha256`.

## Report consistency

`report.json` → `log.head_hash` equals the final hash, and `log.events` equals the number of lines. `report.json` is parsed with non-finite numbers rejected. Its `engine_version` names the engine that built it; `repro verify` refuses a report built by another engine version until it is regenerated.

## Evidence bundles

A bundle is one attempt as a gzip-compressed tar file named `<attempt_id>.tar.gz`. It contains a single top-level directory, `<attempt_id>/`, holding:

- `README.txt`
- `log.jsonl`
- `artifacts/`
- `report.json`, `report.md` and `report.html`

The paper's PDF and extracted text are not included; they belong to the authors or publisher. Bundles are reproducible:

- entries in sorted order
- every entry has mode 0644 and owner 0
- every entry's mtime is the attempt's end time
- the gzip header mtime is 0

So the same attempt always yields the same bytes. `SHA256SUMS` lists each bundle's SHA-256. The verifier accepts a bundle directly. It refuses entries that are links or devices, entries with absolute paths or `..`, and bundles with more than one top-level directory.

## Anchoring

`anchors/heads.jsonl` lists one canonical JSON line per attempt, sorted by `attempt_id`:

```
{"arxiv_id":"…","attempt_id":"…","events":63,"head_hash":"…","report_sha256":"…"}
```

`anchors/heads.sha256` holds that file's SHA-256, a single value that commits to every attempt's final hash at once. With `--anchors`, each attempt's final hash, event count and (when the line carries `report_sha256`) the SHA-256 of its `report.json` must match.

## Release manifest

The release manifest is one JSON document, schema `openrepro.release/v2`, that pins every distributed representation of every *released* attempt, not only the log head. The engine writes it as `anchors/release.json` (canonical JSON) from an explicit released set, and the site publishes the same schema as `/release.json` with extra fields per row (status, release record, URLs); one verifier reads both:

```
{"schema":"openrepro.release/v2","engine_version":"0.2.1","attempts":[
  {"attempt_id":"…","arxiv_id":"…","events":63,"head_hash":"…","engine_version":"0.2.1",
   "files":{"report.html":{"sha256":"…"},"report.json":{"sha256":"…"},"report.md":{"sha256":"…"}},
   "bundle":{"sha256":"…"}}, …]}
```

Required per row: `attempt_id`, `events`, `head_hash` and `files["report.json"].sha256`; the verifier refuses a manifest without them. `files` maps each report representation to an object with its SHA-256 (a site row also carries a `url`); `bundle` is `{"sha256": …}` for `<attempt_id>.tar.gz` (null when no bundle was built; the site adds its `url`). Schema v1 (`files` values as plain digest strings, `bundle_sha256`) is still read. `anchors/release.sha256` holds the engine manifest's own SHA-256. With `--manifest release.json`, each attempt's head hash, event count and every pinned report file must match; when the argument is a bundle, the bundle's own SHA-256 must match too. A manifest digest published outside OpenRepro's control (a public git commit, a timestamping service, a transparency log) is what makes "the report you are reading is the one that was published" checkable.

Publishing either one somewhere outside OpenRepro's control fixes the hashes in time:

- a public git commit
- a timestamping service such as OpenTimestamps
- a transparency log

A log that still verifies against an anchor cannot have been rewritten after the anchor was published.

- **One attempt:** pass its final hash with `--expect-head`.
- **Every attempt:** pass the anchors file with `--anchors heads.jsonl`. Each attempt's final hash and event count must match its line.

## What this does not check

See "What this recipe proves, exactly" above. The engine's `repro verify` recomputes freeze order (claim versions, no registration or protocol change after a result or after the seal, scored jobs only after the seal, every result citing a scored job started after the seal), rebuilds the sealed snapshot from the registrations and protocol that precede the seal and recomputes its hash, recounts the outputs observed before the seal, recomputes deltas and verdicts with non-finite values rejected, re-applies each registered extraction rule (or re-checks the agent-selected excerpt) against the cited artifact, checks each job's finish event against its start event and recompares its start and end manifests, re-matches quotes against `paper/pages.json` (whose SHA-256 and extractor version are logged at create), then re-derives the report and compares it byte for byte. Re-running the commands in the log is how a result is reproduced again.
