Skip to content

Commit fb53530

Browse files
jaybuidlclaude
andcommitted
feat: upload an attachment, behind its own non-signing command
ADR-0009 decided this tool never pins, and that was wrong. A Kleros v2 maintainer reversed it. ADR-0012 records what replaces it; ADR-0009 is marked superseded in part rather than deleted, because its research and its endpoint ranking still stand and this change follows them. Why the old reasoning failed: "the party that authored the content is the party that should keep it available" assumes a caller who already holds a CID. The primary consumer here is an autonomous agent, and for an agent handed a PDF that assumption is false. Evidence with an attachment was not filable end to end. The threat ADR-0009 actually guarded against was narrower than the rule it wrote — a credential and an HTTP client in the signing path — and that is still removed. `upload-file` is a separate command, not a flag on submit-evidence. The decisive reason is that a dry run would have to publish or lie: fold the upload in, and a run without --broadcast either uploads anyway — an irreversible public side effect on the path whose whole promise is that it has none — or simulates a payload whose fileURI is not the one that would be sent. The CID is not knowable without doing the upload, so there is no third option. Two lesser reasons: the upload is free, repeatable and content-addressed while the transaction is none of those; and two commands are two processes, so the one holding the key opens no socket but the RPC. ADR-0009 named this shape itself, in the escape clause it wrote for a decision it did not expect reversed. Publishing is gated behind --publish, not --broadcast: nothing here is broadcast to a chain, and reusing the word would put an HTTP publish behind a flag whose description says "Send the transaction". The gate exists because content addressed by a CID cannot be withdrawn, and an agent pointed at the wrong path leaks a document permanently — unlike a wasted fee, no money undoes it. Verified against the live endpoint on 2026-09-09, and against the function source read from the private kleros/court-functions at master. Four measurements became rules, each marked [service] — a new marker, deliberately weaker than [live]: this is a service, with no bytecode to read and no deployment to fingerprint, so it can change with no signal this repo can detect. - A 200 is not a success. An empty or absent file part returns 200 with cids: []. Refused locally first; the 2xx-with-no-CID case is the backstop. - `operation` must be present and its value is discarded — operation=banana returns 200, omitting it returns 400. A test asserts the parameter stays, because nothing else would notice it going missing and every upload would break. - The ceiling is on the base64-encoded request, not the file. Found by bisection: 6,284,972 encoded bytes accepted, 6,285,020 rejected, leaving ~6.4 KB of the 6 MiB budget spent elsewhere. No fixed maximum file size is correct, so the CLI serialises the real multipart body and measures it — a long filename moves the boundary. Over the limit the edge returns an empty 413. - The handler reassigns the file on every `data` event, so a body arriving in more than one chunk would pin its last chunk alone under a CID entirely valid for the truncated bytes. It does not fire today only because the handler feeds busboy the whole body in one write() — incidental to the bug. Verification is therefore on by default: fetch the CID back, compare, refuse on a mismatch, warn and never fail when the gateway is merely silent. Reading those bytes back is not an ADR-0007 breach. The tool already holds them, compares them and discards the response; nothing read can reach a payload or change which call is made. A URI the operator hands the tool is still never dereferenced. What survives ADR-0009: exactly one credential, because the endpoint is unauthenticated; no HTTP client in the signing path; no subgraph in the write plane; and no state where a submission points at content that never pinned, since ordering removes it. What is overturned: spec/00's non-goal 4, the scope table's "no HTTP client", the README's feature table, and the CLAUDE.md invariant. Costs accepted: a code path whose correctness depends on somebody else's deployment, on exactly one command. An outage cannot wedge a write — no write command talks to it — and the ADR-0009 path of pinning elsewhere and passing --file-uri by hand still works. spec/06 §5 is the re-measurement procedure; appendix-a §2.1 lists the four things measurement cannot settle, including who keeps the content pinned. Closes spec/05 §5 criteria 9 and 10. 329 tests pass, 25 of them new and all offline behind a fake fetch; the exit-code map's exhaustiveness check caught the five new codes at compile time, as designed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f70dd2c commit fb53530

19 files changed

Lines changed: 1683 additions & 32 deletions

CLAUDE.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ overturned (`spec/appendix-a §3.5`).
1919

