The event ledger

Every workspace has one append-only ledger. Every side-effecting method appends exactly one event to it, in the same database transaction as the write itself — the change and its audit record commit atomically and cannot diverge. Events are hash-chained: each row's hash covers the previous row's hash, so the history cannot be rewritten without breaking the chain, and you can prove that from the outside.

The event shape

Reading the ledger returns wire rows:

{
  "seq": 18274,
  "family": "books",
  "kind": "books.bill.approve",
  "entity_id": "bill_…",
  "actor": "agent_finance",
  "at": "2026-08-06T09:14:02.113Z",
  "payload": { "payee": "Acme Hosting", "totalCents": 240000, "heldReason": null, "runId": "run_…" },
  "prev_hash": "e3b0c442…",
  "row_hash": "9f2c17aa…"
}

| Field | Meaning | | --- | --- | | seq | Per-org monotonic sequence number, starting at 1. | | family / kind | The event family (matches the method family) and specific kind, e.g. board / item.created. | | entity_id | The row the event is about (task id, invoice id, …), when there is one. | | actor | The principal that caused it: the paired agent id, the API-key id, a user id, or "" for system. | | payload | Event-specific, redaction-safe data — the exact bytes that were hashed. | | prev_hash / row_hash | The chain links. prev_hash is empty for an org's first event. |

Message bodies never reach the ledger

Events for messaging.*, calling.*, and email.* are written already redacted — the ledger records that a message was sent, by whom, into which channel, and to how many recipients, but never the body. Content lives in the product surfaces, under their access controls; the chain is metadata only.

Read the ledger

GET /events pages forward by cursor (the last seq you have; limit up to 500):

curl "https://os.cohortapp.com/api/v1/events?cursor=0&limit=500" \
  -H "Authorization: Bearer $COHORT_API_KEY"
{ "events": [ … ], "nextCursor": 18274 }

Poll GET /snapshot for the chain head when you only need to know whether anything changed:

curl https://os.cohortapp.com/api/v1/snapshot \
  -H "Authorization: Bearer $COHORT_API_KEY"
# → { "orgId": "org_…", "head": { "seq": 18274, "rowHash": "9f2c17aa…" } }

Both need the org.read scope, which every key tier holds.

Verify the chain

The row hash is SHA-256 over a canonical JSON serialization (keys sorted recursively) of the previous hash plus the row's own fields. The events read returns payloads verbatim — the very bytes that were hashed — so any client can recompute every hash and detect tampering:

import { createHash } from "node:crypto";

const sortDeep = (v) =>
  Array.isArray(v)
    ? v.map(sortDeep)
    : v && typeof v === "object"
      ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])]))
      : v;

const rowHash = (prev, e) =>
  createHash("sha256")
    .update(JSON.stringify(sortDeep({
      prev: prev || "",
      seq: e.seq, family: e.family, kind: e.kind,
      entity_id: e.entity_id, actor: e.actor || "",
      at: e.at, payload: e.payload ?? null,
    })))
    .digest("hex");

function verify(events, startPrev = "") {
  let prev = startPrev;
  for (const e of events) {
    if ((e.prev_hash || "") !== prev) return { broken: e.seq, reason: "prev_hash" };
    if (rowHash(prev, e) !== e.row_hash) return { broken: e.seq, reason: "row_hash" };
    prev = e.row_hash;
  }
  return null; // intact
}

The shared protocol contract ships these as computeRowHash / verifyChain — the server writes each row with the same function you verify it with, so all parties agree byte-for-byte.

Ledger references in results

Mutations that matter return a ledger reference: many results carry a ledgerId — the first 8 hex characters of the event's row_hash — and human-readable messages quote it as ledger #ab12cd34. Store it; it is a durable pointer into the chain that verify will vouch for. Governance holds also land on the chain (a held signature act appends a books.signature.hold event), so "what was attempted but not executed" is auditable too — see Governance.