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 managed subject metadata. Authentication grants (including user-facing API tokens) and upstream credentials live in provider-owned IndexedDB stores.
For encryption mechanics, see Security Internals. For choosing a provider, see Configuration.
Ownership summary
| Owner | Persistent state |
|---|---|
gestaltd core | users, managed_subjects |
| Identity provider | authentication_grants, authentication_token_hashes |
| 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 |
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.
managed_subjects
Metadata for service account identities. The canonical
subject ID is always service_account:<id>. Authorization relationships for
who can manage the subject live in the configured authorization provider as
managed_subject resource relationships; runtime grants live as
provider-backed app relationships.
| Field | Type | Notes |
|---|---|---|
id | string, PK | Same value as subject_id; retained for IndexedDB primary-key compatibility. |
subject_id | string, unique | Canonical service-account subject ID such as service_account:release-bot. |
kind | string | Currently service_account. |
display_name | string | Plaintext. |
description | string | Plaintext. |
created_by_subject_id | string | Canonical user subject that created the managed subject. |
deleted | bool | Deleted subjects are hidden and their IDs are not reused. |
created_at | time | |
updated_at | time | |
deleted_at | time | Null until deleted. |
external_credentials
Owned by the configured external credentials provider, not gestaltd core. Every record holds a credential of one of three kinds under the unique key (subject, audience, qualifier):
| Kind | Holds | audience | qualifier |
|---|---|---|---|
grant | Tokens minted for a subject’s authorization (OAuth tokens, pasted bearer secrets) | Connection ID (e.g. gmail:default) | Instance |
client_info | The client identity issued to gestaltd by an authorization server at RFC 7591 dynamic registration | Authorization server URL | Redirect URI |
opaque | Externally supplied named fields the store does not interpret (e.g. Datadog api_key + app_key) | Connection ID | Instance |
| Field | Type | Notes |
|---|---|---|
id | string, PK | UUID. |
subject | string | Canonical credential owner such as user:<id>, service_account:<id>, or system:gestaltd for client_info rows. |
audience | string | Where the credential is presented. |
qualifier | string | Per-kind disambiguator. |
kind | string | grant, client_info, or opaque. |
access_token_encrypted | string | Encrypted. AES-256-GCM. grant only. |
refresh_token_encrypted | string | Encrypted. AES-256-GCM. grant only. |
scope | string | Plaintext. Space-separated OAuth scopes. grant only. |
expires_at | time | grant only. Null if the provider doesn’t report expiry. |
last_refreshed_at | time | grant only. |
refresh_error_count | int | grant only. Consecutive failed refreshes. Reset to 0 on success. |
client_id | string | client_info only. |
client_secret_encrypted | string | Encrypted. AES-256-GCM. client_info only. |
client_secret_expires_at | time | client_info only. |
fields_encrypted | string | Encrypted. AES-256-GCM JSON object. opaque only. |
metadata_json | string | Plaintext. Connection metadata from post-connect discovery. |
created_at | time | |
updated_at | time |
CreateCredential is insert-only and fails with ALREADY_EXISTS on a (subject, audience, qualifier) conflict, which gives first-writer-wins convergence when multiple server instances race RFC 7591 dynamic client registration. Reconnecting overwrites the existing record via UpsertCredential.
authentication_grants and authentication_token_hashes
Owned by the configured identity provider, not gestaltd core. Grant records hold metadata for each issued bearer grant. Token-hash records map SHA-256 hashes back to grants for introspection; plaintext bearer tokens are never stored.
| Store | Key fields | Notes |
|---|---|---|
authentication_grants | id, subject, scope, client_id, created_at, expires_at, revoked | One row per grant. subject is the canonical owner such as user:<id>. |
authentication_token_hashes | id (SHA-256 hash), grant_id, subject, scope, client_id, expires_at | Hash-only lookup table for bearer validation. |
User-facing API token management (/api/v1/tokens) lists, inspects, and revokes these provider-owned grants.
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 | external_credentials.client_secret_encrypted | AES-256-GCM |
| Manual credential fields | external_credentials.fields_encrypted | AES-256-GCM |
| Authentication grant hashes | authentication_token_hashes.id | 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 subject, audience, and qualifier.
For grant and opaque credentials, the audience is the connection ID from your config and the qualifier is the instance, which distinguishes multiple accounts within the same connection 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 an apps key in config is a breaking change. Stored credentials reference the old name.
Sessions
Platform sessions (OIDC, local, and custom identity 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.