Docs / Architecture — how the record works

Architecture

Paraph is a TypeScript monorepo (pnpm + Turborepo). Domain logic lives in framework-free packages; the Next.js app is the I/O surface. Postgres is the durable system of record; Tideline is the live transport.

The record (hash chain)

@paraph/core owns a tamper-evident, append-only run record. Each event hashes (prevHash, canonical(payload)); the run's headHash advances with every append.

  • appendEvent(prior, input) assigns the next seq and ts, computes hash from the previous event's hash and the canonicalized payload, and returns the new event.
  • verifyChain(events) recomputes the chain and reports the first break — so a dropped, reordered, or mutated event is detectable.
  • canonicalize sorts object keys before hashing, so the chain survives a Postgres jsonb round-trip (key reordering) — proven by a db-level test.

Because verification is pure and runs over the persisted events, the dashboard shows live integrity status for any run.

External anchoring. A hash chain in Paraph's own database proves the record is internally consistent — it does not, by itself, prove Paraph didn't rewrite it. Evidence exports therefore carry an RFC 3161 anchor (lib/tsa.ts, zero-dependency): the bundle's SHA-256 digest is timestamped by an independent TSA (EVIDENCE_TSA_URL, e.g. freetsa.org), and the DER token is embedded at integrity.anchor. Any third party can verify it without trusting Paraph:

# 1. strip integrity.anchor, canonicalize (sorted keys), SHA-256 → must equal anchor.digest
# 2. verify the token against the TSA's own CA:
openssl ts -verify -digest <anchor.digest> -in token.tsr -CAfile <tsa-ca>

Anchoring is best-effort (an export never blocks on a third party) and every export — anchored or not — is written to the audit_log.

Data model (@paraph/db)

Drizzle schema in an isolated paraph Postgres schema (the target database may host other apps' public tables). Five tables:

  • tenantsid, workos_org_id, name, plan, created_at.
  • api_keysid, tenant_id, name, prefix, hash, …. Keys are stored hashed.
  • runsid, tenant_id, external_id, agent_name, framework, model, status, head_hash, started_at, …. (tenant_id, external_id) is unique.
  • run_eventsrun_id, seq, kind, role, name, content, metadata (jsonb), ts, hash, prev_hash. PK (run_id, seq) — the DB-level guard against a duplicate seq.
  • approvalsrun_id, seq, action, policy, status, reviewer, note, requested_at, resolved_at, expires_at. PK (run_id, seq).

Repositories compose core with persistence (appendRunEvent, verifyRun, listRuns, createApprovalRow, resolveApprovalRow, listPendingForTenant, …). Tests run on in-memory pglite via @paraph/db/testing (createTestDb), kept out of the app build behind a subpath export.

Ingestion path (capture)

SDK → POST /api/v1/events (API-key auth) → recordEvent ensures the run exists by external id, then appendRunEvent threads the event onto the hash chain and advances head_hash. The API is idempotent-friendly and resilient: a slow downstream never blocks the agent (the SDK buffers and retries, preserving order).

Oversight path (Article 14)

The approval state machine (createApproval / resolveApproval) carries an idempotent guard: a resolve only takes effect while the approval is pending, so a late second reviewer can't overwrite a recorded decision.

lib/approvals.ts composes it with the record:

  • requestApproval — ensure run · create a pending approval · append an approval_requested decision event · publish to Tideline (best-effort).
  • resolveApprovalForRun — resolve (guarded) · append an approval_resolved decision event · publish to Tideline (best-effort).

The agent (SDK q.gate()) blocks by polling GET …/approvals/:seq; a reviewer resolves via a Server Action on /dashboard/approvals (reviewer identity from the WorkOS session, run checked against the tenant). Both sides converge on the same record.

Tideline integration (real-time spine)

Paraph owns durable state; Tideline is the live transport (apps/web/lib/tideline.ts).

  • publishPOST {TIDELINE_URL}/streams/q:{tenantId}:approvals (bearer TIDELINE_PUBLISH_TOKEN) on every gate request/resolution. Bounded and best-effort: it never throws, never blocks for long, and no-ops when Tideline isn't configured.
  • subscribeGET /api/oversight/subscribe (session auth) mints a short-lived, stream-scoped HMAC ticket (mintTicket, byte-compatible with Tideline's verifier) and returns { url, stream, ticket }. The browser opens an EventSource straight to Tideline (which sets permissive CORS) and router.refresh()es the queue on each event.
  • graceful degradation — if Tideline is unavailable, the durable record and the poll path are unaffected; the control room simply updates on navigation/refresh instead of live.

Ticket format (matches Tideline exactly): base64url(payload).base64url(HMAC-SHA256( base64url-payload)), payload { ns, sid, exp } with exp in unix seconds, signed with TIDELINE_AUTH_SECRET. The tenant-scoped stream id + the ticket's sid confine a reviewer to their own feed.

Auth & multi-tenancy

WorkOS AuthKit guards the dashboard: the middleware enforces sign-in (it can set the session cookie on redirect, which a Server Component can't), so pages read an already-authenticated session. getTenantId provisions a tenant per WorkOS organization on first login. Every query is tenant-scoped; the public ingestion API authenticates by hashed API key.

Testing

  • core — unit tests for the hash chain (append/verify, tamper detection) and the approval state machine.
  • db / web — pglite-backed tests for repositories, the tRPC router, the ingestion and approval routes (handler tests inject a pglite db via vi.hoisted), and the oversight service. The Tideline client is tested against an injected fetch; mintTicket is verified by reproducing Tideline's HMAC check.
  • Run everything with pnpm test (Turbo fans out across packages).