diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b680e23..2b51f0e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,10 +30,21 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.22'
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
- name: Unit tests
run: |
go test ./...
go test -race ./...
+ - name: Seed inventory
+ env:
+ MYSQL_HOST: 127.0.0.1:3306
+ MYSQL_USER: root
+ MYSQL_PASSWORD: root
+ MYSQL_DATABASE: ticketdb
+ REDIS_HOST: 127.0.0.1:6379
+ run: go run . seed
- name: Integration tests
env:
MYSQL_HOST: 127.0.0.1:3306
@@ -42,3 +53,8 @@ jobs:
MYSQL_DATABASE: ticketdb
REDIS_HOST: 127.0.0.1:6379
run: go test -tags=integration ./integration/...
+ - name: Verification lab UI build
+ run: |
+ cd verification-lab/web
+ npm ci
+ npm run build
diff --git a/Makefile b/Makefile
index 8036475..45e23f3 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: test test-race test-integration compose-config seed
+.PHONY: test test-race test-integration compose-config seed run-api run-worker dev
test:
go test ./...
@@ -14,3 +14,13 @@ compose-config:
seed:
go run . seed
+
+run-api:
+ go run .
+
+run-worker:
+ go run . worker
+
+# API and persist worker are separate processes; serve alone does not drain the bookings stream.
+dev:
+ trap 'kill 0' INT TERM; go run . worker & go run . & wait
diff --git a/README.md b/README.md
index 672277a..f1a9b19 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,20 @@ When many users hit `/book` at once, classic row-level booking patterns contend

