Server retention policies
Configure automatic cleanup for self-hosted Happier servers without changing the default keep-forever behavior.
Happier server retention is optional and disabled by default.
If you do not set any retention env vars, the server keeps sessions and other retained records forever.
What retention currently covers
Retention is implemented as one server-side sweep worker backed by one domain registry. Each registry entry owns its domain id, environment keys, policy shape, and deletion rule, so configuration parsing, execution, and public disclosure cannot silently maintain different domain lists. It can clean up:
- Sessions
- Session messages inside stale inactive sessions
- Sidechain messages, with an independently configurable shorter lifetime
- Account changes
- Voice session leases
- User feed items
- Session share access logs
- Public share access logs
- Terminal auth requests
- Account auth requests
- Auth pairing sessions
- Repeat keys
- Global locks
- Automation runs
- Automation run events
How session retention works
The session rule is intentionally conservative.
When HAPPIER_SERVER_RETENTION__SESSIONS__MODE=delete_inactive, the server deletes an entire session tree only if all of the following are true:
- the session
updatedAtis older than the configured cutoff - the session
lastActiveAtis older than the configured cutoff - the persisted session
activeflag isfalse - the runtime does not currently observe the session as active in memory
- the final delete transaction rechecks the same cutoff guard before anything is removed
This means the server does not need to inspect or decrypt stored transcript content to apply the current session rule.
How session message retention works
The session message rule is separate from whole-session deletion. When HAPPIER_SERVER_RETENTION__SESSION_MESSAGES__MODE=delete_older_than, the server only prunes messages that are all of the following:
- older than the configured message cutoff
- owned by a session whose
activeflag isfalse - owned by a session whose
updatedAtandlastActiveAtare older than the same cutoff - below the session read/ready watermarks and outside the preserved tail
- owned by a session that the runtime does not currently observe as active in memory
The final delete rechecks the same old-inactive session guard. The rule treats message content as opaque JSON, so encrypted and plaintext-at-rest sessions use the same retention path.
Sidechain message retention
Sidechain retention is an additional policy for subagent, nested-tool, and workflow-agent transcripts. It does not change the main transcript and does not require the parent session to be inactive. A sidechain becomes eligible only when its newest stored message is older than the configured cutoff, so a recently active sidechain is retained in full. Eligible rows are removed in bounded transactions.
The general SESSION_MESSAGES policy keeps its existing meaning and can still prune both main and sidechain rows in stale inactive sessions. Configure SESSION_MESSAGES=keep_forever with a finite SESSION_SIDECHAIN_MESSAGES policy when main transcripts should remain available indefinitely while old sidechains expire.
The released v1 server capability does not represent message-level retention. General and sidechain message policies therefore remain operator-visible configuration and are omitted from the v1 response so independently upgraded servers remain parseable by released strict v1 clients.
Default behavior
Retention only becomes effective when both of these are true:
HAPPIER_SERVER_RETENTION__ENABLED=1- at least one retention domain is configured with a finite policy
If retention is disabled, or if every domain is still set to keep_forever, the effective policy remains keep-forever for every domain.
Global retention env vars
These env vars control the retention worker itself:
HAPPIER_SERVER_RETENTION__ENABLED=1
HAPPIER_SERVER_RETENTION__INTERVAL_MS=3600000
HAPPIER_SERVER_RETENTION__BATCH_SIZE=500
HAPPIER_SERVER_RETENTION__MAX_DELETES_PER_RULE_PER_RUN=100000
HAPPIER_SERVER_RETENTION__MAX_CANDIDATES_PER_RULE_PER_RUN=10000
HAPPIER_SERVER_RETENTION__SWEEP_TIME_BUDGET_MS=10000
HAPPIER_SERVER_RETENTION__DRY_RUN=0HAPPIER_SERVER_RETENTION__ENABLED0or unset: retention is effectively off1: retention worker can run if at least one domain has a finite policy
HAPPIER_SERVER_RETENTION__INTERVAL_MS- sweep interval in milliseconds
- default:
3600000(1 hour)
HAPPIER_SERVER_RETENTION__BATCH_SIZE- maximum number of rows selected or deleted in one database page
- default:
500
HAPPIER_SERVER_RETENTION__MAX_DELETES_PER_RULE_PER_RUN- hard cap per rule for actual deletions in one sweep
- default:
100000
HAPPIER_SERVER_RETENTION__MAX_CANDIDATES_PER_RULE_PER_RUN- hard cap on candidate records examined by one rule in one sweep
- default:
10000
HAPPIER_SERVER_RETENTION__SWEEP_TIME_BUDGET_MS- wall-time budget shared by the complete sweep
- default:
10000(10 seconds)
HAPPIER_SERVER_RETENTION__DRY_RUN1: log what would be deleted without deleting it0or unset: apply deletions normally
The retention worker reads these values at server startup, runs one sweep immediately, then continues on the configured interval. Restart the server after changing retention configuration. Within a sweep it gives each enabled domain one bounded database page in registry order, then starts another round for domains that still have work. It stops when the data is exhausted or a row, candidate, or wall-time budget is reached. The next scheduled sweep resumes sidechain discovery from a persisted cursor. Successful runs log per-rule delete counts, candidates examined, database pages, stop reasons, and whether the run was a dry run.
The one-hour default keeps expiry reasonably current and gives a backlogged server up to 24 bounded cleanup opportunities per day. Lower intervals are supported, but keep the interval comfortably above the sweep time budget. The worker skips overlapping runs in one process and uses a database lock to prevent multiple server replicas from sweeping at the same time.
Session policy env vars
Sessions use a dedicated policy shape:
HAPPIER_SERVER_RETENTION__SESSIONS__MODE=delete_inactive
HAPPIER_SERVER_RETENTION__SESSIONS__INACTIVITY_DAYS=30Supported session modes:
keep_foreverdelete_inactive
HAPPIER_SERVER_RETENTION__SESSIONS__INACTIVITY_DAYS must be a positive integer and is required when the mode is delete_inactive.
Age-based domain policy env vars
All non-session retention domains use the same two-key pattern:
HAPPIER_SERVER_RETENTION__<DOMAIN>__MODE=delete_older_than
HAPPIER_SERVER_RETENTION__<DOMAIN>__DAYS=30Supported age-based modes:
keep_foreverdelete_older_than
Supported domain names:
SESSION_MESSAGESSESSION_SIDECHAIN_MESSAGESACCOUNT_CHANGESVOICE_SESSION_LEASESUSER_FEED_ITEMSSESSION_SHARE_ACCESS_LOGSPUBLIC_SHARE_ACCESS_LOGSTERMINAL_AUTH_REQUESTSACCOUNT_AUTH_REQUESTSAUTH_PAIRING_SESSIONSREPEAT_KEYSGLOBAL_LOCKSAUTOMATION_RUNSAUTOMATION_RUN_EVENTS
Example:
HAPPIER_SERVER_RETENTION__ACCOUNT_CHANGES__MODE=delete_older_than
HAPPIER_SERVER_RETENTION__ACCOUNT_CHANGES__DAYS=30Recommended rollout
Start conservatively:
- Enable only the rules you actually need.
- Turn on
HAPPIER_SERVER_RETENTION__DRY_RUN=1first. - Begin with session retention and one or two short-lived operational domains such as auth requests or access logs.
- Review the server logs to confirm the expected records would be deleted.
- Switch
DRY_RUNback to0only after the dry-run output matches your intended policy.
For many self-hosted setups, a good first production policy is:
HAPPIER_SERVER_RETENTION__ENABLED=1
HAPPIER_SERVER_RETENTION__INTERVAL_MS=3600000
HAPPIER_SERVER_RETENTION__BATCH_SIZE=500
HAPPIER_SERVER_RETENTION__MAX_DELETES_PER_RULE_PER_RUN=100000
HAPPIER_SERVER_RETENTION__MAX_CANDIDATES_PER_RULE_PER_RUN=10000
HAPPIER_SERVER_RETENTION__SWEEP_TIME_BUDGET_MS=10000
HAPPIER_SERVER_RETENTION__SESSIONS__MODE=delete_inactive
HAPPIER_SERVER_RETENTION__SESSIONS__INACTIVITY_DAYS=30
HAPPIER_SERVER_RETENTION__SESSION_MESSAGES__MODE=delete_older_than
HAPPIER_SERVER_RETENTION__SESSION_MESSAGES__DAYS=30
HAPPIER_SERVER_RETENTION__SESSION_SIDECHAIN_MESSAGES__MODE=delete_older_than
HAPPIER_SERVER_RETENTION__SESSION_SIDECHAIN_MESSAGES__DAYS=14
HAPPIER_SERVER_RETENTION__ACCOUNT_CHANGES__MODE=delete_older_than
HAPPIER_SERVER_RETENTION__ACCOUNT_CHANGES__DAYS=30
HAPPIER_SERVER_RETENTION__TERMINAL_AUTH_REQUESTS__MODE=delete_older_than
HAPPIER_SERVER_RETENTION__TERMINAL_AUTH_REQUESTS__DAYS=7
HAPPIER_SERVER_RETENTION__ACCOUNT_AUTH_REQUESTS__MODE=delete_older_than
HAPPIER_SERVER_RETENTION__ACCOUNT_AUTH_REQUESTS__DAYS=7What users see in the app
Happier surfaces the server's complete retention policy so users understand what it keeps and what it eventually deletes. The UI renders the server-provided domain list generically, including domain ids a newer server adds before the client has a dedicated label.
Today that information is exposed in:
- the active server settings screen
- the login and registration entry screen, before authentication
- session info for sessions affected by finite session retention
- saved server rows
Clients first read the unauthenticated GET /v2/retention-policy endpoint. It returns a complete array
of effective domains. Older servers fall back to GET /v1/features, under
capabilities.server.retention; that legacy response is explicitly treated as partial because v1
cannot represent message-level policies.
Operational notes
- Session retention is based on server-side metadata such as
updatedAt,lastActiveAt, andactive, not by decrypting stored messages. - Session message retention does not parse message envelopes; it applies the same session/watermark guard to encrypted and plaintext content.
- Account change retention can advance the server
changesFloor. After old account changes are pruned, very old/v2/changescursors can become invalid and return410 cursor-gone. - Retention deletes are permanent. There is no built-in quarantine or archive stage before deletion.
- Like any timestamp-based cleanup system, retention depends on accurate server time and correct stored timestamps. If you manually corrupt timestamps in the database or run with a badly wrong system clock, the policy can make wrong decisions.
SQLite maintenance after pruning
Light-server SQLite deployments run two background maintenance loops when HAPPIER_DB_PROVIDER=sqlite:
- WAL checkpointing every
HAPPIER_SQLITE_WAL_CHECKPOINT_INTERVAL_MSmilliseconds, default60000, usingPRAGMA wal_checkpoint(TRUNCATE). - Incremental vacuum every
HAPPIER_SQLITE_INCREMENTAL_VACUUM_INTERVAL_MSmilliseconds, default21600000(6 hours), usingPRAGMA incremental_vacuum(N)withHAPPIER_SQLITE_INCREMENTAL_VACUUM_PAGESpages per batch, default1000.
Both loops use a separate maintenance database client and the SQLite busy timeout controlled by HAPPIER_SQLITE_WAL_CHECKPOINT_BUSY_TIMEOUT_MS, default 5000. Set an interval to 0 to disable that loop. The server intentionally does not run full VACUUM in the live process because it rewrites the database and can block writes for too long; use offline full VACUUM only during an explicit maintenance window.
Incremental vacuum can return pages to the filesystem only when the database was created with SQLite
incremental auto-vacuum enabled. Otherwise deleted pages become reusable inside the existing database
file, but reducing the file itself still requires an offline full VACUUM maintenance window.
Keep-forever example
If you want to keep the current behavior explicitly, either do not set any retention env vars, or set:
HAPPIER_SERVER_RETENTION__ENABLED=0Dry-run example for 30-day inactive sessions
HAPPIER_SERVER_RETENTION__ENABLED=1
HAPPIER_SERVER_RETENTION__DRY_RUN=1
HAPPIER_SERVER_RETENTION__SESSIONS__MODE=delete_inactive
HAPPIER_SERVER_RETENTION__SESSIONS__INACTIVITY_DAYS=30