Skip to content

Edge read replicas — complete client-side replica (ADR-0023, M20–M29)#7

Closed
naveed949 wants to merge 10 commits into
claude/adr-edge-read-replicasfrom
claude/edge-read-replicas
Closed

Edge read replicas — complete client-side replica (ADR-0023, M20–M29)#7
naveed949 wants to merge 10 commits into
claude/adr-edge-read-replicasfrom
claude/edge-read-replicas

Conversation

@naveed949

@naveed949 naveed949 commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Implements ADR-0023 — Edge read replicas end to end, with nothing deferred: a read-only, non-voting replica of the application state machine that runs outside the cluster — browser or Node — by tailing a node's committed-log stream and serving reads from a local copy with no network round-trip, live-updating as writes commit. ADR is Accepted.

Stacked PR. Base is claude/adr-edge-read-replicas (the ADR, PR #6); this diff is implementation only. Merge #6 first, or retarget this to main.

Milestones

# What landed
M20 Committed-log read stream on RaftNode + GET /raft/stream SSE, served by any node
M21 EdgeReplica SDK — bootstrap → tail → local reads, reconnect/backoff, onChange, waitForIndex; Node + browser stream sources
M22 Worked browser + Node examples
M23 Per-client authorization + partial replication (StreamGuard/ScopedFilter)
M24 Compiled browser SDK — the browser runs the same BookStateMachine as the server (yarn build:browser)
M25 raft_stream_subscribers metric, ADR → Accepted, ops guide
M26 Signed, short-lived, scoped tokens — HS256 (Node crypto), fail-closed exp, no alg-confusion; yarn mint-token
M27 Backpressure + connection caps — 503 + Retry-After at capacity, drop slow consumers, raft_stream_rejected/dropped_total
M28 Audit-chain verification (Node) — re-derive + verify the tamper-evident chain on full streams
M29 Audit verification in the browser via async WebCrypto (shared hash payload; one path runs in Node 20+ and browsers)

Every hardening milestone went implementer → reviewer → fix

The review gate caught three real bugs that a green suite was hiding:

  • M26 — added fail-closed exp + an algorithm-confusion regression test.
  • M27B1: a connection dropped during catch-up leaked an onCommitted listener (inflating raft_stream_subscribers forever) → guarded subscription + regression test.
  • M28B1: scoped streams double-applied pre-connect history (silent divergence on BORROW/RETURN, masked by ADD-only fixtures) → materialize-at-head fix + regression test.
  • M29 — fixed an M28 regression that pulled Node crypto into the browser bundle; reviewer approve-with-nits → unified the audit-record type + made auditHead() consistent.

Each fix shipped with a regression test verified to fail on the pre-fix code.

What makes it client-usable & production-shaped

  • Safe to expose: signed, short-lived, scoped tokens (401 otherwise); a scoped client only ever receives its slice — never another tenant's data, the audit, or cluster config.
  • Resilient: per-node connection cap + slow-consumer drop; reconnect/backoff with resume.
  • No drift: the browser runs the compiled shared state machine.
  • Tamper-evident end to end: the browser re-derives the audit hash-chain via WebCrypto and its head matches the server byte-for-byte (verified against the compiled bundle).
  • Observable: raft_stream_subscribers / raft_stream_rejected_total / raft_stream_dropped_total.

Testing

tsc clean · full suite 236/236 (32 suites) · new socket/audit suites stable across repeated runs · browser bundle re-verified to import no Node builtins · the compiled browser ESM loaded as a browser and verified the audit chain (valid, head == server head) against a live node.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu

claude added 6 commits June 22, 2026 12:42
Add a read-only, non-voting tap on the committed log: the consensus-core
primitive an edge read replica consumes. A consumer bootstraps from the
snapshot boundary, replays the committed tail, then live-tails new commits.
None of it participates in consensus — a stream consumer never votes, acks,
or affects quorum/commit.

Core (RaftNode):
- onCommitted(listener) — subscribe to committed entries in commit order,
  emitted from applyCommitted; listener exceptions isolated.
- getCommittedEntries(afterIndex) / getCommitIndex() / getSnapshotIndex()
  — replay the in-memory committed tail and report the live head/boundary.
- needsSnapshot(afterIndex) / getStreamSnapshot() — detect a compacted gap
  and hand off the snapshot at the boundary for bootstrap.

HTTP:
- GET /raft/stream?fromIndex=N — Server-Sent Events stream (snapshot →
  entry* → caughtup → live entry*), served from ANY node's local,
  eventually-consistent state, so read serving fans out across the cluster
  (and, via a browser EventSource, past it). Keepalive + reconnect hints.

Tests: tests/readStream.test.ts — commit-order live tail, late-consumer
replay, follower fan-out, and snapshot handoff after compaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
A read-only, non-voting replica of the application state machine that tails a
node's committed-log stream and serves reads from local state — in Node or the
browser. Writes still go through the leader; the replica never votes or acks.

- EdgeReplica<C,T>: wraps any StateMachine (the SAME deterministic code a
  server runs), bootstraps from the snapshot boundary, applies committed
  commands, and serves reads locally. Owns reconnection with backoff (resumes
  from its applied index, re-bootstrapping cleanly if compaction outran it),
  a change-feed (onChange), and read-your-writes (waitForIndex).
- LogStreamSource seam with two implementations: HttpStreamSource (Node, a
  stdlib SSE client — no new deps, ADR-0013) and EventSourceStreamSource
  (browser, native EventSource injected structurally so it builds without DOM lib).
- Re-exported from the public surface (src/index.ts).

Tests: tests/edgeReplica.test.ts — over real HTTP + SSE: bootstrap + live
tail, onChange + read-your-writes, snapshot handoff after compaction, and a
3-node cluster proving a FOLLOWER serves the stream (read fan-out).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
A runnable demonstration of the edge read replica, browser + Node:

- examples/edge-replica/index.html + app.js — a zero-build browser page that
  tails GET /raft/stream with the native EventSource, applies committed book
  commands to an in-page store, and renders a live-updating list. Add a book
  (a write, forwarded to the leader) and watch it stream back. CORS is already
  enabled on the node.
- examples/edge-replica/node-example.js — the SAME-CODE path: runs the real
  EdgeReplica + BookStateMachine from dist (no hand-port), printing a live table.
- examples/edge-replica/README.md — how it works + how to run both.

Docs: README gets a /raft/stream API row and an "Edge read replicas" section;
CLAUDE.md architecture map gains src/edge/. The browser reducer is a hand-port
flagged with the determinism/code-sharing caveat ADR-0023 raises; the Node
example shows the shared-code alternative.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
…-0023)

