Skip to content

DB-backed config hierarchy: instance/org/brand/prompt run config, encrypted credentials, and plan enforcement#483

Open
jrhizor wants to merge 20 commits into
mainfrom
jrhizor/config-hierarchy-plan
Open

DB-backed config hierarchy: instance/org/brand/prompt run config, encrypted credentials, and plan enforcement#483
jrhizor wants to merge 20 commits into
mainfrom
jrhizor/config-hierarchy-plan

Conversation

@jrhizor

@jrhizor jrhizor commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Part of #8 — the configuration-hierarchy foundation the rest of the cloud project builds on.

What

One uniform configuration hierarchy (instance → organization → brand → prompt) stored in the database, replacing SCRAPE_TARGETS as the source of truth for what runs, how often, and with which credentials. Mode differences (cloud / whitelabel / local / demo) are write-policy data over one schema, not code branches.

  • configs — the single cascading table: one row per (scope, selector, key). Every key is declared once in a code registry (zod schema, default, allowed scopes/selectors, merge rule, permission class); defaults are never stored — the DB holds only overrides (the Sentry/Discourse pattern). Precedence: scope (prompt > brand > org > instance), then selector specificity (target > model > none); entitlement ceilings clamp last. Every resolved value carries provenance.
  • model_targets — the catalog: platform name → implementation (provider/version/webSearch), priority reserved for provider failover, requiredEntitlement for custom-plan-only targets. Selections stay model-name-keyed, so an implementation can move without touching any selection.
  • provider_credentials — provider keys stored encrypted (AES-256-GCM, fresh IV per operation, AAD-bound, fail-closed) under a new optional ELMO_ENCRYPTION_KEY. Env credentials remain first-class: a DB row is authoritative for its provider, otherwise env. The secret-ref source ships in the schema for the Infisical follow-up. Plaintext never reaches the client.
  • organization_settingsplanKey + entitlement overrides, staff-only (Stripe subscription billing via @better-auth/stripe #345 swaps the plan source to Stripe; this stays the custom-plan lever).
  • Resolver with per-key provenance and per-target exclusion reasons (credentials-unready, not-in-plan-menu, pool-exhausted, …) surfaced in the UI, so "why isn't claude running?" is a badge, not a support thread.

Enforcement

Two distinct guard classes:

  • Configurable limits at write time — brand count, org prompt pool (closing the previously UI-only MAX_PROMPTS gap server-side and in API v1), picks ⊆ standard menu and ≤ pick count, Claude pool counted over enabled prompts including the enable transition. All provably inert outside cloud (unlimited entitlements short-circuit before any query).
  • Runnable limits at schedule time — the worker meters against actual promptRuns history (per-target runs/day and an org-wide assignable-model budget), so assign → run → unassign → reassign cannot multiply spend. Per-request caps land with it (from the superseded Per-target replication/cadence for SCRAPE_TARGETS and hard API spend caps #452): output-token caps on all four API providers, Anthropic max_uses: 1, OpenAI maxToolCalls, OpenRouter native-first web plugin with max_results + search_context_size.

Authorization is per-key: registry permission class → CONFIG_POLICY[mode][class] → the only write gates server functions use. Whitelabel members keep full content-config access (brand settings/prompts/competitors — unchanged); run config there is operator-admin-only and the prompt level is off entirely. Demo is read-only at every level. The local bootstrap user is now promoted to instance admin (previously /admin was unreachable in local mode). Cloud follows the #344 packaging. A 160-cell registry-driven policy matrix plus per-mode contract suites pin all of this — any permission change is a visible test diff.

Migration

  • Migration 0011 creates the tables and converts brands.delayOverrideHours / brands.enabledModels into brand-scope rows before dropping the columns (safe today: no environment does rolling deploys — compose migrates before new code starts, cloud is pre-launch).
  • The worker is the only env importer: a one-shot, advisory-locked SCRAPE_TARGETS import on first boot (per-entry parsing, invalid entries skipped with warnings). With unchanged env, effective targets and run volume are identical — asserted by tests. Afterward the DB owns config; a single deprecation warning fires if env diverges. The web app renders a "no targets configured" state pre-import and never imports.
  • e2e no longer uses SCRAPE_TARGETS: the seed plants the stub catalog row and stamps instance_meta (so the worker's boot import can't pull the CLI-driver's real API targets), and a dedicated CI step exercises the env→DB import path end-to-end.
  • elmo init generates ELMO_ENCRYPTION_KEY; the first real elmo upgrade migration backfills it on existing installs (keyed to the published 0.2.17). PostgreSQL ≥ 15 is now a documented requirement (the CLI compose ships 16-alpine).

UI

New admin section: Targets (catalog with credential-readiness status), Providers (write-only key entry, verify, hints), Defaults (instance cadence/replication/onboarding target with provenance and clear-to-default), Organizations (cloud-only plan assignment). The brand LLMs page is finally editable — model checklist writing run.enabled_models (its first-ever write path), plan pick meter, exclusion-reason badges. Per-prompt models card with Claude base/web mode and a pool meter (cloud/local only). Zero new dependencies anywhere in the PR.

Issue linkage

Tests

933 across the repo, all green: lib 367 (resolver precedence/clamp property grid, crypto tamper matrix, import idempotency), config 31, web 472 (policy matrix, enforcement, credential no-plaintext-egress), worker 35 (dueness, budgets, churn, legacy volume equivalence), cli 28. Typecheck clean in web/worker/cli/lib/config; docs validated with a full www build.

Follow-ups

Infisical secret-ref resolver · per-target requestPolicy plumbing into provider request bodies · in-app first-boot setup wizard · org browse/plan-assignment UI (ships as load-by-id) · cost estimator panel · #345 Stripe wiring.

Note: migration 0011 is not executed by this PR; it applies through the normal db-migrate flow.

@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
elmo Ready Ready Preview, Comment Jul 22, 2026 11:30pm

Request Review

jrhizor added 11 commits July 22, 2026 07:35
# Conflicts:
#	packages/lib/src/providers/registry/oxylabs.ts
The admin routes import getEffectiveConfigFn/setConfigValuesFn and the
instance-config entity fns, so those modules are in the client graph. Their
*Impl functions used @/lib/auth/helpers (getRequestHeaders) outside a
createServerFn handler, so the server-only import survived tree-shaking and
the vite build failed import protection. Move the impls behind .server.ts
siblings referenced only from handler bodies; tests import them directly.
Raw U+0000 bytes made git treat run-policy.ts as binary, hiding the whole
module from diff review. Use \u0000 escapes instead — the produced key
string is byte-identical, so history metering and every test are unchanged.
The env->DB import is a one-time legacy migration (advisory-locked, stamped
by instance_meta) and is unit-tested in the lib config import tests. e2e
already seeds model_targets directly, so the standalone config-import step
only kept SCRAPE_TARGETS alive as a permanent fixture. Drop it.
- ResolvedConfig gains concrete per-key value types (TypedEntry<T>), deleting
  the as-number/as-string[] casts in the resolver and its consumers.
- One shared rowSelectorKind ladder feeds row ranking, matching, provenance,
  and the write-time selector check (was spelled out four times).
- selectConfigRows collapses the four near-identical fetch builders' tail.
- resolvePromptTargets accepts an injected assignablePoolUsage so a batch
  caller can compute the org-scoped pool count once instead of per prompt.
- Drop the unused getPropertyForKey; soften the defaults-never-stored note to
  admit the two per-prompt assignment-signal keys.
- listProviderCredentials gates inside the impl (was the one read whose auth
  sat in the wrapper, leaving the exported impl callable ungated).
- setConfigValues resolves the write actor once via requireConfigWrites instead
  of re-fetching session + member row per entry.
- instance-config validators + inferred input types move to a pure
  instance-config.schemas module: the wrapper imports schemas, the impl imports
  types — no more wrapper↔impl circular type import or hand-kept interfaces.
- Drop the now-redundant cadence value casts (resolved config is typed).
- process-prompt fetches brand+competitors together and run-history +
  org-usage together, and counts the assignable pool once per org per batch
  (via resolvePromptTargets' injected usage) instead of once per prompt.
- schedule-maintenance resolves per-org entitlements with Promise.all rather
  than a serial await-in-loop on every tick.
- Document the org-budget cross-job TOCTOU as an accepted soft cap.
- Extract the structured-research maxUses/context-size and OpenAI tracked
  context size into named constants next to the tracked-run caps, so the
  intentional 2-vs-5 gap is visible and edits can't miss a call site.
- VERSION_REQUIRED_PROVIDERS becomes one exported set (was an inline || chain
  plus a duplicate Set); the client route keeps a documented mirror.
The keyId was written but never read; verify it so a key mismatch surfaces a
precise cause instead of an opaque GCM auth failure, matching its comment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-target replication counts and API output-token caps

1 participant