BareGit

Use renewable Matrix sessions

- Authenticate Lisa with a dedicated Matrix password and request refresh
  tokens instead of requiring a manually rotated access token.
- Reauthenticate after a terminal token failure and stop cleanly for invalid
  credentials or an identity mismatch.
- Update the architecture, configuration, and deployment documentation for
  the password-backed session flow.
Author: MetroWind <chris.corsair@gmail.com>
Date: Thu Jul 23 20:08:46 2026 -0700
Commit: 0e7d513710b6c191ec46d69b3d14c3307d053b88

Changes

diff --git a/README.md b/README.md
index 6d48958..9e10030 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,8 @@ using Pandoc, and append only host-validated source links.
 1. Copy `config.example.toml` to a protected location and set the Matrix
    identifiers and allowed room IDs.
 2. Copy `persona.example.md` to the configured `persona.path` and edit it.
-3. Set `LISA_MATRIX_ACCESS_TOKEN` and `LISA_LLM_API_KEY` in the service
-   environment.
+3. Set `LISA_MATRIX_PASSWORD` and `LISA_LLM_API_KEY` in the service
+   environment. Lisa requests and refreshes its own Matrix access tokens.
 4. Create the parent directories for `database_path` and `matrix_store_path`
    with read/write access for the service account.
 5. Run `cargo run -- --config /etc/lisa/config.toml`.
diff --git a/config.example.toml b/config.example.toml
index f65b698..a3f3816 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -2,7 +2,8 @@
 homeserver_url = "https://matrix.example.test"
 user_id = "@lisa:example.test"
 device_id = "LISABOT"
-access_token = "env:LISA_MATRIX_ACCESS_TOKEN"
+device_display_name = "Lisa bot"
+password = "env:LISA_MATRIX_PASSWORD"
 allowed_room_ids = ["!private_room:example.test"]
 mention_aliases = ["Lisa", "@Lisa"]
 sync_timeout_ms = 30000
diff --git a/deploy/lisa.service b/deploy/lisa.service
index f061734..2512f8c 100644
--- a/deploy/lisa.service
+++ b/deploy/lisa.service
@@ -8,9 +8,10 @@ Type=simple
 User=lisa
 Group=lisa
 EnvironmentFile=/etc/lisa/lisa.env
-ExecStart=/opt/lisa/bin/lisa --config /etc/lisa/config.toml
+ExecStart=/usr/local/bin/lisa --config /etc/lisa/config.toml
 Restart=on-failure
 RestartSec=5
+RestartPreventExitStatus=78
 NoNewPrivileges=true
 PrivateTmp=true
 ProtectSystem=strict
diff --git a/designs/design-0-architecture.md b/designs/design-0-architecture.md
index 0398bd0..368e0ef 100644
--- a/designs/design-0-architecture.md
+++ b/designs/design-0-architecture.md
@@ -94,11 +94,14 @@ into implementable interfaces:
   room behavior.
 - Pandoc is installed as a host executable and is new enough to support
   `--sandbox`.
-- The Matrix account is already joined to each allowed room. Provisioning,
-  login, room invitations, and account lifecycle are operator tasks.
-- The configured access token remains associated with the same Matrix device.
-  Lisa does not call logout. Matrix transaction IDs are device-scoped, so
-  changing devices weakens send idempotency across that change.
+- The Matrix account is already joined to each allowed room. Account
+  provisioning, room invitations, and password lifecycle are operator tasks.
+- The operator provides the dedicated Matrix account password through a
+  root-managed secret source. Lisa uses it to establish or re-establish its
+  own device session; no manually copied access token is required.
+- The configured device ID remains stable. Matrix transaction IDs are
+  device-scoped, so changing devices weakens send idempotency across that
+  change.
 - Rooms are unencrypted, as required by the PRD.
 
 Startup validation fails with a clear operator-facing error if these
@@ -401,7 +404,8 @@ Example:
 homeserver_url = "https://matrix.example.test"
 user_id = "@lisa:example.test"
 device_id = "LISABOT"
-access_token = "env:LISA_MATRIX_ACCESS_TOKEN"
+device_display_name = "Lisa bot"
+password = "env:LISA_MATRIX_PASSWORD"
 allowed_room_ids = ["!private_room:example.test"]
 mention_aliases = ["Lisa", "@Lisa"]
 sync_timeout_ms = 30000