2020
Status: bootstrapping. `src/core/`, `src/commands/` and the fork tests are complete — the pure
2121
functional core, the deployment pinned by a fingerprint test, the read layer, the transaction path,
22-
the four commands on incur, and `spec/05 §2`'s seven fork tests, which **broadcast on a fork** and
22+
the five commands on incur, and `spec/05 §2`'s seven fork tests, which **broadcast on a fork** and
2323
settled three of Appendix A's five unverified claims. `README.md` is written (step 13, out of
24-
order); still no skill, nothing published to npm, **no transaction broadcast on Arbitrum One**.
25-
Step 13's remainder, the skill, is next.
24+
order). `upload-file` was added out of build order on maintainer instruction and **has run against
25+
the live endpoint**; still no skill, nothing published to npm, **no transaction broadcast on
26+
Arbitrum One**. Step 13's remainder, the skill, is next — and it now has a fifth command to
27+
document.
2628
Build order: `HANDOFF §10`.
2729

2830
```
@@ -45,7 +47,9 @@ wins and is named.
4547
- **Evidence is operator-supplied and opaque.** Never read, fetch or interpret counterparty
4648
content; never dereference a URI found in on-chain data. Evidence is bytes on the way to a
4749
transaction: never parsed, never interpolated into anything executable, never allowed to
48-
influence which call is made or with what arguments. `ADR-0007`, `spec/02 §4.3`
50+
influence which call is made or with what arguments. The one read-back — `upload-file` fetching a
51+
CID it just created, to compare against bytes it already holds — is not an exception to this and
52+
`ADR-0012` says why. `ADR-0007`, `spec/02 §4.3`
4953
- **Creating a dispute spends money and cannot be undone.** Quote `arbitrationCost` with the
5054
byte-identical `extraData` immediately before sending, send **exactly** that, state the value in
5155
the envelope, and enforce the cost ceiling locally before simulating. Underpaying reverts;
@@ -78,9 +82,12 @@ wins and is named.
7882
- **Discovery happens upstream**, in `@kleros/agentkit`. Reads here are limited to what is needed
7983
to **refuse a bad write**; a read that cannot change the decision to sign does not belong here.
8084
`ADR-0001`, `CONTEXT.md`
81-
- **RPC only — no subgraph, no pinning, no HTTP client.** The write plane must not depend on an
82-
indexer to decide whether to sign, the tool never pins to IPFS, and the credential surface stays
83-
at exactly one signing key. Every URI is an operator-supplied input. `ADR-0009`
85+
- **No subgraph, and HTTP in exactly one command.** The write plane must not depend on an indexer
86+
to decide whether to sign. `upload-file` pins an attachment and is the only command that speaks
87+
HTTP: it never signs, reads the chain or loads a key, and `submit-evidence` **must not** grow a
88+
`--file` flag — a dry run would have to publish or lie. The credential surface is still exactly
89+
one signing key, because the endpoint is unauthenticated. A URI the operator *hands* the tool is
90+
still never dereferenced. `ADR-0012` reverses `ADR-0009`; `spec/06` has what was measured.
8491
- **`submitEvidence` has no access control, no payment and no period gate**, so period discipline
8592
is this CLI's own policy: it **warns and never refuses**. The one hard refusal is a core dispute
8693
ID that does not exist, and the harm there is unreachability, not loss — the subgraph indexes it

CONTEXT.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,31 @@ A JSON document `{ name, description, fileURI?, fileTypeExtension? }` passed **i
7171
`EvidenceModule.submitEvidence`, emitted in an event and never stored on chain. The field is
7272
`name`; the published docs page saying `title` is wrong and a `title`-keyed document indexes with a
7373
null name. A bare `/ipfs/…` string in place of the JSON parse-fails in the subgraph.
74-
_Avoid_: title (as the field name), exhibit, attachment (that is `fileURI`), submission
74+
_Avoid_: title (as the field name), exhibit, attachment (the evidence is the *document*; the
75+
attachment is only what `fileURI` points at — see **Attachment**), submission
76+
77+
**Attachment**:
78+
The one file an evidence document may point at, through its `fileURI`. It is *not* the evidence —
79+
the evidence is the JSON document, and calling that an attachment is the confusion this entry
80+
exists to prevent. Produced by `upload-file`, which is the only command in this tool that speaks
81+
HTTP; supplying an already-pinned URI by hand remains supported.
82+
_Avoid_: exhibit, document (that is the evidence), file (unqualified — a key file and a template
83+
file are also files)
84+
85+
**Pinning**:
86+
Uploading bytes to a service that keeps them retrievable by CID. In scope since 2026-09-09, in
87+
exactly one place: `upload-file`, which never signs, never reads the chain and never loads a key.
88+
No command that signs pins anything, and the tool still never *dereferences* a URI it was handed.
89+
`ADR-0012` reversed `ADR-0009` on this and explains what survived.
90+
_Avoid_: uploading to IPFS (imprecise — the CLI posts to one endpoint, which pins), storing,
91+
hosting, saving
92+
93+
**fileURI**:
94+
The `/ipfs/<cid>` multiaddr naming an attachment, and what `upload-file` prints. Content-addressed
95+
and nothing more: identical bytes produce an identical CID under any filename, so re-uploading is
96+
idempotent and free. Unlike `policyURI` it is not held to the schema's multiaddr refinement — it
97+
is an operator input the contract never reads.
98+
_Avoid_: link, URL (a plain `https://` URL is the thing `policyURI` refuses), hash, IPFS address
7599

