From 79cd461b83c95773f2e03f03f00f12e0e2dd8056 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sat, 2 May 2026 12:24:00 -0500 Subject: [PATCH] feat: phase 1 of runtime config store (neuron_config table, chat.model) New Supabase table neuron_config keyed on (key, scope) with jsonb value column. Web tier reads chat.model via /api/demo with 60s TTL caching, passes to soul via dharma envelope payload.model. No more revision-rollout-per-model-swap. Admin read endpoint at /api/admin/config gated by NEURON_ADMIN_TOKEN. Write surface and Realtime subscription land in Phase 2. Backlog: bl-6eb51893 --- migrations/20260502171044_neuron_config.sql | 29 ++++++ src/main.el | 102 +++++++++++++++++++- 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 migrations/20260502171044_neuron_config.sql diff --git a/migrations/20260502171044_neuron_config.sql b/migrations/20260502171044_neuron_config.sql new file mode 100644 index 0000000..2cb4e9b --- /dev/null +++ b/migrations/20260502171044_neuron_config.sql @@ -0,0 +1,29 @@ +-- 20260502171044_neuron_config.sql +-- +-- Phase 1 of the runtime config store. Backs bl-6eb51893. +-- +-- Adds public.neuron_config: a (key, scope) -> jsonb runtime config table. +-- The web tier reads it at request time with a 60s TTL cache and passes +-- chat.model down to soul via the dharma envelope payload, so swapping +-- models becomes a row update (not a Cloud Run revision rollout). +-- +-- Phase 2 will add Realtime subscription + write surface + admin UI. + +create table if not exists public.neuron_config ( + key text primary key, + value jsonb not null, + scope text not null default 'prod', + updated_at timestamptz not null default now(), + updated_by text +); + +alter table public.neuron_config enable row level security; + +-- No policies. Service-role bypasses RLS. Public anon has no access. The +-- table is server-side only; the web tier reads it with the service key. + +insert into public.neuron_config (key, value, scope, updated_by) values + ('chat.model', '"claude-sonnet-4-5"'::jsonb, 'prod', 'system'), + ('chat.format', '"anthropic"'::jsonb, 'prod', 'system'), + ('chat.url', '"https://api.anthropic.com/v1/messages"'::jsonb, 'prod', 'system') +on conflict (key) do nothing; diff --git a/src/main.el b/src/main.el index 8ff6952..dd12327 100644 --- a/src/main.el +++ b/src/main.el @@ -414,6 +414,65 @@ fn read_asset(abs_path: String) -> String { return fs_read(abs_path) } +// ── Runtime config store (Phase 1) ──────────────────────────────────────────── +// +// Reads a single (key, scope='prod') row from public.neuron_config in +// Supabase. Caches the unwrapped string value in process state for 60s +// before re-fetching. Empty string on miss / on Supabase error so callers +// can safely fall back to a hardcoded default. +// +// jsonb wire format: PostgREST returns the value column as JSON. A jsonb +// string like '"claude-sonnet-4-5"' arrives as the literal six-byte +// payload "claude-sonnet-4-5" - already unquoted by json_get. Numbers and +// objects are left as-is. +// +// Why this exists: chat.model used to be NEURON_LLM_0_MODEL on the Cloud +// Run revision. Swapping models meant a redeploy. With this row, swapping +// is one PATCH and a 60s wait. Phase 2 brings Realtime so propagation is +// near-instant. +fn config_get(key: String) -> String { + let cache_key: String = "__cfg__" + key + let cache_at: String = "__cfg_at__" + key + let cached: String = state_get(cache_key) + let cached_at_str: String = state_get(cache_at) + let now: Int = unix_timestamp() + let cached_at: Int = if str_eq(cached_at_str, "") { 0 } else { str_to_int(cached_at_str) } + if !str_eq(cached, "") && (now - cached_at) < 60 { + return cached + } + let sb_url: String = state_get("__supabase_project_url__") + let sb_key: String = state_get("__supabase_service_key__") + if str_eq(sb_url, "") || str_eq(sb_key, "") { + return cached + } + let q: String = "neuron_config?select=value&key=eq." + key + "&scope=eq.prod&limit=1" + let resp: String = supabase_get(sb_url, sb_key, q) + if str_eq(resp, "") || str_eq(resp, "[]") { + return cached + } + // PostgREST returns [{"value":}]. Pull the first row, then the + // value field. json_get unwraps a jsonb string to its bare payload. + let row: String = json_array_get(resp, 0) + if str_eq(row, "") { + return cached + } + let val: String = json_get(row, "value") + if str_eq(val, "") { + // value could be a non-string jsonb (number/array/object). Fall + // back to the raw form so callers at least see something. + let val_raw: String = json_get_raw(row, "value") + if str_eq(val_raw, "") { + return cached + } + state_set(cache_key, val_raw) + state_set(cache_at, int_to_str(now)) + return val_raw + } + state_set(cache_key, val) + state_set(cache_at, int_to_str(now)) + return val +} + // ── Request handler ─────────────────────────────────────────────────────────── // // NOTE: GET / is intercepted by the El HTTP runtime before reaching this @@ -634,6 +693,38 @@ fn handle_request(method: String, path: String, body: String) -> String { return "{\"status\":\"ok\",\"service\":\"neuron-web\"}" } + // ── Admin: read-only config snapshot (Phase 1) ──────────────────────────── + // POST { "admin_token": "" } - returns the full + // neuron_config table for verification. Phase 2 adds POST/PUT and an + // auth-gated admin page; for now this is the only surface. + // + // Token in the body keeps this consistent with the rest of /api/* (the + // El runtime does not surface request headers to the handler today). + if str_eq(path, "/api/admin/config") { + if !str_eq(method, "POST") { + return "{\"__status__\":405,\"error\":\"POST required\"}" + } + let admin_token_in: String = json_get(body, "admin_token") + let admin_token_expected: String = env("NEURON_ADMIN_TOKEN") + if str_eq(admin_token_expected, "") { + return "{\"__status__\":503,\"error\":\"admin_token_not_configured\"}" + } + if !str_eq(admin_token_in, admin_token_expected) { + return "{\"__status__\":401,\"error\":\"unauthorized\"}" + } + let ac_sb_url: String = state_get("__supabase_project_url__") + let ac_service: String = state_get("__supabase_service_key__") + if str_eq(ac_sb_url, "") || str_eq(ac_service, "") { + return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}" + } + let ac_q: String = "neuron_config?select=key,value,scope,updated_at,updated_by&order=key" + let ac_resp: String = supabase_get(ac_sb_url, ac_service, ac_q) + if str_eq(ac_resp, "") { + return "{\"rows\":[]}" + } + return "{\"rows\":" + ac_resp + "}" + } + // ── My plan: server-side waitlist read with JWT verification ───────────── // POST { "access_token": "" }. We verify the JWT via Supabase // /auth/v1/user, extract the email, then read the waitlist row with the @@ -960,8 +1051,15 @@ fn handle_request(method: String, path: String, body: String) -> String { let qrem_safe: String = if str_eq(qrem_str, "") { "10" } else { qrem_str } let is_last_str: String = json_get(body, "is_last_question") let is_last_safe: String = if str_eq(is_last_str, "true") { "true" } else { "false" } - // Build inner content with history and engram context for thread context - let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_question\":" + is_last_safe + "}}" + // Look up the configured chat model from public.neuron_config + // (Phase 1 runtime config store). 60s TTL caching, falls back + // to the hardcoded default on Supabase miss / error. + let configured_model: String = config_get("chat.model") + let model_safe: String = if str_eq(configured_model, "") { "claude-sonnet-4-5" } else { configured_model } + // Build inner content with history and engram context for thread context. + // soul-demo unwraps payload from the dharma envelope, then reads + // model with json_get(body, "model") - so this propagates end to end. + let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_question\":" + is_last_safe + ",\"model\":\"" + model_safe + "\"}}" // Escape inner for the outer content field let inner_safe: String = str_replace(str_replace(inner, "\\", "\\\\"), "\"", "\\\"") // Build dharma envelope with per-user channel