@@ -469,6 +473,12 @@ Validation happens before the sync loop starts:
   numeric limits must be non-empty and within hard safety ceilings.
 - The configured Lisa user and device IDs must equal `/whoami`'s returned
   identity.
+- `matrix.password` must be an `env:` reference that resolves to a non-empty
+  value. `matrix.access_token` is not accepted: it creates an operational
+  requirement to replace configuration when a homeserver uses expiring tokens.
+- `matrix.device_display_name` must be non-empty and fit within the Matrix
+  protocol's configured implementation limit. It labels the dedicated bot
+  device for operators; it does not affect invocation behavior.
 - The persona file must be a regular file below a hard byte limit. The model
   cannot choose its path.
 - Every `env:` reference must resolve. Logs mention the configuration key but
@@ -705,26 +715,70 @@ substitute for the invocation ledger.
 
 ### 10.1 Authentication and startup
 
-Lisa receives a pre-provisioned access token through the environment. It
-sends it in the `Authorization: Bearer` header, never in a query string.
+Lisa authenticates as a normal Matrix client with the dedicated account
+password from `matrix.password`. The password is supplied only through a
+secret environment reference and is never written to TOML, SQLite, the Matrix
+SDK store, logs, diagnostics, or room events.
+
+On each successful password login, Lisa supplies the configured stable
+`device_id`, the configured `device_display_name`, and requests a Matrix
+refresh token. The homeserver then associates the access token and refresh
+token with the Lisa device. This is the normal Matrix login flow: the
+homeserver may issue an expiring access token together with a refresh token,
+or may issue a non-expiring access token. See the Matrix
+[login](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3login)
+and
+[refresh](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3refresh)
+endpoints.
+
+The `matrix-sdk` client is built with refresh-token handling enabled. While a
+client instance remains active, the SDK refreshes an expired access token and
+uses the replacement token for subsequent requests. Lisa does not copy
+rotated access or refresh tokens into its configuration or its invocation
+database. The password is the durable bootstrap credential, so restarting the
+service does not depend on preserving a rotated token outside the SDK client.
+
+Authentication errors have distinct handling:
+
+1. A transient login, DNS, TLS, or Matrix server failure enters the Matrix
+   reconnect backoff and does not alter the durable sync cursor or invocation
+   ledger.
+2. When the SDK cannot refresh a token, or Matrix returns a terminal invalid
+   authentication response, the Matrix lifecycle supervisor discards that
+   client instance and performs password login again with the same device ID.
+3. After reauthentication, Lisa calls `/whoami`, verifies the configured user
+   and device, then continues from its persisted sync cursor. It never runs a
+   replacement baseline merely because credentials changed.
+4. An invalid password, an identity mismatch, or a homeserver that does not
+   offer a usable password login flow is a startup or reconnect configuration
+   failure. Lisa logs only a stable error code and key name, does not send a
+   room message, and exits with the documented configuration-error status.
+   It never logs the password or protocol body.
+
+Using the same device ID for reauthentication is intentional. Matrix servers
+may invalidate previous tokens associated with a supplied device ID; this does
+not affect Lisa's deterministic transaction IDs because the device ID itself
+remains unchanged. Lisa never calls Matrix logout as part of ordinary retry or
+shutdown handling.
 
 Startup order is:
 
 1. Load and validate local configuration.
 2. Start the database worker and run Lisa's SQLite migrations.
 3. Build `matrix_sdk::Client` with the configured homeserver and persistent
-   SDK state store, then restore the configured user, device, and access
-   token as one Matrix session.
-4. Verify Pandoc.
-5. Call `client.whoami().await`.
-6. Confirm that the returned user and device IDs match configuration.
-7. If no Lisa sync cursor exists, call `client.sync_once()` without a token,
+   SDK state store, with automatic refresh-token handling enabled.
+4. Log in with the configured Matrix user ID, password, stable device ID, and
+   device display name while requesting a refresh token.
+5. Verify Pandoc.
+6. Call `client.whoami().await`.
+7. Confirm that the returned user and device IDs match configuration.
+8. If no Lisa sync cursor exists, call `client.sync_once()` without a token,
    persist only `next_batch`, mark `initialized = 1`, and discard all
    returned timelines as invocation candidates.