Close the ADR's make-or-break prerequisite: never expose the whole committed
log to an untrusted edge client. A StreamGuard authenticates each /raft/stream
connection and returns a per-connection ScopedFilter that restricts BOTH the
bootstrap snapshot and the live entry feed to the client's authorized scope.

Framework (src/edge/streamGuard.ts):
- StreamGuard.authorize(token) -> ScopedFilter | null (null => 401), and a
  ScopedFilter { filterSnapshotState, includes } seam. Domain-agnostic: the
  framework authenticates-and-filters; the app defines what a scope means.
- extractStreamToken: Authorization: Bearer <token> (Node) or ?token= (browser,
  since EventSource can't set headers).

Endpoint (raftRoutes): authorize on connect; scoped snapshot (state only — no
audit/dedup leak) + only in-scope entries. The cursor still advances past
out-of-scope entries so the client stays current; EdgeReplica's index check is
relaxed from strict-contiguous to monotonic (a scoped stream legitimately skips
indices; SSE rides one ordered TCP connection so nothing is dropped mid-stream).

Wiring: createApp accepts a streamGuard; server.ts builds one from STREAM_TOKENS
(default public `demo=*`, swap for JWT in prod). Token support added to both
stream sources and to the worked example.

Book example (models/bookStreamGuard.ts): scope by publisher ("acme" sees only
"Acme Press"), with a TOKEN=scope registry.

Tests: tests/edgeAuthz.test.ts — 401 on missing/invalid token, scoped client
sees only its publisher (snapshot + live), all-scope sees everything.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
Ship the REAL state machine to the client instead of hand-porting it — the
answer to ADR-0023's "determinism across client builds" hazard. The browser now
runs the exact EdgeReplica + BookStateMachine the server runs.

- src/edge/browser.ts: browser entry re-exporting the browser-safe surface
  (EdgeReplica, EventSourceStreamSource) plus the shared BookStateMachine.
  Excludes the Node-only HttpStreamSource/StreamGuard so the emitted graph
  touches no Node builtin.
- bookStateMachine.ts: type-only RaftNode import, so it pulls no consensus
  runtime into the browser bundle.
- tsconfig.browser.json + scripts/build-browser.js + `yarn build:browser`:
  compile to ESM and append the .js import extensions browsers require — zero
  extra dependency. Output (examples/edge-replica/lib/) is gitignored.
- Example rewritten to import the compiled SDK (app.js, index.html); README
  documents the build + scoped-token demo.

Verified: the compiled ESM loads as a browser would and converges against a live
node under both all-scope and a scoped token.

Tests: tests/eventSourceSource.test.ts — the browser source (EventSourceStream-
Source) over a real node via a Node EventSource polyfill: convergence, live tail,
and token-in-URL scoping.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
…ADR-0023)

