KDCube
← Our Journal

A signed-in user decides things about how an app behaves for them: which tools the agent uses, which model answers, whether durable memory participates at all. Those decisions need to survive the conversation, the device, and the restart — and apply fresh on every turn. The platform keeps them all in one home: a per-user, per-app, per-key settings construct, with one contract that makes every setting safe.

one row per decision
  (user_id, bundle_id, key)  →  value_json   [+ subsystem = the owning store]

Everything rides one table — user_bundle_props — and every kind of setting is a store: a thin typed layer that owns one record shape (its key convention, its schema_version, its defaults, its write rules). An app adds its own settings by adding a store; rows are never written ad hoc.

The contract: config grants, the user chooses within

The admin's config declares the space of possibilities; the user's record holds their choices inside it. The two meet on every turn:

CONFIG GRANTS → USER RECORD → THE TURN config grants — the space the inventory (tools · skills) allowed models allowed policies defaults live here declared by the admin, per app · per agent the user's record choices within the grant deny-lists (tools · MCP · ns · skills) one model pick standing cache policy clamp on write merge-writes one row per (user, app, key) = the turn runs with effective = configured ∩ chosen read fresh, every turn fails open a broken settings store never breaks the app defaults flow from config — an absent row is the configured default; new config entries apply to everyone immediately. a stale choice for a since-removed entry is a harmless no-op — the intersection simply drops it.
Defaults flow from config — an absent row is the configured default; every turn runs from the intersection.

Four rules hold for every store:

  • Clamp on write, intersect on read. A choice outside what config grants is stripped at write time, and reads recompute effective = configured ∩ chosen — so a stale stored choice for a since-removed config entry is a harmless no-op.
  • Defaults flow from config, never from storage. An absent row or field means "the configured default": new config entries apply to everyone immediately, and no migration ever back-fills rows.
  • Merge-writes never clobber siblings. A write carries only what changed; the store merges it over the stored record. Toggling one tool never touches the model pick — and two interleaved partial writes both land, because neither rewrites the other's fields.
  • Per-turn reads, fail open. The runtime reads the record fresh at the turn's application point and treats every failure as "use the configured behavior" — a broken settings store never breaks the app.

The shipped proof

Two stores exercise the construct end to end.

Memory preferences (subsystem='memory') — the user's memory posture: participate in durable memory at all, and at what read scope. One convention worth copying: the row uses bundle_id='*', so a single record governs the user's memory behavior across every app.

The agent selection record (subsystem='agents') — everything a user decided about one configured agent, one record per (user, app, agent) under key='agent_selection:<agent_id>':

disabled       deny-lists per category — tools (whole or per tool),
               MCP servers, named-service namespaces, skills
model          the single pick from the admin's supported_models list
cache_policy   the user's standing cold-cache policy per change class
pending        one deferred change, promoted when its trigger fires

This is the record behind the chat composer's "+" menu: the menu reads the config-derived inventory plus this record in one round-trip, saves through debounced partial writes, and the next turn is simply built from the updated selection.

The record grew into a surface

The agent-selection record now reaches deeper and shows up in more places — same contract, three extensions:

  • Namespaces narrow like tools. Deny keys reach INTO a service: operations (object.search) and named actions (object.action.send). A denied entry is not hidden UI — it is rejected at named-service dispatch for that user's turns, clamped on write to what the realm actually declares.
  • Services explain themselves. An expanded namespace renders as a service card built from the realm's own declaration: the purpose line, the third-party dependency ("Works with your Slack workspace through your connected Slack account"), and capability groups (Read / Create & update / Actions) summarized in human words ("Send email"), each entry's access line ("via your connected Google account · send mail") in the expandable details. A realm that declared nothing gets the honest "This service hasn't described itself yet."
  • The picker became a surface of its own. One picker body, three shells: the composer popover for quick toggles, a readable in-chat modal where the prose wraps, and the served capabilities widget — a full page mountable on any scene. All three write the same record through the same clamped merge-writes.
ONE PICKER BODY, THREE SHELLS composer "+" popover quick toggles while writing expanded in-chat modal service cards, prose wraps capabilities widget served full page, any scene one picker body agent_selection record · clamped merge-writes
Three shells, one body, one record — expanding mid-interaction keeps every toggle.

What belongs here — and what stays out

Durable per-user choices an app should honor on every turn belong here: toggles, picks, standing policies, scope preferences. Two things stay out by rule: per-conversation state (that lives with the conversation) and secrets of any kind — tokens and credentials live in the user secret store behind the connections stack, never in value_json.

One table, one contract, any number of stores: the admin grants the space, the user chooses within it, and every turn runs from the intersection.

KDCube Shorts · 06.07.2026