76100
**Policy**:
77101
The court's or the arbitrable's rules document, referenced by the template's `policyURI` as a

README.md

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ the deployed core enforces `arbitrableWhitelist` unconditionally.
4747
| It does | It does not |
4848
| --- | --- |
4949
| Quote the arbitration fee before you commit to it | Decide the claim, the court, or the ruling options |
50-
| Create a dispute and register its template | Read, parse, summarise or fetch anything you pass it |
51-
| Submit one evidence document | Pin to IPFS, or run any HTTP client at all |
50+
| Create a dispute and register its template | Decide what to upload, or keep it pinned afterwards |
51+
| Submit one evidence document, and upload its attachment | Read, parse or fetch a URI *you* hand it |
5252
| Report which period a dispute is in | Discover which disputes you are party to |
5353
| Refuse anything that looks wrong, before spending money | Broadcast anything without `--broadcast` |
5454

@@ -58,8 +58,9 @@ the ones that can change the decision to sign.
5858

5959
## Status
6060

61-
**Pre-release.** All four commands are built and tested; the read paths are verified live against
62-
Arbitrum One, and both write paths broadcast on an Arbitrum One fork under `pnpm test:fork`.
61+
**Pre-release.** All five commands are built and tested; the read paths are verified live against
62+
Arbitrum One, both write paths broadcast on an Arbitrum One fork under `pnpm test:fork`, and
63+
`upload-file` is measured against the live pinning endpoint.
6364
**No transaction has ever been broadcast to Arbitrum One itself.** Treat the first live dispute as
6465
the shakedown run, on a cheap court, with a ceiling you can afford to lose.
6566

@@ -69,6 +70,7 @@ the shakedown run, on a cheap court, with a ceiling you can afford to lose.
6970
| `status` || never |
7071
| `create-dispute` | required | `createDisputeForTemplate`, only with `--broadcast` |
7172
| `submit-evidence` | required | `submitEvidence`, only with `--broadcast` |
73+
| `upload-file` || never — no chain at all, only with `--publish` |
7274

7375
Nothing is published to npm yet, deliberately — see [`CHANGELOG.md`](CHANGELOG.md).
7476

@@ -190,7 +192,28 @@ kleros-disputant create-dispute … --broadcast
190192
`--broadcast` **is** the confirmation. There is no prompt, because there is no human assumed to be
191193
watching.
192194

193-
### 5. Submit evidence
195+
### 5. Upload the attachment, if there is one
196+
197+
```bash
198+
kleros-disputant upload-file --file ./delivery-photos.pdf # checks, uploads nothing
199+
kleros-disputant upload-file --file ./delivery-photos.pdf --publish # uploads, prints the URI
200+
```
201+
202+
Skip this if you already have a pinned URI — `--file-uri` still accepts one you produced yourself.
203+
204+
This is the only command that speaks HTTP, and the only one with no chain in it: it never signs,
205+
never reads the chain and never loads a key. `--publish` is to it what `--broadcast` is to the
206+
other two, for the same reason — content addressed by a CID cannot be withdrawn. It prints the
207+
`--file-uri` and `--file-type-extension` for the next step, and says in words that nothing has been
208+
submitted yet.
209+
210+
Uploads go to the unauthenticated Kleros pinning endpoint, so there is still exactly one credential
211+
in this tool: the signing key. Point `--upload-url` at your own deployment of the same function if
212+
you would rather not use it. What was measured against it, and what could not be, is in
213+
[`docs/spec/06-attachment-upload.md`](docs/spec/06-attachment-upload.md) and
214+
[ADR-0012](docs/adr/0012-attachment-upload-is-in-scope-behind-its-own-command.md).
215+
216+
### 6. Submit evidence
194217