+<<<<<<< HEAD
+Flow (current implementation):
+1. Client sends `POST /book` with `X-User-Id`.
+2. Redis Lua allocation is used for atomic allocation + idempotent replay behavior.
+3. Booking events are written to a Redis Stream.
+4. A separate **worker** process (`go run . worker`) consumes the stream and persists sold ticket ownership to MySQL.
+
+**Important:** the API server (`go run .`) does **not** run the persist consumer. A serve-only deployment accepts bookings into Redis but does not write `sold` rows to MySQL until a worker is running.
+=======
1. Client `POST /book` with `X-User-Id` (optional `X-API-Key` if configured)
2. Redis Lua: `HGET` prior booking → or `LPOP` + `HSET` + `XADD`
3. Stream consumer writes sold state to MySQL
4. Replay path re-enqueues persist if Redis says booked but MySQL lags
+>>>>>>> origin/main
## Tech stack
@@ -26,7 +36,18 @@ When many users hit `/book` at once, classic row-level booking patterns contend
| Load | k6 (`test.js`), legacy wrk Lua (`test.lua`) |
| UI | React + Vite dashboards under `frontend/` (Dashboard, Login, Queue Simulation) |
+<<<<<<< HEAD
+## API surface
+| Method | Endpoint | Purpose |
+|---|---|---|
+| `POST` | `/book` | Attempt booking for one user (`X-User-Id` required) |
+| `GET` | `/tickets/count` | Remaining tickets in Redis queue |
+| `GET` | `/metrics` | Request/success/failure counters |
+| `GET` | `/healthz` | Liveness probe |
+| `GET` | `/readyz` | Readiness probe (Redis + MySQL; consumer check is worker-only in a split deployment) |
+=======
## Key engineering decisions
+>>>>>>> origin/main
- **Redis owns hot allocation**; MySQL owns durable sold state (eventual consistency by design)
- **Idempotent user booking**: same `user_id` retries return the same `ticket_id` with HTTP 200
@@ -70,6 +91,49 @@ docker compose up
# API: http://localhost:8080
```
+<<<<<<< HEAD
+This starts:
+- `mysql` (MySQL 8)
+- `redis` (Redis 7)
+- one-shot `seed` service (`go run . seed`)
+- `server` service (`go run .`) — HTTP API only
+- `worker` service (`go run . worker`) — stream consumer (required for MySQL persistence)
+
+Default API endpoint: `http://localhost:8080`
+
+## Local development
+If you already run MySQL and Redis locally:
+
+```bash
+go run . seed # run once on empty DB
+go run . worker # persist consumer (run alongside API)
+go run . # start API server
+```
+
+Or run API + worker together: `make dev`
+
+Read-only drift check (no Redis/MySQL mutations; exit `0` clean, `1` drift, `2` error):
+
+```bash
+go run . reconcile
+```
+
+## Configuration
+Environment variables used by runtime and compose setup:
+
+| Variable | Purpose |
+|---|---|
+| `HTTP_PORT` | HTTP server port (default `8080`) |
+| `MYSQL_HOST` | MySQL host:port |
+| `MYSQL_USER` | MySQL username |
+| `MYSQL_PASSWORD` | MySQL password |
+| `MYSQL_DATABASE` | MySQL database name |
+| `REDIS_HOST` | Redis host:port |
+| `TICKET_API_KEY` | Optional API key for protected booking access |
+| `TICKET_CORS_ORIGIN` | Allowed CORS origin (default `*`) |
+
+## Testing and verification
+=======
Local (MySQL + Redis already running):
```bash
@@ -79,6 +143,7 @@ go run . reconcile # optional
```
## Testing
+>>>>>>> origin/main
```bash
make test
diff --git a/commands.go b/commands.go
new file mode 100644
index 0000000..9e6fecd
--- /dev/null
+++ b/commands.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "ticketengine/internal/reconcile"
+)
+
+func runSeedCommand() {
+ log.Println("Running inventory seed (explicit command)...")
+ initMySQL()
+ initRedis()
+ if err := SeedInventory(); err != nil {
+ log.Fatalf("seed failed: %v", err)
+ }
+ log.Println("Seed complete.")
+}
+
+func runReconcileCommand() {
+ initMySQL()
+ initRedis()
+ rep, err := RunReconciliation(context.Background())
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "RECONCILE ERROR: %v\n", err)
+ os.Exit(reconcile.ExitOperational)
+ }
+ fmt.Print(rep.Format())
+ if rep.HasDrift() {
+ os.Exit(reconcile.ExitDrift)
+ }
+}
diff --git a/commands_test.go b/commands_test.go
new file mode 100644
index 0000000..b3a17d8
--- /dev/null
+++ b/commands_test.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/go-redis/redis/v8"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "ticketengine/internal/persist"
+)
+
+func setupCommandTestDeps(t *testing.T) {
+ testDB, err := gorm.Open(sqlite.Open("file:cmdtest?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := testDB.AutoMigrate(&Ticket{}); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ db = testDB
+
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ t.Cleanup(mr.Close)
+ redisClient = redis.NewClient(&redis.Options{Addr: mr.Addr()})
+}
+
+func TestServeDoesNotStartConsumer(t *testing.T) {
+ setupCommandTestDeps(t)
+
+ var started atomic.Bool
+ old := runPersistConsumer
+ runPersistConsumer = func(ctx context.Context, c *persist.Consumer) {
+ started.Store(true)
+ }
+ t.Cleanup(func() { runPersistConsumer = old })
+
+ bootstrapAPIServer()
+
+ if started.Load() {
+ t.Fatal("serve bootstrap must not start persist consumer")
+ }
+}
+
+func TestWorkerCommandStartsConsumer(t *testing.T) {
+ setupCommandTestDeps(t)
+
+ started := make(chan struct{}, 1)
+ old := runPersistConsumer
+ runPersistConsumer = func(ctx context.Context, c *persist.Consumer) {
+ started <- struct{}{}
+ <-ctx.Done()
+ }
+ t.Cleanup(func() { runPersistConsumer = old })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ startPersistWorkerLoop(ctx)
+ }()
+ t.Cleanup(cancel)
+
+ select {
+ case <-started:
+ case <-time.After(3 * time.Second):
+ t.Fatal("worker did not start persist consumer")
+ }
+ cancel()
+ wg.Wait()
+}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index be869d8..48a4a3e 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -84,6 +84,29 @@ services:
condition: service_healthy
command: ["sh", "-c", "go mod download && go run ."]
+ worker:
+ image: golang:1.22-bookworm
+ restart: unless-stopped
+ working_dir: /app
+ volumes:
+ - .:/app
+ - go_mod_cache:/go/pkg/mod
+ environment:
+ GOPROXY: https://proxy.golang.org,direct
+ MYSQL_HOST: mysql:3306
+ MYSQL_USER: ticket
+ MYSQL_PASSWORD: ticket
+ MYSQL_DATABASE: ticketdb
+ REDIS_HOST: redis:6379
+ depends_on:
+ seed:
+ condition: service_completed_successfully
+ mysql:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ command: ["sh", "-c", "go mod download && go run . worker"]
+
volumes:
mysql_data:
redis_data:
diff --git a/docs/V2_PROGRESS.md b/docs/V2_PROGRESS.md
index 296ebca..50c33b2 100644
--- a/docs/V2_PROGRESS.md
+++ b/docs/V2_PROGRESS.md
@@ -121,15 +121,57 @@ No service uses `build:`; images are pulled only (documented upstream tags).
**Objective:** Redis Stream + consumer group; remove GoChannel for bookings.
-**Status:** `not_started`
+### Tasks
+
+| Task | Status |
+|------|--------|
+| Redis Stream publish on allocate | complete |
+| Consumer group + MySQL `sold` update | complete |
+| Worker/API split (V3 phase 1) | complete |
+| PEL reclaim (`XAUTOCLAIM`) + shutdown drain (V3 phase 2) | complete |
+| DLQ after delivery cap (V3 phase 3) | complete |
+
+### Acceptance criteria
+
+- [x] Bookings persist via Redis Stream consumer (not GoChannel).
+- [ ] Crash mid-handler: pending entry reclaimed; ticket becomes `sold` once (`TestPersistRecovery` — requires MySQL+Redis; SKIP in agent VM 2026-10-05).
+- [x] Duplicate delivery is idempotent (same user/ticket `UPDATE` guard).
+- [x] DLQ for poison messages after `MaxDeliveryAttempts` (`TestPersistDLQ`).
+
+### Verification log
+
+| Command | Result | Date |
+|---------|--------|------|
+| `go test -count=1 -race ./internal/persist/` | PASS | 2026-10-05 |
+| `go test -tags=integration -count=1 -race -run TestPersistRecovery ./integration/` | SKIP (no Redis on agent VM); run in CI/Compose | 2026-10-05 |
+| `go test -count=1 -race ./...` | PASS | 2026-10-05 |
+| `go vet ./...` | PASS | 2026-10-05 |
+
+### Status
+
+`in_progress` — streams, reclaim, and recovery test landed; DLQ (V3 phase 3) remains.
---
## Phase 4 — Inventory Reconciliation
-**Objective:** Rebuild rules; no destructive reset of buyer state.
+**Objective:** Read-only operator reconcile (`go run . reconcile`); no destructive reset of buyer state.
-**Status:** `not_started`
+**Status:** `in_progress` (V3 Slice 6 — read-only drift report)
+
+### Reconcile command (Slice 6)
+
+- **Read-only:** no Redis/MySQL/stream/PEL mutations (queue rebuild remains `seed` / `TICKET_ENGINE_REDIS_SYNC` on serve only).
+- **Checks:** Redis `tickets` queue vs MySQL `available` (adjusted for in-flight stream/hash allocations), `hash:user` vs MySQL ownership, duplicate buyers, orphan allocations, stream pending (EXPECTED / STALE / SUSPICIOUS / UNKNOWN), DLQ length.
+- **Exit codes:** `0` clean, `1` drift, `2` operational error (DB/Redis/query failure).
+- **Output:** `RECONCILE RESULT: CLEAN` or `DRIFT DETECTED` with sections.
+
+### Verification log (Slice 6)
+
+| Command | Result | Date |
+|---------|--------|------|
+| `go test -count=1 -run TestReconcile ./internal/reconcile/...` | PASS | 2026-10-05 |
+| `go test -tags=integration -count=1 -run TestReconcileDrift ./integration/...` | SKIP if no Redis/MySQL | 2026-10-05 |
---
@@ -170,3 +212,25 @@ No service uses `build:`; images are pulled only (documented upstream tags).
**Objective:** End-to-end reviewer sign-off.
**Status:** `not_started`
+
+---
+
+## V3 — Worker/API split (Composer phase 1)
+
+**Status:** `complete` — `go run . worker` is the only persist consumer entrypoint; Compose/Makefile/CI start worker alongside serve.
+
+## V3 — PEL reclaim and shutdown (Composer phase 2)
+
+**Status:** `complete` — periodic `XAUTOCLAIM`, pending `0` replay, graceful drain with pending log on SIGTERM; `TestPersistRecovery` + `internal/persist` reclaim tests.
+
+## V3 — DLQ (Composer phase 3)
+
+**Status:** `complete` — `MaxDeliveryAttempts` then `XADD` `bookings.dlq` + `XACK`; `TestPersistDLQ` + unit DLQ test.
+
+## V3 — Worker scale-out (Composer phase 4)
+
+**Status:** `complete` — `TICKET_PERSIST_CONSUMER_NAME` / `HOSTNAME` consumer names, bounded parallel handlers, worker-sized MySQL pool (`TICKET_ENGINE_ROLE=worker`), `/readyz` fails when stream pending exceeds `MaxReadyPendingCount`; `TestReadyzStreamPending`, `TestTwoWorkersNoDoubleSell`.
+
+## V3 — Redis-only replay (Composer phase 5)
+
+**Status:** `complete` — idempotent `/book` replay uses `hash:user` in `ensurePersisted` (no MySQL read); `TestBookReplaySkipsMySQL`.
diff --git a/docs/V3_SECURITY_PROOF.md b/docs/V3_SECURITY_PROOF.md
new file mode 100644
index 0000000..66ad3c3
--- /dev/null
+++ b/docs/V3_SECURITY_PROOF.md
@@ -0,0 +1,187 @@
+# V3 Security proof (Slice 7)
+
+This document records **reproducible evidence** for security claims. A claim is **proven** only when it has an automated test (or CI step), an executed result, and a reference to that CI run.
+
+**Status:** Slice 7 not complete — sections below are **planned** until implementation and CI runs land.
+
+## How to use
+
+For each claim:
+
+1. Implement behavior (see `internal/v3-slice7-claims-matrix.md` in the project agent store, or the Slice 7 PR description).
+2. Add or extend automated tests.
+3. Run locally, then merge and capture the GitHub Actions **workflow run URL** for `go test ./...` and integration (if applicable).
+4. Fill **Actual result** and **CI evidence** — do not invent run IDs.
+
+## Claims index
+
+| ID | Claim | Test package / command | Evidence |
+|----|-------|------------------------|----------|
+| S7-API-1 | API key required when configured | `go test -run TestAPIKey -count=1 ./...` | Local PASS (claim-tests branch); CI pending |
+| S7-CORS-1 | CORS allows only configured origin | `go test -run TestCORS -count=1 ./...` | *Pending* |
+| S7-REDIS-1 | Redis AUTH when configured | `go test -tags=integration -run TestRedisAuth ./integration/...` | *Pending* |
+| S7-LOG-1 | Logs omit secrets and raw user ids | `go test -run TestLogRedaction -count=1 ./...` | *Pending* |
+| S7-SEC-1 | No hardcoded production secrets | CI GitGuardian + `go test -run TestSecrets -count=1 ./...` | GitGuardian: see PR checks; custom test *Pending* |
+| S7-PROD-1 | Production profile fails without required config | `go test -run TestProductionConfig -count=1 ./...` | *Pending* |
+| S7-RATE-1 | Rate limit on `/book` (if in scope) | `go test -run TestRateLimit -count=1 ./...` | *Pending* |
+
+---
+
+## S7-API-1 — API authentication
+
+**Claim:** When `TICKET_API_KEY` is set, unauthenticated or invalid clients cannot complete protected booking.
+
+**Threat/condition:** Anonymous or wrong-key callers must not reach allocation.
+
+**Test procedure:**
+
+- `go test -count=1 -run 'TestAPIKey' ./...`
+- Cases: no `X-API-Key`; wrong key; correct key.
+
+**Expected result:**
+
+- Missing key → HTTP 401, no booking side effects.
+- Invalid key → HTTP 401.
+- Valid key → HTTP 200 (with valid `X-User-Id` and inventory).
+
+**Actual result (local, 2026-10-05):** `go test -count=1 -run 'TestAPIKey' ./...` — PASS (`ticketengine`, `internal/security`). Missing and invalid `X-API-Key` return HTTP 401 with body `Unauthorized`; valid key allows booking; env unset keeps `/book` open.
+
+**CI evidence:** Pending CI verification
+
+**Known limitations:** When `TICKET_API_KEY` is unset, `/book` remains open (dev default). API key does not authenticate `X-User-Id` (application identifier only).
+
+---
+
+## S7-CORS-1 — Cross-origin access
+
+**Claim:** With production-style configuration, only explicitly allowed origins receive permissive CORS headers.
+
+**Threat/condition:** Browser clients on hostile origins must not treat the API as credentialed same-site.
+
+**Test procedure:**
+
+- `go test -count=1 -run 'TestCORS' ./...`
+- Allowed origin, disallowed origin, OPTIONS preflight.
+
+**Expected result:**
+
+- Allowed → `Access-Control-Allow-Origin` matches config; POST succeeds.
+- Disallowed → origin header not reflected (or request blocked per policy).
+- Preflight → 200 with expected `Access-Control-Allow-Methods` / headers.
+
+**Actual result:** *Not recorded yet.*
+
+**CI evidence:** *Pending.*
+
+**Known limitations:** Default `TICKET_CORS_ORIGIN=*` is dev-only; document production value.
+
+---
+
+## S7-REDIS-1 — Redis authentication
+
+**Claim:** Application supports password-protected Redis and fails safely when credentials are wrong or required but missing (production profile).
+
+**Threat/condition:** Unauthenticated Redis on a shared network.
+
+**Test procedure:**
+
+- Unit: miniredis with `AUTH` or integration against Redis with `--requirepass`.
+- `go test -tags=integration -count=1 -run 'TestRedisAuth' ./integration/...`
+
+**Expected result:**
+
+- Valid password → ping OK, booking path works.
+- Invalid password → connection error at startup or first use; no silent fallback.
+- Production profile without password env → non-zero exit on startup.
+
+**Actual result:** *Not recorded yet.*
+
+**CI evidence:** *Pending.*
+
+**Known limitations:** Current code uses `REDIS_HOST` only.
+
+---
+
+## S7-LOG-1 — Logging redaction
+
+**Claim:** Production logs do not contain API keys, authorization headers, database/redis passwords, or raw user identifiers (per Slice 7 policy).
+
+**Threat/condition:** Credential or PII leakage via log aggregation.
+
+**Test procedure:**
+
+- `go test -count=1 -run 'TestLogRedaction' ./...`
+- Capture `log` output (or injected logger) while exercising `/book` with known key and user id.
+
+**Expected result:** Captured logs contain none of the injected secret substrings.
+
+**Actual result:** *Not recorded yet.*
+
+**CI evidence:** *Pending.*
+
+**Known limitations:** Third-party driver logs excluded unless documented.
+
+---
+
+## S7-SEC-1 — Secrets not in source
+
+**Claim:** Production secrets are supplied via configuration, not committed in application source.
+
+**Test procedure:**
+
+- GitGuardian on PR (existing CI).
+- Optional: `go test -count=1 -run 'TestNoHardcodedSecrets' ./...` scanning allowlisted dev defaults.
+
+**Expected result:** No new secret patterns in `*.go` / config templates.
+
+**Actual result:** *Partial — GitGuardian only; dedicated Go test pending.*
+
+**CI evidence:** GitGuardian check on Slice 7 PR (*link when available*).
+
+**Known limitations:** README documents dev passwords for Compose.
+
+---
+
+## S7-PROD-1 — Production configuration gate
+
+**Claim:** A documented production mode refuses to start if required security env vars are missing.
+
+**Test procedure:**
+
+- `go test -count=1 -run 'TestProductionConfig' ./...`
+
+**Expected result:** `TICKET_ENV=production` (or agreed name) without required vars → fatal error before serving.
+
+**Actual result:** *Not recorded yet.*
+
+**CI evidence:** *Pending.*
+
+---
+
+## S7-RATE-1 — Rate limiting (if in Slice 7 scope)
+
+**Claim:** Sustained abuse of `/book` is throttled.
+
+**Test procedure:** *Define when rate limit design is fixed.*
+
+**Expected result:** *TBD.*
+
+**Actual result:** *Not recorded yet.*
+
+**CI evidence:** *Pending.*
+
+---
+
+## Final acceptance (Slice 7)
+
+```
+IMPLEMENTATION + AUTOMATED TEST + REPRODUCIBLE RESULT + CI EVIDENCE = PROVEN
+```
+
+Checklist:
+
+- [ ] Every row in the claims matrix has **Actual result** filled
+- [ ] Every row has a **CI workflow run** reference (not local-only)
+- [ ] `go test ./...`, `go test -race ./...`, `go vet ./...` PASS in that run
+- [ ] `go test -tags=integration ./integration/...` PASS when security tests need services
+- [ ] Booking invariants unchanged (unit + integration booking/persist tests still PASS)
diff --git a/health.go b/health.go
index eb69fea..55a5f36 100644
--- a/health.go
+++ b/health.go
@@ -26,8 +26,19 @@ func readyzHandler(consumer *persist.Consumer) http.HandlerFunc {
http.Error(w, "mysql unavailable", http.StatusServiceUnavailable)
return
}
+ pending, err := persist.GroupPendingCountRDB(ctx, redisClient)
+ if err != nil {
+ http.Error(w, "stream pending check failed", http.StatusServiceUnavailable)
+ return
+ }
+ if pending > persist.MaxReadyPendingCount {
+ http.Error(w, "stream pending too high", http.StatusServiceUnavailable)
+ return
+ }
if consumer == nil {
- http.Error(w, "consumer unavailable", http.StatusServiceUnavailable)
+ // Serve mode: stream backlog check above is sufficient for API readiness.
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("ready"))
return
}
w.WriteHeader(http.StatusOK)
diff --git a/health_test.go b/health_test.go
new file mode 100644
index 0000000..3e64976
--- /dev/null
+++ b/health_test.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "ticketengine/internal/persist"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/go-redis/redis/v8"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+func TestReadyzStreamPending(t *testing.T) {
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ oldRedis := redisClient
+ oldDB := db
+ oldMax := persist.MaxReadyPendingCount
+ t.Cleanup(func() {
+ redisClient = oldRedis
+ db = oldDB
+ persist.MaxReadyPendingCount = oldMax
+ })
+
+ gdb, err := gorm.Open(sqlite.Open("file:readyz?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ db = gdb
+
+ redisClient = redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+ if err := redisClient.XGroupCreateMkStream(ctx, persist.StreamKey, persist.ConsumerGroup, "0").Err(); err != nil {
+ t.Fatalf("group: %v", err)
+ }
+ if err := redisClient.XAdd(ctx, &redis.XAddArgs{
+ Stream: persist.StreamKey,
+ Values: map[string]interface{}{"ticket_id": "1", "user_id": "1"},
+ }).Err(); err != nil {
+ t.Fatalf("xadd: %v", err)
+ }
+ if _, err := redisClient.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: persist.ConsumerGroup, Consumer: "probe", Streams: []string{persist.StreamKey, ">"}, Count: 1,
+ }).Result(); err != nil {
+ t.Fatalf("readgroup: %v", err)
+ }
+
+ persist.MaxReadyPendingCount = 0
+ req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
+ rec := httptest.NewRecorder()
+ readyzHandler(nil)(rec, req)
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("expected 503 when pending > max, got %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ persist.MaxReadyPendingCount = 10
+ rec2 := httptest.NewRecorder()
+ readyzHandler(nil)(rec2, req)
+ if rec2.Code != http.StatusOK {
+ t.Fatalf("expected 200 when pending within limit, got %d body=%s", rec2.Code, rec2.Body.String())
+ }
+}
diff --git a/init.go b/init.go
index 6cd1edf..3a3fe76 100644
--- a/init.go
+++ b/init.go
@@ -20,14 +20,14 @@ const (
)
var (
- db *gorm.DB
- redisClient *redis.Client
- mysqlHost = getEnv("MYSQL_HOST", "localhost:3306")
- mysqlUser = getEnv("MYSQL_USER", "root")
+ db *gorm.DB
+ redisClient *redis.Client
+ mysqlHost = getEnv("MYSQL_HOST", "localhost:3306")
+ mysqlUser = getEnv("MYSQL_USER", "root")
mysqlPassword = getEnv("MYSQL_PASSWORD", "root")
mysqlDatabase = getEnv("MYSQL_DATABASE", "ticketdb")
- redisHost = getEnv("REDIS_HOST", "localhost:6379")
- httpPort = getEnv("HTTP_PORT", "8080")
+ redisHost = getEnv("REDIS_HOST", "localhost:6379")
+ httpPort = getEnv("HTTP_PORT", "8080")
totalRequests int64
bookingSuccess int64
@@ -42,6 +42,9 @@ func getEnv(key, fallback string) string {
}
func initMySQL() {
+ if db != nil {
+ return
+ }
var err error
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", mysqlUser, mysqlPassword, mysqlHost, mysqlDatabase)
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
@@ -55,9 +58,10 @@ func initMySQL() {
if err != nil {
log.Fatalf("failed to get MySQL db object: %v", err)
}
- sqlDB.SetMaxOpenConns(200)
- sqlDB.SetMaxIdleConns(100)
- log.Println("DB connected (pool: 200 open, 100 idle)")
+ maxOpen, maxIdle := mysqlPoolLimits()
+ sqlDB.SetMaxOpenConns(maxOpen)
+ sqlDB.SetMaxIdleConns(maxIdle)
+ log.Printf("DB connected (pool: %d open, %d idle)", maxOpen, maxIdle)
applyMigrations()
if err := db.AutoMigrate(&Ticket{}); err != nil {
@@ -66,6 +70,9 @@ func initMySQL() {
}
func initRedis() {
+ if redisClient != nil {
+ return
+ }
redisClient = redis.NewClient(&redis.Options{
Addr: redisHost,
DB: 0,
diff --git a/integration/helpers_test.go b/integration/helpers_test.go
index f87f066..a1be8e0 100644
--- a/integration/helpers_test.go
+++ b/integration/helpers_test.go
@@ -3,9 +3,14 @@
package integration_test
import (
+ "context"
"fmt"
"os"
+ "os/exec"
+ "path/filepath"
+ "strings"
"testing"
+ "time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
@@ -62,14 +67,62 @@ func openIntegrationMySQL(t *testing.T) *gorm.DB {
return gdb
}
+// persistWorkerEnv returns MYSQL/REDIS env for a worker subprocess (same defaults as other integration tests).
+func persistWorkerEnv() []string {
+ env := append([]string{}, os.Environ()...)
+ env = append(env,
+ "MYSQL_HOST="+envDefault("MYSQL_HOST", "127.0.0.1:3306"),
+ "MYSQL_USER="+envDefault("MYSQL_USER", "root"),
+ "MYSQL_PASSWORD="+envDefault("MYSQL_PASSWORD", "root"),
+ "MYSQL_DATABASE="+integrationDatabase(),
+ "REDIS_HOST="+envDefault("REDIS_HOST", "127.0.0.1:6379"),
+ )
+ return env
+}
+
+func moduleRootDir() string {
+ wd, err := os.Getwd()
+ if err != nil {
+ return "."
+ }
+ if strings.HasSuffix(wd, string(filepath.Separator)+"integration") {
+ return filepath.Dir(wd)
+ }
+ return wd
+}
+
+// startPersistWorkerProcess runs `go run . worker` for tests that need stream persistence.
+// Serve-only deployments do not drain the bookings stream; pair API with a worker process.
+func startPersistWorkerProcess(t *testing.T) func() {
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, "go", "run", ".", "worker")
+ cmd.Dir = moduleRootDir()
+ cmd.Env = persistWorkerEnv()
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start worker: %v", err)
+ }
+ waitDone := make(chan error, 1)
+ go func() { waitDone <- cmd.Wait() }()
+ select {
+ case err := <-waitDone:
+ t.Fatalf("worker exited early: %v", err)
+ case <-time.After(2 * time.Second):
+ }
+ return func() {
+ cancel()
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+ }
+}
+
func ensureTicketsTable(t *testing.T, gdb *gorm.DB) {
if err := gdb.Exec(`CREATE TABLE IF NOT EXISTS tickets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
- user_id BIGINT NOT NULL DEFAULT 0,
+ user_id BIGINT NULL,
created_at BIGINT NULL,
sold_at DATETIME(3) NULL,
state VARCHAR(16) NOT NULL DEFAULT 'available',
- INDEX idx_tickets_user_id (user_id),
+ UNIQUE INDEX idx_tickets_user_id (user_id),
INDEX idx_tickets_state (state)
)`).Error; err != nil {
t.Fatalf("ensure table: %v", err)
diff --git a/integration/inventory_integration_test.go b/integration/inventory_integration_test.go
index 158f6ad..0eba645 100644
--- a/integration/inventory_integration_test.go
+++ b/integration/inventory_integration_test.go
@@ -17,7 +17,7 @@ func TestPartialInventoryRefused(t *testing.T) {
if err := gdb.Exec("DELETE FROM tickets").Error; err != nil {
t.Fatalf("cleanup: %v", err)
}
- if err := gdb.Exec("INSERT INTO tickets (user_id) VALUES (0)").Error; err != nil {
+ if err := gdb.Exec("INSERT INTO tickets (user_id, state) VALUES (NULL, 'available')").Error; err != nil {
t.Fatalf("insert: %v", err)
}
var total int64
diff --git a/integration/persist_dlq_test.go b/integration/persist_dlq_test.go
new file mode 100644
index 0000000..8e38fab
--- /dev/null
+++ b/integration/persist_dlq_test.go
@@ -0,0 +1,86 @@
+//go:build integration
+
+package integration_test
+
+import (
+ "context"
+ "testing"
+
+ "ticketengine/internal/persist"
+
+ "github.com/go-redis/redis/v8"
+)
+
+func TestPersistDLQ(t *testing.T) {
+ rdb := redis.NewClient(&redis.Options{Addr: envDefault("REDIS_HOST", "127.0.0.1:6379")})
+ ctx := context.Background()
+ if err := rdb.Ping(ctx).Err(); err != nil {
+ t.Skipf("redis not available: %v", err)
+ }
+ gdb := openIntegrationMySQL(t)
+ ensureTicketsTable(t, gdb)
+
+ const ticketID = 88002
+ const ownerID = 44002
+ const otherID = 44003
+ if err := gdb.Exec(`DELETE FROM tickets WHERE id = ?`, ticketID).Error; err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if err := gdb.Exec(`INSERT INTO tickets (id, user_id, state) VALUES (?, ?, 'sold')`, ticketID, ownerID).Error; err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ oldMax := persist.MaxDeliveryAttempts
+ persist.MaxDeliveryAttempts = 1
+ t.Cleanup(func() { persist.MaxDeliveryAttempts = oldMax })
+
+ _ = rdb.Del(ctx, persist.StreamKey, persist.DLQStreamKey)
+ if err := persist.EnqueueReplay(ctx, rdb, ticketID, otherID); err != nil {
+ t.Fatalf("enqueue: %v", err)
+ }
+
+ const consumerName = "integration-dlq"
+ consumer := persist.NewConsumer(rdb, gdb, consumerName)
+ if err := consumer.EnsureGroup(ctx); err != nil {
+ t.Fatalf("ensure group: %v", err)
+ }
+
+ read, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: persist.ConsumerGroup,
+ Consumer: consumerName,
+ Streams: []string{persist.StreamKey, ">"},
+ Count: 1,
+ }).Result()
+ if err != nil {
+ t.Fatalf("xreadgroup: %v", err)
+ }
+ if len(read) == 0 || len(read[0].Messages) != 1 {
+ t.Fatalf("expected one stream message, got %v", read)
+ }
+ consumer.ProcessMessageSync(ctx, read[0].Messages[0])
+
+ dlqLen, err := rdb.XLen(ctx, persist.DLQStreamKey).Result()
+ if err != nil {
+ t.Fatalf("dlq len: %v", err)
+ }
+ if dlqLen != 1 {
+ t.Fatalf("expected 1 dlq message, got %d", dlqLen)
+ }
+
+ pending, err := consumer.GroupPendingCount(ctx)
+ if err != nil {
+ t.Fatalf("pending: %v", err)
+ }
+ if pending != 0 {
+ t.Fatalf("expected original stream pending 0 after dlq ack, got %d", pending)
+ }
+
+ var state string
+ var uid int64
+ if err := gdb.Raw(`SELECT state, user_id FROM tickets WHERE id = ?`, ticketID).Row().Scan(&state, &uid); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if state != "sold" || uid != ownerID {
+ t.Fatalf("ticket must stay sold to original owner, got state=%s user=%d", state, uid)
+ }
+}
diff --git a/integration/persist_recovery_test.go b/integration/persist_recovery_test.go
new file mode 100644
index 0000000..7e6db69
--- /dev/null
+++ b/integration/persist_recovery_test.go
@@ -0,0 +1,146 @@
+//go:build integration
+
+package integration_test
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "ticketengine/internal/persist"
+
+ "github.com/go-redis/redis/v8"
+)
+
+func TestPersistRecovery(t *testing.T) {
+ oldIdle := persist.ReclaimMinIdle
+ oldInterval := persist.ReclaimInterval
+ persist.ReclaimMinIdle = 50 * time.Millisecond
+ persist.ReclaimInterval = 100 * time.Millisecond
+ t.Cleanup(func() {
+ persist.ReclaimMinIdle = oldIdle
+ persist.ReclaimInterval = oldInterval
+ })
+
+ rdb := redis.NewClient(&redis.Options{Addr: envDefault("REDIS_HOST", "127.0.0.1:6379")})
+ ctx := context.Background()
+ if err := rdb.Ping(ctx).Err(); err != nil {
+ t.Skipf("redis not available: %v", err)
+ }
+ gdb := openIntegrationMySQL(t)
+ ensureTicketsTable(t, gdb)
+
+ const ticketID = 88001
+ const userID = 44001
+ if err := gdb.Exec(`DELETE FROM tickets WHERE id = ?`, ticketID).Error; err != nil {
+ t.Fatalf("delete ticket: %v", err)
+ }
+ if err := gdb.Exec(`INSERT INTO tickets (id, user_id, state) VALUES (?, NULL, 'available')`, ticketID).Error; err != nil {
+ t.Fatalf("insert ticket: %v", err)
+ }
+
+ _ = rdb.Del(ctx, persist.StreamKey)
+ if err := persist.EnqueueReplay(ctx, rdb, ticketID, userID); err != nil {
+ t.Fatalf("enqueue: %v", err)
+ }
+
+ pauseStarted := make(chan struct{})
+ var pauseOnce sync.Once
+ persist.MessagePause = func(pauseCtx context.Context, tid int) error {
+ if tid != ticketID {
+ return nil
+ }
+ pauseOnce.Do(func() { close(pauseStarted) })
+ select {
+ case <-pauseCtx.Done():
+ return pauseCtx.Err()
+ case <-time.After(30 * time.Second):
+ return errors.New("pause timeout")
+ }
+ }
+ t.Cleanup(func() { persist.MessagePause = nil })
+
+ runCtx, runCancel := context.WithCancel(ctx)
+ consumer := persist.NewConsumer(rdb, gdb, "integration-pause")
+ if err := consumer.EnsureGroup(ctx); err != nil {
+ t.Fatalf("ensure group: %v", err)
+ }
+
+ go consumer.Run(runCtx)
+
+ select {
+ case <-pauseStarted:
+ case <-time.After(10 * time.Second):
+ t.Fatal("consumer did not reach handle pause")
+ }
+
+ runCancel()
+ persist.MessagePause = nil
+ time.Sleep(300 * time.Millisecond)
+
+ recoverCtx, recoverCancel := context.WithTimeout(ctx, 15*time.Second)
+ defer recoverCancel()
+ recoverConsumer := persist.NewConsumer(rdb, gdb, "integration-recover")
+ go recoverConsumer.Run(recoverCtx)
+
+ var pending int64
+ var pendingErr error
+
+ deadline := time.Now().Add(12 * time.Second)
+ recovered := false
+ for time.Now().Before(deadline) {
+ sold, err := persist.IsSold(gdb, ticketID)
+ if err != nil {
+ t.Fatalf("is sold: %v", err)
+ }
+ if sold {
+ recovered = true
+ break
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ if !recovered {
+ t.Fatal("ticket not sold after recovery window")
+ }
+ pendingDeadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(pendingDeadline) {
+ pending, pendingErr = recoverConsumer.GroupPendingCount(ctx)
+ if pendingErr != nil {
+ t.Fatalf("pending: %v", pendingErr)
+ }
+ if pending == 0 {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ pending, pendingErr = recoverConsumer.GroupPendingCount(ctx)
+ if pendingErr != nil {
+ t.Fatalf("pending: %v", pendingErr)
+ }
+ if pending != 0 {
+ t.Fatalf("expected no group pending after recovery, got %d", pending)
+ }
+ recoverCancel()
+
+ var state string
+ var uid int64
+ if err := gdb.Raw(`SELECT state, user_id FROM tickets WHERE id = ?`, ticketID).Row().Scan(&state, &uid); err != nil {
+ t.Fatalf("scan ticket: %v", err)
+ }
+ if state != "sold" {
+ t.Fatalf("expected sold, got %s", state)
+ }
+ if uid != userID {
+ t.Fatalf("expected user_id %d, got %d", userID, uid)
+ }
+
+ var soldCount int64
+ if err := gdb.Raw(`SELECT COUNT(*) FROM tickets WHERE id = ? AND state = 'sold'`, ticketID).Scan(&soldCount).Error; err != nil {
+ t.Fatalf("count sold: %v", err)
+ }
+ if soldCount != 1 {
+ t.Fatalf("expected one sold row, got %d", soldCount)
+ }
+}
diff --git a/integration/reconcile_test.go b/integration/reconcile_test.go
new file mode 100644
index 0000000..54fc5c3
--- /dev/null
+++ b/integration/reconcile_test.go
@@ -0,0 +1,88 @@
+//go:build integration
+
+package integration_test
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "github.com/go-redis/redis/v8"
+
+ "ticketengine/internal/booking"
+ "ticketengine/internal/reconcile"
+)
+
+func TestReconcileDrift(t *testing.T) {
+ gdb := openIntegrationMySQL(t)
+ rdb := openIntegrationRedis(t)
+ ctx := context.Background()
+
+ if err := gdb.Exec("DELETE FROM tickets").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := gdb.Exec("INSERT INTO tickets (id, user_id, state) VALUES (1, NULL, 'available')").Error; err != nil {
+ t.Fatal(err)
+ }
+ _ = rdb.Del(ctx, booking.KeyQueue, booking.KeyUserBooking, "bookings.stream", "bookings.dlq")
+ _ = rdb.RPush(ctx, booking.KeyQueue, 1, 2)
+
+ rep, err := reconcile.Run(ctx, gdb, rdb)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if !rep.HasDrift() {
+ t.Fatalf("expected drift: %s", rep.Format())
+ }
+ if !strings.Contains(rep.Format(), "DRIFT DETECTED") {
+ t.Fatal(rep.Format())
+ }
+}
+
+func TestReconcileCLIReadOnly(t *testing.T) {
+ gdb := openIntegrationMySQL(t)
+ rdb := openIntegrationRedis(t)
+ ctx := context.Background()
+
+ if err := gdb.Exec("DELETE FROM tickets").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := gdb.Exec("INSERT INTO tickets (id, user_id, state) VALUES (10, NULL, 'available')").Error; err != nil {
+ t.Fatal(err)
+ }
+ _ = rdb.Del(ctx, booking.KeyQueue)
+ _ = rdb.RPush(ctx, booking.KeyQueue, 10)
+
+ beforeQ, _ := rdb.LLen(ctx, booking.KeyQueue).Result()
+ beforeH, _ := rdb.HLen(ctx, booking.KeyUserBooking).Result()
+
+ cmd := exec.Command("go", "run", ".", "reconcile")
+ cmd.Dir = moduleRootDir()
+ cmd.Env = persistWorkerEnv()
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ var exitErr *exec.ExitError
+ if !errors.As(err, &exitErr) || exitErr.ExitCode() == reconcile.ExitOperational {
+ t.Fatalf("reconcile command failed: %v\n%s", err, out)
+ }
+ }
+ if !strings.Contains(string(out), "RECONCILE RESULT:") {
+ t.Fatalf("missing reconcile header in output:\n%s", out)
+ }
+ afterQ, _ := rdb.LLen(ctx, booking.KeyQueue).Result()
+ afterH, _ := rdb.HLen(ctx, booking.KeyUserBooking).Result()
+ if beforeQ != afterQ || beforeH != afterH {
+ t.Fatalf("redis mutated: q %d->%d h %d->%d", beforeQ, afterQ, beforeH, afterH)
+ }
+}
+
+func openIntegrationRedis(t *testing.T) *redis.Client {
+ host := envDefault("REDIS_HOST", "127.0.0.1:6379")
+ rdb := redis.NewClient(&redis.Options{Addr: host})
+ if err := rdb.Ping(context.Background()).Err(); err != nil {
+ t.Skipf("redis not available: %v", err)
+ }
+ return rdb
+}
diff --git a/integration/restart_inventory_test.go b/integration/restart_inventory_test.go
index a40105d..970e927 100644
--- a/integration/restart_inventory_test.go
+++ b/integration/restart_inventory_test.go
@@ -20,7 +20,7 @@ func TestRestartDoesNotMintInventory(t *testing.T) {
const target = 100
for i := 0; i < target; i++ {
- if err := gdb.Exec("INSERT INTO tickets (user_id) VALUES (0)").Error; err != nil {
+ if err := gdb.Exec("INSERT INTO tickets (user_id, state) VALUES (NULL, 'available')").Error; err != nil {
t.Fatalf("insert: %v", err)
}
}
diff --git a/integration/two_workers_test.go b/integration/two_workers_test.go
new file mode 100644
index 0000000..7e115f0
--- /dev/null
+++ b/integration/two_workers_test.go
@@ -0,0 +1,87 @@
+//go:build integration
+
+package integration_test
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "ticketengine/internal/persist"
+
+ "github.com/go-redis/redis/v8"
+)
+
+func TestTwoWorkersNoDoubleSell(t *testing.T) {
+ rdb := redis.NewClient(&redis.Options{Addr: envDefault("REDIS_HOST", "127.0.0.1:6379")})
+ ctx := context.Background()
+ if err := rdb.Ping(ctx).Err(); err != nil {
+ t.Skipf("redis not available: %v", err)
+ }
+ gdb := openIntegrationMySQL(t)
+ ensureTicketsTable(t, gdb)
+
+ const ticketA = 88101
+ const ticketB = 88102
+ for _, id := range []int{ticketA, ticketB} {
+ if err := gdb.Exec(`DELETE FROM tickets WHERE id = ?`, id).Error; err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if err := gdb.Exec(`INSERT INTO tickets (id, user_id, state) VALUES (?, NULL, 'available')`, id).Error; err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+ }
+
+ _ = rdb.Del(ctx, persist.StreamKey)
+ if err := persist.EnqueueReplay(ctx, rdb, ticketA, 501); err != nil {
+ t.Fatalf("enqueue a: %v", err)
+ }
+ if err := persist.EnqueueReplay(ctx, rdb, ticketB, 502); err != nil {
+ t.Fatalf("enqueue b: %v", err)
+ }
+
+ runCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+
+ w1 := persist.NewConsumer(rdb, gdb, "worker-a")
+ w2 := persist.NewConsumer(rdb, gdb, "worker-b")
+ if err := w1.EnsureGroup(ctx); err != nil {
+ t.Fatalf("group: %v", err)
+ }
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() { defer wg.Done(); w1.Run(runCtx) }()
+ go func() { defer wg.Done(); w2.Run(runCtx) }()
+
+ deadline := time.Now().Add(12 * time.Second)
+ for time.Now().Before(deadline) {
+ soldA, _ := persist.IsSold(gdb, ticketA)
+ soldB, _ := persist.IsSold(gdb, ticketB)
+ if soldA && soldB {
+ break
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+
+ for _, pair := range []struct {
+ id int
+ user int64
+ }{
+ {ticketA, 501},
+ {ticketB, 502},
+ } {
+ var state string
+ var uid int64
+ if err := gdb.Raw(`SELECT state, user_id FROM tickets WHERE id = ?`, pair.id).Row().Scan(&state, &uid); err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if state != "sold" || uid != pair.user {
+ t.Fatalf("ticket %d expected sold to %d, got state=%s user=%d", pair.id, pair.user, state, uid)
+ }
+ }
+
+ cancel()
+ wg.Wait()
+}
diff --git a/integration/worker_harness_test.go b/integration/worker_harness_test.go
new file mode 100644
index 0000000..7a266d1
--- /dev/null
+++ b/integration/worker_harness_test.go
@@ -0,0 +1,33 @@
+//go:build integration
+
+package integration_test
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/go-redis/redis/v8"
+)
+
+func TestPersistWorkerProcessStarts(t *testing.T) {
+ if os.Getenv("CI") == "true" {
+ t.Skip("nested go run . worker in CI can contend on the module cache; run locally")
+ }
+ rdb := redis.NewClient(&redis.Options{Addr: envDefault("REDIS_HOST", "127.0.0.1:6379")})
+ ctx := context.Background()
+ if err := rdb.Ping(ctx).Err(); err != nil {
+ t.Skipf("redis not available: %v", err)
+ }
+ gdb := openIntegrationMySQL(t)
+ sqlDB, _ := gdb.DB()
+ defer sqlDB.Close()
+
+ stop := startPersistWorkerProcess(t)
+ defer stop()
+
+ // Worker should stay up while Redis/MySQL are reachable.
+ if err := rdb.Ping(ctx).Err(); err != nil {
+ t.Fatalf("redis ping after worker start: %v", err)
+ }
+}
diff --git a/internal/booking/allocate.go b/internal/booking/allocate.go
index 734f94b..0f6c656 100644
--- a/internal/booking/allocate.go
+++ b/internal/booking/allocate.go
@@ -86,6 +86,22 @@ func parseAllocateResult(raw interface{}) (Result, error) {
}
}
+// TicketForUser returns the ticket id stored in Redis for userID, if any.
+func TicketForUser(ctx context.Context, client *redis.Client, userID int) (int, bool, error) {
+ raw, err := client.HGet(ctx, KeyUserBooking, strconv.Itoa(userID)).Result()
+ if err == redis.Nil {
+ return 0, false, nil
+ }
+ if err != nil {
+ return 0, false, err
+ }
+ tid, err := strconv.Atoi(raw)
+ if err != nil {
+ return 0, false, fmt.Errorf("booking: bad hash ticket id %q: %w", raw, err)
+ }
+ return tid, true, nil
+}
+
func toInt(v interface{}) (int, error) {
switch n := v.(type) {
case int64:
diff --git a/internal/booking/allocate_test.go b/internal/booking/allocate_test.go
index 4fba2b5..029dcfb 100644
--- a/internal/booking/allocate_test.go
+++ b/internal/booking/allocate_test.go
@@ -137,6 +137,50 @@ func TestConcurrentUsersUniqueTickets(t *testing.T) {
}
}
+func TestAllocateOneUserOneTicket(t *testing.T) {
+ alloc, mr := newTestAllocator(t, "201", "202")
+ ctx := context.Background()
+
+ first, err := alloc.Allocate(ctx, 99)
+ if err != nil || first.Replay || first.TicketID != 201 {
+ t.Fatalf("first: %+v err=%v", first, err)
+ }
+ client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ remaining, err := client.LLen(ctx, KeyQueue).Result()
+ if err != nil {
+ t.Fatalf("llen: %v", err)
+ }
+ if remaining != 1 {
+ t.Fatalf("expected 1 ticket left after first allocate, got %d", remaining)
+ }
+
+ replay, err := alloc.Allocate(ctx, 99)
+ if err != nil || !replay.Replay || replay.TicketID != 201 {
+ t.Fatalf("replay: %+v err=%v", replay, err)
+ }
+ remaining, err = client.LLen(ctx, KeyQueue).Result()
+ if err != nil {
+ t.Fatalf("llen after replay: %v", err)
+ }
+ if remaining != 1 {
+ t.Fatalf("replay must not consume queue; expected 1 left, got %d", remaining)
+ }
+}
+
+func TestAllocateOneTicketOneWinner(t *testing.T) {
+ alloc, _ := newTestAllocator(t, "301")
+ ctx := context.Background()
+
+ winner, err := alloc.Allocate(ctx, 1)
+ if err != nil || winner.SoldOut || winner.TicketID != 301 {
+ t.Fatalf("winner: %+v err=%v", winner, err)
+ }
+ loser, err := alloc.Allocate(ctx, 2)
+ if err != nil || !loser.SoldOut {
+ t.Fatalf("second user: %+v err=%v", loser, err)
+ }
+}
+
func TestConcurrentIdempotentSameUser(t *testing.T) {
alloc, _ := newTestAllocator(t, "99")
ctx := context.Background()
diff --git a/internal/persist/consumer.go b/internal/persist/consumer.go
index 1e6a48e..b190960 100644
--- a/internal/persist/consumer.go
+++ b/internal/persist/consumer.go
@@ -6,6 +6,7 @@ import (
"log"
"strconv"
"strings"
+ "sync"
"time"
"github.com/go-redis/redis/v8"
@@ -19,18 +20,28 @@ type TicketRow struct {
State string
}
+// MessagePause, when set, runs at the start of handleMessage (integration tests).
+var MessagePause func(ctx context.Context, ticketID int) error
+
// Consumer reads the bookings stream and applies MySQL updates.
type Consumer struct {
- redis *redis.Client
- db *gorm.DB
- name string
+ redis *redis.Client
+ db *gorm.DB
+ name string
+ inflight sync.WaitGroup
+ sem chan struct{}
}
func NewConsumer(rdb *redis.Client, db *gorm.DB, consumerName string) *Consumer {
if consumerName == "" {
consumerName = ConsumerName
}
- return &Consumer{redis: rdb, db: db, name: consumerName}
+ parallel := MaxParallelHandlers
+ if parallel < 1 {
+ parallel = 1
+ }
+ sem := make(chan struct{}, parallel)
+ return &Consumer{redis: rdb, db: db, name: consumerName, sem: sem}
}
func (c *Consumer) EnsureGroup(ctx context.Context) error {
@@ -41,38 +52,237 @@ func (c *Consumer) EnsureGroup(ctx context.Context) error {
return nil
}
+// GroupPendingCount returns total pending messages for the consumer group.
+func (c *Consumer) GroupPendingCount(ctx context.Context) (int64, error) {
+ return GroupPendingCountRDB(ctx, c.redis)
+}
+
func (c *Consumer) Run(ctx context.Context) {
+ reclaimTicker := time.NewTicker(ReclaimInterval)
+ defer reclaimTicker.Stop()
+
+ for ctx.Err() == nil {
+ c.reclaimStale(ctx)
+ c.readAndProcess(ctx, "0", 0)
+
+ select {
+ case <-ctx.Done():
+ case <-reclaimTicker.C:
+ c.reclaimStale(ctx)
+ default:
+ }
+
+ if ctx.Err() != nil {
+ break
+ }
+ c.readAndProcess(ctx, ">", 2*time.Second)
+ }
+
+ pending, err := c.GroupPendingCount(context.Background())
+ if err != nil {
+ log.Printf("[persist] shutdown: pending count: %v", err)
+ } else {
+ log.Printf("[persist] shutdown drain started (group_pending=%d)", pending)
+ }
+ if !c.waitInflight(ShutdownDrainTimeout) {
+ log.Printf("[persist] shutdown drain timed out after %s", ShutdownDrainTimeout)
+ }
+}
+
+// WaitIdle blocks until in-flight handlers finish or timeout elapses.
+func (c *Consumer) WaitIdle(timeout time.Duration) bool {
+ return c.waitInflight(timeout)
+}
+
+func (c *Consumer) waitInflight(timeout time.Duration) bool {
+ done := make(chan struct{})
+ go func() {
+ c.inflight.Wait()
+ close(done)
+ }()
+ select {
+ case <-done:
+ return true
+ case <-time.After(timeout):
+ return false
+ }
+}
+
+func (c *Consumer) readAndProcess(ctx context.Context, streamID string, block time.Duration) {
+ streams, err := c.redis.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: ConsumerGroup,
+ Consumer: c.name,
+ Streams: []string{StreamKey, streamID},
+ Count: 10,
+ Block: block,
+ }).Result()
+ if err == redis.Nil {
+ return
+ }
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ log.Printf("[persist] xreadgroup (%s): %v", streamID, err)
+ time.Sleep(time.Second)
+ return
+ }
+ for _, s := range streams {
+ for _, msg := range s.Messages {
+ c.processMessage(ctx, msg)
+ }
+ }
+}
+
+func (c *Consumer) reclaimStale(ctx context.Context) {
+ if ctx.Err() != nil {
+ return
+ }
+ minIdleMs := ReclaimMinIdle.Milliseconds()
+ if minIdleMs < 1 {
+ minIdleMs = 1
+ }
+ start := "0-0"
for {
if ctx.Err() != nil {
return
}
- streams, err := c.redis.XReadGroup(ctx, &redis.XReadGroupArgs{
- Group: ConsumerGroup,
- Consumer: c.name,
- Streams: []string{StreamKey, ">"},
- Count: 10,
- Block: time.Second * 2,
- }).Result()
- if err == redis.Nil {
+ next, msgs, err := c.autoClaim(ctx, start, minIdleMs, 10)
+ if err != nil {
+ if ctx.Err() == nil {
+ log.Printf("[persist] xautoclaim: %v", err)
+ }
+ return
+ }
+ for _, msg := range msgs {
+ c.processMessage(ctx, msg)
+ }
+ if next == "0-0" || len(msgs) == 0 {
+ return
+ }
+ start = next
+ }
+}
+
+func (c *Consumer) autoClaim(ctx context.Context, start string, minIdleMs int64, count int) (string, []redis.XMessage, error) {
+ raw, err := c.redis.Do(ctx, "XAUTOCLAIM", StreamKey, ConsumerGroup, c.name, minIdleMs, start, "COUNT", count).Result()
+ if err != nil {
+ return start, nil, err
+ }
+ return parseXAutoClaimResult(raw)
+}
+
+func parseXAutoClaimResult(raw interface{}) (string, []redis.XMessage, error) {
+ parts, ok := raw.([]interface{})
+ if !ok || len(parts) < 2 {
+ return "0-0", nil, fmt.Errorf("unexpected xautoclaim reply type")
+ }
+ next, _ := parts[0].(string)
+ if next == "" {
+ next = "0-0"
+ }
+ msgs, err := parseXStreamMessages(parts[1])
+ return next, msgs, err
+}
+
+func parseXStreamMessages(raw interface{}) ([]redis.XMessage, error) {
+ entries, ok := raw.([]interface{})
+ if !ok {
+ return nil, fmt.Errorf("unexpected stream message list")
+ }
+ out := make([]redis.XMessage, 0, len(entries))
+ for _, entry := range entries {
+ pair, ok := entry.([]interface{})
+ if !ok || len(pair) < 2 {
continue
}
- if err != nil {
- log.Printf("[persist] xreadgroup: %v", err)
- time.Sleep(time.Second)
+ id, _ := pair[0].(string)
+ fieldsRaw, ok := pair[1].([]interface{})
+ if !ok {
continue
}
- for _, s := range streams {
- for _, msg := range s.Messages {
- if err := c.handleMessage(ctx, msg); err != nil {
- log.Printf("[persist] handle %s: %v", msg.ID, err)
- continue
- }
- if err := c.redis.XAck(ctx, StreamKey, ConsumerGroup, msg.ID).Err(); err != nil {
- log.Printf("[persist] xack %s: %v", msg.ID, err)
- }
+ values := map[string]interface{}{}
+ for i := 0; i+1 < len(fieldsRaw); i += 2 {
+ k, _ := fieldsRaw[i].(string)
+ values[k] = fieldsRaw[i+1]
+ }
+ out = append(out, redis.XMessage{ID: id, Values: values})
+ }
+ return out, nil
+}
+
+func (c *Consumer) processMessage(ctx context.Context, msg redis.XMessage) {
+ c.inflight.Add(1)
+ go func() {
+ defer c.inflight.Done()
+ c.sem <- struct{}{}
+ defer func() { <-c.sem }()
+ c.processMessageSync(ctx, msg)
+ }()
+}
+
+// ProcessMessageSync applies one stream message synchronously (integration tests).
+func (c *Consumer) ProcessMessageSync(ctx context.Context, msg redis.XMessage) {
+ c.processMessageSync(ctx, msg)
+}
+
+func (c *Consumer) processMessageSync(ctx context.Context, msg redis.XMessage) {
+ delivery, err := c.messageDeliveryCount(ctx, msg.ID)
+ if err != nil {
+ log.Printf("[persist] delivery count %s: %v", msg.ID, err)
+ }
+
+ if err := c.handleMessage(ctx, msg); err != nil {
+ if delivery >= MaxDeliveryAttempts {
+ if dlqErr := c.moveToDLQ(ctx, msg, err, delivery); dlqErr != nil {
+ log.Printf("[persist] dlq %s: %v", msg.ID, dlqErr)
+ return
}
+ log.Printf("[persist] moved %s to %s after %d deliveries: %v", msg.ID, DLQStreamKey, delivery, err)
+ return
}
+ log.Printf("[persist] handle %s (delivery %d): %v", msg.ID, delivery, err)
+ return
+ }
+ if err := c.redis.XAck(ctx, StreamKey, ConsumerGroup, msg.ID).Err(); err != nil {
+ log.Printf("[persist] xack %s: %v", msg.ID, err)
+ }
+}
+
+func (c *Consumer) messageDeliveryCount(ctx context.Context, msgID string) (int64, error) {
+ ext, err := c.redis.XPendingExt(ctx, &redis.XPendingExtArgs{
+ Stream: StreamKey,
+ Group: ConsumerGroup,
+ Start: msgID,
+ End: msgID,
+ Count: 1,
+ }).Result()
+ if err != nil {
+ return 0, err
+ }
+ if len(ext) == 0 {
+ return 1, nil
+ }
+ return ext[0].RetryCount, nil
+}
+
+func (c *Consumer) moveToDLQ(ctx context.Context, msg redis.XMessage, cause error, delivery int64) error {
+ values := map[string]interface{}{
+ "source_id": msg.ID,
+ "ticket_id": fmt.Sprint(msg.Values["ticket_id"]),
+ "user_id": fmt.Sprint(msg.Values["user_id"]),
+ "delivery_count": delivery,
+ "error": cause.Error(),
+ "consumer": c.name,
+ "moved_at_unix": time.Now().Unix(),
}
+ if err := c.redis.XAdd(ctx, &redis.XAddArgs{
+ Stream: DLQStreamKey,
+ Values: values,
+ }).Err(); err != nil {
+ return err
+ }
+ return c.redis.XAck(ctx, StreamKey, ConsumerGroup, msg.ID).Err()
}
func (c *Consumer) handleMessage(ctx context.Context, msg redis.XMessage) error {
@@ -86,6 +296,11 @@ func (c *Consumer) handleMessage(ctx context.Context, msg redis.XMessage) error
if err != nil {
return fmt.Errorf("user_id: %w", err)
}
+ if MessagePause != nil {
+ if err := MessagePause(ctx, ticketID); err != nil {
+ return err
+ }
+ }
soldAt := time.Now()
res := c.db.Exec(
`UPDATE tickets SET user_id = ?, sold_at = ?, state = 'sold' WHERE id = ? AND state = 'available'`,
diff --git a/internal/persist/consumer_test.go b/internal/persist/consumer_test.go
index 1e2b358..0b088e9 100644
--- a/internal/persist/consumer_test.go
+++ b/internal/persist/consumer_test.go
@@ -3,6 +3,7 @@ package persist
import (
"context"
"testing"
+ "time"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
@@ -57,3 +58,215 @@ func TestConsumerAppliesSoldState(t *testing.T) {
t.Fatalf("expected sold, got %s", state)
}
}
+
+func TestConsumerDuplicateHandleIdempotent(t *testing.T) {
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+
+ db, err := gorm.Open(sqlite.Open("file:dup?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := db.Exec(`CREATE TABLE tickets (id INTEGER PRIMARY KEY, user_id INTEGER, state TEXT, sold_at DATETIME)`).Error; err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ if err := db.Exec(`INSERT INTO tickets (id, state) VALUES (9, 'available')`).Error; err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ c := NewConsumer(rdb, db, "t1")
+ msg := redis.XMessage{
+ ID: "1-0",
+ Values: map[string]interface{}{"ticket_id": "9", "user_id": "2"},
+ }
+ if err := c.handleMessage(ctx, msg); err != nil {
+ t.Fatalf("first handle: %v", err)
+ }
+ if err := c.handleMessage(ctx, msg); err != nil {
+ t.Fatalf("second handle: %v", err)
+ }
+ var state string
+ if err := db.Raw(`SELECT state FROM tickets WHERE id = 9`).Scan(&state).Error; err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if state != "sold" {
+ t.Fatalf("expected sold, got %s", state)
+ }
+}
+
+func TestReclaimStalePending(t *testing.T) {
+ oldIdle := ReclaimMinIdle
+ ReclaimMinIdle = 50 * time.Millisecond
+ t.Cleanup(func() { ReclaimMinIdle = oldIdle })
+
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ start := time.Unix(1_700_000_000, 0)
+ mr.SetTime(start)
+ rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+
+ db, err := gorm.Open(sqlite.Open("file:reclaim?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := db.Exec(`CREATE TABLE tickets (id INTEGER PRIMARY KEY, user_id INTEGER, state TEXT, sold_at DATETIME)`).Error; err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ if err := db.Exec(`INSERT INTO tickets (id, state) VALUES (11, 'available')`).Error; err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ c := NewConsumer(rdb, db, "reclaimer")
+ if err := c.EnsureGroup(ctx); err != nil {
+ t.Fatalf("group: %v", err)
+ }
+ if err := rdb.XAdd(ctx, &redis.XAddArgs{
+ Stream: StreamKey,
+ Values: map[string]interface{}{"ticket_id": "11", "user_id": "5"},
+ }).Err(); err != nil {
+ t.Fatalf("xadd: %v", err)
+ }
+
+ _, err = rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: ConsumerGroup, Consumer: "dead-worker", Streams: []string{StreamKey, ">"}, Count: 1,
+ }).Result()
+ if err != nil {
+ t.Fatalf("dead read: %v", err)
+ }
+
+ mr.SetTime(start.Add(200 * time.Millisecond))
+ c.reclaimStale(ctx)
+ if !c.WaitIdle(2 * time.Second) {
+ t.Fatal("reclaim handlers did not finish")
+ }
+
+ var state string
+ if err := db.Raw(`SELECT state FROM tickets WHERE id = 11`).Scan(&state).Error; err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if state != "sold" {
+ t.Fatalf("expected sold after reclaim, got %s", state)
+ }
+
+ pending, err := c.GroupPendingCount(ctx)
+ if err != nil {
+ t.Fatalf("pending: %v", err)
+ }
+ if pending != 0 {
+ t.Fatalf("expected pending 0 after reclaim, got %d", pending)
+ }
+}
+
+func TestConsumerReadsOwnPending(t *testing.T) {
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+
+ db, err := gorm.Open(sqlite.Open("file:pending0?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := db.Exec(`CREATE TABLE tickets (id INTEGER PRIMARY KEY, user_id INTEGER, state TEXT, sold_at DATETIME)`).Error; err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+ if err := db.Exec(`INSERT INTO tickets (id, state) VALUES (12, 'available')`).Error; err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ c := NewConsumer(rdb, db, "worker-1")
+ if err := c.EnsureGroup(ctx); err != nil {
+ t.Fatalf("group: %v", err)
+ }
+ if err := rdb.XAdd(ctx, &redis.XAddArgs{
+ Stream: StreamKey,
+ Values: map[string]interface{}{"ticket_id": "12", "user_id": "6"},
+ }).Err(); err != nil {
+ t.Fatalf("xadd: %v", err)
+ }
+
+ _, err = rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: ConsumerGroup, Consumer: "worker-1", Streams: []string{StreamKey, ">"}, Count: 1,
+ }).Result()
+ if err != nil {
+ t.Fatalf("first read: %v", err)
+ }
+
+ c.readAndProcess(ctx, "0", 0)
+ if !c.WaitIdle(2 * time.Second) {
+ t.Fatal("pending handlers did not finish")
+ }
+
+ var state string
+ if err := db.Raw(`SELECT state FROM tickets WHERE id = 12`).Scan(&state).Error; err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if state != "sold" {
+ t.Fatalf("expected sold from pending replay, got %s", state)
+ }
+}
+
+func TestMovesFailedMessageToDLQ(t *testing.T) {
+ oldMax := MaxDeliveryAttempts
+ MaxDeliveryAttempts = 1
+ t.Cleanup(func() { MaxDeliveryAttempts = oldMax })
+
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+
+ db, err := gorm.Open(sqlite.Open("file:dlq?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := db.Exec(`CREATE TABLE tickets (id INTEGER PRIMARY KEY, user_id INTEGER, state TEXT, sold_at DATETIME)`).Error; err != nil {
+ t.Fatalf("schema: %v", err)
+ }
+
+ c := NewConsumer(rdb, db, "dlq-worker")
+ if err := c.EnsureGroup(ctx); err != nil {
+ t.Fatalf("group: %v", err)
+ }
+ if err := rdb.XAdd(ctx, &redis.XAddArgs{
+ Stream: StreamKey,
+ Values: map[string]interface{}{"ticket_id": "404", "user_id": "9"},
+ }).Err(); err != nil {
+ t.Fatalf("xadd: %v", err)
+ }
+
+ read, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: ConsumerGroup, Consumer: "dlq-worker", Streams: []string{StreamKey, ">"}, Count: 1,
+ }).Result()
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ c.processMessageSync(ctx, read[0].Messages[0])
+
+ pending, err := c.GroupPendingCount(ctx)
+ if err != nil {
+ t.Fatalf("pending: %v", err)
+ }
+ if pending != 0 {
+ t.Fatalf("expected no pending after dlq, got %d", pending)
+ }
+
+ dlqLen, err := rdb.XLen(ctx, DLQStreamKey).Result()
+ if err != nil {
+ t.Fatalf("xlen dlq: %v", err)
+ }
+ if dlqLen != 1 {
+ t.Fatalf("expected 1 dlq entry, got %d", dlqLen)
+ }
+}
diff --git a/internal/persist/ready.go b/internal/persist/ready.go
new file mode 100644
index 0000000..cd82eb4
--- /dev/null
+++ b/internal/persist/ready.go
@@ -0,0 +1,16 @@
+package persist
+
+import (
+ "context"
+
+ "github.com/go-redis/redis/v8"
+)
+
+// GroupPendingCountRDB returns pending entries for the bookings consumer group.
+func GroupPendingCountRDB(ctx context.Context, rdb *redis.Client) (int64, error) {
+ pending, err := rdb.XPending(ctx, StreamKey, ConsumerGroup).Result()
+ if err != nil {
+ return 0, err
+ }
+ return pending.Count, nil
+}
diff --git a/internal/persist/stream.go b/internal/persist/stream.go
index 364d60a..212fd32 100644
--- a/internal/persist/stream.go
+++ b/internal/persist/stream.go
@@ -1,7 +1,28 @@
package persist
+import "time"
+
const (
- StreamKey = "bookings.stream"
- ConsumerGroup = "bookings-mysql"
- ConsumerName = "worker-1"
+ StreamKey = "bookings.stream"
+ ConsumerGroup = "bookings-mysql"
+ ConsumerName = "worker-1"
+ DLQStreamKey = "bookings.dlq"
)
+
+// MaxDeliveryAttempts moves a message to DLQStreamKey after this many deliveries with handle errors.
+var MaxDeliveryAttempts int64 = 5
+
+// MaxParallelHandlers limits concurrent handleMessage calls per consumer process.
+var MaxParallelHandlers = 4
+
+// MaxReadyPendingCount makes /readyz fail when group pending exceeds this value.
+var MaxReadyPendingCount int64 = 500
+
+// ReclaimMinIdle must exceed worst-case handleMessage duration so live workers are not reclaimed.
+var ReclaimMinIdle = 2 * time.Minute
+
+// ReclaimInterval is how often the consumer scans for stale pending entries.
+var ReclaimInterval = 10 * time.Second
+
+// ShutdownDrainTimeout bounds graceful shutdown while in-flight handlers finish.
+var ShutdownDrainTimeout = 30 * time.Second
diff --git a/internal/reconcile/reconcile.go b/internal/reconcile/reconcile.go
new file mode 100644
index 0000000..91f883a
--- /dev/null
+++ b/internal/reconcile/reconcile.go
@@ -0,0 +1,556 @@
+package reconcile
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-redis/redis/v8"
+ "gorm.io/gorm"
+
+ "ticketengine/internal/booking"
+ "ticketengine/internal/persist"
+)
+
+// Exit codes for the reconcile CLI.
+const (
+ ExitClean = 0
+ ExitDrift = 1
+ ExitOperational = 2
+)
+
+// PendingDisposition classifies PEL entries for operators.
+type PendingDisposition string
+
+const (
+ PendingExpected PendingDisposition = "EXPECTED"
+ PendingStale PendingDisposition = "STALE"
+ PendingSuspicious PendingDisposition = "SUSPICIOUS"
+ PendingUnknown PendingDisposition = "UNKNOWN"
+)
+
+// Issue is a single drift finding.
+type Issue struct {
+ Section string
+ Detail string
+}
+
+// PendingEntry describes one PEL message.
+type PendingEntry struct {
+ ID string
+ Idle time.Duration
+ Retries int64
+ Disposition PendingDisposition
+ TicketID int
+ UserID int
+}
+
+// Report is the full read-only reconciliation outcome.
+type Report struct {
+ Issues []Issue
+
+ RedisQueueLen int64
+ MySQLAvailable int64
+ MySQLSold int64
+ MySQLTotal int64
+ HashUserEntries int64
+ StreamLen int64
+ StreamLag int64
+ PendingTotal int64
+ DLQLen int64
+ PendingBreakdown []PendingEntry
+}
+
+func (r *Report) HasDrift() bool {
+ return len(r.Issues) > 0
+}
+
+func (r *Report) ExitCode() int {
+ if r.HasDrift() {
+ return ExitDrift
+ }
+ return ExitClean
+}
+
+// Run performs read-only checks across Redis and MySQL.
+func Run(ctx context.Context, db *gorm.DB, rdb *redis.Client) (*Report, error) {
+ rep := &Report{}
+
+ if err := loadInventoryCounts(ctx, db, rdb, rep); err != nil {
+ return rep, err
+ }
+ if err := checkDuplicateOwnership(ctx, db, rdb, rep); err != nil {
+ return rep, err
+ }
+
+ streamState, err := loadStreamState(ctx, rdb, rep)
+ if err != nil {
+ return rep, err
+ }
+ if err := checkInventoryAlignment(ctx, db, rdb, rep, streamState); err != nil {
+ return rep, err
+ }
+ if err := checkOwnershipOrphans(ctx, db, rdb, rep, streamState); err != nil {
+ return rep, err
+ }
+
+ return rep, nil
+}
+
+func loadInventoryCounts(ctx context.Context, db *gorm.DB, rdb *redis.Client, rep *Report) error {
+ if err := db.Model(&struct{}{}).Table("tickets").Count(&rep.MySQLTotal).Error; err != nil {
+ return fmt.Errorf("mysql count tickets: %w", err)
+ }
+ if err := db.Model(&struct{}{}).Table("tickets").Where("state = ?", "available").Count(&rep.MySQLAvailable).Error; err != nil {
+ return fmt.Errorf("mysql count available: %w", err)
+ }
+ if err := db.Model(&struct{}{}).Table("tickets").Where("state = ?", "sold").Count(&rep.MySQLSold).Error; err != nil {
+ return fmt.Errorf("mysql count sold: %w", err)
+ }
+
+ redisLen, err := rdb.LLen(ctx, booking.KeyQueue).Result()
+ if err != nil {
+ return fmt.Errorf("redis llen queue: %w", err)
+ }
+ rep.RedisQueueLen = redisLen
+
+ hlen, err := rdb.HLen(ctx, booking.KeyUserBooking).Result()
+ if err != nil {
+ return fmt.Errorf("redis hlen user hash: %w", err)
+ }
+ rep.HashUserEntries = hlen
+
+ return nil
+}
+
+func checkInventoryAlignment(ctx context.Context, db *gorm.DB, rdb *redis.Client, rep *Report, stream streamState) error {
+ inFlight, err := explainedInFlightCount(ctx, db, rdb, stream)
+ if err != nil {
+ return err
+ }
+ expectedQueue := rep.MySQLAvailable - inFlight
+ if rep.RedisQueueLen != expectedQueue {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "inventory",
+ Detail: fmt.Sprintf(
+ "redis queue length %d != mysql available %d minus in-flight %d (expected queue %d)",
+ rep.RedisQueueLen, rep.MySQLAvailable, inFlight, expectedQueue,
+ ),
+ })
+ }
+ return nil
+}
+
+func explainedInFlightCount(ctx context.Context, db *gorm.DB, rdb *redis.Client, stream streamState) (int64, error) {
+ fields, err := rdb.HGetAll(ctx, booking.KeyUserBooking).Result()
+ if err != nil {
+ return 0, fmt.Errorf("redis hgetall user hash: %w", err)
+ }
+ var count int64
+ for uidStr, tidStr := range fields {
+ userID, err := strconv.Atoi(uidStr)
+ if err != nil {
+ continue
+ }
+ ticketID, err := strconv.Atoi(tidStr)
+ if err != nil {
+ continue
+ }
+ var state string
+ if err := db.Raw(`SELECT state FROM tickets WHERE id = ?`, ticketID).Scan(&state).Error; err != nil {
+ return 0, err
+ }
+ if state != "available" {
+ continue
+ }
+ if ref, ok := stream.pending[ticketID]; ok && ref.userID == userID {
+ count++
+ continue
+ }
+ if u, ok := stream.undelivered[ticketID]; ok && u == userID {
+ count++
+ }
+ }
+ return count, nil
+}
+
+type ticketOwner struct {
+ TicketID int
+ UserID *int
+ State string
+}
+
+func checkDuplicateOwnership(ctx context.Context, db *gorm.DB, rdb *redis.Client, rep *Report) error {
+ type dupUser struct {
+ UserID int
+ Count int64
+ }
+ var dups []dupUser
+ err := db.Raw(`
+ SELECT user_id AS user_id, COUNT(*) AS count
+ FROM tickets
+ WHERE state = 'sold' AND user_id IS NOT NULL
+ GROUP BY user_id
+ HAVING COUNT(*) > 1`).Scan(&dups).Error
+ if err != nil {
+ return fmt.Errorf("mysql duplicate users: %w", err)
+ }
+ for _, d := range dups {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "duplicate_ownership",
+ Detail: fmt.Sprintf("mysql user_id %d owns %d sold tickets", d.UserID, d.Count),
+ })
+ }
+
+ fields, err := rdb.HGetAll(ctx, booking.KeyUserBooking).Result()
+ if err != nil {
+ return fmt.Errorf("redis hgetall user hash: %w", err)
+ }
+ ticketToUser := map[string]string{}
+ for uid, tid := range fields {
+ if prev, ok := ticketToUser[tid]; ok {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "duplicate_ownership",
+ Detail: fmt.Sprintf("redis ticket %s claimed by users %s and %s", tid, prev, uid),
+ })
+ continue
+ }
+ ticketToUser[tid] = uid
+ }
+ return nil
+}
+
+type streamState struct {
+ pending map[int]pendingRef
+ undelivered map[int]int
+}
+
+func loadStreamState(ctx context.Context, rdb *redis.Client, rep *Report) (streamState, error) {
+ st := streamState{
+ pending: map[int]pendingRef{},
+ undelivered: map[int]int{},
+ }
+
+ streamLen, err := rdb.XLen(ctx, persist.StreamKey).Result()
+ if err != nil && !keyMissing(err) {
+ return st, fmt.Errorf("redis xlen stream: %w", err)
+ }
+ rep.StreamLen = streamLen
+
+ lastDelivered, lagErr := consumerGroupLastDelivered(ctx, rdb)
+ if lagErr != nil && !keyMissing(lagErr) && !noGroup(lagErr) {
+ return st, fmt.Errorf("redis xinfo groups: %w", lagErr)
+ }
+ st.undelivered, rep.StreamLag = undeliveredAllocations(ctx, rdb, lastDelivered)
+
+ pending, err := persist.GroupPendingCountRDB(ctx, rdb)
+ if err != nil && !keyMissing(err) && !noGroup(err) {
+ return st, fmt.Errorf("redis pending count: %w", err)
+ }
+ rep.PendingTotal = pending
+
+ dlqLen, err := rdb.XLen(ctx, persist.DLQStreamKey).Result()
+ if err != nil && !keyMissing(err) {
+ return st, fmt.Errorf("redis xlen dlq: %w", err)
+ }
+ rep.DLQLen = dlqLen
+ if dlqLen > 0 {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "dlq",
+ Detail: fmt.Sprintf("bookings.dlq has %d entries (failed persist)", dlqLen),
+ })
+ }
+
+ if pending > 0 {
+ ext, err := rdb.XPendingExt(ctx, &redis.XPendingExtArgs{
+ Stream: persist.StreamKey,
+ Group: persist.ConsumerGroup,
+ Start: "-",
+ End: "+",
+ Count: 1000,
+ }).Result()
+ if err != nil {
+ rep.PendingBreakdown = append(rep.PendingBreakdown, PendingEntry{
+ ID: "-",
+ Disposition: PendingUnknown,
+ })
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "stream_pending",
+ Detail: fmt.Sprintf("could not inspect pending entries: %v", err),
+ })
+ return st, nil
+ }
+ staleCount := 0
+ suspiciousCount := 0
+ for _, e := range ext {
+ entry := PendingEntry{
+ ID: e.ID,
+ Idle: time.Duration(e.Idle) * time.Millisecond,
+ Retries: e.RetryCount,
+ }
+ entry.Disposition = classifyPending(entry.Idle, entry.Retries)
+ switch entry.Disposition {
+ case PendingStale:
+ staleCount++
+ case PendingSuspicious:
+ suspiciousCount++
+ }
+ tid, uid, err := streamMessageTicketUser(ctx, rdb, e.ID)
+ if err != nil {
+ entry.Disposition = PendingUnknown
+ } else {
+ entry.TicketID = tid
+ entry.UserID = uid
+ st.pending[tid] = pendingRef{userID: uid, disposition: entry.Disposition}
+ }
+ rep.PendingBreakdown = append(rep.PendingBreakdown, entry)
+ }
+ if staleCount > 0 {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "stream_pending",
+ Detail: fmt.Sprintf("%d STALE pending entries (idle >= %s)", staleCount, persist.ReclaimMinIdle),
+ })
+ }
+ if suspiciousCount > 0 {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "stream_pending",
+ Detail: fmt.Sprintf("%d SUSPICIOUS pending entries (high delivery count)", suspiciousCount),
+ })
+ }
+ }
+
+ return st, nil
+}
+
+func consumerGroupLastDelivered(ctx context.Context, rdb *redis.Client) (string, error) {
+ groups, err := rdb.XInfoGroups(ctx, persist.StreamKey).Result()
+ if err == nil {
+ for _, g := range groups {
+ if g.Name == persist.ConsumerGroup {
+ if g.LastDeliveredID == "" {
+ return "0-0", nil
+ }
+ return g.LastDeliveredID, nil
+ }
+ }
+ return "0-0", nil
+ }
+ raw, err2 := rdb.Do(ctx, "XINFO", "GROUPS", persist.StreamKey).Result()
+ if err2 != nil {
+ return "", err
+ }
+ entries, ok := raw.([]interface{})
+ if !ok {
+ return "", err
+ }
+ for _, entry := range entries {
+ pairs, ok := entry.([]interface{})
+ if !ok {
+ continue
+ }
+ var name, lastID string
+ for i := 0; i+1 < len(pairs); i += 2 {
+ key, _ := pairs[i].(string)
+ switch key {
+ case "name":
+ name, _ = pairs[i+1].(string)
+ case "last-delivered-id":
+ lastID, _ = pairs[i+1].(string)
+ }
+ }
+ if name == persist.ConsumerGroup {
+ if lastID == "" {
+ return "0-0", nil
+ }
+ return lastID, nil
+ }
+ }
+ return "0-0", nil
+}
+
+func undeliveredAllocations(ctx context.Context, rdb *redis.Client, lastDelivered string) (map[int]int, int64) {
+ out := map[int]int{}
+ start := "-"
+ if lastDelivered != "" && lastDelivered != "0-0" {
+ start = "(" + lastDelivered
+ }
+ msgs, err := rdb.XRange(ctx, persist.StreamKey, start, "+").Result()
+ if err != nil {
+ return out, 0
+ }
+ for _, msg := range msgs {
+ tid, err := strconv.Atoi(fmt.Sprint(msg.Values["ticket_id"]))
+ if err != nil {
+ continue
+ }
+ uid, err := strconv.Atoi(fmt.Sprint(msg.Values["user_id"]))
+ if err != nil {
+ continue
+ }
+ out[tid] = uid
+ }
+ return out, int64(len(msgs))
+}
+
+type pendingRef struct {
+ userID int
+ disposition PendingDisposition
+}
+
+func classifyPending(idle time.Duration, retries int64) PendingDisposition {
+ if retries >= persist.MaxDeliveryAttempts-1 {
+ return PendingSuspicious
+ }
+ if idle >= persist.ReclaimMinIdle {
+ return PendingStale
+ }
+ return PendingExpected
+}
+
+func streamMessageTicketUser(ctx context.Context, rdb *redis.Client, id string) (int, int, error) {
+ msgs, err := rdb.XRange(ctx, persist.StreamKey, id, id).Result()
+ if err != nil {
+ return 0, 0, err
+ }
+ if len(msgs) == 0 {
+ return 0, 0, fmt.Errorf("message %s not found in stream", id)
+ }
+ tid, err := strconv.Atoi(fmt.Sprint(msgs[0].Values["ticket_id"]))
+ if err != nil {
+ return 0, 0, err
+ }
+ uid, err := strconv.Atoi(fmt.Sprint(msgs[0].Values["user_id"]))
+ if err != nil {
+ return 0, 0, err
+ }
+ return tid, uid, nil
+}
+
+func checkOwnershipOrphans(ctx context.Context, db *gorm.DB, rdb *redis.Client, rep *Report, stream streamState) error {
+ fields, err := rdb.HGetAll(ctx, booking.KeyUserBooking).Result()
+ if err != nil {
+ return fmt.Errorf("redis hgetall user hash: %w", err)
+ }
+
+ for uidStr, tidStr := range fields {
+ userID, err := strconv.Atoi(uidStr)
+ if err != nil {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "ownership",
+ Detail: fmt.Sprintf("invalid redis user id field %q", uidStr),
+ })
+ continue
+ }
+ ticketID, err := strconv.Atoi(tidStr)
+ if err != nil {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "ownership",
+ Detail: fmt.Sprintf("invalid redis ticket id for user %d: %q", userID, tidStr),
+ })
+ continue
+ }
+
+ var row ticketOwner
+ err = db.Raw(`SELECT id AS ticket_id, user_id, state FROM tickets WHERE id = ?`, ticketID).
+ Scan(&row).Error
+ if err != nil {
+ return fmt.Errorf("mysql load ticket %d: %w", ticketID, err)
+ }
+ if row.TicketID == 0 {
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "ownership",
+ Detail: fmt.Sprintf("redis allocation user=%d ticket=%d missing mysql row", userID, ticketID),
+ })
+ continue
+ }
+
+ if row.State == "sold" {
+ if row.UserID == nil || *row.UserID != userID {
+ mysqlUID := 0
+ if row.UserID != nil {
+ mysqlUID = *row.UserID
+ }
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "ownership",
+ Detail: fmt.Sprintf("ticket %d sold to mysql user %d but redis hash:user has user %d", ticketID, mysqlUID, userID),
+ })
+ }
+ continue
+ }
+
+ if row.State == "available" {
+ if ref, ok := stream.pending[ticketID]; ok && ref.userID == userID {
+ continue
+ }
+ if uid, ok := stream.undelivered[ticketID]; ok && uid == userID {
+ continue
+ }
+ rep.Issues = append(rep.Issues, Issue{
+ Section: "orphan_allocation",
+ Detail: fmt.Sprintf("redis owns ticket %d for user %d but mysql state=%s (not explained by expected pending)", ticketID, userID, row.State),
+ })
+ }
+ }
+ return nil
+}
+
+func keyMissing(err error) bool {
+ if err == nil {
+ return false
+ }
+ return strings.Contains(err.Error(), "no such key") || err == redis.Nil
+}
+
+func noGroup(err error) bool {
+ if err == nil {
+ return false
+ }
+ return strings.Contains(err.Error(), "NOGROUP")
+}
+
+// Format renders human-readable output for operators.
+func (r *Report) Format() string {
+ var b strings.Builder
+ if r.HasDrift() {
+ b.WriteString("RECONCILE RESULT: DRIFT DETECTED\n")
+ } else {
+ b.WriteString("RECONCILE RESULT: CLEAN\n")
+ }
+ b.WriteString("\n[inventory]\n")
+ b.WriteString(fmt.Sprintf(" redis_queue_len: %d\n", r.RedisQueueLen))
+ b.WriteString(fmt.Sprintf(" mysql_available: %d\n", r.MySQLAvailable))
+ b.WriteString(fmt.Sprintf(" mysql_sold: %d\n", r.MySQLSold))
+ b.WriteString(fmt.Sprintf(" mysql_total: %d\n", r.MySQLTotal))
+ b.WriteString(fmt.Sprintf(" hash_user_entries: %d\n", r.HashUserEntries))
+
+ b.WriteString("\n[stream]\n")
+ b.WriteString(fmt.Sprintf(" stream_len: %d\n", r.StreamLen))
+ b.WriteString(fmt.Sprintf(" group_lag: %d\n", r.StreamLag))
+ b.WriteString(fmt.Sprintf(" pending_total: %d\n", r.PendingTotal))
+ b.WriteString(fmt.Sprintf(" dlq_len: %d\n", r.DLQLen))
+
+ if len(r.PendingBreakdown) > 0 {
+ b.WriteString("\n[pending_detail]\n")
+ for _, p := range r.PendingBreakdown {
+ b.WriteString(fmt.Sprintf(" id=%s ticket=%d user=%d idle=%s retries=%d %s\n",
+ p.ID, p.TicketID, p.UserID, p.Idle.Round(time.Millisecond), p.Retries, p.Disposition))
+ }
+ }
+
+ if len(r.Issues) > 0 {
+ b.WriteString("\n[issues]\n")
+ bySection := map[string][]string{}
+ for _, iss := range r.Issues {
+ bySection[iss.Section] = append(bySection[iss.Section], iss.Detail)
+ }
+ for section, details := range bySection {
+ b.WriteString(fmt.Sprintf(" [%s]\n", section))
+ for _, d := range details {
+ b.WriteString(fmt.Sprintf(" - %s\n", d))
+ }
+ }
+ }
+ return b.String()
+}
diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go
new file mode 100644
index 0000000..0d44233
--- /dev/null
+++ b/internal/reconcile/reconcile_test.go
@@ -0,0 +1,250 @@
+package reconcile
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/go-redis/redis/v8"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "ticketengine/internal/booking"
+ "ticketengine/internal/persist"
+)
+
+type ticketRow struct {
+ ID int `gorm:"primaryKey"`
+ UserID *int
+ State string `gorm:"size:16;default:available"`
+}
+
+func (ticketRow) TableName() string { return "tickets" }
+
+var reclaimMinIdleMu sync.Mutex
+
+func withReclaimMinIdle(t *testing.T, d time.Duration) {
+ reclaimMinIdleMu.Lock()
+ old := persist.ReclaimMinIdle
+ persist.ReclaimMinIdle = d
+ t.Cleanup(func() {
+ persist.ReclaimMinIdle = old
+ reclaimMinIdleMu.Unlock()
+ })
+}
+
+func setupReconcileTest(t *testing.T) (*gorm.DB, *redis.Client, *miniredis.Miniredis) {
+ dsn := fmt.Sprintf("file:reconcile_%s?mode=memory&cache=shared", t.Name())
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := db.AutoMigrate(&ticketRow{}); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ t.Cleanup(mr.Close)
+ rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ return db, rdb, mr
+}
+
+func seedAlignedInventory(ctx context.Context, db *gorm.DB, rdb *redis.Client, ids ...int) {
+ for _, id := range ids {
+ if err := db.Create(&ticketRow{ID: id, State: "available"}).Error; err != nil {
+ panic(err)
+ }
+ if err := rdb.RPush(ctx, booking.KeyQueue, id).Err(); err != nil {
+ panic(err)
+ }
+ }
+}
+
+func TestReconcile(t *testing.T) {
+ ctx := context.Background()
+ db, rdb, _ := setupReconcileTest(t)
+ seedAlignedInventory(ctx, db, rdb, 1, 2, 3)
+
+ rep, err := Run(ctx, db, rdb)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if rep.HasDrift() {
+ t.Fatalf("expected clean: %s", rep.Format())
+ }
+ if rep.ExitCode() != ExitClean {
+ t.Fatalf("exit code %d", rep.ExitCode())
+ }
+ if !strings.Contains(rep.Format(), "RECONCILE RESULT: CLEAN") {
+ t.Fatalf("output: %s", rep.Format())
+ }
+}
+
+func TestReconcileInventoryDrift(t *testing.T) {
+ ctx := context.Background()
+ db, rdb, _ := setupReconcileTest(t)
+ seedAlignedInventory(ctx, db, rdb, 1, 2)
+ _ = rdb.RPush(ctx, booking.KeyQueue, 99)
+
+ rep, err := Run(ctx, db, rdb)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if !rep.HasDrift() {
+ t.Fatal("expected inventory drift")
+ }
+ if !strings.Contains(rep.Format(), "inventory") {
+ t.Fatalf("output: %s", rep.Format())
+ }
+}
+
+func TestReconcileDuplicateOwnership(t *testing.T) {
+ ctx := context.Background()
+ db, rdb, _ := setupReconcileTest(t)
+ uid := 42
+ if err := db.Create(&ticketRow{ID: 1, State: "sold", UserID: &uid}).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&ticketRow{ID: 2, State: "sold", UserID: &uid}).Error; err != nil {
+ t.Fatal(err)
+ }
+ _ = rdb.HSet(ctx, booking.KeyUserBooking, "10", "1", "11", "1")
+
+ rep, err := Run(ctx, db, rdb)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if !rep.HasDrift() {
+ t.Fatal("expected duplicate drift")
+ }
+}
+
+func TestReconcileOrphanAllocation(t *testing.T) {
+ ctx := context.Background()
+ db, rdb, _ := setupReconcileTest(t)
+ if err := db.Create(&ticketRow{ID: 5, State: "available"}).Error; err != nil {
+ t.Fatal(err)
+ }
+ _ = rdb.HSet(ctx, booking.KeyUserBooking, "7", "5")
+
+ rep, err := Run(ctx, db, rdb)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if !rep.HasDrift() {
+ t.Fatalf("expected orphan drift: %s", rep.Format())
+ }
+}
+
+func TestReconcileLegitimatePendingNotFalsePositive(t *testing.T) {
+ withReclaimMinIdle(t, time.Hour)
+ ctx := context.Background()
+ db, rdb, _ := setupReconcileTest(t)
+ if err := db.Create(&ticketRow{ID: 8, State: "available"}).Error; err != nil {
+ t.Fatal(err)
+ }
+ _ = rdb.HSet(ctx, booking.KeyUserBooking, "99", "8")
+ _ = rdb.XGroupCreateMkStream(ctx, persist.StreamKey, persist.ConsumerGroup, "0")
+ _, err := rdb.XAdd(ctx, &redis.XAddArgs{
+ Stream: persist.StreamKey,
+ Values: map[string]interface{}{"ticket_id": 8, "user_id": 99},
+ }).Result()
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
+ Group: persist.ConsumerGroup,
+ Consumer: "reconcile-test",
+ Streams: []string{persist.StreamKey, ">"},
+ Count: 1,
+ }).Result()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rep, err := Run(ctx, db, rdb)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if rep.HasDrift() {
+ t.Fatalf("expected clean during expected pending: %s", rep.Format())
+ }
+}
+
+func TestReconcileRedisUnavailable(t *testing.T) {
+ ctx := context.Background()
+ db, rdb, mr := setupReconcileTest(t)
+ seedAlignedInventory(ctx, db, rdb, 1)
+ mr.Close()
+
+ _, err := Run(ctx, db, rdb)
+ if err == nil {
+ t.Fatal("expected operational error")
+ }
+}
+
+func TestReconcileMySQLUnavailable(t *testing.T) {
+ ctx := context.Background()
+ db, rdb, _ := setupReconcileTest(t)
+ sqlDB, _ := db.DB()
+ sqlDB.Close()
+
+ _, err := Run(ctx, db, rdb)
+ if err == nil {
+ t.Fatal("expected operational error")
+ }
+}
+
+func TestReconcileReadOnlyGuarantee(t *testing.T) {
+ ctx := context.Background()
+ db, rdb, _ := setupReconcileTest(t)
+ seedAlignedInventory(ctx, db, rdb, 1, 2)
+ before := snapshotState(ctx, t, db, rdb)
+
+ _, err := Run(ctx, db, rdb)
+ if err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ after := snapshotState(ctx, t, db, rdb)
+ if before != after {
+ t.Fatalf("state changed:\nbefore=%s\nafter=%s", before, after)
+ }
+}
+
+func TestClassifyPendingDisposition(t *testing.T) {
+ withReclaimMinIdle(t, 2*time.Minute)
+ if classifyPending(0, persist.MaxDeliveryAttempts) != PendingSuspicious {
+ t.Fatal("expected suspicious on high retries")
+ }
+ if classifyPending(persist.ReclaimMinIdle, 0) != PendingStale {
+ t.Fatal("expected stale on long idle")
+ }
+ if classifyPending(0, 0) != PendingExpected {
+ t.Fatal("expected expected on fresh pending")
+ }
+}
+
+func snapshotState(ctx context.Context, t *testing.T, db *gorm.DB, rdb *redis.Client) string {
+ var count int64
+ if err := db.Model(&ticketRow{}).Count(&count).Error; err != nil {
+ t.Fatalf("count mysql: %v", err)
+ }
+ q, _ := rdb.LLen(ctx, booking.KeyQueue).Result()
+ h, _ := rdb.HLen(ctx, booking.KeyUserBooking).Result()
+ s, _ := rdb.XLen(ctx, persist.StreamKey).Result()
+ d, _ := rdb.XLen(ctx, persist.DLQStreamKey).Result()
+ return strings.Join([]string{
+ "mysql:" + fmt.Sprint(count),
+ "q:" + fmt.Sprint(q),
+ "h:" + fmt.Sprint(h),
+ "s:" + fmt.Sprint(s),
+ "d:" + fmt.Sprint(d),
+ }, "|")
+}
diff --git a/internal/security/apikey.go b/internal/security/apikey.go
new file mode 100644
index 0000000..152b528
--- /dev/null
+++ b/internal/security/apikey.go
@@ -0,0 +1,20 @@
+package security
+
+import "crypto/subtle"
+
+// APIKeyValid reports whether provided satisfies required API key policy.
+// If required is empty, authentication is disabled (development default).
+func APIKeyValid(provided, required string) bool {
+ if required == "" {
+ return true
+ }
+ if provided == "" {
+ return false
+ }
+ if len(provided) != len(required) {
+ // Avoid short-circuit timing leak; compare against self.
+ subtle.ConstantTimeCompare([]byte(required), []byte(required))
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(provided), []byte(required)) == 1
+}
diff --git a/internal/security/apikey_test.go b/internal/security/apikey_test.go
new file mode 100644
index 0000000..33b5734
--- /dev/null
+++ b/internal/security/apikey_test.go
@@ -0,0 +1,31 @@
+package security
+
+import "testing"
+
+func TestAPIKeyValid_DisabledWhenRequiredEmpty(t *testing.T) {
+ if !APIKeyValid("", "") {
+ t.Fatal("expected open when required empty")
+ }
+ if !APIKeyValid("anything", "") {
+ t.Fatal("expected open when required empty")
+ }
+}
+
+func TestAPIKeyValid_MissingProvided(t *testing.T) {
+ if APIKeyValid("", "required-secret") {
+ t.Fatal("expected reject when provided empty")
+ }
+}
+
+func TestAPIKeyValid_Invalid(t *testing.T) {
+ if APIKeyValid("wrong-key-value", "required-secret") {
+ t.Fatal("expected reject for wrong key")
+ }
+}
+
+func TestAPIKeyValid_Valid(t *testing.T) {
+ key := "test-api-key-12345"
+ if !APIKeyValid(key, key) {
+ t.Fatal("expected accept for matching key")
+ }
+}
diff --git a/main.go b/main.go
index 4396588..aef0695 100644
--- a/main.go
+++ b/main.go
@@ -1,110 +1,22 @@
package main
import (
- "context"
- "fmt"
- "log"
- "net/http"
"os"
- "os/signal"
- "sync/atomic"
- "syscall"
- "time"
-
- "ticketengine/internal/persist"
)
func main() {
- if len(os.Args) > 1 && os.Args[1] == "seed" {
- runSeedCommand()
- return
- }
- if len(os.Args) > 1 && os.Args[1] == "reconcile" {
- runReconcileCommand()
- return
- }
-
- log.Println("Starting system initialization...")
- initMySQL()
- initRedis()
- initBookingAllocator()
-
- consumer := persist.NewConsumer(redisClient, db, persist.ConsumerName)
- if err := consumer.EnsureGroup(context.Background()); err != nil {
- log.Fatalf("persist consumer group: %v", err)
- }
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- go consumer.Run(ctx)
-
- prepareRuntimeData()
-
- if httpPort == "" {
- httpPort = "8080"
- }
-
- http.HandleFunc("/book", enableCORS(apiKeyMiddleware(ticketHandler)))
- http.HandleFunc("/tickets/count", enableCORS(ticketCountHandler))
- http.HandleFunc("/metrics", enableCORS(metricsHandler))
- http.HandleFunc("/healthz", healthzHandler)
- http.HandleFunc("/readyz", readyzHandler(consumer))
-
- go func() {
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
- for range ticker.C {
- reqs := atomic.LoadInt64(&totalRequests)
- if reqs > 0 {
- log.Printf("[metrics] requests=%d success=%d failures=%d",
- reqs,
- atomic.LoadInt64(&bookingSuccess),
- atomic.LoadInt64(&bookingFailures),
- )
- }
+ if len(os.Args) > 1 {
+ switch os.Args[1] {
+ case "seed":
+ runSeedCommand()
+ return
+ case "reconcile":
+ runReconcileCommand()
+ return
+ case "worker":
+ runWorkerCommand()
+ return
}
- }()
-
- srv := &http.Server{
- Addr: fmt.Sprintf(":%s", httpPort),
- ReadTimeout: 5 * time.Second,
- WriteTimeout: 10 * time.Second,
- IdleTimeout: 120 * time.Second,
- }
-
- go func() {
- sigCh := make(chan os.Signal, 1)
- signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
- <-sigCh
- log.Println("shutdown signal received, draining...")
- cancel()
- shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer shutdownCancel()
- _ = srv.Shutdown(shutdownCtx)
- }()
-
- log.Printf("Server listening on port %s", httpPort)
- log.Printf("Endpoints: POST /book | GET /tickets/count | GET /metrics | GET /healthz | GET /readyz")
- if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- log.Fatalf("Server failed: %v", err)
- }
- log.Println("server stopped")
-}
-
-func runSeedCommand() {
- log.Println("Running inventory seed (explicit command)...")
- initMySQL()
- initRedis()
- if err := SeedInventory(); err != nil {
- log.Fatalf("seed failed: %v", err)
- }
- log.Println("Seed complete.")
-}
-
-func runReconcileCommand() {
- initMySQL()
- initRedis()
- if err := RunReconciliation(context.Background()); err != nil {
- log.Fatalf("reconcile failed: %v", err)
}
- log.Println("reconcile complete")
+ runServeCommand()
}
diff --git a/migrate.go b/migrate.go
index 15fe061..b18c45a 100644
--- a/migrate.go
+++ b/migrate.go
@@ -39,6 +39,9 @@ func applyMigrations() {
log.Fatalf("migrations: read %s: %v", full, err)
}
if e.Name() == "002_ticket_state.sql" && migrationColumnExists(sqlDB, "tickets", "state") {
+ if err := apply002IndexUpgrade(sqlDB); err != nil {
+ log.Fatalf("migrations: exec %s index upgrade: %v", e.Name(), err)
+ }
log.Printf("skip migration %s (state column exists)", e.Name())
continue
}
@@ -52,6 +55,23 @@ func applyMigrations() {
}
}
+func apply002IndexUpgrade(sqlDB *sql.DB) error {
+ // Idempotent fix when 002 partially applied (state added but unique index step failed).
+ if _, err := sqlDB.Exec(`ALTER TABLE tickets MODIFY user_id BIGINT NULL`); err != nil {
+ return err
+ }
+ if _, err := sqlDB.Exec(`DROP INDEX idx_tickets_user_id ON tickets`); err != nil {
+ if !strings.Contains(err.Error(), "1091") && !strings.Contains(strings.ToLower(err.Error()), "check that it exists") {
+ return err
+ }
+ }
+ _, err := sqlDB.Exec(`CREATE UNIQUE INDEX idx_tickets_user_id ON tickets (user_id)`)
+ if err != nil && strings.Contains(err.Error(), "1061") {
+ return nil
+ }
+ return err
+}
+
func splitSQLStatements(sql string) []string {
var b strings.Builder
for _, line := range strings.Split(sql, "\n") {
diff --git a/migrations/002_ticket_state.sql b/migrations/002_ticket_state.sql
index 4dd87ca..86f99bb 100644
--- a/migrations/002_ticket_state.sql
+++ b/migrations/002_ticket_state.sql
@@ -6,4 +6,8 @@ UPDATE tickets SET state = 'sold' WHERE user_id IS NOT NULL AND user_id != 0;
UPDATE tickets SET user_id = NULL WHERE user_id = 0 OR state = 'available';
+ALTER TABLE tickets MODIFY user_id BIGINT NULL;
+
+-- 001 created a non-unique index with this name; replace it for one-user-one-booking.
+ALTER TABLE tickets DROP INDEX idx_tickets_user_id;
CREATE UNIQUE INDEX idx_tickets_user_id ON tickets (user_id);
diff --git a/mysql_pool.go b/mysql_pool.go
new file mode 100644
index 0000000..e090cb3
--- /dev/null
+++ b/mysql_pool.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "os"
+ "strconv"
+)
+
+func mysqlPoolLimits() (maxOpen, maxIdle int) {
+ role := getEnv("TICKET_ENGINE_ROLE", "serve")
+ if role == "worker" {
+ maxOpen = envInt("TICKET_MYSQL_MAX_OPEN", 50)
+ maxIdle = envInt("TICKET_MYSQL_MAX_IDLE", 25)
+ return maxOpen, maxIdle
+ }
+ return envInt("TICKET_MYSQL_MAX_OPEN", 200), envInt("TICKET_MYSQL_MAX_IDLE", 100)
+}
+
+func envInt(key string, def int) int {
+ v := os.Getenv(key)
+ if v == "" {
+ return def
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil || n < 1 {
+ return def
+ }
+ return n
+}
diff --git a/reconcile.go b/reconcile.go
index ed99e4b..c42591d 100644
--- a/reconcile.go
+++ b/reconcile.go
@@ -2,29 +2,11 @@ package main
import (
"context"
- "fmt"
- "log"
- "ticketengine/internal/booking"
+ "ticketengine/internal/reconcile"
)
-// RunReconciliation compares Redis queue length with MySQL available tickets and logs drift.
-func RunReconciliation(ctx context.Context) error {
- var available int64
- if err := db.Model(&Ticket{}).Where("state = ?", "available").Count(&available).Error; err != nil {
- return err
- }
- redisLen, err := redisClient.LLen(ctx, booking.KeyQueue).Result()
- if err != nil {
- return err
- }
- if redisLen != available {
- log.Printf("WARN: reconcile drift redis=%d mysql_available=%d", redisLen, available)
- if getEnv("TICKET_ENGINE_REDIS_SYNC", "") == "1" {
- return syncRedisQueue(true)
- }
- return fmt.Errorf("inventory drift detected (set TICKET_ENGINE_REDIS_SYNC=1 to rebuild queue)")
- }
- log.Printf("reconcile ok: redis=%d mysql_available=%d", redisLen, available)
- return nil
+// RunReconciliation compares Redis and MySQL booking state (read-only).
+func RunReconciliation(ctx context.Context) (*reconcile.Report, error) {
+ return reconcile.Run(ctx, db, redisClient)
}
diff --git a/router.go b/router.go
index 2c0431d..eef9907 100644
--- a/router.go
+++ b/router.go
@@ -10,7 +10,9 @@ import (
"strconv"
"sync/atomic"
+ "ticketengine/internal/booking"
"ticketengine/internal/persist"
+ "ticketengine/internal/security"
)
// JSON response types for structured API output
@@ -51,10 +53,10 @@ func apiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return next
}
return func(w http.ResponseWriter, r *http.Request) {
- if r.Header.Get("X-API-Key") != required {
+ if !security.APIKeyValid(r.Header.Get("X-API-Key"), required) {
writeJSON(w, http.StatusUnauthorized, BookingResponse{
Status: "error",
- Message: "Invalid API key",
+ Message: "Unauthorized",
})
return
}
@@ -137,11 +139,11 @@ func ticketHandler(w http.ResponseWriter, r *http.Request) {
}
func ensurePersisted(ctx context.Context, ticketID, userID int) error {
- sold, err := persist.IsSold(db, ticketID)
+ redisTicket, ok, err := booking.TicketForUser(ctx, redisClient, userID)
if err != nil {
return err
}
- if sold {
+ if ok && redisTicket == ticketID {
return nil
}
return persist.EnqueueReplay(ctx, redisClient, ticketID, userID)
diff --git a/router_apikey_test.go b/router_apikey_test.go
new file mode 100644
index 0000000..03489ea
--- /dev/null
+++ b/router_apikey_test.go
@@ -0,0 +1,179 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/go-redis/redis/v8"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "ticketengine/internal/booking"
+)
+
+func setupAPIKeyBookHandler(t *testing.T) http.HandlerFunc {
+ dsn := "file:apikey_" + strings.ReplaceAll(t.Name(), "/", "_") + "?mode=memory"
+ testDB, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := testDB.AutoMigrate(&Ticket{}); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ if err := testDB.Create(&Ticket{ID: 1, State: "available"}).Error; err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ db = testDB
+
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ t.Cleanup(mr.Close)
+ redisClient = redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+ if err := redisClient.LPush(ctx, booking.KeyQueue, "1").Err(); err != nil {
+ t.Fatalf("lpush: %v", err)
+ }
+ alloc, err := booking.NewAllocator(ctx, redisClient)
+ if err != nil {
+ t.Fatalf("allocator: %v", err)
+ }
+ ticketAllocator = alloc
+ return apiKeyMiddleware(ticketHandler)
+}
+
+func TestAPIKey_Valid(t *testing.T) {
+ const key = "slice7-test-api-key"
+ t.Setenv("TICKET_API_KEY", key)
+ handler := setupAPIKeyBookHandler(t)
+
+ req := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ req.Header.Set(httpUserHeader, "42")
+ req.Header.Set("X-API-Key", key)
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+ if strings.Contains(rec.Body.String(), key) {
+ t.Fatal("response must not contain api key")
+ }
+}
+
+func TestAPIKey_Missing(t *testing.T) {
+ const key = "slice7-test-api-key"
+ t.Setenv("TICKET_API_KEY", key)
+ handler := setupAPIKeyBookHandler(t)
+
+ req := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ req.Header.Set(httpUserHeader, "42")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status %d want 401", rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), key) {
+ t.Fatal("response must not contain api key")
+ }
+}
+
+func TestAPIKey_Invalid(t *testing.T) {
+ const key = "slice7-test-api-key"
+ t.Setenv("TICKET_API_KEY", key)
+ handler := setupAPIKeyBookHandler(t)
+
+ req := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ req.Header.Set(httpUserHeader, "42")
+ req.Header.Set("X-API-Key", "not-the-right-key")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status %d want 401", rec.Code)
+ }
+ body := rec.Body.String()
+ if strings.Contains(body, key) {
+ t.Fatal("response must not echo required api key")
+ }
+}
+
+func TestAPIKey_NotRequiredOnPublicRoutes(t *testing.T) {
+ const key = "slice7-test-api-key"
+ t.Setenv("TICKET_API_KEY", key)
+
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ t.Cleanup(mr.Close)
+ redisClient = redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+ if err := redisClient.LPush(ctx, booking.KeyQueue, "9", "8").Err(); err != nil {
+ t.Fatalf("lpush: %v", err)
+ }
+
+ metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
+ metricsRec := httptest.NewRecorder()
+ metricsHandler(metricsRec, metricsReq)
+ if metricsRec.Code != http.StatusOK {
+ t.Fatalf("metrics status %d body %s", metricsRec.Code, metricsRec.Body.String())
+ }
+
+ countReq := httptest.NewRequest(http.MethodGet, "/tickets/count", nil)
+ countRec := httptest.NewRecorder()
+ ticketCountHandler(countRec, countReq)
+ if countRec.Code != http.StatusOK {
+ t.Fatalf("tickets/count status %d body %s", countRec.Code, countRec.Body.String())
+ }
+ if !strings.Contains(countRec.Body.String(), `"remaining":2`) {
+ t.Fatalf("unexpected count body %s", countRec.Body.String())
+ }
+
+ bookHandler := setupAPIKeyBookHandler(t)
+ bookReq := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ bookReq.Header.Set(httpUserHeader, "1")
+ bookRec := httptest.NewRecorder()
+ bookHandler(bookRec, bookReq)
+ if bookRec.Code != http.StatusUnauthorized {
+ t.Fatalf("book without key status %d want 401", bookRec.Code)
+ }
+}
+
+func TestBookHandlerIdempotentHTTP_WithAPIKey(t *testing.T) {
+ const key = "slice7-idempotent-key"
+ t.Setenv("TICKET_API_KEY", key)
+ handler := setupAPIKeyBookHandler(t)
+
+ for i := 0; i < 2; i++ {
+ req := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ req.Header.Set(httpUserHeader, "77")
+ req.Header.Set("X-API-Key", key)
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("request %d status %d body %s", i, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+func TestAPIKey_OpenWhenEnvUnset(t *testing.T) {
+ t.Setenv("TICKET_API_KEY", "")
+ handler := setupAPIKeyBookHandler(t)
+
+ req := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ req.Header.Set(httpUserHeader, "43")
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
+ }
+}
diff --git a/router_book_test.go b/router_book_test.go
index b507e10..2a23ddc 100644
--- a/router_book_test.go
+++ b/router_book_test.go
@@ -5,7 +5,9 @@ import (
"context"
"net/http"
"net/http/httptest"
+ "strconv"
"testing"
+ "time"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
@@ -58,3 +60,125 @@ func TestBookHandlerIdempotentHTTP(t *testing.T) {
}
}
}
+
+func TestBookReplaySkipsMySQL(t *testing.T) {
+ testDB, err := gorm.Open(sqlite.Open("file:replayskip?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := testDB.AutoMigrate(&Ticket{}); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ if err := testDB.Create(&Ticket{ID: 77, State: "available"}).Error; err != nil {
+ t.Fatalf("seed row: %v", err)
+ }
+ db = testDB
+
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ redisClient = redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+ if err := redisClient.LPush(ctx, booking.KeyQueue, "77").Err(); err != nil {
+ t.Fatalf("lpush: %v", err)
+ }
+ alloc, err := booking.NewAllocator(ctx, redisClient)
+ if err != nil {
+ t.Fatalf("allocator: %v", err)
+ }
+ ticketAllocator = alloc
+
+ handler := apiKeyMiddleware(ticketHandler)
+ req := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ req.Header.Set(httpUserHeader, "55")
+ res := httptest.NewRecorder()
+ handler(res, req)
+ if res.Code != http.StatusOK {
+ t.Fatalf("first book status %d body %s", res.Code, res.Body.String())
+ }
+
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatalf("db handle: %v", err)
+ }
+ if err := sqlDB.Close(); err != nil {
+ t.Fatalf("close mysql: %v", err)
+ }
+
+ req2 := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ req2.Header.Set(httpUserHeader, "55")
+ res2 := httptest.NewRecorder()
+ handler(res2, req2)
+ if res2.Code != http.StatusOK {
+ t.Fatalf("replay after mysql close status %d body %s", res2.Code, res2.Body.String())
+ }
+}
+
+func TestBookEventuallyPersistsViaConsumer(t *testing.T) {
+ testDB, err := gorm.Open(sqlite.Open("file:bookpersist?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("sqlite: %v", err)
+ }
+ if err := testDB.AutoMigrate(&Ticket{}); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ const ticketID = 88
+ const userID = 12
+ if err := testDB.Create(&Ticket{ID: ticketID, State: "available"}).Error; err != nil {
+ t.Fatalf("seed row: %v", err)
+ }
+ db = testDB
+
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatalf("miniredis: %v", err)
+ }
+ redisClient = redis.NewClient(&redis.Options{Addr: mr.Addr()})
+ ctx := context.Background()
+ if err := redisClient.LPush(ctx, booking.KeyQueue, strconv.Itoa(ticketID)).Err(); err != nil {
+ t.Fatalf("lpush: %v", err)
+ }
+ alloc, err := booking.NewAllocator(ctx, redisClient)
+ if err != nil {
+ t.Fatalf("allocator: %v", err)
+ }
+ ticketAllocator = alloc
+ if err := persist.NewConsumer(redisClient, db, "persist-claim-test").EnsureGroup(ctx); err != nil {
+ t.Fatalf("group: %v", err)
+ }
+
+ handler := apiKeyMiddleware(ticketHandler)
+ req := httptest.NewRequest(http.MethodPost, "/book", bytes.NewReader(nil))
+ req.Header.Set(httpUserHeader, strconv.Itoa(userID))
+ res := httptest.NewRecorder()
+ handler(res, req)
+ if res.Code != http.StatusOK {
+ t.Fatalf("book status %d body %s", res.Code, res.Body.String())
+ }
+
+ consumer := persist.NewConsumer(redisClient, db, "persist-claim-test")
+ runCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ go consumer.Run(runCtx)
+
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ var state string
+ if err := testDB.Raw("SELECT state FROM tickets WHERE id = ?", ticketID).Scan(&state).Error; err != nil {
+ t.Fatalf("scan: %v", err)
+ }
+ if state == "sold" {
+ var uid *int
+ if err := testDB.Raw("SELECT user_id FROM tickets WHERE id = ?", ticketID).Scan(&uid).Error; err != nil {
+ t.Fatalf("user scan: %v", err)
+ }
+ if uid == nil || *uid != userID {
+ t.Fatalf("expected user_id %d, got %v", userID, uid)
+ }
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Fatal("ticket not persisted as sold within timeout")
+}
diff --git a/serve.go b/serve.go
new file mode 100644
index 0000000..d2aea86
--- /dev/null
+++ b/serve.go
@@ -0,0 +1,76 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "sync/atomic"
+ "syscall"
+ "time"
+)
+
+// bootstrapAPIServer initializes dependencies for HTTP serve mode. It does not start the persist consumer.
+func bootstrapAPIServer() {
+ log.Println("Starting system initialization...")
+ initMySQL()
+ initRedis()
+ initBookingAllocator()
+ prepareRuntimeData()
+}
+
+func runServeCommand() {
+ bootstrapAPIServer()
+
+ if httpPort == "" {
+ httpPort = "8080"
+ }
+
+ http.HandleFunc("/book", enableCORS(apiKeyMiddleware(ticketHandler)))
+ http.HandleFunc("/tickets/count", enableCORS(ticketCountHandler))
+ http.HandleFunc("/metrics", enableCORS(metricsHandler))
+ http.HandleFunc("/healthz", healthzHandler)
+ http.HandleFunc("/readyz", readyzHandler(nil))
+
+ go func() {
+ ticker := time.NewTicker(5 * time.Second)
+ defer ticker.Stop()
+ for range ticker.C {
+ reqs := atomic.LoadInt64(&totalRequests)
+ if reqs > 0 {
+ log.Printf("[metrics] requests=%d success=%d failures=%d",
+ reqs,
+ atomic.LoadInt64(&bookingSuccess),
+ atomic.LoadInt64(&bookingFailures),
+ )
+ }
+ }
+ }()
+
+ srv := &http.Server{
+ Addr: fmt.Sprintf(":%s", httpPort),
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ }
+
+ go func() {
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+ <-sigCh
+ log.Println("shutdown signal received, draining HTTP...")
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer shutdownCancel()
+ _ = srv.Shutdown(shutdownCtx)
+ }()
+
+ log.Printf("Server listening on port %s", httpPort)
+ log.Printf("Endpoints: POST /book | GET /tickets/count | GET /metrics | GET /healthz | GET /readyz")
+ log.Println("NOTE: serve mode does not run the persist consumer; run `go run . worker` to persist stream bookings to MySQL")
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("Server failed: %v", err)
+ }
+ log.Println("server stopped")
+}
diff --git a/verification-lab/cmd/verilab/main.go b/verification-lab/cmd/verilab/main.go
new file mode 100644
index 0000000..63f82ef
--- /dev/null
+++ b/verification-lab/cmd/verilab/main.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "log"
+ "os"
+
+ "ticketengine/verification-lab/internal/api"
+ "ticketengine/verification-lab/internal/claims"
+ "ticketengine/verification-lab/internal/metrics"
+ "ticketengine/verification-lab/internal/runner"
+ "ticketengine/verification-lab/internal/target"
+)
+
+func env(k, def string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return def
+}
+
+func main() {
+ rawTarget := env("VERILAB_TARGET", "http://127.0.0.1:8080")
+ base, err := target.BaseURL(rawTarget)
+ if err != nil {
+ log.Fatalf("target: %v", err)
+ }
+ mysqlHost := env("MYSQL_HOST", "127.0.0.1:3306")
+ dsn := metrics.DSN(mysqlHost, env("MYSQL_USER", "ticket"), env("MYSQL_PASSWORD", "ticket"), env("MYSQL_DATABASE", "ticketdb"))
+
+ repoRoot := env("VERILAB_REPO_ROOT", claims.DiscoverRepoRoot())
+ eng := runner.New(runner.Config{
+ TargetBase: base,
+ APIKey: os.Getenv("TICKET_API_KEY"),
+ RedisAddr: env("REDIS_HOST", "127.0.0.1:6379"),
+ MySQLDSN: dsn,
+ RepoRoot: repoRoot,
+ })
+ hub := metrics.NewHub(eng.Collector(), eng)
+ eng.Hub = hub
+
+ srv := &api.Server{
+ Addr: env("VERILAB_HTTP", ":8090"),
+ Engine: eng,
+ Hub: hub,
+ }
+ if err := srv.ListenAndServe(); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/verification-lab/internal/api/server.go b/verification-lab/internal/api/server.go
new file mode 100644
index 0000000..95187b6
--- /dev/null
+++ b/verification-lab/internal/api/server.go
@@ -0,0 +1,234 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "embed"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "ticketengine/verification-lab/internal/failurelab"
+ "ticketengine/verification-lab/internal/metrics"
+ "ticketengine/verification-lab/internal/runner"
+)
+
+//go:embed static_fallback/*
+var fallbackFS embed.FS
+
+type Server struct {
+ Addr string
+ Engine *runner.Engine
+ Hub *metrics.Hub
+}
+
+type runRequest struct {
+ Preset string `json:"preset"`
+ Claims []string `json:"claims"`
+ Gates []string `json:"gates"`
+ Scenarios []string `json:"scenarios"`
+ ConfirmHeavy bool `json:"confirm_heavy"`
+ ConfirmFault bool `json:"confirm_fault"`
+}
+
+func (s *Server) ListenAndServe() error {
+ if s.Hub != nil {
+ s.Hub.Start()
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/health", func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, map[string]string{"status": "ok"})
+ })
+ mux.HandleFunc("/api/snapshot", s.handleSnapshot)
+ mux.HandleFunc("/api/stream", s.handleSSE)
+ mux.HandleFunc("/api/runs", s.handleRuns)
+ mux.HandleFunc("/api/runs/", s.handleRunByID)
+ mux.HandleFunc("/api/failure-lab/scenarios", s.handleFailureLabCatalog)
+
+ staticHandler := s.staticHandler()
+ mux.Handle("/", staticHandler)
+
+ srv := &http.Server{Addr: s.Addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
+ log.Printf("verilab listening on %s (target %s)", s.Addr, s.Engine.Collector().TargetBase)
+ return srv.ListenAndServe()
+}
+
+func (s *Server) staticHandler() http.Handler {
+ if dir := os.Getenv("VERILAB_WEB_DIST"); dir != "" {
+ if st, err := os.Stat(dir); err == nil && st.IsDir() {
+ return http.FileServer(http.Dir(dir))
+ }
+ }
+ candidate := filepath.Join("verification-lab", "web", "dist")
+ if st, err := os.Stat(candidate); err == nil && st.IsDir() {
+ return http.FileServer(http.Dir(candidate))
+ }
+ sub, err := fs.Sub(fallbackFS, "static_fallback")
+ if err != nil {
+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "ui unavailable", 500)
+ })
+ }
+ return http.FileServer(http.FS(sub))
+}
+
+func (s *Server) latestSnapshot(ctx context.Context) metrics.Snapshot {
+ if s.Hub != nil {
+ snap := s.Hub.Latest()
+ if snap.Sequence > 0 {
+ snap.StaleMs = time.Since(snap.At).Milliseconds()
+ return snap
+ }
+ }
+ snap, _ := s.Engine.Collector().Collect(ctx)
+ return snap
+}
+
+func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ writeJSON(w, s.latestSnapshot(r.Context()))
+}
+
+func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming unsupported", 500)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ ctx := r.Context()
+ if s.Hub != nil {
+ ch := s.Hub.Subscribe(ctx)
+ for snap := range ch {
+ snap.StaleMs = time.Since(snap.At).Milliseconds()
+ b, _ := json.Marshal(snap)
+ _, _ = w.Write([]byte("event: snapshot\n"))
+ _, _ = w.Write([]byte("data: "))
+ _, _ = w.Write(b)
+ _, _ = w.Write([]byte("\n\n"))
+ flusher.Flush()
+ }
+ return
+ }
+
+ tick := time.NewTicker(200 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-tick.C:
+ snap := s.latestSnapshot(ctx)
+ b, _ := json.Marshal(snap)
+ _, _ = w.Write([]byte("event: snapshot\n"))
+ _, _ = w.Write([]byte("data: "))
+ _, _ = w.Write(b)
+ _, _ = w.Write([]byte("\n\n"))
+ flusher.Flush()
+ }
+ }
+}
+
+func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ var req runRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "bad json", 400)
+ return
+ }
+ ctx := context.Background()
+ var id string
+ var err error
+ switch strings.ToLower(req.Preset) {
+ case "smoke", "":
+ id, err = s.Engine.StartSmoke(ctx)
+ case "claims":
+ id, err = s.Engine.StartClaims(ctx, req.Claims)
+ case "gates", "phase-gates":
+ id, err = s.Engine.StartGates(ctx, req.Gates)
+ case "failure-lab":
+ if !req.ConfirmFault {
+ http.Error(w, "confirm_fault required for failure-lab preset", 400)
+ return
+ }
+ id, err = s.Engine.StartFailureLab(ctx, req.Scenarios)
+ case "1k", "load-1k":
+ id, err = s.Engine.StartBoundedLoad(ctx, "1k", 1_000)
+ case "10k", "load-10k":
+ id, err = s.Engine.StartBoundedLoad(ctx, "10k", 10_000)
+ case "25k", "load-25k":
+ id, err = s.Engine.StartBoundedLoad(ctx, "25k", 25_000)
+ case "50k", "load-50k":
+ id, err = s.Engine.StartBoundedLoad(ctx, "50k", 50_000)
+ case "heavy-100k", "100k":
+ if !req.ConfirmHeavy {
+ http.Error(w, "confirm_heavy required for 100k preset", 400)
+ return
+ }
+ id, err = s.Engine.StartHeavy(ctx)
+ case "benchmark-smoke", "benchmark-exhaustion-mini", "benchmark-saturated-15k":
+ id, err = s.Engine.StartBenchmark(ctx, strings.ToLower(req.Preset))
+ case "benchmark-exhaustion-15k":
+ if !req.ConfirmHeavy {
+ http.Error(w, "confirm_heavy required for benchmark-exhaustion-15k preset", 400)
+ return
+ }
+ id, err = s.Engine.StartBenchmark(ctx, "benchmark-exhaustion-15k")
+ default:
+ http.Error(w, "unknown preset", 400)
+ return
+ }
+ if err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ writeJSON(w, map[string]string{"run_id": id})
+}
+
+func (s *Server) handleRunByID(w http.ResponseWriter, r *http.Request) {
+ rest := strings.TrimPrefix(r.URL.Path, "/api/runs/")
+ if rest == "" {
+ http.NotFound(w, r)
+ return
+ }
+ parts := strings.Split(rest, "/")
+ id := parts[0]
+ export := len(parts) == 2 && parts[1] == "export"
+ rep, ok := s.Engine.Get(id)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if export {
+ w.Header().Set("Content-Disposition", "attachment; filename="+id+".json")
+ }
+ writeJSON(w, rep)
+}
+
+func (s *Server) handleFailureLabCatalog(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ writeJSON(w, failurelab.Catalog())
+}
+
+func writeJSON(w http.ResponseWriter, v interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ _ = enc.Encode(v)
+}
diff --git a/verification-lab/internal/api/static_fallback/index.html b/verification-lab/internal/api/static_fallback/index.html
new file mode 100644
index 0000000..e0bb98b
--- /dev/null
+++ b/verification-lab/internal/api/static_fallback/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+ Verification Lab
+
+
+
+
+ Verification Lab
+ Build the Vite UI: cd verification-lab/web && npm ci && npm run build
+ FALLBACK UI
+ Connecting…
+
+
+
+
diff --git a/verification-lab/internal/claims/gate_session_test.go b/verification-lab/internal/claims/gate_session_test.go
new file mode 100644
index 0000000..ed92e28
--- /dev/null
+++ b/verification-lab/internal/claims/gate_session_test.go
@@ -0,0 +1,47 @@
+package claims
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+)
+
+// TestGateSessionAll runs P1..P7 when VERILAB_GATE_SMOKE=1 (local/CI with repo + optional services).
+func TestGateSessionAll(t *testing.T) {
+ if os.Getenv("VERILAB_GATE_SMOKE") == "" {
+ t.Skip("set VERILAB_GATE_SMOKE=1 to run full gate session")
+ }
+ root := os.Getenv("VERILAB_REPO_ROOT")
+ if root == "" {
+ root = DiscoverRepoRoot()
+ }
+ dsn := os.Getenv("VERILAB_MYSQL_DSN")
+ if dsn == "" {
+ dsn = "ticket:ticket@tcp(127.0.0.1:3306)/ticketdb?charset=utf8mb4&parseTime=True&loc=Local"
+ }
+ target := os.Getenv("VERILAB_TARGET")
+ if target == "" {
+ target = "http://127.0.0.1:8080"
+ }
+ redis := os.Getenv("REDIS_HOST")
+ if redis == "" {
+ redis = "127.0.0.1:6379"
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+ results := RunGates(ctx, GateOpts{
+ RepoRoot: root,
+ TargetBase: target,
+ RedisAddr: redis,
+ MySQLDSN: dsn,
+ }, DefaultGateIDs)
+ for _, r := range results {
+ t.Logf("%s %s %s", r.ID, r.Status, r.Message)
+ }
+ for _, r := range results {
+ if r.Status == StatusFail {
+ t.Fatalf("gate %s failed: %s", r.ID, r.Message)
+ }
+ }
+}
diff --git a/verification-lab/internal/claims/gates.go b/verification-lab/internal/claims/gates.go
new file mode 100644
index 0000000..82feeda
--- /dev/null
+++ b/verification-lab/internal/claims/gates.go
@@ -0,0 +1,357 @@
+package claims
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// DefaultGateIDs is the full P1..P7 sequence.
+var DefaultGateIDs = []string{"P1", "P2", "P3", "P4", "P5", "P6", "P7"}
+
+// GateRegistry maps phase gate IDs to documented go test references.
+var GateRegistry = map[string][]string{
+ "P1": {"main:TestServeDoesNotStartConsumer", "main:TestWorkerCommandStartsConsumer"},
+ "P2": {"internal/persist:TestReclaimStalePending", "internal/persist:TestConsumerReadsOwnPending", "integration:TestPersistRecovery"},
+ "P3": {"internal/persist:TestMovesFailedMessageToDLQ", "integration:TestPersistDLQ"},
+ "P4": {"main:TestReadyzStreamPending", "integration:TestTwoWorkersNoDoubleSell"},
+ "P5": {"main:TestBookReplaySkipsMySQL"},
+ "P6": {"internal/reconcile:TestReconcileInventoryDrift", "integration:TestReconcileCLIReadOnly"},
+ "P7": {"main:TestAPIKey_Missing", "main:TestAPIKey_Invalid", "internal/security:TestAPIKeyValid_*"},
+}
+
+// GateOpts configures gate execution (go test subprocess + live probes).
+type GateOpts struct {
+ RepoRoot string
+ TargetBase string
+ APIKey string
+ RedisAddr string
+ MySQLDSN string
+ HTTP *http.Client
+}
+
+// RunGates executes selected phase gates.
+func RunGates(ctx context.Context, opts GateOpts, ids []string) []Result {
+ if len(ids) == 0 {
+ ids = DefaultGateIDs
+ }
+ out := make([]Result, 0, len(ids))
+ for _, id := range ids {
+ out = append(out, runGate(ctx, opts, id))
+ }
+ return out
+}
+
+func runGate(ctx context.Context, opts GateOpts, id string) Result {
+ now := time.Now().UTC().Format(time.RFC3339)
+ r := Result{
+ ID: id,
+ RegistryTests: GateRegistry[id],
+ Evidence: map[string]interface{}{"ran_at": now},
+ }
+ switch id {
+ case "P1":
+ return gateP1(ctx, opts, r)
+ case "P2":
+ return gateP2(ctx, opts, r)
+ case "P3":
+ return gateP3(ctx, opts, r)
+ case "P4":
+ return gateP4(ctx, opts, r)
+ case "P5":
+ return gateP5(ctx, opts, r)
+ case "P6":
+ return gateP6(ctx, opts, r)
+ case "P7":
+ return gateP7(ctx, opts, r)
+ default:
+ r.Status = StatusInconclusive
+ r.Message = "unknown gate id"
+ r.FailureReason = "unknown_gate"
+ return r
+ }
+}
+
+func gateP1(ctx context.Context, opts GateOpts, r Result) Result {
+ got := RunGoTest(ctx, opts.RepoRoot, ".", "TestServeDoesNotStartConsumer|TestWorkerCommandStartsConsumer", nil)
+ r.Evidence["go_test"] = got
+ if got.Skipped && opts.RepoRoot == "" {
+ return inconclusive(r, "repo root unavailable for go test", "repo_root_missing")
+ }
+ if got.Passed {
+ r.Status = StatusPass
+ r.Message = "serve vs worker consumer separation verified via unit tests"
+ return r
+ }
+ r.Status = StatusFail
+ r.Message = "serve/worker consumer tests failed"
+ r.FailureReason = "go_test_failed"
+ return r
+}
+
+func gateP2(ctx context.Context, opts GateOpts, r Result) Result {
+ unit := RunGoTest(ctx, opts.RepoRoot, "./internal/persist", "TestReclaimStalePending|TestConsumerReadsOwnPending", nil)
+ r.Evidence["unit_go_test"] = unit
+ if unit.Skipped && opts.RepoRoot == "" {
+ return inconclusive(r, "repo root unavailable", "repo_root_missing")
+ }
+ if !unit.Passed {
+ r.Status = StatusFail
+ r.Message = "PEL reclaim unit tests failed"
+ r.FailureReason = "go_test_failed"
+ return r
+ }
+ if !ServicesReachable(opts.RedisAddr, opts.MySQLDSN) {
+ r.Status = StatusInconclusive
+ r.Message = "unit reclaim PASS; integration TestPersistRecovery not run (Redis/MySQL unavailable)"
+ r.FailureReason = "integration_services_unavailable"
+ r.Evidence["integration"] = "skipped"
+ return r
+ }
+ integ := RunGoTest(ctx, opts.RepoRoot, "./integration", "TestPersistRecovery", []string{"-tags=integration"})
+ r.Evidence["integration_go_test"] = integ
+ if integ.Passed {
+ r.Status = StatusPass
+ r.Message = "reclaim unit + integration recovery verified"
+ return r
+ }
+ r.Status = StatusInconclusive
+ r.Message = "unit PASS; integration recovery did not pass (skipped or failed in env)"
+ r.FailureReason = "integration_inconclusive"
+ return r
+}
+
+func gateP3(ctx context.Context, opts GateOpts, r Result) Result {
+ unit := RunGoTest(ctx, opts.RepoRoot, "./internal/persist", "TestMovesFailedMessageToDLQ", nil)
+ r.Evidence["unit_go_test"] = unit
+ if unit.Skipped && opts.RepoRoot == "" {
+ return inconclusive(r, "repo root unavailable", "repo_root_missing")
+ }
+ if !unit.Passed {
+ r.Status = StatusFail
+ r.Message = "DLQ unit test failed"
+ r.FailureReason = "go_test_failed"
+ return r
+ }
+ if !ServicesReachable(opts.RedisAddr, opts.MySQLDSN) {
+ r.Status = StatusInconclusive
+ r.Message = "unit DLQ PASS; integration TestPersistDLQ not run (Redis/MySQL unavailable)"
+ r.FailureReason = "integration_services_unavailable"
+ return r
+ }
+ integ := RunGoTest(ctx, opts.RepoRoot, "./integration", "TestPersistDLQ", []string{"-tags=integration"})
+ r.Evidence["integration_go_test"] = integ
+ if integ.Passed {
+ r.Status = StatusPass
+ r.Message = "DLQ unit + integration verified"
+ return r
+ }
+ r.Status = StatusInconclusive
+ r.Message = "unit PASS; integration DLQ inconclusive in this environment"
+ r.FailureReason = "integration_inconclusive"
+ return r
+}
+
+func gateP4(ctx context.Context, opts GateOpts, r Result) Result {
+ unit := RunGoTest(ctx, opts.RepoRoot, ".", "TestReadyzStreamPending", nil)
+ r.Evidence["unit_go_test"] = unit
+ if unit.Skipped && opts.RepoRoot == "" {
+ return inconclusive(r, "repo root unavailable", "repo_root_missing")
+ }
+ if !unit.Passed {
+ r.Status = StatusFail
+ r.Message = "readyz pending unit test failed"
+ r.FailureReason = "go_test_failed"
+ return r
+ }
+
+ probe := probeReadyz(ctx, opts)
+ r.Evidence["live_readyz"] = probe
+ if probe["error"] == nil {
+ if code, ok := probe["http_status"].(int); ok && code == http.StatusOK {
+ r.Evidence["live_readyz_note"] = "target /readyz returned 200"
+ } else {
+ r.Evidence["live_readyz_note"] = "target /readyz not 200 (stack may be down)"
+ }
+ }
+
+ if !ServicesReachable(opts.RedisAddr, opts.MySQLDSN) {
+ r.Status = StatusInconclusive
+ r.Message = "readyz unit PASS; multi-worker TestTwoWorkersNoDoubleSell not run (Redis/MySQL unavailable)"
+ r.FailureReason = "multi_worker_unavailable"
+ return r
+ }
+ integ := RunGoTest(ctx, opts.RepoRoot, "./integration", "TestTwoWorkersNoDoubleSell", []string{"-tags=integration"})
+ r.Evidence["integration_go_test"] = integ
+ if integ.Passed {
+ r.Status = StatusPass
+ r.Message = "readyz unit + two-worker integration PASS"
+ return r
+ }
+ r.Status = StatusInconclusive
+ r.Message = "readyz unit PASS; two-worker integration did not pass in this env"
+ r.FailureReason = "integration_inconclusive"
+ return r
+}
+
+func gateP5(ctx context.Context, opts GateOpts, r Result) Result {
+ got := RunGoTest(ctx, opts.RepoRoot, ".", "TestBookReplaySkipsMySQL", nil)
+ r.Evidence["go_test"] = got
+ if got.Skipped && opts.RepoRoot == "" {
+ return inconclusive(r, "repo root unavailable", "repo_root_missing")
+ }
+ if got.Passed {
+ r.Status = StatusPass
+ r.Message = "Redis-only idempotent replay skips MySQL read"
+ return r
+ }
+ r.Status = StatusFail
+ r.Message = "TestBookReplaySkipsMySQL failed"
+ r.FailureReason = "go_test_failed"
+ return r
+}
+
+func gateP6(ctx context.Context, opts GateOpts, r Result) Result {
+ unit := RunGoTest(ctx, opts.RepoRoot, "./internal/reconcile", "TestReconcile", nil)
+ r.Evidence["unit_go_test"] = unit
+ if unit.Skipped && opts.RepoRoot == "" {
+ return inconclusive(r, "repo root unavailable", "repo_root_missing")
+ }
+ if !unit.Passed {
+ r.Status = StatusFail
+ r.Message = "reconcile unit tests failed"
+ r.FailureReason = "go_test_failed"
+ return r
+ }
+ if !ServicesReachable(opts.RedisAddr, opts.MySQLDSN) {
+ r.Status = StatusPass
+ r.Message = "reconcile unit PASS (integration CLI read-only not run; services unavailable)"
+ r.Evidence["integration"] = "skipped_no_services"
+ return r
+ }
+ integ := RunGoTest(ctx, opts.RepoRoot, "./integration", "TestReconcileCLIReadOnly", []string{"-tags=integration"})
+ r.Evidence["integration_go_test"] = integ
+ if integ.Passed {
+ r.Status = StatusPass
+ r.Message = "reconcile drift + read-only CLI integration PASS"
+ return r
+ }
+ r.Status = StatusInconclusive
+ r.Message = "unit PASS; TestReconcileCLIReadOnly inconclusive in env"
+ r.FailureReason = "integration_inconclusive"
+ return r
+}
+
+func gateP7(ctx context.Context, opts GateOpts, r Result) Result {
+ got := RunGoTest(ctx, opts.RepoRoot, ".", "TestAPIKey_", nil)
+ r.Evidence["go_test"] = got
+ if got.Skipped && opts.RepoRoot == "" {
+ return inconclusive(r, "repo root unavailable", "repo_root_missing")
+ }
+ if !got.Passed {
+ r.Status = StatusFail
+ r.Message = "API key handler tests failed"
+ r.FailureReason = "go_test_failed"
+ return r
+ }
+
+ probe := probeMissingAPIKey(ctx, opts)
+ r.Evidence["live_missing_key"] = probe
+ if opts.APIKey == "" {
+ r.Status = StatusPass
+ r.Message = "API key tests PASS; target lab has no TICKET_API_KEY for live deny probe"
+ r.Evidence["live_note"] = "open /book when key unset (dev default)"
+ return r
+ }
+ if probe["error"] != nil {
+ r.Status = StatusInconclusive
+ r.Message = "go test PASS; live missing-key probe failed"
+ r.FailureReason = "live_probe_error"
+ return r
+ }
+ code, _ := probe["http_status"].(int)
+ if code == http.StatusUnauthorized || code == http.StatusForbidden {
+ r.Status = StatusPass
+ r.Message = "API key tests + live missing-key denial PASS"
+ return r
+ }
+ r.Status = StatusInconclusive
+ r.Message = fmt.Sprintf("go test PASS; live missing-key returned HTTP %d (target may not enforce key)", code)
+ r.FailureReason = "live_probe_unexpected_status"
+ return r
+}
+
+func inconclusive(r Result, msg, reason string) Result {
+ r.Status = StatusInconclusive
+ r.Message = msg
+ r.FailureReason = reason
+ return r
+}
+
+func probeReadyz(ctx context.Context, opts GateOpts) map[string]interface{} {
+ client := opts.HTTP
+ if client == nil {
+ client = &http.Client{Timeout: 5 * time.Second}
+ }
+ url := strings.TrimRight(opts.TargetBase, "/") + "/readyz"
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return map[string]interface{}{"error": err.Error()}
+ }
+ res, err := client.Do(req)
+ if err != nil {
+ return map[string]interface{}{"error": err.Error(), "url": url}
+ }
+ defer res.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(res.Body, 4096))
+ var parsed interface{}
+ if json.Unmarshal(body, &parsed) == nil {
+ return map[string]interface{}{"http_status": res.StatusCode, "url": url, "body": parsed}
+ }
+ return map[string]interface{}{"http_status": res.StatusCode, "url": url, "body_text": strings.TrimSpace(string(body))}
+}
+
+func probeMissingAPIKey(ctx context.Context, opts GateOpts) map[string]interface{} {
+ client := opts.HTTP
+ if client == nil {
+ client = &http.Client{Timeout: 5 * time.Second}
+ }
+ url := strings.TrimRight(opts.TargetBase, "/") + "/book"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
+ if err != nil {
+ return map[string]interface{}{"error": err.Error()}
+ }
+ req.Header.Set("X-User-Id", "999777001")
+ res, err := client.Do(req)
+ if err != nil {
+ return map[string]interface{}{"error": err.Error(), "url": url}
+ }
+ defer res.Body.Close()
+ _, _ = io.ReadAll(io.LimitReader(res.Body, 512))
+ return map[string]interface{}{"http_status": res.StatusCode, "url": url}
+}
+
+// SummarizeGateRun returns overall run status and optional failure_reason for the report.
+func SummarizeGateRun(results []Result) (status string, failureReason string) {
+ hasFail := false
+ hasInconclusive := false
+ for _, r := range results {
+ switch r.Status {
+ case StatusFail:
+ hasFail = true
+ case StatusInconclusive:
+ hasInconclusive = true
+ }
+ }
+ if hasFail {
+ return "failed", "gate_failed"
+ }
+ if hasInconclusive {
+ return "completed", "gates_inconclusive"
+ }
+ return "completed", ""
+}
diff --git a/verification-lab/internal/claims/gates_test.go b/verification-lab/internal/claims/gates_test.go
new file mode 100644
index 0000000..3458231
--- /dev/null
+++ b/verification-lab/internal/claims/gates_test.go
@@ -0,0 +1,45 @@
+package claims
+
+import (
+ "testing"
+)
+
+func TestSummarizeGateRun(t *testing.T) {
+ passOnly := []Result{{ID: "P1", Status: StatusPass}}
+ st, reason := SummarizeGateRun(passOnly)
+ if st != "completed" || reason != "" {
+ t.Fatalf("pass: %s %s", st, reason)
+ }
+
+ mixed := []Result{
+ {ID: "P1", Status: StatusPass},
+ {ID: "P2", Status: StatusInconclusive},
+ }
+ st, reason = SummarizeGateRun(mixed)
+ if st != "completed" || reason != "gates_inconclusive" {
+ t.Fatalf("inconclusive: %s %s", st, reason)
+ }
+
+ fail := []Result{{ID: "P5", Status: StatusFail}}
+ st, reason = SummarizeGateRun(fail)
+ if st != "failed" || reason != "gate_failed" {
+ t.Fatalf("fail: %s %s", st, reason)
+ }
+}
+
+func TestGoTestOutputFailed(t *testing.T) {
+ if goTestOutputFailed("ok \tpackage\n") {
+ t.Fatal("expected pass output")
+ }
+ if !goTestOutputFailed("--- FAIL: TestFoo (0.00s)\nFAIL\n") {
+ t.Fatal("expected fail detection")
+ }
+}
+
+func TestGateRegistryKeys(t *testing.T) {
+ for _, id := range DefaultGateIDs {
+ if len(GateRegistry[id]) == 0 {
+ t.Fatalf("missing registry for %s", id)
+ }
+ }
+}
diff --git a/verification-lab/internal/claims/gotest.go b/verification-lab/internal/claims/gotest.go
new file mode 100644
index 0000000..f3264c3
--- /dev/null
+++ b/verification-lab/internal/claims/gotest.go
@@ -0,0 +1,161 @@
+package claims
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+)
+
+// GoTestOutcome is the result of a single go test invocation.
+type GoTestOutcome struct {
+ Command string `json:"go_test_command"`
+ ExitCode int `json:"exit_code"`
+ Passed bool `json:"passed"`
+ Skipped bool `json:"skipped"`
+ Output string `json:"output_tail"`
+}
+
+// RunGoTest runs `go test` in repoRoot for pkg with -run pattern.
+func RunGoTest(ctx context.Context, repoRoot, pkg, runPattern string, extraArgs []string) GoTestOutcome {
+ out := GoTestOutcome{Command: "", ExitCode: -1}
+ if repoRoot == "" {
+ out.Output = "VERILAB_REPO_ROOT not set and go.mod not found from cwd"
+ out.Skipped = true
+ return out
+ }
+ if ctx.Err() != nil {
+ out.Output = ctx.Err().Error()
+ return out
+ }
+
+ args := []string{"test", "-count=1", "-timeout", "3m"}
+ for _, a := range extraArgs {
+ args = append(args, a)
+ }
+ if runPattern != "" {
+ args = append(args, "-run", runPattern)
+ }
+ args = append(args, pkg)
+
+ cmd := exec.CommandContext(ctx, "go", args...)
+ cmd.Dir = repoRoot
+ cmd.Env = os.Environ()
+
+ var buf bytes.Buffer
+ cmd.Stdout = &buf
+ cmd.Stderr = &buf
+
+ out.Command = fmt.Sprintf("cd %s && go %s", repoRoot, strings.Join(args, " "))
+ err := cmd.Run()
+ if err == nil {
+ out.ExitCode = 0
+ } else if ee, ok := err.(*exec.ExitError); ok {
+ out.ExitCode = ee.ExitCode()
+ } else {
+ out.Output = tailLines(buf.String(), 40)
+ out.Output += "\n" + err.Error()
+ return out
+ }
+ text := buf.String()
+ out.Output = tailLines(text, 40)
+ out.Passed = out.ExitCode == 0 && !goTestOutputFailed(text)
+ out.Skipped = strings.Contains(text, "[no tests to run]") || strings.Contains(text, "build constraints exclude all Go files")
+ return out
+}
+
+func goTestOutputFailed(text string) bool {
+ if strings.Contains(text, "--- FAIL:") {
+ return true
+ }
+ lines := strings.Split(text, "\n")
+ for _, ln := range lines {
+ if strings.HasPrefix(ln, "FAIL\t") || strings.HasPrefix(ln, "FAIL ") {
+ return true
+ }
+ }
+ return false
+}
+
+func tailLines(s string, max int) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return ""
+ }
+ lines := strings.Split(s, "\n")
+ if len(lines) <= max {
+ return s
+ }
+ return strings.Join(lines[len(lines)-max:], "\n")
+}
+
+// DiscoverRepoRoot returns VERILAB_REPO_ROOT or the nearest directory containing go.mod.
+func DiscoverRepoRoot() string {
+ if v := os.Getenv("VERILAB_REPO_ROOT"); v != "" {
+ return v
+ }
+ cwd, err := os.Getwd()
+ if err != nil {
+ return ""
+ }
+ for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
+ return dir
+ }
+ }
+ return ""
+}
+
+// MySQLReachable reports whether the DSN accepts a ping.
+func MySQLReachable(mysqlDSN string) bool {
+ if mysqlDSN == "" {
+ return false
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ return pingMySQL(ctx, mysqlDSN)
+}
+
+// ServicesReachable reports whether Redis and MySQL DSN are likely usable for integration tests.
+func ServicesReachable(redisAddr, mysqlDSN string) bool {
+ if redisAddr == "" || mysqlDSN == "" {
+ return false
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if !pingRedis(ctx, redisAddr) {
+ return false
+ }
+ return pingMySQL(ctx, mysqlDSN)
+}
+
+func pingRedis(ctx context.Context, addr string) bool {
+ d := net.Dialer{Timeout: 2 * time.Second}
+ c, err := d.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return false
+ }
+ c.Close()
+ return true
+}
+
+func pingMySQL(ctx context.Context, dsn string) bool {
+ gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{SkipDefaultTransaction: true})
+ if err != nil {
+ return false
+ }
+ sqlDB, err := gdb.DB()
+ if err != nil {
+ return false
+ }
+ defer sqlDB.Close()
+ return sqlDB.PingContext(ctx) == nil
+}
diff --git a/verification-lab/internal/claims/gotest_test.go b/verification-lab/internal/claims/gotest_test.go
new file mode 100644
index 0000000..b3731b9
--- /dev/null
+++ b/verification-lab/internal/claims/gotest_test.go
@@ -0,0 +1,40 @@
+package claims
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestRunGoTestP5InRepo(t *testing.T) {
+ root := DiscoverRepoRoot()
+ if root == "" {
+ t.Skip("repo root not found")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
+ defer cancel()
+ out := RunGoTest(ctx, root, ".", "TestBookReplaySkipsMySQL", nil)
+ if !out.Passed {
+ t.Fatalf("expected pass: %+v\n%s", out, out.Output)
+ }
+ if out.Command == "" {
+ t.Fatal("expected command string")
+ }
+}
+
+func TestDiscoverRepoRootFromModule(t *testing.T) {
+ wd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // When tests run from verification-lab/internal/claims, discovery should find module root.
+ root := DiscoverRepoRoot()
+ if root == "" {
+ t.Skip("no go.mod ancestor from " + wd)
+ }
+ if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
+ t.Fatalf("invalid root %s: %v", root, err)
+ }
+}
diff --git a/verification-lab/internal/claims/registry.go b/verification-lab/internal/claims/registry.go
new file mode 100644
index 0000000..c5a4b89
--- /dev/null
+++ b/verification-lab/internal/claims/registry.go
@@ -0,0 +1,12 @@
+package claims
+
+// Registry maps lab claim IDs to underlying go test names (documentation + export).
+var Registry = map[string][]string{
+ "INV-1": {"internal/booking:TestConcurrentUsersUniqueTickets", "integration:TestTwoWorkersNoDoubleSell"},
+ "INV-2": {"internal/booking:TestAllocateOneUserOneTicket", "internal/booking:TestConcurrentIdempotentSameUser"},
+ "INV-3": {"main:TestBookHandlerIdempotentHTTP", "integration:TestIntegrationBookingIdempotency"},
+ "INV-4": {"integration:TestRestartDoesNotMintInventory", "internal/inventory:TestSeedDecision"},
+ "INV-5": {"integration:TestReconcileDrift", "internal/reconcile:TestReconcileInventoryDrift"},
+ "INV-6": {"main:TestBookEventuallyPersistsViaConsumer", "integration:TestPersistRecovery"},
+ "INV-7": {"internal/reconcile:TestReconcileOrphanAllocation", "internal/reconcile:TestReconcileLegitimatePendingNotFalsePositive"},
+}
diff --git a/verification-lab/internal/claims/scenarios.go b/verification-lab/internal/claims/scenarios.go
new file mode 100644
index 0000000..2b39923
--- /dev/null
+++ b/verification-lab/internal/claims/scenarios.go
@@ -0,0 +1,304 @@
+package claims
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+)
+
+const (
+ StatusPass = "PASS"
+ StatusFail = "FAIL"
+ StatusInconclusive = "INCONCLUSIVE"
+)
+
+type Result struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+ FailureReason string `json:"failure_reason,omitempty"`
+ Evidence map[string]interface{} `json:"evidence"`
+ RegistryTests []string `json:"registry_tests"`
+}
+
+type Client struct {
+ BaseURL string
+ APIKey string
+ HTTP *http.Client
+}
+
+type bookJSON struct {
+ Status string `json:"status"`
+ TicketID int `json:"ticket_id"`
+ UserID int `json:"user_id"`
+}
+
+// Book performs a single POST /book for lab scenarios.
+func (c Client) Book(ctx context.Context, userID int) (bookJSON, int, error) {
+ return c.book(ctx, userID)
+}
+
+func (c *Client) book(ctx context.Context, userID int) (bookJSON, int, error) {
+ client := c.HTTP
+ if client == nil {
+ client = &http.Client{Timeout: 5 * time.Second}
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/book", nil)
+ if err != nil {
+ return bookJSON{}, 0, err
+ }
+ req.Header.Set("X-User-Id", fmt.Sprintf("%d", userID))
+ if c.APIKey != "" {
+ req.Header.Set("X-API-Key", c.APIKey)
+ }
+ res, err := client.Do(req)
+ if err != nil {
+ return bookJSON{}, 0, err
+ }
+ defer res.Body.Close()
+ body, _ := io.ReadAll(res.Body)
+ var out bookJSON
+ _ = json.Unmarshal(body, &out)
+ return out, res.StatusCode, nil
+}
+
+// Run executes selected claim scenarios against a live target.
+func Run(ctx context.Context, c Client, ids []string) []Result {
+ out := make([]Result, 0, len(ids))
+ for _, id := range ids {
+ out = append(out, runOne(ctx, c, id))
+ }
+ return out
+}
+
+func runOne(ctx context.Context, c Client, id string) Result {
+ reg := Registry[id]
+ r := Result{ID: id, RegistryTests: reg, Evidence: map[string]interface{}{}}
+ switch id {
+ case "INV-1":
+ return runINV1(ctx, c, r)
+ case "INV-2":
+ return runINV2(ctx, c, r)
+ case "INV-3":
+ return runINV3(ctx, c, r)
+ case "INV-4":
+ return runINV4(ctx, c, r)
+ case "INV-5":
+ return runINV5(ctx, c, r)
+ case "INV-6":
+ return runINV6(ctx, c, r)
+ case "INV-7":
+ return runINV7(ctx, c, r)
+ default:
+ r.Status = StatusInconclusive
+ r.Message = "unknown claim id"
+ return r
+ }
+}
+
+func runINV1(ctx context.Context, c Client, r Result) Result {
+ const n = 32
+ base := 880_000 + int(time.Now().Unix()%1000)*100
+ seen := make(map[int]int)
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ errs := 0
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func(uid int) {
+ defer wg.Done()
+ b, code, err := c.book(ctx, uid)
+ if err != nil || code != http.StatusOK || b.TicketID == 0 {
+ errs++
+ return
+ }
+ mu.Lock()
+ seen[b.TicketID]++
+ mu.Unlock()
+ }(base + i)
+ }
+ wg.Wait()
+ r.Evidence["concurrent_users"] = n
+ r.Evidence["unique_tickets"] = len(seen)
+ r.Evidence["errors"] = errs
+ r.Evidence["duplicate_ticket_ids"] = duplicateKeys(seen)
+ if errs > 0 {
+ r.Status = StatusFail
+ r.Message = "one or more bookings failed"
+ return r
+ }
+ if len(seen) != n {
+ r.Status = StatusFail
+ r.Message = "ticket ids not unique across concurrent users"
+ return r
+ }
+ r.Status = StatusPass
+ r.Message = "concurrent users received distinct tickets"
+ return r
+}
+
+func runINV2(ctx context.Context, c Client, r Result) Result {
+ uid := 881_000 + int(time.Now().Unix()%10000)
+ const n = 16
+ var tickets []int
+ var tMu sync.Mutex
+ var wg sync.WaitGroup
+ errs := 0
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ b, code, err := c.book(ctx, uid)
+ if err != nil || code != http.StatusOK {
+ errs++
+ return
+ }
+ tMu.Lock()
+ tickets = append(tickets, b.TicketID)
+ tMu.Unlock()
+ }()
+ }
+ wg.Wait()
+ uniq := uniqueInts(tickets)
+ r.Evidence["user_id"] = uid
+ r.Evidence["attempts"] = n
+ r.Evidence["success_responses"] = len(tickets)
+ r.Evidence["unique_ticket_ids"] = uniq
+ r.Evidence["errors"] = errs
+ if errs > 0 && len(tickets) == 0 {
+ r.Status = StatusFail
+ r.Message = "all concurrent bookings for same user failed"
+ return r
+ }
+ if len(uniq) > 1 {
+ r.Status = StatusFail
+ r.Message = "same user received multiple ticket ids"
+ return r
+ }
+ r.Status = StatusPass
+ r.Message = "same user maps to at most one ticket"
+ return r
+}
+
+func runINV3(ctx context.Context, c Client, r Result) Result {
+ uid := 882_000 + int(time.Now().Unix()%10000)
+ first, c1, err1 := c.book(ctx, uid)
+ second, c2, err2 := c.book(ctx, uid)
+ r.Evidence["user_id"] = uid
+ r.Evidence["first_status"] = c1
+ r.Evidence["second_status"] = c2
+ r.Evidence["first_ticket"] = first.TicketID
+ r.Evidence["second_ticket"] = second.TicketID
+ if err1 != nil || err2 != nil || c1 != http.StatusOK || c2 != http.StatusOK {
+ r.Status = StatusFail
+ r.Message = "idempotent replay requests failed"
+ return r
+ }
+ if first.TicketID == 0 || first.TicketID != second.TicketID {
+ r.Status = StatusFail
+ r.Message = "retry did not return same ticket_id"
+ return r
+ }
+ r.Status = StatusPass
+ r.Message = "retry returned same booking result"
+ return r
+}
+
+func runINV4(ctx context.Context, c Client, r Result) Result {
+ // Lab cannot restart serve process safely here; compare row count stability via metrics + optional MySQL if wired in runner.
+ r.Status = StatusInconclusive
+ r.Message = "full serve restart not orchestrated by lab; use integration TestRestartDoesNotMintInventory"
+ r.Evidence["reason"] = "requires controlled process restart"
+ return r
+}
+
+func runINV5(ctx context.Context, c Client, r Result) Result {
+ // Exercised when runner passes mysql counts into evidence; without MySQL snapshot, inconclusive.
+ r.Status = StatusInconclusive
+ r.Message = "requires LIVE MySQL sold/available/total counts in run export"
+ r.Evidence["hint"] = "run with MYSQL_* env for verilab collector"
+ return r
+}
+
+func runINV6(ctx context.Context, c Client, r Result) Result {
+ uid := 883_000 + int(time.Now().Unix()%10000)
+ b, code, err := c.book(ctx, uid)
+ r.Evidence["user_id"] = uid
+ r.Evidence["http_status"] = code
+ r.Evidence["ticket_id"] = b.TicketID
+ if err != nil || code != http.StatusOK || b.TicketID == 0 {
+ r.Status = StatusFail
+ r.Message = "booking did not succeed"
+ return r
+ }
+ r.Status = StatusInconclusive
+ r.Message = "MySQL durability poll not available in this scenario-only call; see runner export mysql_after"
+ r.Evidence["next_step"] = "runner waits on sold row when DSN configured"
+ return r
+}
+
+func runINV7(ctx context.Context, c Client, r Result) Result {
+ r.Status = StatusInconclusive
+ r.Message = "orphan allocation injection not performed in shared lab env"
+ r.Evidence["reason"] = "safe failure lab not enabled (slice G)"
+ return r
+}
+
+func duplicateKeys(m map[int]int) []int {
+ var d []int
+ for k, v := range m {
+ if v > 1 {
+ d = append(d, k)
+ }
+ }
+ return d
+}
+
+func uniqueInts(in []int) []int {
+ seen := map[int]struct{}{}
+ var out []int
+ for _, v := range in {
+ if v == 0 {
+ continue
+ }
+ if _, ok := seen[v]; ok {
+ continue
+ }
+ seen[v] = struct{}{}
+ out = append(out, v)
+ }
+ return out
+}
+
+// EnrichMySQL updates INV-5/INV-6 results when runner has MySQL telemetry.
+func EnrichMySQL(results []Result, beforeSold, afterSold int64, ticketID int, soldSeen bool) []Result {
+ out := make([]Result, len(results))
+ for i, r := range results {
+ out[i] = r
+ switch r.ID {
+ case "INV-5":
+ out[i].Evidence["sold_before"] = beforeSold
+ out[i].Evidence["sold_after"] = afterSold
+ if beforeSold >= 0 && afterSold >= 0 && afterSold >= beforeSold {
+ out[i].Status = StatusPass
+ out[i].Message = "sold count monotonic; reconcile tests cover hard ceiling"
+ }
+ case "INV-6":
+ if ticketID > 0 {
+ out[i].Evidence["mysql_sold_observed"] = soldSeen
+ if soldSeen {
+ out[i].Status = StatusPass
+ out[i].Message = "ticket reached sold in MySQL within drain window"
+ } else {
+ out[i].Status = StatusFail
+ out[i].Message = "ticket not observed sold in MySQL before timeout"
+ }
+ }
+ }
+ }
+ return out
+}
diff --git a/verification-lab/internal/claims/scenarios_test.go b/verification-lab/internal/claims/scenarios_test.go
new file mode 100644
index 0000000..d7964c2
--- /dev/null
+++ b/verification-lab/internal/claims/scenarios_test.go
@@ -0,0 +1,50 @@
+package claims
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+)
+
+func TestINV3IdempotentReplay(t *testing.T) {
+ var ticket int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if ticket == 0 {
+ ticket = 42
+ }
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{
+ "status": "success",
+ "ticket_id": ticket,
+ "user_id": 1,
+ })
+ }))
+ defer srv.Close()
+
+ res := Run(context.Background(), Client{BaseURL: srv.URL}, []string{"INV-3"})
+ if len(res) != 1 || res[0].Status != StatusPass {
+ t.Fatalf("got %+v", res[0])
+ }
+}
+
+func TestINV1UniqueTickets(t *testing.T) {
+ var mu sync.Mutex
+ issued := map[int]int{}
+ next := 100
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ next++
+ tid := next
+ issued[tid]++
+ mu.Unlock()
+ _ = json.NewEncoder(w).Encode(map[string]interface{}{"status": "success", "ticket_id": tid})
+ }))
+ defer srv.Close()
+
+ res := Run(context.Background(), Client{BaseURL: srv.URL}, []string{"INV-1"})
+ if res[0].Status != StatusPass {
+ t.Fatalf("INV-1: %+v", res[0])
+ }
+}
diff --git a/verification-lab/internal/failurelab/failurelab.go b/verification-lab/internal/failurelab/failurelab.go
new file mode 100644
index 0000000..76d6f1e
--- /dev/null
+++ b/verification-lab/internal/failurelab/failurelab.go
@@ -0,0 +1,402 @@
+package failurelab
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+
+ "ticketengine/internal/reconcile"
+ "ticketengine/verification-lab/internal/claims"
+ "ticketengine/verification-lab/internal/metrics"
+)
+
+// DefaultScenarioIDs is the full safe failure-lab sequence.
+var DefaultScenarioIDs = []string{
+ "worker-unavailable",
+ "restart-inventory",
+ "dlq-growth",
+ "reconcile-after-stress",
+}
+
+// Registry maps scenario IDs to documented tests (claim registry alignment).
+var Registry = map[string][]string{
+ "worker-unavailable": {
+ "integration:TestPersistRecovery",
+ "health:GET /readyz stream pending",
+ },
+ "restart-inventory": {
+ "integration:TestRestartDoesNotMintInventory",
+ "internal/inventory:TestSeedDecision",
+ },
+ "dlq-growth": {
+ "internal/persist:TestMovesFailedMessageToDLQ",
+ "integration:TestPersistDLQ",
+ "gates:P3",
+ },
+ "reconcile-after-stress": {
+ "internal/reconcile:TestReconcileInventoryDrift",
+ "integration:TestReconcileCLIReadOnly",
+ "gates:P6",
+ },
+}
+
+// ScenarioMeta describes a failure-lab scenario for operators and the UI catalog.
+type ScenarioMeta struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Warning string `json:"warning"`
+ ManualSteps []string `json:"manual_steps,omitempty"`
+ SafeAuto bool `json:"safe_automated"`
+ Registry []string `json:"registry_tests"`
+}
+
+// Catalog returns static scenario metadata (no side effects).
+func Catalog() []ScenarioMeta {
+ return []ScenarioMeta{
+ {
+ ID: "worker-unavailable",
+ Title: "Worker stalled — stream pending grows",
+ Warning: "Stop the persist worker before running; lab only probes pending via Redis (no arbitrary writes).",
+ ManualSteps: []string{
+ "Stop `go run . worker` (or docker worker service) while keeping API + Redis up.",
+ "Run this scenario: one bounded POST /book, then compare stream pending before/after.",
+ "Restart the worker after inspection to drain the stream.",
+ },
+ SafeAuto: true,
+ Registry: Registry["worker-unavailable"],
+ },
+ {
+ ID: "restart-inventory",
+ Title: "Serve restart does not mint inventory (INV-4)",
+ Warning: "Automated path runs integration TestRestartDoesNotMintInventory when MySQL is reachable; full process restart remains manual.",
+ ManualSteps: []string{
+ "Note MySQL ticket row count (or lab snapshot mysql.total).",
+ "Restart API (`go run .`) without running `seed` again.",
+ "Confirm row count unchanged; compare with integration test output in evidence.",
+ },
+ SafeAuto: false,
+ Registry: Registry["restart-inventory"],
+ },
+ {
+ ID: "dlq-growth",
+ Title: "DLQ length (read-only)",
+ Warning: "Read-only Redis XLEN on bookings.dlq; inducing DLQ growth is covered by P3 go tests, not browser automation.",
+ ManualSteps: []string{},
+ SafeAuto: true,
+ Registry: Registry["dlq-growth"],
+ },
+ {
+ ID: "reconcile-after-stress",
+ Title: "Reconcile CLI after load (read-only)",
+ Warning: "Runs `go run . reconcile` read-only against current Redis/MySQL; drift reports INCONCLUSIVE, clean reports PASS.",
+ ManualSteps: []string{
+ "Optional: run a bounded load preset first to stress the stack.",
+ "Run this scenario to execute reconcile with no Redis mutations.",
+ },
+ SafeAuto: true,
+ Registry: Registry["reconcile-after-stress"],
+ },
+ }
+}
+
+// Opts configures failure-lab execution.
+type Opts struct {
+ RepoRoot string
+ TargetBase string
+ APIKey string
+ RedisAddr string
+ MySQLDSN string
+}
+
+// Run executes selected failure-lab scenarios.
+func Run(ctx context.Context, opts Opts, col metrics.Collector, ids []string) []claims.Result {
+ if len(ids) == 0 {
+ ids = DefaultScenarioIDs
+ }
+ out := make([]claims.Result, 0, len(ids))
+ for _, id := range ids {
+ out = append(out, runOne(ctx, opts, col, id))
+ }
+ return out
+}
+
+func runOne(ctx context.Context, opts Opts, col metrics.Collector, id string) claims.Result {
+ now := time.Now().UTC().Format(time.RFC3339)
+ r := claims.Result{
+ ID: id,
+ RegistryTests: Registry[id],
+ Evidence: map[string]interface{}{"ran_at": now},
+ }
+ switch id {
+ case "worker-unavailable":
+ return scenarioWorkerUnavailable(ctx, opts, col, r)
+ case "restart-inventory":
+ return scenarioRestartInventory(ctx, opts, r)
+ case "dlq-growth":
+ return scenarioDLQGrowth(ctx, opts, col, r)
+ case "reconcile-after-stress":
+ return scenarioReconcileAfterStress(ctx, opts, r)
+ default:
+ r.Status = claims.StatusInconclusive
+ r.Message = "unknown failure-lab scenario id"
+ r.FailureReason = "unknown_scenario"
+ return r
+ }
+}
+
+func scenarioWorkerUnavailable(ctx context.Context, opts Opts, col metrics.Collector, r claims.Result) claims.Result {
+ r.Evidence["manual_checklist"] = Catalog()[0].ManualSteps
+ before, _ := col.Collect(ctx)
+ pendingBefore := before.StreamPending
+ r.Evidence["stream_pending_before"] = pendingBefore
+ r.Evidence["worker_pending_before"] = before.Worker.Pending
+ r.Evidence["field_provenance"] = before.FieldProvenance
+
+ client := claims.Client{BaseURL: opts.TargetBase, APIKey: opts.APIKey}
+ uid := 890_000 + int(time.Now().Unix()%10000)
+ bookStart := time.Now()
+ b, code, err := client.Book(ctx, uid)
+ r.Evidence["probe_user_id"] = uid
+ r.Evidence["book_http_status"] = code
+ r.Evidence["book_ticket_id"] = b.TicketID
+ if err != nil {
+ r.Status = claims.StatusInconclusive
+ r.Message = "booking probe failed (target unreachable?)"
+ r.FailureReason = "book_probe_error"
+ r.Evidence["book_error"] = err.Error()
+ return r
+ }
+ if code != http.StatusOK || b.TicketID == 0 {
+ r.Status = claims.StatusInconclusive
+ r.Message = "booking did not succeed; cannot observe stream backlog"
+ r.FailureReason = "book_not_ok"
+ return r
+ }
+
+ time.Sleep(400 * time.Millisecond)
+ after, _ := col.Collect(ctx)
+ pendingAfter := after.StreamPending
+ r.Evidence["stream_pending_after"] = pendingAfter
+ r.Evidence["worker_pending_after"] = after.Worker.Pending
+ r.Evidence["book_latency_ms"] = time.Since(bookStart).Milliseconds()
+
+ delta := pendingAfter - pendingBefore
+ r.Evidence["stream_pending_delta"] = delta
+
+ workerLive := after.FieldProvenance["worker"] == metrics.Live
+ if after.FieldProvenance["stream_pending"] != metrics.Live {
+ r.Status = claims.StatusInconclusive
+ r.Message = "stream pending telemetry unavailable (Redis not wired to verilab?)"
+ r.FailureReason = "telemetry_unavailable"
+ return r
+ }
+
+ if delta > 0 {
+ r.Status = claims.StatusPass
+ r.Message = "stream pending increased after booking (worker likely stalled)"
+ return r
+ }
+
+ if workerLive && after.Worker.Pending == 0 && pendingAfter == 0 {
+ r.Status = claims.StatusInconclusive
+ r.Message = "pending did not grow — worker may still be draining; stop worker and re-run"
+ r.FailureReason = "worker_still_active"
+ return r
+ }
+
+ r.Status = claims.StatusInconclusive
+ r.Message = "pending unchanged; inconclusive without stopped worker"
+ r.FailureReason = "no_pending_growth"
+ return r
+}
+
+func scenarioRestartInventory(ctx context.Context, opts Opts, r claims.Result) claims.Result {
+ meta := Catalog()[1]
+ r.Evidence["manual_checklist"] = meta.ManualSteps
+
+ seed := claims.RunGoTest(ctx, opts.RepoRoot, "./internal/inventory", "TestSeedDecision", nil)
+ r.Evidence["unit_seed_decision"] = seed
+
+ if opts.RepoRoot == "" {
+ r.Status = claims.StatusInconclusive
+ r.Message = "repo root unavailable for restart inventory tests"
+ r.FailureReason = "repo_root_missing"
+ return r
+ }
+
+ if !claims.MySQLReachable(opts.MySQLDSN) {
+ if seed.Passed {
+ r.Status = claims.StatusInconclusive
+ r.Message = "seed decision unit PASS; integration restart test skipped (MySQL unavailable)"
+ r.Evidence["integration"] = "skipped_no_mysql"
+ return r
+ }
+ r.Status = claims.StatusFail
+ r.Message = "inventory unit tests failed"
+ r.FailureReason = "go_test_failed"
+ return r
+ }
+
+ integ := claims.RunGoTest(ctx, opts.RepoRoot, "./integration", "TestRestartDoesNotMintInventory", []string{"-tags=integration"})
+ r.Evidence["integration_go_test"] = integ
+ if integ.Passed {
+ r.Status = claims.StatusPass
+ r.Message = "simulated serve restarts do not mint rows (integration TestRestartDoesNotMintInventory)"
+ return r
+ }
+ if integ.Skipped {
+ r.Status = claims.StatusInconclusive
+ r.Message = "integration restart test skipped in this environment"
+ r.FailureReason = "integration_skipped"
+ return r
+ }
+ r.Status = claims.StatusInconclusive
+ r.Message = "integration restart test did not pass; use manual serve restart checklist"
+ r.FailureReason = "integration_failed"
+ return r
+}
+
+func scenarioDLQGrowth(ctx context.Context, opts Opts, col metrics.Collector, r claims.Result) claims.Result {
+ snap, _ := col.Collect(ctx)
+ r.Evidence["dlq_len"] = snap.Redis.DLQLen
+ r.Evidence["dlq_provenance"] = snap.FieldProvenance["redis"]
+ r.Evidence["p3_registry"] = Registry["dlq-growth"]
+
+ unit := claims.RunGoTest(ctx, opts.RepoRoot, "./internal/persist", "TestMovesFailedMessageToDLQ", nil)
+ r.Evidence["unit_go_test"] = unit
+
+ if snap.FieldProvenance["redis"] != metrics.Live && snap.Redis.DLQLen == 0 {
+ r.Status = claims.StatusInconclusive
+ r.Message = "DLQ length unavailable (Redis not reachable from verilab)"
+ r.FailureReason = "redis_unavailable"
+ return r
+ }
+
+ if opts.RepoRoot != "" && !unit.Passed && !unit.Skipped {
+ r.Status = claims.StatusFail
+ r.Message = "DLQ unit test failed"
+ r.FailureReason = "go_test_failed"
+ return r
+ }
+
+ r.Status = claims.StatusPass
+ r.Message = fmt.Sprintf("read-only DLQ len=%d; P3 covered by TestMovesFailedMessageToDLQ", snap.Redis.DLQLen)
+ if unit.Passed {
+ r.Evidence["p3_note"] = "unit DLQ move PASS"
+ } else if unit.Skipped {
+ r.Evidence["p3_note"] = "unit test skipped (no repo root)"
+ }
+ return r
+}
+
+func scenarioReconcileAfterStress(ctx context.Context, opts Opts, r claims.Result) claims.Result {
+ if opts.RepoRoot == "" {
+ r.Status = claims.StatusInconclusive
+ r.Message = "repo root unavailable for reconcile CLI"
+ r.FailureReason = "repo_root_missing"
+ return r
+ }
+ if !claims.ServicesReachable(opts.RedisAddr, opts.MySQLDSN) {
+ r.Status = claims.StatusInconclusive
+ r.Message = "Redis/MySQL unreachable; cannot run reconcile CLI"
+ r.FailureReason = "services_unavailable"
+ return r
+ }
+
+ out := runReconcileCLI(ctx, opts.RepoRoot)
+ r.Evidence["reconcile_cli"] = out
+ if out.ExitCode == reconcile.ExitOperational {
+ r.Status = claims.StatusFail
+ r.Message = "reconcile CLI operational error"
+ r.FailureReason = "reconcile_error"
+ return r
+ }
+ if out.ExitCode == reconcile.ExitDrift {
+ r.Status = claims.StatusInconclusive
+ r.Message = "reconcile reported drift (read-only); inspect output"
+ r.FailureReason = "reconcile_drift"
+ return r
+ }
+ if out.ExitCode == reconcile.ExitClean {
+ r.Status = claims.StatusPass
+ r.Message = "reconcile clean (no drift detected)"
+ return r
+ }
+ r.Status = claims.StatusInconclusive
+ r.Message = fmt.Sprintf("reconcile exited with code %d", out.ExitCode)
+ r.FailureReason = "reconcile_unexpected_exit"
+ return r
+}
+
+type reconcileCLIOutcome struct {
+ Command string `json:"command"`
+ ExitCode int `json:"exit_code"`
+ Output string `json:"output_tail"`
+}
+
+func runReconcileCLI(ctx context.Context, repoRoot string) reconcileCLIOutcome {
+ out := reconcileCLIOutcome{Command: fmt.Sprintf("cd %s && go run . reconcile", repoRoot), ExitCode: -1}
+ if ctx.Err() != nil {
+ out.Output = ctx.Err().Error()
+ return out
+ }
+ cmd := exec.CommandContext(ctx, "go", "run", ".", "reconcile")
+ cmd.Dir = repoRoot
+ cmd.Env = os.Environ()
+ var buf bytes.Buffer
+ cmd.Stdout = &buf
+ cmd.Stderr = &buf
+ err := cmd.Run()
+ text := buf.String()
+ out.Output = tailLines(text, 48)
+ if err == nil {
+ out.ExitCode = reconcile.ExitClean
+ return out
+ }
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ out.ExitCode = exitErr.ExitCode()
+ return out
+ }
+ out.Output = tailLines(text+"\n"+err.Error(), 48)
+ return out
+}
+
+func tailLines(s string, max int) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return ""
+ }
+ lines := strings.Split(s, "\n")
+ if len(lines) <= max {
+ return s
+ }
+ return strings.Join(lines[len(lines)-max:], "\n")
+}
+
+// SummarizeFailureRun returns overall run status for the failure-lab preset.
+func SummarizeFailureRun(results []claims.Result) (status string, failureReason string) {
+ hasFail := false
+ hasInconclusive := false
+ for _, r := range results {
+ switch r.Status {
+ case claims.StatusFail:
+ hasFail = true
+ case claims.StatusInconclusive:
+ hasInconclusive = true
+ }
+ }
+ if hasFail {
+ return "failed", "failure_lab_failed"
+ }
+ if hasInconclusive {
+ return "completed", "failure_lab_inconclusive"
+ }
+ return "completed", ""
+}
diff --git a/verification-lab/internal/failurelab/failurelab_test.go b/verification-lab/internal/failurelab/failurelab_test.go
new file mode 100644
index 0000000..35d65fa
--- /dev/null
+++ b/verification-lab/internal/failurelab/failurelab_test.go
@@ -0,0 +1,41 @@
+package failurelab
+
+import (
+ "testing"
+
+ "ticketengine/verification-lab/internal/claims"
+)
+
+func TestCatalogCoversDefaults(t *testing.T) {
+ cat := Catalog()
+ ids := map[string]bool{}
+ for _, m := range cat {
+ ids[m.ID] = true
+ }
+ for _, id := range DefaultScenarioIDs {
+ if !ids[id] {
+ t.Fatalf("catalog missing %s", id)
+ }
+ }
+}
+
+func TestSummarizeFailureRun(t *testing.T) {
+ st, reason := SummarizeFailureRun([]claims.Result{
+ {ID: "a", Status: claims.StatusPass},
+ })
+ if st != "completed" || reason != "" {
+ t.Fatalf("pass: %s %s", st, reason)
+ }
+ st, reason = SummarizeFailureRun([]claims.Result{
+ {ID: "a", Status: claims.StatusFail},
+ })
+ if st != "failed" || reason != "failure_lab_failed" {
+ t.Fatalf("fail: %s %s", st, reason)
+ }
+ st, reason = SummarizeFailureRun([]claims.Result{
+ {ID: "a", Status: claims.StatusInconclusive},
+ })
+ if st != "completed" || reason != "failure_lab_inconclusive" {
+ t.Fatalf("inconclusive: %s %s", st, reason)
+ }
+}
diff --git a/verification-lab/internal/loadgen/loadgen.go b/verification-lab/internal/loadgen/loadgen.go
new file mode 100644
index 0000000..5383b9e
--- /dev/null
+++ b/verification-lab/internal/loadgen/loadgen.go
@@ -0,0 +1,156 @@
+package loadgen
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// Options bounds concurrent workers and total logical booking attempts.
+type Options struct {
+ BaseURL string
+ Workers int
+ Total int
+ UserIDStart int
+ APIKey string
+ Timeout time.Duration
+}
+
+// Stats from a bounded load run.
+type Stats struct {
+ LogicalRequests int
+ HTTP200 int
+ HTTP404 int
+ OtherStatus int
+ Errors int
+ DurationMs int64
+ LatencyP50Ms int64
+ LatencyP95Ms int64
+ LatencyP99Ms int64
+}
+
+// Run issues POST /book with distinct X-User-Id per logical request (unless UserIDStart reused by caller).
+func Run(ctx context.Context, opt Options) (Stats, error) {
+ if opt.Workers <= 0 {
+ opt.Workers = 4
+ }
+ if opt.Total <= 0 {
+ return Stats{}, fmt.Errorf("loadgen: total must be positive")
+ }
+ if opt.Timeout <= 0 {
+ opt.Timeout = 10 * time.Second
+ }
+ client := &http.Client{
+ Timeout: opt.Timeout,
+ Transport: &http.Transport{
+ MaxIdleConns: opt.Workers * 2,
+ MaxIdleConnsPerHost: opt.Workers * 2,
+ MaxConnsPerHost: opt.Workers * 2,
+ },
+ }
+
+ var (
+ http200 int64
+ http404 int64
+ other int64
+ errors int64
+ latencies []int64
+ latMu sync.Mutex
+ )
+
+ start := time.Now()
+ jobs := make(chan int, opt.Workers*2)
+ var wg sync.WaitGroup
+
+ worker := func() {
+ defer wg.Done()
+ for uid := range jobs {
+ if ctx.Err() != nil {
+ return
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, opt.BaseURL+"/book", nil)
+ if err != nil {
+ atomic.AddInt64(&errors, 1)
+ continue
+ }
+ req.Header.Set("X-User-Id", fmt.Sprintf("%d", uid))
+ if opt.APIKey != "" {
+ req.Header.Set("X-API-Key", opt.APIKey)
+ }
+ t0 := time.Now()
+ res, err := client.Do(req)
+ ms := time.Since(t0).Milliseconds()
+ latMu.Lock()
+ latencies = append(latencies, ms)
+ latMu.Unlock()
+ if err != nil {
+ atomic.AddInt64(&errors, 1)
+ continue
+ }
+ io.Copy(io.Discard, res.Body)
+ res.Body.Close()
+ switch res.StatusCode {
+ case http.StatusOK:
+ atomic.AddInt64(&http200, 1)
+ case http.StatusNotFound:
+ atomic.AddInt64(&http404, 1)
+ default:
+ atomic.AddInt64(&other, 1)
+ }
+ }
+ }
+
+ for w := 0; w < opt.Workers; w++ {
+ wg.Add(1)
+ go worker()
+ }
+
+ go func() {
+ for i := 0; i < opt.Total; i++ {
+ if ctx.Err() != nil {
+ break
+ }
+ jobs <- opt.UserIDStart + i
+ }
+ close(jobs)
+ }()
+
+ wg.Wait()
+
+ sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
+ p50, p95, p99 := percentile(latencies, 50), percentile(latencies, 95), percentile(latencies, 99)
+
+ return Stats{
+ LogicalRequests: opt.Total,
+ HTTP200: int(atomic.LoadInt64(&http200)),
+ HTTP404: int(atomic.LoadInt64(&http404)),
+ OtherStatus: int(atomic.LoadInt64(&other)),
+ Errors: int(atomic.LoadInt64(&errors)),
+ DurationMs: time.Since(start).Milliseconds(),
+ LatencyP50Ms: p50,
+ LatencyP95Ms: p95,
+ LatencyP99Ms: p99,
+ }, nil
+}
+
+func percentile(sorted []int64, p int) int64 {
+ if len(sorted) == 0 {
+ return 0
+ }
+ if p <= 0 {
+ return sorted[0]
+ }
+ if p >= 100 {
+ return sorted[len(sorted)-1]
+ }
+ idx := (len(sorted)*p + 99) / 100
+ if idx >= len(sorted) {
+ idx = len(sorted) - 1
+ }
+ return sorted[idx]
+}
diff --git a/verification-lab/internal/loadgen/loadgen_test.go b/verification-lab/internal/loadgen/loadgen_test.go
new file mode 100644
index 0000000..f632bb9
--- /dev/null
+++ b/verification-lab/internal/loadgen/loadgen_test.go
@@ -0,0 +1,38 @@
+package loadgen
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+)
+
+func TestRunBounded(t *testing.T) {
+ var hits int64
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt64(&hits, 1)
+ if r.Header.Get("X-User-Id") == "" {
+ http.Error(w, "missing user", 400)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ st, err := Run(context.Background(), Options{
+ BaseURL: srv.URL,
+ Workers: 8,
+ Total: 50,
+ UserIDStart: 1000,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if st.LogicalRequests != 50 || st.HTTP200 != 50 || st.Errors != 0 {
+ t.Fatalf("stats: %+v hits=%d", st, hits)
+ }
+ if hits != 50 {
+ t.Fatalf("expected 50 hits, got %d", hits)
+ }
+}
diff --git a/verification-lab/internal/metrics/events.go b/verification-lab/internal/metrics/events.go
new file mode 100644
index 0000000..9151524
--- /dev/null
+++ b/verification-lab/internal/metrics/events.go
@@ -0,0 +1,74 @@
+package metrics
+
+import (
+ "sync"
+ "time"
+)
+
+const defaultRingCap = 200
+
+// TelemetryEvent is a single timeline entry (snapshot deltas or run lifecycle).
+type TelemetryEvent struct {
+ Seq uint64 `json:"seq"`
+ At time.Time `json:"at"`
+ Kind string `json:"kind"`
+ Message string `json:"message"`
+ RunID string `json:"run_id,omitempty"`
+ CorrelationID string `json:"correlation_id,omitempty"`
+ Provenance string `json:"provenance"`
+}
+
+// RingBuffer stores recent events in insertion order with a fixed capacity.
+type RingBuffer struct {
+ mu sync.Mutex
+ cap int
+ buf []TelemetryEvent
+ next uint64
+ start int
+ len int
+}
+
+func NewRingBuffer(cap int) *RingBuffer {
+ if cap <= 0 {
+ cap = defaultRingCap
+ }
+ return &RingBuffer{cap: cap, buf: make([]TelemetryEvent, cap)}
+}
+
+func (r *RingBuffer) Add(ev TelemetryEvent) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.next++
+ ev.Seq = r.next
+ if r.len < r.cap {
+ idx := (r.start + r.len) % r.cap
+ r.buf[idx] = ev
+ r.len++
+ return
+ }
+ r.buf[r.start] = ev
+ r.start = (r.start + 1) % r.cap
+}
+
+func (r *RingBuffer) SnapshotTail(n int) []TelemetryEvent {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if n <= 0 || r.len == 0 {
+ return nil
+ }
+ if n > r.len {
+ n = r.len
+ }
+ out := make([]TelemetryEvent, n)
+ for i := 0; i < n; i++ {
+ idx := (r.start + r.len - n + i) % r.cap
+ out[i] = r.buf[idx]
+ }
+ return out
+}
+
+func (r *RingBuffer) LastSeq() uint64 {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.next
+}
diff --git a/verification-lab/internal/metrics/events_test.go b/verification-lab/internal/metrics/events_test.go
new file mode 100644
index 0000000..71fe8b6
--- /dev/null
+++ b/verification-lab/internal/metrics/events_test.go
@@ -0,0 +1,22 @@
+package metrics
+
+import (
+ "testing"
+)
+
+func TestRingBufferCapAndTail(t *testing.T) {
+ r := NewRingBuffer(3)
+ for i := 0; i < 5; i++ {
+ r.Add(TelemetryEvent{Kind: "k", Message: string(rune('a' + i))})
+ }
+ tail := r.SnapshotTail(2)
+ if len(tail) != 2 {
+ t.Fatalf("tail len %d", len(tail))
+ }
+ if tail[0].Message != "d" || tail[1].Message != "e" {
+ t.Fatalf("tail order: %+v", tail)
+ }
+ if r.LastSeq() != 5 {
+ t.Fatalf("seq %d", r.LastSeq())
+ }
+}
diff --git a/verification-lab/internal/metrics/hub.go b/verification-lab/internal/metrics/hub.go
new file mode 100644
index 0000000..b8d4cd3
--- /dev/null
+++ b/verification-lab/internal/metrics/hub.go
@@ -0,0 +1,209 @@
+package metrics
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+// RunContextProvider supplies active lab run metadata for telemetry frames.
+type RunContextProvider interface {
+ ActiveRun() (runID, correlationID string)
+}
+
+// Hub polls the collector on a fixed interval and fans out enriched frames to SSE subscribers.
+type Hub struct {
+ Collector Collector
+ Interval time.Duration
+ Runs RunContextProvider
+
+ mu sync.RWMutex
+ latest Snapshot
+ sequence uint64
+ events *RingBuffer
+
+ prevTarget TargetMetrics
+ prevAt time.Time
+
+ stop chan struct{}
+ stopped chan struct{}
+ startOnce sync.Once
+}
+
+func NewHub(col Collector, runs RunContextProvider) *Hub {
+ return &Hub{
+ Collector: col,
+ Interval: 200 * time.Millisecond,
+ Runs: runs,
+ events: NewRingBuffer(defaultRingCap),
+ stop: make(chan struct{}),
+ stopped: make(chan struct{}),
+ }
+}
+
+func (h *Hub) Start() {
+ h.startOnce.Do(func() {
+ go h.loop()
+ })
+}
+
+func (h *Hub) Stop() {
+ close(h.stop)
+ <-h.stopped
+}
+
+func (h *Hub) Latest() Snapshot {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ return h.latest
+}
+
+func (h *Hub) Subscribe(ctx context.Context) <-chan Snapshot {
+ ch := make(chan Snapshot, 2)
+ go func() {
+ defer close(ch)
+ tick := time.NewTicker(h.Interval)
+ defer tick.Stop()
+ var lastSeq uint64
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-h.stop:
+ return
+ case <-tick.C:
+ snap := h.Latest()
+ if snap.Sequence == 0 {
+ continue
+ }
+ if snap.Sequence == lastSeq {
+ continue
+ }
+ lastSeq = snap.Sequence
+ snap.StaleMs = time.Since(snap.At).Milliseconds()
+ select {
+ case ch <- snap:
+ default:
+ }
+ }
+ }
+ }()
+ return ch
+}
+
+func (h *Hub) loop() {
+ tick := time.NewTicker(h.Interval)
+ defer tick.Stop()
+ defer close(h.stopped)
+
+ for {
+ select {
+ case <-h.stop:
+ return
+ case <-tick.C:
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ raw, err := h.Collector.Collect(ctx)
+ cancel()
+ if err != nil {
+ continue
+ }
+ frame := h.enrich(raw)
+ h.mu.Lock()
+ h.latest = frame
+ h.mu.Unlock()
+ }
+ }
+}
+
+func (h *Hub) enrich(raw Snapshot) Snapshot {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ h.sequence++
+ raw.Sequence = h.sequence
+ raw.Events = h.events.SnapshotTail(32)
+
+ if h.Runs != nil {
+ runID, corr := h.Runs.ActiveRun()
+ raw.ActiveRunID = runID
+ raw.CorrelationID = corr
+ }
+
+ // API rates from target metric deltas.
+ if raw.FieldProvenance["target_metrics"] == Live && !h.prevAt.IsZero() {
+ dt := raw.At.Sub(h.prevAt).Seconds()
+ if dt > 0 {
+ raw.APIRates = APIRates{
+ RequestsPerSec: float64(raw.TargetMetrics.TotalRequests-h.prevTarget.TotalRequests) / dt,
+ SuccessPerSec: float64(raw.TargetMetrics.BookingSuccess-h.prevTarget.BookingSuccess) / dt,
+ FailuresPerSec: float64(raw.TargetMetrics.BookingFailures-h.prevTarget.BookingFailures) / dt,
+ }
+ raw.FieldProvenance["api_rates"] = Derived
+ }
+ }
+ if raw.FieldProvenance["api_rates"] == "" {
+ raw.FieldProvenance["api_rates"] = Unavailable
+ }
+
+ // Persistence lag heuristic.
+ if raw.FieldProvenance["target_metrics"] == Live && raw.FieldProvenance["mysql"] == Live {
+ raw.PersistenceLag.SuccessGap = raw.TargetMetrics.BookingSuccess - raw.MySQL.Sold
+ raw.FieldProvenance["persistence_lag"] = Derived
+ if raw.APIRates.SuccessPerSec > 0.01 {
+ raw.PersistenceLag.EstimatedMs = float64(raw.Redis.StreamPending) / raw.APIRates.SuccessPerSec * 1000
+ }
+ } else {
+ raw.FieldProvenance["persistence_lag"] = Unavailable
+ }
+
+ h.emitDeltas(raw)
+ h.prevTarget = raw.TargetMetrics
+ h.prevAt = raw.At
+
+ raw.FieldProvenance["sequence"] = Derived
+ raw.FieldProvenance["events"] = Derived
+ return raw
+}
+
+func (h *Hub) emitDeltas(s Snapshot) {
+ prev := h.latest
+ if prev.Sequence == 0 {
+ return
+ }
+ runID := s.ActiveRunID
+ corr := s.CorrelationID
+ emit := func(kind, msg string) {
+ h.events.Add(TelemetryEvent{
+ At: s.At,
+ Kind: kind,
+ Message: msg,
+ RunID: runID,
+ CorrelationID: corr,
+ Provenance: Derived,
+ })
+ }
+ if s.Redis.StreamPending > prev.Redis.StreamPending+5 {
+ emit("redis", "stream pending increased")
+ }
+ if s.Redis.DLQLen > prev.Redis.DLQLen {
+ emit("redis", "DLQ length increased")
+ }
+ if s.PersistenceLag.SuccessGap > prev.PersistenceLag.SuccessGap+10 {
+ emit("persist", "success vs sold gap widened")
+ }
+}
+
+// EmitRunEvent records a LIVE lifecycle event from the runner.
+func (h *Hub) EmitRunEvent(kind, message, runID, correlationID string) {
+ if h == nil || h.events == nil {
+ return
+ }
+ h.events.Add(TelemetryEvent{
+ At: time.Now().UTC(),
+ Kind: kind,
+ Message: message,
+ RunID: runID,
+ CorrelationID: correlationID,
+ Provenance: Live,
+ })
+}
diff --git a/verification-lab/internal/metrics/snapshot.go b/verification-lab/internal/metrics/snapshot.go
new file mode 100644
index 0000000..292dc47
--- /dev/null
+++ b/verification-lab/internal/metrics/snapshot.go
@@ -0,0 +1,204 @@
+package metrics
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-redis/redis/v8"
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+
+ "ticketengine/internal/booking"
+ "ticketengine/internal/persist"
+)
+
+// Provenance labels for dashboard fields.
+const (
+ Live = "LIVE"
+ Derived = "DERIVED"
+ Unavailable = "UNAVAILABLE"
+)
+
+type TargetMetrics struct {
+ TotalRequests int64 `json:"total_requests"`
+ BookingSuccess int64 `json:"booking_success"`
+ BookingFailures int64 `json:"booking_failures"`
+}
+
+type APIRates struct {
+ RequestsPerSec float64 `json:"requests_per_sec"`
+ SuccessPerSec float64 `json:"success_per_sec"`
+ FailuresPerSec float64 `json:"failures_per_sec"`
+}
+
+type RedisState struct {
+ QueueLen int64 `json:"queue_len"`
+ UserHashLen int64 `json:"user_hash_len"`
+ StreamLen int64 `json:"stream_len"`
+ StreamPending int64 `json:"stream_pending"`
+ DLQLen int64 `json:"dlq_len"`
+}
+
+type WorkerStats struct {
+ Consumers int64 `json:"consumers"`
+ Pending int64 `json:"pending"`
+ LastDeliveredID string `json:"last_delivered_id,omitempty"`
+}
+
+type MySQLCounts struct {
+ Available int64 `json:"available"`
+ Sold int64 `json:"sold"`
+ Total int64 `json:"total"`
+}
+
+type PersistenceLag struct {
+ SuccessGap int64 `json:"success_gap"`
+ EstimatedMs float64 `json:"estimated_ms"`
+}
+
+// Snapshot is a single telemetry frame for SSE / REST.
+type Snapshot struct {
+ At time.Time `json:"at"`
+ Sequence uint64 `json:"sequence"`
+ CollectMs int64 `json:"collect_ms"`
+ StaleMs int64 `json:"stale_ms,omitempty"`
+ TargetBase string `json:"target_base"`
+ ActiveRunID string `json:"active_run_id,omitempty"`
+ CorrelationID string `json:"correlation_id,omitempty"`
+ TargetMetrics TargetMetrics `json:"target_metrics"`
+ APIRates APIRates `json:"api_rates"`
+ Redis RedisState `json:"redis"`
+ Worker WorkerStats `json:"worker"`
+ MySQL MySQLCounts `json:"mysql"`
+ PersistenceLag PersistenceLag `json:"persistence_lag"`
+ Events []TelemetryEvent `json:"events"`
+ FieldProvenance map[string]string `json:"field_provenance"`
+
+ // Legacy flat fields (UI compat).
+ QueueRemaining int64 `json:"queue_remaining"`
+ StreamPending int64 `json:"stream_pending"`
+}
+
+type Collector struct {
+ TargetBase string
+ HTTPClient *http.Client
+ RedisAddr string
+ MySQLDSN string
+}
+
+func (c Collector) Collect(ctx context.Context) (Snapshot, error) {
+ t0 := time.Now()
+ snap := Snapshot{
+ At: time.Now().UTC(),
+ TargetBase: c.TargetBase,
+ FieldProvenance: map[string]string{},
+ }
+ if c.HTTPClient == nil {
+ c.HTTPClient = &http.Client{Timeout: 2 * time.Second}
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.TargetBase+"/metrics", nil)
+ if err == nil {
+ res, err := c.HTTPClient.Do(req)
+ if err == nil && res.StatusCode == http.StatusOK {
+ var m TargetMetrics
+ if json.NewDecoder(res.Body).Decode(&m) == nil {
+ snap.TargetMetrics = m
+ snap.FieldProvenance["target_metrics"] = Live
+ }
+ res.Body.Close()
+ }
+ }
+ if snap.FieldProvenance["target_metrics"] == "" {
+ snap.FieldProvenance["target_metrics"] = Unavailable
+ }
+
+ if c.RedisAddr != "" {
+ rdb := redis.NewClient(&redis.Options{Addr: c.RedisAddr})
+ defer rdb.Close()
+ if err := rdb.Ping(ctx).Err(); err == nil {
+ if n, err := rdb.LLen(ctx, booking.KeyQueue).Result(); err == nil {
+ snap.Redis.QueueLen = n
+ snap.QueueRemaining = n
+ snap.FieldProvenance["queue_remaining"] = Live
+ snap.FieldProvenance["redis"] = Live
+ }
+ if n, err := rdb.HLen(ctx, booking.KeyUserBooking).Result(); err == nil {
+ snap.Redis.UserHashLen = n
+ }
+ if n, err := rdb.XLen(ctx, persist.StreamKey).Result(); err == nil {
+ snap.Redis.StreamLen = n
+ }
+ if pending, err := persist.GroupPendingCountRDB(ctx, rdb); err == nil {
+ snap.Redis.StreamPending = pending
+ snap.StreamPending = pending
+ snap.FieldProvenance["stream_pending"] = Live
+ }
+ if n, err := rdb.XLen(ctx, persist.DLQStreamKey).Result(); err == nil {
+ snap.Redis.DLQLen = n
+ }
+ groups, err := rdb.XInfoGroups(ctx, persist.StreamKey).Result()
+ if err == nil {
+ for _, g := range groups {
+ if g.Name == persist.ConsumerGroup {
+ snap.Worker.Consumers = g.Consumers
+ snap.Worker.Pending = g.Pending
+ snap.Worker.LastDeliveredID = g.LastDeliveredID
+ snap.FieldProvenance["worker"] = Live
+ break
+ }
+ }
+ }
+ if snap.FieldProvenance["worker"] == "" && snap.FieldProvenance["stream_pending"] == Live {
+ snap.Worker.Pending = snap.Redis.StreamPending
+ snap.FieldProvenance["worker"] = Live
+ }
+ }
+ }
+ if snap.FieldProvenance["queue_remaining"] == "" {
+ snap.FieldProvenance["queue_remaining"] = Unavailable
+ }
+ if snap.FieldProvenance["stream_pending"] == "" {
+ snap.FieldProvenance["stream_pending"] = Unavailable
+ }
+ if snap.FieldProvenance["redis"] == "" {
+ snap.FieldProvenance["redis"] = Unavailable
+ }
+ if snap.FieldProvenance["worker"] == "" {
+ snap.FieldProvenance["worker"] = Unavailable
+ }
+
+ if c.MySQLDSN != "" {
+ gdb, err := gorm.Open(mysql.Open(c.MySQLDSN), &gorm.Config{SkipDefaultTransaction: true})
+ if err == nil {
+ sqlDB, _ := gdb.DB()
+ if sqlDB != nil {
+ defer sqlDB.Close()
+ }
+ var available, sold, total int64
+ if err := gdb.Raw("SELECT COUNT(*) FROM tickets WHERE state = 'available'").Scan(&available).Error; err == nil {
+ if err := gdb.Raw("SELECT COUNT(*) FROM tickets WHERE state = 'sold'").Scan(&sold).Error; err == nil {
+ if err := gdb.Raw("SELECT COUNT(*) FROM tickets").Scan(&total).Error; err == nil {
+ snap.MySQL = MySQLCounts{Available: available, Sold: sold, Total: total}
+ snap.FieldProvenance["mysql"] = Live
+ }
+ }
+ }
+ }
+ }
+ if snap.FieldProvenance["mysql"] == "" {
+ snap.FieldProvenance["mysql"] = Unavailable
+ }
+
+ snap.CollectMs = time.Since(t0).Milliseconds()
+ snap.FieldProvenance["collect_ms"] = Derived
+ return snap, nil
+}
+
+// DSN builds a MySQL DSN from standard ticket env pieces.
+func DSN(host, user, pass, database string) string {
+ return fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, pass, host, database)
+}
diff --git a/verification-lab/internal/metrics/snapshot_test.go b/verification-lab/internal/metrics/snapshot_test.go
new file mode 100644
index 0000000..7568c00
--- /dev/null
+++ b/verification-lab/internal/metrics/snapshot_test.go
@@ -0,0 +1,87 @@
+package metrics
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/go-redis/redis/v8"
+ "ticketengine/internal/booking"
+ "ticketengine/internal/persist"
+)
+
+func TestCollectorRedisAndWorkerLive(t *testing.T) {
+ mr, err := miniredis.Run()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer mr.Close()
+
+ ctx := context.Background()
+ rdbAddr := mr.Addr()
+ rdb := redis.NewClient(&redis.Options{Addr: rdbAddr})
+ defer rdb.Close()
+ _ = rdb.LPush(ctx, booking.KeyQueue, "1", "2").Err()
+ _ = rdb.HSet(ctx, booking.KeyUserBooking, "7", "1").Err()
+ _ = rdb.XGroupCreateMkStream(ctx, persist.StreamKey, persist.ConsumerGroup, "0").Err()
+ _ = rdb.XAdd(ctx, &redis.XAddArgs{Stream: persist.StreamKey, Values: map[string]interface{}{"ticket_id": "1"}}).Err()
+
+ metricsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _ = json.NewEncoder(w).Encode(TargetMetrics{TotalRequests: 10, BookingSuccess: 8, BookingFailures: 2})
+ }))
+ defer metricsSrv.Close()
+
+ col := Collector{TargetBase: metricsSrv.URL, RedisAddr: rdbAddr}
+ snap, err := col.Collect(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snap.FieldProvenance["target_metrics"] != Live {
+ t.Fatalf("metrics prov %s", snap.FieldProvenance["target_metrics"])
+ }
+ if snap.Redis.QueueLen != 2 {
+ t.Fatalf("queue %d", snap.Redis.QueueLen)
+ }
+ if snap.Redis.UserHashLen != 1 {
+ t.Fatalf("hash %d", snap.Redis.UserHashLen)
+ }
+ if snap.FieldProvenance["worker"] != Live {
+ t.Fatalf("worker prov %s", snap.FieldProvenance["worker"])
+ }
+ if snap.Worker.Pending < 0 {
+ t.Fatalf("worker pending %d", snap.Worker.Pending)
+ }
+}
+
+func TestHubSubscribeCancels(t *testing.T) {
+ metricsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _ = json.NewEncoder(w).Encode(TargetMetrics{})
+ }))
+ defer metricsSrv.Close()
+
+ h := NewHub(Collector{TargetBase: metricsSrv.URL}, nil)
+ h.Interval = 50 * time.Millisecond
+ h.Start()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ ch := h.Subscribe(ctx)
+ cancel()
+ deadline := time.Now().Add(500 * time.Millisecond)
+ for time.Now().Before(deadline) {
+ select {
+ case _, ok := <-ch:
+ if !ok {
+ h.Stop()
+ return
+ }
+ default:
+ time.Sleep(10 * time.Millisecond)
+ }
+ }
+ h.Stop()
+ t.Fatal("subscribe channel did not close after cancel")
+}
diff --git a/verification-lab/internal/runner/benchmark.go b/verification-lab/internal/runner/benchmark.go
new file mode 100644
index 0000000..096b103
--- /dev/null
+++ b/verification-lab/internal/runner/benchmark.go
@@ -0,0 +1,102 @@
+package runner
+
+import (
+ "context"
+ "time"
+)
+
+// Standard V2 lab seed inventory (see docs/BENCHMARKS.md); used for report assumptions only.
+const AssumedInventorySeed = 15_000
+
+// BenchmarkProfile is a named verification-lab load profile.
+type BenchmarkProfile struct {
+ Preset string
+ Total int
+ Workers int
+ Mode string
+ AssumedInventorySeed int64
+ Timeout time.Duration
+ Description string
+}
+
+func benchmarkProfiles() map[string]BenchmarkProfile {
+ return map[string]BenchmarkProfile{
+ "benchmark-smoke": {
+ Preset: "benchmark-smoke",
+ Total: 100,
+ Workers: 8,
+ Mode: WorkloadAllSuccess,
+ AssumedInventorySeed: AssumedInventorySeed,
+ Description: "100 logical bookings; expects all HTTP 200 on a healthy target.",
+ },
+ "benchmark-exhaustion-15k": {
+ Preset: "benchmark-exhaustion-15k",
+ Total: 100_000,
+ Workers: 32,
+ Mode: WorkloadExhaustion,
+ AssumedInventorySeed: AssumedInventorySeed,
+ Timeout: 15 * time.Second,
+ Description: "100k logical attempts against a 15k-ticket seed; pass uses queue/inventory rules (not http_200 count).",
+ },
+ "benchmark-exhaustion-mini": {
+ Preset: "benchmark-exhaustion-mini",
+ Total: 5_000,
+ Workers: 16,
+ Mode: WorkloadExhaustion,
+ AssumedInventorySeed: AssumedInventorySeed,
+ Timeout: 15 * time.Second,
+ Description: "5k logical attempts for quick exhaustion sanity on a 15k seed.",
+ },
+ "benchmark-saturated-15k": {
+ Preset: "benchmark-saturated-15k",
+ Total: 15_000,
+ Workers: 24,
+ Mode: WorkloadAllSuccess,
+ AssumedInventorySeed: AssumedInventorySeed,
+ Timeout: 20 * time.Second,
+ Description: "Exactly 15k distinct users; expects all HTTP 200 when inventory is full at start.",
+ },
+ }
+}
+
+func IsBenchmarkPreset(preset string) bool {
+ _, ok := benchmarkProfiles()[preset]
+ return ok
+}
+
+// StartBenchmark runs a named benchmark profile.
+func (e *Engine) StartBenchmark(ctx context.Context, preset string) (string, error) {
+ prof, ok := benchmarkProfiles()[preset]
+ if !ok {
+ return "", errUnknownBenchmarkPreset(preset)
+ }
+ workers := prof.Workers
+ if workers <= 0 {
+ workers = boundedWorkers(prof.Total)
+ }
+ timeout := prof.Timeout
+ if timeout <= 0 {
+ timeout = 10 * time.Second
+ }
+ userBase := freshUserIDBase(prof.Total)
+ if prof.Mode == WorkloadAllSuccess && preset == "benchmark-smoke" {
+ userBase = 700_000
+ }
+ return e.startLoad(ctx, loadPresetSpec{
+ Preset: prof.Preset,
+ Total: prof.Total,
+ UserIDStart: userBase,
+ Workers: workers,
+ Timeout: timeout,
+ }, prof.Mode, &prof)
+}
+
+type unknownBenchmarkPreset string
+
+func (e unknownBenchmarkPreset) Error() string {
+ return "unknown benchmark preset: " + string(e)
+}
+
+func errUnknownBenchmarkPreset(preset string) error {
+ return unknownBenchmarkPreset(preset)
+}
diff --git a/verification-lab/internal/runner/benchmark_summary.go b/verification-lab/internal/runner/benchmark_summary.go
new file mode 100644
index 0000000..713adff
--- /dev/null
+++ b/verification-lab/internal/runner/benchmark_summary.go
@@ -0,0 +1,100 @@
+package runner
+
+import (
+ "math"
+
+ "ticketengine/verification-lab/internal/loadgen"
+ "ticketengine/verification-lab/internal/metrics"
+)
+
+// BenchmarkSummary is exported under report.extra["benchmark"] for benchmark presets.
+type BenchmarkSummary struct {
+ Profile string `json:"profile"`
+ Description string `json:"description,omitempty"`
+ AssumedInventorySeed int64 `json:"assumed_inventory_seed"`
+ WorkloadMode string `json:"workload_mode"`
+ RequestsPerSec float64 `json:"requests_per_sec,omitempty"`
+ LatencyMs map[string]int64 `json:"latency_ms,omitempty"`
+ QueueConsumed *int64 `json:"queue_consumed,omitempty"`
+ StreamPendingEnd *int64 `json:"stream_pending_end,omitempty"`
+ MySQLSoldDelta *int64 `json:"mysql_sold_delta,omitempty"`
+ TelemetryOverheadNote string `json:"telemetry_overhead_note"`
+}
+
+const benchmarkTelemetryNote = "RPS and client latency percentiles come from the lab load generator only; they include HTTP round-trip and exclude snapshot collection (collect_ms on SSE frames)."
+
+func attachBenchmarkSummary(rep *Report, prof *BenchmarkProfile, st loadgen.Stats, startSnap, endSnap *metrics.Snapshot) {
+ if rep == nil || !IsBenchmarkPreset(rep.Preset) {
+ return
+ }
+ if rep.Extra == nil {
+ rep.Extra = map[string]interface{}{}
+ }
+ sum := BenchmarkSummary{
+ Profile: rep.Preset,
+ WorkloadMode: workloadModeFromExtra(rep.Extra),
+ TelemetryOverheadNote: benchmarkTelemetryNote,
+ }
+ if prof != nil {
+ sum.Description = prof.Description
+ sum.AssumedInventorySeed = prof.AssumedInventorySeed
+ sum.WorkloadMode = prof.Mode
+ } else if v, ok := rep.Extra["assumed_inventory_seed"]; ok {
+ switch n := v.(type) {
+ case int64:
+ sum.AssumedInventorySeed = n
+ case int:
+ sum.AssumedInventorySeed = int64(n)
+ case float64:
+ sum.AssumedInventorySeed = int64(n)
+ }
+ }
+ if st.DurationMs > 0 {
+ sum.RequestsPerSec = round2(float64(st.LogicalRequests) / (float64(st.DurationMs) / 1000))
+ }
+ lat := map[string]int64{}
+ if st.LatencyP50Ms > 0 {
+ lat["p50"] = st.LatencyP50Ms
+ }
+ if st.LatencyP95Ms > 0 {
+ lat["p95"] = st.LatencyP95Ms
+ }
+ if st.LatencyP99Ms > 0 {
+ lat["p99"] = st.LatencyP99Ms
+ }
+ if len(lat) > 0 {
+ sum.LatencyMs = lat
+ }
+ if consumed := queueConsumedFromSnapshots(startSnap, endSnap); consumed != nil {
+ sum.QueueConsumed = consumed
+ }
+ if endSnap != nil && endSnap.FieldProvenance["stream_pending"] == metrics.Live {
+ v := endSnap.StreamPending
+ sum.StreamPendingEnd = &v
+ }
+ if startSnap != nil && endSnap != nil &&
+ startSnap.FieldProvenance["mysql"] == metrics.Live &&
+ endSnap.FieldProvenance["mysql"] == metrics.Live {
+ delta := endSnap.MySQL.Sold - startSnap.MySQL.Sold
+ sum.MySQLSoldDelta = &delta
+ }
+ rep.Extra["benchmark"] = sum
+ if sum.AssumedInventorySeed > 0 {
+ rep.Extra["assumed_inventory_seed"] = sum.AssumedInventorySeed
+ rep.Extra["inventory_assumption_note"] = "Pass/fail for exhaustion profiles uses LIVE queue depth when available; assumed_inventory_seed documents the standard 15k lab seed, not live inventory."
+ }
+}
+
+func workloadModeFromExtra(extra map[string]interface{}) string {
+ if extra == nil {
+ return ""
+ }
+ if v, ok := extra["workload_mode"].(string); ok {
+ return v
+ }
+ return ""
+}
+
+func round2(f float64) float64 {
+ return math.Round(f*100) / 100
+}
diff --git a/verification-lab/internal/runner/benchmark_summary_test.go b/verification-lab/internal/runner/benchmark_summary_test.go
new file mode 100644
index 0000000..36a8017
--- /dev/null
+++ b/verification-lab/internal/runner/benchmark_summary_test.go
@@ -0,0 +1,71 @@
+package runner
+
+import (
+ "testing"
+
+ "ticketengine/verification-lab/internal/loadgen"
+ "ticketengine/verification-lab/internal/metrics"
+)
+
+func TestAttachBenchmarkSummary_shapesExport(t *testing.T) {
+ start := &metrics.Snapshot{
+ QueueRemaining: 15_000,
+ StreamPending: 2,
+ MySQL: metrics.MySQLCounts{Sold: 100},
+ FieldProvenance: map[string]string{
+ "queue_remaining": metrics.Live,
+ "stream_pending": metrics.Live,
+ "mysql": metrics.Live,
+ },
+ }
+ end := &metrics.Snapshot{
+ QueueRemaining: 14_900,
+ StreamPending: 0,
+ MySQL: metrics.MySQLCounts{Sold: 200},
+ FieldProvenance: map[string]string{
+ "queue_remaining": metrics.Live,
+ "stream_pending": metrics.Live,
+ "mysql": metrics.Live,
+ },
+ }
+ prof := benchmarkProfiles()["benchmark-smoke"]
+ rep := &Report{
+ Preset: "benchmark-smoke",
+ Extra: map[string]interface{}{"workload_mode": WorkloadAllSuccess},
+ }
+ st := loadgen.Stats{
+ LogicalRequests: 100,
+ HTTP200: 100,
+ DurationMs: 1000,
+ LatencyP50Ms: 5,
+ LatencyP95Ms: 12,
+ LatencyP99Ms: 20,
+ }
+ attachBenchmarkSummary(rep, &prof, st, start, end)
+
+ bm, ok := rep.Extra["benchmark"].(BenchmarkSummary)
+ if !ok {
+ t.Fatalf("benchmark extra type %T", rep.Extra["benchmark"])
+ }
+ if bm.RequestsPerSec != 100 {
+ t.Fatalf("rps: %v", bm.RequestsPerSec)
+ }
+ if bm.LatencyMs["p99"] != 20 {
+ t.Fatalf("p99: %v", bm.LatencyMs)
+ }
+ if bm.QueueConsumed == nil || *bm.QueueConsumed != 100 {
+ t.Fatalf("queue_consumed: %v", bm.QueueConsumed)
+ }
+ if bm.StreamPendingEnd == nil || *bm.StreamPendingEnd != 0 {
+ t.Fatalf("stream_pending_end: %v", bm.StreamPendingEnd)
+ }
+ if bm.MySQLSoldDelta == nil || *bm.MySQLSoldDelta != 100 {
+ t.Fatalf("mysql_sold_delta: %v", bm.MySQLSoldDelta)
+ }
+ if bm.AssumedInventorySeed != AssumedInventorySeed {
+ t.Fatalf("seed: %d", bm.AssumedInventorySeed)
+ }
+ if bm.TelemetryOverheadNote == "" {
+ t.Fatal("expected telemetry note")
+ }
+}
diff --git a/verification-lab/internal/runner/evaluate.go b/verification-lab/internal/runner/evaluate.go
new file mode 100644
index 0000000..5b6a312
--- /dev/null
+++ b/verification-lab/internal/runner/evaluate.go
@@ -0,0 +1,76 @@
+package runner
+
+import (
+ "fmt"
+
+ "ticketengine/verification-lab/internal/loadgen"
+ "ticketengine/verification-lab/internal/metrics"
+)
+
+// LoadWorkloadMode describes how HTTP outcomes are interpreted for pass/fail.
+const (
+ WorkloadAllSuccess = "all_success"
+ WorkloadExhaustion = "exhaustion"
+)
+
+// inventoryAtStartFromSnapshot returns queue depth when LIVE, else nil.
+func inventoryAtStartFromSnapshot(snap *metrics.Snapshot) *int64 {
+ if snap == nil {
+ return nil
+ }
+ if snap.FieldProvenance["queue_remaining"] != metrics.Live {
+ return nil
+ }
+ v := snap.QueueRemaining
+ return &v
+}
+
+// queueConsumedFromSnapshots is new tickets drawn from the Redis queue (HTTP 200 includes idempotent replays).
+func queueConsumedFromSnapshots(start, end *metrics.Snapshot) *int64 {
+ if start == nil || end == nil {
+ return nil
+ }
+ if start.FieldProvenance["queue_remaining"] != metrics.Live || end.FieldProvenance["queue_remaining"] != metrics.Live {
+ return nil
+ }
+ consumed := start.QueueRemaining - end.QueueRemaining
+ if consumed < 0 {
+ consumed = 0
+ }
+ return &consumed
+}
+
+// EvaluateLoadPass decides whether a bounded load run met its workload intent.
+func EvaluateLoadPass(st loadgen.Stats, mode string, inventoryAtStart *int64, startSnap, endSnap *metrics.Snapshot) (ok bool, failureReason string) {
+ transportAndOther := st.Errors + st.OtherStatus
+ definitive := st.HTTP200 + st.HTTP404
+
+ if transportAndOther != 0 {
+ return false, fmt.Sprintf("transport_or_unexpected_status: errors=%d other_status=%d", st.Errors, st.OtherStatus)
+ }
+ if definitive != st.LogicalRequests {
+ return false, fmt.Sprintf("incomplete_definitive_responses: http_200=%d http_404=%d logical_requests=%d",
+ st.HTTP200, st.HTTP404, st.LogicalRequests)
+ }
+
+ switch mode {
+ case WorkloadAllSuccess:
+ if st.HTTP200 != st.LogicalRequests {
+ return false, fmt.Sprintf("expected_all_http_200: got %d of %d", st.HTTP200, st.LogicalRequests)
+ }
+ return true, ""
+ case WorkloadExhaustion:
+ queueConsumed := queueConsumedFromSnapshots(startSnap, endSnap)
+ if inventoryAtStart != nil && queueConsumed != nil {
+ if *queueConsumed > *inventoryAtStart {
+ return false, fmt.Sprintf("queue_consumed_exceeds_inventory: consumed=%d inventory_at_start=%d",
+ *queueConsumed, *inventoryAtStart)
+ }
+ return true, ""
+ }
+ // Without LIVE queue snapshots, do not infer from http_200 (replays also return 200).
+ return true, ""
+ default:
+ return false, fmt.Sprintf("unknown_workload_mode: %s", mode)
+ }
+}
diff --git a/verification-lab/internal/runner/evaluate_test.go b/verification-lab/internal/runner/evaluate_test.go
new file mode 100644
index 0000000..2d6ffe9
--- /dev/null
+++ b/verification-lab/internal/runner/evaluate_test.go
@@ -0,0 +1,133 @@
+package runner
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "ticketengine/verification-lab/internal/loadgen"
+ "ticketengine/verification-lab/internal/metrics"
+)
+
+func TestEvaluateLoadPass_exhaustion(t *testing.T) {
+ cases := []struct {
+ name string
+ st loadgen.Stats
+ inventory *int64
+ wantOK bool
+ wantSub string
+ }{
+ {
+ name: "100k attempts 15k ok 85k sold out",
+ st: loadgen.Stats{LogicalRequests: 100_000, HTTP200: 15_000, HTTP404: 85_000},
+ inventory: int64Ptr(15_000),
+ wantOK: true,
+ },
+ {
+ name: "all 200 when under inventory",
+ st: loadgen.Stats{LogicalRequests: 1_000, HTTP200: 1_000},
+ inventory: int64Ptr(15_000),
+ wantOK: true,
+ },
+ {
+ name: "transport error fails",
+ st: loadgen.Stats{LogicalRequests: 100, HTTP200: 99, Errors: 1},
+ wantOK: false,
+ wantSub: "transport_or_unexpected_status",
+ },
+ {
+ name: "unexpected status fails",
+ st: loadgen.Stats{LogicalRequests: 100, HTTP200: 99, OtherStatus: 1},
+ wantOK: false,
+ wantSub: "transport_or_unexpected_status",
+ },
+ {
+ name: "missing responses fails",
+ st: loadgen.Stats{LogicalRequests: 100, HTTP200: 50, HTTP404: 40},
+ wantOK: false,
+ wantSub: "incomplete_definitive_responses",
+ },
+ {
+ name: "queue consumed exceeds inventory",
+ st: loadgen.Stats{LogicalRequests: 100, HTTP200: 20, HTTP404: 80},
+ inventory: int64Ptr(15),
+ wantOK: false,
+ wantSub: "queue_consumed_exceeds_inventory",
+ },
+ {
+ name: "inventory unknown still passes on definitive split",
+ st: loadgen.Stats{LogicalRequests: 50_000, HTTP200: 15_000, HTTP404: 35_000},
+ wantOK: true,
+ },
+ {
+ name: "depleted queue idempotent 200s do not fail",
+ st: loadgen.Stats{LogicalRequests: 100_000, HTTP200: 14_760, HTTP404: 85_240},
+ inventory: int64Ptr(0),
+ wantOK: true,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var start, end *metrics.Snapshot
+ switch tc.name {
+ case "queue consumed exceeds inventory":
+ start = &metrics.Snapshot{
+ QueueRemaining: 20,
+ FieldProvenance: map[string]string{"queue_remaining": metrics.Live},
+ }
+ end = &metrics.Snapshot{
+ QueueRemaining: 0,
+ FieldProvenance: map[string]string{"queue_remaining": metrics.Live},
+ }
+ case "depleted queue idempotent 200s do not fail":
+ start = &metrics.Snapshot{
+ QueueRemaining: 0,
+ FieldProvenance: map[string]string{"queue_remaining": metrics.Live},
+ }
+ end = &metrics.Snapshot{
+ QueueRemaining: 0,
+ FieldProvenance: map[string]string{"queue_remaining": metrics.Live},
+ }
+ }
+ ok, reason := EvaluateLoadPass(tc.st, WorkloadExhaustion, tc.inventory, start, end)
+ if ok != tc.wantOK {
+ t.Fatalf("ok=%v want %v reason=%q", ok, tc.wantOK, reason)
+ }
+ if !tc.wantOK && tc.wantSub != "" && reason != "" && !strings.Contains(reason, tc.wantSub) {
+ t.Fatalf("reason %q want substring %q", reason, tc.wantSub)
+ }
+ })
+ }
+}
+
+func TestEvaluateLoadPass_allSuccess(t *testing.T) {
+ ok, _ := EvaluateLoadPass(loadgen.Stats{LogicalRequests: 100, HTTP200: 100}, WorkloadAllSuccess, nil, nil, nil)
+ if !ok {
+ t.Fatal("expected pass")
+ }
+ ok, reason := EvaluateLoadPass(loadgen.Stats{LogicalRequests: 100, HTTP200: 99, HTTP404: 1}, WorkloadAllSuccess, nil, nil, nil)
+ if ok {
+ t.Fatal("expected fail")
+ }
+ if !strings.Contains(reason, "expected_all_http_200") {
+ t.Fatalf("reason: %s", reason)
+ }
+}
+
+func TestInventoryAtStartFromSnapshot(t *testing.T) {
+ snap := &metrics.Snapshot{
+ QueueRemaining: 15_000,
+ FieldProvenance: map[string]string{"queue_remaining": metrics.Live},
+ At: time.Now(),
+ }
+ v := inventoryAtStartFromSnapshot(snap)
+ if v == nil || *v != 15_000 {
+ t.Fatalf("got %v", v)
+ }
+ snap.FieldProvenance["queue_remaining"] = metrics.Unavailable
+ if inventoryAtStartFromSnapshot(snap) != nil {
+ t.Fatal("expected nil when not LIVE")
+ }
+}
+
+func int64Ptr(n int64) *int64 { return &n }
diff --git a/verification-lab/internal/runner/runner.go b/verification-lab/internal/runner/runner.go
new file mode 100644
index 0000000..c6dbe4c
--- /dev/null
+++ b/verification-lab/internal/runner/runner.go
@@ -0,0 +1,515 @@
+package runner
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "sync"
+ "time"
+
+ "ticketengine/verification-lab/internal/claims"
+ "ticketengine/verification-lab/internal/failurelab"
+ "ticketengine/verification-lab/internal/loadgen"
+ "ticketengine/verification-lab/internal/metrics"
+
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+)
+
+type Config struct {
+ TargetBase string
+ APIKey string
+ RedisAddr string
+ MySQLDSN string
+ RepoRoot string
+}
+
+type Report struct {
+ RunID string `json:"run_id"`
+ Preset string `json:"preset"`
+ Status string `json:"status"`
+ TargetBase string `json:"target_base"`
+ LogicalRequests int `json:"logical_requests,omitempty"`
+ HTTP200 int `json:"http_200,omitempty"`
+ HTTP404 int `json:"http_404,omitempty"`
+ InventoryAtStart *int64 `json:"inventory_at_start,omitempty"`
+ Errors int `json:"errors,omitempty"`
+ DurationMs int64 `json:"duration_ms,omitempty"`
+ LatencyP95Ms int64 `json:"latency_p95_ms,omitempty"`
+ LatencyP99Ms int64 `json:"latency_p99_ms,omitempty"`
+ Claims []claims.Result `json:"claims,omitempty"`
+ SnapshotStart *metrics.Snapshot `json:"snapshot_start,omitempty"`
+ SnapshotEnd *metrics.Snapshot `json:"snapshot_end,omitempty"`
+ FieldProvenance map[string]string `json:"field_provenance,omitempty"`
+ Extra map[string]interface{} `json:"extra,omitempty"`
+}
+
+type Engine struct {
+ cfg Config
+ mu sync.RWMutex
+ runs map[string]*Report
+ col metrics.Collector
+ Hub *metrics.Hub
+
+ activeRunID string
+ activeCorrID string
+}
+
+// ActiveRun implements metrics.RunContextProvider.
+func (e *Engine) ActiveRun() (runID, correlationID string) {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ return e.activeRunID, e.activeCorrID
+}
+
+func (e *Engine) setActiveRun(runID, corr string) {
+ e.mu.Lock()
+ e.activeRunID = runID
+ e.activeCorrID = corr
+ e.mu.Unlock()
+}
+
+func (e *Engine) clearActiveRun() {
+ e.mu.Lock()
+ e.activeRunID = ""
+ e.activeCorrID = ""
+ e.mu.Unlock()
+}
+
+func New(cfg Config) *Engine {
+ return &Engine{
+ cfg: cfg,
+ runs: map[string]*Report{},
+ col: metrics.Collector{
+ TargetBase: cfg.TargetBase,
+ RedisAddr: cfg.RedisAddr,
+ MySQLDSN: cfg.MySQLDSN,
+ },
+ }
+}
+
+func (e *Engine) Get(id string) (*Report, bool) {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ r, ok := e.runs[id]
+ if !ok {
+ return nil, false
+ }
+ cp := *r
+ return &cp, true
+}
+
+func (e *Engine) StartSmoke(ctx context.Context) (string, error) {
+ id := newRunID()
+ rep := &Report{
+ RunID: id,
+ Preset: "smoke",
+ Status: "running",
+ TargetBase: e.cfg.TargetBase,
+ }
+ e.mu.Lock()
+ e.runs[id] = rep
+ e.mu.Unlock()
+
+ go func() {
+ defer e.clearActiveRun()
+ e.setActiveRun(id, id)
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", "smoke started", id, id)
+ }
+ startSnap, _ := e.col.Collect(ctx)
+ st, err := loadgen.Run(ctx, loadgen.Options{
+ BaseURL: e.cfg.TargetBase,
+ Workers: 8,
+ Total: 100,
+ UserIDStart: 700_000,
+ APIKey: e.cfg.APIKey,
+ })
+ endSnap, _ := e.col.Collect(ctx)
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ rep.SnapshotEnd = &endSnap
+ rep.FieldProvenance = map[string]string{
+ "load_stats": metrics.Derived,
+ }
+ e.finalizeLoadReport(rep, startSnap, endSnap, st, err, WorkloadAllSuccess, nil, nil)
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", "smoke finished: "+rep.Status, id, id)
+ }
+ }()
+ return id, nil
+}
+
+type loadPresetSpec struct {
+ Preset string
+ Total int
+ UserIDStart int
+ Workers int
+ Timeout time.Duration
+}
+
+func (e *Engine) StartHeavy(ctx context.Context) (string, error) {
+ return e.startLoad(ctx, loadPresetSpec{
+ Preset: "heavy-100k",
+ Total: 100_000,
+ UserIDStart: freshUserIDBase(100_000),
+ Workers: 32,
+ Timeout: 15 * time.Second,
+ }, WorkloadExhaustion, nil)
+}
+
+func (e *Engine) StartBoundedLoad(ctx context.Context, preset string, total int) (string, error) {
+ workers := boundedWorkers(total)
+ return e.startLoad(ctx, loadPresetSpec{
+ Preset: preset,
+ Total: total,
+ UserIDStart: 800_000 + total,
+ Workers: workers,
+ Timeout: 15 * time.Second,
+ }, WorkloadExhaustion, nil)
+}
+
+func boundedWorkers(total int) int {
+ switch {
+ case total <= 1_000:
+ return 8
+ case total <= 10_000:
+ return 16
+ case total <= 50_000:
+ return 24
+ default:
+ return 32
+ }
+}
+
+func (e *Engine) startLoad(ctx context.Context, spec loadPresetSpec, mode string, benchProf *BenchmarkProfile) (string, error) {
+ id := newRunID()
+ extra := map[string]interface{}{
+ "workload_mode": mode,
+ "requested_total": spec.Total,
+ }
+ if benchProf != nil {
+ extra["assumed_inventory_seed"] = benchProf.AssumedInventorySeed
+ extra["benchmark_profile"] = benchProf.Preset
+ }
+ rep := &Report{
+ RunID: id,
+ Preset: spec.Preset,
+ Status: "running",
+ TargetBase: e.cfg.TargetBase,
+ LogicalRequests: spec.Total,
+ Extra: extra,
+ }
+ e.mu.Lock()
+ e.runs[id] = rep
+ e.mu.Unlock()
+
+ go func() {
+ defer e.clearActiveRun()
+ e.setActiveRun(id, id)
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", spec.Preset+" started", id, id)
+ }
+ startSnap, _ := e.col.Collect(ctx)
+ inv := inventoryAtStartFromSnapshot(&startSnap)
+ e.mu.Lock()
+ rep.SnapshotStart = &startSnap
+ rep.InventoryAtStart = inv
+ if inv != nil {
+ rep.Extra["inventory_at_start"] = *inv
+ }
+ e.mu.Unlock()
+
+ st, err := loadgen.Run(ctx, loadgen.Options{
+ BaseURL: e.cfg.TargetBase,
+ Workers: spec.Workers,
+ Total: spec.Total,
+ UserIDStart: spec.UserIDStart,
+ APIKey: e.cfg.APIKey,
+ Timeout: spec.Timeout,
+ })
+ endSnap, _ := e.col.Collect(ctx)
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ rep.SnapshotEnd = &endSnap
+ rep.FieldProvenance = map[string]string{"load_stats": metrics.Derived}
+ e.finalizeLoadReport(rep, startSnap, endSnap, st, err, mode, inv, benchProf)
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", spec.Preset+" finished: "+rep.Status, id, id)
+ }
+ }()
+ return id, nil
+}
+
+func freshUserIDBase(span int) int {
+ // Avoid overlapping prior lab runs (idempotent replays) while staying in safe int range.
+ var b [4]byte
+ _, _ = rand.Read(b[:])
+ base := 1_000_000 + int(b[0])<<16 + int(b[1])<<8 + int(b[2])
+ if base > 2_000_000_000-span {
+ base = 2_000_000_000 - span
+ }
+ return base
+}
+
+func (e *Engine) finalizeLoadReport(rep *Report, startSnap, endSnap metrics.Snapshot, st loadgen.Stats, runErr error, mode string, inventoryAtStart *int64, benchProf *BenchmarkProfile) {
+ if runErr != nil {
+ rep.Status = "failed"
+ if rep.Extra == nil {
+ rep.Extra = map[string]interface{}{}
+ }
+ rep.Extra["error"] = runErr.Error()
+ rep.Extra["failure_reason"] = "loadgen_error"
+ return
+ }
+ rep.LogicalRequests = st.LogicalRequests
+ rep.HTTP200 = st.HTTP200
+ rep.HTTP404 = st.HTTP404
+ rep.Errors = st.Errors + st.OtherStatus
+ rep.DurationMs = st.DurationMs
+ rep.LatencyP95Ms = st.LatencyP95Ms
+ rep.LatencyP99Ms = st.LatencyP99Ms
+ if rep.SnapshotStart == nil {
+ rep.SnapshotStart = &startSnap
+ }
+ ok, reason := EvaluateLoadPass(st, mode, inventoryAtStart, &startSnap, &endSnap)
+ if ok {
+ rep.Status = "completed"
+ } else {
+ rep.Status = "failed"
+ if rep.Extra == nil {
+ rep.Extra = map[string]interface{}{}
+ }
+ rep.Extra["failure_reason"] = reason
+ }
+ if consumed := queueConsumedFromSnapshots(&startSnap, &endSnap); consumed != nil && rep.Extra != nil {
+ rep.Extra["queue_consumed"] = *consumed
+ }
+ if IsBenchmarkPreset(rep.Preset) {
+ prof := benchProf
+ if prof == nil {
+ if p, ok := benchmarkProfiles()[rep.Preset]; ok {
+ prof = &p
+ }
+ }
+ attachBenchmarkSummary(rep, prof, st, &startSnap, &endSnap)
+ }
+}
+
+func (e *Engine) StartClaims(ctx context.Context, claimIDs []string) (string, error) {
+ if len(claimIDs) == 0 {
+ claimIDs = []string{"INV-1", "INV-2", "INV-3", "INV-4", "INV-5", "INV-6", "INV-7"}
+ }
+ id := newRunID()
+ rep := &Report{
+ RunID: id,
+ Preset: "claims",
+ Status: "running",
+ TargetBase: e.cfg.TargetBase,
+ }
+ e.mu.Lock()
+ e.runs[id] = rep
+ e.mu.Unlock()
+
+ go func() {
+ defer e.clearActiveRun()
+ e.setActiveRun(id, id)
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", "claims started", id, id)
+ }
+ startSnap, _ := e.col.Collect(ctx)
+ var soldBefore int64
+ if startSnap.FieldProvenance["mysql"] == metrics.Live {
+ soldBefore = startSnap.MySQL.Sold
+ }
+
+ client := claims.Client{BaseURL: e.cfg.TargetBase, APIKey: e.cfg.APIKey}
+ results := claims.Run(ctx, client, claimIDs)
+
+ // INV-6 durability poll when MySQL configured
+ var inv6Ticket int
+ for _, cr := range results {
+ if cr.ID == "INV-6" {
+ if tid, ok := cr.Evidence["ticket_id"].(int); ok {
+ inv6Ticket = tid
+ }
+ }
+ }
+ soldSeen := false
+ if inv6Ticket > 0 && e.cfg.MySQLDSN != "" {
+ soldSeen = waitTicketSold(ctx, e.cfg.MySQLDSN, inv6Ticket, 15*time.Second)
+ }
+
+ endSnap, _ := e.col.Collect(ctx)
+ var soldAfter int64
+ if endSnap.FieldProvenance["mysql"] == metrics.Live {
+ soldAfter = endSnap.MySQL.Sold
+ }
+ results = claims.EnrichMySQL(results, soldBefore, soldAfter, inv6Ticket, soldSeen)
+
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ rep.SnapshotStart = &startSnap
+ rep.SnapshotEnd = &endSnap
+ rep.Claims = results
+ rep.FieldProvenance = map[string]string{"claims": metrics.Derived}
+ rep.Status = "completed"
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", "claims finished", id, id)
+ }
+ }()
+
+ return id, nil
+}
+
+func (e *Engine) StartGates(ctx context.Context, gateIDs []string) (string, error) {
+ if len(gateIDs) == 0 {
+ gateIDs = claims.DefaultGateIDs
+ }
+ id := newRunID()
+ rep := &Report{
+ RunID: id,
+ Preset: "gates",
+ Status: "running",
+ TargetBase: e.cfg.TargetBase,
+ }
+ e.mu.Lock()
+ e.runs[id] = rep
+ e.mu.Unlock()
+
+ go func() {
+ defer e.clearActiveRun()
+ e.setActiveRun(id, id)
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", "gates started", id, id)
+ }
+ startSnap, _ := e.col.Collect(ctx)
+ repoRoot := e.cfg.RepoRoot
+ if repoRoot == "" {
+ repoRoot = claims.DiscoverRepoRoot()
+ }
+ results := claims.RunGates(ctx, claims.GateOpts{
+ RepoRoot: repoRoot,
+ TargetBase: e.cfg.TargetBase,
+ APIKey: e.cfg.APIKey,
+ RedisAddr: e.cfg.RedisAddr,
+ MySQLDSN: e.cfg.MySQLDSN,
+ }, gateIDs)
+ endSnap, _ := e.col.Collect(ctx)
+ status, failureReason := claims.SummarizeGateRun(results)
+
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ rep.SnapshotStart = &startSnap
+ rep.SnapshotEnd = &endSnap
+ rep.Claims = results
+ rep.FieldProvenance = map[string]string{"gates": metrics.Derived}
+ rep.Status = status
+ if failureReason != "" {
+ if rep.Extra == nil {
+ rep.Extra = map[string]interface{}{}
+ }
+ rep.Extra["failure_reason"] = failureReason
+ }
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", "gates finished: "+rep.Status, id, id)
+ }
+ }()
+
+ return id, nil
+}
+
+func (e *Engine) StartFailureLab(ctx context.Context, scenarioIDs []string) (string, error) {
+ if len(scenarioIDs) == 0 {
+ scenarioIDs = failurelab.DefaultScenarioIDs
+ }
+ id := newRunID()
+ rep := &Report{
+ RunID: id,
+ Preset: "failure-lab",
+ Status: "running",
+ TargetBase: e.cfg.TargetBase,
+ Extra: map[string]interface{}{
+ "failure_lab_catalog": failurelab.Catalog(),
+ },
+ }
+ e.mu.Lock()
+ e.runs[id] = rep
+ e.mu.Unlock()
+
+ go func() {
+ defer e.clearActiveRun()
+ e.setActiveRun(id, id)
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", "failure-lab started", id, id)
+ }
+ startSnap, _ := e.col.Collect(ctx)
+ repoRoot := e.cfg.RepoRoot
+ if repoRoot == "" {
+ repoRoot = claims.DiscoverRepoRoot()
+ }
+ results := failurelab.Run(ctx, failurelab.Opts{
+ RepoRoot: repoRoot,
+ TargetBase: e.cfg.TargetBase,
+ APIKey: e.cfg.APIKey,
+ RedisAddr: e.cfg.RedisAddr,
+ MySQLDSN: e.cfg.MySQLDSN,
+ }, e.col, scenarioIDs)
+ endSnap, _ := e.col.Collect(ctx)
+ status, failureReason := failurelab.SummarizeFailureRun(results)
+
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ rep.SnapshotStart = &startSnap
+ rep.SnapshotEnd = &endSnap
+ rep.Claims = results
+ rep.FieldProvenance = map[string]string{"failure_lab": metrics.Derived}
+ rep.Status = status
+ if failureReason != "" {
+ if rep.Extra == nil {
+ rep.Extra = map[string]interface{}{}
+ }
+ rep.Extra["failure_reason"] = failureReason
+ }
+ if e.Hub != nil {
+ e.Hub.EmitRunEvent("run", "failure-lab finished: "+rep.Status, id, id)
+ }
+ }()
+
+ return id, nil
+}
+
+func (e *Engine) Collector() metrics.Collector {
+ return e.col
+}
+
+func newRunID() string {
+ var b [16]byte
+ _, _ = rand.Read(b[:])
+ return hex.EncodeToString(b[:])
+}
+
+func waitTicketSold(ctx context.Context, dsn string, ticketID int, timeout time.Duration) bool {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if ctx.Err() != nil {
+ return false
+ }
+ gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{SkipDefaultTransaction: true})
+ if err != nil {
+ time.Sleep(200 * time.Millisecond)
+ continue
+ }
+ sqlDB, _ := gdb.DB()
+ var state string
+ err = gdb.Raw("SELECT state FROM tickets WHERE id = ? LIMIT 1", ticketID).Scan(&state).Error
+ if sqlDB != nil {
+ sqlDB.Close()
+ }
+ if err == nil && state == "sold" {
+ return true
+ }
+ time.Sleep(250 * time.Millisecond)
+ }
+ return false
+}
diff --git a/verification-lab/internal/runner/runner_test.go b/verification-lab/internal/runner/runner_test.go
new file mode 100644
index 0000000..83a87a2
--- /dev/null
+++ b/verification-lab/internal/runner/runner_test.go
@@ -0,0 +1,42 @@
+package runner
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestSmokePresetMockTarget(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/metrics":
+ _ = json.NewEncoder(w).Encode(map[string]int64{"total_requests": 1, "booking_success": 1})
+ case "/book":
+ w.WriteHeader(http.StatusOK)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ eng := New(Config{TargetBase: srv.URL})
+ id, err := eng.StartSmoke(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ rep, ok := eng.Get(id)
+ if ok && rep.Status != "running" {
+ if rep.Status != "completed" || rep.HTTP200 != 100 {
+ t.Fatalf("report: %+v", rep)
+ }
+ return
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Fatal("timeout waiting for smoke run")
+}
diff --git a/verification-lab/internal/target/allowlist.go b/verification-lab/internal/target/allowlist.go
new file mode 100644
index 0000000..c1d06e6
--- /dev/null
+++ b/verification-lab/internal/target/allowlist.go
@@ -0,0 +1,32 @@
+package target
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+)
+
+// BaseURL validates and normalizes an allowlisted HTTP target (localhost only).
+func BaseURL(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", fmt.Errorf("target: empty URL")
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", fmt.Errorf("target: parse: %w", err)
+ }
+ if u.Scheme != "http" {
+ return "", fmt.Errorf("target: only http scheme allowed")
+ }
+ host := strings.ToLower(u.Hostname())
+ switch host {
+ case "127.0.0.1", "localhost", "::1":
+ default:
+ return "", fmt.Errorf("target: host %q not allowlisted", host)
+ }
+ u.Path = strings.TrimSuffix(u.Path, "/")
+ u.RawQuery = ""
+ u.Fragment = ""
+ return u.String(), nil
+}
diff --git a/verification-lab/internal/target/allowlist_test.go b/verification-lab/internal/target/allowlist_test.go
new file mode 100644
index 0000000..2aea7f5
--- /dev/null
+++ b/verification-lab/internal/target/allowlist_test.go
@@ -0,0 +1,18 @@
+package target
+
+import "testing"
+
+func TestBaseURLAllowlist(t *testing.T) {
+ ok, err := BaseURL("http://127.0.0.1:8080/")
+ if err != nil || ok != "http://127.0.0.1:8080" {
+ t.Fatalf("ok=%q err=%v", ok, err)
+ }
+ _, err = BaseURL("http://evil.example/book")
+ if err == nil {
+ t.Fatal("expected reject external host")
+ }
+ _, err = BaseURL("https://127.0.0.1:8080")
+ if err == nil {
+ t.Fatal("expected reject https")
+ }
+}
diff --git a/verification-lab/web/index.html b/verification-lab/web/index.html
new file mode 100644
index 0000000..0bb6030
--- /dev/null
+++ b/verification-lab/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Ticket Engine — Verification Lab
+
+
+
+
+
+
diff --git a/verification-lab/web/package-lock.json b/verification-lab/web/package-lock.json
new file mode 100644
index 0000000..c20ede2
--- /dev/null
+++ b/verification-lab/web/package-lock.json
@@ -0,0 +1,1871 @@
+{
+ "name": "verification-lab-ui",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "verification-lab-ui",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "zustand": "^5.0.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "~5.7.2",
+ "vite": "^6.0.3"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz",
+ "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz",
+ "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.9",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.3.tgz",
+ "integrity": "sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.3.tgz",
+ "integrity": "sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.3.tgz",
+ "integrity": "sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.3.tgz",
+ "integrity": "sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.3.tgz",
+ "integrity": "sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.3.tgz",
+ "integrity": "sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.3.tgz",
+ "integrity": "sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.3.tgz",
+ "integrity": "sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.3.tgz",
+ "integrity": "sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.3.tgz",
+ "integrity": "sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.3.tgz",
+ "integrity": "sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.3.tgz",
+ "integrity": "sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.3.tgz",
+ "integrity": "sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.3.tgz",
+ "integrity": "sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.3.tgz",
+ "integrity": "sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.3.tgz",
+ "integrity": "sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.3.tgz",
+ "integrity": "sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.3.tgz",
+ "integrity": "sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.3.tgz",
+ "integrity": "sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.3.tgz",
+ "integrity": "sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.3.tgz",
+ "integrity": "sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.3.tgz",
+ "integrity": "sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.3.tgz",
+ "integrity": "sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.3.tgz",
+ "integrity": "sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.3.tgz",
+ "integrity": "sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.28",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz",
+ "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001815",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
+ "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.452",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz",
+ "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.58",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz",
+ "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.64.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.3.tgz",
+ "integrity": "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.64.3",
+ "@rollup/rollup-android-arm64": "4.64.3",
+ "@rollup/rollup-darwin-arm64": "4.64.3",
+ "@rollup/rollup-darwin-x64": "4.64.3",
+ "@rollup/rollup-freebsd-arm64": "4.64.3",
+ "@rollup/rollup-freebsd-x64": "4.64.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.64.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.64.3",
+ "@rollup/rollup-linux-arm64-musl": "4.64.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.64.3",
+ "@rollup/rollup-linux-loong64-musl": "4.64.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.64.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.64.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.64.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.64.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.64.3",
+ "@rollup/rollup-linux-x64-gnu": "4.64.3",
+ "@rollup/rollup-linux-x64-musl": "4.64.3",
+ "@rollup/rollup-openbsd-x64": "4.64.3",
+ "@rollup/rollup-openharmony-arm64": "4.64.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.64.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.64.3",
+ "@rollup/rollup-win32-x64-gnu": "4.64.3",
+ "@rollup/rollup-win32-x64-msvc": "4.64.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
+ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
+ "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz",
+ "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.15",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz",
+ "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/verification-lab/web/package.json b/verification-lab/web/package.json
new file mode 100644
index 0000000..0c5c574
--- /dev/null
+++ b/verification-lab/web/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "verification-lab-ui",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "zustand": "^5.0.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "~5.7.2",
+ "vite": "^6.0.3"
+ }
+}
diff --git a/verification-lab/web/src/App.tsx b/verification-lab/web/src/App.tsx
new file mode 100644
index 0000000..5bf6f0d
--- /dev/null
+++ b/verification-lab/web/src/App.tsx
@@ -0,0 +1,32 @@
+import { StatusBar } from "./components/StatusBar";
+import { Pipeline } from "./components/Pipeline";
+import { Inspectors } from "./components/Inspectors";
+import { Charts } from "./components/Charts";
+import { Timeline } from "./components/Timeline";
+import { RunControl } from "./components/RunControl";
+import { ClaimsPanel } from "./components/ClaimsPanel";
+import { FailureLabPanel } from "./components/FailureLabPanel";
+import { useTelemetrySSE } from "./hooks/useTelemetrySSE";
+
+export function App() {
+ useTelemetrySSE();
+
+ return (
+
+ );
+}
diff --git a/verification-lab/web/src/components/Charts.tsx b/verification-lab/web/src/components/Charts.tsx
new file mode 100644
index 0000000..c218614
--- /dev/null
+++ b/verification-lab/web/src/components/Charts.tsx
@@ -0,0 +1,128 @@
+import { useMemo } from "react";
+import { useLabStore } from "../store/labStore";
+import type { ChartPoint } from "../store/labStore";
+
+function downsample(data: T[], max: number): T[] {
+ if (data.length <= max) return data;
+ const step = data.length / max;
+ const out: T[] = [];
+ for (let i = 0; i < max; i++) {
+ out.push(data[Math.floor(i * step)]);
+ }
+ return out;
+}
+
+function lastVal(values: number[]): number {
+ return values.length ? values[values.length - 1] : 0;
+}
+
+function Sparkline({ values, color }: { values: number[]; color: string }) {
+ const w = 280;
+ const h = 48;
+ const { pts, flat } = useMemo(() => {
+ if (!values.length) return { pts: "", flat: true };
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = max - min;
+ const flatLine = span < 1e-9;
+ const top = flatLine ? Math.max(max, 1) : max;
+ const bottom = flatLine ? 0 : min;
+ const range = top - bottom || 1;
+ if (values.length === 1) {
+ const y = h / 2;
+ return { pts: `0,${y} ${w},${y}`, flat: flatLine };
+ }
+ const line = values
+ .map((v, i) => {
+ const x = (i / (values.length - 1)) * w;
+ const norm = flatLine ? 0.5 : (v - bottom) / range;
+ const y = h - norm * (h - 8) - 4;
+ return `${x},${y}`;
+ })
+ .join(" ");
+ return { pts: line, flat: flatLine };
+ }, [values]);
+
+ if (!values.length) {
+ return No samples yet — waiting for SSE
;
+ }
+
+ return (
+
+ {flat && }
+
+
+ );
+}
+
+function ChartCard({
+ title,
+ values,
+ color,
+ unit,
+ format,
+}: {
+ title: string;
+ values: number[];
+ color: string;
+ unit: string;
+ format?: (v: number) => string;
+}) {
+ const fmt = format ?? ((v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v.toFixed(1)));
+ const current = lastVal(values);
+ return (
+
+
{title}
+
+ {fmt(current)}
+ {unit}
+ {values.length} pts
+
+
+
+ );
+}
+
+export function Charts() {
+ const chart = useLabStore((s) => s.chart);
+ const connected = useLabStore((s) => s.connected);
+ const ds = downsample(chart, 120);
+
+ const series = useMemo(() => {
+ const pick = (fn: (p: ChartPoint) => number) => ds.map(fn);
+ return {
+ req: pick((p) => p.reqPerSec),
+ success: pick((p) => p.successPerSec),
+ fail: pick((p) => p.failPerSec),
+ queue: pick((p) => p.queue),
+ pending: pick((p) => p.pending),
+ sold: pick((p) => p.sold),
+ };
+ }, [ds]);
+
+ return (
+
+
+
Charts
+
+ {connected ? "SSE · " : "offline · "}
+ {chart.length} samples · display downsampled to 120
+
+
+ {chart.length < 2 && (
+
+ Charts fill from the telemetry stream during a run. If you started the page after a test finished, start a smoke
+ run or keep this tab open during the next heavy load.
+
+ )}
+
+
+
+
+ String(Math.round(v))} />
+ String(Math.round(v))} />
+ String(Math.round(v))} />
+
+
+ );
+}
diff --git a/verification-lab/web/src/components/ClaimsPanel.tsx b/verification-lab/web/src/components/ClaimsPanel.tsx
new file mode 100644
index 0000000..3741b90
--- /dev/null
+++ b/verification-lab/web/src/components/ClaimsPanel.tsx
@@ -0,0 +1,99 @@
+import { useMemo } from "react";
+import {
+ benchmarkSummaryLine,
+ isLoadPreset,
+ loadRunHumanLine,
+ loadRunStatsLine,
+ loadRunStatusLabel,
+} from "../lib/runSummary";
+import { useLabStore } from "../store/labStore";
+
+const INV_IDS = ["INV-1", "INV-2", "INV-3", "INV-4", "INV-5", "INV-6", "INV-7"];
+const GATE_IDS = ["P1", "P2", "P3", "P4", "P5", "P6", "P7"];
+
+export function ClaimsPanel() {
+ const run = useLabStore((s) => s.run);
+
+ const isGates = run?.preset === "gates" || run?.preset === "phase-gates";
+ const isFailure = run?.preset === "failure-lab";
+ const rowIds = isGates ? GATE_IDS : INV_IDS;
+
+ if (isFailure) {
+ return (
+
+
+
Verification
+ Failure-lab results are in the Failure lab panel below.
+
+
+ );
+ }
+
+ const rows = useMemo(() => {
+ const byId = new Map(run?.claims?.map((c) => [c.id, c]));
+ return rowIds.map((id) => {
+ const c = byId.get(id);
+ const defaultStatus =
+ run?.status === "running" && (run.preset === "claims" || isGates) ? "RUNNING" : "NOT RUN";
+ return {
+ id,
+ status: c?.status ?? defaultStatus,
+ message: c?.message ?? "",
+ failureReason: c?.failure_reason ?? "",
+ evidence: c?.evidence,
+ };
+ });
+ }, [run, rowIds, isGates]);
+
+ const loadHeader = run && isLoadPreset(run.preset) && run.logical_requests != null;
+
+ return (
+
+
+
{isGates ? "Phase gates" : "Verification"}
+ {run && (
+
+
+ {loadHeader ? loadRunStatusLabel(run) : run.status}
+ {" · "}
+ {run.preset}
+ {run.run_id && <> · {run.run_id.slice(0, 8)}>}
+
+ {loadHeader && (
+ <>
+ {loadRunStatsLine(run)}
+ {loadRunHumanLine(run)}
+ {benchmarkSummaryLine(run) && (
+ {benchmarkSummaryLine(run)}
+ )}
+ >
+ )}
+ {run.extra?.failure_reason && (
+ run: {String(run.extra.failure_reason)}
+ )}
+ {run.error && {run.error} }
+
+ )}
+
+
+ {rows.map((r) => (
+
+
{r.id}
+
+ {r.status}
+ {r.failureReason && {r.failureReason} }
+ {r.message && {r.message} }
+ {r.evidence && Object.keys(r.evidence).length > 0 && (
+
+ evidence
+ {JSON.stringify(r.evidence, null, 2)}
+
+ )}
+
+
+ ))}
+
+ {run && {JSON.stringify(run, null, 2)} }
+
+ );
+}
diff --git a/verification-lab/web/src/components/FailureLabPanel.tsx b/verification-lab/web/src/components/FailureLabPanel.tsx
new file mode 100644
index 0000000..627e503
--- /dev/null
+++ b/verification-lab/web/src/components/FailureLabPanel.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useMemo, useState } from "react";
+import { pollDeadlineLabel, pollRun } from "../lib/pollRun";
+import { useLabStore } from "../store/labStore";
+import type { ClaimResult } from "../types";
+
+type ScenarioMeta = {
+ id: string;
+ title: string;
+ warning: string;
+ manual_steps?: string[];
+ safe_automated: boolean;
+ registry_tests: string[];
+};
+
+const DEFAULT_IDS = [
+ "worker-unavailable",
+ "restart-inventory",
+ "dlq-growth",
+ "reconcile-after-stress",
+];
+
+export function FailureLabPanel() {
+ const busy = useLabStore((s) => s.busy);
+ const setBusy = useLabStore((s) => s.setBusy);
+ const setRun = useLabStore((s) => s.setRun);
+ const run = useLabStore((s) => s.run);
+ const [catalog, setCatalog] = useState([]);
+
+ useEffect(() => {
+ fetch("/api/failure-lab/scenarios")
+ .then((r) => r.json())
+ .then((body) => {
+ if (Array.isArray(body)) setCatalog(body as ScenarioMeta[]);
+ })
+ .catch(() => {
+ setCatalog(
+ DEFAULT_IDS.map((id) => ({
+ id,
+ title: id,
+ warning: "",
+ safe_automated: true,
+ registry_tests: [],
+ })),
+ );
+ });
+ }, []);
+
+ const isFailureRun = run?.preset === "failure-lab";
+ const resultsById = useMemo(() => {
+ const m = new Map();
+ run?.claims?.forEach((c) => m.set(c.id, c));
+ return m;
+ }, [run?.claims]);
+
+ const start = async (scenarios: string[]) => {
+ if (
+ !window.confirm(
+ "Run failure-lab scenarios against the allowlisted target? Destructive faults require manual steps per scenario warnings.",
+ )
+ ) {
+ return;
+ }
+ setBusy(true);
+ setRun(null);
+ try {
+ const res = await fetch("/api/runs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ preset: "failure-lab",
+ scenarios,
+ confirm_fault: true,
+ }),
+ });
+ if (!res.ok) {
+ const text = (await res.text()).trim() || `HTTP ${res.status}`;
+ setRun({ run_id: "", preset: "failure-lab", status: "failed", error: text });
+ return;
+ }
+ const { run_id } = (await res.json()) as { run_id: string };
+ setRun({ run_id, preset: "failure-lab", status: "running" });
+ const report = await pollRun(run_id, "failure-lab");
+ if (report) setRun(report);
+ else {
+ setRun({
+ run_id,
+ preset: "failure-lab",
+ status: "failed",
+ error: `Polling timed out after ${pollDeadlineLabel("failure-lab")}.`,
+ });
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
Failure lab
+ {isFailureRun && run && (
+
+ {run.status}
+ {run.run_id && <> · {run.run_id.slice(0, 8)}>}
+ {run.extra?.failure_reason && (
+ · {String(run.extra.failure_reason)}
+ )}
+
+ )}
+
+
+ Safe, bounded probes only (localhost allowlist, server-side Redis). Confirm faults before POST{" "}
+ /api/runs preset failure-lab.
+
+
+ start([])}>
+ Run all scenarios
+
+
+
+
+ );
+}
diff --git a/verification-lab/web/src/components/Inspectors.tsx b/verification-lab/web/src/components/Inspectors.tsx
new file mode 100644
index 0000000..68ecaae
--- /dev/null
+++ b/verification-lab/web/src/components/Inspectors.tsx
@@ -0,0 +1,76 @@
+import { useLabStore } from "../store/labStore";
+import { ProvenanceBadge } from "./ProvenanceBadge";
+
+function Row({ k, v }: { k: string; v: string | number }) {
+ return (
+
+ {k}
+ {v}
+
+ );
+}
+
+export function Inspectors() {
+ const snap = useLabStore((s) => s.snap);
+ const focus = useLabStore((s) => s.selectedNode);
+ const prov = snap?.field_provenance ?? {};
+
+ return (
+
+
+
Inspectors
+ {focus ? `focused: ${focus}` : "all layers"}
+
+
+ {(!focus || focus === "api") && (
+
+ )}
+ {(!focus || focus === "redis") && (
+
+ )}
+ {(!focus || focus === "stream") && (
+
+ )}
+ {(!focus || focus === "worker") && (
+
+ )}
+ {(!focus || focus === "mysql") && (
+
+ )}
+
+
+ );
+}
diff --git a/verification-lab/web/src/components/Pipeline.tsx b/verification-lab/web/src/components/Pipeline.tsx
new file mode 100644
index 0000000..384529d
--- /dev/null
+++ b/verification-lab/web/src/components/Pipeline.tsx
@@ -0,0 +1,44 @@
+import type { PipelineNode } from "../types";
+import { useLabStore } from "../store/labStore";
+
+const nodes: { id: PipelineNode; label: string; hint: string }[] = [
+ { id: "api", label: "API", hint: "POST /book" },
+ { id: "redis", label: "Redis", hint: "queue + hash" },
+ { id: "stream", label: "Stream", hint: "bookings.stream" },
+ { id: "worker", label: "Worker", hint: "consumer group" },
+ { id: "mysql", label: "MySQL", hint: "durable sold" },
+];
+
+export function Pipeline() {
+ const snap = useLabStore((s) => s.snap);
+ const selected = useLabStore((s) => s.selectedNode);
+ const setSelected = useLabStore((s) => s.setSelectedNode);
+
+ return (
+
+
+
Pipeline
+ Click a stage for inspector focus
+
+
+ {nodes.map((n, i) => (
+ setSelected(selected === n.id ? null : n.id)}
+ >
+ {n.label}
+ {n.hint}
+ {i < nodes.length - 1 && → }
+
+ ))}
+
+
+
success/s {(snap?.api_rates.success_per_sec ?? 0).toFixed(1)}
+
pending {snap?.redis.stream_pending ?? snap?.stream_pending ?? "—"}
+
gap {snap?.persistence_lag.success_gap ?? "—"}
+
+
+ );
+}
diff --git a/verification-lab/web/src/components/ProvenanceBadge.tsx b/verification-lab/web/src/components/ProvenanceBadge.tsx
new file mode 100644
index 0000000..8954abb
--- /dev/null
+++ b/verification-lab/web/src/components/ProvenanceBadge.tsx
@@ -0,0 +1,5 @@
+export function ProvenanceBadge({ label }: { label?: string }) {
+ const v = (label ?? "UNAVAILABLE").toUpperCase();
+ const cls = v === "LIVE" ? "live" : v === "DERIVED" ? "derived" : "unavailable";
+ return {v} ;
+}
diff --git a/verification-lab/web/src/components/RunControl.tsx b/verification-lab/web/src/components/RunControl.tsx
new file mode 100644
index 0000000..ddb7abe
--- /dev/null
+++ b/verification-lab/web/src/components/RunControl.tsx
@@ -0,0 +1,155 @@
+import { useCallback } from "react";
+import { pollDeadlineLabel, pollRun } from "../lib/pollRun";
+import { useLabStore } from "../store/labStore";
+
+const GATE_IDS = ["P1", "P2", "P3", "P4", "P5", "P6", "P7"];
+
+export function RunControl() {
+ const busy = useLabStore((s) => s.busy);
+ const setBusy = useLabStore((s) => s.setBusy);
+ const setRun = useLabStore((s) => s.setRun);
+ const run = useLabStore((s) => s.run);
+ const clearChart = useLabStore((s) => s.clearChart);
+ const pushRunSnaps = useLabStore((s) => s.pushChartFromRunSnapshots);
+
+ const gateById = (id: string) => run?.claims?.find((c) => c.id === id);
+
+ const start = useCallback(
+ async (body: Record) => {
+ const preset = String(body.preset ?? "smoke");
+ setBusy(true);
+ setRun(null);
+ clearChart();
+ try {
+ const res = await fetch("/api/runs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const text = (await res.text()).trim() || `HTTP ${res.status}`;
+ setRun({
+ run_id: "",
+ preset,
+ status: "failed",
+ error: text,
+ });
+ return;
+ }
+ const { run_id } = (await res.json()) as { run_id: string };
+ setRun({ run_id, preset, status: "running" });
+ const report = await pollRun(run_id, preset);
+ if (report) {
+ setRun(report);
+ if (report.snapshot_start && report.snapshot_end) {
+ pushRunSnaps(report.snapshot_start, report.snapshot_end);
+ }
+ } else {
+ setRun({
+ run_id,
+ preset,
+ status: "failed",
+ error: `Polling timed out after ${pollDeadlineLabel(preset)}; the run may still be executing on the server.`,
+ });
+ }
+ } finally {
+ setBusy(false);
+ }
+ },
+ [clearChart, pushRunSnaps, setBusy, setRun],
+ );
+
+ const confirmHeavy = () => {
+ if (!window.confirm("Run 100,000 logical booking requests against the allowlisted target?")) return;
+ start({ preset: "heavy-100k", confirm_heavy: true });
+ };
+
+ const confirmBenchmarkExhaustion = () => {
+ if (
+ !window.confirm(
+ "Run benchmark-exhaustion-15k (100k logical attempts; assumes 15k ticket seed)?",
+ )
+ ) {
+ return;
+ }
+ start({ preset: "benchmark-exhaustion-15k", confirm_heavy: true });
+ };
+
+ const isGatesRun = run?.preset === "gates" || run?.preset === "phase-gates";
+
+ return (
+
+
+
Run control
+ {busy && run?.preset && (
+ Polling up to {pollDeadlineLabel(run.preset)}…
+ )}
+
+ {run?.error && (
+
+ {run.error}
+
+ )}
+
+ start({ preset: "smoke" })}>
+ Smoke · 100
+
+ start({ preset: "claims", claims: ["INV-1", "INV-2", "INV-3"] })}>
+ INV-1..3
+
+ start({ preset: "claims" })}>
+ INV-1..7
+
+ start({ preset: "gates" })}>
+ Gates P1..P7
+
+
+ Heavy · 100k
+
+
+
+ Benchmarks (15k seed assumption in export)
+ start({ preset: "benchmark-smoke" })}>
+ Bench smoke · 100
+
+ start({ preset: "benchmark-exhaustion-mini" })}>
+ Bench exhaust mini · 5k
+
+ start({ preset: "benchmark-saturated-15k" })}>
+ Bench saturated · 15k
+
+
+ Bench exhaustion · 100k
+
+
+
+
Phase gates (P1..P7)
+
Runs go test subprocesses + live probes (readyz, API key) against VERILAB_TARGET.
+
+ {GATE_IDS.map((p) => {
+ const g = gateById(p);
+ const status = g?.status ?? (isGatesRun && run?.status === "running" ? "RUNNING" : "NOT RUN");
+ const ranAt = g?.evidence?.ran_at;
+ return (
+
+ {p}
+ {status}
+ start({ preset: "gates", gates: [p] })}
+ >
+ Run
+
+ {g?.failure_reason && {g.failure_reason} }
+ {g?.message && {g.message} }
+ {ranAt != null && {String(ranAt)} }
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/verification-lab/web/src/components/StatusBar.tsx b/verification-lab/web/src/components/StatusBar.tsx
new file mode 100644
index 0000000..b0b7943
--- /dev/null
+++ b/verification-lab/web/src/components/StatusBar.tsx
@@ -0,0 +1,33 @@
+import { useLabStore } from "../store/labStore";
+import { ProvenanceBadge } from "./ProvenanceBadge";
+
+export function StatusBar() {
+ const snap = useLabStore((s) => s.snap);
+ const connected = useLabStore((s) => s.connected);
+ const prov = snap?.field_provenance ?? {};
+
+ const lag = snap?.stale_ms ?? 0;
+ const lagClass = lag > 800 ? "lag-bad" : lag > 400 ? "lag-warn" : "lag-ok";
+
+ return (
+
+
+
Verification Lab
+ Ticket Engine V3 · control plane
+
+
+ {connected ? "SSE live" : "SSE reconnecting"}
+ lag {lag}ms
+ seq {snap?.sequence ?? "—"}
+ collect {snap?.collect_ms ?? "—"}ms
+
+
+ Target
{snap?.target_base ?? "…"}
+ {snap?.active_run_id && (
+
run {snap.active_run_id.slice(0, 8)}
+ )}
+
+
+
+ );
+}
diff --git a/verification-lab/web/src/components/Timeline.tsx b/verification-lab/web/src/components/Timeline.tsx
new file mode 100644
index 0000000..72e0a45
--- /dev/null
+++ b/verification-lab/web/src/components/Timeline.tsx
@@ -0,0 +1,40 @@
+import { useMemo } from "react";
+import { useLabStore } from "../store/labStore";
+import { ProvenanceBadge } from "./ProvenanceBadge";
+
+export function Timeline() {
+ const events = useLabStore((s) => s.events);
+ const filter = useLabStore((s) => s.eventFilter);
+ const setFilter = useLabStore((s) => s.setEventFilter);
+
+ const filtered = useMemo(() => {
+ const f = filter.trim().toLowerCase();
+ if (!f) return [...events].reverse();
+ return events.filter((e) => e.kind.toLowerCase().includes(f) || e.message.toLowerCase().includes(f)).reverse();
+ }, [events, filter]);
+
+ return (
+
+ );
+}
diff --git a/verification-lab/web/src/hooks/useTelemetrySSE.ts b/verification-lab/web/src/hooks/useTelemetrySSE.ts
new file mode 100644
index 0000000..08e1deb
--- /dev/null
+++ b/verification-lab/web/src/hooks/useTelemetrySSE.ts
@@ -0,0 +1,40 @@
+import { useEffect } from "react";
+import type { Snapshot } from "../types";
+import { useLabStore } from "../store/labStore";
+
+export function useTelemetrySSE() {
+ const setSnap = useLabStore((s) => s.setSnap);
+ const setConnected = useLabStore((s) => s.setConnected);
+ const pushChart = useLabStore((s) => s.pushChartFromSnap);
+ const mergeEvents = useLabStore((s) => s.mergeEvents);
+
+ useEffect(() => {
+ fetch("/api/snapshot")
+ .then((r) => (r.ok ? r.json() : null))
+ .then((snap) => {
+ if (snap) {
+ setSnap(snap as Snapshot);
+ pushChart(snap as Snapshot);
+ }
+ })
+ .catch(() => {});
+
+ const es = new EventSource("/api/stream");
+ es.onopen = () => setConnected(true);
+ es.onerror = () => setConnected(false);
+ es.addEventListener("snapshot", (ev) => {
+ try {
+ const snap = JSON.parse(ev.data) as Snapshot;
+ setSnap(snap);
+ pushChart(snap);
+ if (snap.events?.length) mergeEvents(snap.events);
+ } catch {
+ /* ignore */
+ }
+ });
+ return () => {
+ es.close();
+ setConnected(false);
+ };
+ }, [mergeEvents, pushChart, setConnected, setSnap]);
+}
diff --git a/verification-lab/web/src/lab.css b/verification-lab/web/src/lab.css
new file mode 100644
index 0000000..911642d
--- /dev/null
+++ b/verification-lab/web/src/lab.css
@@ -0,0 +1,569 @@
+:root {
+ color-scheme: dark;
+ --bg: #05070b;
+ --panel: #0c1018;
+ --panel-2: #121a26;
+ --border: #1f2a3d;
+ --text: #e8eef7;
+ --muted: #7f92ad;
+ --accent: #5eead4;
+ --pass: #4ade80;
+ --fail: #f87171;
+ --inc: #fbbf24;
+ font-family: "IBM Plex Sans", system-ui, sans-serif;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: radial-gradient(1400px 700px at 15% -20%, #152238 0%, var(--bg) 50%);
+ color: var(--text);
+}
+
+.console {
+ max-width: 1440px;
+ margin: 0 auto;
+ padding: 1rem 1.25rem 2.5rem;
+}
+
+.status-bar {
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 0.85rem 1rem;
+ background: linear-gradient(145deg, var(--panel) 0%, #0a0f16 100%);
+ margin-bottom: 0.85rem;
+}
+
+.status-bar h1 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.status-bar .sub {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--muted);
+ margin-top: 0.15rem;
+}
+
+.brand {
+ margin-bottom: 0.5rem;
+}
+
+.status-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-bottom: 0.45rem;
+}
+
+.pill {
+ font-size: 0.7rem;
+ padding: 0.2rem 0.5rem;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ font-variant-numeric: tabular-nums;
+}
+
+.pill.on {
+ border-color: #2d6b62;
+ color: var(--accent);
+}
+.pill.off {
+ color: var(--inc);
+}
+.pill.lag-ok {
+ color: var(--pass);
+}
+.pill.lag-warn {
+ color: var(--inc);
+}
+.pill.lag-bad {
+ color: var(--fail);
+}
+.pill.muted {
+ color: var(--muted);
+}
+
+.target-line {
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.target-line code {
+ color: var(--text);
+ font-size: 0.78rem;
+}
+
+.run-tag {
+ margin-left: 0.5rem;
+ color: var(--accent);
+}
+
+.console-main {
+ display: grid;
+ grid-template-columns: 1.2fr 0.9fr;
+ gap: 0.75rem;
+}
+
+@media (max-width: 1024px) {
+ .console-main {
+ grid-template-columns: 1fr;
+ }
+}
+
+.col-left,
+.col-right {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.panel {
+ background: linear-gradient(160deg, var(--panel) 0%, var(--panel-2) 100%);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 0.75rem 0.85rem;
+}
+
+.panel-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-bottom: 0.55rem;
+}
+
+.panel h2 {
+ margin: 0;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+}
+
+.hint {
+ font-size: 0.72rem;
+ color: var(--muted);
+}
+
+.pipeline-track {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ align-items: stretch;
+}
+
+.pipe-node {
+ position: relative;
+ flex: 1;
+ min-width: 88px;
+ text-align: left;
+ background: #0a121c;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.45rem 0.55rem;
+ color: var(--text);
+ cursor: pointer;
+}
+
+.pipe-node.selected {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 1px #2d6b6244;
+}
+
+.pipe-label {
+ display: block;
+ font-weight: 600;
+ font-size: 0.85rem;
+}
+
+.pipe-hint {
+ display: block;
+ font-size: 0.65rem;
+ color: var(--muted);
+}
+
+.pipe-arrow {
+ position: absolute;
+ right: -0.55rem;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--muted);
+ font-size: 0.75rem;
+ pointer-events: none;
+}
+
+.pipeline-metrics {
+ display: flex;
+ gap: 1rem;
+ margin-top: 0.55rem;
+ font-size: 0.75rem;
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.insp-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 0.5rem;
+}
+
+.insp-card {
+ background: #080d14;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.45rem 0.55rem;
+}
+
+.insp-card h3 {
+ margin: 0 0 0.35rem;
+ font-size: 0.72rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.insp-row {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.78rem;
+ padding: 0.12rem 0;
+}
+
+.insp-row .val {
+ font-variant-numeric: tabular-nums;
+}
+
+.chart-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 0.5rem;
+}
+
+.chart-card {
+ background: #080d14;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.4rem 0.5rem;
+}
+
+.chart-title {
+ font-size: 0.68rem;
+ color: var(--muted);
+ margin-bottom: 0.25rem;
+}
+
+.chart-now {
+ display: flex;
+ align-items: baseline;
+ gap: 0.35rem;
+ margin-bottom: 0.2rem;
+ font-size: 0.72rem;
+}
+
+.chart-now-val {
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.chart-now-unit {
+ color: var(--muted);
+}
+
+.chart-now-n {
+ margin-left: auto;
+ color: var(--muted);
+ font-size: 0.65rem;
+}
+
+.chart-hint {
+ font-size: 0.72rem;
+ margin: 0 0 0.5rem;
+ line-height: 1.35;
+}
+
+.chart-empty {
+ font-size: 0.68rem;
+ color: var(--muted);
+ height: 48px;
+ display: flex;
+ align-items: center;
+}
+
+.spark {
+ width: 100%;
+ height: 48px;
+}
+
+.filter-input {
+ background: #080d14;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 0.75rem;
+ padding: 0.25rem 0.45rem;
+ min-width: 160px;
+}
+
+.timeline {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 220px;
+ overflow: auto;
+ font-size: 0.75rem;
+}
+
+.timeline li {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ align-items: center;
+ padding: 0.28rem 0;
+ border-bottom: 1px solid #1a2435;
+}
+
+.timeline .ts {
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.timeline .kind {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+.timeline .msg {
+ flex: 1;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+}
+
+button {
+ background: #141e2c;
+ border: 1px solid var(--border);
+ color: var(--text);
+ border-radius: 8px;
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ font-size: 0.8rem;
+}
+
+button:hover:not(:disabled) {
+ border-color: var(--accent);
+}
+
+button:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+
+button.primary {
+ background: #0f2f2c;
+ border-color: #2d6b62;
+}
+
+button.danger {
+ border-color: #7f2d2d;
+ background: #2a1212;
+}
+
+.phase-gates {
+ margin-top: 0.75rem;
+ font-size: 0.78rem;
+}
+
+.phase-gates h3 {
+ margin: 0 0 0.35rem;
+ font-size: 0.72rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.phase-gates ul {
+ list-style: none;
+ margin: 0.35rem 0 0;
+ padding: 0;
+}
+
+.phase-gates li {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.2rem 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.failure-lab-panel .warning-text {
+ color: #fbbf24;
+ font-size: 0.78rem;
+ margin: 0.25rem 0;
+}
+
+.failure-scenario-list {
+ list-style: none;
+ margin: 0.5rem 0 0;
+ padding: 0;
+}
+
+.failure-scenario {
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.78rem;
+}
+
+.failure-scenario-head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.failure-scenario-title {
+ font-weight: 600;
+ margin-top: 0.15rem;
+}
+
+.manual-steps ol {
+ margin: 0.35rem 0 0 1.1rem;
+ padding: 0;
+}
+
+.badge {
+ display: inline-block;
+ font-size: 0.6rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ padding: 0.1rem 0.35rem;
+ border-radius: 4px;
+ margin-left: 0.25rem;
+ vertical-align: middle;
+}
+
+.badge.live {
+ background: #12352b;
+ color: var(--pass);
+}
+
+.badge.derived {
+ background: #2a2340;
+ color: #c4b5fd;
+}
+
+.badge.unavailable {
+ background: #3a2a22;
+ color: var(--inc);
+}
+
+.claims-table .claim-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.4rem 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.82rem;
+}
+
+.status {
+ font-weight: 600;
+ font-size: 0.72rem;
+ letter-spacing: 0.05em;
+}
+
+.status.PASS {
+ color: var(--pass);
+}
+.status.FAIL {
+ color: var(--fail);
+}
+.status.INCONCLUSIVE {
+ color: var(--inc);
+}
+.status.NOT_RUN {
+ color: var(--muted);
+}
+
+.claim-row .msg {
+ color: var(--muted);
+ margin-left: 0.45rem;
+ font-weight: 400;
+}
+
+pre.export {
+ margin-top: 0.65rem;
+ background: #060a10;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.55rem;
+ font-size: 0.68rem;
+ overflow: auto;
+ max-height: 200px;
+}
+
+.muted {
+ color: var(--muted);
+}
+
+.run-error {
+ font-size: 0.75rem;
+ color: var(--fail);
+ background: rgba(248, 113, 113, 0.08);
+ border: 1px solid rgba(248, 113, 113, 0.35);
+ border-radius: 8px;
+ padding: 0.45rem 0.55rem;
+ margin-bottom: 0.55rem;
+}
+
+.run-head-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.15rem;
+ max-width: 72%;
+ text-align: right;
+}
+
+.run-stats-line {
+ font-variant-numeric: tabular-nums;
+}
+
+.run-human-line {
+ color: var(--text);
+}
+
+.run-error-inline {
+ color: var(--fail);
+}
+
+.status.completed {
+ color: var(--pass);
+}
+
+.status.failed {
+ color: var(--fail);
+}
+
+.status.running {
+ color: var(--inc);
+}
diff --git a/verification-lab/web/src/lib/pollRun.ts b/verification-lab/web/src/lib/pollRun.ts
new file mode 100644
index 0000000..1cc314d
--- /dev/null
+++ b/verification-lab/web/src/lib/pollRun.ts
@@ -0,0 +1,55 @@
+import type { RunReport } from "../types";
+
+/** Client poll deadline by preset (server may run longer). */
+export function pollDeadlineMs(preset: string): number {
+ const p = preset.toLowerCase();
+ switch (p) {
+ case "smoke":
+ case "":
+ return 2 * 60_000;
+ case "claims":
+ case "gates":
+ case "phase-gates":
+ case "failure-lab":
+ return 10 * 60_000;
+ case "load-1k":
+ case "1k":
+ return 5 * 60_000;
+ case "load-10k":
+ case "10k":
+ return 15 * 60_000;
+ case "load-25k":
+ case "25k":
+ case "load-50k":
+ case "50k":
+ return 30 * 60_000;
+ case "heavy-100k":
+ case "100k":
+ case "benchmark-exhaustion-15k":
+ return 45 * 60_000;
+ case "benchmark-saturated-15k":
+ return 30 * 60_000;
+ case "benchmark-smoke":
+ case "benchmark-exhaustion-mini":
+ return 10 * 60_000;
+ default:
+ return 5 * 60_000;
+ }
+}
+
+export function pollDeadlineLabel(preset: string): string {
+ const ms = pollDeadlineMs(preset);
+ const min = Math.round(ms / 60_000);
+ return `${min}m`;
+}
+
+export async function pollRun(runId: string, preset: string): Promise {
+ const deadline = Date.now() + pollDeadlineMs(preset);
+ while (Date.now() < deadline) {
+ const rep = await fetch(`/api/runs/${runId}`);
+ const body = (await rep.json()) as RunReport;
+ if (body.status !== "running") return body;
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ return null;
+}
diff --git a/verification-lab/web/src/lib/runSummary.ts b/verification-lab/web/src/lib/runSummary.ts
new file mode 100644
index 0000000..1ae1df0
--- /dev/null
+++ b/verification-lab/web/src/lib/runSummary.ts
@@ -0,0 +1,118 @@
+import type { RunReport } from "../types";
+
+const LOAD_PRESETS = new Set([
+ "smoke",
+ "",
+ "heavy-100k",
+ "100k",
+ "load-1k",
+ "1k",
+ "load-10k",
+ "10k",
+ "load-25k",
+ "25k",
+ "load-50k",
+ "50k",
+ "benchmark-smoke",
+ "benchmark-exhaustion-15k",
+ "benchmark-exhaustion-mini",
+ "benchmark-saturated-15k",
+]);
+
+type BenchmarkExtra = {
+ requests_per_sec?: number;
+ latency_ms?: { p50?: number; p95?: number; p99?: number };
+ queue_consumed?: number;
+ mysql_sold_delta?: number;
+ assumed_inventory_seed?: number;
+};
+
+export function benchmarkSummaryLine(run: RunReport): string | null {
+ const bm = run.extra?.benchmark as BenchmarkExtra | undefined;
+ if (!bm) return null;
+ const parts: string[] = [];
+ if (bm.requests_per_sec != null) parts.push(`${bm.requests_per_sec} rps`);
+ const lat = bm.latency_ms;
+ if (lat?.p50 != null) parts.push(`p50 ${lat.p50}ms`);
+ if (lat?.p95 != null) parts.push(`p95 ${lat.p95}ms`);
+ if (lat?.p99 != null) parts.push(`p99 ${lat.p99}ms`);
+ if (bm.queue_consumed != null) parts.push(`queue −${bm.queue_consumed}`);
+ if (bm.mysql_sold_delta != null) parts.push(`mysql sold Δ${bm.mysql_sold_delta}`);
+ if (bm.assumed_inventory_seed != null) parts.push(`seed ${bm.assumed_inventory_seed}`);
+ return parts.length ? parts.join(" · ") : null;
+}
+
+export function isLoadPreset(preset: string): boolean {
+ return LOAD_PRESETS.has(preset.toLowerCase());
+}
+
+function fmtCount(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 10_000) return `${Math.round(n / 1000)}k`;
+ if (n >= 1_000) return `${(n / 1000).toFixed(1)}k`;
+ return String(n);
+}
+
+/** Short human line for exhaustion / load runs (200 vs 404 vs errors). */
+export function loadRunHumanLine(run: RunReport): string {
+ const ok = run.http_200 ?? 0;
+ const soldOut = run.http_404 ?? 0;
+ const err = run.errors ?? 0;
+ const total = run.logical_requests ?? ok + soldOut + err;
+ const p = run.preset.toLowerCase();
+
+ if (err > 0) {
+ return `load: ${fmtCount(ok)} OK, ${fmtCount(soldOut)} sold-out, ${fmtCount(err)} errors`;
+ }
+ if (p === "heavy-100k" || p === "100k" || p === "benchmark-exhaustion-15k" || p === "benchmark-exhaustion-mini") {
+ if (soldOut > 0) {
+ return `exhaustion workload: ${fmtCount(ok)} OK, ${fmtCount(soldOut)} sold-out`;
+ }
+ }
+ if (total > 0 && ok === total) {
+ return `all ${fmtCount(total)} requests OK`;
+ }
+ return `load: ${fmtCount(ok)} OK, ${fmtCount(soldOut)} sold-out`;
+}
+
+export function loadRunStatsLine(run: RunReport): string {
+ const parts: string[] = [];
+ if (run.http_200 != null) parts.push(`200: ${run.http_200}`);
+ if (run.http_404 != null) parts.push(`404: ${run.http_404}`);
+ if (run.errors != null && run.errors > 0) parts.push(`errors: ${run.errors}`);
+ if (run.logical_requests != null) parts.push(`logical: ${run.logical_requests}`);
+ if (run.duration_ms != null) parts.push(`${run.duration_ms}ms`);
+ if (run.latency_p95_ms != null) parts.push(`p95 ${run.latency_p95_ms}ms`);
+ if (run.latency_p99_ms != null) parts.push(`p99 ${run.latency_p99_ms}ms`);
+ const bench = benchmarkSummaryLine(run);
+ if (bench) parts.push(bench);
+ return parts.join(" · ");
+}
+
+export function loadRunStatusLabel(run: RunReport): string {
+ const base = run.status;
+ if (!isLoadPreset(run.preset) || run.logical_requests == null) {
+ return base;
+ }
+ const ok = run.http_200 ?? 0;
+ const soldOut = run.http_404 ?? 0;
+ const err = run.errors ?? 0;
+ const p = run.preset.toLowerCase();
+
+ if (base === "completed") return "completed";
+ if (err > 0) return `${base} (errors present)`;
+ if (
+ (p === "heavy-100k" ||
+ p === "100k" ||
+ p === "benchmark-exhaustion-15k" ||
+ p === "benchmark-exhaustion-mini") &&
+ soldOut > 0 &&
+ err === 0
+ ) {
+ return `${base} (inventory exhausted — ${fmtCount(ok)} OK / ${fmtCount(soldOut)} sold-out)`;
+ }
+ if (ok < (run.logical_requests ?? 0)) {
+ return `${base} (${fmtCount(ok)}/${fmtCount(run.logical_requests ?? 0)} OK)`;
+ }
+ return base;
+}
diff --git a/verification-lab/web/src/main.tsx b/verification-lab/web/src/main.tsx
new file mode 100644
index 0000000..f5a697c
--- /dev/null
+++ b/verification-lab/web/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+import "./lab.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/verification-lab/web/src/store/labStore.ts b/verification-lab/web/src/store/labStore.ts
new file mode 100644
index 0000000..2d25b1c
--- /dev/null
+++ b/verification-lab/web/src/store/labStore.ts
@@ -0,0 +1,109 @@
+import { create } from "zustand";
+import type { PipelineNode, RunReport, Snapshot, TelemetryEvent } from "../types";
+
+/** ~15 min of 200ms frames before display downsampling. */
+const CHART_CAP = 4500;
+
+export type ChartPoint = {
+ t: number;
+ seq: number;
+ reqPerSec: number;
+ successPerSec: number;
+ failPerSec: number;
+ queue: number;
+ pending: number;
+ sold: number;
+};
+
+type LabState = {
+ snap: Snapshot | null;
+ connected: boolean;
+ lastFrameAt: number;
+ chart: ChartPoint[];
+ events: TelemetryEvent[];
+ eventFilter: string;
+ selectedNode: PipelineNode | null;
+ run: RunReport | null;
+ busy: boolean;
+ setSnap: (s: Snapshot) => void;
+ setConnected: (v: boolean) => void;
+ setEventFilter: (f: string) => void;
+ setSelectedNode: (n: PipelineNode | null) => void;
+ setRun: (r: RunReport | null) => void;
+ setBusy: (b: boolean) => void;
+ pushChartFromSnap: (s: Snapshot) => void;
+ pushChartFromRunSnapshots: (start: Snapshot, end: Snapshot) => void;
+ clearChart: () => void;
+ mergeEvents: (ev: TelemetryEvent[]) => void;
+};
+
+export const useLabStore = create((set, get) => ({
+ snap: null,
+ connected: false,
+ lastFrameAt: 0,
+ chart: [],
+ events: [],
+ eventFilter: "",
+ selectedNode: null,
+ run: null,
+ busy: false,
+ setSnap: (s) => set({ snap: s, lastFrameAt: Date.now() }),
+ setConnected: (v) => set({ connected: v }),
+ setEventFilter: (f) => set({ eventFilter: f }),
+ setSelectedNode: (n) => set({ selectedNode: n }),
+ setRun: (r) => set({ run: r }),
+ setBusy: (b) => set({ busy: b }),
+ pushChartFromSnap: (s) => {
+ const t = Date.parse(s.at);
+ if (!Number.isFinite(t)) return;
+ const chart = get().chart;
+ if (s.sequence > 0 && chart.length > 0 && chart[chart.length - 1]?.seq === s.sequence) {
+ return;
+ }
+ const pt: ChartPoint = {
+ t,
+ seq: s.sequence,
+ reqPerSec: s.api_rates?.requests_per_sec ?? 0,
+ successPerSec: s.api_rates?.success_per_sec ?? 0,
+ failPerSec: s.api_rates?.failures_per_sec ?? 0,
+ queue: s.redis?.queue_len ?? s.queue_remaining ?? 0,
+ pending: s.redis?.stream_pending ?? s.stream_pending ?? 0,
+ sold: s.mysql?.sold ?? 0,
+ };
+ const next = [...chart, pt];
+ set({ chart: next.length > CHART_CAP ? next.slice(-CHART_CAP) : next });
+ },
+ pushChartFromRunSnapshots: (start, end) => {
+ const pts: ChartPoint[] = [];
+ for (const s of [start, end]) {
+ const t = Date.parse(s.at);
+ if (!Number.isFinite(t)) continue;
+ pts.push({
+ t,
+ seq: s.sequence,
+ reqPerSec: s.api_rates?.requests_per_sec ?? 0,
+ successPerSec: s.api_rates?.success_per_sec ?? 0,
+ failPerSec: s.api_rates?.failures_per_sec ?? 0,
+ queue: s.redis?.queue_len ?? s.queue_remaining ?? 0,
+ pending: s.redis?.stream_pending ?? s.stream_pending ?? 0,
+ sold: s.mysql?.sold ?? 0,
+ });
+ }
+ if (!pts.length) return;
+ set({ chart: [...get().chart, ...pts].slice(-CHART_CAP) });
+ },
+ clearChart: () => set({ chart: [] }),
+ mergeEvents: (ev) => {
+ if (!ev?.length) return;
+ const seen = new Set(get().events.map((e) => e.seq));
+ const merged = [...get().events];
+ for (const e of ev) {
+ if (!seen.has(e.seq)) {
+ merged.push(e);
+ seen.add(e.seq);
+ }
+ }
+ const trimmed = merged.length > 300 ? merged.slice(-300) : merged;
+ set({ events: trimmed });
+ },
+}));
diff --git a/verification-lab/web/src/types.ts b/verification-lab/web/src/types.ts
new file mode 100644
index 0000000..47b331a
--- /dev/null
+++ b/verification-lab/web/src/types.ts
@@ -0,0 +1,65 @@
+export type TelemetryEvent = {
+ seq: number;
+ at: string;
+ kind: string;
+ message: string;
+ run_id?: string;
+ correlation_id?: string;
+ provenance: string;
+};
+
+export type Snapshot = {
+ at: string;
+ sequence: number;
+ collect_ms: number;
+ stale_ms?: number;
+ target_base: string;
+ active_run_id?: string;
+ correlation_id?: string;
+ target_metrics: { total_requests: number; booking_success: number; booking_failures: number };
+ api_rates: { requests_per_sec: number; success_per_sec: number; failures_per_sec: number };
+ redis: {
+ queue_len: number;
+ user_hash_len: number;
+ stream_len: number;
+ stream_pending: number;
+ dlq_len: number;
+ };
+ worker: { consumers: number; pending: number; last_delivered_id?: string };
+ mysql: { available: number; sold: number; total: number };
+ persistence_lag: { success_gap: number; estimated_ms: number };
+ events: TelemetryEvent[];
+ field_provenance: Record;
+ queue_remaining: number;
+ stream_pending: number;
+};
+
+export type ClaimResult = {
+ id: string;
+ status: string;
+ message: string;
+ failure_reason?: string;
+ evidence: Record;
+ registry_tests: string[];
+};
+
+export type RunReport = {
+ run_id: string;
+ preset: string;
+ status: string;
+ claims?: ClaimResult[];
+ http_200?: number;
+ http_404?: number;
+ errors?: number;
+ logical_requests?: number;
+ duration_ms?: number;
+ latency_p95_ms?: number;
+ latency_p99_ms?: number;
+ snapshot_start?: Snapshot;
+ snapshot_end?: Snapshot;
+ /** Client-side or API error text (POST failure, poll timeout). */
+ error?: string;
+ extra?: Record;
+};
+
+export type PipelineNode = "api" | "redis" | "stream" | "worker" | "mysql";
diff --git a/verification-lab/web/src/vite-env.d.ts b/verification-lab/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/verification-lab/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/verification-lab/web/tsconfig.json b/verification-lab/web/tsconfig.json
new file mode 100644
index 0000000..eda4905
--- /dev/null
+++ b/verification-lab/web/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "strict": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
+ },
+ "include": ["src"]
+}
diff --git a/verification-lab/web/tsconfig.node.json b/verification-lab/web/tsconfig.node.json
new file mode 100644
index 0000000..2ab3f37
--- /dev/null
+++ b/verification-lab/web/tsconfig.node.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/verification-lab/web/vite.config.ts b/verification-lab/web/vite.config.ts
new file mode 100644
index 0000000..4f62823
--- /dev/null
+++ b/verification-lab/web/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5173,
+ proxy: {
+ "/api": "http://127.0.0.1:8090",
+ },
+ },
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ },
+});
diff --git a/worker.go b/worker.go
new file mode 100644
index 0000000..d5ba192
--- /dev/null
+++ b/worker.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "context"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "ticketengine/internal/persist"
+)
+
+// runPersistConsumer is the only entry that should invoke consumer.Run (overridden in tests).
+var runPersistConsumer = func(ctx context.Context, c *persist.Consumer) {
+ c.Run(ctx)
+}
+
+func startPersistWorkerLoop(ctx context.Context) {
+ name := resolvePersistConsumerName()
+ consumer := persist.NewConsumer(redisClient, db, name)
+ if err := consumer.EnsureGroup(ctx); err != nil {
+ log.Fatalf("persist consumer group: %v", err)
+ }
+ log.Printf("Persist consumer running (name=%s)", name)
+ runPersistConsumer(ctx, consumer)
+}
+
+func resolvePersistConsumerName() string {
+ if v := os.Getenv("TICKET_PERSIST_CONSUMER_NAME"); v != "" {
+ return v
+ }
+ if h := os.Getenv("HOSTNAME"); h != "" {
+ return "worker-" + h
+ }
+ return persist.ConsumerName
+}
+
+func runWorkerCommand() {
+ log.Println("Starting persist worker...")
+ os.Setenv("TICKET_ENGINE_ROLE", "worker")
+ initMySQL()
+ initRedis()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ go func() {
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+ <-sigCh
+
+ drainCtx, drainCancel := context.WithTimeout(context.Background(), persist.ShutdownDrainTimeout)
+ defer drainCancel()
+
+ consumer := persist.NewConsumer(redisClient, db, resolvePersistConsumerName())
+ pending, err := consumer.GroupPendingCount(drainCtx)
+ if err != nil {
+ log.Printf("worker shutdown: group pending count: %v", err)
+ } else {
+ log.Printf("worker shutdown signal received (group_pending=%d), draining up to %s", pending, persist.ShutdownDrainTimeout)
+ }
+
+ cancel()
+ }()
+
+ startPersistWorkerLoop(ctx)
+ log.Println("persist worker stopped")
+}