Vancetope — Settings System

Unified, typed settings system for all configurations. Core principle: Everything is a setting with a type. Passwords are encrypted. No separate config/secret system. See also: identity-credentials | llm-resource-management | architektur-scopes-clients


1. Design Principle

No separate config system and secret system. Everything consists of typed settings at different Scope levels. A password type is stored encrypted and never returned in plain text. Everything else is transparently readable.

Like GitHub Repository Settings or K8s ConfigMaps + Secrets — but in one system.


2. Setting Types

Type Storage Return via API Example
string Plaintext Plaintext project.name = "Literature Review"
int Plaintext Plaintext quota.daily_tokens = 500000
double Plaintext Plaintext linker.confidence_threshold = 0.7
boolean Plaintext Plaintext features.brain_linker_enabled = true
password Encrypted "***" (masked) credentials.jira.api_key = "sk-..."

Password Type Rules

  • Encrypted upon writing (AES-256, master key from environment)
  • Never returned in plain text when read via API, only "***" or "[set]"
  • Decrypted only internally in the Brain, at the moment of the Tool-Call or LLM-Call
  • Never appears in logs, chat output, Think Process results, or exports
  • Can be overwritten and deleted, but not read

3. Scope Levels

Settings are project attributes: each setting is attached to a Project (or a Think Process). Tenant and User Scopes are syntactic sugar — they collapse to the _tenant system Project or the per-user _user_<login> Projects, respectively.

Storage Layer (what is actually in Mongo)

Only two referenceType values are persisted:

referenceType referenceId Meaning
project _tenant Tenant-wide defaults
project _user_<login> Per-user Settings
project <projectId> Project-specific
think-process <processId> Innermost — only for the lifetime of the Process

Wire Layer (what the Admin API shows)

The AdminSettingsController maps Wire ↔ Storage transparently. The UI still sees four Reference Types and does not need to know about the _tenant/_user_<login> trick:

Wire-referenceType Wire-referenceId is mapped to Storage
tenant (any) project / _tenant
user <login> project / _user_<login>
project <projectId> passthrough
think-process <processId> passthrough

Reads translate the Storage reference back into the Wire form. This is also why tenant settings still exist in the UI — only persistence has been consolidated.

Resolution — Two Separate Cascades

SettingService exposes two lookup APIs, with different Scopes. They are intentionally separated so that per-user preferences do not accidentally overwrite Tenant/Project defaults for security-relevant keys (LLM provider, API keys).

a) Project Cascade — getStringValueCascade(tenantId, projectId, processId, key)

Inner-to-outer, first-match-wins. No User Layer:

1. Think Process tp:                  storage think-process/tp
2. <projectId>-Project (e.g., "p"):    storage project/p
3. _tenant-Project:                    storage project/_tenant
4. → null

For everything related to the worker context: ai.* (provider, model, aliases, API keys), web.* (search keys), memory.* hints. A cascade variant for passwords exists as getDecryptedPasswordCascade(...) — same order.

b) User API — getUserStringValue(tenantId, userId, key) and getUserStringValueWithDefault(...)

Direct read in the _user_<login> Project. No Project Layer, optional fallback to _tenant:

getUserStringValue(tenantId, userId, key) :=
  storage project/_user_<userId>
  → null (no fallback)

getUserStringValueWithDefault(tenantId, userId, key) :=
  1. storage project/_user_<userId>
  2. storage project/_tenant              (no <projectId>-Layer!)
  3. → null

For purely per-user preferences that should not be mixed with the project context: webui.language, display.timezone, telegram.conversation_id, notification.channel, Terminal theme, … The <projectId> layer is intentionally skipped — otherwise, switching to another project would unexpectedly change the UI language or the displayed time.

Language: Three Settings, Three Cascades

Language breaks down into three concepts with different cascades. Resolved via LanguageResolver:

Setting Meaning Cascade
webui.language UI chrome (buttons, labels). User-only (no cascade).
chat.language Language in which the assistant responds/listens. think-process → _user_<userId> → <projectId> → _tenant — User default can be overridden by a project (e.g., an English code review project for a German-speaking user).
content.language Language in which Documents/Insights/Memory entries are written. think-process → <projectId> → _tenant — deliberately without User Layer, because content belongs to the project (otherwise, project with documents in three languages depending on the author).

Default fallback: LanguageResolver.DEFAULT_LANGUAGE = "en". The MemoryContextLoader renders both languages in a ## Languages block in the system prompt — Engines thus get the correct language context without further action. The historical context.language key no longer exists; migration is “manual” (replace settings rows, the resolver does not read the old key).

Timezone: display.timezone

