Skip to main content

Proposal: exd Console

Status: Draft / not yet implemented. Authoring tracking branch: claude/statsig-ui-exd-server-bAdzW.

A Statsig-class web admin console for exd-server. Goal is not visual parity with Statsig but feature parity with the day-to-day flag-management experience operators expect — minus the parts exd has deliberately chosen not to offer (today: experiments, archival).

1. Decisions of record

These were settled with the user before this doc was written. They are inputs to the design, not open questions.

DecisionChoice
Write modelFull read/write through the existing REST surface. Every edit commits to the namespace's bare git repo with source = "rest". Git push and PR-based workflows remain in parallel.
DeploymentBundled into the exd-server binary behind a console-ui cargo feature; served from /console/* on the same origin as the API. One binary.
Scope of v1Full CRUD on flags / segments / env blocks / namespace descriptor; tokens; tenants; manifest version history; sandbox eval; telemetry dashboard included.
AuthenticationSession login built first. Two login modes: Google OIDC and email-domain magic-link. No service-token paste-and-go in the console.
Flag archivalNot in v1. Lifecycle stays `temporary
ExperimentsDeferred to a future native primitive. v1 treats lifecycle = "experiment" as a filter chip only.
Email deliverySMTP config block added to exd-server. Unset → fall back to printing magic links to stderr (bootstrap-style).
OIDC scopeGoogle only as a hardcoded provider. Generic OIDC is a later extension.
Default telemetry sinkBundle DuckDB-on-disk. Server gains a query proxy so the console's /telemetry/* endpoints work out of the box.
WASM bundleEditor uses the lite WASM bundle; sandbox lazy-imports the full bundle only when opened.
Primary interaction surfaceMobile. Console is designed mobile-first; tablet and desktop are progressive enhancements.

2. Architecture

2.1 Crate layout

crates/
exd-console/ # new crate
Cargo.toml # exd-console = { ..., default-features = ["embed"] }
src/
lib.rs # axum::Router::new()-style mountable router
routes.rs # /console/* serving + SPA index fallback
assets.rs # rust-embed of dist/
web/ # SPA source
package.json
vite.config.ts
tsconfig.json
src/
main.tsx
routes/ # TanStack Router file-based routes
...
public/ # static assets (icons, manifest.webmanifest, robots.txt)
dist/ # build output, embedded by build.rs
build.rs # runs `pnpm install && pnpm build` if `embed` is on

exd-server gains a console-ui feature that pulls in exd-console and mounts its router at /console. cargo build --release --features console-ui produces one binary with the SPA inside.

For development the operator runs pnpm dev (Vite HMR) and the dev server proxies /api/* and /auth/* to a running exd-server.

2.2 Tech stack

LayerPickWhy
FrameworkReact 19 + TypeScript + ViteLargest contributor pool; Vite plays well with WASM.
RoutingTanStack RouterFile-based + type-safe; URL state carries tenant / namespace / flag / env / version.
DataTanStack QueryCaching, optimistic updates, ETag-aware.
StylingTailwind v4 + shadcn/uiDensity without rebuilding primitives.
Mobile primitivesvaul (bottom sheets), @radix-ui/* via shadcn, @use-gesture/react (touch)Native-feeling sheets, drawers, swipe gestures.
Formsreact-hook-form + zodSchema-validate against generated OpenAPI types.
API typesopenapi-typescript + openapi-fetch against exd-server-admin openapi outputSingle source of truth. CI fails on drift.
In-browser lint@exd/client lite WASM bundlePreview lint before save without round-trip.
ChartsuPlot (time series) + lightweight bar/pie componentsuPlot is small (~40 KB), touch-friendly, fast. Recharts has touch issues at scale.
Diff (TOML / structured)hand-rolled unified diff component; Monaco diff editor only on desktop Versions detail (route-level code split)Monaco is ~3 MB; unacceptable on mobile shell.
Drag-and-dropdnd-kit with pointer + touch sensors; always paired with up/down keyboard buttonsDrag works on touch; buttons are the a11y / small-screen fallback.
PWAVite PWA plugin (Workbox under the hood)Service worker, manifest, install prompt, Web Push registration.
Error reportingfirst-party — POST /api/v1/console/diagnosticsNo third-party shipping by default. Operators wire Sentry themselves.

2.3 Bundling and serving

  • crates/exd-console/src/routes.rs mounts:
    • GET /console/assets/* — static, immutable, Cache-Control: public, max-age=31536000, immutable.
    • GET /console/manifest.webmanifest, /console/sw.js, /console/icons/* — PWA assets.
    • GET /console/* (anything else) — serve index.html with Cache-Control: no-cache so the shell is always fresh and the immutable asset hashes inside it pick up new bundles.
  • Strict response headers on every console route:
    • Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'
    • X-Frame-Options: DENY
    • Referrer-Policy: strict-origin-when-cross-origin
    • Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
    • HSTS already set by exd-server; no change.
  • The console never accepts cross-origin requests; connect-src 'self' is enforceable because the API is on the same host. Operators who want to fork the SPA onto a separate host are unsupported in v1.

3. Server prerequisites

3.1 Session login

New database tables (single migration):

users(
id UUID PRIMARY KEY,
email CITEXT UNIQUE NOT NULL,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL,
last_login_at TIMESTAMPTZ,
status TEXT NOT NULL -- 'active' | 'suspended'
)

sessions(
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
secret_hash BYTEA NOT NULL, -- HMAC-SHA-256 of session secret
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ,
ip INET,
user_agent TEXT
)

tenant_user_roles(
tenant_slug TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users(id),
role TEXT NOT NULL, -- 'tenant-admin' | 'namespace-read'
-- | 'namespace-write' | 'namespace-client'
namespace_slug TEXT, -- NULL when role is tenant-wide
environment_slug TEXT, -- NULL except for namespace-client
granted_by UUID REFERENCES users(id),
granted_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant_slug, user_id, role, namespace_slug, environment_slug)
)

superadmins(
user_id UUID PRIMARY KEY REFERENCES users(id),
granted_by UUID REFERENCES users(id),
granted_at TIMESTAMPTZ NOT NULL
)

magic_links(
id UUID PRIMARY KEY,
email CITEXT NOT NULL,
secret_hash BYTEA NOT NULL,
purpose TEXT NOT NULL, -- 'login' | 'invite' | 'bootstrap'
redirect_to TEXT,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
)

User identity is global, not per-tenant. A single user record can have roles in multiple tenants via tenant_user_roles. This resolves the magic-link ambiguity case where the same email exists in two tenants — there is only one user, and the active tenant is tracked in URL state.

3.1.1 Login modes (in tenants.login_mode)

  • email_domain — user types email; if the domain matches tenants.email_domain, server mints a magic_links row and sends the link via SMTP. Link is single-use, 30 min TTL, signed token. Following the link sets the session cookie and redirects to redirect_to (default /console).
  • google — user clicks "Sign in with Google"; server begins OIDC authorization-code + PKCE against https://accounts.google.com; on callback the email_verified=true claim is required and the email domain must match tenants.email_domain (or be on an allowlist on the tenant). User auto-created on first login if domain matches; otherwise login is refused.
  • bootstrap (internal) — used once per installation, see §3.1.5.

OIDC client id and client secret are configured per-tenant (tenants.sso_client_id, tenants.sso_client_secret_encrypted); Google issuer URL is hardcoded.

3.1.2 New auth endpoints

Method + PathPurpose
POST /api/v1/auth/login/startBody { email, tenant?, redirect_to? }. For email_domain tenants: mints magic link, returns { method: "email", sent: true }. For google tenants: returns { method: "google", redirect_url: "https://accounts.google.com/o/oauth2/v2/auth?..." }. Rate-limited per email and per IP (token-bucket; 3/min, 20/hour). Generic responses — never leaks whether the email exists.
GET /api/v1/auth/login/callbackHandles both magic-link and OIDC callback (distinguished by query params). Verifies, creates sessions row, sets __Host-exd_session cookie (HttpOnly, Secure, SameSite=Lax, Path=/), sets exd_csrf cookie (not HttpOnly), 302 redirects.
POST /api/v1/auth/logoutRevokes session, clears cookies. Requires CSRF header.
GET /api/v1/auth/whoamiReturns { user, tenants: [{slug, display_name, roles[]}], superadmin: bool }. Console calls this on every load.
GET /api/v1/auth/sessionsLists caller's active sessions (id, created, last_seen, ip, user_agent, is_current).
DELETE /api/v1/auth/sessions/{id}Revoke one session. CSRF-protected.

3.1.3 Principal plumbing

The existing Principal enum gains a Session(SessionPrincipal) variant alongside the token variants. The route authorization layer already enforces superadmin / tenant-admin / namespace-{read,write,client} scopes against the principal; session roles map onto the same scopes via tenant_user_roles and superadmins. Route handler code does not change.

3.1.4 CSRF

Cookies are used only by browser flows. State-changing requests from the cookie-authenticated console (any non-GET) must include X-Exd-Csrf: <value> matching the exd_csrf cookie (double-submit). Token-authenticated requests (CLI, SDKs) bypass CSRF since they don't carry cookies. Same-origin + SameSite=Lax is the primary defense; the header check is defense in depth.

3.1.5 Bootstrap upgrade path

The existing exd-server-admin bootstrap command continues to mint the first superadmin token. After the migration, operators upgrading an existing install run:

exd-server-admin bootstrap-user --email <addr>

This creates a users row, a superadmins row pointing at it, and a magic_links row with purpose = 'bootstrap'. The link is emailed via SMTP if configured; otherwise printed to stderr. Following it sets the session cookie. Fresh installs can call this immediately after bootstrap.

3.1.6 Session lifetime

  • Idle: 8 hours from last_seen_at. Updated on every authenticated request, debounced to once per minute server-side to avoid write amplification.
  • Absolute: 24 hours from created_at.
  • "Remember me" on login: idle becomes 30 days, absolute becomes 90 days. Off by default.
  • Logout revokes server-side (revoked_at set). Subsequent requests with the cookie 401.
  • Concurrent sessions allowed; visible in GET /auth/sessions; users can revoke individually.

3.2 SMTP config block

New environment-variable group:

VariablePurposeDefault
EXD_SMTP_HOSTSMTP server hostnameunset
EXD_SMTP_PORTPort587
EXD_SMTP_USERNAMESMTP userunset
EXD_SMTP_PASSWORDSMTP passwordunset
EXD_SMTP_FROMFrom addressunset
EXD_SMTP_STARTTLStrue / falsetrue

If EXD_SMTP_HOST is unset, every email-sending path (magic-link login, invitations, bootstrap link) falls back to writing the link to stderr at info level on the audit tracing target with a clear // EMAIL FALLBACK: SMTP unconfigured prefix. This matches how the existing bootstrap token is surfaced and keeps fresh installs unblocked.

3.3 Default telemetry sink

New optional config:

VariablePurposeDefault
EXD_TELEMETRY_DIRDirectory holding the default DuckDB telemetry file${EXD_DATA_DIR}/telemetry
EXD_TELEMETRY_DEFAULT_SINKduckdb to enable; none to disableduckdb

When the default sink is enabled, the server opens ${EXD_TELEMETRY_DIR}/evaluations.duckdb and registers it as a sink against the canonical telemetry pipeline used by the SDK telemetry layer. Operators who run external sinks (OTLP, Parquet, object-store) still configure those as before; this is additive.

The server exposes a query proxy at GET /api/v1/tenants/{t}/namespaces/{ns}/telemetry/{query} that delegates to crates/exd-client/src/telemetry/queries/ against the local DuckDB. The console hits this for charts.

Operators who don't want telemetry can set EXD_TELEMETRY_DEFAULT_SINK=none; the dashboard then shows a clear empty state with a link to docs.

3.4 New non-auth endpoints

Method + PathPurpose
GET /api/v1/activityCross-namespace activity feed: manifest versions + audit events + token mints, merged by timestamp. Scoped to the principal's authorization. Cursor-paginated. Powers Home / Activity.
POST /api/v1/tenants/{t}/namespaces/{ns}/sandbox/evaluateEval one flag against an arbitrary EvalContext and an arbitrary manifest version (current or historical). Returns { variant, matched_rule, walk_steps[], bucket_hash? }. Read-only; no telemetry recorded.
GET /api/v1/tenants/{t}/namespaces/{ns}/diff?from=&to=Structural diff between two manifest versions: per-flag added/removed/modified with field-level changes. Powers the Versions detail page.
GET /api/v1/tenants/{t}/namespaces/{ns}/telemetry/{query}Telemetry query proxy. See §3.3.
POST /api/v1/console/diagnosticsClient-side error reporting endpoint. Writes to the audit.console tracing target. Rate-limited per session.

3.5 OpenAPI as contract

exd-server-admin openapi already emits OpenAPI 3.1 JSON. CI runs the console's type generation against the latest output on every PR; drift between server and console becomes a build failure. The openapi artifact is published as a build artifact for offline regeneration.

4. Information architecture (mobile-first)

4.1 Layout strategy

  • Mobile-first CSS. Design for ~390px viewport first.
  • Breakpoints: sm 640, md 768, lg 1024, xl 1280. The three-pane flag editor only exists at lg and above.
  • Tables become cards on mobile, table layouts above md.
  • Hover-only affordances are forbidden. Every action that exists on desktop hover is reachable by tap on mobile.
  • Touch targets ≥ 44×44 px (Apple HIG); ≥ 48 dp recommended on Android.
  • Safe-area insets respected (env(safe-area-inset-bottom) etc.) so bottom tab bar clears iOS home indicator and Android gesture navigation.
  • Input zoom suppressed on iOS by ensuring all <input> font-size ≥ 16px.

4.2 Mobile chrome

┌─────────────────────────────────────┐
│ [≡] exd / acme ▾ [🔍] [👤]│ ← Top bar (sticky)
├─────────────────────────────────────┤
│ │
│ page content (drill-down) │
│ │
├─────────────────────────────────────┤
│ 🏠 📦 🔬 📊 ⋯ │ ← Bottom tab bar
│ Home Namespaces Sandbox Telemetry │
└─────────────────────────────────────┘
  • Bottom tab bar carries the five most-used destinations. Tap-and-hold a tab in a future iteration could surface tenant-scoped quick actions.
  • The "⋯ More" tab opens a sheet with: Tokens, Tenant settings, Audit log, Tenants (superadmin), User settings, Sign out.
  • Tenant switcher is a chip in the top bar; tap opens a full-height bottom sheet listing every tenant the user belongs to.
  • ⌘K palette becomes a full-screen search modal on mobile, opened by the 🔍 icon. Voice input where supported (webkitSpeechRecognition).
  • Hamburger menu (≡) is intentionally absent on phones — navigation is bottom-tab + drill-down. It appears at md+ as a collapsible side rail.

4.3 Tablet / desktop chrome

At md+ the bottom tab bar moves to a collapsible left rail. At lg+ the flag editor switches from drill-down to a three-pane layout. At xl+ the activity feed sits beside the namespace list on Home.

4.4 Sitemap

/console
/login (login screens — not authed)
/ Home / Activity
/search ⌘K full screen (mobile)
/t/:tenant
/ Tenant home (recent activity, namespace list)
/namespaces
/:ns
/ Namespace overview
/flags Flags list
/:flag Flag detail (mobile drill-down)
/env/:env Env tab detail (mobile)
/rules/:id Rule edit (mobile sheet)
/sandbox Inline sandbox for this flag
/segments
/:segment
/environments
/versions
/:version Version detail + diff
/telemetry Per-namespace telemetry
/settings namespace.toml editor
/sandbox Cross-namespace sandbox
/telemetry Cross-tenant telemetry rollup
/tokens
/settings Tenant settings (SSO, SMTP-related, users, roles)
/audit
/tenants Superadmin only
/me User settings (sessions, name)

5. Per-view specifications

For each view, the mobile design is described first; tablet/desktop are noted only where they meaningfully differ.

5.1 Login

  • Mobile-first single-column form. Logo, tenant slug field (or subdomain detection if hosted as tenant.exd.example.com), email field, "Continue" button.
  • If tenant is google mode: button becomes "Continue with Google" and triggers redirect.
  • If tenant is email_domain mode: shows "We sent a link to {email}. Open it on this device" with a fallback "Resend" after 30s.
  • Magic-link callback page handles both desktop and in-app browsers; sets cookies; redirects to redirect_to or /console.
  • "Remember me" toggle defaults off.

5.2 Home / Activity

  • Top: three KPI cards (namespaces, evals/sec, open lint warnings).
  • Below: activity feed as a vertical list of cards (one per event). Each card: actor avatar, action, scope chip, timestamp ("2m ago"). Pull-to-refresh.
  • Infinite-scroll with cursor pagination; SSE listener also prepends new events in real time.
  • Tablet: KPI cards in a row across; feed full-width below.
  • Desktop: feed in a left column, KPI summary chart in a right column.

5.3 Namespaces list

  • Card per namespace: slug, display name, version chip, last commit author/time, env chips, lint badge.
  • Pull-to-refresh. "+ New namespace" floating action button on mobile; in the table header at md+.
  • "+ New namespace" opens a bottom sheet (mobile) / modal (desktop).

5.4 Namespace overview

  • Mobile: vertical sections — Stats card (flag/segment/env counts), Recent versions (last 5 as cards, tap to detail), Diagnostics (warning rows), Quick links (last-edited flags).
  • "Clone this namespace" button reveals the git URL with a copy button.

5.5 Flag editor — the hardest view

The desktop three-pane layout (metadata / environments / live preview) collapses on mobile into a drill-down flow:

Mobile flow

  1. Flag detail page (/flags/:flag):

    • Header card: name, type chip, lifecycle chip, owner, tags. Tap header to edit metadata in a bottom sheet.
    • Variants section: list of variants with value chips. Tap to edit a variant value (bottom sheet with type-aware input — number pad for numerics, JSON editor for json).
    • Environments section: one row per env, showing the env's effective default (variant) and rule count. Tap → env detail.
    • Sticky bottom button: "Test" → opens sandbox bottom sheet pre-filled with this flag.
  2. Env tab detail (/flags/:flag/env/:env):

    • Sticky env name + back chevron.
    • testing = true toggle at top with an inline explainer chip linking to docs.
    • Variant selector for step 2 / step 4 of the resolution walk (bottom sheet picker).
    • Rules list: vertical cards. Each rule card shows audience chip (segment name or inline-predicate summary), → variant, optional description.
      • Reordering: each card has a drag handle on the right (44×44 px touch target). Long-press to grab; haptic feedback (navigator.vibrate(10) where supported). Cards also expose explicit ▲ ▼ buttons (visible on :focus-within and always on mobile) for keyboard / a11y / small-screen users.
      • Tap a card → rule edit sheet.
    • "+ Add rule" button at the bottom.
  3. Rule edit sheet (/rules/:id):

    • Bottom sheet (slides from bottom, drag-to-dismiss). Full-height on mobile.
    • Audience: segment-or-predicate toggle. Segment mode: searchable list. Predicate mode: vertical stack of attribute → operator → value rows. Native HTML inputs where possible (type=number, type=text); chip-style multi-select for in / not in operators.
    • Variant: bottom-sheet picker.
    • Description: textarea.
    • "Save" pins to the bottom of the sheet above the keyboard.
  4. Inline sandbox (/sandbox under the flag): same as global sandbox but pre-pinned.

Save flow

  • Each save shows a bottom sheet "Save reason" prompt with a default message and edit field. Confirmation triggers the REST write.
  • Optimistic UI update via TanStack Query; on 412 (ETag mismatch) UI shows the three-way conflict resolver (see §6.2).
  • Errors surfaced as toasts (top of viewport, above safe-area).

Desktop layout (lg+)

Three-pane: metadata left, environments center (tabs), live sandbox right. Drag-to-reorder rules with mouse. Same save flow.

5.6 Segments

  • Mobile: card list (key, predicate summary, "used by N flags").
  • Detail page: predicate builder (same stacked structured editor as rules) + bucket editor (percentage by attribute).
  • "Delete" disabled when references > 0; tap shows the same diagnostic the linter would.

5.7 Environments

  • Read-mostly. For each declared env: env slug, current effective default per flag (catch-all variant or named override), counts of flags with rules, counts of flags with testing = true.
  • Editing the env list is in namespace settings (rename / add / remove env) — each is a namespace.toml edit through the linter.

5.8 Versions

  • Mobile: vertical timeline list. Each row: version, SHA prefix, author, message, source chip (put / push / rest), change counts. Tap → detail.
  • Version detail: structural diff as expandable cards (per-flag changes). "View raw TOML diff" link reveals unified diff. Side-by-side diff is desktop-only.
  • "Revert" button confirms with a dry-run lint, then commits.

5.9 Telemetry

  • Time-range picker: native <input type="datetime-local"> at the top.
  • Env filter and flag filter chips.
  • Charts stack vertically on mobile, grid at md+:
    • Eval volume stacked area by variant (uPlot).
    • Variant distribution pie + rolling time series.
    • Telemetry diagnostics (T-codes): table of severity / code / count, tap row for sample records.
  • Charts that lack underlying T-codes today render an empty state explaining what would appear once the catalog supports them. The Telemetry team can plumb new T-codes (T-014+) for any aggregate the dashboard needs that the Phase-1 query catalog doesn't cover; tracked separately per the recipe in root CLAUDE.md.
  • Pinch-zoom on the time axis where touch is available. Tap a chart point for a tooltip card.

5.10 Sandbox

  • Pick namespace (chip), flag (search-driven sheet), manifest version (defaults to current; "Use historical version" reveals a version picker).
  • Build context: entity ID, then key/value rows with type tags (string, i64, f64, bool, json).
  • Include testing rules toggle.
  • "Evaluate" button. Result card shows: chosen variant chip, matched rule with link to its source location in TOML, bucket hash for percentage rollouts, the full four-step resolution walk annotated with which step short-circuited.
  • "Copy as parity fixture" emits JSON matching fixtures/evaluation-parity.json shape, with one tap.

5.11 Tokens

  • List per scope: type, name, last used, expires, status.
  • "+ Mint token" bottom sheet (mobile) mirrors exd-server-admin token mint. After mint, secret is shown once with a copy button and an "I've saved it" confirm. Going back without confirming requires an explicit "Discard" tap.
  • Rotate / revoke buttons per token.

5.12 Tenant settings

  • Display name, login mode (Google / email-domain), Google client id+secret (if Google), email domain.
  • User roster: list of users with their roles. Invite by email → magic-link sent. Tap a user to change role or remove.
  • "Test SMTP" button under email-domain mode sends a test magic-link-style email to the operator's address.

5.13 Tenants (superadmin)

  • Tenant list + create + suspend. Form fields match exd-server-admin tenant create.

5.14 Audit log

  • Filtered by tenant / namespace / actor / time / event type. Tap row for raw tracing payload.
  • Mobile: cards with summary; payload in expandable detail.

5.15 User settings

  • Display name.
  • Sessions list (GET /auth/sessions) with revoke buttons per session. Current session marked.

6. Concurrency and write model

6.1 REST write path

Every console write — flag CRUD, segment CRUD, env-block CRUD, rule reorder, namespace descriptor edit — goes through the existing REST endpoints. The endpoints already funnel into lint::lint_with_model and commit to the namespace's bare git repo with source = "rest". The session user becomes the commit author (name = display_name, email = email).

6.2 ETag / If-Match conflict resolution

Every editor view loads its data with the manifest version that produced it. Saves include If-Match: <version>. On 412 Precondition Failed:

  1. Server returns the current head + a structural diff between (loaded base) and (current head) scoped to the resource being edited.
  2. Console opens a three-way conflict resolver: their pending edit / their loaded base / current head, with field-level highlights.
  3. User chooses: discard their change, override (force-write with new If-Match: <head>), or re-apply their edit on top of head and resubmit.
  4. Auto-rebase is disabled even for trivial cases. An admin tool's conflict policy should default to manual.

6.3 SSE live updates

The console maintains one SSE connection to /api/v1/events subscribed to the user's visible namespaces. Events surface as:

  • Passive banners in list views ("Version 47 just landed — refresh").
  • Conflict warnings in active editor views when the head moves past the loaded version. The editor still saves through ETag, but the warning gives users a heads-up before they hit a 412.

The single SSE connection lives in a background context (TanStack Query subscription) and reconnects with since= resume on transient network drops.

6.4 Git-parallel writes

UI edits coexist with git push and PUT /manifest. The pre-receive hook lints every push, so a bad push can never become the new head and poison editor sessions. The activity feed shows all three sources interleaved. The console never assumes it is the only writer.

6.5 Branches

exd-server only accepts refs/heads/main. There is no preview/branch concept exposed to the console.

7. Telemetry

  • Default sink: DuckDB on disk under ${EXD_TELEMETRY_DIR}/evaluations.duckdb. Operators can disable with EXD_TELEMETRY_DEFAULT_SINK=none.
  • Query proxy at GET /api/v1/tenants/{t}/namespaces/{ns}/telemetry/{query} delegates to the canonical query layer in crates/exd-client/src/telemetry/queries/.
  • Console charts only render for queries whose backing T-codes are implemented today. The rest show explanatory empty states until the query catalog catches up.
  • New aggregate queries needed by the console (e.g. cross-flag variant distribution over time) get a new T-code per the recipe in the root CLAUDE.md; the console then enables the chart once the T-code ships.
  • The dashboard never surfaces private_attributes — those are redacted at the SDK before they ever land in a sink, so the constraint is upheld upstream. Worth re-stating in code review of any new telemetry endpoint.

8. Security

ConcernMitigation
XSSStrict CSP (see §2.3). React's escape-by-default. No dangerouslySetInnerHTML.
CSRFDouble-submit token via __Host-exd_session cookie + X-Exd-Csrf header. SameSite=Lax on the session cookie.
Session hijackingHttpOnly + Secure cookies. Session secret hashed at rest. Idle + absolute expiry. Sessions revocable per-device.
Magic-link abuseRate-limited at POST /auth/login/start per email (3/min, 20/hour) and per IP (10/min, 60/hour). Links single-use, 30 min TTL. Errors are generic; never leak existence.
ClickjackingX-Frame-Options: DENY + frame-ancestors 'none' in CSP.
Open redirectredirect_to in login params is validated against an allowlist of console-internal paths.
Insufficient OIDC verificationAlways require email_verified=true from Google. Compare email domain against tenants.email_domain or allowlist before creating a user.
Secret leakage in audit logsNever log raw cookies or secrets. Session IDs only.
Stored secretsSMTP password and OIDC client secret stored in DB encrypted at the application layer using the existing server_secrets.closure_signing_key-style key (rename to installation_master_key if we extend it).

9. Mobile-specific concerns

9.1 PWA

  • manifest.webmanifest: name "exd Console", short name "exd", display: standalone, theme color matches brand, icons at 192 / 512 / 1024 px (also apple-touch-icon meta).
  • Service worker (Workbox): pre-cache the SPA shell (HTML + immutable asset hashes) for instant loads. API responses are not cached — flag state going stale is dangerous; every API call is network-first with no offline fallback for write operations.
  • Read-only offline fallback: cached last namespace overview is served with a clear "offline — data may be stale" banner. Write attempts return an immediate "you're offline" toast and are not queued. Background sync is not in v1.
  • Install prompt surfaced after the user logs in successfully twice (not on first visit — anti-spam).
  • On iOS, "Add to Home Screen" guidance shown to Safari users on iOS 16.4+ since Web Push only works once installed there.

9.2 Web Push

  • Optional; user opts in from User Settings.
  • Trigger events (v1): your manifest push failed lint; someone in your tenant added you to a new namespace; a flag you authored last has been edited by someone else.
  • VAPID keys minted by the server at bootstrap (server_secrets.vapid_public_key, vapid_private_key_encrypted).
  • Server endpoint POST /api/v1/console/push/subscribe records the subscription against the session user. Notifications sent via standard Web Push protocol; SMTP-style fallback is not appropriate (use Web Push or nothing).

9.3 Performance budgets

MetricBudget
Initial JS (shell, gzipped)≤ 150 KB
Initial CSS (gzipped)≤ 30 KB
All lazy chunks combined (gzipped)≤ 1 MB
Lite WASM (lazy, gzipped)≤ 350 KB
LCP on Moto G4 + Slow 4G (Lighthouse)< 2.5 s
INP on the flag editor< 200 ms
CLS< 0.05

CI runs Lighthouse against the built bundle in mobile profile; budget regressions fail the build.

9.4 Touch & input

  • All tap targets ≥ 44×44 px. Spacing ≥ 8 px between adjacent targets.
  • Drag-and-drop uses dnd-kit pointer + touch sensors with long-press activation (300 ms) and haptic feedback. Up/Down buttons are always present on every reorderable card so users never have to drag if they don't want to (a11y + small-screen + reduced-motion fallback).
  • Native HTML inputs preferred (<input type=number>, <input type=datetime-local>, <input type=email>) for native keyboards and pickers.
  • Inputs use font-size: 16px minimum to suppress iOS auto-zoom.
  • Bottom sheets respect env(safe-area-inset-bottom) and keyboard insets (visualViewport API).
  • Pull-to-refresh on list views uses native scroll containers plus a small custom indicator.

9.5 Device & browser support

  • iOS Safari 16.4+ (Web Push requirement).
  • Android Chrome current and previous major.
  • Desktop: last 2 majors of Chrome / Firefox / Safari / Edge.
  • No IE, no pre-16 Safari, no Samsung Internet pre-23.

9.6 Real-device testing

  • Lighthouse mobile profile in CI.
  • BrowserStack matrix run weekly: iPhone SE (small), iPhone 15 Pro (notch), Pixel 7, Samsung A-series (low-end Android). Run smoke tests of: login, list a namespace, edit a flag, save, view telemetry chart.

10. Accessibility

  • Target: WCAG 2.2 AA.
  • Full keyboard navigation everywhere. The flag editor's drag-to-reorder has explicit Up/Down keyboard fallback (§9.4).
  • Visible focus rings on every interactive element. Skip-link to main content on every page.
  • Color contrast ≥ 4.5:1 for text, ≥ 3:1 for UI controls. Dark mode meets the same bars.
  • Reduced motion (prefers-reduced-motion: reduce) disables sheet slide animations and chart entry animations.
  • Screen-reader pass: every chart has a tabular fallback exposed via aria-describedby. Every action has an aria-label. Live regions announce SSE-driven banners.
  • Automated axe-core run in CI; manual screen-reader pass on every editor view before Phase 2 ships.

11. Theming and localization

  • Light + dark themes, system-preference default. Theme switch in User Settings.
  • shadcn/ui CSS variable system; brand color tokens centralized.
  • English only in v1. Every user-facing string goes through a single t() helper that today returns the input unchanged but routes through a message catalog. Adding a second language later becomes a translation effort, not a code rewrite.
  • Dates and times render in the user's local TZ with UTC shown on hover (title attribute on desktop; tap-to-toggle on mobile). Relative times ("2m ago") in lists, absolute in detail views, both in the audit log.

12. Observability of the console itself

  • POST /api/v1/console/diagnostics accepts { event, severity, breadcrumbs[], context } and writes to the audit.console tracing target. The console's error boundary auto-reports unhandled exceptions; manual reports are also possible from a "Report an issue" affordance under User Settings.
  • No third-party error shipping by default. Operators who want Sentry / Datadog forward audit.console from their log pipeline.
  • Console-side product analytics are opt-in. Off by default; operators who want it set EXD_CONSOLE_ANALYTICS=on.

13. Operability

ConcernNotes
BackupsDB now carries user identities, sessions, role grants, and (encrypted) SMTP and OIDC secrets. Update docs/user-guide/exd-server-backup.md to bump recommended cadence and call out the new tables. Bare git repos under EXD_GIT_ROOT remain the manifest source of truth; nothing new there.
Restore drillsAdd a test-restore drill to the runbook that includes a console login as smoke.
SMTP unset fallbackBootstrap link + magic links printed to stderr at info level on the audit target. Documented in the bootstrap runbook.
OIDC unsetTenants without Google config can only use email_domain mode. UI prevents enabling Google mode until client id+secret are saved.
Default sink path${EXD_TELEMETRY_DIR} is separately mountable so operators can put telemetry on its own volume.
Health endpoints/healthz and /readyz unchanged. /readyz returns ready only after the migrations have run successfully.
Browser-cache bustingVite hashes filenames; index.html is Cache-Control: no-cache; assets are immutable. New deploys take effect on next navigation.
OpenAPI as CI contractexd-server-admin openapi runs in CI; console type generation runs against it; mismatch fails the build.
Audit log retentionNo automatic trimming in v1. Document the table sizes in the runbook so operators can plan their own retention.

14. Core invariants the console must respect

These come from the root CLAUDE.md and are constraints, not preferences.

  • Single validator. Every write routes through existing REST endpoints, which already funnel into lint::lint_with_model. In-editor preview lint uses the WASM SDK — the same exd-client code compiled differently. No parallel validation logic anywhere in the console.
  • Per-env-complete resolution + mandatory catch-all. The flag editor never lets users remove the _ catch-all. The four-step resolution walk is rendered literally in the editor and sandbox — no abstraction hides whether step 1 vs step 3 caused a match.
  • Terminology. User-facing copy says "flag namespace", not bare "namespace". Route param remains ns.
  • Rollout-workflow testing. Sandbox exposes a visible Include testing rules toggle that maps to EvalOptions::with_include_testing(true). Sandbox behavior must match the SDK exactly. Any sandbox-discovered new case ships with a fixtures/evaluation-parity.json entry per the recipe.
  • No second source of truth. Console doesn't cache flag state in its own store beyond TanStack Query. Optimistic updates are fine for UX; server response always wins.
  • Telemetry redaction is upstream. Console never receives private_attributes values from a sink — that redaction happens at the SDK before write. Re-state this in code review of any new telemetry endpoint.

15. Phasing

Each phase ships an end-to-end useful artifact.

Phase 0 — Server foundations (~3 weeks)

  • Sessions schema + login endpoints (Google OIDC + email-domain magic-link).
  • SMTP config block and email service.
  • Default DuckDB telemetry sink + query proxy.
  • bootstrap-user CLI command.
  • VAPID keys minted at bootstrap.
  • Encrypted-secrets handling for OIDC client secret and SMTP password.
  • All four new endpoints stubbed (/activity, /sandbox/evaluate, /diff, /console/diagnostics) but only /activity and /diff fully implemented (needed by Phase 1).

Phase 1 — Shell + read-only (~2 weeks)

  • Crate scaffolding, build wiring, embedding, /console/* routes, CSP headers.
  • PWA manifest + service worker for shell caching.
  • Login screens (both modes).
  • Mobile chrome (bottom tab bar, top bar, tenant switcher, ⌘K search modal).
  • Read-only views: Home / Activity, Namespaces, Namespace overview, Flags list, Flag detail, Segments list/detail, Environments, Versions list/detail with structural diff.
  • SSE subscription with passive refresh banners.

Phase 2 — Editing (~3 weeks)

  • Flag editor full mobile drill-down flow + desktop three-pane.
  • Segment editor (structured predicate + bucket).
  • namespace.toml editor.
  • Save dialog with commit message + ETag-based conflict resolver.
  • In-browser lint via lazy-loaded WASM SDK.
  • Reorder with drag + up/down buttons.

Phase 3 — Sandbox + Versions actions (~1 week)

  • POST /sandbox/evaluate fully implemented.
  • Sandbox view (cross-namespace and inline-per-flag).
  • Revert flow with dry-run lint.
  • "Copy as parity fixture" export.

Phase 4 — Telemetry (~2 weeks)

  • Per-namespace telemetry view with the charts whose T-codes exist today.
  • Cross-tenant telemetry rollup on Home.
  • Add new T-codes if the dashboard needs aggregates the Phase-1 catalog doesn't cover.

Phase 5 — Admin surfaces (~1.5 weeks)

  • Tokens (mint / rotate / revoke).
  • Tenant settings (incl. SSO and SMTP fields, user roster, invites).
  • Tenants list (superadmin).
  • Audit log.
  • User settings (sessions list + revoke).
  • Web Push opt-in.

Total: ~12–13 weeks of focused work, of which ~3 are server-side.

16. Out of scope for v1

These are explicitly excluded to keep v1 tractable. Each is a candidate for a follow-up proposal.

  • Flag archival.
  • Experiments primitive.
  • Generic OIDC (anything other than Google).
  • Preview-branch editing (the server only accepts refs/heads/main).
  • Bulk operations beyond tag + lifecycle.
  • Full-text search across TOML contents.
  • Internationalization (English only).
  • Embedding the console in an iframe (X-Frame-Options: DENY).
  • Background sync / write queueing while offline.
  • Third-party error shipping built in.
  • Service-token paste-and-go login.
  • Branch-based draft workspaces.

17. Open items to close during Phase 0 design review

These don't block writing the spec but should be settled before code lands.

  1. Encrypted-secrets key. Reuse or extend the existing server_secrets.closure_signing_key? Recommend introducing an installation_master_key row and rotating secrets through it; keeps the closure key purpose-specific.
  2. OIDC callback hosting. If operators front exd-server with their own domain, the Google OAuth redirect URI must match. Document the registration steps in the runbook.
  3. Tenant subdomain routing. If operators want <tenant>.exd.example.com to auto-fill the tenant on the login screen, the console needs a hostname → tenant map. v1 design: support via EXD_TENANT_DOMAIN_SUFFIX; if a request host ends with the suffix, the leading label is the tenant hint.
  4. Charts library final choice. Validate uPlot covers stacked area + tooltips with acceptable touch ergonomics. Fallback: Visx with hand-rolled tooltip.
  5. Push notification scope. The three trigger events in §9.2 are a starting list; collect more from early users during Phase 5.