## Summary

- Native Matrix / Element channel — Carapace owns the Matrix SDK
  client, login/session restore, sync loop, invite decisions, device
  verification (SAS) state, encrypted SQLite store, and outbound queue.
- Encrypted Matrix rooms supported via `matrix-sdk` 0.14.x with
  `e2e-encryption` + `sqlite` + `rustls-tls`. Cross-signing,
  recovery-key restore/show, and a documented store-rekey lifecycle.
- Inbound message dispatch with bounded body cap, idempotency-key
  deduplication, encrypted dead-letter queue (v2 Argon2id-derived
  AEAD key for new writes, legacy HKDF v1 read support, capped at
  10 000 records), and operator-visible status counters.
- `/health/ready` now returns `503` if any registered channel is in
  `Error` state. Existing channels and a configured Matrix channel
  contribute; an unconfigured channel cannot drop readiness.
- Typed `MatrixError` variants and a stable `lastErrorKind` JSON
  field on `/control/channels` so the CLI's `cara verify --outcome
  matrix` can route per-variant remediation hints (rekey-token,
  rekey-recovery, fix-config) without substring-matching the
  human-readable message.
- Operator-visible runtime hardening: hostile-homeserver identifier
  sanitization (bidi/zero-width/TAG/Variation Selectors), control-
  byte / Cf strip in the global log redactor, Zeroize discipline for
  config passphrases and derived AEAD keys, subprocess env-strip
  list extended to cover all carapace + Matrix secrets.
- Control-plane origin hardening: local-direct detection now trusts
  only sanitized forwarded headers from configured proxies, and
  OAuth `redirectBaseUrl` / provider `redirectUri` values reject
  whitespace, userinfo, fragments, and off-shape URLs.
- WebSocket broadcasts now measure the final serialized frame against
  `WS_BROADCAST_PAYLOAD_MAX_BYTES` (1 MiB), drop oversized non-agent
  events before fan-out, and replace oversized agent payloads with a
  structured truncation marker when it fits.

## Breaking Changes

- **`/health/ready` returns `503` when any configured channel is in
  `Error` state.** Operators relying on `/health/ready` for Kubernetes
  readiness probes / load-balancer membership will see pod-level
  `503` when a Matrix channel goes Error (interrupted rekey, wrong
  passphrase, homeserver-revoked token), a Slack channel loses its
  WebSocket token, etc. Use `cara status` or `GET /control/channels`
  to identify which channel is broken. Probes that need binary-only
  liveness can use `/health/live`, which always returns `200` if the
  HTTP server is responding.
- **Matrix is a top-level channel.** New `matrix:` config section
  with `enabled` / `homeserverUrl` / `userId` / `password` /
  `accessToken` / `deviceId` / `storePassphrase` / `encrypted` /
  `autoJoin.{allowUsers,allowServerNames}` keys. Existing carapace
  configs without a `matrix:` section silently no-op (the runtime
  reads `MatrixConfigResolve::Missing` and skips registration).
- **New `/control/matrix/*` REST surface:** `GET devices`,
  `GET verifications`, `POST verifications` (start a flow),
  `POST verifications/{id}/accept`, `POST verifications/{id}/confirm`,
  `POST verifications/{id}/cancel`, plus `POST send-test`. All
  require control-plane auth (token or password), same as existing
  control endpoints. See [`docs/protocol/http.md`](../protocol/http.md)
  for the full mapping. Note: `recovery-key/{show,restore,rotate}`
  and `rekey-store` are CLI-only by design (`cara matrix recovery-key
  show` etc.) — they never traverse the control API.
- **Per-variant Matrix HTTP status mapping is exhaustive.** `410 Gone`
  for `VerificationCancelled`, `503 Service Unavailable` for
  account-state classes (`M_FORBIDDEN`, `M_UNKNOWN_TOKEN`,
  `M_USER_DEACTIVATED`, `M_USER_LOCKED`, `M_USER_SUSPENDED`), `422
  Unprocessable Entity` for permanent send rejections (`M_TOO_LARGE`,
  `M_BAD_JSON`, `M_GUEST_ACCESS_FORBIDDEN`, `M_UNRECOGNIZED`). Status
  table in [`docs/protocol/http.md`](../protocol/http.md#matrix-endpoint-error-mapping).
- **`/control/channels` Matrix entries now carry `extra` with the
  full `MatrixStatusMetadata` shape**, including the new
  `lastErrorKind` discriminator, forensic-timestamp / DLQ-loss
  fields (`inboundDlqDurabilityError`,
  `inboundDlqDurabilityErrorAt`, `inboundDlqLostEventIds`,
  `inboundDlqLostEventIdsAt`, `inboundDlqUndecodableLostCount`,
  `lastInboundFailureAt`, `lastInboundDlqAppendFailureAt`), Matrix
  peer-drop counters, and the corrupt inbound dedupe-index counter.
  All optional / omitted-by-default or cumulative. Wire-stable. See
  [`docs/protocol/http.md` → `extra.lastErrorKind` (Matrix)](../protocol/http.md#extralasterrorkind-matrix)
  for the full kind table and the forensic-timestamp table.
- **`/control/matrix/devices` entries carry an optional
  `rawDeviceIdHex` field**, populated only when identifier
  sanitization altered the homeserver-original `deviceId`. The
  value is the hex encoding of the original UTF-8 bytes — chosen
  for terminal safety so adversarial peer device_ids carrying DEL
  / C1 / bidi / control bytes never reach the operator's terminal
  via `cara matrix devices` JSON output. Wire-stable. Operator
  scripts performing byte-exact SDK lookups decode the hex; humans
  use `deviceId` and rely on `cara matrix verify`'s
  sanitization-equivalence resolver.
- **Delivery results are a tagged HTTP/control shape.** Native
  channels now surface terminal vs transient retryability via the
  `retryability.kind` tag, and transient Slack / Discord / Telegram /
  Signal / webhook 429 responses carry bounded `retryAfterMs` when the provider
  supplied a Retry-After value. WASM channel adapters now preserve the
  plugin's `retryable` error bit as tagged retryability, but the WIT
  ABI still has no retry-after field. Message hook payloads keep the legacy
  `delivery.retryable` boolean alongside the tagged `retryability`
  object for compatibility. Existing WASM provider plugins remain on the
  `carapace:plugin@1.0.0` boolean `retryable` ABI until a future WIT
  version adds an optional retry-after field.

## Migration

- **OAuth `redirectBaseUrl` / per-provider `redirectUri` are now
  validated at startup with `Severity::Error` (fatal).** Operators
  on master who set `auth.profiles.redirectBaseUrl` to anything
  other than a bare origin (e.g.
  `"https://gw.example.com/oauth"` with a path) — or set a
  `redirectUri` containing whitespace / userinfo / fragment — will
  fail startup on upgrade with a schema error rather than the
  prior silent-passthrough behavior. The tightened acceptance is
  the security improvement; the fail-loud upgrade behavior is the
  intentional UX trade-off vs the master-era silent-acceptance-
  then-mis-route. To fix: edit your config to remove the path /
  userinfo / fragment from `redirectBaseUrl` (keep the
  origin only: `"https://gw.example.com"`), and use per-provider
  `redirectUri` for the path portion. See
  [`docs/protocol/http.md` → OAuth](../protocol/http.md) for the
  full callback URL contract.
- **Existing operators upgrading without configuring Matrix** need no
  migration. The `matrix:` section is optional and unconfigured-by-
  default. Matrix-related env vars (`CARAPACE_CONFIG_PASSWORD`,
  `CARAPACE_GATEWAY_TOKEN`, `CARAPACE_GATEWAY_PASSWORD`,
  `CARAPACE_SERVER_SECRET`, `CARAPACE_HOOKS_TOKEN`,
  `MATRIX_STORE_PASSPHRASE`, `MATRIX_PASSWORD`, `MATRIX_ACCESS_TOKEN`)
  are added to the subprocess env-strip list — operators relying on
  these names being passed through to spawned tools should rename their
  variables.
- **Operators wiring Matrix** should follow
  [`docs/channels.md` → Matrix / Element](../channels.md#matrix--element)
  for setup. The encrypted-store passphrase derivation MUST stay
  consistent across daemon runs — rotating `CARAPACE_CONFIG_PASSWORD`
  requires `cara matrix rekey-store --new` first; see
  [Matrix store rekey lifecycle](../channels.md#matrix-store-rekey-lifecycle).
- **DLQ envelope v1 → v2 migration is automatic.** Existing on-
  disk Matrix inbound DLQ records (envelope v1 / HKDF) continue to
  decode after upgrade. New writes always emit v2 (Argon2id).
  Operators do not need to drain the DLQ before bumping carapace.
  V1 records rotate to v2 organically as the replay loop re-encodes
  after dispatch failure. The v2 derivation is memory-hard, raising
  the per-guess cost of offline brute-force on
  `CARAPACE_CONFIG_PASSWORD` (given local read access to the DLQ
  file plus `installation_id`) from HKDF-fast (microseconds) to
  tens of milliseconds.
- **Operators with Kubernetes readiness probes against `/health/ready`**
  should review the new channel-Error → 503 contract above. If pod-
  level reload-on-channel-Error is undesirable, switch to
  `/health/live` (binary up/down) and rely on `cara status` /
  Prometheus metrics for channel-state visibility.

## Security

- Hostile-homeserver identifier sanitization on every inbound /
  invite / verification path (room IDs, sender IDs, event IDs,
  device IDs). Strips bidi controls, zero-width chars, combining
  marks, TAG codepoints, Variation Selectors. Byte-bounded at 255
  bytes for Matrix v11+ event-id length cap.
- `redact_string` (the canonical log/error redaction sink) now
  strips ASCII control bytes and Unicode Cf-class formatting chars
  BEFORE running secret-pattern regexes. Closes ANSI scrollback
  injection via hostile-homeserver error strings AND closes a
  bounded "ZWSP-injected mid-token defeats Bearer regex" evasion.
- HKDF-SHA256 for the matrix-sdk SQLite store key
  (`MATRIX_STORE_INFO`); Argon2id for the inbound DLQ AEAD key
  (envelope v2 — see Migration below). Pinned test vectors guard
  against silent drift in the HKDF chain; v2 derivation
  determinism is pinned via direct round-trip.
- Session integrity docs now call out the existing
  `manifest_integrity_failed` public code (introduced before v0.9.0)
  as the whole-session manifest authenticity error for manifest/HMAC
  root failures.
- Recovery-key file at `{state_dir}/matrix/recovery_key` is written
  `0o600` on Unix. Encrypted Matrix state currently fails closed on
  Windows until owner-only ACL support lands (#440 / WindowsACL);
  use `matrix.encrypted=false` only for unencrypted-room deployments
  on that platform.
- Matrix recovery-key rotation recovery documents all
  `MatrixRecoveryKeyPromotionRefusalReason` wire values, fails closed
  when `pending_key_written` lacks a current key to compare against
  the recorded previous digest, and reports malformed typed markers as
  `matrix_recovery_key_rotation_marker_invalid` / `corrupt_typed_marker`
  without logging marker contents, paths, or key digests.
- `audit_blocking` writes synchronously to the state directory supplied
  by the caller when no in-process daemon writer owns that directory;
  same-state-dir direct writes are refused so rotation has one owner.
- `Zeroize` discipline: `CARAPACE_CONFIG_PASSWORD`,
  `MATRIX_STORE_PASSPHRASE`, `MATRIX_PASSWORD`,
  `MATRIX_ACCESS_TOKEN`, derived AEAD keys, recovery key.
- `ALWAYS_STRIP_FROM_CHILDREN` removes `CARAPACE_CONFIG_PASSWORD`,
  `CARAPACE_GATEWAY_TOKEN`, `CARAPACE_GATEWAY_PASSWORD`,
  `CARAPACE_SERVER_SECRET`, `CARAPACE_HOOKS_TOKEN`,
  `MATRIX_STORE_PASSPHRASE`, `MATRIX_PASSWORD`, and
  `MATRIX_ACCESS_TOKEN` from any subprocess Carapace spawns
  (claude CLI, agent runtimes, filesystem tools). Operator-visible identifier env vars
  (`MATRIX_HOMESERVER_URL`, `MATRIX_USER_ID`, `MATRIX_DEVICE_ID`)
  are NOT stripped — these are non-secret configuration values
  child processes may legitimately read.
- `cara matrix verify <user> <device>` defaults to interactive SAS
  prompt; `--unsafe-skip-sas-prompt` is the explicit opt-out, and
  emits a durable `matrix_sas_unsafe_skip` audit record so the
  bypass is forensic-visible even when an attacker controls the
  launching shell. The prompt is the only MITM-resistance step in
  the SAS protocol.
- Auto-join allowlists are fail-closed: an empty allowlist rejects
  all invites. `allowUsers` and `allowServerNames` are unioned, not
  intersected — see [auto-join semantics](../channels.md#matrix--element).
## Platform Hardening

These improvements were part of v0.9.0 alongside the Matrix
channel — most were forced into view by the Matrix runtime's
durable-audit, signal-safety, and concurrency-cap requirements,
but each is independently load-bearing for any operator running
Carapace.

- **Single-choke-point `agents.defaults.maxConcurrent`
  enforcement.** Five register-call sites (chat.send auto-reply,
  HTTP `/agent` POST, inbound-channel dispatcher, cron-spawned
  runs, `setup_agent_session`) now route through one helper
  (`AgentRunRegistry::try_register_with_cap`) instead of each
  enforcing the cap independently. HTTP returns 503 with the
  active/cap counts in the response body; inbound and chat.send
  emit a once-per-minute throttled warn so a saturating peer
  cannot amplify log volume. Idempotent retries at the cap
  return `DuplicateActive` rather than the misleading `AtCap`.

- **Audit-line byte cap defense-in-depth at every emit seam.**
  Plugin-controlled capability strings are sanitized (control
  bytes + `"` / `\\` → `?`) before JSON encoding so a hostile
  plugin cannot inflate per-entry length via `\uXXXX` expansion.
  `CronJobQuarantined`, `TaskMutated`, `PromptGuardBlocked`,
  `ClassifierWarned`, and `MatrixSasUnsafeSkip` all truncate
  their unbounded String fields at the emit site. The macOS
  512-byte `AUDIT_LINE_MAX_BYTES` is now achievable under
  worst-case hostile inputs.

- **Sandbox `RLIMIT_NPROC` + schema validation.** Tool
  subprocesses now apply `setrlimit(RLIMIT_NPROC, max_processes)`
  via the post-fork `pre_exec` hook (default 32, raise for
  fan-out workloads). The schema validator rejects `0`,
  negative, and non-integer values for the four sandbox numeric
  fields at config load — closing the silent "from_config falls
  back to `Self::default()` and loses the operator's tuned
  values" trap.

- **Async-signal-safe `pre_exec` body.** `apply_resource_limits`,
  `apply_sandbox`, and `apply_landlock` no longer call
  `tracing::*` macros from within the post-fork pre-exec child
  context. The tracing subscriber's locks held by other parent
  threads at fork time would deadlock the child; diagnostics
  for soft-failed rlimits now write directly to `STDERR_FILENO`
  via `libc::write` (POSIX async-signal-safe).

- **Cron impossible-schedule auto-quarantine.** A persisted
  `enabled: true` job whose schedule resolves to `None` (cron
  expression like Feb 31, invalid IANA timezone, etc.) is
  disabled at load with a `cron_job_quarantined` audit emit and
  the disable persists to disk. Eliminates the per-restart
  brute-force iteration cost the prior behavior re-incurred on
  every boot.

- **Session-history file byte cap atomic across single and
  batch append.** `append_message` and `append_messages` both
  now pre-check against `SESSION_HISTORY_FILE_MAX_BYTES`
  (256 MiB). Refusal surfaces as the new permanent
  `SessionStoreError::HistoryFileFull` variant; the matrix DLQ
  replay classifier routes it through `SessionHistoryCorrupt`
  so a brick-the-session attempt becomes a one-shot
  quarantine rather than an infinite retry storm.

- **`AuditLog` writer-done signaling survives panic/cancel.**
  A `WriterDoneGuard` RAII helper trips the `writer_done` flag
  and notifies waiters on every exit path (return, panic,
  tokio cancellation). Previous code only ran the trip on
  normal return; a writer panic would leave the flag false
  forever, blocking `shutdown_and_drain` on the caller's
  deadline.

- **Server-task abort escalation forensic trail.** If the
  server task does not honor `abort()` within the 2s deadline
  the daemon escalates to `SIGKILL self` (Unix) or
  `process::exit(137)` (non-Unix) so the pid-guard /
  matrix-rekey lock / FDs release atomically with process
  death. A `ServerTaskAbortFailed` audit record is emitted
  best-effort on a dedicated thread with a 500ms join deadline
  before the signal fires; a wedged disk-writer cannot postpone
  the kill.

- **Async-context discipline in shutdown path.** The audit
  thread-join wait loop uses `tokio::time::sleep` instead of
  `std::thread::sleep` so it yields the runtime worker during
  the 500ms polling window.

- **`u64` overflow safety in history append.** The byte-cap
  check now compares `projected > MAX.saturating_sub(HEADROOM)`
  rather than `projected + HEADROOM > MAX`, so a hypothetical
  `projected == u64::MAX` from saturating_add cannot wrap and
  fail open.

- **Operator-config error surfaces.** Sandbox config that fails
  to deserialize now emits a `warn!` at the caller (matching
  `promptGuard`/`outputSanitizer`/`classifier` siblings) rather
  than silently falling back to `Self::default()` and discarding
  the operator's `allowed_paths` / `env_filter` / `network_access`.

- **Cron tick panic + drain-abort signaling.** The cron
  `JoinSet` no longer swallows `JoinError`; panicked payload
  tasks surface via `tracing::error!`, and shutdown-drain expiry
  records the aborted-task count so the operator can correlate
  missing `mark_run_finished` rows.

## Rollback Steps

1. Stop Cara.
2. Reinstall the previous known-good binary (`v0.8.0` pinned tag).
3. Restore the backup created before upgrade:
   - `cara restore ./carapace-backup-v0.8.x.tar.gz`
4. Revert any v0.9.0-only config changes (`matrix.*` keys,
   sandbox `max_processes`) before starting `v0.8.0`. The older
   binary ignores unknown `matrix.*` keys but cannot read Matrix
   state-dir contents.
5. Decide whether to retain or remove the v0.9.0 Matrix state
   subtree (`{state_dir}/matrix/`). v0.8.0 ignores these files;
   leaving them in place lets you roll forward again without
   re-registering devices.
6. v0.9.0 wrote audit.jsonl entries with new event-name tags
   (`server_task_abort_failed`, `cron_job_quarantined`,
   `matrix_*`). v0.8.0's audit-line parser preserves these as
   opaque entries via `AuditEntry`'s string-tagged shape (the
   on-disk format is unchanged); v0.8.0 has no per-event pretty-
   printer so the entries are operator-visible only via raw
   audit.jsonl tail.
7. Verify:
   - `cara status --port 18789`
   - `cara verify --outcome auto --port 18789`
   - `cara verify --outcome autonomy --port 18789`

## Verification

- `cara verify --outcome matrix` performs the full Matrix wiring
  health check: config validity, encrypted-store passphrase
  resolution, gateway reachability, runtime registration, channel-
  registry connection state, optional send-test against
  `--matrix-to <room_id>`. Per-variant operator hints fire on
  typed `lastErrorKind` discriminators; see
  [`docs/channels.md` → Matrix runtime startup failure modes](../channels.md#matrix-runtime-startup-failure-modes).
- `cara matrix verify <user> <device>` performs interactive SAS
  device verification with a peer. (Distinct from the gateway-
  health check above.)
- `cara matrix devices` lists known peer devices and trust state.
- Channel smoke validation matrix in
  [`docs/channel-smoke.md`](../channel-smoke.md) covers Matrix
  alongside the other native channels.

## Known Caveats

- Matrix sync uses operator-supplied homeserver and credentials.
  Carapace cannot enforce SAS verification on the peer side; an
  unverified peer's messages still dispatch through the inbound
  pipeline. Verification is for operator-side MITM defense.
- Matrix recovery-key rotation is interruptible. If the daemon
  crashes mid-rotation the recovery-key rotation marker plus the
  cleanup journal preserve enough state for
  `recover_interrupted_recovery_key_rotation` to resume on next
  start; operator action is not required. (The sibling
  `recover_interrupted_matrix_store_rekey` resumes a separate
  flow — store passphrase rekey — that is also crash-safe.)
- E2EE encrypted-room E2E integration testing relies on the
  `scripts/smoke/matrix-smoke.sh` operator-side script. There is
  no CI mock-homeserver lane yet; future releases plan to add a
  containerized Synapse integration test.
- `max_processes` default of 32 may be too restrictive for shell-
  pipeline workloads on shared-UID hosts (the limit is enforced
  per real UID, not per child). Operators seeing opaque tool
  failures should raise `agents.defaults.sandbox.max_processes`.
