Data Model
Gestalt stores most persistent state through configured providers. Gestaltd core provisions only the object stores it directly owns through the configured IndexedDB provider: users and API tokens. Provider-owned state, such as upstream credentials, workflow state, and agent sessions/turns, is provisioned by those providers instead of by gestaltd core.
MCP OAuth client registrations (oauth_registrations) are stored separately in a raw SQL table managed outside the IndexedDB provider.
For encryption mechanics, see Security Internals. For choosing a provider, see Configuration.
Ownership summary
| Owner | Persistent state |
|---|---|
gestaltd core | users, api_tokens |
| External credentials provider | external_credentials |
| Agent provider | Provider-defined agent session, turn, interaction, event, and idempotency state |
| Workflow provider | Provider-defined schedule, event trigger, run, execution ref, and idempotency state |
| MCP OAuth support | oauth_registrations raw SQL table |
Object stores
users
Created automatically on first login.
| Field | Type | Notes |
|---|---|---|
id | string, PK | UUID. |
email | string, unique | Plaintext. From the identity provider. |
display_name | string | Plaintext. |
created_at | time | |
updated_at | time |
Emails and display names are plaintext. If you need encrypted PII at rest, handle it at the storage layer.
external_credentials
Owned by the configured external credentials provider, not gestaltd core. The default provider stores upstream credentials (OAuth tokens, API keys, manual credentials) for each subject’s connected integrations.
| Field | Type | Notes |
|---|---|---|
id | string, PK | UUID. |
subject_id | string | Canonical credential owner such as user:<id>, service_account:<id>, or a runtime-supplied system:<id> subject. |
integration | string | Provider name from config (e.g., slack). |
connection | string | Named connection within the provider (e.g., default, mcp). |
instance | string | User-chosen or discovery-assigned name for multi-account providers. |
access_token_encrypted | string | Encrypted. AES-256-GCM. |
refresh_token_encrypted | string | Encrypted. AES-256-GCM. Empty (still encrypted) if no refresh token. |
scopes | string | Plaintext. Space-separated OAuth scopes. |
expires_at | time | Null if the provider doesn’t report expiry. |
last_refreshed_at | time | |
refresh_error_count | int | Consecutive failed refreshes. Reset to 0 on success. |
metadata_json | string | Plaintext. Connection metadata from post-connect discovery. |
created_at | time | |
updated_at | time |
Unique index on (subject_id, integration, connection, instance). Reconnecting overwrites the existing record via Put.
api_tokens
Gestalt API tokens (gst_api_*). The plaintext is never stored, only a one-way hash.
| Field | Type | Notes |
|---|---|---|
id | string, PK | UUID. |
owner_kind | string | Token owner kind (user or subject). |
owner_id | string | Owner ID within owner_kind; subject-owned tokens use a canonical non-system subject ID. |
credential_subject_id | string | Subject whose upstream credentials the token may use. |
name | string | Display name (e.g., automation, cli-token). |
hashed_token | string, unique | SHA-256 hash. Plaintext returned once at creation. |
scopes | string | Plaintext. Space-separated provider names. |
permissions_json | string | JSON-encoded provider operation permissions and provider-scoped action permissions such as provider_dev.attach. |
expires_at | time | Null for non-expiring tokens. |
created_at | time | |
updated_at | time |
oauth_registrations
Caches dynamic OAuth client registrations for mcp_oauth connections, so Gestalt doesn’t re-register on every request.
| Field | Type | Notes |
|---|---|---|
id | string, PK | |
auth_server_url | string | Upstream authorization server. |
redirect_uri | string | Callback URI. |
client_id | string | Dynamically registered client ID. |
client_secret_encrypted | string | Encrypted. AES-256-GCM. |
expires_at | time | |
authorization_endpoint | string | Discovered URL. |
token_endpoint | string | Discovered URL. |
scopes_supported | string | |
discovered_at | time | |
created_at | time | |
updated_at | time |
Unique index on (auth_server_url, redirect_uri).
Encryption summary
Credentials are encrypted. Everything else is plaintext.
| Data | Where | How |
|---|---|---|
| Access tokens | external_credentials.access_token_encrypted | AES-256-GCM |
| Refresh tokens | external_credentials.refresh_token_encrypted | AES-256-GCM |
| MCP OAuth client secrets | oauth_registrations.client_secret_encrypted | AES-256-GCM |
| API tokens | api_tokens.hashed_token | One-way SHA-256 |
| Emails, names, metadata, scopes, timestamps | Various | Plaintext |
All encrypted fields use the same key derived from server.encryptionKey. There is no per-user or per-provider key separation.
Credential storage granularity
External credentials are keyed by four dimensions: subject_id, integration, connection, and instance.
The integration is the provider name from your config. The connection is the named connection within that provider (most have just default). The instance distinguishes multiple accounts within the same connection, which matters for providers with post-connect discovery (e.g., a user connected to three Slack workspaces).
The _connection and _instance selectors in the HTTP API and CLI map directly to these storage keys.
Connection modes
none: No credentials stored. Operations run without upstream authentication.user: Each calling subject connects individually. Tokens are keyed bysubject_id.
Renaming a plugins key in config is a breaking change. Stored credentials reference the old name.
Sessions
Platform sessions (OIDC, local, and custom authentication providers) are not stored in the IndexedDB provider. They are HTTP-only cookies (session_token) with Secure (when HTTPS), SameSite=Lax, and a default 24-hour TTL (configurable via sessionTtl for OIDC). Sessions cannot be revoked server-side; they expire on TTL. Logout clears the client cookie.