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 nextseqandts, computeshashfrom the previous event'shashand 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.canonicalizesorts object keys before hashing, so the chain survives a Postgresjsonbround-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:
tenants—id, workos_org_id, name, plan, created_at.api_keys—id, tenant_id, name, prefix, hash, …. Keys are stored hashed.runs—id, tenant_id, external_id, agent_name, framework, model, status, head_hash, started_at, ….(tenant_id, external_id)is unique.run_events—run_id, seq, kind, role, name, content, metadata (jsonb), ts, hash, prev_hash. PK(run_id, seq)— the DB-level guard against a duplicate seq.approvals—run_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 anapproval_requesteddecision event · publish to Tideline (best-effort).resolveApprovalForRun— resolve (guarded) · append anapproval_resolveddecision 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).
- publish —
POST {TIDELINE_URL}/streams/q:{tenantId}:approvals(bearerTIDELINE_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. - subscribe —
GET /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 anEventSourcestraight to Tideline (which sets permissive CORS) androuter.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 }withexpin unix seconds, signed withTIDELINE_AUTH_SECRET. The tenant-scoped stream id + the ticket'ssidconfine 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 viavi.hoisted), and the oversight service. The Tideline client is tested against an injected fetch;mintTicketis verified by reproducing Tideline's HMAC check.- Run everything with
pnpm test(Turbo fans out across packages).