Capstone for the edge read replica:

- Observability: raft_stream_subscribers gauge — active committed-log stream
  subscribers (edge replicas) per node, for read-fan-out monitoring.
- ADR-0023 → Accepted, with an Implementation status section recording what
  landed (M20–M24) and what is deferred (SSE backpressure/connection caps,
  JWT/session auth + TLS/wss, client-side audit-chain verification).
- docs/OPERATIONS.md: "Edge read replicas in production" — auth (swap the demo
  token registry for JWT), token transport caveats (?token= vs Bearer), scaling
  reads via followers, partial replication, compaction-outrun behavior, limits.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
@naveed949 naveed949 changed the title Edge read replicas — M20–M22: committed-log stream, EdgeReplica SDK, worked example (ADR-0023) Edge read replicas — production-ready client-side replica (ADR-0023, M20–M25) Jun 22, 2026
claude added 3 commits June 22, 2026 13:52
Harden edge-replica auth: replace reliance on a guessable static token
registry with cryptographically-verified, short-lived, scoped tokens.
Dependency-free (Node crypto only, ADR-0013).

- src/edge/signedToken.ts: a compact JWT-shaped HS256 token. mintStreamToken /
  verifyStreamToken (base64url, constant-time length-guarded timingSafeEqual,
  always computes HS256 so there is no algorithm-confusion, never throws on
  adversarial input) and a generic createSignedTokenGuard({ secret, toFilter }).
  exp is REQUIRED and numeric — a missing/non-numeric expiry fails closed (no
  perpetual tokens).
- bookStreamGuard: extract bookScopeToFilter / claimsToBookScope so the signed
  and static guards share one scope->filter source of truth; buildSignedBook-
  StreamGuard(secret) maps a `scope` claim ("*" or a publisher) to the filter.
- server.ts: use signed tokens when STREAM_TOKEN_SECRET is set, else fall back
  to the demo STREAM_TOKENS registry. scripts/mint-token.js + `yarn mint-token`.
- Public exports + docs (OPERATIONS.md auth/token-transport, ADR-0023 status).

Reviewed (security pass, approve-with-nits): applied the fail-closed exp policy
and added algorithm-confusion + missing-exp regression tests.

Tests: tests/edgeSignedToken.test.ts — 8 unit (valid, tampered payload/sig,
wrong secret, expired, malformed, alg-confusion, missing-exp) + 3 integration
(scoped/all over the real endpoint, 401 on missing/expired/tampered).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
Protect a node from slow or abundant edge-stream consumers.

- Per-node connection cap: GET /raft/stream tracks active connections and, at
  capacity, fails closed with 503 + Retry-After (checked AFTER auth, so a
  rejected token never consumes a slot). Default 10000, env STREAM_MAX_CLIENTS.
- Backpressure: after each SSE write, if res.writableLength exceeds a ceiling
  (default 1 MiB, env STREAM_MAX_BUFFER_BYTES) the consumer can't keep up — the
  connection is dropped (res.destroy) instead of buffering unboundedly. It
  reconnects and resumes from its applied index. Cleanup (clear keepalive,
  unsubscribe, release the slot) is idempotent across the drop path and the
  req/res close events.
- Observability: raft_stream_rejected_total and raft_stream_dropped_total
  counters; RaftNode.streamSubscriberCount() backs raft_stream_subscribers.
- Wiring: AppDeps.streamLimits threaded through createApp; server.ts reads the
  env knobs. Docs: OPERATIONS.md limits + ADR-0023 status appendix.

Reviewed (resource-safety pass, request-changes): fixed B1 — a connection
dropped DURING catch-up (before the live-tail subscription was set up) would
register an onCommitted listener that nothing removed, leaking it and inflating
raft_stream_subscribers. The subscription is now guarded by `if (!closed)`,
mirroring the keepalive. Added a deterministic regression test (verified it
fails on the pre-fix code).