The user’s displayed timezone is a single per-user setting display.timezone (IANA ID, e.g., Europe/Berlin), resolved via TimezoneResolver via getUserStringValueWithDefault — cascade _user_<userId> → _tenant, code fallback UTC. Consumers:

  • the Current-Date-Block in the prompt (renders in the user’s zone instead of the server’s zone — see prompt-caching §5b),
  • the current_time tool (default zone without explicit zone parameter),
  • the Scheduler (scheduler_set writes the user’s zone into the YAML upon creation — scheduler §10c).

The PromptDateContextResolver (vance-brain) lifts Process → Session → userId for this purpose and is headless-proof. display.timezone is set in the Web UI profile (timezone selector with browser default seed on first load) or in the Foot-CLI via /timezone; both write via the self-service PUT /brain/{tenant}/profile/settings/display.timezone path (key is in the Profile allowlist).

For passwords, similarly: getDecryptedUserPassword(tenantId, userId, key) — direct, no fallback (per-user Secrets are explicit).

Team and Account Scopes are not modeled in v1. If they are added, they will sit between Project and _tenant in the Project cascade.


4. Data Model

MongoDB Collection: settings

{
  _id: ObjectId,
  tenantId: "acme",
  referenceType: "project",                  // project | think-process
  referenceId: "_tenant",                     // see Storage Layer §3
  key: "ai.providers.anthropic.api_key",     // Dot notation, hierarchical
  type: "PASSWORD",                          // STRING | INT | LONG | DOUBLE | BOOLEAN | PASSWORD
  value: "enc:aes256:...",                   // encrypted for PASSWORD, otherwise plaintext
  description: "Anthropic API Key",          // optional, for UI/documentation
  createdAt: ISODate,
  updatedAt: ISODate
}

// Compound index:
// { tenantId: 1, referenceType: 1, referenceId: 1, key: 1 }  — unique

Bootstrap Order

For settings to be written to the _tenant Project, the Project must already exist during settings operations. Two paths:

  • Demo Tenant acme: InitBrainService calls homeBootstrapService.ensureVance(ACME) directly before InitSettingsLoader.loadIfPresent() — the loader places its YAML entries in (project, _tenant) without the caller needing to know.
  • Other Tenants: _tenant is lazily created (idempotently) by AccessController on the first user login, before settings can be set via the Admin REST API.

5. Naming Conventions (Keys)

Dot notation, grouped by area:

Preferences (per-user)

display.timezone                     string     "Europe/Berlin"
webui.language                       string     "de"
chat.language                        string     "de"

Per-user keys (storage in _user_<login> Project, cascade User → _tenant). display.timezone + webui.language are writable via the self-service Profile endpoint; see §3b.

LLM

llm.default_model                    string     "claude-sonnet-4"
llm.planning_model                   string     "claude-sonnet-4"
llm.execution_model                  string     "claude-sonnet-4"
llm.light_model                      string     "gemini-2.5-flash"
llm.providers.anthropic.api_key      password   "sk-ant-..."
llm.providers.anthropic.priority     int        1
llm.providers.google.api_key         password   "AIza..."
llm.providers.google.priority        int        2
llm.providers.ollama.endpoint        string     "http://localhost:11434"
llm.failover.enabled                 boolean    true
llm.failover.max_retries             int        2
llm.failover.fallback_to_local       boolean    true

Quotas

quota.daily_tokens                   int        500000
quota.monthly_tokens                 int        10000000
quota.max_tokens_per_call            int        8192
quota.warn_at_percent                int        80
quota.downgrade_at_percent           int        95

Credentials (external services)

credentials.jira.type                string     "oauth2"
credentials.jira.access_token        password   "eyJ..."
credentials.jira.refresh_token       password   "dGhp..."
credentials.jira.instance_url        string     "https://acme.atlassian.net"
credentials.google_drive.type        string     "oauth2"
credentials.google_drive.token       password   "ya29..."
credentials.slack.webhook_url        password   "https://hooks.slack.com/..."

Features

features.brain_linker_enabled              boolean    true
features.auto_continue                     boolean    false
features.max_think_processes_per_project   int        20
features.max_concurrent_think_processes    int        5

Think Process Defaults

process.stop_after_each_task         boolean    true
process.max_depth                    int        5
process.allow_dynamic_children       boolean    true
process.auto_promote_results         boolean    false

Connectors

connectors.sync_interval_minutes     int        60
connectors.max_per_project           int        10

Routing

routing.fallback.recipe              string     hactar

Recipe name that is spawned by selector-routed process_create if the trigger-gated selector returns NONE (user text does not trigger any special Recipe). Empty string = no fallback (caller gets NONE back and decides itself). See recipe-routing.md §6.


6. API

Read Settings

GET /api/settings?scope=account/acc_mike
  → All settings for this account (passwords masked)

GET /api/settings?scope=project/proj_5&key=llm.*
  → All LLM settings for this project

GET /api/settings/resolved?scope=think-process/tp_12&account=acc_mike&project=proj_5
  → Resolved settings (cascade applied), shows effective values
  → Includes "source" field: where the value comes from (tenant/team/project/account/engine)