-8. If a cursor exists, begin controlled `sync_once()` calls using that
+9. If a cursor exists, begin controlled `sync_once()` calls using that
    explicit token.
-9. Requeue expired `processing` and all `ready_to_send` rows.
-10. Start the dispatcher and typing-independent health status.
+10. Requeue expired `processing` and all `ready_to_send` rows.
+11. Start the dispatcher and typing-independent health status.
 
 Discarding the first timeline is the explicit protection against answering
 old messages when Lisa is first installed. The Matrix specification describes
@@ -870,6 +924,17 @@ Retry network errors and `5xx` responses with exponential backoff capped by
 configuration. Do not retry ordinary `4xx` responses except token-position
 recovery and rate limits.
 
+An authentication failure is handled before that ordinary-`4xx` rule. The
+client lifecycle supervisor first lets the SDK refresh when the response is a
+refreshable token failure. If refresh is unavailable or rejected, it replaces
+the client through the password-login sequence in section 10.1. A bad password
+or identity mismatch is not retried as a Matrix request; it remains an
+operator-visible configuration failure until the secret or configuration is
+corrected. Lisa uses exit status `78` for that permanent configuration error;
+the service unit prevents systemd from immediately restarting it. This
+distinction prevents an expired token from requiring manual configuration
+rotation while avoiding an unbounded password-login loop.
+
 The sync task catches failures at its outer loop, logs a redacted error code,
 backs off, and creates a new request. Matrix long polling is stateless beyond
 the persisted `since` token, so no special socket-level reconnect API is