Tests: tests/edgeBackpressure.test.ts — cap (503 + Retry-After, slot freed on
close), slow-consumer drop, and the B1 listener-leak regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
Let an edge replica re-derive and verify the tamper-evident audit hash-chain
the server maintains — proving the served history is internally consistent
(ADR-0023's end-to-end tamper-evidence). Node + full streams; reuses the
existing ReplicatedStateMachine chain logic (Node crypto, sync).

- EdgeReplica gains `verifyAudit: true`. An internal Applier seam swaps strategy:
  DirectApplier (default, behavior unchanged) vs AuditingApplier, which applies
  through a ReplicatedStateMachine wrapping the same app so it rebuilds the chain.
  Reads still come from replica.app. New API: verifyAudit() / auditHead() /
  getAuditLog() — all null when not auditing OR when the bootstrap snapshot lacks
  audit data (a scoped/partial stream), so a scoped client never gets a false
  "valid" (the uniform-log-vs-authz tension).
- Browser audit verification remains future work (needs async WebCrypto).

Reviewed (request-changes): fixed B1 — a SCOPED, from-scratch stream sent the
full materialized scoped state as a bootstrap marker but left the cursor at 0,
so the catch-up loop replayed every committed entry ON TOP of it, double-applying
non-idempotent commands (BORROW/RETURN) and silently diverging the replica (the
existing scoped tests used ADD-only fixtures and masked it). The marker now
labels lastIncludedIndex = the live head and advances the cursor, so scoped
clients materialize at the head and tail from there — no replay.

Tests: tests/edgeAudit.test.ts (happy path + server-head match, live tail,
tamper detection, scoped⇒unavailable, non-auditing⇒null) and an edgeAuthz
regression for the B1 BORROW double-apply (verified it fails pre-fix).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
@naveed949 naveed949 changed the title Edge read replicas — production-ready client-side replica (ADR-0023, M20–M25) Edge read replicas — production-ready client-side replica (ADR-0023, M20–M28) Jun 22, 2026
Complete client-side audit-chain verification in the BROWSER, and fix an M28
regression that pulled Node crypto into the browser bundle.

- src/consensus/auditChain.ts (new): the audit-hash preimage as a shared, pure
  module (GENESIS_HASH, auditEntryPayload, AuditRecord/AuditPayloadInput).
  ReplicatedStateMachine now uses it, so the server and client hash formats are
  one source of truth and cannot drift.
- src/edge/sha256.ts (new): webcryptoSha256Hex over globalThis.crypto.subtle —
  one async hasher that runs in Node 20+ AND the browser, producing the identical
  lowercase-hex digest the server's Node crypto produces.
- edgeReplica.ts: AuditingApplier reworked to be browser-safe — applies app
  commands directly and records audit INPUTS (mirroring the server's NOOP-skip /
  CONFIG-audited-as-200 / metadata-default rules), with NO ReplicatedStateMachine
  value-import (import type only), so the browser graph no longer touches Node
  crypto. verifyAudit()/auditHead() are now async (fold the chain via WebCrypto);
  EdgeReplicaOptions.auditHasher allows injection. Scoped streams still report
  unavailable (null); the non-auditing path is unchanged.

M28 broke the browser bundle (edgeReplica -> replicatedStateMachine -> 'crypto');
this restores it: the browser runtime graph trace is clean (BAD: NONE, no
replicatedStateMachine.js). Verified end to end: the COMPILED browser bundle,
loaded as a browser would, verifies the chain (valid) and its audit head equals
the live server's head byte-for-byte.

Reviewed (approve-with-nits): unified the duplicated audit-record type into
auditChain.ts and made auditHead() return GENESIS_HASH for an empty-but-available
chain (so available => non-null head, consistent with verifyAudit).

Tests: tests/edgeAudit.test.ts — async verify/head over WebCrypto, head equality
with the server, async tamper detection, scoped⇒null, non-auditing⇒null.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NneoGGKxBp5ZExL7D7i1Lu
@naveed949 naveed949 changed the title Edge read replicas — production-ready client-side replica (ADR-0023, M20–M28) Edge read replicas — complete client-side replica (ADR-0023, M20–M29) Jun 22, 2026
@naveed949 naveed949 closed this Jun 23, 2026
@naveed949
naveed949 deleted the claude/edge-read-replicas branch June 23, 2026 05:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants