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
This commit is contained in:
Will Anderson
2026-05-02 12:24:00 -05:00
parent 7f1fe1347a
commit 79cd461b83
2 changed files with 129 additions and 2 deletions
@@ -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;
+100 -2
View File
@@ -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":<jsonb>}]. 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": "<NEURON_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": "<user_jwt>" }. 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