# Public log schema

Cloudflare Worker + D1 (SQLite). Code: `worker/log.js`, `worker/index.js`. Table definitions: `migrations/0001_init.sql`.

## What is stored

Only DIDs (public keys), hashes, signatures, optional http(s) links and timestamps. The receipt JSON itself is stored because it contains nothing else: the receipt shape is strict (see `checkShape` in `src/index.js`), so free-text fields are rejected before anything is written. Links may not carry a query string, fragment or credentials. The client IP is never stored; rate limiting keeps `sha256(salt | kind | ip | hour)` for the current hour only and deletes older rows.

## Entry fields

| Field | Meaning | After removal |
|---|---|---|
| seq | position in the chain (1, 2, ...) | kept |
| receipt_id | receipt `id` (urn:uuid), unique: a receipt can be published once (replay is rejected with 409) | kept |
| published_at | UTC ISO 8601, set by the log | kept |
| seller_key, buyer_key | issuer DID and credentialSubject.id DID | NULL |
| file_sha256 | delivered file hash | NULL |
| seller_sig, buyer_sig | proofValue of the seller and buyer proofs | NULL |
| seller_link, buyer_link | optional http(s) URL given at publish time | NULL |
| seller_link_check, buyer_link_check | `match`, `mismatch`, `unreachable` (checked once at publish; see below) or NULL | kept |
| receipt | full receipt JSON | NULL |
| receipt_hash | sha256(JCS(receipt)) | kept |
| seller_link_hash, buyer_link_hash | sha256(link URL string) or NULL | kept |
| excluded | `"ours"` if the seller or buyer DID is in `config/internal-dids.json`, else NULL | kept |
| prev_hash | entry_hash of seq-1; 64 zeros for seq 1 | kept |
| entry_hash | see chain below | kept |
| tombstoned, tombstoned_at, tombstone_reason | removal state | set |

## Hash chain

```
entry_hash = sha256_hex( JCS({ v: 1, seq, published_at, receipt_hash, seller_link_hash, buyer_link_hash, excluded, prev_hash }) )
```

JCS = RFC 8785. Every field in the hash survives removal, so the whole chain can be recomputed from `/log.jsonl` at any time, including removed entries: `node scripts/verify-chain.js log.jsonl`. For live entries the script also recomputes `receipt_hash` from `receipt` and the link hashes from the links.

Append-only: the Worker only INSERTs new rows (seq = head + 1, primary key; concurrent writers retry on the new head). The only UPDATE is a removal, which nulls body columns and never touches chain fields.

## Excluded ('ours')

Receipts signed by our own test, demo or release keys are stored with `excluded = "ours"` and are left out of every `/stats` count. The list is `config/internal-dids.json`, generated from the Toolsmith's `/workspace/venture/tools/receipts-count/our_keys.json` by `node scripts/make-examples.js` (which appends new demo DIDs to both). `/log.jsonl` also includes `ours: true|false` for the counter. The DID list is compiled into the Worker, so redeploy after editing it.

## /stats

Counts only live, non-excluded entries: `counted_receipts`, `distinct_seller_dids`, `distinct_buyer_dids`, `distinct_pairs`, `distinct_pairs_with_verified_link` (pairs where at least one side's link check was `match`), plus `not_counted.excluded_ours` and `not_counted.tombstoned`. Distinct DIDs are not distinct organisations: one operator can make many keys.

## Link ownership

A link is `match` only if (a) the DID is did:web and the link host equals the DID host (the DID document is fetched from did.json and must have the same id), or (b) `https://<host>/.well-known/wayreceipt.txt` or the linked page contains the exact DID string. Otherwise `mismatch`, or `unreachable` on network error/timeout (4 s at publish). Same logic as `wayreceipt check-link <did> <url>` (exit 0 match, 1 mismatch, 2 unreachable). The stored result is a snapshot at publish time; the counter should re-check with `--check-link-cmd`.

## Routes

| Route | Notes |
|---|---|
| POST /log | `{receipt, seller_link?, buyer_link?}`; countersigned only, re-verified server side (did:web resolved over HTTPS); 16 KB cap; 20/hour/IP; 2,000/day global cap; duplicate receipt id → 409 |
| GET /log | `?after=&limit=` (max 100) pages in chain order; `?receipt_id=`, `?file_sha256=`, `?seller=` search |
| GET /log/{seq or receipt_id} | one entry |
| GET /log.jsonl | full export, one JSON object per line |
| GET /stats | counts above |
| POST /report | `{entry, reason}`, reason ∈ personal_data, illegal_content, not_a_party, spam, other. No free text, no reporter identity stored. 10/hour/IP. Removal and abuse reports can also go to foundry500@mail.grokbot.com (entry number or receipt ID and reason; do not send the personal data itself). |
| POST /log/{seq}/tombstone | `{signer, signature}`: seller or buyer signs the UTF-8 string `wayreceipt-tombstone:v1:<entry_hash>` with Ed25519, base58btc with `z` prefix. 10/hour/IP |
| POST /admin/tombstone/{seq} | operator removal, `Authorization: Bearer <ADMIN_TOKEN>`, body `{reason}`. Returns 404 unless the `ADMIN_TOKEN` secret is set. Locally the operator can also run `npx wrangler d1 execute wayreceipt-log --local --command "UPDATE ..."`. |
| POST /mcp | MCP endpoint, see docs/MCP.md |

## /log.jsonl line

```
{"receipt_id","file_sha256","seller_key","buyer_key","seller_sig","buyer_sig","seller_link","buyer_link","published_at","excluded","ours","receipt",
 "seq","tombstoned","seller_link_check","buyer_link_check","receipt_hash","seller_link_hash","buyer_link_hash","prev_hash","entry_hash"}
```

The first twelve are what the Toolsmith asked for (`ours` added for count.py); the rest let anyone recompute the chain. Local export without HTTP: `node scripts/export-log.js > log.jsonl` (reads the local D1 via wrangler) or `curl http://localhost:8787/log.jsonl`.

## Removal (tombstones)

Either party removes an entry by signing the tombstone message; operators remove on valid reports. Removal sets seller_key, buyer_key, file_sha256, seller_sig, buyer_sig, seller_link, buyer_link and receipt to NULL and sets tombstoned_at and tombstone_reason. receipt_id, published_at, link check results, all hashes and excluded stay, so `entry_hash` still recomputes and the chain still verifies. Open reports on the entry are marked `removed`.

Limits that remain: anyone who downloaded the log before removal still has the copy, and receipt_hash/file hashes could confirm a guess of the original content.
