# Coordinate Log format, version 1

Coordinate writes each workspace's event log to the customer's own bucket (S3, Cloud Storage, Azure Blob or R2) in
an open format. Any tool can read it without Coordinate, and `verify` (or a reimplementation of the rules below) can
rebuild and check the full history from the bucket alone.

## Layout

All keys sit under the store prefix (`s3://bucket/prefix`, `gs://bucket/prefix`, `azblob://account/container/prefix`).

| Key | Written | Holds |
| --- | --- | --- |
| `lease.json` | compare-and-swap | The node that may write, its epoch, and when its lease expires. |
| `ws/<workspace>/events/e<epoch>/<from>-<to>.ndjson.gz` | create-if-absent | A segment: events `from`..`to`, gzip NDJSON. |
| `ws/<workspace>/cuts/e<epoch>.json` | create-if-absent | Where the epoch's history starts: after `cursor`, whose hash is `hash`. |
| `ws/<workspace>/head.json` | compare-and-swap | The last shipped event: `epoch`, `cursor`, `hash`, `segment`. |

Epochs are padded to 8 digits and cursors to 16, so keys sort in log order. Segments, cuts and the files they
describe are never rewritten.

## Events

Each segment line is one JSON object with these fields, in this order:

| Field | Type | |
| --- | --- | --- |
| `cursor` | integer | Position in the workspace's log, from 1, with no gaps. |
| `id` | string | `evt_...` |
| `source_id` | string | The source that recorded it. |
| `type` | string | |
| `schema_version` | integer | |
| `producer_event_id` | string or null | The producer's idempotency key. |
| `content_hash` | string | Hash of the producer's content, used for deduplication. |
| `occurred_at` | string or null | When it happened, per the producer (RFC 3339). |
| `recorded_at` | integer | When Coordinate recorded it, in milliseconds since the epoch. |
| `payload` | string | The payload as JSON text, exactly as stored. |
| `metadata` | string or null | JSON text. |
| `correlation_id`, `causation_id` | string or null | |
| `recorded_by` | string or null | Who recorded it, e.g. `grant:...` or `system:github`. |
| `subjects` | string or null | JSON array text of the entities it concerns, e.g. `["github:pr:owner/repo#12"]`. |
| `hash` | string | The chain hash, 64 hex digits. |

`payload`, `metadata` and `subjects` are strings so the hash covers exact bytes. In DuckDB use `payload::JSON` or
`json_extract_string(payload, '$.field')`.

## Hash chain

```
hash(0) = "0" * 64
hash(n) = hex(SHA-256( "coordinate-log-v1\n" || hash(n-1) || "\n" || enc(cursor) || enc(id) || ... || enc(recorded_by) ))
```

Fields go in the order of the table above, without `hash`. `enc(null)` is `-`. `enc(v)` for any other value is its
UTF-8 byte length, a colon, and then its text. Integers are written in base 10, so `enc("héllo")` is `6:héllo`.

## Epochs, cuts and fenced writes

Only the lease holder writes. Each time a node takes the lease, the epoch goes up by one. Before an epoch writes its
first segment for a workspace, it writes that workspace's cut record. The cut says the history before this epoch
ends at the head the node read: `cursor` and `hash`.

A stale node may still write a few segments after a new epoch has started. Those segments lie past the next epoch's
cut and are not part of the history. To rebuild the history:

1. List the segments and cuts. Walk the epochs in ascending order.
2. At each epoch with a cut, drop the segments from earlier epochs that start after the cut's cursor. The history
   must then end exactly at the cut's cursor, with the cut's hash.
3. Append the epoch's segments in order. Each starts right after the history's last cursor, and every line's hash
   must recompute from the previous one.
4. `head.json` must point into the history. It can trail it briefly, between a segment write and the head update.

## On Cloudflare

The Worker writes the same log to an R2 bucket once its Durable Objects have a `LOG` binding:

```jsonc
// wrangler.jsonc
"r2_buckets": [{ "binding": "LOG", "bucket_name": "coordinate-log" }],
"vars": { "LOG_PREFIX": "coordinate" }
```