195218
```bash
196219
kleros-disputant status --dispute 215 # is the evidence period still open?
@@ -315,7 +338,8 @@ artifacts, which differ from what `master` compiles to — a fingerprint test as
315338
every run and fails the build if upstream drifts, rather than letting a transaction find out
316339
([ADR-0006](docs/adr/0006-deployment-imported-from-contracts-package.md)).
317340

318-
The runtime dependencies are exactly two: `incur` and `viem`.
341+
The runtime dependencies are exactly two: `incur` and `viem`. `upload-file` speaks HTTP through
342+
Node's own `fetch`, `FormData` and `Blob`, so it adds no third.
319343

320344
## Where things live
321345

@@ -329,8 +353,9 @@ The runtime dependencies are exactly two: `incur` and `viem`.
329353

330354
This repo inherited no specification: one was written here from the deployed contracts, and every
331355
chain fact in it carries a marker saying how it was established — `[live]`, `[fork]`, `[abi]`,
332-
`[computed]`, `[client]`, `[inferred]`, `[maintainer]`. **`[client]` and `[inferred]` claims must
333-
not be depended on without a fork test**, and `[fork]` is what a claim becomes once one has settled
356+
`[computed]`, `[client]`, `[inferred]`, `[maintainer]`, and `[service]` for the one thing here that
357+
is not a contract. **`[client]` and `[inferred]` claims must not be depended on without a fork
358+
test**, and `[fork]` is what a claim becomes once one has settled
334359
it. `[live]` claims are stamped with a date and a block; re-run
335360
[`docs/spec/05-verification.md`](docs/spec/05-verification.md) §4 to refresh them.
336361

docs/adr/0009-the-cli-references-ipfs-and-never-pins.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# The CLI references IPFS content and never pins it
22

3+
> **Superseded in part, 2026-09-09.** The conclusion below — that this tool never pins — was
4+
> reversed by a Kleros v2 maintainer and is replaced by
5+
> [ADR-0012](./0012-attachment-upload-is-in-scope-behind-its-own-command.md). Uploading an
6+
> attachment is now in scope, behind the separate non-signing `upload-file` command.
7+
>
8+
> **What still stands:** everything in *The research that made this easy* and *Two layers that are
9+
> easy to conflate*, and the endpoint ranking under *If pinning is ever brought in scope* — ADR-0012
10+
> follows it and takes the first option. The credential surface is still exactly one signing key,
11+
> because that endpoint is unauthenticated. Read this ADR for why the happy path touches IPFS in so
12+
> few places; read ADR-0012 for what happens when it does.
13+
314
`kleros-juror-cli` is pure RPC: no subgraph, no HTTP services, no off-chain writes. That simplicity
415
is load-bearing — it is why the tool has no credentials beyond a signing key, why no service outage
516
can wedge it, and why every failure mode is on chain. Dispute templates and evidence attachments
@@ -77,3 +88,9 @@ content is pinned but not submitted, or submitted pointing at content that never
7788
outage can wedge a write. And `--template-uri`, `--policy-uri` and `--file-uri` are ordinary string
7889
arguments the caller is responsible for — which is the honest division of labour, because the party
7990
that authored the content is the party that should be keeping it available.
91+
92+
> **ADR-0012:** the first two sentences survive unchanged — the endpoint needs no credential, and
93+
> ordering `upload-file` before `submit-evidence` still leaves no state where a submission points at
94+
> content that never pinned. The last one did not. "The caller is responsible for it" is not a
95+
> division of labour when the caller is an agent holding a PDF and no way to pin it; it is the tool
96+
> declining to finish the job. That is why this ADR was reversed.

0 commit comments

Comments
 (0)