Claude Dev Environment
Historical proposal. The Reaktor Shadow textbook pages and Operations & Reliability supersede this draft. In particular, moving-base publication differs from pinned replay, store materialization is explicit, deterministic fidelity is qualified, production read-through is denied and pinned cases obey deletion policy. The original prose below is preserved for lineage.
Dev runs against a continuously-refreshed shadow of production: the last 7 days of Supabase rows, Memgraph nodes, R2 objects, worker payloads, and JVM API traces — plus a hand-curated set of pinned crashes and anomalies that never expire. All testing, load testing, and exploratory debugging happens here. Built as a graph visitor (per Reaktor Spec §28) extended end-to-end across the actual data surfaces in BestBuds.
-
Builds on — reaktor-shadow
-
First consumer BestBuds dev cluster
-
Window rolling 7-day + permanent pinned fixtures
-
Drivers Keploy · k6 · Maestro
-
Status Design
-
Stateful surfaces — 7 (Postgres · Memgraph · R2 × 2 · Workers AI · Sheets · JVM)
-
Cloudflare workers in scope — 10
-
Storage budget — ~50 GB at peak
-
Refresh cadence — hourly capture · nightly trim
-
PII handling — scrub at capture (not query)
-
Side-effect safety — tenant prefix + outbox interceptor
1. Executive summary
"Dev" today means an empty database and a stubbed worker — production bugs lived in a state of the world the staging environment never produced. The shadow dev environment fixes this by making production state the default state of dev: the same Supabase tables, the same Memgraph subgraphs, the same R2 objects, the same chat-message shapes, all updated continuously and trimmed to a rolling 7-day window. Any captured trace can be replayed against your branch — like Keploy, but covering all six Reaktor transports (HTTP · LOCAL · PEER · PUBSUB · QUEUE · WORKFLOW), every database, and the full causal chain of downstream calls a request triggers.
The architecture has three loops:
- Capture loop — a
ShadowCaptureVisitorattaches to everyServiceNode,RepositoryNode, andWorkerBasein production. Every request/response pair, every DB write, every external call is encoded as a FlexBuffer envelope tagged with acapture.idand stored in R2 partitioned by hour. Logical replication mirrors Postgres into a shadow database. R2 reads through to production for cache misses. - Refresh loop — nightly Worker trims captures older than 7 days, except for those marked
pinned=trueby either a human or the anomaly detector. Postgres shadow vacuums tombstoned rows; Memgraph snapshots roll forward. - Replay loop — devs run
shadow replay <capture-id>(CLI, also a Workbench button); the engine reconstructs the request context, deflects external API calls to deterministic stubs, executes against the branch, and diffs the response + causal chain against the captured baseline. Used in CI, load tests, and interactive debugging.
2. Current state
2.1 What dev/test infrastructure exists today
| Surface | Today | Status |
|---|---|---|
| Dev Postgres | None — devs share staging Supabase or run an empty local Postgres. | missing |
| Dev Memgraph | None — dev hits the same GCP CE Memgraph as staging. | missing |
| Dev R2 | None — dev writes leak into the production IMAGES bucket. | unsafe |
| Traffic capture | None — only Kermit log lines via Loki (planned, not deployed). | missing |
| Outbox / idempotency | Not implemented — the spec calls for it but no reaktor_outbox or *_idempotency tables exist yet. | missing |
| Nightly backup | bestbudsArchiver (Worker, cron 0 3 * * *) dumps Postgres → R2 BACKUPS bucket. | prototype |
| Test framework | Maestro flows in bestbuds/maestro/; nothing beyond. | prototype |
| n8n workflows | 10 workflows for deploys, builds, k8s sync — none for shadow refresh. | unrelated |
| Keploy / k6 / load test | Mentioned in spec §28.2; not wired. | missing |
2.2 The honest gap
Today a dev with a branch has three options, all bad: (a) run nothing, write code blind; (b) wire their laptop to staging and hope they don't fat-finger a destructive query; (c) spin up an empty local Postgres + Memgraph + Workers and hand-craft fixtures that never resemble production. Option (c) is what most teams settle for, and it's why every "this worked in staging" bug exists.
3. Why a moving shadow
Bugs live in production shapes
The user with 4,200 chat messages, the group with 87 members, the friend graph with a 6-hop cycle — these don't appear in fixtures. Reproducing requires the actual data; recent actual data, because schemas and content drift week to week.
Load tests are useless without realistic mix
k6 scripts that hit /api/feed at uniform 100 rps don't predict the production failure mode where one user with 12,000 friends makes a single read that joins 12,000 rows. Real traffic patterns are the input; synthetic load is the multiplier.
Edge cases need permanence
A crash from January that took 6 hours to root-cause must be reproducible in October — not "if you can find the row in the backup." Pinned fixtures keep the weird ones forever.
Side effects need a sandbox
Devs experimenting can't be allowed to send real FCM notifications, write to the real Manna Google Sheet, or trigger Workers AI charges. Shadow stack means side effects are intercepted at the visitor.
Privacy by construction
PII scrubbed once at capture time beats scrubbing on every query. Shadow data is already safe to look at; devs don't need to remember to anonymise.
Replay closes the regression loop
Every captured request is a candidate test case. CI replays the last week of distinct traffic against the PR branch. If the response shape or P95 latency drifts, the PR is flagged — same model as Keploy, but covering all transports.
4. Prior art
| Tool | What it does well | Adopted precedent · proposed extension |
|---|---|---|
| Keploy | eBPF/proxy capture of HTTP traffic; auto-generates regression tests; replay with assertion diffing. | Take: the capture/replay/diff model. Add: capture at the Service layer (visitor) instead of HTTP, so all six transports are covered; capture downstream causal chain, not just request/response. |
| GoReplay | TCP-level traffic shadowing — splits live traffic to a shadow service for soak testing. | Take: the "split live traffic" idea. Add: the shadow service doesn't have to handle real load — it can sample and persist for later replay. |
| mirrord | Runs a local process "inside" a remote pod's network/file/env namespaces. | Take: the "your laptop is the cluster" feeling. Add: the design requires isolation — devs hit the shadow stack, not prod. |
| Replay.io / rr | Deterministic record/replay of an entire program execution, including non-determinism (syscalls, RNG, time). | Take: "record once, debug forever" pinning of weird traces. Add: capture operates at the service layer, not the syscall layer — much coarser, but covers distributed systems. |
| Stripe's Capture & Replay | Internal tool that records production webhook deliveries and replays them against dev for integration testing. | Take: the dedup + replay-against-branch ergonomics. Add: covers more than webhooks — every async hop in the graph. |
| Supabase branching / Neon branching | Cheap database forks per branch via copy-on-write storage. | Take: "DB per branch" for write isolation. Add: the shadow source is fresher than backup, and Memgraph + R2 also need branching. |
5. Principles
1 · Capture is a visitor, not a proxy
Per spec §28.1: ShadowCaptureVisitor attaches at the graph layer. Every transport, every node, one mechanism. No sidecar, no service mesh, no eBPF.
2 · The shadow IS dev
There is no separate "dev environment". The shadow stack is dev. Devs run their branch against shadow workers, shadow Postgres, shadow Memgraph, shadow R2.
3 · Moving by default, pinned by exception
The default is a 7-day rolling window — every captured trace older than that is trimmed. Exceptions (crashes, anomalies, hand-curated edge cases) are pinned and never expire.
4 · Side effects fail loudly in shadow
Every external call (FCM, APNs, Workers AI, Google Sheets) hits a stub by default. Bypassing requires explicit --allow-real-effects + a confirmation prompt.
5 · PII scrubbed at capture
Names, emails, phone numbers, message bodies, location coordinates — replaced with deterministic fakes (same input → same fake) before anything hits R2. The shadow store contains no real PII.
6 · Replay must be deterministic
Time, RNG, IDs, external calls — all read from the captured envelope, not the system. A replay that gives a different answer means the code changed, not the environment.
7 · One refresh job per surface
Each stateful surface has its own refresh worker — Postgres has a logical-replication subscriber, Memgraph has a nightly snapshot importer, R2 has an event-driven mirror. Failures are isolated; one stuck refresh doesn't poison the rest.
8 · Tenant prefix everywhere
Shadow tenants live as shadow_$tenant — IDs prefixed, indexes prefixed, R2 keys prefixed. Prod queries with the wrong prefix hit nothing; shadow queries can't accidentally hit prod.
6. Stateful surfaces to shadow
From a fresh audit of bestbuds/targets/*Server, cloud/, and the JVM reaktorServer:
| Surface | Binding(s) | Shadow strategy | Refresh rate |
|---|---|---|---|
| Postgres (Supabase) | supabase · supabasedirect (Hyperdrive) | Logical replication slot → shadow DB; 7-day TTL on row shadow_inserted_at; vacuum nightly. | continuous (≤5 s lag) |
| CockroachDB | cockroach · cockroachdirect | Same approach (CDC feed → shadow cluster). | continuous |
| Memgraph (GCP CE) | JVM memgraphDriver() | Daily Cypher DUMP DATABASE snapshot → R2; shadow Memgraph imports on schedule. Plus a delta capture of write Cyphers from the visitor for incremental replay. | nightly snapshot + delta capture continuous |
| R2 IMAGES | IMAGES bucket (10 worker readers, mediaServer writes) | Read-through proxy in front of shadow_IMAGES. On read miss, fetch from prod, store in shadow. Captured writes go only to shadow. | on-demand (read-through) + bulk seed |
| R2 BACKUPS | BACKUPS (archiver-only) | Not shadowed — already a backup of prod; shadow can read it for cold restore. | n/a |
| Cloudflare Workers AI | AI binding (botServer) | Capture every textGeneration request + response; replay returns cached response keyed by SHA-256(prompt + model + params). Falls back to live Workers AI only with explicit flag. | per-call cache |
| Google Sheets | credentials.json in reaktorServer | Captured write payloads go to a shadow spreadsheet in a sandbox Google account, never the prod Manna sheet. | per-call deflection |
| Service bindings | APP_SERVICE in 4 workers | Routed to shadow worker instances, never prod. | per-call |
| JVM reaktorServer API | REST + GraphQL on /apps/*, /graphql | Captured by ShadowCaptureVisitor (this is the visitor's natural attach point on JVM). | per-request |
| FCM / APNs | (planned per notifications doc) | Captured → null-sink in shadow; never delivered. Replay produces the same captured "would have sent" record. | per-call |
7. Side-effect classification
Replay correctness hinges on knowing which calls are safe to re-execute (idempotent reads, deterministic computations) vs which would produce duplicate real-world effects. Three categories, each with a default behaviour:
| Category | Examples | Default in shadow | Override |
|---|---|---|---|
| Pure / idempotent reads | Postgres SELECT, Memgraph MATCH, R2 GET, public HTTP GET with cache headers | Execute against shadow store | none needed |
| Idempotent writes | UPSERT by primary key, R2 PUT (key-addressed) | Execute against shadow store | none needed |
| Non-idempotent writes | SQL INSERT without dedup key, bus publish without outbox, FCM/APNs send, Google Sheets append, Workers AI inference, payment, email | Intercepted — return captured response from envelope | --allow-real-effects + per-call confirmation |
The visitor classifies on capture using two signals: HttpMethod for HTTP (GET/HEAD/OPTIONS → idempotent), and explicit @SideEffect(idempotent = true|false) annotations on Reaktor service handlers. Handlers without an annotation default to non-idempotent (safe default — better to over-stub than over-fire).
8. Stack diagram
┌────────────────────────── PRODUCTION ────────────────────────────────┐
│ user request │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Cloudflare Worker / JVM Service │ │
│ │ ┌────────────────────────────────────────────────────────────┐ │ │
│ │ │ ServiceNode │ │ │
│ │ │ ├─ ShadowCaptureVisitor.intercept(req) │ │ │
│ │ │ │ ↓ writes envelope → R2 captures bucket │ │ │
│ │ │ ├─ RepositoryNode → Postgres (logical replication) │─┼──┐
│ │ │ ├─ GraphNode → Memgraph (CDC + nightly dump) │ │ │
│ │ │ └─ ActorNode → bus publish (outbox) │ │ │
│ │ └────────────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────│────────────────────────────────────┘
│ replication / capture
▼
┌────────────────────────── SHADOW STACK ──────────────────────────────────┐
│ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ shadow Postgres │ │ shadow Memgraph │ │ shadow R2 │ │
│ │ logical replica │ │ snapshot + delta │ │ read-through │ │
│ │ 7d window + pinned │ │ rebuilt nightly │ │ + write capture │ │
│ └──────────────────────┘ └──────────────────────┘ └──────────────────┘ │
│ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ captures bucket │ │ stubs registry │ │ pin index │ │
│ │ FlexBuffer envelopes │ │ AI / Sheets / FCM │ │ DB-backed list │ │
│ │ partitioned by hour │ │ replay responses │ │ of permanent IDs │ │
│ └──────────────────────┘ └──────────────────────┘ └──────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Shadow workers (mirror of prod fleet) │ │
│ │ analyticsServer · chatServer · stickerServer · configServer · │ │
│ │ mediaServer · notificationServer · socialServer · messagingServer · │ │
│ │ botServer — all with shadow bindings │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ dev branch / CI replay │
│ ┌───────────────────┴──────────────────────────────────────────────────┐ │
│ │ ShadowReplay engine · CLI · CI job · Workbench panel │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
▲
│ nightly trim · anomaly auto-pin
┌───────────────────────────┐
│ ShadowRefresh Worker │
│ cron: 0 4 * * * UTC │
└───────────────────────────┘
9. Capture path
9.1 The visitor
class ShadowCaptureVisitor(
private val sink: CaptureSink,
private val scrubber: PiiScrubber,
private val sampler: CaptureSampler,
) : Visitor() {
override fun visitServiceNode(node: ServiceNode): ExitScope {
node.intercept(object : ServiceInterceptor {
override suspend fun intercept(req: Request, chain: ServiceChain): Response {
if (!sampler.shouldCapture(req)) return chain.proceed(req)
val captureId = Uuid.v7().toString()
val downstream = mutableListOf<DownstreamCall>()
// Push a CaptureContext into the coroutine context so that child
// service / DB / actor calls all append to `downstream`.
return withCaptureContext(CaptureContext(captureId, downstream)) {
val startedAt = Clock.System.now()
val response = try { chain.proceed(req) }
catch (t: Throwable) { sink.captureError(captureId, t); throw t }
val finishedAt = Clock.System.now()
sink.write(CaptureEnvelope(
id = captureId,
tenantId = req.tenantId,
transport = req.transport,
request = scrubber.scrub(req),
response = scrubber.scrub(response),
downstream = downstream.toList(),
startedAt = startedAt,
durationMs = (finishedAt - startedAt).inWholeMilliseconds,
handlerNode = node.id,
))
response
}
}
})
return NoOpExit
}
override fun visitRepositoryNode(node: RepositoryNode): ExitScope { /* append to downstream */ }
override fun visitActorNode(node: ActorNode): ExitScope { /* append to downstream */ }
}
9.2 What an envelope contains
{
"id": "01HXKQF8M7...", // UUID v7 (time-sortable)
"tenantId": "shadow_bestbuds",
"transport": "HTTP", // HTTP | LOCAL | PEER | PUBSUB | QUEUE | WORKFLOW
"handlerNode": "ChatService.sendMessage",
"request": {
"method": "POST", "path": "/chats/{id}/message",
"params": { "id": "chat_42" },
"headers": { "x-user": "user_b3ad...", "x-trace": "..." },
"body": "<FlexBuffer>" // scrubbed
},
"response": { "status": 201, "body": "<FlexBuffer>" },
"downstream": [
{ "kind": "sql.query", "target": "supabase", "durationMs": 8, "rows": 1 },
{ "kind": "r2.put", "target": "IMAGES", "durationMs": 42, "bytes": 81920 },
{ "kind": "bus.publish", "target": "chat.message.sent", "durationMs": 2 },
{ "kind": "ai.generation", "target": "@cf/meta/llama-3", "durationMs": 340, "tokensIn": 120 }
],
"startedAt": "2026-05-27T18:42:19Z",
"durationMs": 412,
"tags": { "client": "android-1.4.2", "build.sha": "a3c...", "anomaly": null },
"pinned": false
}
9.3 Sampling
- Default sample rate: 5% of normal traffic. Sufficient to cover the long tail without overflowing R2.
- 100% on error: any request with
response.status >= 500or an uncaught exception is captured regardless of sample. - 100% on slow: any request whose
durationMsexceeds the p99 budget for its endpoint. - Cohort burst: 100% for users tagged
debug.session_idby the dev tools panel — for "trace this user end-to-end" debugging. - Selector knob: per-endpoint rate overrides in
shadow-config.tomlso high-value endpoints (auth, payments when added) can be captured at 100%.
10. Shadow stores per surface
10.1 Postgres / Supabase / Cockroach
Mechanism: logical replication. Supabase exposes WAL via the wal2json output plugin. A long-running Worker (or a JVM service in shadow) subscribes to a replication slot named shadow_replica and applies changes to shadow_db on a separate cluster.
- 7-day TTL: every row gets a
shadow_inserted_at TIMESTAMPTZ DEFAULT now()column added in the shadow schema (no change to prod schema). NightlyDELETE FROM ... WHERE shadow_inserted_at < now() - interval '7 days' AND id NOT IN (SELECT id FROM shadow_pinned). Vacuum follows. - Pinned table:
shadow_pinned (table_name, row_id, pinned_at, pinned_by, reason). Anomaly detector + manual curator both insert here. - Per-branch fork: if Neon/Supabase branching is enabled, each dev branch gets a copy-on-write fork of the shadow DB at branch creation. Writes during dev stay in the fork; the underlying shadow continues to refresh from prod.
- Tenant prefix: shadow records get
tenant_id = 'shadow_' || original_tenant_idrewritten on replication.
10.2 Memgraph
Mechanism: Memgraph supports DUMP DATABASE for snapshots and triggers for change capture. Hybrid:
- Nightly snapshot:
shadow-refreshWorker shells into the prod Memgraph, runsDUMP DATABASEto a Cypherl file, uploads to R2, then a shadow Memgraph instance imports it. - Delta capture: the
ShadowCaptureVisitoralso captures Cypher writes (CREATE/MERGE/DELETE) on every request. Between snapshots, deltas can be replayed to bring shadow forward, or to replay one specific write sequence in isolation. - Tenant scoping: every shadow query is automatically rewritten to add
WHERE n.tenant_id STARTS WITH 'shadow_'— protects against accidentally querying prod data.
10.3 R2 IMAGES
Mechanism: read-through caching with copy-on-write writes.
class ShadowR2Binding(
private val shadow: R2Bucket, // shadow_IMAGES
private val production: R2Bucket, // IMAGES (read-only)
) : R2Bucket {
override suspend fun get(key: String): R2Object? {
shadow.get(key)?.let { return it }
val upstream = production.get(key) ?: return null
shadow.put(key, upstream.body, metadata = mapOf("shadow.cached" to "true"))
return upstream
}
override suspend fun put(key: String, body: ByteArray) = shadow.put(key, body)
}
10.4 Workers AI
Captures every textGeneration call's input/output. Replay returns the cached response keyed by SHA-256(prompt + model + temperature + maxTokens). Identical inputs always produce the captured output; novel inputs (in dev where prompts change) either fail fast (--strict) or fall through to live Workers AI (--allow-novel).
10.5 Google Sheets / Firebase / FCM / APNs
Stubbed by default. Captured request payloads are logged to a side store so devs can inspect "what would have been sent" without anything actually leaving the shadow cluster. A single shadow Manna sheet in a sandbox Google account can be enabled per-tenant for integration tests that need a real write.
11. The 7-day window + pinned fixtures
11.1 Two-tier retention
ROLLING WINDOW PINNED FIXTURES
────────────────────── ─────────────────────
moves with wall clock permanent
~7 days deep no TTL
~50 GB at peak grows slowly
auto-trimmed nightly human + AI curated
┌─────────────────────────┐ ┌──────────────────────────┐
│ captures from last 7d │ ─ promote ▶│ pinned (5,000 + entries) │
│ - 5% sample of normal │ on anomaly │ - "the OOM from Jan" │
│ - 100% on error/slow │ / manual │ - "12k-friend user" │
│ - 100% for debug.uid │ pin │ - "race in chat init" │
└─────────────────────────┘ └──────────────────────────┘
│
▼
trim job: 0 4 * * *
11.2 Pin reasons (canonical list)
| Reason | Who pins | Why permanent |
|---|---|---|
crash | auto — any 5xx + stack trace pattern not seen in last 7d | Bug class proven real; must remain reproducible. |
slow_outlier | auto — request that exceeded p99.9 of its endpoint | Load-test corpus for "what makes this endpoint slow." |
schema_edge | auto — request body that hit a rare combination of optional fields | Test coverage for combinations CI fixtures never produce. |
cohort_extreme | auto — top-1% input size (largest payload, deepest graph, most friends) | Performance ceiling testing. |
incident_repro | human — engineer pins during incident retro | Permanent regression test for "the X bug." |
release_baseline | auto — one capture per endpoint snapshotted at each tagged release | "Did this PR change behaviour vs the v1.4 baseline?" |
customer_report | human — support engineer pins when a user complaint comes in | Reproducer attached to the support ticket. |
11.3 Storage maths
Average envelope ~4 KB (compressed FlexBuffer). At 50 rps production traffic × 5% sample = 2.5 captures/sec = 216,000/day = 6.5M/week. At 4 KB each that's ~26 GB for the rolling window. Pinned set assumed to grow to ~50,000 entries over 12 months = ~200 MB. Postgres shadow data depends on retention; a 7-day cut of BestBuds today is ~12 GB. Total shadow R2 + Postgres footprint at maturity: under 50 GB — fits comfortably in a single Hyperdrive-backed Postgres + R2 free tier.
12. Replay engine
12.1 CLI ergonomics
# replay one capture against the local dev worker
shadow replay 01HXKQF8M7XYZ # single capture by ID
# replay last hour of /api/feed against branch
shadow replay --endpoint /api/feed --since 1h --against http://localhost:8787
# replay every pinned crash against my branch (regression suite)
shadow replay --pinned --reason crash --against http://localhost:8787
# load-test: replay last 24h at 5x speed
shadow replay --since 24h --speedup 5 --against http://staging-shadow
# diff replay output against captured baseline
shadow replay --since 1h --diff --against http://localhost:8787
# → reports: 2 response body diffs, 1 status diff, 1 P95 regression
12.2 What gets replayed
Replay isn't just "re-execute the request" — it reconstructs the captured causal chain:
- Set
Clock,Random,Uuidto deterministic streams seeded fromcaptureId. - Wrap every external binding (R2, Workers AI, Sheets, FCM) so that calls matching the captured downstream return the captured response.
- Issue the captured request with all original headers preserved (except
x-trace, which gets a fresh one taggedshadow.replay-of=<captureId>). - Compare the new response + downstream chain to the captured baseline.
- Report diffs structurally: status mismatch, header diff, body diff (with FlexBuffer-aware path-level reporting), missing downstream call, extra downstream call, latency regression.
12.3 Three replay modes
strict
Response must match the captured baseline byte-for-byte (after FlexBuffer canonicalisation). Used in CI for regression detection.
tolerant
Schema match + value equivalence (numbers within ε, timestamps modulo replay clock, IDs allowed to differ if downstream-consistent). Used during refactors.
exploratory
Diff reported but never fails. Used for "I'm trying something new, just show me what changed." Most-used mode interactively.
13. Stubbing dangerous side effects
The replay engine consults a StubRegistry before any external call leaves the shadow:
object StubRegistry {
private val stubs = mapOf<CallKind, StubStrategy>(
CallKind.AI_TEXT_GENERATION to CachedByPrompt,
CallKind.SHEETS_APPEND to NullSink(captureFor = "sheets-writes.jsonl"),
CallKind.FCM_SEND to NullSink(captureFor = "fcm-sends.jsonl"),
CallKind.APNS_SEND to NullSink(captureFor = "apns-sends.jsonl"),
CallKind.SUPABASE_AUTH_LINK to DeterministicSuccess,
CallKind.WEBHOOK_OUTBOUND to NullSink(captureFor = "webhooks-out.jsonl"),
)
fun strategyFor(call: CallKind): StubStrategy =
stubs[call] ?: if (call.isIdempotent) PassThrough else FailWithGuidance(call)
}
FailWithGuidance throws an exception with a message like "Call to POST https://api.stripe.com/charges blocked in shadow mode. Add a stub via StubRegistry.register(CallKind.STRIPE_CHARGE, …) or replay with --allow-real-effects." — surfaces the dangerous call instead of silently letting it through.
14. Anomaly detection & auto-pinning
A scheduled Worker runs every 15 min over the rolling capture stream and pins anything that looks unusual:
- Stack-trace clustering — captured 5xx errors with stack traces are hashed (top 10 frames, normalised). A new cluster (or a cluster that hasn't appeared in 30 days) auto-pins one exemplar.
- Latency outliers — per-endpoint p99.9 over a 24h window; any capture exceeding it gets pinned with reason
slow_outlier. - Payload extremes — for each endpoint, track the 99th percentile of payload size. Captures above pin with reason
cohort_extreme. - Schema-edge detection — track which combinations of optional fields appear in request bodies. A combination not seen in the last 30 days auto-pins.
- Causal-chain depth — captures whose
downstream.lengthis above p99 (deep graph fan-out) pin.
Pin budget: anomaly detector pins at most 50 captures per day per category, to keep the pinned set from ballooning. Excess go into a candidate queue for human curation in the Workbench.
15. Privacy & PII scrubbing
Scrubbing happens once at capture time, before anything is written to R2 or shadow Postgres. The shadow store contains no real PII; devs cannot accidentally see it.
| Field type | Detection | Replacement |
|---|---|---|
| Name (display, full) | Schema annotation @Pii(.Name) + heuristics | Deterministic faker: same input always → same fake name. Preserves uniqueness for join testing. |
| Regex + schema | shadow_$hash@example.test | |
| Phone | Regex + schema | +1-555-$hash6 |
| Message body | Schema | lorem_ipsum_of_length(original.length) |
| Coordinates (PostGIS) | Schema | Snap to ~1 km grid + add ±500 m jitter |
| Photos in R2 | R2 metadata + content-type | Replace with same-size placeholder PNG |
| OAuth tokens, JWTs | Detected by prefix (eyJ…, ghp_…) | Token revoked + replaced with shadow_-prefixed forgery |
Deterministic-faker key: a per-tenant secret HMAC key, scoped only to the capture-time scrubber. Same key for the life of the shadow cluster — so the same user is always "Alice Wonderland" in shadow, every replay. Devs can join across tables.
15.1 Auditing
Every capture's scrubManifest records which fields were scrubbed and by which rule. A nightly compliance job samples 100 random captures, decrypts the manifest, and verifies that no raw PII slipped through (e.g. a free-form text field that wasn't annotated). Failures alert the data-team channel.
16. Tenant isolation
Three layers of "you can't accidentally hit prod":
- ID prefix — all shadow tenant IDs, user IDs, room IDs are rewritten
shadow_. Prod has no such IDs; any query with the prefix returns empty against prod, any query without returns empty against shadow. - Bucket / database prefix — separate R2 bucket (
shadow_IMAGES), separate Postgres database (shadow_db), separate Memgraph (shadow-memgraphon a sandbox GCP project). - Credential isolation — shadow credentials are scoped at the Cloudflare account + GCP project level. The prod service account literally cannot reach shadow infrastructure and vice versa.
17. Time travel
Every capture envelope carries startedAt. Replay can pin to any historical moment within the retention window:
shadow replay --as-of 2026-05-20T14:30:00Z --endpoint /api/feed --since 1h
The engine restores the shadow Postgres + Memgraph to that timestamp via SELECT * FROM ... AT TIMESTAMP (CockroachDB native), or via Postgres logical replay rewind, then runs the requested captures. For points outside the 7-day window, the cold restore from the nightly bestbudsArchiver backup is used (slower, ~10 minutes to materialise).
18. Load testing from real patterns
The capture corpus is also a load-test corpus. Per spec §28.2:
shadow load --since 7d --speedup 10x --target http://staging-shadow --duration 30m
# Internally:
# 1. Sample captures from last 7d, weighted by endpoint frequency in prod.
# 2. Replay them at 10x the original arrival cadence.
# 3. k6 collects per-endpoint latency histograms, errors, throughput.
# 4. Export to Mimir; Grafana panels render against the performance-observability
# pipeline (https://reaktor.build/docs/performance-observability).
Pattern fidelity: traffic is replayed with the same inter-arrival distribution as production — bursts, idle periods, diurnal cycles — not as a uniform RPS stream. This catches issues that uniform load tests miss (queue depth spikes, cache stampedes).
19. Observability
Every capture and replay emits OTel spans into the same Grafana LGTM pipeline described in performance-observability §17. Two service names:
service.name = "shadow-capture"— the visitor itself. Tells you capture overhead, sample drops, scrubber failures.service.name = "shadow-replay"— every replay run. Tells you replay duration, diffs detected, false-positive rate.
The Workbench gets a Shadow tab showing: captures/sec live, pinned set size, oldest unpinned capture, today's replay diff count, anomaly auto-pin rate.
20. Workbench & CLI
20.1 Workbench panel
A dedicated Shadow panel in the Reaktor Desktop workbench (lives alongside the existing Blueprint editor):
- Capture stream — live tail of incoming captures, filter by endpoint / tenant / status. Click → inspect envelope.
- Pinned fixtures — browsable, searchable, pinnable/unpinnable. Each entry shows: pin reason, age, last replayed, recent diff count.
- Replay against branch — pick any capture, hit "Replay against my branch" → opens a diff view (response, downstream, latency).
- Anomaly candidates — the queue the auto-pinner couldn't keep up with; human-curator approves or dismisses.
- Refresh status — when did Postgres replication last lag? When did Memgraph last snapshot? When did the trim job last run?
20.2 CLI verbs
| Verb | What it does |
|---|---|
shadow capture --pause / --resume | Toggle the production visitor (e.g. before a sensitive release). |
shadow inspect <id> | Pretty-print one capture envelope (request, response, downstream chain, tags). |
shadow replay … | (See §12.) |
shadow pin <id> --reason customer_report --note "ticket #42" | Manually pin. |
shadow load … | (See §18.) |
shadow diff <a> <b> | Compare two captures (useful during refactors: did v2 produce the same chain as v1?). |
shadow restore --as-of <ts> | Restore the shadow stack to a historical moment. |
shadow seed <dataset> | Apply a curated seed (e.g. "100 users with synthetic friend graphs of depth 6"). |
21. CI integration
Three CI jobs per PR, all running against the shadow stack:
CI · job 1~2 min
Replay pinned regression suite
shadow replay --pinned --strict --against $PR_BRANCH. Every pinned crash and incident-repro fixture must still produce the captured response. Failure blocks merge.
CI · job 2~3 min
Replay yesterday's traffic sample
shadow replay --since 24h --sample 1% --tolerant. A random 1% of the last day's captures replayed in tolerant mode. Catches behavioural drift that pinned fixtures don't cover yet.
CI · job 3~10 min (nightly)
Load test from last week's patterns
shadow load --since 7d --speedup 5x --duration 8m. Runs nightly on main, not per-PR. Latency histograms feed the perf dashboard; regressions vs the previous nightly create a ticket.
22. BestBuds wiring
| Where | Change |
|---|---|
| bestbuds/cloud/k3s/shadow/ | New: Helm chart for shadow Postgres, shadow Memgraph, shadow worker fleet, OTel collector. Sits next to the existing observability stack. |
| bestbuds/cloud/cloudflare/shadow/ | New: Wrangler configs for shadow-$worker mirroring each of the 10 production workers, plus the shadow_IMAGES R2 bucket and the captures R2 bucket. |
| bestbuds/modules/app/src/jvmMain/.../ShadowCaptureFilter.kt | New: Spring filter that installs ShadowCaptureVisitor on the JVM reaktorServer when the SHADOW_CAPTURE_ENABLED=true env is set in prod. |
| bestbuds/modules/app/src/jsMain/.../ShadowCaptureMiddleware.kt | New: Worker middleware that wraps every Hono route on every worker. Same envelope schema, same R2 sink. |
| bestbuds/.github/workflows/shadow-replay.yml | New: 3 CI jobs above. |
| bestbuds/tools/shadow/ | New: the shadow CLI (Kotlin/JVM binary, distributed via Homebrew tap). |
| bestbuds/targets/reaktorWeb/public/docs/ | This doc lands here. |
| bestbuds/cloud/cloudflare/wrangler.json (root) | Add a new Hyperdrive binding shadow_supabase pointing at the shadow Postgres. Workers detect env.SHADOW_MODE and pick the binding accordingly. |
22.1 First slice: chatServer
Start with one worker — chatServer — because it has the richest traffic and an obvious high-value debug surface. Validate the capture path on one worker before fanning out to the other nine.
23. Phased plan
Phase 0~3 days
Spec the envelope & pin schema
CaptureEnvelope, PinReason, StubStrategy, ShadowConfig in reaktor-shadow commonMain. FlexBuffer schemas + serde. Unit tests on round-trip.
Phase 1~1 week
Capture path on one worker
ShadowCaptureVisitor in TS for chatServer. Captures envelopes to shadow_captures R2 bucket. PII scrubber for chat-message fields. 5% sampling. Done when: 24h of chatServer traffic visible in R2 as well-formed envelopes, <1% capture overhead per request.
Phase 2~1 week
Shadow Postgres via logical replication
Provision shadow Supabase project. Set up wal2json replication slot. Subscriber Worker applies changes to shadow with tenant prefix rewrite + shadow_inserted_at column. Trim job. Done when: shadow DB lags prod by < 5 s and 7-day trim works.
Phase 3~1 week
Shadow R2 + Memgraph + stubs
Read-through R2 binding. Nightly Memgraph dump + import. Workers AI cache stub. Sheets / FCM null sinks. Done when: a dev can run chatServer locally against the full shadow stack and observe end-to-end requests producing no real side effects.
Phase 4~1 week
Replay engine + CLI
The shadow CLI with replay, inspect, pin, diff. Three replay modes (strict/tolerant/exploratory). Diff reporter. Done when: a captured request from yesterday can be replayed against the local branch and produces a structured diff.
Phase 5~½ week
Anomaly detector + pin queue
Scheduled 15-min Worker doing stack-trace clustering + latency outliers + payload extremes. Pin budget (50/cat/day). Candidate queue surfaces in Workbench.
Phase 6~1 week
Roll capture to all 10 workers
Apply the Phase-1 middleware to every worker. Tune per-endpoint sample rates. Validate scrubber coverage on every endpoint's schema.
Phase 7~½ week
CI integration
Three CI jobs above. PR-comment formatting for diff reports.
Phase 8~1 week
Load test + Workbench Shadow panel
shadow load verb + k6 integration. The Workbench Shadow tab. Grafana dashboards for capture/replay metrics.
Phase 9~1 week
Time travel + per-branch DB forks
shadow restore --as-of. Supabase branching for per-PR DB forks (only if Supabase enables it on the account tier).
24. Risks & open questions
Risks
- Capture overhead in prod. The visitor sits in every request path. Target: <1% latency overhead at p95 with 5% sampling. Mitigation: capture envelope assembly is async, writes batched to R2 every 200 ms, dropped on backpressure.
- Logical replication lag. If shadow DB lags prod by minutes, recent captures replay against stale data. Mitigation: monitor replication slot lag as a first-class metric; alert if > 30 s.
- R2 cost. 26 GB rolling + 200 MB pinned at $0.015/GB/mo = trivial. Class A writes at 216k/day × $0.0045 per 1k = ~$0.03/day. Capture egress for replay is the wild card — if devs replay aggressively, bandwidth costs spike. Mitigation: replay reads stay within Cloudflare's network (free).
- PII scrubber misses a field. A new schema field added without
@Piiannotation could leak real PII into shadow. Mitigation: nightly compliance sampler (§15.1) + a schema-evolution CI check that flags any new free-form text field without an explicit PII annotation. - Memgraph snapshot freshness. Nightly is too stale for some bugs. Mitigation: the delta-capture stream allows fine-grained roll-forward; the snapshot interval can shrink to 6h if needed.
- Stub registry stales. When prod adds a new external API, the stub registry doesn't know about it — replay either fails or fires the real call. Mitigation:
FailWithGuidancedefault + a CI check that scans Kotlin/TS for new HTTP-client construction. - Replay non-determinism. A handler that reads system time, RNG, or hostname without going through the framework will not be deterministic. Mitigation: a BlockHound-style runtime check (in CI only) that catches
System.currentTimeMillis,UUID.randomUUID, etc. outside the framework's clock / id facilities. - Per-branch DB fork cost. If we go the Supabase-branching route, every PR creates a DB fork. Mitigation: only branch on demand (when a dev explicitly runs
shadow branch create), prune after 7 days of inactivity.
Open questions
- Does the shadow stack live in a separate Cloudflare account, or same-account-different-zone? Separate is safer (isolation), same is cheaper (no inter-account billing).
- Memgraph CE on GCP has a per-instance cost; do we run one shadow Memgraph 24/7 or spin one up on demand for replay sessions? On-demand is cheaper but adds 2-min startup latency to replay.
- Capture corpus retention beyond 7 days for pinned + 30 days for stack-trace fingerprints — is that GDPR-safe given the scrubber? Likely yes (no real PII), but legal sign-off needed before public launch.
- Do we expose shadow access to external contributors (if Reaktor is open-sourced)? Probably no — captures still encode tenant-specific shapes that could be reverse-engineered.
- Integration with the existing
bestbudsArchiver: should the archiver's nightly Postgres dump become the cold-restore source for shadow, or is that a separate path? - How does this compose with
reaktor-notifications' outbox pattern when that lands? The outbox row IS already a perfect capture; we may be able to bootstrap from outbox content rather than installing a separate visitor.
25. References
Spec sections this design extends
- §28 Reaktor Shadow: Testing & Quality Infrastructure — the "capture as a visitor" foundation this doc operationalises into a permanent dev environment.
- §21.8 Transactional outbox — the pattern that lets us safely capture publish intentions without firing them in shadow.
- §21.9 Idempotent consumers — replay correctness depends on this; the bus design assumes consumers handle duplicates.
- §29 Performance: Measurement, Profiling, and Optimization — load testing & OTel pipeline this doc plugs into.
- §29.4 Automated performance testing — Keploy / k6 / Maestro integration assumed.
Peer designs in this repo
- performance-observability — Grafana LGTM pipeline shadow capture/replay flows into; Keploy + k6 driver runners.
- reaktor-notifications-design — uses outbox; FCM/APNs stubs from §13 reuse the same null-sink pattern.
- deep-link-integration — same graph-visitor pattern (
RouteIndexVisitor,BackStackPlannerVisitor); a deep-link tap is a typical capture target. - auth-analysis — service principals + tenant API keys; shadow runs as a dedicated service principal.
- security-plan — PII scrubbing rules; shadow extends them with the deterministic-faker key.
- graph-kernel-analysis — visitor + facets foundation.
- reaktor-graph-visitors-design — base classes
ShadowCaptureVisitorextends.
Files inventoried during the design
- bestbuds/wrangler.json (root) — Hyperdrive bindings:
supabase,supabasedirect,cockroach,cockroachdirect; R2:IMAGES. - bestbuds/targets/{analytics,chat,sticker,config,bot,bestbudsArchiver,media,notification,social,messaging,prototype}{Server,Worker}/wrangler.json — 10 production workers + 1 archiver + 1 prototype.
- bestbuds/targets/reaktorServer/src/main/kotlin/ai/bestbuds/server/ServerApplication.kt — Spring Boot REST + GraphQL surface,
memgraphDriver()on lines 18, 294. - bestbuds/targets/reaktorServer/.../MannaKnowledgeGraphGraphQl.kt — Cypher queries; node labels (
Domain,Resource,Chapter,Tenant,Task) and edges (ChapterEdge,TaskDependencyEdge,TaskResourceEdge,GoalTaskEdge). - bestbuds/modules/app/src/jvmMain/kotlin/ai/bestbuds/app/db/schema.sql — Postgres schema (
user_profiles,friends,groups,group_users,embeddings,heimdall.personal_access_token). - bestbuds/n8n/workflows/ — 10 deploy / build / sync workflows; none for shadow refresh yet.
External tools cited
- Keploy — capture/replay regression testing
- GoReplay — TCP traffic mirroring
- mirrord — local-process-in-remote-pod
- Replay.io — deterministic record/replay
- Supabase Branching
- Neon Branching
- Memgraph DUMP DATABASE
- Supabase logical replication
- Cloudflare R2 event notifications
- k6 load testing
Doc lineage. Authored 2026-05-28. Grounded in Reaktor Spec §28 (the visitor-based capture primitive) and a fresh inventory of BestBuds' actual production data surfaces (10 Cloudflare workers, 4 Hyperdrive bindings, 2 R2 buckets, 1 JVM service, 1 Memgraph on GCP CE, plus Workers AI + Google Sheets + planned FCM/APNs). Cross-linked to the deep-link, performance, and notifications design briefs published this week.
Related docs
- Shadow Dev - Use before building production-like test and replay environments.
- Codex Dev Env - Use only when comparing prior shadow environment proposals.
- Perf and Observability - Use before optimizing or adding instrumentation.
- Reaktor Secrets - Use before moving, rotating, distributing, caching, or materializing secrets.
- DevOps - Use before changing the build/deploy/ops pipeline, the Kotlin platform model, deployment partitioning, gated deploy operations, or the reaktorDesktop Deploy pane; pairs with reaktor-cloud-pane (operator view) and secrets-management.
- FlexBuffer Plan - Use for history only; prefer the FlexBuffer Binary Serialization document.