Each workspace's Durable Object is its log's only writer. It ships from its alarm about a second after new events
arrive. The deployment holds the bucket's lease for good: the first object to ship writes `lease.json` with owner
`cloudflare`, epoch 1 and no expiry. A self-hosted node pointed at the same bucket waits instead of writing beside
it. Read or verify the log from anywhere through R2's S3 API:
`--store "s3://coordinate-log/coordinate?endpoint=https://<account>.r2.cloudflarestorage.com&region=auto"`.

Handing the log's writing to a self-hosted node takes three steps:

1. Remove the binding and deploy.
2. Run `node host.mjs release-lease --store … --owner cloudflare`.
3. Start the node.

The node takes the next epoch. A Durable Object that is still shipping stops at the latest when it sees the later
epoch, and anything it wrote after the cut is not history.

Database streams are not written on Cloudflare, whose storage is replicated already. So the node cannot `restore`
a Cloudflare workspace's configuration and state; its event shipper refuses to continue a log its disk does not
hold. Moving whole workspaces off Cloudflare needs a database export, which is not built yet. Until then the event
history moves and stays verifiable; the workspaces do not.

## Database streams

The event log is the open record. To restore a node, the bucket also holds each database's changes, in SQLite's own
formats. The query store is left out because the service rebuilds it from its workspace.

| Key | Written | Holds |
| --- | --- | --- |
| `db/<class>/<id>/e<epoch>/<seq>.snapshot.gz` | create-if-absent | The whole database after change `seq` (a SQLite file, gzip). |
| `db/<class>/<id>/e<epoch>/<seq>.changeset.gz` | create-if-absent | The changes from `seq - 1` to `seq` (a SQLite session changeset, gzip). |
| `db/<class>/<id>/cuts/e<epoch>.json` | create-if-absent | Where the epoch's stream starts: after change `seq`. |
| `dbheads/<class>/<id>.json` | compare-and-swap | The last shipped change, and the latest snapshot. |

There are two kinds of snapshot:

- A periodic snapshot is taken right after changeset `n` and carries seq `n`. It bounds how many changesets a restore
  has to apply.
- A base snapshot is taken when a node starts on a disk that may hold unshipped changes, or after a schema change. It
  carries the next seq, with no changeset of its own.

To restore, take the last snapshot in the history and apply the changesets after it in order. Epochs and cuts work as
they do for the event log. A workspace's events then come up to date from the event log's segments, which may be
slightly ahead, and each added event must continue the restored hash chain.

```sh
node host.mjs restore --store s3://bucket/prefix --data-dir /var/lib/coordinate   # into an empty directory
```

A node writes a marker beside each database recording which epoch's stream its disk belongs to. `serve` refuses to
start (exit 78) when the bucket's stream has moved past this disk, for example the old disk of a node that was
replaced. The fix is `restore` into an empty directory.

Snapshots and changesets hold the service's databases, including sealed secrets (sealed with `SECRETS_KEY`, which
never ships). Use the bucket's encryption at rest and keep access to the prefix as narrow as for the node itself.

## Checking a hosted log

A workspace on Coordinate's own service exposes the same lines over HTTP, without a bucket: `GET /v1/log` gives the
event count and the chain head, and `GET /v1/log/events?after_cursor=N&limit=1000` pages the events in the format above.
The Coordinate CLI recomputes the chain from the first event and keeps each head it verifies as a checkpoint, so a
later run fails if anything before a checked head changed:

```sh
coordinate log verify
coordinate log export --out log.ndjson
```

## Reading it

```sh
node host.mjs verify --store s3://bucket/prefix            # every workspace: ok, or each problem found
node host.mjs verify --store s3://bucket/prefix --json     # also lists the history's segment keys, in order
```

```sql
-- DuckDB, over the segment keys `verify --json` lists (or a glob, when it reports no fenced segments)
SELECT type, count(*) FROM read_json('segments/*.ndjson.gz', format = 'newline_delimited') GROUP BY type;
```

Events are acknowledged once they are durable on the node's disk. Shipping follows within the ship interval
(default 1 s). A clean shutdown ships everything before it releases the lease.