Response:

{
  "settings": [
    {
      "key": "llm.default_model",
      "type": "string",
      "value": "claude-sonnet-4",
      "source": "account/acc_mike"
    },
    {
      "key": "llm.providers.anthropic.api_key",
      "type": "password",
      "value": "***",
      "source": "project/proj_5"
    },
    {
      "key": "quota.monthly_tokens",
      "type": "int",
      "value": 5000000,
      "source": "project/proj_5"
    }
  ]
}

Write Settings

PUT /api/settings
  Body: {
    "scope": { "type": "account", "id": "acc_mike" },
    "key": "llm.providers.anthropic.api_key",
    "type": "password",
    "value": "sk-ant-new-key-123"
  }
  → Stored (encrypted for password)

Delete Settings

DELETE /api/settings?scope=account/acc_mike&key=llm.default_model
  → Override removed, falls back to next Scope level

Bulk Operations

PUT /api/settings/bulk
  Body: {
    "scope": { "type": "project", "id": "proj_5" },
    "settings": [
      { "key": "quota.monthly_tokens", "type": "int", "value": 5000000 },
      { "key": "llm.providers.anthropic.api_key", "type": "password", "value": "sk-..." }
    ]
  }

7. CLI

vance settings list
  llm.default_model          = claude-sonnet-4     (account)
  llm.light_model            = gemini-2.5-flash    (tenant)
  quota.daily_tokens         = 500000              (account)
  quota.monthly_tokens       = 5000000             (project)
  credentials.jira.api_key   = ***                 (account)

vance settings get llm.default_model
  claude-sonnet-4 (source: account/acc_mike)

vance settings set llm.default_model claude-opus-4
  Updated: llm.default_model = claude-opus-4 (account/acc_mike)

vance settings set credentials.jira.api_key --type password
  Enter value: ****
  Updated: credentials.jira.api_key = *** (account/acc_mike)

vance settings delete llm.default_model
  Deleted: llm.default_model (account/acc_mike)
  Effective value now: claude-sonnet-4 (from: tenant/tenant_acme)

vance settings resolved --engine tp_12
  → Shows all effective settings with origin

8. In the Brain (internal)

@Service
public class SettingsService {

    // Resolves a setting value through the scope cascade
    public <T> T get(String key, Class<T> type, SettingsContext ctx) {
        // ctx contains: thinkProcessId, accountId, projectId, teamId, tenantId
        // Checks: engine → account → project → team → tenant → default
    }

    // Resolves a password (decrypted)
    // ONLY use internally, never return to API/client
    public String getSecret(String key, SettingsContext ctx) {
        Setting s = resolve(key, ctx);
        if (s.getType() != SettingType.PASSWORD) throw ...;
        return decrypt(s.getValue());
    }

    // All settings for a scope
    public List<Setting> list(Scope scope) { ... }

    // All effective settings (cascade resolved)
    public List<ResolvedSetting> resolved(SettingsContext ctx) { ... }
}

LLM-Factory uses Settings

public ChatClient createForSession(Session session, String purpose) {
    SettingsContext ctx = SettingsContext.from(session);
    
    String model = settings.get("llm." + purpose + "_model", String.class, ctx);
    String provider = determineProvider(model, ctx);
    String apiKey = settings.getSecret("llm.providers." + provider + ".api_key", ctx);
    
    return ChatClient.builder()
        .model(provider, model)
        .apiKey(apiKey)
        .build();
}

9. Desktop / Web UI

Settings page in the client, grouped by area:

Settings
├── LLM
│   ├── Default model:     [claude-sonnet-4  ▼]     (account)
│   ├── Planning model:    [claude-sonnet-4  ▼]     (tenant default)
│   ├── Light model:       [gemini-2.5-flash ▼]     (tenant default)
│   ├── Anthropic API Key: [••••••••••] [Edit]       (project)
│   └── Google API Key:    [Not set]    [Set]        
│
├── Quotas
│   ├── Daily tokens:      [500,000]                 (account)
│   └── Monthly tokens:    [5,000,000]               (project)
│
├── Integrations
│   ├── Jira:              Connected ✓  [Revoke]     (account)
│   ├── Google Drive:      Connected ✓  [Revoke]     (account)
│   └── Slack:             Not connected [Connect]
│
├── Think Process Defaults
│   ├── Stop after task:   [✓]                       (tenant default)
│   └── Max depth:         [5]                       (tenant default)
│
└── Features
    ├── Brain Linker:      [✓]                       (tenant)
    └── Auto-continue:     [ ]                       (account)

(source) shows where the value comes from — hover for details

10. Summary

Everything is a setting.
Settings have types: string, int, double, boolean, password.
Passwords are stored encrypted and never returned in plain text.
Settings exist at Scope levels: Tenant → Team → Project → Account → Think Process.
Lower level overrides higher level.
One system, one code path, one API.