@@ -1014,8 +1079,8 @@ Representative request:
 ```
 
 Unknown response fields are ignored, but required fields and types are
-strictly validated. The access token, full request, and response are never
-logged.
+strictly validated. Matrix credentials, the full request, and the response are
+never logged.
 
 ### 12.2 Prompt layers
 
@@ -1559,6 +1624,15 @@ account. The OS user needs:
 It does not need shell login, source-tree write access, home-directory
 secrets, Docker control, or broad filesystem read access.
 
+The Matrix account must be dedicated to Lisa and restricted by its room
+memberships. Its password is a long-lived bootstrap credential, not a general
+operator password. Store `LISA_MATRIX_PASSWORD` in the root-managed service
+secret file with mode `0600`. The file may also contain LLM and optional search
+credentials, but must never contain a Matrix access or refresh token. A user
+who can read the Lisa process environment can use the account, so the service
+account must not have interactive login access and the host must not grant
+untrusted users process-inspection access.
+
 ### 18.2 Service manager hardening
 
 For a systemd deployment, use protections such as:
@@ -1567,9 +1641,11 @@ For a systemd deployment, use protections such as:
 [Service]
 User=lisa
 Group=lisa
-ExecStart=/opt/lisa/bin/lisa --config /etc/lisa/config.toml
+EnvironmentFile=/etc/lisa/lisa.env
+ExecStart=/usr/local/bin/lisa --config /etc/lisa/config.toml
 Restart=on-failure
 RestartSec=5
+RestartPreventExitStatus=78
 NoNewPrivileges=true
 PrivateTmp=true
 ProtectSystem=strict
@@ -1613,8 +1689,11 @@ and previous application release.
 Configuration:
 
 - Accept valid `env:` references without exposing their values.
-- Reject literal secrets, missing variables, duplicate rooms, invalid URLs,
-  invalid Matrix IDs, unsafe limits, and oversized personas.
+- Reject literal secrets, legacy access-token configuration, missing or empty
+  password variables, duplicate rooms, invalid URLs, invalid Matrix IDs,
+  unsafe limits, and oversized personas.
+- Redact the Matrix password in `Debug`, `Display`, structured errors, and
+  captured logs.
 
 Invocation classification:
 
@@ -1714,10 +1793,22 @@ SDK-compatible HTTP protocol fake for deterministic failure cases. Use
 test setup:
 
 - First sync ignores historical mentions.
+- Password login includes the configured user and stable device ID, requests a
+  refresh token, and rejects a response whose `/whoami` identity differs from
+  configuration.
+- An expired access token triggers one SDK refresh, retains the same Matrix
+  device identity, and resumes the failed Matrix operation without creating a
+  duplicate invocation or response.
+- A rejected refresh token rebuilds the Matrix client and performs password
+  login, then resumes from the persisted sync cursor without a replacement
+  baseline sync.
+- Reauthentication with an invalid password emits a redacted operator error,
+  sends no room event, and leaves durable invocations retryable for a later
+  successful reconnect.
 - Invocation processing consumes returned `SyncResponse` batches rather than
   SDK event-handler callbacks.
-- Lisa's explicitly persisted token wins if the SDK store contains a newer
-  token from a response that was not committed to Lisa's ledger.
+- Refresh-token rotation never changes Lisa's persisted sync cursor,
+  invocation ledger, or deterministic transaction IDs.
 - Incremental mention produces one reply.
 - Reply to Lisa without a mention produces one reply.
 - Ordinary message and unallowed-room mention produce none.
@@ -1757,8 +1848,9 @@ integration scenario. The release gate requires:
 1. Create the Cargo package, configuration types, secret resolution, and
    structured `tracing` setup.
 2. Implement the dedicated SQLite worker, migrations, and state transitions.
-3. Configure `matrix-sdk` without encryption, restore the Matrix session, and
-   add SDK-backed protocol fixtures.
+3. Configure `matrix-sdk` without encryption, implement password bootstrap,
+   automatic token refresh, reauthentication, and SDK-backed protocol
+   fixtures.
 4. Implement startup baseline and controlled `sync_once()` ingestion.
 5. Implement Ruma event classification and deterministic reply sending with
    `with_transaction_id()`.
@@ -1866,6 +1958,8 @@ the only supported extension mechanism.
 | LLM context exceeds model capacity | Event, character, tool, and adapter request limits |
 | Pandoc consumes excessive resources | Input/output/time bounds and OS sandboxing |
 | Secrets appear in logs | Structured allowlisted fields, redaction filter, canary tests |
+| Matrix access token expires | Request and handle refresh tokens; reauthenticate with the dedicated password when refresh fails |
+| Matrix password is exposed | Root-only secret file, dedicated account, no process inspection for untrusted users, and secret-canary tests |
 | Persona conflicts with capability | Host policy precedence and runtime authorization |
 | SQLite is corrupted | Fail closed, restore backup, never guess deduplication state |
 
diff --git a/docs/operations.md b/docs/operations.md
index d8b120e..e08fd48 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -7,9 +7,12 @@ model-selected web destinations.
 
 Use [`deploy/lisa.service`](../deploy/lisa.service) as a systemd starting point.
 Keep credentials in `/etc/lisa/lisa.env` with restrictive permissions; do not
-place tokens in the TOML file or the systemd unit. The service restarts after
-temporary failures and uses its durable Matrix transaction IDs when retrying a
-reply.
+place them in the TOML file or the systemd unit. Set
+`LISA_MATRIX_PASSWORD` for Lisa's dedicated Matrix account, not a manually
+created access token. Lisa requests refresh tokens and renews access tokens
+while it runs; after a rejected refresh it logs in again with the password.
+The service stops without restart for a bad password or identity mismatch,
+using exit status 78, until the configuration is corrected.
 
 ## Health and diagnostics
 
diff --git a/src/app.rs b/src/app.rs
index 730fab0..8392dda 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -139,7 +139,18 @@ impl LisaApp {
                 }
                 result = self.pollOnce() =>
                 {
-                    let added = result?;
+                    let added = match result
+                    {
+                        Ok(added) => added,
+                        Err(error) if error.needsMatrixReauthentication() =>
+                        {
+                            warn!("matrix_session_reauthentication_started");
+                            self.matrix.reconnect().await?;
+                            info!("matrix_session_reauthentication_complete");
+                            continue;
+                        }
+                        Err(error) => return Err(error),
+                    };
                     if added > 0
                     {
                         info!(candidate_count = added, "matrix_candidates_ingested");
diff --git a/src/config.rs b/src/config.rs
index 14e07a6..ea5fd99 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -17,6 +17,7 @@ const MAX_CONCURRENT_ROOMS: u32 = 64;
 const MAX_CONTEXT_EVENTS: u32 = 200;
 const MAX_CONTEXT_CHARACTERS: u32 = 200_000;
 const MAX_LLM_ITERATIONS: u32 = 16;
+const MAX_DEVICE_DISPLAY_NAME_CHARACTERS: usize = 256;
 
 /// Holds a resolved secret while preventing accidental debug output.
 #[derive(Clone, Eq, PartialEq)]
@@ -36,7 +37,7 @@ impl fmt::Debug for SecretString {
     }
 }
 
-/// Holds the Matrix settings needed during the foundation phase.
+/// Holds the Matrix settings needed for Lisa's dedicated client session.
 #[derive(Clone, Debug)]
 pub struct MatrixConfig {
     /// Absolute Matrix homeserver URL.
@@ -45,8 +46,10 @@ pub struct MatrixConfig {
     pub user_id: String,
     /// Stable Matrix device ID used for transaction idempotency.
     pub device_id: String,
-    /// Access token resolved from the environment.
-    pub access_token: SecretString,
+    /// Human-readable label assigned to Lisa's Matrix device.
+    pub device_display_name: String,
+    /// Password resolved from the environment for automatic session renewal.
+    pub password: SecretString,
     /// Exact room IDs in which Lisa may operate.
     pub allowed_room_ids: HashSet<String>,
     /// Human-facing aliases accepted as Lisa mentions.
@@ -192,11 +195,13 @@ struct RawConfig {
 }
 
 #[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
 struct RawMatrixConfig {
     homeserver_url: String,
     user_id: String,
     device_id: String,
-    access_token: String,
+    device_display_name: String,
+    password: String,
     allowed_room_ids: Vec<String>,
     mention_aliases: Vec<String>,
     sync_timeout_ms: u64,
@@ -280,8 +285,8 @@ impl AppConfig {
         let homeserver_url = parse_http_url("matrix.homeserver_url", &raw.matrix.homeserver_url)?;
         validate_user_id(&raw.matrix.user_id)?;
         validate_device_id(&raw.matrix.device_id)?;
-        let access_token =
-            resolve_secret("matrix.access_token", &raw.matrix.access_token, &get_env)?;
+        validate_device_display_name(&raw.matrix.device_display_name)?;
+        let password = resolve_secret("matrix.password", &raw.matrix.password, &get_env)?;
         validate_limits(&raw.matrix, &raw.runtime)?;
         validate_llm(&raw.llm)?;
         validate_persona(&raw.persona)?;
@@ -296,7 +301,8 @@ impl AppConfig {
                 homeserver_url,
                 user_id: raw.matrix.user_id,
                 device_id: raw.matrix.device_id,
-                access_token,
+                device_display_name: raw.matrix.device_display_name,
+                password,
                 allowed_room_ids,
                 mention_aliases,
                 sync_timeout_ms: raw.matrix.sync_timeout_ms,
@@ -405,6 +411,16 @@ fn validate_device_id(value: &str) -> Result<(), ConfigurationError> {
     Ok(())
 }
 
+fn validate_device_display_name(value: &str) -> Result<(), ConfigurationError> {
+    if value.trim().is_empty() || value.chars().count() > MAX_DEVICE_DISPLAY_NAME_CHARACTERS {
+        return Err(invalid(
+            "matrix.device_display_name",
+            "must be non-empty and no more than 256 characters",
+        ));
+    }
+    Ok(())
+}
+
 fn validate_rooms(values: Vec<String>) -> Result<HashSet<String>, ConfigurationError> {
     if values.is_empty() {
         return Err(invalid("matrix.allowed_room_ids", "must not be empty"));
@@ -595,7 +611,8 @@ mod tests {
 homeserver_url = "https://matrix.example.test"
 user_id = "@lisa:example.test"
 device_id = "LISABOT"
-access_token = "env:LISA_MATRIX_ACCESS_TOKEN"
+device_display_name = "Lisa bot"
+password = "env:LISA_MATRIX_PASSWORD"
 allowed_room_ids = ["!private:example.test"]
 mention_aliases = ["Lisa", "@Lisa"]
 sync_timeout_ms = 30000
@@ -651,22 +668,28 @@ user_agent = "LisaBot/1.0"
 "#;
 
     #[test]
-    fn resolves_secret_without_exposing_it() {
+    fn resolves_password_without_exposing_it() {
         let config = AppConfig::from_toml_with_env(CONFIG, |name| {
-            matches!(name, "LISA_MATRIX_ACCESS_TOKEN" | "LISA_LLM_API_KEY")
+            matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
                 .then(|| "secret-canary".to_owned())
         })
         .unwrap();
 
-        assert_eq!(config.matrix.access_token.expose(), "secret-canary");
-        assert_eq!(format!("{:?}", config.matrix.access_token), "[REDACTED]");
+        assert_eq!(config.matrix.password.expose(), "secret-canary");
+        assert_eq!(format!("{:?}", config.matrix.password), "[REDACTED]");
     }
 
     #[test]
     fn rejects_literal_secret_and_duplicate_room() {
-        let literal = CONFIG.replace("env:LISA_MATRIX_ACCESS_TOKEN", "secret");
+        let literal = CONFIG.replace("env:LISA_MATRIX_PASSWORD", "secret");
         assert!(AppConfig::from_toml_with_env(&literal, |_| None).is_err());
 
+        let legacy = CONFIG.replace(
+            "password = \"env:LISA_MATRIX_PASSWORD\"",
+            "password = \"env:LISA_MATRIX_PASSWORD\"\naccess_token = \"env:LISA_MATRIX_ACCESS_TOKEN\"",
+        );
+        assert!(AppConfig::from_toml_with_env(&legacy, |_| Some("token".to_owned())).is_err());
+
         let duplicate = CONFIG.replace(
             "[\"!private:example.test\"]",
             "[\"!private:example.test\", \"!private:example.test\"]",
diff --git a/src/errors.rs b/src/errors.rs
index 902c39f..cc3e6ee 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -53,6 +53,27 @@ pub enum MatrixError {
     /// Reports a Matrix session identity mismatch.
     #[error("Matrix identity does not match the configured account")]
     IdentityMismatch,
+
+    /// Reports a rejected Matrix password without exposing it.
+    #[error("Matrix credentials were rejected")]
+    InvalidCredentials,
+}
+
+impl MatrixError {
+    /// Returns whether rebuilding the Matrix session can recover this error.
+    #[must_use]
+    pub fn needsReauthentication(&self) -> bool {
+        let Self::Sdk(error) = self else {
+            return false;
+        };
+        let matrix_sdk::Error::Http(error) = error else {
+            return false;
+        };
+        matches!(
+            error.client_api_error_kind(),
+            Some(matrix_sdk::ruma::api::error::ErrorKind::UnknownToken(_))
+        ) || matches!(error.as_ref(), matrix_sdk::HttpError::RefreshToken(_))
+    }
 }
 
 /// Reports a bounded LLM completion failure without exposing request content.
@@ -90,3 +111,21 @@ pub enum AppError {
     #[error(transparent)]
     Llm(#[from] LlmError),
 }
+
+impl AppError {
+    /// Returns whether the Matrix session must be recreated before continuing.
+    #[must_use]
+    pub fn needsMatrixReauthentication(&self) -> bool {
+        matches!(self, Self::Matrix(error) if error.needsReauthentication())
+    }
+
+    /// Returns whether systemd should stop restarting for this error.
+    #[must_use]
+    pub fn isPermanentConfigurationFailure(&self) -> bool {
+        matches!(
+            self,
+            Self::Configuration(_)
+                | Self::Matrix(MatrixError::IdentityMismatch | MatrixError::InvalidCredentials)
+        )
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 21bd0de..30478a1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,6 +9,8 @@ use std::{
 
 use lisa::{app::LisaApp, config::AppConfig, logging, state::StateStore};
 
+const CONFIGURATION_ERROR_EXIT: u8 = 78;
+
 struct Command
 {
     config_path: PathBuf,
@@ -28,7 +30,7 @@ async fn main() -> ExitCode {
         Ok(config) => config,
         Err(error) => {
             eprintln!("configuration error: {error}");
-            return ExitCode::FAILURE;
+            return ExitCode::from(CONFIGURATION_ERROR_EXIT);
         }
     };
     logging::init(&config.runtime.log_level);
@@ -44,7 +46,14 @@ async fn main() -> ExitCode {
         Ok(()) => ExitCode::SUCCESS,
         Err(error) => {
             eprintln!("Lisa stopped: {error}");
-            ExitCode::FAILURE
+            if error.isPermanentConfigurationFailure()
+            {
+                ExitCode::from(CONFIGURATION_ERROR_EXIT)
+            }
+            else
+            {
+                ExitCode::FAILURE
+            }
         }
     }
 }
diff --git a/src/matrix.rs b/src/matrix.rs
index 45ba423..4e89c6d 100644
--- a/src/matrix.rs
+++ b/src/matrix.rs
@@ -1,11 +1,14 @@
 //! Matrix SDK session handling, controlled sync ingestion, and event policy.
 
-use std::time::Duration;
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
 
 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
 use matrix_sdk::{
-    Client, SessionMeta, SessionTokens,
-    authentication::matrix::MatrixSession,
+    Client,
     config::SyncSettings,
     ruma::{
         OwnedEventId, OwnedRoomId, OwnedTransactionId, UInt,
@@ -19,6 +22,7 @@ use matrix_sdk::{
 };
 use serde_json::Value;
 use sha2::{Digest, Sha256};
+use tokio::sync::{Mutex, RwLock};
 
 use crate::{config::MatrixConfig, errors::MatrixError, models::InvocationCandidate};
 
@@ -52,36 +56,53 @@ pub enum Classification {
 /// Wraps the Matrix SDK while keeping Lisa's sync cursor authoritative.
 #[derive(Clone, Debug)]
 pub struct MatrixClient {
-    client: Client,
+    client: Arc<RwLock<Client>>,
     config: MatrixConfig,
+    matrix_store_path: Arc<PathBuf>,
+    reconnect_lock: Arc<Mutex<()>>,
 }
 
 impl MatrixClient {
-    /// Builds an unencrypted Matrix SDK client and restores Lisa's session.
+    /// Builds an unencrypted Matrix SDK client and creates Lisa's session.
     pub async fn connect(
         config: MatrixConfig,
-        matrix_store_path: &std::path::Path,
+        matrix_store_path: &Path,
     ) -> Result<Self, MatrixError> {
+        let matrix_store_path = Arc::new(matrix_store_path.to_owned());
+        let client = Self::login(&config, &matrix_store_path).await?;
+        Ok(Self {
+            client: Arc::new(RwLock::new(client)),
+            config,
+            matrix_store_path,
+            reconnect_lock: Arc::new(Mutex::new(())),
+        })
+    }
+
+    /// Recreates Lisa's Matrix session after a terminal token failure.
+    pub async fn reconnect(&self) -> Result<(), MatrixError> {
+        let _guard = self.reconnect_lock.lock().await;
+        let client = Self::login(&self.config, &self.matrix_store_path).await?;
+        *self.client.write().await = client;
+        Ok(())
+    }
+
+    async fn login(config: &MatrixConfig, matrix_store_path: &Path) -> Result<Client, MatrixError> {
         let client = Client::builder()
             .homeserver_url(config.homeserver_url.as_str())
             .sqlite_store(matrix_store_path, None)
+            .handle_refresh_tokens()
             .build()
             .await
             .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
-        let session = MatrixSession {
-            meta: SessionMeta {
-                user_id: config
-                    .user_id
-                    .parse::<matrix_sdk::ruma::OwnedUserId>()
-                    .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?,
-                device_id: config.device_id.clone().into(),
-            },
-            tokens: SessionTokens {
-                access_token: config.access_token.expose().to_owned(),
-                refresh_token: None,
-            },
-        };
-        client.restore_session(session).await?;
+        client
+            .matrix_auth()
+            .login_username(&config.user_id, config.password.expose())
+            .device_id(&config.device_id)
+            .initial_device_display_name(&config.device_display_name)
+            .request_refresh_token()
+            .send()
+            .await
+            .map_err(loginError)?;
 
         let identity = client
             .whoami()
@@ -96,17 +117,22 @@ impl MatrixClient {
             return Err(MatrixError::IdentityMismatch);
         }
 
-        Ok(Self { client, config })
+        Ok(client)
+    }
+
+    async fn activeClient(&self) -> Client {
+        self.client.read().await.clone()
     }
 
     /// Executes exactly one explicit-token Matrix sync request.
     pub async fn syncOnce(&self, since: Option<String>) -> Result<SyncBatch, MatrixError> {
+        let client = self.activeClient().await;
         let mut settings =
             SyncSettings::new().timeout(Duration::from_millis(self.config.sync_timeout_ms));
         if let Some(token) = since {
             settings = settings.token(token);
         }
-        let response = self.client.sync_once(settings).await?;
+        let response = client.sync_once(settings).await?;
         let mut rooms = Vec::new();
         for (room_id, update) in response.rooms.joined {
             if !self.config.allowed_room_ids.contains(room_id.as_str()) {
@@ -135,13 +161,14 @@ impl MatrixClient {
         room_id: &str,
         event_id: &str,
     ) -> Result<bool, MatrixError> {
+        let client = self.activeClient().await;
         let room_id: OwnedRoomId = room_id
             .parse::<OwnedRoomId>()
             .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
         let event_id: OwnedEventId = event_id
             .parse::<OwnedEventId>()
             .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
-        let Some(room) = self.client.get_room(&room_id) else {
+        let Some(room) = client.get_room(&room_id) else {
             return Ok(false);
         };
         let event = room.event(&event_id, None).await?;
@@ -157,11 +184,12 @@ impl MatrixClient {
         response_json: &str,
         transaction_id: &str,
     ) -> Result<String, MatrixError> {
+        let client = self.activeClient().await;
         let room_id: OwnedRoomId = room_id
             .parse::<OwnedRoomId>()
             .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
         let transaction_id: OwnedTransactionId = transaction_id.into();
-        let Some(room) = self.client.get_room(&room_id) else {
+        let Some(room) = client.get_room(&room_id) else {
             return Err(MatrixError::InvalidResponse(
                 "configured room is unavailable".to_owned(),
             ));
@@ -182,13 +210,14 @@ impl MatrixClient {
         event_id: &str,
         maximum_events: u32,
     ) -> Result<Vec<Value>, MatrixError> {
+        let client = self.activeClient().await;
         let room_id: OwnedRoomId = room_id
             .parse::<OwnedRoomId>()
             .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
         let event_id: OwnedEventId = event_id
             .parse::<OwnedEventId>()
             .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
-        let Some(room) = self.client.get_room(&room_id) else {
+        let Some(room) = client.get_room(&room_id) else {
             return Err(MatrixError::InvalidResponse(
                 "configured room is unavailable".to_owned(),
             ));
@@ -207,10 +236,11 @@ impl MatrixClient {
 
     /// Updates Lisa's typing notice for one configured joined room.
     pub async fn setTyping(&self, room_id: &str, typing: bool) -> Result<(), MatrixError> {
+        let client = self.activeClient().await;
         let room_id: OwnedRoomId = room_id
             .parse::<OwnedRoomId>()
             .map_err(|error| MatrixError::InvalidResponse(error.to_string()))?;
-        let Some(room) = self.client.get_room(&room_id) else {
+        let Some(room) = client.get_room(&room_id) else {
             return Err(MatrixError::InvalidResponse(
                 "configured room is unavailable".to_owned(),
             ));
@@ -220,6 +250,21 @@ impl MatrixClient {
     }
 }
 
+fn loginError(error: matrix_sdk::Error) -> MatrixError {
+    if let matrix_sdk::Error::Http(http_error) = &error
+        && matches!(
+            http_error.client_api_error_kind(),
+            Some(matrix_sdk::ruma::api::error::ErrorKind::Forbidden)
+        )
+    {
+        MatrixError::InvalidCredentials
+    }
+    else
+    {
+        MatrixError::Sdk(error)
+    }
+}
+
 /// Returns a deterministic transaction ID for an invocation's single reply.
 #[must_use]
 pub fn transactionId(room_id: &str, event_id: &str) -> String {
@@ -371,7 +416,8 @@ mod tests {
 homeserver_url = "https://matrix.example.test"
 user_id = "@lisa:example.test"
 device_id = "LISABOT"
-access_token = "env:TOKEN"
+device_display_name = "Lisa bot"
+password = "env:TOKEN"
 allowed_room_ids = ["!room:example.test"]
 mention_aliases = ["Lisa", "@Lisa"]
 sync_timeout_ms = 30000