# Changelog All notable changes to epicurus are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `v0.1.0` is the first release — the first version usable on a server with a UI. A release is cut by pushing a semver tag (`git tag v0.1.0 && git push origin v0.1.0`); GitHub Actions then publishes the GitHub Release and versioned container images to GHCR. ## [Unreleased] - **Web search through OpenRouter, once an OpenRouter key is stored** (#984) — web search ran only through the bundled SearXNG, whose engines are blocked or rate-limited on many home-lab IPs (#920, #936). The websearch module's settings on the Modules page now have a **Search provider** choice: SearXNG (the default, unchanged) or **OpenRouter web search**. The OpenRouter option is greyed out, with a hint to add the key on the Models page, until the tenant's OpenRouter key is stored; the core refuses the same save without one (409 `provider_key_required`), and the form shows the core's sentence rather than "[object Object]". With OpenRouter chosen, `web_search` asks the core — new `POST /platform/v1/web-search`, `PlatformClient.web_search` — which runs one search through OpenRouter's `openrouter:web_search` server tool (the documented replacement for the deprecated `web` plugin and `:online` suffix) with the tenant's key, turns the `url_citation` annotations into `{title, url, snippet, engine}` results, and meters it under the caller's tenant (`llm.usage` gains `web_search_requests`). The key never reaches the module. Results get the same chips, hover-cards (engine "OpenRouter"), dedupe and three outcomes as SearXNG's; a missing key is a plain "no search ran, add a key or switch back" message, never an empty result, and there is no silent fallback to SearXNG; an OpenRouter failure (bad key, no credits, unreachable) reaches the model through the tool-error seam with OpenRouter's own reason. Two new core settings, on Compose and the chart alike: `OPENROUTER_WEB_SEARCH_MODEL` (`openai/gpt-4.1-nano`) and `OPENROUTER_WEB_SEARCH_ENGINE` (`exa`), chart `core.openrouterWebSearch.{model,engine}`. `/status` gains `backend` and `openrouter_last_result`. This also fixes a general defect: a module's Modules-page settings were stored by the core and **never delivered** to the module. `epicurus-core` gains `PlatformClient.get_module_config` and `ModuleConfigCache` (15-second cache, last-good on failure), and websearch now honours its stored max-results and engines at runtime; the other modules with a settings form (echo, knowledge, storage) are a follow-up. `web_search`'s `num_results` is now optional (`null` = the configured max). `core-app` 0.130.0→0.131.0 (MINOR) · `epicurus-core` 0.41.0→0.42.0 (MINOR) · `websearch` 0.4.0→0.5.0 (MINOR) · `web` 0.152.0→0.153.0 (MINOR) · chart 0.3.0→0.4.0 (MINOR). - **Sign-in on Compose and Kubernetes, and how to set it up** (#969) — the core's new OpenID Connect sign-in only helps if every way of running epicurus can turn it on, and if an operator can find out how. On **Compose** the core-app fragment now passes `AUTH_MODE`, `AUTH_SESSION_DAYS` and every `OIDC_*` key through from `.env`, with the core's own defaults — and the switches and the day count arrive as `false` / `30`, never blank, because a blank boolean is a start-up error, not "unset"; `.env.example` gains a commented Pocket ID block. On **Kubernetes** the chart gains an `auth:` block. A release that says nothing about it renders exactly one env entry more than before — `AUTH_MODE=none` — and a test proves it against the chart with the sign-in wiring cut out, on the default, published and hosted-only shapes. `oidc` renders every key, the client id from a value or a Secret and the client secret only from one (`auth.oidc.existingSecret`, else the shared Secret; optional, since a public client has none), and the render **refuses** what the core would refuse to start with: no issuer, an issuer or public URL that is not an absolute http(s) URL, no client-id source, or no admission rule — an empty allowlist, a list of blanks and `--set-string …=false` included, because against Google an empty allowlist would admit the internet. The Kubernetes smoke gate turns sign-in on through these values (`helm upgrade --reuse-values`), so the chart's own path is what boots. A sign-in variable in `core.extraEnv` fails the render too, rather than emit a duplicate env entry that server-side apply (Flux) rejects. `NOTES.txt` prints the callback to register, flags a plain-http one, and warns when a release publishes an Ingress with sign-in off. The new [sign-in guide](docs/infrastructure/sign-in.md) walks through Pocket ID on both platforms (the Secret created without touching shell history, SOPS for GitOps), the admission rules, Authentik / Keycloak / Authelia / Kanidm / Google, the phone PWA, sign-out and a troubleshooting table keyed by every `auth_error` code — and says plainly what sign-in does not cover: the published internal ports and the Compose gateway's `.localhost` routes. So the remote-access recipes now point `tailscale serve` and Caddy at the web shell (`8084` / `web:8080`) instead of the gateway, and present built-in sign-in as the option to use beside a network perimeter. chart 0.2.1→0.3.0 (MINOR). - **A sign-in screen for OpenID Connect sign-in — and the OAuth callback reaches the core again** (#969) — with the core's sign-in on, the web shell now asks `GET /platform/v1/auth/session` before it mounts anything, so a signed-out browser sees a sign-in screen instead of a page of refused requests: the ε mark and one button, "Sign in with {provider}" (or "Sign in"), a centred card on a desktop and a full-height screen with a tall, thumb-reach button and safe-area insets on a phone. The button sends the window to the core's login route with a `next` that brings it back to exactly where it was — the screen a session ran out on, a share waiting at `/?share=1`, a notification's deep link. A failed sign-in comes back as `auth_error`, and every code reads as a sentence that says what to do ("your account isn't allowed to use this epicurus — ask whoever runs it to add you"; "the operator needs the `groups` scope"); the raw value is never shown, and the parameter is removed from the address bar. The operator's auto-redirect sends a signed-out visitor straight to the provider, with a 30-second loop guard that shows the button instead of bouncing a browser that came back still signed out. Any 401 from the platform — every fetch, both stream readers, the archive upload — returns to the sign-in screen, is never retried, and never lights the "can't reach epicurus" banner. Settings gains an **Account** card with who is signed in and **Sign out**, which holds auto-redirect off in that tab so signing out sticks. With sign-in off — the default — none of it shows, and a core that is down or predates sign-in opens the app exactly as before. The change also fixes a real bug: the service worker answered **every** top-level navigation with the cached app shell, `/platform/` included, so on any device that had installed the PWA the **connected-account OAuth callback** (`/platform/v1/oauth/callback`, Google's redirect back) never reached the core and connecting Google silently failed; navigations to `/platform/` now go to the network, which the sign-in routes need too. The dev and preview proxies add `X-Forwarded-*` (`xfwd`), so `npm run dev` sees the core's enforcement as production does. `web` 0.151.0 → 0.152.0 (MINOR). - **Sign in with an OpenID Connect provider** (#969) — epicurus had no sign-in of its own: whoever reached the web shell reached `/platform/v1/*`, which is everything — chats, memory, mail, calendar, files, provider keys — and the documented ways to close that (basic auth in Caddy, oauth2-proxy) all sat outside the app, so it never knew who was using it and a phone PWA met a browser password prompt. The core is now an **OpenID Connect relying party** (`AUTH_MODE=oidc`): authorization code flow with PKCE, a `state` bound to an HttpOnly transaction cookie, a `nonce` bound to an ID token verified against the provider's JWKS with the algorithm pinned to asymmetric ones the provider advertises, userinfo merged in, and an admission rule — allowlisted emails (never an unverified one), allowlisted groups, or an explicit `OIDC_ALLOW_ALL_USERS=true`, because an empty allowlist pointed at a public provider would admit the internet. Four endpoints under `/platform/v1/auth` (`session`, `login`, `callback`, `logout`); every failed sign-in lands back on the shell as `/?auth_error=` from a closed set of ten, never on a JSON page. Sessions are server-side — an opaque 256-bit cookie, only its SHA-256 stored, sliding at most hourly — in two new tables (revision `0007`) that a tenant archive deliberately leaves behind. The enforcement is a **trust boundary**, not a login wall: a request that came through a proxy (the web shell's nginx, an ingress, the Compose gateway) needs a session or gets a 401, and an unsafe one must also pass a cross-site check (403) because `SameSite=Lax` does not stop a sibling subdomain; a module calling the platform API directly on the internal network is untouched, so no module changed. The core **refuses to start** half-configured, naming everything missing in one line, yet never contacts the provider at boot — discovery is lazy, so a provider that is down cannot take the platform with it. Both smoke gates now end with sign-in on and prove the web door answers 401 while module ↔ core traffic still flows, on Compose and on Kubernetes. `AUTH_MODE=none` — the default — is the platform exactly as it was. `core-app` 0.129.0 → 0.130.0 (MINOR). - **MinIO images now pull from a registry that still serves them anonymously** (#973) — Quay followed Docker Hub (#934) and started refusing anonymous pulls of `quay.io/minio/minio` and `quay.io/minio/mc` — a `401` on the whole repository, not one tag — so every fresh `compose up`, the `quality` gate's testcontainers suites, `runtime-smoke`, and `k8s-smoke` all failed the same way. `docker.io/pgsty/minio` and `docker.io/pgsty/mc` are a **community-maintained fork** of the AGPL MinIO server and client (published by Pigsty — not MinIO Inc. and not a Docker Hub verified publisher) that still serves anonymously with pinned, multi-arch (amd64/arm64) release tags; the Compose fragment, the chart's `minio.image` / `minio.initImage` defaults, the testcontainers pins, and the docs now point there (`RELEASE.2026-08-04T00-00-00Z` / `RELEASE.2026-09-16T00-00-00Z`). Entrypoints, `Cmd`, and environment surface are unchanged from upstream, so nothing else moves. An operator who overrode MinIO to `quay.io` in their own values must switch too — that source is now anonymous-401 for everyone. Chart 0.2.0→0.2.1 (PATCH); no component bump. - **A hosted-only deployment runs no local runtime** (#962) — running with hosted chat and hosted embeddings and *no* Ollama was a documented capability that nothing actually supported. The Helm chart refused to render it (`ollama.enabled: false` demanded an external URL), Compose could not express it (an unconditional fragment and a hard `depends_on`), and an operator who forced it with a placeholder URL met a core that collapsed three different facts into one: **absent** (no runtime configured — a deliberate choice), **unreachable** (one is configured and does not answer) and **ok**. `GET /platform/v1/llm/models` **500ed**, on a page that polls it every ten seconds; pull and delete 500ed the same way; the chat warm-up indicator reported the model "warming" forever, on every turn, for the life of the deployment; and startup spent 180 seconds polling an address that was never going to answer. Absence is now a first-class mode, spelled `OLLAMA_URL=""`: the model list answers **200 and an empty array** in both non-serving states, the new `GET /platform/v1/llm/local-runtime` says which one applies, the local-only actions (pull, delete, unload, the KV-cache setting) refuse with **409** and a sentence naming the mode — and pull and delete answer **502** when a configured runtime is unreachable, never a bare 500 — readiness reports the model as **n/a** instead of warming, and the bootstrap logs one line and returns. A local model id asked to serve is refused before any provider call with the capability error the rest of the gateway already speaks (ADR-0140), so a hosted embedding model keeps working while a bare one fails with the fix in the message instead of a connection error. On **Kubernetes** the chart renders an empty `OLLAMA_URL`, blanks `LLM_BOOTSTRAP_MODELS` on its own, and gains the guard actually worth having — it refuses to render a runtime-less release whose chat or embedding default is still a bare local name, naming the hosted alias to set. On **Compose** it is an opt-out overlay — `task hosted-only-up` — which removes the Ollama services *and* blanks `OLLAMA_URL` in one step, the same idiom as the Docker-socket and external-mount opt-ins; the documented install (`git clone`, `docker compose up -d`, no `.env`) is byte-identical and still starts the local runtime, which a compose profile on those services would have quietly stopped doing. Three gates cover it: `compose-validate` proves both the default stack and the overlay resolve, `chart-validate` renders the hosted-only release *and* proves the guard refuses the half-working one, and `k8s-smoke` upgrades a live release into the mode and asserts the core answers. `core-app` 0.128.0→0.129.0 (MINOR), chart 0.1.2→0.2.0 (MINOR). - **A deployment with no local AI says so once, instead of warning forever** (#962) — the web shell had two inline strings for a dead local runtime ("The local runtime is unreachable — is the ollama service up?", "local runtime unreachable") and rendered every other Ollama control — the pull card, the catalog, the KV-cache card, the context-window card, the `Local (Ollama)` optgroup — as though a runtime existed. On a deployment that deliberately runs **none** (hosted chat, hosted embeddings, `OLLAMA_URL=""`) that reads as a fault the operator is supposed to fix, when in fact nothing is wrong and there is nothing to fix. The shell now reads the runtime's state from `GET /platform/v1/llm/local-runtime` — never from a failing model list, which since this change answers `[]` with a 200 in *both* unhappy states and so carries no information at all — and when that state is `absent` the local half of the Models page collapses into one line: local AI is not configured on this deployment, hosted models are unaffected. The five Ollama cards are removed rather than disabled, the embedding picker drops its local group so an unrunnable model cannot be chosen, the chat picker drops its `Local` heading while keeping the core-default row (that default may itself be hosted), the first-run welcome stops offering a pull, and a module's model slot with nothing left to offer says why instead of looking broken. `unreachable` keeps today's warning, because that state *is* an error and must still look like one, and an older core with no such endpoint keeps today's behaviour exactly. `web` 0.150.0→0.151.0 (MINOR). - **The bound on a turn is the operator's; runaway is caught by behaviour** (#925) — the **Agent cycles** setting stopped at 12, and the route enforced it *silently*: type 40 and 12 was stored. A genuinely long task — search → read → read → summarize → write — ran out of rounds and handed back a truncated answer with nothing saying why, and there was no way to give it more. The ceiling is gone: the pref and `AGENT_MAX_STEPS` are floored at 1 and otherwise taken as written, and the Settings copy names the real trade-off (time and tokens) instead of a range. What actually keeps a confused model from looping forever is the loop's behaviour, so the existing guards became rules you can state: **three identical tool calls in a row** end the turn, counted per run of repeats — every fresh repetition now gets the same allowance, rather than the whole turn sharing one nudge — the three-consecutive-tool-errors stop is unchanged, and a new **per-turn wall-clock budget** (`AGENT_TURN_DEADLINE_S`) is the last backstop, **off by default**, because a deadline can kill exactly the long turn this change exists to allow. When it does fire the turn still answers, and the answer *says* it stopped early: the final round is told the time ran out, so the caveat reaches the web, the chat bridges and the automation log alike rather than depending on each surface to render a stop reason. Because a turn may now honestly run for minutes, it reports where it is — every `tool` stream frame carries `round`/`max_rounds`, and the finished turn carries `rounds`/`max_rounds` — so the activity indicator reads "Working… · round 7 of 40" instead of spinning with nothing to show. A SaaS tier that wants a ceiling imposes it from the overlay, where tier policy belongs. `core-app` 0.127.0→0.128.0 (MINOR), `web` 0.149.0→0.150.0 (MINOR). - **A failed reply always says so, and never in the provider's words** (#944, #947) — a streaming turn that died *after* it had started answering left the transcript with a sentence that simply stopped: no banner, no note, just Copy and Regenerate under half a reply. The terminal error event now fires on that path too, the message itself is persisted as interrupted so the mark survives a reload (or a re-attach that lands on history rather than on the failing stream), and a re-attach that gives up says the connection was lost and the reply may be incomplete instead of going quiet. The other half is what the banner *says*: a hosted model's refusal used to be rendered verbatim, which put the provider's raw JSON — account identifier included — in the browser. Every failure is now described in a sentence written here, naming the model and the provider it was routed to and quoting the provider's own message only when that message survives a redaction test; the raw exception stays in the log, at ERROR, once. A new `epicurus_core_llm_stream_failures_total{tenant,reason}` counter gives the observability stack something to alert on. `core-app` 0.126.0→0.127.0 (MINOR), `web` 0.148.0→0.149.0 (MINOR). - **A dimension change is healed or named, never silently degraded** (#944 part 2, #879, #860) — switch the embedding model to one with a different output size and every vector already stored becomes unqueryable. Cross-chat memory was supposed to repair itself when that happened; on the owner's install it did not, and said nothing: the reconcile met a vector configuration it did not recognise, resolved no width, repaired nothing — and still cached the collection as repaired, so every later recall repeated the same rejected query while the assistant quietly answered with no memory at all. The cache now records a width that was actually **confirmed**, a configuration it cannot read is raised instead of shrugged off, and the rebuild is shielded from the recall time-box, so a corpus too large to re-embed inside that budget finishes healing instead of being restarted and cancelled on every turn. What cannot be healed is now *said*: recall logs the two widths and the cure, `knowledge` search answers "the embedding model changed — run Re-embed everything" instead of forwarding Qdrant's raw dimension error, and the Models page shows the stuck state on the embedding card and names the cure — which, for recall memory, is the **Memory facts re-embed** maintenance job rather than "Re-embed everything" (that one fans out to the modules only). The same card stops calling a **refused** re-embed a failure: a module that declines to rebuild from a source reading empty is protecting your data, and now says so, with its reason, in its own third state. `core-app` 0.125.0→0.126.0 (MINOR) · `web` 0.147.0→0.148.0 (MINOR) · `knowledge` 0.31.0→0.31.2 (PATCH) · `notes` 0.15.0→0.15.2 (PATCH) — the wave's ladder reserved a patch each for a lane that turned out not to touch them. - **A model's capabilities are known, not assumed — and a turn that cannot work says so before it starts** (#944, #947, #879; ADR-0140). Two dogfood failures with one cause: the core knew nothing about a model beyond "is it hosted". An embedding model starred as the chat default took every turn straight to an opaque provider 400, and a hosted model served without tool calling ended a "hi" in a red card holding the provider's raw JSON — account identifier and all. A saved hosted model now carries a **role** (chat / embedding / unknown) and a **tool capability**, each resolved as *operator override → what the gateway learned from the provider → the shipped catalogue*, and the Models page's Capabilities block gained the two controls beside Image input. A model whose role is known and wrong is refused **before any provider call**, by one gate every entry point shares — chat, stream and embed alike — with a sentence naming the single action that fixes it, and the same rule guards the write, so the bad default cannot be set from the star in the first place. Tool support is **learned rather than guessed**: whether a model accepts a tool list is a property of the deployment serving it, so on a provider's refusal the core records it for that tenant, says why at WARNING without quoting the payload, and **retries the same turn once without tools** — the turn answers, and the shell says the model can only chat, for a hosted model as well as a local one. The tool-less turn is also told it has no tools, so it stops narrating actions it never took. Two long-standing Models-page nits go with it: a provider-only `claude/` no longer reads as a hosted id in the browser when the core says otherwise, and an id the shipped catalogue has never heard of now says **unlisted** instead of showing an empty chip. `core-app` 0.124.3→0.125.0 (MINOR), `web` 0.146.0→0.147.0 (MINOR). - **`auto` model bootstrap now seeds an empty runtime only, instead of every restart** (#923, ADR-0118 amendment) — "first-boot" was a docstring, not a guard: the bootstrap diffed the effective chat + embedding defaults against the local runtime on *every* start, so a model deleted on the Models page was silently pulled back on the next restart — routine on both Compose and Kubernetes (a rollout-restart, ADR-0134). `auto` now no-ops the instant `/api/tags` reports any installed model at all — logged at INFO with the count — and never resolves the defaults again; a from-scratch install still gets its defaults exactly as ADR-0118 intends. An explicit `LLM_BOOTSTRAP_MODELS` list is unchanged: a stated pin, ensured on every start regardless of what else is installed. `core-app` 0.124.2→0.124.3 (PATCH). - **The portability seam's last two gaps: a bodyless crash, and a remove/apply race** (#918) — an unhandled exception inside a module's `/import` route used to escape as Starlette's default 500 (`text/plain`, no JSON), so a module's own crash reached the operator's report line as nothing at all and the only recourse was `docker compose logs`; `add_portability_routes` now catches it and answers with a `detail` naming it, the same courtesy a deliberate refusal already got. `#903`'s null-normalisation helper (`TableSpec.encode`/`.normalize`) existed as two identical copies in `calendar` and the core's own `core_data`; it is promoted once into `epicurus_core.PortableTable`, and both adopt it — the other five portable modules (`tasks`, `notes`, `knowledge`, `storage`, `mail`) go through a domain store with explicit per-field defaults and never had the defect to begin with, so there was nothing there to adopt. And a `DELETE .../imports/{id}` racing a `POST .../apply` for the same job could delete the row (and the staging directory the applier was about to open) between the apply's read and its write; `PortabilityService` now holds one lock per job id so the two requests serialize. `epicurus-core` 0.40.0→0.41.0 (MINOR), `core-app` 0.124.1→0.124.2 (PATCH), `calendar` 0.22.0→0.22.1 (PATCH). - **A degraded web search no longer looks like an empty one** (#936, #920) — SearXNG can answer `200 {"results": [], "unresponsive_engines": [...]}` when every engine it asked timed out, was blocked, or got rate-limited, and `web_search` used to discard that field, making a genuinely empty query indistinguishable from a search instance quietly failing. The tool now tells the three outcomes apart — results, genuinely empty, degraded — logs a WARNING naming the unresponsive engines, and `GET /status` gains a `degraded` flag reflecting the most recent search rather than a separate probe. `web_search` also stopped swallowing every exception into a clean `[]`: a SearXNG failure now reaches the model as an actionable message through the ADR-0136 tool-error seam, which is why `httpx.HTTPStatusError`/`TransportError` joined `epicurus-core`'s anticipated-exception set in the same change — a provider HTTP error is expected traffic, not a crash, so it logs at WARNING instead of ERROR-with-traceback (fixing the same misclassification for mail's Gmail client along the way). The default agent prompt now says plainly when search is unavailable instead of narrating "no results" and quietly falling back to training data. `epicurus-core` 0.39.0→0.40.0 (MINOR), `websearch` 0.3.1→0.4.0 (MINOR), `core-app` 0.124.0→0.124.1 (PATCH). - **The backup archives your files, and the gates stop grading themselves on a curve** (#919, #895, #864) — three quiet failures of the same kind: something reported success while doing less than it said. `infra/backups/backup.sh` looped over two volumes that had not existed since the file space moved to `epicurus-files`, and it never archived `epicurus-files` itself — the core's `/data`, and the bytes behind every knowledge document and note. A backup taken before this holds no user files at all, and it exited 0. The loop is now correct, the file space is archived whether it lives in the named volume or in the host directory `EPICURUS_FILES_ROOT` points at, a restore puts it back wherever *this* machine keeps it, the `valkey` cache is no longer archived (nothing in the codebase reads it), and a repo test holds both scripts to the volumes the compose files really declare — so the next stale name fails CI instead of silently shrinking a backup. **Take a fresh backup.** On the CI side the two smoke gates now assert the same things on both runtimes: the Compose gate was never starting the web shell (so nginx's runtime-derived resolver was gated on Kubernetes only), neither gate checked that the MinIO bucket seed succeeded, the Kubernetes gate had no Ollama workload so the restart arm of the container-runtime seam — and the chart Role's `statefulsets` verb — never ran, nothing proved a file write reaches the cluster's RWO volume, and every cluster boot was a *fresh* install, the one shape that cannot show an upgrade defect. All five are closed, the gate-parity test no longer passes on needles that match a comment, Helm's download is checksummed like every other pinned tool, and the JetStream integration suite is isolated by per-test stream and subject names instead of by wiping a shared stream — which is what it had actually been failing on in three CI runs. Version bumps: none — no shipped component changed. - **The core's schema is migration-managed, and #903's `NULL` is fixed at the source** (#834, #927) — core-app owns 40 tables, by far the biggest schema here, and built them at every startup with 29 separate `create_all` + additive-reconcile calls, each wrapped in its own "log it and carry on". One `run_migrations` call replaces all of them, and is deliberately not wrapped: a database the core cannot reach now fails the boot, with a restart and a clear log line, instead of bringing the core up with 29 error lines and no working feature. Two **backfill revisions** finish what the old reconcile could not start: `module_prefs.suggestions_enabled` — the column behind #903, where a preferences *write* on a long-lived install aborted on a `NULL` the reconcile had no way to avoid leaving — and `automations.agent_gated_delivery`, in exactly the same position. Existing rows get the value every reader was already pretending they had, and both columns become `NOT NULL` for real. A third revision repairs five column defaults that have been wrong on disk since they shipped (`module_prefs.models` / `.disabled_tools` / `.collections`, `llm_prefs.hidden_models`, `saved_models.added_at`): declared as plain strings, they compiled to a default whose value included the quote characters when the table was created fresh, and to the intended value when the reconcile added the column — one release, two different databases. Nothing user-visible changes, and nothing about a schema change here is guesswork any more: a model edited without a revision fails CI. `core-app` 0.123.0→0.124.0 (MINOR). - **`mail` adopts the migration foundation** (#834, #932) — the five `mail_*` local-cache tables (ADR-0096) are now schema-migration-managed like `storage`: a baseline revision, applied in-process at startup under a Postgres advisory lock, replaces the old `create_all` + `ensure_columns` reconcile. Mail turned out to need neither of the two per-service defects the foundation warned about: it carries no plain-string `server_default` (only `func.now()`), and every `default=`-without-`server_default=` NOT NULL column has existed since its table's first release rather than being added later to a populated table, so the #903 backfill rule finds nothing to write here either — both are recorded in the PR body's audit. `mail` 0.21.0→0.22.0 (MINOR). - **`knowledge` schema is migration-managed** (#834, #931) — the fourth service to adopt the migration foundation. Every model change now ships as an Alembic revision: startup runs `run_migrations` once, before any store touches a row, and no store reconciles its own schema on Postgres any more. The gate caught the same defect it found in `storage`: a *plain string* `server_default` is a literal SQLAlchemy quotes for you, so `knowledge_suggestions.to_path`'s `server_default="''"` compiled to `DEFAULT ''''''` (a default whose value carries two quote characters) while the additive reconcile, pasting the same string in as raw SQL, produced the real empty string — the model now says `text("''")`, and a revision normalises an existing database. A further backfill-audit pass adds a matching database-level default to eight more columns that carried a Python-side one only, closing the gap on principle even though none of them was ever at risk of a `NULL` row. `knowledge` 0.30.0→0.31.0 (MINOR). - **`notes` adopts the migration foundation** (#834, #930) — the note bodies, folders, version history and the suggestion queue + its audit trail are now Alembic-managed, one baseline revision applied at startup in place of the four `create_all` calls the lifespan used to make. Unlike several other services, notes carried no plain-string `server_default` bug, so no normalisation revision was needed; a backfill audit of its seven `default=`-without- `server_default=` `NOT NULL` columns found none at risk — every one was introduced alongside its table, never added to a populated one. `notes` 0.14.0→0.15.0 (MINOR). - **`tasks`'s schema is migration-managed** (#834, #929) — the module adopts the Alembic foundation (#926): a startup `run_migrations` call replaces `TaskStore`/`LeadTimePrefsStore`/ `FiredMarkerStore`'s `create_all` + additive-reconcile, under a Postgres advisory lock, across the four tables its three `DeclarativeBase` objects own (`tasks_local`, `task_repeats`, `tasks_lead_time_prefs`, `tasks_fired_markers`). The baseline revision absorbs the reconcile that used to repair a pre-#218 `tasks_local` missing `status`/`priority`/`tags`/`repeat` on an existing deployment. The backfill audit (#903's rule) found one `NOT NULL` `default=`-without-`server_default=` column, `tasks_local.completed` — no revision needed, since it has been part of the table since v1 and both its writers always set it explicitly, so no deployment can have carried a `NULL` there. `tasks` 0.24.0→0.25.0 (MINOR). - **`calendar`'s schema is migration-managed now** (#834, #928) — the second service to adopt the Alembic foundation (#926), after `storage`. A baseline revision covers all four `DeclarativeBase`s (six tables: the local event store, the lead-time preference, the lead-time scheduler's fire-once markers, and the reconcile layer's sync cursor / observed-event cache / self-write ledger); the startup reconcile calls (`epicurus_core.db.ensure_columns`, `_ADDED_COLUMNS`) are gone from every store, replaced by one `run_migrations` call in the lifespan. The backfill audit found **seven** `NOT NULL` columns with a Python-side default and no server default — `calendar_events.{all_day, excluded}` (which the old reconcile could only add *nullable* to a populated table, so an upgraded deployment could genuinely hold `NULL` there, #903's shape of bug) and `calendar_sync_state.collection` / `calendar_synced_event.{collection,title,all_day, change_hash}` (always `NOT NULL` in practice, since those tables have no reconcile history) — a revision backfills any real `NULL` and adds the server default to all seven. `calendar` 0.21.1→0.22.0 (MINOR). - **Schema changes are real migrations now** (#834, #926) — schema was additive-only by design: the startup reconcile could add a column and nothing else, so a rename, a retype or a backfill was un-shippable, a `NOT NULL` column added without a server default reached existing rows as `NULL` (the cause of #903), and no gate anywhere ever exercised an *upgrade* — the unit tests build tables from the models and the smoke gates boot an empty database, so both production failures that motivated this (#214, #218) were visible only on a deployment that had been running a while. epicurus now uses **Alembic, one migration environment per service**, applied in-process at startup under a Postgres advisory lock so two replicas or an overlapping restart never migrate at once — the same on Compose and on Kubernetes. Each service keeps its own version table (`alembic_version_`), because all seven share one database and own disjoint tables in it. A service's *baseline* revision is idempotent, so one `upgrade head` serves an empty database, one built by the old reconcile, and one already at head — with no branch and, unlike a stamp, no revision ever silently skipped. `storage` is migrated as the reference; authoring is `task migrate:new` and `task migrate:check` (a second, on SQLite), and a new **`migrations`** CI gate proves every migrated service on real Postgres: fresh install, restart, adoption of a pre-Alembic database, and adoption of a *drifted* one. A model change with no revision now fails review instead of production. The gate earned its keep on its very first run: a *plain string* `server_default` is a literal SQLAlchemy quotes for you, so the house pattern `server_default="'fs'"` has been compiling to `DEFAULT '''fs'''` — a default whose value carries the quote characters — while the additive reconcile, pasting the same string in as raw SQL, produced `DEFAULT 'fs'`: two deployments of the same release could disagree about their own column defaults. Storage's is corrected and an existing database normalised by a revision — the first change here the reconcile could never have made. The remaining services carry the same pattern and each lane fixes its own. `epicurus-core` 0.38.0→0.39.0 (MINOR), `storage` 0.11.0→0.12.0 (MINOR). - **Add a hosted model straight from the Models page** (#922) — the Hosted models card's "None yet" copy used to send the operator to a chat's model picker just to save an id; now an **Add a hosted model** row sits on the card itself, with a provider select drawn from what the core actually reports (never the client's static alias list, so an unregistered provider can't be picked), a model-id field that prepends the alias for you (OpenRouter's two-slash ids stay intact), and an inline hint when the chosen provider's key is `missing`/`unavailable` — the first place the core's `key_state` (#728) is rendered. `web` 0.145.0→0.146.0 (MINOR). - **MinIO images now pull from Quay** — Docker Hub no longer serves the `minio/minio` and `minio/mc` repositories (a `404` on the repository itself, not just the tag), so every fresh `compose up`, the `runtime-smoke` and `k8s-smoke` gates, and a chart install failed with `pull access denied for minio/minio`. The same pinned releases exist on Quay under the same names, so the Compose fragment, the chart's `minio.image` / `minio.initImage` defaults, and the docs now point at `quay.io/minio/minio` / `quay.io/minio/mc` — identical digests, nothing else moves. An existing deployment that already holds the images keeps running; it picks the new ref up on its next pull. The two testcontainers suites that boot MinIO (`epicurus-core` `test_s3_round_trip`, storage's object-store tests) pin the same Quay image instead of the library's Docker Hub default, so the `quality` gate pulls from the same place. Chart 0.1.1→0.1.2 (PATCH); no component bump. - **The Helm chart has now actually booted** (#894) — the chart shipped rendered and schema-checked and never once started, which left a whole class of failure (a bad probe, an unwritable mount, an RBAC grant one verb short) uncaught until an operator hit it, and left the Kubernetes half of the container-runtime seam proven against nothing but a mock transport. A new `k8s-smoke` CI job creates a kind cluster, builds the service images from the checkout and loads them into it, `helm install`s the chart, and asserts the integration last mile from a curl pod inside the namespace. The assertions themselves moved into `infra/ci/smoke-assert.sh`, shared verbatim with the Compose gate, so both runtimes are held to one list that cannot drift — and on top of it the Kubernetes gate proves the OpenBao bootstrap Job and its unseal loop work, that the web shell resolves the core through the pod's own DNS, and that a confirmed module removal really does scale that module's Deployment to zero through the chart's namespace-scoped Role. Deliberately not a required check until it has been green for two weeks. **The first boot immediately earned its keep**, four times over. The chart's OpenBao bootstrap read the unseal key out of `/v1/sys/init` under the *CLI's* field name (`unseal_keys_b64`) rather than the HTTP API's (`keys_base64`), so it initialised a vault and discarded the only key that could ever open it — unrecoverable, and invisible to every existing test because the stub they run against mirrored the same mistake. And SearXNG crash-looped forever: Kubernetes hands every pod a `SEARXNG_PORT=tcp://10.96.x.x:8080` service-link variable, SearXNG's entrypoint feeds that straight into `GRANIAN_PORT`, and the server dies on a URL where it wanted a port number — so the chart now turns that Docker-links injection off on every pod, since nothing in the stack reads those names and they collide with the ones it does. And the web UI was broken outright on Kubernetes: nginx resolves the core's name per request and never applies `/etc/resolv.conf`'s `search` list, so the bare `core-app` it was handed SERVFAILed and every `/platform/` call 502'd — while both probes stayed green, because `/healthz` is a static handler that never touches the resolver. The chart now gives the shell a fully qualified core URL (`clusterDomain` / `web.coreAppUrl`). And OpenBao could not survive its own restart: its container drops every capability but four, and `chown -R` on the data directory needs `DAC_OVERRIDE` to descend into the 0700 directories the file backend creates — so a fresh vault booted, a vault holding data crash-looped forever, which is a node drain or an upgrade away from any operator. None of the four is reproducible under Compose. `epicurus` chart 0.1.0→0.1.1 (PATCH). - **Storage honours the tenant the caller names** (#836) — the object store's rows and buckets were always tenant-scoped, but the module resolved the tenant for you: its HTTP routes pinned `DEFAULT_TENANT_ID` and merely *accepted* a `tenant_id` "for forward-compatibility", so a request that named a tenant was answered from the default's catalogue — the tenant-first constraint honoured everywhere except at the door. Browse, search, read, download, move and delete now scope to the `tenant_id` the caller sends; `/ingest` files an upload under the `x-epicurus-tenant` header the core's upload sink has always stamped on it; an unnamed tenant still means the deployment default, so a single-tenant self-host is unchanged; and a malformed one is a 400 rather than a silent cross-tenant read. The MCP tools resolve the bound tenant context instead of one fixed at build time — nothing carries a tenant across the MCP hop yet, so today they still land on the default. No backfill: every existing row and object was written under the default tenant and stays exactly where it is. `storage` 0.10.0→0.11.0 (MINOR). - **The import report shows what happened to your files, not just your records** (#905) — a module that keeps files of its own (`storage`, where every chat attachment lives) has always answered an import with a byte-level result: how many objects were written, which ones were **left untouched because the bytes here differ**, and which arrived as a record whose file the archive did not carry — the entry is findable and its download answers 404 until the operator copies it across. None of that was rendered anywhere (#886), so the one component where it matters most reported only record counts and the operator had to read the JSON to learn that a file had not come with them. Export & import now shows a `bytes` line under the component's own row and both id lists, each headed with what it means and what to do about it, collapsed past five entries so a module with forty conflicts cannot push the rest of the report off the card. Plain rows in the shell's own style; the core decides what they mean and the card shows them (ADR-0018). `web` 0.144.0→0.145.0 (MINOR). - **A `NULL` no longer costs the operator a whole data set, and a failed import can be thrown away** (#903) — dogfooding the tenant import on a second machine lost the entire `prefs` set to a single row. A column added after its table's first release with no `server_default` is reconciled in **nullable** (there is nothing to backfill a populated table with), so a long-lived source carries `NULL` where the model says `NOT NULL` — and a fresh target, whose `create_all` made the column `NOT NULL` for real, refused the insert. One row took `llm_prefs`, `saved_models`, `model_settings`, the timezone, the page order, push and the maintenance schedule with it, because a set is one transaction. Both ends now normalise a `NULL` to the column's own default, on export *and* on import, so an archive is portable whichever reconcile its source went through and archives already written apply cleanly; a null that genuinely cannot be defaulted costs **one row**, named with its column in the report, and the rest of the set still lands. The same shape was what made `calendar` answer the import with a 500 and lose the calendar — `all_day` and `excluded` are exactly that kind of column — and it is fixed the same way at both ends. And a job can now be **removed**: `DELETE /platform/v1/portability/{exports,imports}/{id}` drops the row and its staged archive (409 while it is running — a delete is not a cancel), with Remove on the import half and on every settled row of Recent jobs. Until now jobs left only through the retention sweep, which runs when the *next* job starts, so a failed import sat on the card with its report for a day and the only way to clear it was to cause another one. `core-app` 0.122.0→0.123.0 (MINOR) · `web` 0.143.1→0.144.0 (MINOR) · `calendar` 0.21.0→0.21.1 (PATCH). - **The editor's text cursor now reads in the accent colour, not body text** (#904) — dogfooding turned up a `.ProseMirror` caret that inherited `color` and so sat at exactly the same lightness as the character next to it, easy to lose in a long note, worst on the dark theme. `caret-color: var(--ep-accent)` fixes it with one inherited declaration — it reaches code blocks, blockquotes and the title line too, since none of them set their own `caret-color` — and needs no custom ProseMirror decoration: the accent clears WCAG 1.4.11's 3:1 non-text floor against the editor surface on every theme/power combination. `web` 0.143.0→0.143.1 (PATCH). - **The published Helm chart is a release artifact, not a nightly one** (#907, part of #889) — `testing.yml` published a chart on every push to `testing`, which made the on-demand `0.0.0-testing.` build look like a supported second track when the standard install and upgrade path is, and always was, a tagged release pulled straight from `oci://ghcr.io/baakhoff/charts/epicurus`. `testing.yml` no longer packages a chart at all; `release.yml` is unchanged. A new `chart-branch.yml` packages and pushes a chart from any branch or sha on a manual dispatch only — `0.0.0-.<7-char sha>`, with the dispatch naming the image tag that ref's images actually carry (`testing` is the one ref that can infer it) — for the owner's own testing clusters, never automatic and never the documented path. `docs/infrastructure/kubernetes.md` now leads with the OCI release install and gives the checkout path its honest name, "developing the chart." No component bump. - **A tool's failure message reaches the model again under mcp 2.1** (#908) — mcp 2.1 started masking any tool exception other than `ToolError`/`ResourceError`/`MCPError` as a bare `Error executing tool `, dropping the model-actionable text every module's `KeyError`/`ValueError`/`LookupError` failures rely on (37 tests across every module caught it). `EpicurusModule.tool()` now wraps the registered function so any exception it raises reaches the SDK already as a `ToolError` carrying the original message, chained — restoring the mcp 2.0 contract exactly. The wrapper logs an anticipated exception (`KeyError`/`LookupError`/`ValueError`/`PermissionError`/`FileNotFoundError`) at WARNING and everything else at ERROR with a traceback, so a genuine crash still stands out to the operator even though the model still gets a readable message. `mcp`'s floor moves to `>=2.1,<3`. `epicurus-core` 0.37.0→0.38.0 (MINOR). - **An import now shows its work, and the Platform card tells the truth** (#893) — dogfooding a tenant import on a second machine found four things the Settings card never said. The upload was a busy button: `fetch` cannot report upload progress at all, so the archive route is now the app's one `XMLHttpRequest` (its error taxonomy and connectivity evidence reproduced intact) and a multi-gigabyte archive shows a real percentage instead of looking hung. The apply was a bare spinner: the import job now carries the same `progress` list an export does — sets, modules, the file space, then two `rebuild` rows for the forced rescan and the re-embed — seeded from the preview in the apply's own answer, which is also what makes a second Apply press *impossible* rather than merely refused with a red 409 (the card's polled `staged` copy used to outlive the mutation's fresh one). The report never mentioned the commonest reason a clean import still feels broken — **no embedding model on the new box** — because the re-embed fan-out cannot report it: every module accepts the job and parks in `error` minutes later, out of sight. The core now asks the gateway *before* it asks the modules and states the finding plainly, pointing at Models → pull → "Re-embed everything"; a runtime that is down or a vault that will not answer is reported as *unknown*, never as absent. And the Platform card showed `epicurus_core`'s version under the label "core version", so the one number an operator quotes in a bug report named the wrong component: `GET /platform/v1/info` now carries `core_app_version`, `library_version` and the `release_track` actually pulled beside the unchanged `core_version`, and the card renders them apart. `core-app` 0.121.0→0.122.0 (MINOR) · `web` 0.142.0→0.143.0 (MINOR). - **The Helm chart ships beside the images, not as a separate download** (#892, part of #889) — `release.yml` and `testing.yml` now package `infra/k8s/epicurus/` and push it to `oci://ghcr.io/baakhoff/charts/epicurus` in the same run that builds and pushes the service images, so `helm install epicurus oci://ghcr.io/baakhoff/charts/epicurus --version ` works with no chart repo or index to host. A release tag publishes the chart at that tag's version (`appVersion` matching); every push to `testing` publishes `0.0.0-testing.<7-char sha>` with `appVersion: testing`. Both publish steps are no-op-safe — they skip cleanly on any commit before `infra/k8s/epicurus/Chart.yaml` exists (#890), so this lands independently of chart's own PR. No component bump. - **epicurus runs on Kubernetes — one Helm chart for the whole stack** (#890, part of #889) — the stack was Docker Compose or nothing, even though the architecture was built for a cluster all along: stateless services with externalised state, swappable storage and LLM backends, health and metrics on every service, images on GHCR. What was missing was the packaging. `infra/k8s/epicurus/` now installs the lot — the core, the web shell, all nine modules, and a data plane where each piece is either in-chart or pointed at a managed endpoint (`postgres.enabled=false` + `external.host`, and so on), so a homelab installs one release and a real cluster brings its own Postgres. The core is a singleton with a ReadWriteOnce file volume, which is honest about what it is today rather than pretending it scales. Only the web shell gets an Ingress — a module answers the core and nobody else, and the optional NetworkPolicy makes that a rule the cluster enforces rather than a convention. Its default ingress annotations carry the lesson from the archive-import fix: no body cap, no request buffering, or a real export is refused before the core sees a byte. The hardest part, OpenBao, is a Job that behaves the way the compose script does — initialise once, unseal, mint the periodic app token — except it stores the unseal key in a Kubernetes Secret it creates itself, so `helm uninstall` cannot take your vault with it, and a companion loop unseals a restarted pod. Passwords are generated once and kept across upgrades. A new `chart-validate` CI job renders every branch and schema-checks it with `kubeconform`, the bootstrap script is exercised against a stubbed API in pytest, and `task new-module` writes the chart entry too — so a module can no longer be wired into compose and forgotten here. The object store is on by default, as it is under Compose: `storage` keeps chat uploads, agent-written objects and the byte half of a tenant export there, so shipping it off would have installed a module that starts and then fails at call time. Chart `0.1.0`; no component bump. - **The core no longer assumes a Docker daemon, and the web shell no longer assumes Docker's DNS** (#891, part of #889) — the two places a Kubernetes deployment would have failed silently. The one audited path that touches containers (tearing down a removed module's container, restarting Ollama after a KV-cache change) now sits behind a runtime seam with three arms, chosen by `CONTAINER_RUNTIME` (`auto` by default): Docker exactly as before under Compose; in a pod, the cluster's own API through the ServiceAccount — a module's Deployment scaled to zero, Ollama rollout-restarted, workloads found by label and never by name, under a Role that grants nothing else; and `none`, which says once at startup that teardown is unavailable instead of deferring forever in silence. A refusal (RBAC saying no) lands where a missing socket already landed — the module is removed and hidden at once, only its workload waits for the next restart. The web shell's nginx used to hardcode Docker's embedded resolver, which does not exist in a cluster: it now derives `NGINX_RESOLVER` from the container's own `/etc/resolv.conf` at start, so one image proxies the core in both worlds. Riding along, a repo test now fails if nginx's upload cap is ever set below the core's own — the trap that cost #887 an unimportable archive. `core-app` 0.120.0→0.121.0 (MINOR) · `web` 0.141.1→0.142.0 (MINOR). - **A real archive can now actually be imported** (#887) — the web shell's proxy capped every `/platform/` request body at 12 MB (right for chat attachments), and the archive upload from the Settings card went through the same block, so any export that carried a knowledge vault or stored objects was refused by nginx before the core saw a byte — with nothing in the job list to say so. The import route now has its own proxy location with no nginx cap and request buffering off, so the archive streams to the core and the core's own `PORTABILITY_MAX_ARCHIVE_MB` ceiling is the only one; and an upload the core never answers is named as "never reached the core" instead of "Failed to fetch". `web` 0.141.0→0.141.1 (PATCH). - **Your uploads move house too — the tenant archive now carries bytes, not just rows** (#876, part of #866) — every other module's data is rows, and `storage`'s is not: the files you drop into chat and the objects the agent writes live in a per-tenant object bucket, which is nowhere in the file space the archive already carried. Exporting the catalogue alone would have handed you a list of filenames at the far end. So the module contract grows an optional **byte half** — a second, tiny protocol (`blobs` / `open_blob` / `put_blob`) that the same `add_portability_routes` call serves, discovered by the core from a route answering rather than declared in a manifest, so no module that has no bytes is touched by it. An archive now carries `modules//blobs.ndjson` and one member per object, and `storage` exports its catalogue as records whose stable id is the file's path — the *same* id its bytes travel under, so a row and its file pair up with nothing to join on. Nothing is ever held whole: each object is streamed through the disposable staging directory in both directions, and in and out of the object backend behind the module's own seam (multipart above 8 MiB), so a four-gigabyte upload is never a four-gigabyte allocation and nothing here assumes MinIO. Idempotency is by **content**: the core hashes each archived member and declares the digest on the way in, the module skips an object it already holds byte-identically, and an id that holds *different* bytes keeps them and is named as a conflict — the same rule the file space already had, so a second apply writes nothing and an import can never overwrite an edit you made since. The per-file ceiling applies per object; an over-cap one is omitted and **named**, while its catalogue record still travels, so the file stays listed with its size and you know exactly which bytes to carry across by hand instead of discovering the gap later. `storage` 0.9.3→0.10.0 (MINOR) · `epicurus-core` 0.36.0→0.37.0 (MINOR) · `core-app` 0.119.0→0.120.0 (MINOR). - **An export survives a reload — and the archive says which bridges to reconnect** (#877, #875, part of #866) — the Export & import card kept its job id in component state and nothing else, so refreshing the tab mid-export orphaned the run: it finished staging on the server, was never offered to anyone, and was quietly cleaned up a day later. The job row was durable all along; what was missing was a way to *find* it without an id. The core now answers `GET /platform/v1/portability/jobs` — this tenant's last 20 jobs, newest first, both kinds, each with an `archive_available` answered from the filesystem rather than from the row, so a download link appears exactly when the download would work and a cleaned-up archive says so plainly instead of handing out a link that 410s. The card reads that list on mount and re-attaches to the newest job of each kind, whatever this tab did or did not start: a running export resumes its progress, a finished one offers its download, an apply left mid-flight comes back with its report, and a **Recent jobs** disclosure holds the rest. It re-attaches on a second device and in a different browser too — which is why the server's list is the source of truth and `localStorage` is not used at all. Alongside it, the archive's secret inventory grew a third part (`module_secrets`): the export probes each enabled module's declared OpenBao paths for **presence** — never the value — so a module that persists no rows and still holds a credential is no longer invisible to the fan-out. `messaging` is exactly that module: nothing to export, and a bot token per connected bridge, so the import report now says "re-enter in module settings: messaging — discord, telegram" instead of leaving the operator to find out on the far side that Discord went quiet. `core-app` 0.118.0→0.119.0 (MINOR) · `web` 0.140.0→0.141.0 (MINOR). - **Mail evaluated for tenant export/import — nothing to carry, so the flag stays off** (#874, part of #866) — `mail` now serves `GET /export` / `POST /import` (`MailPortability`, schema `mail/1`) for the core's tenant export/import orchestrator (#867), but declares `portable=False`. Checking what the module actually persists (`mail_thread`, `mail_label`, `mail_sync`, `mail_landing`, `mail_category`) found that every one of them is a Gmail-derived cache (ADR-0096, #623/#765) — mail has no local mailbox, so nothing it stores is source-of-truth data, and ADR-0133 excludes exactly that from an export. Per the documented convention for a module with nothing worth carrying, the flag stays off and the core records mail as excluded, rather than listing an always-empty component in every tenant archive; the routes are still wired (`add_portability_routes`) so a future genuine per-tenant setting is a one-line flip. `mail` 0.20.0→0.21.0 (MINOR). - **`knowledge` joins tenant export/import: the suggestion queue travels, the vault's indexes rebuild** (#873, part of #866) — the module now declares `portable: true` and serves `GET /export` / `POST /import` (schema `knowledge/1`) so an operator's pending agent-proposed changes and their resolved-decision history move with them into a new install. What travels is exactly the module's source-of-truth data outside the vault itself: `knowledge_suggestions` and `knowledge_suggestion_decisions`, each upserted by the stable `sid` the module already mints — never the surrogate row id. Everything derived stays behind and is rebuilt after import instead: `knowledge_notes` / `knowledge_doc_index` / `knowledge_module_docs` (index ledgers), `knowledge_versions` (editor save history), and the `__knowledge` / `__docs` Qdrant collections, whose vectors are specific to the source's embedding model. The vault's own files ride the archive's `files/` tree, not a record here; the core's post-import rescan and #332 re-embed fan-out walk them back into an index on the target install, safely — a fresh install's ledgers start at zero rows, which is exactly the case the #848 mass de-index fuse always lets through. `knowledge` 0.29.0→0.30.0 (MINOR). - **Your notes move house with their words intact** (#872, part of #866) — `notes` now implements the module half of tenant export/import (schema `notes/1`): every note, every folder you made, the agent's pending suggestion queue and the decision trail behind it travel in the archive and land in the new installation under its own tenant. **Bodies travel with the rows**, which is the opposite of how it first looked: a note is already mirrored to `notes/.md` in the shared file space, and the file space is in the archive — but that mirror is write-only derived output that nothing ever reads back into the store, so carrying metadata alone would have handed the operator a Files tree full of markdown and a Notes page full of empty documents. What stays behind is what can be rebuilt or was never ours: per-save version history (already deduped and pruned to 50 per note, and every row a whole body of text whose current state travels beside it), the Qdrant vectors (rebuilt by the re-embed fan-out, since a vector belongs to the model that made it), the `.md` mirror itself, and the review on/off toggle (a core preference). Import upserts by the note's slug — never a surrogate key — in a single transaction, never deletes, and a second apply of the same archive is a no-op; a record that omits a column leaves that column alone, so nothing can blank a body it did not carry, and a note with no body at all is refused rather than conjured into the editor as an empty document. `notes` 0.13.0→0.14.0 (MINOR). - **Tasks joins tenant export/import** (#871, part of #866/#867) — `tasks` implements the module half of the portability contract: `portable=True` plus `GET /export` / `POST /import` (schema `tasks/1`) over local tasks, a Google-linked task's recurrence rule (the *only* copy of it — Google Tasks has no recurrence field of its own, ADR-0082), and the operator's `task_due_soon` lead-time preference when set. Each carries its own stable id (a task's own uuid, never the surrogate `pk`; a repeat rule's natural `list_id`/`task_id` pair; a fixed id for the single per-tenant lead-time row) so import is a plain upsert and re-applying the same export is a no-op. The Google task itself is not exported — it lives in the operator's account, not this module's database — and the operator's enabled/active list selection is left to the core's own export (`module_prefs.collections`) rather than duplicated here; the scheduler's fire-once markers are operational and excluded too. `tasks` 0.23.3→0.24.0 (MINOR). - **Your calendar travels with you** (#870, part of #866) — the tenant archive now carries calendar. Everything the local store owns goes: plain events, all-day events, and a recurring series *whole* — its repeat rule, the wall-clock zone it expands in, an occurrence you moved or retitled, and one you deleted — plus your `event_starting_soon` lead time. Import upserts by each event's own id, so re-importing an archive changes nothing and importing into a calendar you have already started using merges rather than replaces; a dry run tells you what would land before anything does. What is deliberately left behind is everything that describes *this* installation rather than your calendar: the Google mirror and its sync cursors (reconnect the account and the next sync rebuilds them from Google itself, which still holds those events), the fire-once reminder markers (so the new install still tells you about tomorrow's meeting), and — of course — the OAuth token, which never leaves OpenBao. Which calendars are enabled and which one new events land on already travel in the core's own half of the archive (they live in `module_prefs`), so they are not duplicated here. `calendar` 0.20.1→0.21.0 (MINOR). - **Take everything with you: tenant export and import, from Settings** (#867, part of #866) — an operator could stand up a second epicurus but had no way to *move into it*. The volume backups in `infra/backups` image one deployment for disaster recovery; nothing carried a person's own data across installations. Settings now has an **Export & import** card that writes the whole tenant — chats, memory facts, playbooks and instructions, automations, notifications, every preference, the file space, and each portable module's own data — into a plain `.tar.gz` you can open and read (`manifest.json` · `core/.ndjson` · `modules/.ndjson` · `files/…`), and reads one back. The module half is a contract shaped exactly like `reindexable`: a `portable` manifest flag plus `add_portability_routes`, which serves `GET /export` / `POST /import` over NDJSON for any store implementing three members — so a module lane implements its half without inventing an export format. Four properties are load-bearing and tested as such: **import never destroys** (upsert by stable id, so applying the same archive twice is a no-op and importing into a populated install merges rather than replaces — and a file whose bytes differ here is named as a conflict, never overwritten); **one component's failure is one component's failure** (a module that is disabled, unreachable, or speaking a schema this core cannot read is skipped with its reason while everything else lands); **only source of truth travels** (the file index and every Qdrant collection are rebuilt afterwards by the forced rescan and the #332 re-embed fan-out, because a vector is specific to the model that made it — #848 would otherwise rightly refuse a fresh install's empty→populated flip, hence *forced*, and the report says so); and **no secret ever enters an archive** (keys stay in OpenBao; the manifest carries their *names* so the import report can say what to re-enter and reconnect, and a test greps the produced bytes for the fixture's key values). Jobs are durable rows, so the work outlives the request that started it and stays readable by id; the archive itself is a disposable staging artefact (constraint #2), and a download of a swept one answers 410 rather than pretending. Module implementations (calendar, tasks, notes, knowledge, mail, messaging, storage) are separate follow-up issues. `epicurus-core` 0.35.0→0.36.0 (MINOR) · `core-app` 0.117.0→0.118.0 (MINOR) · `web` 0.139.0→0.140.0 (MINOR). - **OpenRouter, and embedding models that don't have to run on your box** (#865) — reaching OpenRouter meant spending the single generic OpenAI-compatible slot on it, and hosted *embedding* models were not reachable at all: `LlmGateway.embed` hardcoded `ollama/`, sent Ollama-only runtime options, and refused every call while the runtime was paused. Both are fixed at the root rather than per-case. `openrouter` is now a first-class provider alias (key at `llm/openrouter`), so LiteLLM's own OpenRouter dispatch and its context/cost map apply — and because an OpenRouter model id carries a vendor segment of its own (`openrouter/openai/text-embedding-3-small`), every place that classifies a model id now splits on the *first* slash only, in Python and in TypeScript alike. `embed` classifies its model through the same provider registry chat uses: a local id behaves exactly as before, while a hosted one takes the provider path with the tenant's key fetched from OpenBao at call time, no Ollama options, and — mirroring hosted chat — keeps serving while the runtime is paused, so memory recall and module indexing survive a paused GPU. Both classes emit the same tenant-scoped usage event naming the model actually called. The Models page's **Embedding model** select now lists saved hosted models beside the local ones and opens the hosted settings sheet for them (no keep-alive, no Run-on), saying plainly that the saved list can't tell a chat model from an embedding one — and that a hosted choice sends the whole notes/knowledge/memory corpus to that provider. Switching between a 768-d local model and a 1536-d hosted one is a *vector-size* change, which Qdrant used to answer with an opaque dimension error on the next upsert and on every search; the knowledge and notes indexers now recreate the collection at the new size and rebuild from source instead, with the two sources sharing `__docs` healing together so neither is left claiming vectors that are gone, and the #848 mass de-index fuse still weighed first and untouched. "Re-embed everything" remains the documented step after a model switch; this is the net for switching without it. Fixed along the way: an explicit `local/` embedding id reached the runtime verbatim as the nonexistent model `local/`. `core-app` 0.116.0→0.117.0 (MINOR) · `web` 0.138.0→0.139.0 (MINOR) · `knowledge` 0.28.1→0.29.0 (MINOR) · `notes` 0.12.3→0.13.0 (MINOR). - **Six module manifests were lying on screen** (#845) — the Modules page badge reads `manifest.version`, which every module hardcodes as a literal in `build_module()` rather than reading its own package's declared version. Six of them had drifted behind `pyproject.toml` (echo 0.5.0 vs 0.5.2, knowledge 0.27.0 vs 0.27.4, messaging 0.2.0 vs 0.3.1, notes 0.12.0 vs 0.12.2, storage 0.9.0 vs 0.9.2, tasks 0.23.0 vs 0.23.1), and each module's own test pinned the same stale literal the manifest hardcoded — so the drift passed the gate release after release while the badge quietly told the operator the wrong number. Calendar and websearch had already been fixed this way (wave 15); the same pattern now covers all six: the manifest still hardcodes a literal (a package can't read its own `pyproject.toml` from an installed wheel), but the test now compares it against the version *declared* in that `pyproject.toml` via `tomllib` rather than pinning a second copy of the same literal — a stale editable install can lie about installed metadata, so declared beats installed. Two modules (echo, storage) had no version assertion at all; one is added. The service template's own test never pinned a version literal, so it needed no change. `echo` 0.5.2→0.5.3 · `knowledge` 0.28.0→0.28.1 · `messaging` 0.3.1→0.3.2 · `notes` 0.12.2→0.12.3 · `storage` 0.9.2→0.9.3 · `tasks` 0.23.2→0.23.3 (all PATCH, this lane's own bump landing on top of #847's and #848's — this PR is the wave's merge-train anchor and lands last). - **A file space that reads empty no longer takes your indexes with it** (#848) — after two weeks of downtime the dogfood stack came up with a stale bind mount: the container saw an empty `/data` while every file sat intact on the host. Nothing failed. The core's file scan reconciled `core_files` to zero rows, a knowledge re-index purged the note ledger and emptied the vault's Qdrant collection, and not one error was logged — because "the source is empty" is a legitimate state everywhere in this codebase, and only a *raising* backend failure ever stopped a reconciler from deleting. The mount was stale, not the data, so nothing was irrecoverable that time; with an S3 backend or a slower embedding pass the same five seconds destroy hours of derived state. This puts a fuse in front of every reconciler that deletes because its source went quiet. Before a pass touches anything it weighs the deletion against what is already indexed, tenant-scoped and namespace-scoped: a source reading empty against a non-empty index is refused at any size, and so is a pass deleting at least half of an index (with an absolute floor, so ordinary editing — two notes of three — never trips a guard meant for wholesale loss). A refused knowledge pass is abandoned whole, ledger and collection untouched; a refused file scan still records what it saw and withholds only the deletions, so a stale mount leaves a *stale* index instead of an emptied one. The refusal is loud where silence did the damage: an `ERROR` log with the counts, a `/metrics` gauge and counter per tenant and source, `index_fuse_tripped` on knowledge's status panel, and `GET /platform/v1/files/scan-status` on the core. Deliberate wholesale deletion stays possible — `force=true` on `knowledge_reindex` and `POST /reindex`, `POST /platform/v1/files/rescan?force=true` — and `POST /reindex` now refuses *synchronously* rather than starting, because it drops each ledger before rebuilding and would otherwise erase the very evidence the fuse weighs. The first-ever index has nothing to protect and never trips; the first clean pass re-arms a tripped fuse. `knowledge` 0.27.4→0.28.0 (MINOR). · `core-app` 0.115.0→0.116.0 (MINOR). - **Mail no longer blames Google when the problem is the core** (#835) — the mail module's availability check returned a bool and read *any* failure of the token fetch as "not available", so an unreachable core, an expired module→core token and "nobody has connected Google" were one indistinguishable answer. Every surface then rendered that answer as the not-connected one: a core that was merely restarting produced a Mail page reading *"Google is not connected. Connect it in Settings"*, and an operator who followed that advice would disconnect and reconnect an account that was fine. The provider now answers with three states and a reason — `connected`, `not_connected` (the core answered, and it holds no Google tokens), `unreachable` (we could not find out) — the same failure-attribution split the core already makes for model keys after the #728 misdiagnosis. `/status` reports which state and why; the mailbox page carries `unreachable` instead of `disconnected`, and the shell draws it as its own empty state naming the actual reason, offering a retry and pointedly *not* a trip to Settings; `mail_send` refuses with advice that fits; and the background poller stops treating an unreachable core as an idle one — it warns once with the reason and backs off, instead of silently skipping every tick for as long as the outage lasts. `is_available()` survives as a thin compatibility wrapper, and tokens still come only from `PlatformClient.get_oauth_token` (ADR-0020). `mail` 0.19.1→0.20.0 (MINOR) · `web` 0.137.1→0.138.0 (MINOR). - **Fixed a pre-existing race in the tasks board-backlog test** (#847) — the booted-lifespan fixture runs the real app, whose lead-time scheduler ticks once at boot and independently calls `list_tasks(tenant, scope="open")` in the background (#664); the backlog test asserted on the mock's `await_args` — the *last* await — so a scheduler tick landing between the request and the assertion made it read the scheduler's call instead of the page's own `scope="all"` fetch, failing (or passing on the wrong evidence) nondeterministically. It now searches `await_args_list` for the specific call carrying `scope="all"`, which the page makes and the scheduler never does — independent of any interleaving. The rest of the file and the other tasks test files were audited for the same `await_args`/`assert_awaited*` pattern against a mock the scheduler also touches; this was the only instance. `tasks` 0.23.1→0.23.2 (PATCH). - **A recurring series you changed here is announced once, however the provider chops it up** (#843) — calendar's reconcile suppresses the changes this module made itself by looking them up in a short-lived ledger, and a series-scoped write is meant to leave its marker in place, because one such write legitimately comes back as one change per occurrence. It did not: collapsing a series' occurrences into a single emission re-keys that emission onto the series id, which made the id it looked up indistinguishable from an ordinary event id, so the *series* marker took the consume path and was deleted on the first pass. Occurrences arriving on a later pass — the delta paginates, the poll interval lands mid-write, a restart cuts a pass in half, one occurrence is edited a minute after the rest — then found nothing to match, and the write was announced a second time; a duplicate the spine's `dedup_key` absorbs only when the two payloads happen to coincide. Suppression now decides peek-versus-consume from where the id came from rather than by comparing ids, so a collapsed group peeks and only a genuine single-occurrence match consumes. A peeked series marker is released by its TTL alone (`SYNC_SELF_WRITE_TTL_S`, 15 minutes): nothing says how many occurrences one series write will come back as, so there is no "fully observed" moment to release it at. `calendar` 0.20.0→0.20.1 (PATCH). - **Calendar now notices changes you didn't make here** (#831) — the three calendar change events had exactly one emitter, the provider-write seam, which sees every write made *through* this module and, structurally, nothing else: an event created, moved or deleted in Google Calendar's own UI was never observed and never announced. Every downstream consumer — the automations matcher, push alerts, the event feed — was correct and simply never heard about half the changes to the operator's calendar. This adds the second emitter, on the shape mail's reconcile proved (ADR-0096): a per-collection incremental sync over Google's `syncToken`, with a tenant-scoped local cache of *what this module last observed* — which is what turns a provider's flat "here is a changed event" into the three different things the spine wants to hear. An id never seen is a creation; an id whose change hash moved is an edit, with `time_changed` a real before/after comparison against the cached start/end rather than a guess; an id that vanishes is a cancellation with a title still worth printing, since a tombstone is little more than an id. An expired token (`410 GONE`) is reported through the provider seam as `None` rather than an exception — a lapsed cursor is a recoverable state, not a failure — and the loop answers it by full-syncing and **diffing against that cache**, so the gap is reported exactly instead of replayed blindly or swallowed; a row that merely fell out of the forward-moving window is pruned in silence, because the passage of time is not an operator action. A first-ever sync stays silent (a calendar you already had is not news), and the signal is the sync-state row's mere existence, so a *restart* resumes and reports what changed during the outage rather than absorbing it into a silent prime. The hard part is having two emitters without ever double-announcing one action: each write records a short-lived durable marker keyed `"|:"` — plus the series id when the write was series-scoped — which the reconcile consumes (exact id) or peeks (series) before emitting. That key deliberately omits the change hash the `dedup_key` carries, so suppression survives the provider normalising content on the way back and survives the series → occurrence identity shift; both sides now build payloads, dedup keys and suppression keys from one module, because two copies of a dedup rule are one copy of a bug. Markers are written only for external-provider writes, so a local-only deployment gains no churn at all — and by the same one-rule degrade (#815) an unconfigured or disconnected provider resolves to zero sync targets, so an idle tick makes no provider call and prints no log line. A recurring series arrives as one change per occurrence, so a pass collapses them per `(series, event type)` — one click in Google's UI is one event, not thirty — and the whole pass is capped, mirroring mail's resume-backlog ceiling. `invitation_received` / `attendee_responded` are deliberately still **not** declared: they are Google-only, and a declared-but-never-published event would repeat the mistake mail's docs already record as a lesson learned once. The manifest's hardcoded version had meanwhile drifted two minors behind `pyproject.toml` (0.18.0 vs 0.20.0) — its test pinned the same stale literal, so the gap passed the gate while the Modules page badge read it; the manifest is corrected and the test now reads the declared version, so it cannot drift again. `calendar` 0.19.0→0.20.0 (MINOR). - **The module event spine survives a restart** (#832) — the spine was at-most-once by deliberate choice (ADR-0103 §4): core NATS pub/sub, JetStream enabled on the server and pointedly unused, because in July nothing consumed events so nothing could miss them. That stopped being true when proactivity — listeners → automations → alerts → the notification centre — moved inside 1.0, and a core restart began quietly costing the world's changes. It is now **at-least-once**: a JetStream stream (`EPICURUS_EVENTS`) over the *unchanged* `.events.>` subjects, provisioned idempotently on every boot, and a **durable pull consumer** (`core-event-intake`) whose server-side cursor outlives the process. Not one emitter changed — persistence is a property of the subject, not of the publisher's API, so a stream captures whatever lands on its subjects whoever published it. The rule that makes it worth anything is one line of ordering: **the message is acked only after the row is committed** to `module_events`. A core killed mid-message never acked it and gets it back; a store that fails is *naked*, so a database outage costs latency rather than history; and a message that can never be stored — unparseable, or claiming one tenant on the subject and another in the envelope — is *terminated*, because unlimited redelivery with no terminal case is an infinite loop. Fan-out to `on_event` listeners deliberately runs *after* the ack: holding it would blow through `ack_wait` and trigger a redelivery that dedup then absorbs as a no-op, skipping the listeners anyway. Redelivery is safe because the log's `(tenant, module, dedup_key)` uniqueness is a **database constraint**, not a convention — verified rather than assumed, and now asserted on the consume path and not only on the store. The honest half: emitting is still an *unacknowledged* publish, and that is not a trade that was available to make differently. A tenant-scoped subject puts the tenant in the leading token, so the stream's subject starts with `*` — and NATS refuses a stream whose subjects overlap its reserved `$JS.>` namespace unless publisher acks are disabled (`no_ack`). Publisher-side acks would mean changing the *subject scheme*, which is a contract change, not a transport one. So the guarantee is: **at-least-once from the NATS server to the durable log; best-effort from the emitter to the NATS server**, with the windows documented rather than glossed — and the widest is open on a *healthy* connection: nats-py appends a publish to a pending buffer and only *queues* the flush, so `emit_event` returns before the bytes are on the wire, and an emitter killed in that gap loses the event with NATS entirely up. **The guarantee starts at publish, not at the source of truth**; closing that needs an outbox in the emitter, not a transport change. The rest — a stream at its limit discarding oldest-first, an unclean server crash — are documented beside it, and the one loud case is a publish while the bus is disconnected, which raises rather than buffering across a reconnect. The `module_events` table stays the copy of record; the stream is a bounded delivery buffer in front of it (7 days, 512 MiB, discard oldest), not a second archive. Also folded in: the LLM diagnostics no longer report "no key configured" when the truth is "OpenBao could not be reached" — a bare `except SecretError` had collapsed the two since #728, so a provider list read as *unconfigured* during a vault outage. `ProviderInfo` now carries `key_state` (`not_required` / `present` / `missing` / `unavailable`) plus the store's reason, with `configured` unchanged for existing readers. `epicurus-core` 0.34.0→0.35.0 (MINOR). · `core-app` 0.114.2→0.115.0 (MINOR). - **`mypy --strict` now actually checks test code** (#833) — the root `[tool.mypy]` block was packages-only, so CI's bare `uv run mypy` step structurally never looked inside any service's or lib's `tests/`, and 606 strict-mode errors had piled up invisibly behind that blind spot (mostly mcp 2.0's content union replacing the SDK's older, looser types — `content[0].text` is now a real `union-attr` error against `TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource`, plus a long tail of `# type: ignore` comments nobody had re-validated in years). The config moves from `packages` to `files`, because mypy's own CLI rejects combining `packages` with `files`/`modules` in one run ("May only specify one of: module/package, files, or command"); `explicit_package_bases` + `namespace_packages`, plus a `mypy_path` matching every package's `src`, keep same-named test files across services (several ship their own `test_service.py`) resolving to distinct module names instead of colliding on a bare one, and keep a file reached directly the same module as the one reached by import — otherwise mypy reports the source "found twice under different module names." `scripts/new_module.py` now wires a new module's `mypy_path`/`files` entries too, so `task new-module` keeps producing a gate-clean scaffold with no manual follow-up. All 606 errors fixed, test-side only: `.text` access on an mcp content union narrowed with a small `isinstance(x, TextContent)` helper per file rather than erased to `Any`, ignore comments whose bracketed code had drifted stale corrected to what mypy actually reports now (`[attr-defined]` where it's really `[method-assign]`), and a few dict/list literals annotated at their wider expected type at the point of construction instead of left to infer the narrower concrete one. - **De-raced `test_manual_run_history_reports_source_manual`** — `current_run` reads `None` both *before* a maintenance run starts and *after* it finishes, so polling for "cleared" immediately after `POST /run` (itself fire-and-forget, #561) could observe the pre-run idle state and never actually wait. The poll now matches on the completed run's own history `id` — assigned once persisted, and strictly increasing — instead of the transient flag, which is unambiguous even across repeated runs in the same test. (A `started_at` string comparison looked simpler but doesn't survive the round trip through SQLite's `DateTime` column, which quietly drops the UTC offset on the way back out.) - **`services/web/src/components/EventAlertsCard.tsx` carried a raw NUL byte** — `keyOf`'s map-key separator, sitting inside a template literal since the file was first added, which made git treat the whole file as binary and drop it from every reviewable diff from day one. Escaped to `\0` instead (identical at runtime); a new fast repo-wide test (`tests/test_no_nul_bytes.py`) guards against the next one slipping through the same way unnoticed. - **`compose-validate` now also lints the observability profile** — the existing step resolves every service regardless of active profile, which doesn't actually prove `infra/observability/compose.yaml` (gated behind `profiles: [observability]`) activates cleanly under its own profile; a second `--profile observability` invocation does. `web` 0.137.0→0.137.1 (PATCH). · `tasks` 0.23.0→0.23.1 (PATCH). · `core-app` 0.114.1→0.114.2 (PATCH). · `mail` 0.19.0→0.19.1 (PATCH). · `knowledge` 0.27.3→0.27.4 (PATCH). · `notes` 0.12.1→0.12.2 (PATCH). · `echo` 0.5.1→0.5.2 (PATCH). · `messaging` 0.3.0→0.3.1 (PATCH). · `storage` 0.9.1→0.9.2 (PATCH). · `websearch` 0.3.0→0.3.1 (PATCH). `epicurus-core` and `calendar` also had test files fixed here but carry **no line of their own**: both take a MINOR in this same wave (#832, #831), and a PATCH landing first would only collide with their version-line edits. - **Every public status surface told a retired story — reconciled to the milestone roadmap** (#830) — `README.md` and `docs/index.md` still opened with "Phases 0–3 complete… Phase 2/chat bridges next," a model the project moved off of. Both now state the actual gate: working toward **1.0.0 — Foundation complete & stable** (a quality bar, not a feature checklist), with the `messaging` module (loopback, Discord, Telegram) already shipped and Slack/WhatsApp landing at milestone 2.0.0; latest tagged release stays v0.2.0. `docs/index.md`'s at-a-glance module tree was missing `notes` and `websearch`; both added. `docs/user/installation.md`'s port table listed 3 of the platform's 11 module ports; rebuilt against the source-of-truth `docs/reference/ports.md` (module band, data plane, and the opt-in observability profile), plus a new First-run models section documenting what the ADR-0118 bootstrap (#773) actually does — the effective chat/embedding defaults it resolves, its backoff/skip/no-op behavior, and the `LLM_BOOTSTRAP_MODELS` knob. `docs/services/index.md`'s mail row dropped a stale, unrelated-to-current-version "(v0.1)" tag. `docs/services/web.md`'s Chat row was a single ~9,690-character table cell; it's now a one-line summary pointing at a new prose `### Chat` subsection carrying the exact same content (verified word-for-word against the original, nothing dropped). That page's "two board pages from one module" illustrative example (tasks' board + Can) has had no live instance since #820 folded Can into the Tasks page, so the dead example was dropped and the underlying `BoardView` keying behavior kept, described generically. `docs/services/core-app.md` and `scheduled_turns.py`'s module docstring both still called event-driven listeners/alerts "a later milestone"; that shipped inside 1.0 via #662–#672 (the module event spine, automations engine, push notifications, and event alerts) — both corrected to point at the automations engine. `core-app` 0.114.0→0.114.1 (PATCH). - **The document pane now types** (#654) — v1 (#541, ADR-0101) opens the pane when an annotated `writes_document` call *lands*, by which point the model has already written the whole body; v2 shows it arriving. The blocker was never the pane: tool-call fragments were assembled **inside** `LlmGateway.stream_chat` and surfaced only on the final `result`, so the agent loop could not observe a call being written at all. So this starts with a gateway streaming-contract change — a new optional `StreamEvent.tool_call` carrying `ToolCallFragment{slot, id?, name?, arguments?}`, emitted as each fragment arrives. It is strictly additive: the accumulation and the final `result` are untouched, and `slot` is the accumulator's *own* slot rather than a second guess at it, so #324's hard-won index discipline (OpenAI shares an `index` across a call's fragments; LiteLLM leaves it unset for Ollama's complete-per-fragment calls, and honouring `index or 0` once fused two calls into invalid JSON that crashed the next turn on replay) is inherited rather than duplicated — `arguments` is the fragment's delta, `id`/`name` are the call's values as resolved so far, and the whole-dict provider flavour reports no delta because it is replaced rather than appended. Reading the body out of that stream needs a value extracted from JSON that is still an unterminated fragment, which `json.loads` can only reject: `agent/partial_json.py` is a hand-rolled resumable scanner rather than a tolerant-parser dependency, because the job is narrower than "parse partial JSON" and its real failure modes are the ones a general parser does not solve for us — it hands out the *delta* since last time rather than re-decoding the document per fragment, it matches only top-level keys with a real scanner (so `{"title": "content", "content": "real"}` cannot confuse the two), and it holds back anything a fragment boundary cut in half: a `\` split from what it escapes, a `\uXXXX` split from its digits, a surrogate pair split down the middle. That last one is not cosmetic — a lone surrogate lives happily in a Python `str` and then fails to encode into the SSE frame carrying it, so the pane would have killed its own stream. Malformed JSON marks the reader broken and keeps what it decoded; a preview is never worth an exception in a turn. On top sits the throttle, the answer to #541's "a large document must never starve the chat deltas": since the document and the answer share one stream, what protects the answer is bounding the document, so a `doc_preview` frame goes out at most every **100 ms** per call (`PREVIEW_INTERVAL_S`) or sooner once **4096** characters have piled up (`PREVIEW_MAX_CHARS`) — the interval bounds the frame rate at ~10/s whatever the model's token rate, the cap bounds frame size, the first slice goes out immediately so the pane appears at once, and the tracker is flushed when the gateway stream ends so the tail is never withheld. The new `doc_preview` SSE kind is the first top-level kind added since the protocol settled, and the only **purely ephemeral** one: `{tool, text, preview}`, where `text` is the coalesced body delta and `preview` is `{module, target?, title?}`, repeated on every frame so a frame stands alone. It never reaches the timeline — live or persisted — for the same reason v1's finished `document` payload doesn't (ADR-0041), only more so, because a preview reads an *unfinished* call; `target`/`title` are reported only once their argument has closed, so the header fills in instead of flickering through a half-typed title. **Re-attach needed a decision and got the smaller one:** previews ride the same seq-tagged live-run buffer as every other frame, so `GET /runs/{id}/stream?after_seq=N` replays them in order — from 0 a reload rebuilds the body from its deltas, from N a resume gets only what it missed. Holding the latest coalesced snapshot and re-sending *that* would make the run carry document state it has no other reason to keep, and would hand a resuming client a prefix it already has. On the web the pane opens on the first character the model types and steers on every one after it: the store concatenates deltas into `liveDocument` (`streaming: true`), a caret marks text still arriving, and the panel's `replace(payload, title)` finally has its first caller (#659's affordance) so a title that finishes typing late reaches the header instead of leaving it reading "Document" for the whole write. The hand-off is v1's settle path unchanged and is what keeps ADR-0101's promise that the pane never lies about whether a write happened: the following `tool` frame carries the arguments as actually *parsed* and replaces the previewed body wholesale, so a preview that drifted cannot outlive the real thing. The pane stays **read-only** from the first previewed character through the settle, so #541's edit/write conflict — the one v1 sidestepped structurally — stays impossible rather than becoming live; closing the pane mid-typewriter stays closed for the rest of that write. `core-app` 0.113.0→0.114.0 (MINOR), `web` 0.136.0→0.137.0 (MINOR). - **Send it any link and the agent can actually read it** (#739) — `web_search` could *find* a page; nothing in the platform could *open* one. No URL-fetch tool existed anywhere, in websearch or in the agent's built-ins, so "save the substance of this article to my knowledge base" bottomed out at a search snippet plus whatever the model happened to remember about the site. The save half was already done (`knowledge_propose_edit`, #220/#722) and so was gateway vision (#633/#711) — but only for a chat attachment, never for an image behind a URL. The missing piece was the reading. websearch gains a second tool, **`link_ingest(url)`**, returning `{kind, title, site, author?, published?, text, image_descriptions?, transcript, notes}` across three tiers: articles and ordinary pages (readability-grade body text plus OpenGraph/JSON-LD metadata), direct images (described by the core's vision model), and public video/reel/audio links (metadata plus the uploader's own subtitles). It stays **inside websearch** rather than becoming a module of its own — the roadmap forbids new modules before 1.0, and metadata-only ingestion needs no ffmpeg and no OS packages, so the image is unchanged. It calls **no other module** (ADR-0004): the tool returns an extract, and composing it into a document and filing it is the agent's own next step through the knowledge tools — which is why the steering lives in the tool docstring and in a new rung on #703's grounding ladder in the default agent instructions (read the link, work from what came back, keep the source URL and retrieval date on anything filed). Extraction is `trafilatura`, chosen over `readability-lxml` + `beautifulsoup4` because one dependency covers both the body text and the metadata the contract needs; `yt-dlp` is a lazily-imported, failure-tolerant extra that degrades to oEmbed + OpenGraph when it is absent or an extractor breaks overnight. Two things are deliberately refused rather than approximated. **Transcription**: `transcript` is reserved and always empty — ASR is a new gateway modality (milestone 5.0.0) — and the platform's own machine captions are not used either, since passing a platform's speech recognition off as the video's text would blur exactly the line the contract draws; uploader-published subtitles *are* included, in `text`, labelled as captions. **Authentication**: no sign-in, no credentials, no cookie jar, no CAPTCHA circumvention — structurally rather than as a promise, since the extractor is handed no credential source at all and a URL carrying `user:pass@` is refused outright. A private or login-walled link comes back as `kind: "unreachable"` with a note saying so, and the same is true of a dead host, a PDF, a captionless video, or an image no model could see: every failure is a well-formed result carrying an honest note, never a raised exception. The agent's job is to relay the gaps, not to fill them. This is the first tool in the platform that fetches an **arbitrary, operator-supplied URL from inside the Docker network**, where the core, Postgres, Valkey, Qdrant, OpenBao, and the docker proxy all answer without authentication — and nothing server-side existed to reuse. So it ships a purpose-built SSRF guard: `http(s)` only; no credentials in the URL; single-label hosts refused (on this network a dotless name *is* a service name) along with `.localhost`, `.local`, `.internal`, `.localdomain`, `.home.arpa`, and `.onion`; the host resolved and **every** returned address checked against the private, loopback, link-local, reserved, multicast, and CGNAT ranges in both families, with IPv4-mapped and 6to4-wrapped IPv6 unwrapped first so `::ffff:127.0.0.1` cannot smuggle a loopback through; and — the part that matters most — redirects followed **by hand** so every hop is re-validated before it is requested, because a public URL that 302s to `169.254.169.254` is the classic exploit and the hop is what it turns on. Bytes, wall-clock across all hops, redirect count, and content types are all capped, tunable through new `LINK_INGEST_*` settings with conservative defaults. yt-dlp does its own HTTP outside that client, so it is confined to an allow-list of known public media platforms and only ever sees a URL that already passed the guard. One residual gap is stated rather than papered over: the guard resolves the host and httpx resolves it again to connect, so DNS rebinding between the two is not caught — closing it needs connect-time address pinning, which httpx does not expose without a custom transport. Tier 2 forced a gap in the core into the open. `POST /platform/v1/chat` had **no vision gate**: `supports_vision` guarded only the interactive agent turn (#633), so a module sending image content-parts to a text-only model got either a silent ignore or a raw provider error — precisely the two outcomes that gate exists to prevent — and `link_ingest` would have been the first module-initiated vision inference to hit it. The endpoint now applies the same check before any provider call and refuses with a structured **400** (`{"error": "unsupported_media", "message": …, "model": …}`) a caller can branch on; both the OpenAI `image_url` and the Anthropic-native `image` spelling trigger it, and an image anywhere in the history counts, not only in the last message. websearch catches exactly that 400 and degrades to metadata plus a note naming the model and the fix, so an operator with no vision-capable model configured still gets the article — just without the picture described. Text-only requests are untouched and pay no extra capability lookup. No new client helper was added to `epicurus_core`: `ChatMessage.content` has accepted `str | list[dict] | None` since #633, so the module builds content parts over the existing `PlatformClient.chat` and the wire contract is unchanged. `websearch` 0.2.2→0.3.0 (MINOR), `core-app` 0.112.0→0.113.0 (MINOR). - **Going Google-free is now a first-class path, per module and platform-wide** (#764) — the operator's instinct was that it wasn't possible at all, and the shell had been quietly proving them right. Everything needed already existed: the ADR-0030 collections panel could untick a Google list, and Settings could disconnect the account outright. But "stop using Google in tasks" was N individual unticks, after which the Google block kept its full visual weight and nothing anywhere said the module was now local-only — configuration archaeology dressed as a setting. The panel now offers **"Stop using Google in this module"**: one write against the existing prefs API that disables every one of that account's collections, clears the write target back to the built-in local default (and only when the active collection belonged to that account — a second provider's target is never collateral), and collapses the block to a single quiet row, *"Google — not used · Use again"*. Tokens are untouched, so it is per module by construction: every other module keeps working, nothing at Google changes, and one click undoes it. Two deliberate decisions, recorded as ADR-0122. The collapsed state is **derived, never stored** — "not used" *is* "none of this account's collections are enabled", the only place it could live given `CollectionPrefs` holds `{enabled, active}` and nothing else, so it can never disagree with the toggles it replaces (safe as a default because connecting an account seeds every collection enabled, #209, making a connected account with nothing ticked always a deliberate act). And **"Use again" re-seeds rather than replays**: it enables all of the account's collections and makes the first writable one active — exactly what a fresh connect does — because restoring a hand-picked subset would need hidden session state that silently stops working after a reload, i.e. two behaviours behind one button; the toggles show what is on, so re-narrowing is one tick. The other half was the global disconnect, which worked but didn't *look* like it had. Disconnecting deletes the tokens and strips the provider from every module's stored selection (#209), yet the web kept several deliberately long-lived caches describing what modules can see — the calendar's account view holds Google calendar names and colours for five minutes, the mailbox its thread list for thirty — so the next page the operator opened still painted a connected account. The disconnect mutation now invalidates the module-facing keys by prefix alongside its own status. And mail, the one module with **no local provider** (ADR-0032: no collections, no fallback mailbox), had no story at all for the disconnected state: the page relayed a raw `httpx.HTTPStatusError` under a scope hint that didn't apply, and the tools re-raised it at the agent. The token seam now distinguishes the case — `GmailProvider._get_token` maps the core's documented 404/400 to `MailNotConnected`, translated *there* precisely so it can never be confused with Gmail's own "no such message" 404 — and every surface answers honestly: each MCP tool returns one model-actionable sentence naming both ways out (connect it in Settings, or disable the module) instead of raising, `mail_send` refuses to compose a draft that could never be delivered, the page returns a valid empty list carrying `disconnected` that the shell renders as an honest empty state with a tap to each exit, a leftover message chip answers 503 rather than a 404 claiming the message was deleted, and `mail.sync_failed` is no longer emitted — an absence the operator chose is not a failure to alert on. The local cache is kept but not served, so a reconnect restores the mailbox with no resync and no restart. Not in scope, and deliberately so: local-first replacements for the Google-backed capabilities themselves (no local mailbox, no richer local calendar) — going Google-free means keeping the local half, not re-implementing the other one. New operator note, `docs/user/running-without-google.md`. `web` 0.135.0→0.136.0 (MINOR), `mail` 0.18.1→0.19.0 (MINOR). - **Folded the Can into the Tasks page as a Show → Backlog option, instead of a second nav entry** (#820) — the Can's own **partition** (#766, the board shows only dated tasks, the backlog holds the rest) was right; its **placement** wasn't. A second left-nav page gave the backlog the visual weight of its own module and split one workflow — triage the backlog, schedule things onto the board — across two pages. The *Show* control (already shared by the board and the Can, open/completed/all) gains a fourth value, **`backlog`**: a page-level dated-ness partition, deliberately **not** a widened `TaskScope` — the app branches on it before the provider fetch rather than teaching the providers a fourth read scope. The Can's own `PageSpec` and its `GET /pages/can` route are gone (`can` now 404s like any other unknown page id); its data now comes back from `GET /pages/board?show=backlog`, rendered by a new `build_tasks_backlog` that keeps the Can's exact shape — a flat column, an Add with no due/repeat field, and each card's leading Schedule action. *Group by* is omitted whenever Show is Backlog (a flat backlog has nothing to group, the same dead-knob rule #767 already gives List/Calendar), and the **Calendar** view drops `backlog` from Show's own options entirely — a backlog has no due dates to place on a grid — correcting a stale or explicit `show=backlog` back to Open whenever `view=calendar`, so the control's echoed value is always inside its own offered options. One axis nuance needed a decision: Show used to mean *status scope* on the board and, independently, the Can page's *own* Show filter over the backlog; folded onto a single control, only one Show value can be active at a time, so the backlog can no longer carry that second, independent filter. The chosen rule fetches every status for the backlog regardless and splits internally — open and in-progress tasks lead in a flat **Backlog** column, any completed undated task follows in its own muted **Completed** column (an ordinary struck-through card) — rather than making completed/all leak undated items onto the dated board, which would have broken its "dated tasks only, no 'No date' bucket" invariant. Either column is dropped when empty, neither is ever omitted, which is what keeps the acceptance bar: no undated task, open or completed, becomes unreachable. Agent- and web-form-facing copy was swept from "the Can" to "the backlog" / "Show → Backlog" throughout — `tasks_add`'s tool description, both `due` parameter descriptions, and a task's hover-card `href` (now a `?show=backlog` deep link on the one Tasks route rather than a separate page's URL) — so the words the agent uses match what the operator sees. `tasks` 0.22.1→0.23.0 (MINOR). - **Disabling a connected calendar account silently emptied the Calendar page** (#814) — `CollectionRouter` resolved a *missing provider* in exactly opposite ways on the write and read paths. The trigger is a stale `enabled`/`active` collection reference: nothing prunes the operator's stored selection when the account behind a reference is disconnected, so the dead reference stays in place. A write (`calendar_create_event`, `calendar_find_free`) hit `self._provider_for(ref.account) or self._local` and fell back to the local store; the `list_events` aggregate hit the same condition and `continue`d — and because its fallback (`prefs.enabled or [_LOCAL_REF]`) only triggers when `enabled` is *empty*, never when a reference inside it is dead, it never consulted local at all. So an event created through a stale reference was written, persisted, and then absent from the Calendar page, while the tool honestly reported a real, saved `Event`. Neither side was lying; they simply disagreed about what "this reference's provider is gone" means. The skip was also completely silent — the neighbouring transient-failure path logs, but a disconnected account left no trace, so nothing in the logs hinted that a read source was being dropped. This is the calendar twin of the same defect reported against tasks (#795); the blast radius was smaller here only because `_search_refs` always appends the local reference, so single-event lookups (`get_event`/`update_event`/`delete_event`) already walked past a dead reference and reached local — the aggregate read had no such backstop. Fixed by giving every read and write one shared rule, `_resolve_provider`: a live provider for the reference's account is used as-is; a missing one **degrades to the local collection** and logs a warning naming the account and collection that went missing. The degraded reference is replaced by the local reference rather than merely re-pointed, so a write is never handed the dead account's collection id and the events a degraded read returns are tagged `local` — a token the page's per-calendar visibility toggles can match, rather than a calendar the operator no longer has. A `_dedup_refs` helper applies the rule across a multi-reference read and de-duplicates by *effective* target, so two independently stale references — or a stale one landing on a local entry already enabled — read local once instead of double-counting its events in the returned window. Transient read failures keep their existing skip-and-log (#209): a provider that is live but erroring is a different condition from one that is gone. With no `active` set the write-default scan is choosing among candidates rather than honouring a stated one, so passing over a dead reference there stays silent and unchanged; an explicitly-set `active` that has gone stale degrades to local, deliberately not to some other external calendar the operator never named. `calendar` 0.18.1→0.19.0 (MINOR). - **The MCP SDK moved to 2.0** (#792) — a genuine breaking API change in the two places that carry the module↔core contract: `epicurus_core.module`, imported by every module, and the core's MCP host. `FastMCP` became `MCPServer` (the decorator API itself unchanged), every transport parameter moved off the server constructor onto `streamable_http_app()`, the result attributes went snake_case (`inputSchema`→`input_schema`, `isError`→`is_error`, the wire JSON unchanged via aliases), `call_tool` returns a `CallToolResult` rather than a `(content, structured)` pair, the low-level transport/session/`initialize()` layering collapsed into a single `Client` owning all three with `float`-seconds timeouts instead of `timedelta`s, and the SDK now raises `httpx2` exceptions from its own httpx fork. **No module source changed**: the `EpicurusModule` constructor, the `tool()` decorator, `manifest()`, `http_app()`, and the `session_manager.run()` lifespan hook all survive 2.0 verbatim. Two additive members close the one hole 2.0 opened — module *tests* were reaching through `module.mcp` into raw SDK surface that moved — so `EpicurusModule.call_tool()` is now the stable in-process invocation surface returning the pre-2.0 pair, and `ToolError` is re-exported from `epicurus_core`; an SDK reshape now lands in the library once instead of in every module's tests. `McpHost`'s outward contract is byte-identical: discovery returns the same specs and routes, and a call still distinguishes a tool that ran and reported failure (#435) from a module that never answered (#472), with every hop bounded. One deliberate hardening: discovery now carries the same 30s RPC read bound as a call, because 2.0's default HTTP read timeout is 300s — sized for long-lived SSE streams, far too generous for a scan that runs on every agent turn. `epicurus-core` 0.33.0→0.34.0 (MINOR), `core-app` 0.112.0 (MINOR), and a test-only PATCH for every module whose tests were migrated: `tasks` 0.22.1, `mail` 0.18.1, `calendar` 0.18.1, `notes` 0.12.1, `knowledge` 0.27.3, `storage` 0.9.1, `echo` 0.5.1, `websearch` 0.2.2. - **A push notification could die at any of six gates without a single trace** (#797) — push crossed six independent conditions on the way to a device (no event subscription, the per-subscription rate cap, the category's push toggle, quiet hours, the tenant-wide rate cap, no registered device) and several of them declined in complete silence, so "I got no notification" was indistinguishable from a broken pipeline and there was no way to bisect the two without instrumenting the code. Every gate now logs one distinct, greppable line naming the tenant and the category or module/type — `event alert declined: no subscription for this event`, `event alert rate cap reached`, `push skipped: push disabled for this notification`, `push queued for quiet-hours digest`, `push rate cap reached; delivery skipped`, `push skipped: no registered devices`, `push send failed`, `pruned dead push subscription` — and a clean send logs none of them, so a decline line always means a decline. Settings gained a delivery-state surface behind a new `GET /platform/v1/push/status`: the registered-device count and the most recent delivery *attempt* with what became of it, in plain language, where a send that reached zero devices with failures reads as failed rather than as success. A warning banner now fires at configure time when push is enabled somewhere but no device is registered — the misconfiguration that used to fail silently once per delivery. The last-attempt readout is in-memory, single-instance v1, the same disposable-cache trade the rate windows make: `null` after a restart means "nothing attempted since boot", not "never". **Behavior change — the notification center is now a superset of push, amending ADR-0102 §4 and ADR-0104 §1.** `push` and `center` used to be independent per-category toggles, so a push missed while the device was off — or that failed outright — could simply be *gone*, and `center: off` suppressed the durable row entirely. The center row is now written unconditionally for every notification that fires, quiet-hours-queued and outright-failed deliveries included, and `push` means "*also* deliver to devices". `ChannelPrefs.center` is vestigial: still accepted, stored, and returned on the wire, so nothing on the contract breaks, but delivery no longer consults it, and `NotifyResult.notification_id` is therefore always set. **Anyone relying on `center: off` to suppress rows is affected** — that state no longer exists; the only way for an alert not to be recorded is for it not to fire. The event alerts card's independent Push/Center switch pair collapses accordingly into two coupled switches over the unchanged `{push, center}` wire shape — **Alert** (the master, on iff either stored flag is on) and **Push** (also deliver to devices, implying Alert) — and legacy `{push: true, center: false}` rows read as both on. The per-subscription event-alert rate cap still gates the whole notification, center row included: a firehose into the center is still a firehose. The tenant-wide push cap gates delivery only. `core-app` 0.111.0 (MINOR), `web` 0.134.1→0.135.0 (MINOR). - **Tasks added while an account was disconnected were saved and then invisible forever** (#795) — `TasksRouter` resolved a *missing provider* in exactly opposite ways on its write and read paths, and the trigger is a stale `enabled`/`active` collection reference, which nothing prunes when the account behind it goes away. A write fell back to the local store — but kept the stale reference, so the task was mislabeled with the account and collection it had fallen back *from*, visible in the emitted `tasks.task_created` event's `dedup_key` and in the list id handed to the local provider. The read aggregate hit the very same condition and `continue`d past it, never trying local at all, because its fallback only fires when `enabled` is *empty*, never when an entry inside it is dead. So `tasks_add` reported a genuine, persisted task and the very next `tasks_list` or board read came back empty — not lost, just unreadable through the router — and the skip was silent, unlike the neighbouring read-failure path which logs. Fixed by giving every read and write one shared rule, `_resolve_provider`: a live provider for the reference's account is used as-is; a missing one degrades to the local collection — replacing the reference rather than merely re-pointing it, so a write is never mislabeled — and logs a warning naming the account and collection that went missing. A `_dedup_refs` helper applies the rule across a multi-collection read and de-duplicates by *effective* target, so two independently stale references, or a degraded one landing on a local entry already enabled, read local once instead of double-counting its tasks. The explicit-`list_id` write branch funnels through the same rule, which also closes a related asymmetry: a `list_id` matching a stale enabled reference now degrades to local instead of falling through to "the sole external provider owns this unlisted id" and potentially misrouting to an unrelated account. Pruning the stale reference itself is deliberately left alone — the core's disconnect cleanup is best-effort by design and a module-side prune would need a new write-back into the shared library — because the router-level rule makes staleness permanently harmless whatever caused it. The calendar module had the identical defect; it is fixed separately as #814. `tasks` 0.21.0→0.22.0 (MINOR). - **`mail.received` only fired while the Mail page was open, so automations and push alerts on new mail never ran** (#796) — the event had exactly one emitter, `CachedMailbox.reconcile`, and exactly one caller: the mailbox page's `?reconcile=1` read, which the web fires when an operator opens Mail. Mail had no background worker at all, unlike calendar and tasks, both of which have shipped a scheduler since #664. So "notify me when mail arrives" could only ever fire *after* the operator had already seen the mail — the inverse of the feature. Nothing downstream was broken: the automation matcher and the per-event alert listener were wired and waiting on an event nobody emitted. Worse for the reported case, a cold or expired cursor fell back to a full sync, which by design emits nothing (the no-firehose rule), so the first open after a while — exactly when the most mail had accumulated — was the one guaranteed to announce nothing at all. Mail now runs the loop itself (`epicurus_mail.poller`, started and cancelled by the service lifespan, the same shape calendar and tasks use). A tick is `is_available()` → `reconcile("INBOX")` and nothing else: deliberately only a *caller* of the existing path, so the events, payloads, dedup keys and every consumer downstream are literally the ones the page produces, and the two can never drift. It is **on by default** at `MAIL_POLL_INTERVAL_S=300` (`0` disables the loop entirely) — an event contract that holds only while a human is watching the page is not a contract, and the cost is one history-delta call per tick. An unconnected deployment costs one token-presence check per tick and logs nothing at all. Two supporting changes fall out of having a second caller. Reconcile is now **single-flight per account**, so a poll tick and a page read serialize on the change cursor instead of both announcing one message (per process, like the tasks scheduler's `_claim_materialize`; the spine's message-id `dedup_key` is the backstop beyond that). And the no-firehose rule is split in two by the cache's `synced_at` stamp: a **first-ever** sync still emits nothing, while a sync that *resumes* a mailbox synced before — the cursor lapsed while the service was down — replays `mail.received` for what arrived since that instant, newest first, capped at 50 messages, via a new capability-gated `MailProvider.messages_since` (Gmail: one `messages.list` with an epoch-second `after:` term). A provider that can't answer inherits the empty default and simply gets no replay; the full sync itself is never blocked or failed by it. `mail` 0.17.0→0.18.0 (MINOR). - **Entities the assistant named in an answer were plain text, never clickable** (#794) — the shell has rendered an inline `epicurus://entity///` markdown link as an interactive chip since ADR-0019, excluding anything so linked from the trailing "Sources" pill, but the model was never told the syntax exists — so it never emitted one, and every reference fell through to the pill unconditionally. The one place the model does see reference ids, the "Referenced items" block appended to a tool result whose envelope carries entity refs (ADR-0079), framed them as tool *inputs* only: "pass an item's id to a tool that needs one". That block now carries each reference's ready-made link beside its id, and the intro teaches the syntax — link what you mention, using the URL shown, verbatim. Fixing it in that one place fixes every module at once, the same reasoning that introduced the id listing. Every component of the URL is percent-encoded, `/` included, because the web's inline-link matcher stops at the first `)` or whitespace and then decodes: a `ref_id` containing either would otherwise break the link, and being markdown, break it *silently*. Handing the model the finished URL rather than three raw fields to assemble removes the failure mode entirely. The instruction is deliberately to link what is actually named rather than everything — a twenty-hit search should not become twenty chips, and the pill remains the outlet for the long tail. No web change: the renderer, the pill exclusion, and the graceful degradation for an unknown or malformed link all already existed. `core-app` 0.109.0→0.110.0 (MINOR). - **A bare weekday name landed the event exactly one week late** (#793) — the `now` built-in handed the model the current date, time and weekday *name*, leaving weekday-to-date conversion as head arithmetic: count forward from today's weekday, carry the month or year boundary, hope. Nothing anywhere downstream could catch a slip, because the calendar accepts any well-formed ISO date — a date a week out is a perfectly valid event, not an error — so the failure was silent and self-confident, and "Wednesday" reliably meant the Wednesday after the one that was asked for. `now` resolves it in the tool now rather than describing it: the payload gains `today` and `tomorrow` as ISO dates, plus an `upcoming` map from every weekday name to its next date, strictly in the future — asked on a Friday, `upcoming.Friday` is next Friday, not today. All three are computed from the same zone-resolved instant the rest of the payload already uses, so the operator's configured timezone decides what "today" is rather than UTC, the same trap #559 closed for calendar read paths. The tool description now tells the model to take `upcoming` verbatim and never to count days itself, and to ask rather than guess when phrasing like "next Monday" is genuinely ambiguous *and* the difference matters. `core-app` 0.108.0→0.109.0 (MINOR). - **Cold-switching documents in the editor could save the outgoing note's content over the newly-opened one — data loss** (#781) — every `EditorView` host (Notes, Knowledge, the chat document pane) shares the same Preview surface: Milkdown's Crepe (#377), deliberately *uncontrolled* after mount — it reads its markdown once and, by design, ignores later prop updates, so a live cursor never fights a reset; the parent instead re-keys the component on the document path to reseed it with fresh content, per its own doc comment. That contract broke on a **cold** switch — opening a document whose content hasn't reached the browser yet. `openDoc` flushed and re-pointed `selectedPath` to the new document immediately, remounting the WYSIWYG under its new key, but the seed that hands it real content only runs once the fetch resolves and the placeholder-data guard (#712) lets it through — so the remount picked up whatever the buffer still held: the *outgoing* document's markdown. Crepe mounted on it while `selectedPath`/`seededPath` had already both moved on to the new document, and the next transaction in that live-but-foreign surface — a click, a keystroke, Crepe's own post-init normalization — reported it back through `onChange` as if it were a genuine edit of the new document. Every save guard then passed honestly: the idle timer, the re-click flush, and the leave/refresh flush each wrote the *old* note's content to the *new* note's path. A warm switch (the document already cached this session) never showed it — the seed lands during the same render, before the remount commits — which is why "click over and back" looked fine and made the bug easy to miss in practice. Fixed with two independent layers. The editor pane now gates on `selectedPath === seededPath`: neither the WYSIWYG nor the raw-source view mounts until the newly-selected document's own content has actually seeded the buffer — a cold switch shows a brief loading state instead of a frozen, still-editable snapshot of the old note; a warm switch stays instant, since the seed already resolves before the gate is checked. As defense in depth, the WYSIWYG now reports the identity of the document it was mounted for (`docKey`) with every change, captured once at mount and never re-read; the parent drops any report whose `docKey` no longer names the live `seededPath`, so a stray write can never land on the wrong document however it arises. The vitest mock for the WYSIWYG was itself part of why this stayed invisible to the suite: it was fully *controlled* (`value={value}`, re-rendering on every prop change), so it could never reproduce a stale mount — it is now `defaultValue`-based and mount-faithful, like the real Crepe. Every save already snapshots a version (ADR-0046); a note clobbered by this bug can be recovered from the previous entry in its History dropdown. `web` 0.134.0→0.134.1 (PATCH). - **The "can't reach epicurus" banner no longer flaps on a single dropped request** (#791) — the banner (`ConnectionBanner`, `src/App.tsx`) used to be one single-strike fetch failure away from covering the shell: any one `TypeError` or gateway 502/504, anywhere in the app, rendered it on the next paint, and any one healthy response cleared it — on a loaded self-hosted box (CPU-only inference, Docker Desktop VM) a transient gateway hiccup reads exactly like an outage, so the banner appeared regularly, lived a few seconds, and vanished, with nothing shown about what actually failed. The banner's own signal (`confirmedDown`, `src/stores/connection.ts`) is now debounced: a first failure only arms a `pendingDown` state, and `useConnectionWatch` immediately fires one confirming re-probe (the same vitals refetch recovery already uses); the banner renders only once that evidence persists past a short grace window (`UNREACHABLE_GRACE_MS`, 5 s) or a second failure confirms it sooner — so an ordinary blip resolves silently and a genuinely stopped stack still surfaces within ~5 s. `coreDown` itself is unchanged (still eager, single-strike) — the composer's Send-gate and the Files/Mail "connection lost" states still refuse instantly rather than wait out a debounce window on an action that would just fail anyway. Every report now also carries its evidence — the failing request's method, path, and failure class (`TypeError`/`502`/`504`) — kept in the store and logged via `console.debug`, and named in the banner's `title` tooltip on hover, so a flap can be attributed without network-tab archaeology. `web` 0.133.0→0.134.0 (MINOR). - **The automations `push` sink is wired up — all ten starter templates now deliver something** (#723) — `push` has been valid sink vocabulary since the automations engine shipped (ADR-0105), but `SinkDispatcher` never had a handler registered for it: a run configured with `sinks=["push"]` executed, recorded a ledger row, and delivered nothing an operator could see — exactly the case every one of the ten starter templates #717 shipped was in. `make_push_sink` closes the gap the same way `document_sinks.py` closed it for `notes`/`kb`: a thin adapter that calls `PushService.notify` under a fixed `"automation"` category (with `automation_id`, so a per-automation override can silence just one without touching the category default), title the automation's own name, body the run's raw output. It goes *through* `notify()`, never around it, so quiet hours, the tenant-wide rate cap, and the push/center toggles all apply exactly as they do for the settings UI's test button or #732's event alerts. `PushService.notify` gains `NotifyResult.notification_id` — the notification-center row's id, set the moment that row is written regardless of what push delivery itself then does — so the sink can record it as an `EntityRef` (`module="core"`, `kind="notification"`) on the run's `artifacts`, the same ledger field the notes/kb sinks already populate; the runs feed renders a chip for it with no further UI work. `PushService._send_now` also gains a 500-character cap on the outgoing push payload's body (an automation's full report should not be able to blow a push service's own size ceiling) — the notification-center row keeps the caller's complete text regardless. `core-app` 0.107.0→0.108.0 (MINOR). - **Nightly reflection can no longer propose edits to the base system prompt** (#762) — the pass (ADR-0093) offered two targets: a named playbook, or `"instructions"` — the base prompt itself. The second target is removed, by design: reflection reads **tainted transcripts** (mail bodies, web results, document text the agent quoted), so outside text could surface as a plausible edit to the very document the agent's rules live in; an instructions proposal was a **full-document replacement** reviewed nightly under approval fatigue, and a poisoned approval would persist in every future turn's system prompt; and the governed system drafting its own governing document is a conflict of interest even with review. Enforced at three layers: the reflection prompt now offers **playbooks only** (the base prompt stays in its context read-only, explicitly non-proposable, so playbooks don't duplicate base rules); `_resolve` drops any `"instructions"` target the model returns anyway (logged); and the review sink refuses to stage a proposal against the instructions path **for every origin**, with the instructions apply-path removed outright — no future proposal source can quietly reintroduce the target. A pending instructions proposal from before the upgrade still renders with its diff and is cleanly rejectable; Approve refuses it with a clear message. Operator editing of the base prompt in Settings is unchanged. Reflection also gains its **own off-switch**, `PLAYBOOK_REFLECTION_ENABLED` (default on, now that it is playbook-only): disabled, the nightly job reports "skipped — disabled" without spending a gateway call — previously the only way to stop it was disabling all nightly maintenance. `core-app` 0.106.0→0.107.0 (MINOR). - **Invisible chats** (#772) — a ghost toggle left of the model chooser starts a **fresh invisible session**: everything works normally while you're in it, and when you leave — toggling off, switching sessions, starting a new chat, or closing the app — the conversation is **deleted, not archived** (the #771 cascade, so nothing survives anywhere). The design is *persist-flagged, then hard-delete*: the session writes normally under a server-side flag (`PUT /agent/sessions/{id}/ephemeral`, table `ephemeral_sessions`), so an accidental mid-chat reload keeps the thread, while the flag hides it from the sessions list and from **every learner** while live — never enqueued for fact extraction (checked at learn time in both extraction modes, failing closed), excluded from nightly reflection's transcript scan (via an `include_ephemeral=False` default on the store's `sessions()`), and from `memory_search`'s past-conversation half. An **orphan sweep** — at startup and on every session-list read (`GET /agent/sessions?active=`) — erases any flagged session a crash left stranded, sparing the client-named live one and any session with a turn in flight. Honest semantics, in the UI's own words: the header reads "Invisible — deleted when you leave", and the footnote states that nothing is learned while tool effects (tasks, mail, files) persist — like downloads from a private browser window; usage metering still records tokens (the events carry no content). Normal chats are byte-identically unaffected. `core-app` 0.105.0→0.106.0 (MINOR), `web` 0.132.1→0.133.0 (MINOR). - **Deleting a chat now deletes the chat — everything it produced, everywhere** (#771) — `DELETE /agent/sessions/{id}` removed only the `agent_messages` rows, so a "deleted" conversation lived on across every sidecar it had touched. Worst: the fact-extraction queue (ADR-0051) carried the exchange *text* with no `session_id`, so a deleted chat's queued exchanges could not even be targeted — the nightly drain still distilled the "deleted" conversation into memory facts that night. Also surviving: the uploaded attachments' bytes (`agent_attachments`), the per-session model override (`session_models`, #707), and any paused runs (`agent_suspended_runs` / `agent_pending_drafts` / `agent_pending_approvals`) — each carrying the conversation verbatim — while an in-flight turn kept generating into the deleted session. Now: the queue is **stamped** with a nullable `session_id` at enqueue (reconciled additively, ADR-0067; legacy rows drain as before), and the route runs a tenant-scoped **cascade** (`SessionDeleteCascade`) — cancel + evict the live run, purge queued extractions, clear the model override, drop the paused runs and the automation badge mapping, delete the attachment bytes, then the messages last (a failure partway leaves the session visible and retriable). The audited referencing stores keep deliberate exceptions: `scheduled_turns` rows are operator-authored standing config (kept — the next delivery starts the session afresh), `notifications` carry no session linkage (kept), and facts distilled on *previous* nights are curated memory managed in the Memory view — the web confirm dialog now states that boundary honestly instead of implying total erasure. `core-app` 0.104.0→0.105.0 (MINOR), `web` 0.132.0→0.132.1 (PATCH — confirm-dialog copy). - **Tags become a first-class part of the tasks board: group by them, and pick them as chips** (#763) — the model and tools carried `tags` all along, but the UX was half-wired: a bare comma-separated text input and no grouping. **Group by → Tags** joins the board's dimensions, offered (like *List*) only when a visible task actually has a tag — the first **multi-membership** grouping: a task appears under **each** of its tags, untagged tasks land in an **Untagged** column, and columns sort alphabetically (case-insensitive, Untagged last, stable across reloads). The add/edit forms' tags field becomes a **chips input** — "a box inside a box": removable chips inside the field, Enter/comma commits, backspace erases into the chips — via a new `format: "tags"` hint (the ADR-0082 seam), with a typeahead over the module's distinct tags supplied as **`field_suggestions`** (a new open-valued `field_choices` sibling: suggestions are offered, never enforced, so a new tag is created by typing it). Serialization stays the tool's comma-separated string — the MCP contract is unchanged. **Google honesty**: tags are local-only (Google Tasks has no such field; the provider ignores them on write), so the field is hidden wherever the write would land on Google — a Google task's Edit form, and Add forms whenever the (external-only) list picker shows — never a silent no-op; Google tasks group under Untagged. Drag-and-drop stays honest too: list-grouped columns now carry their **`list_id`** and the shell matches drop targets by id — never by a display title a tag column might share — and with no list columns on screen, cards don't offer a grab that could only dead-end (drag-to-retag deliberately not implemented in v1). `tasks` 0.20.0→0.21.0 (MINOR) · `web` 0.131.0→0.132.0 (MINOR). - **The Tasks page gets three representations of the same data — Board, List, and Calendar — switchable in place** (#767). The `board` archetype gains a **reserved `view` control** (an extension of ADR-0049's declarative controls): a board page declaring `{id: "view"}` opts into the shell's client-side representations, rendered via the standard segmented view switcher. **List** flattens the columns to one deduped row set — title, due, priority, list, tag chips, the same per-card actions — with every column client-side sortable (stable, missing values last); **Calendar** is a month grid (Monday-start, the calendar archetype's geometry) placing each card on its due date, with prev/today/next paging, chips toned by the board's overdue/today language, and a chip opening the ordinary card + actions in a sheet (no new mutation paths anywhere). To support this, board cards additionally carry their **structured fields** — `due` (bare ISO date), `priority`, `tags`, `list_title` — as data beside the rendered badges (additive; a board that sets none renders as before). The chosen view **persists per page** (localStorage, the #743 pattern), a `?view=` deep-link wins over the stored choice (and is kept in sync on switch), junk clamps to the kanban, and a board with no `view` control ignores the param. The tasks module declares the control (Board / List / Calendar), echoes the clamped `?view=`, and omits *Group by* off the Board view — grouping shapes kanban columns; the flat/date-keyed views would render it as a dead knob — while *Show* applies under every view; the payload is identical across views. Undated tasks never appear in any of the three — they live in the Can (#766). The calendar *page's* cross-module task overlay (#469) is untouched. `BoardView` is now keyed per page in `ModulePageScreen`, so tasks' two board pages can't leak control state onto each other. Day-cell quick-add is deferred. `tasks` 0.19.0→0.20.0 (MINOR) · `web` 0.130.0→0.131.0 (MINOR). - **The Can: undated tasks get their own backlog page, and the board shows only what's scheduled** (#766) — undated tasks used to land on the Tasks board in a "No date" bucket under the due grouping and inside every other grouping's columns, so the backlog and the plan shared one surface. The tasks module now declares a second left-nav `board` page, **Can** (`/m/tasks/can`): one flat **Backlog** column holding every task without a due date, across the same enabled lists the board aggregates (category badges preserved). The board and the Can partition the same fetch by `due` alone — the board excludes undated tasks under **every** grouping (the "No date" bucket is gone). Each Can card leads with a one-tap **Schedule** action (a due-only `tasks_update` form prefilled to today, rendered as the shell's native date picker via a new `format: "date"` hint on both tools' `due` params — the ADR-0082 format seam, no new web code); clearing a due date moves a task back to the Can. The Can's Add offers no due or repeat field, and its only view control is *Show*, so completed backlog items stay reachable. Nothing vanishes silently: `tasks_add`'s description and both `due` param descriptions (which double as the web form's field hints) say an undated task files into the Can, and a task's hover-card `href` now targets the page it actually lives on. Purely a read-partition — no provider or DB change; lead-time notifications are untouched (they key on due dates, which Can items don't have — by design). `tasks` 0.18.0→0.19.0 (MINOR); no web change — the shell already renders manifest pages, form actions, and the date field. - **Approving a delete suggestion no longer reports success when the file is already gone** (#761) — `SuggestionReview.approve`'s `delete` branch caught the file API's 404 (the vault document had already been removed some other way) and swallowed it outright, so both callers landed on ordinary success: the operator's approval in the Suggestions UI closed silently, and review-off auto-apply printed "Delete of '' applied directly — review is off" even though nothing was deleted. The suggestion is still fully resolved either way — index cleanup runs, the audit row is recorded, the row drops from the queue, exactly as a real delete — a stale suggestion must not be left un-resolvable just because there's nothing left to remove. But `approve` now raises afterward instead of returning a false "approved": a 404 naming the path and suggesting a next step (the #742 not-found bar: `"" does not exist — it may have been deleted or moved; list the folder for current contents`), so the Suggestions UI shows an error instead of a silent close, and the review-off tool call fails structurally instead of claiming the delete happened. `knowledge` 0.27.1→0.27.2 (PATCH). - **A knowledge indexer test no longer fails one run in 256** (#769) — `test_indexer`'s `test_mtime_ns_reconciles_from_a_raw_seeded_value` opened by asserting that a raw `st_mtime_ns` and the indexer's derived `round(mtime * 1_000_000_000)` must differ, i.e. that the `int → float → int` round-trip is always lossy. It is not: a float64's ULP near `1.8e18` (the epoch in nanoseconds, as of 2026) is exactly 256, so any mtime landing on a multiple of 256 round-trips unchanged and the two agree — about 1 run in 256, and only where stamps carry nanosecond granularity (ext4/CI; NTFS's 100ns ticks hide it entirely). Because `quality` is a required check, an unlucky run blocked unrelated PRs — it was hit twice during the 2026-07-29 train, once on an npm-only change, which is what identified it as ambient rather than caused. The scenario needs a ledger row whose `mtime_ns` *disagrees* with the derived value; how the disagreement arose is immaterial, so the fixture now supplies one directly instead of relying on float imprecision. Every real assertion in the test is untouched. `knowledge` 0.27.0→0.27.1 (PATCH). - **The docs link checker now validates anchors in source references, not just the file** (#725) — tier 2 of `scripts/check_docs_links.py` scans shipped source for repo-relative `docs/….md` strings and asserted only that the file exists, so a comment pointing at `docs/foo.md#some-section` kept passing after the heading it named was renamed or removed — exactly the rot the checker exists to catch, and it fails silently: the reader follows the link and lands at the top of the page with nothing reported anywhere. Anchored references now resolve against the target file's headings, the same way tier 1 already validates internal doc links. `_anchors_in()` additionally recognizes explicit `{#custom-anchor}` markdown syntax alongside the auto-generated slug, so a heading that pins its own anchor is accepted rather than reported broken. Tooling only — no package version bump (ADR-0017 scopes SemVer to `services/*` and `libs/epicurus-core`). - **Vision models are no longer mistagged as code models in the model catalog** (#727) — `_derive_tags` matched the bare substring `cod(?:e|er|ing)` against each catalog entry's name and description, and "encoder" contains "code": llava, whose blurb describes a *vision encoder*, came back tagged `code`, so the Models page filed it under coding models and the `code` filter offered a model that cannot write code. The pattern is now word-bounded. Its sibling `multiling` keeps a **leading** boundary only, deliberately — it is a prefix that must still match "multilingual"/"multilinguality", and bounding both ends silently dropped gemma3:1b's tag, which `test_parse_derives_tags` caught on the first push. Ships inside `core-app` 0.104.0. - **First boot pulls the default models instead of 404ing until someone finds the Models page** (#773, ADR-0118) — models are never baked into the Ollama image (the core owns the model lifecycle, constraint #8, ADR-0010), so a fresh install booted an empty volume and the first chat or embedding call failed with `model not found`, while the knowledge indexer failed noisily in the background — verified on a from-scratch deployment. The core now closes that gap itself: a fire-and-forget lifespan task waits for the runtime, resolves the deployment's *effective* chat + embedding defaults (stored prefs, else `LLM_DEFAULT_MODEL` / `MEMORY_EMBED_MODEL`), skips hosted-prefixed ids, and pulls whichever are missing through the same `gateway.pull()` the Models page uses — then applies the same post-pull context sizing (#386), so a first-boot model opens correctly sized too. It never blocks startup, readiness, or a live turn; per-model retries back off exponentially (Ollama resumes partial downloads) and exhaustion is a warning naming the Models page, never a crash. An unreachable runtime — a hosted-only deployment running no Ollama at all — costs one warning after a bounded wait. `LLM_BOOTSTRAP_MODELS` tunes it: `auto` (default), blank to disable, or an explicit comma-separated list; the CI smoke override pins it blank, since the gate asserts the wiring, not the weights. `core-app` 0.103.0→0.104.0 (MINOR). - **Observability no longer mounts the raw Docker socket** (#724, #726, ADR-0109) — Prometheus and Alloy each bind-mounted `/var/run/docker.sock` for container service discovery and log tailing, and Prometheus additionally ran as `user: root` purely to read it — full Docker API access, write included, for two components that only ever read. Both now reach Docker through `docker-proxy-observability`, a third read-only `wollomatic/socket-proxy` sibling of `docker-proxy-core` and `docker-proxy-traefik`, gated behind the same `observability` compose profile so it exists only when that stack is up; Prometheus's `user: root` override is gone along with the mount. The allowlist is GET/HEAD-only and was derived from a live boot rather than copied from `docker-proxy-traefik` — both consumers use Prometheus's `discovery/moby` library, which issues a `HEAD /_ping` probe Traefik never sends and calls `GET /networks` to resolve each container's network labels, and omitting either makes discovery error outright rather than merely degrade a label; Alloy's `loki.source.docker` separately needs `GET /containers/{id}/logs` to tail at all, whose absence fails silently. New `tests/test_docker_proxy_allowlist.py` renders the real `docker compose config` and pins all three proxies' allowlists by equality, so widening one fails as loudly as breaking one — it also asserts the socket reaches nothing but the proxies (scanned across every rendered service, not a named few) and that the hardening flags (`cap_drop: [ALL]`, `read_only`, `no-new-privileges`) stay put. Infra, docs, and a repo-root test only — no service or library version bump. ### Added - **Inbox category tabs on the mail page** (#765) — Gmail's Primary / Promotions / Social / Updates tabs, in our mailbox: a strip over the Inbox list, each tab carrying its unread count and a dim one-line preview of its newest message, and Forums appearing only when it has mail (exactly as Gmail hides an empty one). Selecting a tab filters the thread list through the same label/query mechanism the rail and search already use, so **Primary correctly means inbox-minus-categorized**. Gmail's `CATEGORY_*` labels stay out of the folder rail where ADR-0087 put them — a tab over one folder is a different UI element, and this is that element. The `MailProvider` seam gains two **concrete, capability-gated** members — `list_categories` (the tabs, as presentation-ready data) and `category_query` (the single point where a neutral tab id becomes provider query syntax) — so a provider that doesn't classify mail inherits the no-op defaults and the page renders exactly as it did before. The strip is drawn entirely by the shell from module *data* (ADR-0018): no markup leaves the module, and the neutral tab ids are the local-first seam — a future local mail provider can back the same tabs by classifying through the core's LLM gateway (constraint #8) with zero shell change. Assembling the tabs is a provider fan-out, so they are cached in a new tenant-scoped `mail_category` table on a short TTL (`MAIL_CATEGORY_TTL_S`, default 60s, with a negative-cache row so an *uncategorized* mailbox isn't the expensive case); a mark-read drops that cache, so the active tab's count converges through the shell's existing invalidation with no full reload. `mail_search` gains a matching `category` argument, so "summarize today's Promotions" works in chat without the model knowing Gmail query syntax. `mail` 0.16.0→0.17.0 (MINOR) · `web` 0.129.0→0.130.0 (MINOR). - **External file mounts** (#731) — add a directory to Files the way you'd add a drive: an operator binds a host folder (or a whole drive) into `core-app` via a compose overlay (`services/core-app/compose.external-mounts.yaml`, `task external-mounts-up` — never a default bind, never auto-loaded), and it shows up as an additional top-level root on the Files page and in the agent's `storage_list`/`storage_search`/`storage_read` tools, addressed as `mount:/`. Read-only by default; a declared `rw` mount is writable through the same operations the tenant space has (upload/write/delete/move/mkdir) — enforced **server-side** (403), not just hidden in the UI. Traversal and symlink-escape attempts are rejected (400) via `PathEscapeError`, a new `epicurus_core.files` exception type an app-level handler catches uniformly. Indexing/watching is **opt-in per mount** (`FILES_EXTERNAL_MOUNTS_INDEXED`, with exclude globs) — a whole-drive mount never auto-indexes by default. `LocalFileStore` gained a `tenant_subdir` flag so a mount can address its own root directly rather than nesting a `/` segment inside the operator's drive. A move never crosses between the tenant space and a mount, or between two mounts — mounts are isolated compartments. `epicurus-core` 0.32.0→0.33.0 (MINOR) · `core-app` 0.102.0→0.103.0 (MINOR) · `web` 0.128.0→0.129.0 (MINOR). - **Per-event alerts** (#732) — "push me when X happens" for any module-declared event, no automation required. A tenant-scoped `(module, event_type) -> ChannelPrefs` subscription store, off by default, backs a new `EventAlertListener` wired beside the automations engine at the same event-intake seam — a dumb fan-out (no agent turn, no ledger, no autonomy dial), not an automation. `PushService.notify_effective` delivers through the existing center/quiet-hours/rate-cap send path for a caller whose channel prefs don't come from a category; a second, per-subscription rate cap keeps one chatty event type from spending a tenant's whole push budget. An automation triggered by the same event still fires independently — two notifications, by design. Settings gains an "Event alerts" block, grouped by module with a Custom section for a free-typed `(module, event_type)` pair. `core-app` 0.96.0→0.97.0 (MINOR) · `web` 0.125.1→0.126.0 (MINOR). ADR-0114. - **Persisted maintenance-run history** (#733) — the Maintenance orchestrator's `last_run` was a single in-memory slot, gone on restart, with no record of whether a run was scheduled or manually triggered. A new tenant-scoped `maintenance_runs` table is the durable counterpart, written via an `on_recorded` hook (mirroring `AutomationRunner`'s live-runs-feed pattern) on every completion — including a shutdown-interrupted batch, persisted from `shutdown()` itself since the driver's own `except CancelledError` can't safely await again once caught. Every run now carries a `source` (`scheduled` | `manual`). `GET /platform/v1/maintenance`'s `last_run` reads the store (survives a restart); a new paginated `GET /platform/v1/maintenance/runs` pages back through it, newest-first; retention prunes past a row cap or age cutoff as its own nightly-eligible maintenance job. Settings' Maintenance card gains a "Run history" list — time, source, duration, per-job status chips, expandable detail, "Load more" pagination. `core-app` 0.97.0→0.98.0 (MINOR) · `web` 0.126.0→0.127.0 (MINOR). ADR-0116 (amends ADR-0060). - **`ask_approval` — pause a turn for an inline Approve/Reject of a staged change** (#745) — a third sibling of `ask_user` (ADR-0053) and draft-first send (ADR-0085): after the model stages a change through an existing propose tool, it can call `ask_approval(summary, refs)` to pause in place rather than telling the user to go check Suggestions separately. The chat UI renders an inline card (summary + a chip per staged entity); Approve/Reject each call the linked change's owning module's *own* review-decision endpoint directly — the operator's own click, never the agent, preserving `suggestions.py`'s existing "the agent must never approve its own work" boundary — then resume the turn with the outcome. Chat-only by construction: the tool is spliced only into the interactive streaming path, which has no `automation_id` concept at all, so it is structurally absent from every automation/scheduled/inbound-bridge turn regardless of autonomy level — those keep today's async review-queue behavior unchanged, no new toggle needed. A new `agent_pending_approvals` table (a third sibling of `agent_suspended_runs`/`agent_pending_drafts`) holds the pause; it decays to the same async queue on expiry (`ASK_APPROVAL_TTL_HOURS`, default 24h) — nothing lost either way. `core-app` 0.100.0→0.101.0 (MINOR) · `web` 0.127.0→0.128.0 (MINOR). ADR-0117 (extends ADR-0053, ADR-0033, ADR-0085). - **Knowledge and Notes remember where you left off** (#743) — opening the page always landed on the module's default project, with every folder collapsed back to its initial state and no document open, no matter where you'd actually been working: the active scope, fold state, and open document were all plain component state, hydrated from nothing on every mount. All three now persist to `localStorage` — the scope choice keyed by `(module, pageId)`, fold state and selection by `(module, pageId, scope)` since tree paths are scope-relative (restoring one project's folds onto another would be worse than restoring nothing). Reopening the page — navigating away and back, switching projects and back within one visit, or a full reload — restores scope, then folds, then the open document, in that order; the document lands in preview per #729, same as any other open. An explicit `?doc=` deep-link or a host-provided document (#541) still always wins over restored state — they set the selection during render, before the restore effect (which only ever fills an untouched slot) gets a chance to run. Decay is graceful: a restored scope that no longer exists falls back to the module's own default (which re-triggers the same restore for whatever that default resolves to); a restored document that's gone is simply never selected, so it never gets a doc-fetch to 404 on — no error flash, just the ordinary empty-state prompt. A real bug surfaced while testing this against a genuine remount (not just a scope switch within one mount): the write-back effect was gated only on `scope` being known, not on the restore having actually run for it yet, so on a fresh mount it fired one render early with the plain in-memory defaults and clobbered the very state the restore was about to read — fixed by gating the write on the same "restored for this scope" marker the restore effect itself sets. Scroll position and in-document read-position are out of scope. `web` 0.124.0→0.125.0 (MINOR). - **Move Knowledge documents by drag-and-drop, and an honest actions menu** (#741) — there was no way to move a document to another folder (or back to the root) at all, and the row's "three dots" — the icon that universally means "more actions" — just deleted the file; the real rename affordance was a bare `ChevronRight`, easy to mistake for navigation. The move machinery already existed (`moveItem`, which rename already used) — the gap was purely presentational. File rows are now **drag-and-drop movable**: drop one on a folder row to move it in, or on the tree's own background to move it to the top level; a collapsed folder auto-expands the moment a drag hovers over it, and Escape (or dropping outside any target) cancels for free — the browser fires `dragend` regardless of how the drag ended. The three old hover icons are replaced by one honest **⋯ actions menu**: Rename, Move to… (a compact folder picker), New file in folder (folders only), and Delete — still gated by the same themed confirm (#488), no longer the bare ⋯ icon's own click action. Moving the currently open document keeps it open at its new path (no dead pane, no 404 fetch) — the existing `moveItem.onSuccess` already handled this for rename, unchanged. Both Move to… and drag-and-drop reuse the not-yet-saved guard #740 added for rename: moving an unsaved document swaps its path locally instead of a server call that would 404. Drag-and-drop is desktop-only by design (native HTML5 DnD doesn't fire from touch input), so the menu is what covers mobile and touch. Folder-to-folder moves are out of scope for this pass — files only. Notes is untouched: `can_manage_files` stays false there, so it shows no hover actions, same as always. `web` 0.123.1→0.124.0 (MINOR). - **Resizable tree panel for Knowledge and Notes** (#730) — the editor archetype's document list fixed the tree column at 18rem, so long titles truncated on a narrow tree and a wide screen couldn't give the tree more room. A drag handle now sits between the tree and the editor: pointer-drag resizes live (clamped to 12rem–40rem), double-click resets to the 18rem default, and the handle is keyboard-accessible (`role="separator"`, arrow keys nudge the width) rather than mouse-only. The width persists per `(module, pageId)` in `localStorage` (`editor-tree-width:/`) — the same pattern CalendarView already uses for its own view state — so Knowledge and Notes remember independent preferences. Below the `sm` breakpoint the layout is unchanged (a single stacked pane, so there's nothing to divide); the fixed-width grid column becomes a CSS custom property (`--tree-w`) read only by the `sm:` grid-template, so the responsive stack is undisturbed by the same inline style always being present. One archetype change covers both pages (Notes and Knowledge share the `editor` archetype, ADR-0018) — a shared `ResizableSplit` component is deliberately not extracted yet; that's for if/when a second consumer (e.g. the Files browser) actually needs one. `web` 0.122.1→0.123.0 (MINOR). - **Knowledge/notes: suggestion lifecycle tools** (#744) — the agent could only *add* to its review queue; "change that suggestion" had no better option than proposing a near-duplicate, and the agent had no way to see what was already pending. Four new tools per module — `{knowledge,notes}_list_suggestions` (the pending queue: id, kind, target, a content preview), `_read_suggestion` (one suggestion's full proposed content), `_update_suggestion` (revise content/note/target in place — stays pending, `proposed-at` refreshes, one queue entry never two), `_withdraw_suggestion` (retract a pending suggestion, kept as history) — let it check first and revise instead of duplicating. Update/withdraw stay strictly inside the pending queue and never touch the vault/note, so they're safe to expose even though approve/reject correctly stay off the MCP surface (letting the agent approve its own proposals would defeat the review gate, ADR-0033). A refusal on an already-resolved or unknown suggestion raises with a message naming *why* (already approved/rejected/withdrawn, or unknown outright) rather than a bare 404 the model can't act on (#697 precedent). Notes' `notes_read_suggestion` is a deliberate, narrow exception to "notes are private, no read tool" — it echoes back only the agent's own draft, never a note's stored body. Every existing propose-shaped tool in both modules now nudges the agent to check the pending queue first. `knowledge` 0.26.0→0.27.0 (MINOR) · `notes` 0.11.0→0.12.0 (MINOR). - **Per-saved-model capability overrides** (#711) — a saved hosted model can now carry the operator's correction to what the core *believes* it can do. LiteLLM's static cost map is the only source for a hosted model's vision support and context length, and it is missing ids entirely (`grok/grok-latest`, which resolves to an unmapped `xai/grok-latest`) and mislabels others; the consequence was concrete — `supports_vision()` resolved `False` and the image gate (#633) refused image turns for a genuinely vision-capable model, leaving "rename your model to a mapped id" as the workaround. The override is consulted **before** the map in both `supports_vision()` and `_hosted_details`, and applies even when the map lookup fails outright — an unmapped id is precisely the case it exists for, so that path must not be the one that skips it. `vision: auto` (the default) with no `context_length` is today's behaviour exactly, so an absent override changes nothing. New `PUT /platform/v1/llm/saved-models/capabilities` (404 for an unsaved id — an override is a property of a saved row, never a way to create one); `GET /saved-models` rows now carry both the **resolved** capabilities and the raw `override`, so the editor round-trips without a client-side merge. The Models page's hosted-model sheet gains the controls, and the saved rows finally render capability badges (they never did) — driven by the resolved answer, so an override shows the moment it's saved. Two new columns on `saved_models`, reconciled additively (ADR-0067). Gating and display only: routing, keys, and metering are untouched (constraint #8). The `get_model_info`/`supports_vision` lookup misses now warn **once per model id per process** and debug thereafter — an operator-saved alias outside a curated static map is expected, not anomalous. `core-app` 0.92.1→0.93.0 (MINOR) · `web` 0.119.0→0.120.0 (MINOR). ### Fixed - **The OpenBao app token no longer silently expires 32 days after bootstrap** (#728) — the bootstrap minted the token with `-explicit-max-ttl=0` and called it "non-expiring". That flag only removes the *explicit cap*: the result was a plain service token falling back to the system default lease TTL of 768h, and nothing ever renewed it. Every self-hosted deployment therefore lost **all** secret reads exactly one month after bootstrap — provider keys, OAuth clients and tokens, module config, bridge credentials, push VAPID keys — presenting as a per-path `permission denied` that reads like a policy bug (and, after a restart, as `OpenBao client is not authenticated`, with core-app refusing to start). No test shape catches this: CI's vault is minutes old, so the defense has to be design. The bootstrap now mints a **periodic orphan** token (`-orphan -period=768h`) — the only non-root token with an unlimited lifetime — and `SecretStore` grows the maintenance to match: `renew_self()`, a `run_token_renewal()` loop core-app starts from its lifespan (daily against a 768h period, so renewal can fail for weeks before it becomes an outage), and a **403 self-heal** that drops the cached client, re-authenticates, and retries the operation once. Because the token is re-resolved on rebuild, a rotated `OPENBAO_TOKEN_FILE` now takes effect without a process bounce. Renewal failures are bounded warnings (the first, then every 7th) with a recovery line, never a crash; a non-renewable dev root token is detected with a lookup-self and the loop exits quietly. A 403 that survives the retry now names token expiry as the likely cause instead of reporting a bare "permission denied". `messaging` holds the same token, so the core's single renewer covers it too. **Existing deployments must still mint a periodic replacement by hand** — this prevents future expiry, it cannot revive an already-expired token; the migration is in [docs/infrastructure/secrets.md](docs/infrastructure/secrets.md), and the symptom now has a runbook entry in `startup-and-recovery.md`. Also repairs a drift the release process missed: `epicurus_core.__version__` still read `0.30.0` while the package published `0.31.0` — a test now pins the two together. `epicurus-core` 0.31.0→0.32.0 (MINOR) · `core-app` 0.101.0→0.102.0 (MINOR). - **The agent now verifies a stale-memory entity before mutating it, and recovers instead of blind-retrying a not-found** (#742) — reported from a real trace: create a file via chat, delete it in the Files UI, come back to the same chat and ask to move it — the agent acted on the path it remembered from earlier in the conversation and failed on a nonexistent file. `DEFAULT_AGENT_INSTRUCTIONS`'s "Doing things" paragraph gains two rules: **verify before mutating** — something known only from earlier turns, not a tool result just now, gets re-checked (list/stat/read/search) before a mutating call relies on it; **recover on not-found** — when a tool reports something missing or different from expected, re-ground with a list/search and report reality rather than retrying the same call. Both are prompt text, so an operator who has already replaced the default doesn't inherit them automatically — a docs note covers porting them into a custom prompt. Alongside: `files_routes.py`'s `read`/`stat`/`move` 404s now name the path and suggest a recovery step (`"" does not exist — it may have been deleted or moved; list the folder for current contents`) instead of a bare `"not found"` — the platform file API's own contract, hardened independently of which specific tool a caller used. `core-app` 0.99.0→0.100.0 (MINOR — a behavior-visible instructions change, the #704 precedent). - **PDF attachments reach the model as real text, not mojibake** (#738) — the attachment expander decoded every non-image file as UTF-8, so a PDF arrived as `[file: report.pdf]` followed by thousands of replacement characters — the same noise images used to produce (#633), just mistaken for text instead of flagged as binary. A new `document_extraction.py` seam (`pypdf`, pure-Python, no system deps) reads a PDF's real text layer instead, page by page (`[page N]` markers), bounded to 20k characters with a truncation note. An encrypted (beyond an empty/owner-only password) or scanned (image-only) PDF renders an honest metadata block — `[file: report.pdf — PDF, 12 pages, no extractable text]` — never mojibake, and the attachment is never silently dropped either. The same honest-block treatment now catches any *other* file whose UTF-8 decode turns out mostly replacement characters (a zip renamed `.bin`) — no format gets to pretend binary is text. Out of scope, deliberately: OCR, docx/pptx extractors (the seam is built to make adding one trivial, not built yet), and native PDF pass-through for a hosted model that accepts documents directly. `core-app` 0.98.0→0.99.0 (MINOR — a new `pypdf` dependency; no web change). - **A flaky `AutomationsScreen` test under full-suite CPU contention** (#758) — `"toggles an automation without a reload"` reached for a `getByRole("switch", …)` query as its very first assertion after render, with no cheaper checkpoint first; a role query computes accessible names over the whole tree and is measurably pricier than a text match, so it timed out twice in full-suite (`npx vitest run`, no file filter) background runs while every sibling test in the file either settles on plain text first or (in one case) already follows that exact pattern. It always passed cleanly in isolation — a timing symptom of many parallel worker processes contending for CPU, not a logic bug. Fixed the test to check text first, matching its own sibling, and raised testing-library's global `asyncUtilTimeout` from the 1000ms default to 3000ms in the shared test setup — the more general fix for the same class of contention-induced timeout anywhere else in the suite, while still failing a genuinely-hung query promptly. `web` 0.125.0→0.125.1 (PATCH). - **Editor: Knowledge's "New document" can now be named at creation** (#740) — it used to materialize as `new-note.md` (then `new-note-2.md`, …) with no chance to name it: the `can_create` flow (Notes) already prompted for a name and slugified it, but Knowledge's `can_manage_files` doors — the root "New document" and the in-folder create — hardcoded the slug instead, and rename couldn't rescue it because an unsaved doc isn't in the tree yet (a `moveItem` call against it would 404 before the first save). Both Knowledge doors now open the same naming step Notes has: the root door reuses its toolbar form, and the in-folder door gets a new inline row right in the tree at the folder you clicked, rather than a form that pops up disconnected from it in the toolbar. A single shared submit now backs all three doors (`can_create`'s form and both `can_manage_files` doors), slugifying the name and seeding the same `# ` heading (Knowledge's slugs keep the `.md` extension its file store has always used; Notes' never have had one) — and lands in preview per #729, same as any other door. Rename is now safe for an unsaved document too: the shell shows a just-created, not-yet-saved doc in the tree (Knowledge only — Notes never exposes tree actions) precisely so its hover rename/delete are reachable, and both are guarded to act **locally** — swap the slug, abandon the draft — instead of firing a server call that would 404; once the first save lands, both fall through to the existing server-backed behavior unchanged. Incidental fix while touching the shared slug helper: `uniqueSlug`'s collision suffix now lands before a `.md` extension (`name-2.md`) instead of after it (the pre-existing, never-before-exercised `name.md-2`). `web` 0.123.0→0.123.1 (PATCH). - **Editor: create lands in preview — render-first now applies to creation, not just opening** (#729) — creating a document (Knowledge or Notes) opened it in **edit** mode, while opening an *existing* document landed in **preview** (render-first, ADR-0042); create was the one door that showed a raw, unrendered buffer where every other door showed the rendered result immediately. The archetype flipped to preview only on the doc-fetch seeding path, and all three create doors explicitly set edit mode (and skip that fetch via `isNew`, so the flip never ran): the plain "New document", the named "New note" (Notes' `can_create` flow), and "New file in folder" — the command palette's `?new=1` deep-link funnels into the same doors. All three now land in preview like every other open; Edit stays one click away on the mode toggle. The two doors that seeded an empty buffer ("New document", "New file in folder") now seed a `# Untitled` heading — matching the Notes service's own "no heading found" fallback (`derive_title`) — so the first preview never renders a blank pane; the named door already seeded `# ` and keeps doing so. One archetype change covers both pages (Notes and Knowledge share the `editor` archetype, ADR-0018). `web` 0.122.0→0.122.1 (PATCH). - **KV-cache fallback message: a staged choice needs a restart, not an environment edit** (#709) — `apply_kv_cache_type` has two distinct degraded modes and the API collapsed both into `applied: false`, so the Models page always showed the scarier, mostly-wrong instruction ("set `OLLAMA_KV_CACHE_TYPE` … in your environment"). In the common case — the env file was written and only Docker access is missing — that instruction is busywork: Ollama's entrypoint re-sources `/etc/epicurus/ollama.env` on every start, so a plain container restart applies the already-staged choice. The prefs route now returns `staged` beside `applied` (`applied` ⇒ `staged`; a `KvCacheApplyResult` replaces the bare bool), and the UI branches on it: staged → "restart the Ollama container to apply … `docker compose restart ollama`"; not staged (the env file could not be written at all) → today's environment-variable text. The clear-to-default path stages identically — a successful unlink *is* the choice on disk. A core predating this reports no `staged`, which defaults to `false`, so an older backend keeps the old copy rather than promising a restart that wouldn't help. `core-app` 0.93.0→0.93.1 (PATCH) · `web` 0.120.0→0.120.1 (PATCH). - **Model catalog: re-anchored on the library's current markup** (#710) — the public model library dropped the `x-test-*` attributes the parser keyed on, so every refresh parsed to `[]`, the box served the seed indefinitely, and a `model catalog refresh failed` warning was written every `refresh_seconds` for weeks. The parser now keys on the page's **structure and user-visible copy** — the per-model `/library/` link, the rounded-badge idiom, and the word "Pulls" — and classifies each chip by its *text* rather than by the Tailwind colour that distinguishes capabilities from sizes, so a restyle degrades instead of silently mislabelling. Three regressions fall out of the live data: mixture-of-experts (`8x7b`, `128x17b`) and "effective" (`e2b`) size labels are now expanded into pullable entries instead of dropped; comma-grouped pull counts (`8,171`) rank correctly instead of scoring 0; and a blurb that merely *mentions* "updated"/"tags"/"pulls" is no longer blanked (the stats line is told apart structurally now — this affected 7 of the library's 233 families, `mistral` among them). Repeated failures are **bounded**: the first failure of a streak warns, a *changed* error warns again, the rest drop to debug, and the recovery reports how long the outage ran. `tests/fixtures/ollama-library.html` and `ollama-tags-*.html` are trimmed verbatim captures (2026-07-25) that pin the real markup, so the next redesign fails a test instead of the box. The tags-page selectors (#571, #330) were verified against the same redesign and needed no change — they key on the `/library/:` link, which survived. `core-app` 0.92.0→0.92.1 (PATCH). ### Changed - **Agent grounding: local sources first, then the web — never an unsourced guess** (#703) — the shipped default instructions (#497) gain a "Finding answers" source ladder: the operator's module data (knowledge base, notes, calendar, tasks, mail, files) first, then `web_search` when local sources come up empty or a fact may have changed since training, and an explicit never-guess rule (answer from a source, or say plainly nothing was found). `web_search`'s tool description now says *when* to reach for it, not just what it does. Tenants with a customized prompt keep their text — the paragraph can be adopted via Settings → Assistant instructions. `core-app` 0.91.0→0.92.0 (MINOR) · `websearch` 0.2.0→0.2.1 (PATCH). ### Added - **`set_chat_model`: switch a conversation's model by asking** (#707) — "answer with grok from now on" now works. The new core tool resolves the request against exactly what the web's model picker offers — installed local models and the tenant's saved hosted models — case-insensitively, exact match first then a substring match but only when it's unique; an unknown or ambiguous name changes nothing and returns the available list rather than guess. The choice persists to a new `session_models` sidecar table (there is no other "session row" — a session is derived from `agent_messages` via `GROUP BY`) and survives a reload; `GET /sessions` reads it back for the picker, and an explicit picker change writes the *same* field via a new `PUT /sessions/{id}/model` (`model: null` clears it back to the device default) — one owner of truth, so a tool switch and a manual pick can never fight. The **server** resolves that row at turn time (`/chat`, `/chat/stream`, `/regenerate`, `/edit`), so `model` on a request is the caller's *default* and the conversation's own choice wins whenever it has one. Resolving it client-side would have left a window: a turn sent between the tool writing the row and the client's cached sessions list refreshing would have run the old model, silently — the agent says it switched and the next answer proves otherwise. A store hiccup degrades to the caller's default rather than costing the turn. Classified `write`, following the ordinary autonomy dial like `remember`/`ask_user` rather than a bespoke automation exclusion; it's naturally inert for an automation with no chat sink (no session to apply it to). `core-app` 0.95.0→0.96.0 (MINOR) · `web` 0.121.0→0.122.0 (MINOR). ADR-0111. - **Least-privilege Docker control, on by default** (#708) — a KV-cache change now applies immediately and a removed module's container is torn down at once, out of the box, with no operator setup. `core-app` reaches Docker through a new `docker-proxy-core` allowlist proxy (`wollomatic/socket-proxy`) instead of a raw socket — scoped to exactly the calls `DockerController` makes (list/inspect a container, stop/restart/remove one by id), refusing exec/create/attach/images/volumes/networks/system regardless of who asks. The edge gateway gets its own read-only sibling, `docker-proxy-traefik` (list/inspect/events/version, no write verb at all), replacing its previous raw `:ro` socket mount — implementing the socket-proxy option #656 proposed (final posture confirmed at review). The pre-existing raw-socket overlay (`services/core-app/compose.docker-socket.yaml`) remains as a documented escape hatch for an operator who'd rather core-app hold the socket directly. `core-app` 0.94.0→0.95.0 (MINOR). - **Agent-gated delivery: a `quiet` run outcome the model opts into** (#706) — a per-automation "agent decides delivery" toggle (`agent_gated_delivery`, off by default) offers the run's turn a run-scoped `finish_quiet(reason)` tool, bound at the tool surface — spliced into `Agent._loop` only when both `automation_id` and the toggle are set, never a global built-in, so it can never reach an ordinary chat turn. Calling it marks the run outcome `quiet` and skips the push/notes/kb sink fan-out; not calling it delivers exactly as before (fail-loud beats fail-silent). The ledger still always records the run, with the model's own reason (`AutomationRun.quiet_reason`); `chat` is deliberately exempted (it is turn-time — ADR-0108 — and rolling continuity needs the next run to see this one's reply regardless). The Automations editor gains the toggle; the runs feed and the per-automation history badge `quiet` outcomes distinctly with their reason. `core-app` 0.93.1→0.94.0 (MINOR) · `web` 0.120.1→0.121.0 (MINOR). ADR-0110. - **Propose-autonomy automations can now actually reach mail's compose tools and knowledge/ notes' suggestion tools** (#721) — completes the sweep #705 started: `mail_send`/ `mail_reply`, knowledge's six `knowledge_propose_*`/`knowledge_create_document`, and notes' four propose-shaped tools now declare `side_effect="propose"`. Previously unannotated (defaulting to `write`), a `propose`-autonomy automation had zero usable tools on any of these three modules — the tier was structurally dead for them. Knowledge's and notes' tools stage a suggestion unless the operator has turned suggestion review off for that module, in which case they apply directly, same as they already do in chat (ADR-0112 documents why this is an accepted interaction with an existing per-module setting, not a gap the annotation should paper over); `mail_send`/`mail_reply` carry no such caveat — they compose a draft unconditionally, and an unattended automation turn already degrades a draft it can't show into an informative error rather than ever sending. `mail` 0.15.0→0.16.0 · `knowledge` 0.25.0→0.26.0 · `notes` 0.10.0→0.11.0 (all MINOR). No `core-app` change. - **Starter automation templates for every module** (#705) — the Templates tab was effectively empty (only `echo`'s reference `on-ping`); mail, calendar, tasks, notes, and knowledge each now declare two curated presets — mail (triage new mail, morning unread digest), calendar (tomorrow-at-a-glance, event-starting-soon — "notify on a new invitation" was dropped, no such event exists yet), tasks (due-today digest, overdue alert), notes (weekly review, on-note-created), knowledge (large-vault-sync notify, index-failed notify). All ten are `notify`-autonomy with a `push` sink, never auto-instantiated (the operator instantiates from the Templates tab, same as ever). Getting these actually useful required annotating each module's read tools `side_effect="read"` (`mail_search`/`mail_read`, `calendar_list_events`/`calendar_find_free`, `tasks_list`/`tasks_lists`, `notes_list`/ `notes_tree`, `knowledge_search`/`knowledge_list_projects`/`knowledge_tree`/ `knowledge_read_document`) — unannotated defaults to `write`, which a Notify automation can never call, so a schedule-triggered template (no triggering-event payload to fall back on) would otherwise reach zero tools and produce nothing useful. `mail` 0.14.0→0.15.0 · `calendar` 0.17.0→0.18.0 · `tasks` 0.17.0→0.18.0 · `notes` 0.9.1→0.10.0 · `knowledge` 0.24.1→0.25.0 (all MINOR). No `core-app` change — the templates contract already existed (ADR-0105). - **Automations completion: conversational drafting + the three sinks** (#667, #672) — closes the automations loop opened in W7. `propose_automation` is a core built-in that drafts an automation from a natural-language ask and **only stages** it as a `ReviewSuggestion` — a hard guardrail with no path to create or enable at any autonomy. Approval on a **second in-process `core` review page** (a `CorePages` composite declaring both `PageSpec`s; `ModuleRegistry` already fans out over a manifest's `pages`, so no registry change) is the one create path, and it creates the automation *enabled* — the review is the consent. The review modal gains an editable **model picker** over an additive `ReviewSuggestion.automation` (`AutomationPreview`). The engine's sink seam (ADR-0105, left registered but empty) is now wired under the owner rules: **chat is turn-time** — the runner passes `session_id` only when the chat sink is set, so an unchecked chat sink means zero sessions; a new `automation_sessions` table badges those chats and groups a per-run automation's chats, and the post-run dispatcher **skips** chat. **Notes/KB** route through the existing `ModuleRegistry.save_page_doc` (the #541 no-second-write-path rule) at a per-automation `DocumentTarget` (a `path_pattern` with `{date}`/`{datetime}`/`{time}` tokens, create or append); each write records an `EntityRef` on the run's new `automation_runs.artifacts` column, so the runs feed (#688) links what was produced. `automations.sink_config` and `automation_runs.artifacts` are the first columns added since #682, so `AutomationStore.init` now runs `ensure_columns` (ADR-0067). Implements ADR-0107 and ADR-0108. `epicurus-core` 0.30.0→0.31.0 (MINOR) · `core-app` 0.89.0→0.91.0 (MINOR) · `web` 0.116.0→0.118.0 (MINOR). - **Web: the Automations page** (#668, stacked on #669) — the operator's controls for the engine, a first-class core surface beside Settings. The list reads each row **in words** ("When mail.received arrives, matching subject contains invoice, between 09:00–17:00" · "Weekly on Tuesday at 09:00") with an autonomy badge, sink icons, an enabled toggle that takes effect without a reload, and last-run status; the tenant-wide **kill switch** is pinned above everything. One Sheet editor serves create / edit / instantiate-a-template and edits **every field the engine stores** — instructions, a per-automation model (core-default fall-through, local + hosted lists), an event trigger whose type picker is driven by the live event catalog (module manifests' declared `events.*` subjects, with a free-text escape hatch for the core's own `files.*`/`core.*` families) plus the matcher builder and active-hours window, or a schedule in the ADR-0092 cadence/hour/weekday vocabulary; sinks + chat mode; the 4-level autonomy dial with its reach spelled out; rate cap and digest window. Saved **explicitly** (the fields are interdependent — the ADR-0098 posture), and a server-rejected save shows its reason inline with the sheet still open. The **Templates** tab groups module presets by module; *Use* prefills the editor and saving creates an independent `source="template:"` row — **enabled on save** (the editor pass is the review; owner-decided) and never retro-edited by later template changes (provenance is not editable, enforced server-side). Per-row **Run history** reads the ledger and deep-links into the runs feed (`/observability?tab=runs&automation=` — the observability screen learned to open on a requested tab), and the feed's automation badge now links back. **Run now** exercises the real runner. The engine gains the missing management endpoint — `PUT /platform/v1/automations/{id}`, create-shape validation before any write, `source`/`chat_session_id`/`created_at`/last-run stamps never touched — and `AutomationStore.update`. The old **scheduled-turns Settings card is absorbed**: the card, its api client and contract are gone (the engine already migrated the rows and keeps the old endpoints answering); migrated rows simply appear as automations. `web` 0.115.0→0.116.0 (MINOR) · `core-app` 0.88.1→0.89.0 (MINOR — the management endpoint). - **Observability: the Automation runs feed** (#669, stacked on #666) — the third live tab, and the engine's glass: a triggered run is traceable end-to-end in one place, fire → filter verdict → run (model, tokens, duration) → sinks delivered / error. **Skips are the point**: a rate-capped or paused run appears as loudly as a real one with its *why* inline ("rate cap reached (4/hour)", "runtime paused") — a cap being hit should be visible, not inferred from an automation that mysteriously went quiet — and a `silent_act` run, whose only trace is the ledger, is visible here too. Backed by `GET /platform/v1/automations/runs/stream` (SSE, history-then-live per the ADR-0031 console shape; a new `RunFeed` fans out every ledger entry the moment the runner's new `on_recorded` hook records it) and an `outcome` filter on the list endpoint (`ok`/`error`/`skipped`, server-side). Each run's `trigger_refs` come back as `trigger_entity_refs` — the triggering events' `EntityRef`s resolved from the event log (`EventLogStore.by_ids`) — so rows carry ADR-0019 hover-card chips to the source entities with no per-module code. The tab filters by automation and outcome server-side (they re-subscribe) and by trigger module client-side over the automations list (a run itself carries no module), reusing `useSseFeed` exactly as the Logs and Events tabs do; the automations list also names each run. `web` 0.114.0→0.115.0 (MINOR) · `core-app` 0.88.0→0.88.1 (PATCH — endpoints and hook exist to serve the new surface, per the issue's own bump call). - **Automations: the engine** (#666, ADR-0105) — the centerpiece of event-driven proactivity, and what the event spine exists to feed: modules announce that the world changed, and this decides whether the assistant should do anything about it. A tenant-scoped `automations` row is a **trigger** (an event — module + type + a deterministic payload filter + an optional local-hour window — *or* a schedule, reusing the ADR-0092 cadence vocabulary), an **agent step** (prompt + optional per-automation model + autonomy level), **sinks**, a rate cap, and a digest window. **The 4-level autonomy dial is structural, not persuasive.** A Notify automation is not asked to avoid writing — it is handed no tool that can. That needed a vocabulary the codebase didn't have, so tools now declare `side_effect` on their `ToolSpec`: `read` (observes), `propose` (**stages for approval by construction** — a draft-first send, a propose tool that files a suggestion), `write` (applies directly). Three classes, because two collapse the dial: with only read/write, Propose and Act get identical surfaces and the middle of the dial becomes prompt wording again. It is *declared*, never inferred — `mail_mark_read` contains "read" and mutates — and **defaults to `write`**, so a forgotten annotation costs availability, never containment. Enforcement filters the `route` the agent dispatches on, not just the specs the model is shown: a withheld tool is **unroutable**, and a model that names it anyway is told `error: unknown tool`. Levels: notify→read · propose→+propose · act→+write · silent_act→ the same reach as act, reporting **only** to the ledger. The runner matches on the event intake, queues durably (ADR-0051), batches a digest from the *oldest* pending trigger, runs one agent turn with the triggering events as **context, explicitly not instructions**, then fans out to sinks **deterministically after** the turn — a model that could choose its own reporting could choose not to report. Safety: a **Postgres-persisted** kill switch (unlike the in-memory power pause — a stop a restart undoes is not a stop), rate caps that a *failing* run also consumes, a rate-limited `core.automation_failed`, and a **depth-1 loop guard** — an event a run produces carries a `causation_id` and the matcher refuses *any* event carrying one, because A→B→A is a loop too. `core-app` 0.87.0→0.88.0, `epicurus-core` 0.29.0→0.30.0, `echo` 0.4.0→0.5.0 (all MINOR). `automation_runs` always records, at every level, with **dual metering**: tenant *and* automation, on the ledger and on `UsageEvent`, since an automation quietly burning tokens is otherwise indistinguishable from the operator's own chatting. **Scheduled turns (#614) fold in** — migrated at startup, idempotently and non-destructively, keeping cadence, session, enabled flag and last-run stamp, at `notify` because that is what they already were. Modules may declare `automation_templates`; they are **never auto-instantiated** (the contract carries no `enabled` field to set). - **Events: the module event spine** (#662, ADR-0103) — the keystone of event-driven proactivity: modules announce that the world changed, the core keeps the copy of record, and the automations engine (a companion issue) decides whether anything should happen about it. Nothing consumes events yet, which is exactly why the contract is fixed now — every emitter and every consumer will be written against it. **One envelope** in `epicurus_core.module_events` (`tenant_id · module · type · occurred_at · dedup_key · entity_ref? · payload · schema_version`) plus an `emit_event()` helper over the existing NATS plumbing, on a dedicated `events.` subject namespace (`.events.mail.received`) so the spine is subscribable as a whole while the bus's existing per-module subjects keep their names. Payload discipline — **pointers and metadata, never content, never secrets** — is *enforced*, not requested: a 4096-byte cap a mail body cannot fit through, and rejection of credential-shaped payload keys (an over-match by design: `idempotency_key` is refused exactly like `api_key`, because a false positive costs a rename and a false negative leaks a credential to a browser tab). A `type` must be prefixed with its own module, so a subject is self-describing and a typo fails at emit rather than mis-attributing an event forever. **Durable intake** in core-app subscribes `*.events.>` — one subscription, *every* tenant (`EventBus.subscribe_any_tenant`), departing from the inbound-messaging consumer's per-tenant subscribe, which would leave a tenant added at runtime silently unheard until restart. Each message carries two independent tenant claims (the subject's and the envelope's); a mismatch is dropped rather than filed under a guess. Events land in a tenant-scoped `module_events` log, deduped by a database constraint on `(tenant, module, dedup_key)` with **first write wins** (a later delivery of an already-recorded change carries no newer truth), bounded by `EVENTS_RETENTION_DAYS` (30; `0` keeps everything). Delivery is **best-effort v1** — core pub/sub, at-most-once; JetStream is enabled on the server and deliberately unused, which is *why* the log rather than the bus is the copy of record. A **raw Events feed** joins the log console on the Observability screen behind a new tab strip (the automation-runs feed lands as a third tab); the ADR-0031 redaction rule moved into `epicurus_core.redaction` so both surfaces share one list rather than drifting. **echo** gains `echo_ping` / a "Ping the spine" action emitting `echo.pinged` — the reference emitter — and `task smoke` now asserts the whole chain end-to-end on a fresh stack: emit → NATS → intake → durable log → feed, plus the log's idempotency. `docs/reference/events.md` starts **the event catalog**; real module emitters extend it. - **Mail: the first real module emitters — `mail.received` / `mail.sent` / `mail.sync_failed`** (#663, stacked on #662) — mail's cache reconcile (ADR-0096, #623) is the one place a genuinely-new message, as opposed to a flag flip, is already known, so that's where all three now fire. `mail.received` needed message-granular detection the sync seam never had: Gmail's `changed_threads_since` was thread-granular only (`_history_thread_ids` conflates `messagesAdded`/`messagesDeleted`/`labelsAdded`/`labelsRemoved` into one set), so `ThreadChanges` gains a narrower `new_message_ids` field, filled from `messagesAdded` history records specifically — a flag flip on an existing message never fires it. Each new message gets one `provider.read()` (thread-summary data reflects only a thread's *latest* message, wrong the moment two new messages land in the same thread within one reconcile window) for an accurate `from`/`subject` (capped)/`folder`/`has_attachments` payload — `MailMessage` gains its own `label_ids` so `folder` reflects the message's real placement, not whichever label triggered the poll. **No-firehose, by construction, not by a special case**: a cold cache and an expired-cursor forced resync both already route through the same `_full_sync` path, which the reconcile loop never touches for `mail.received` — the initial/full-sync case costs no new code. `mail.sync_failed` fires on a provider/auth error or an expired sync cursor, rate-limited per instance (`MAIL_SYNC_FAILED_COOLDOWN_S`, default 900s) so a flapping account can't storm the bus. `mail.sent` — previously declared but publishing on the bare `mail.sent` subject via a raw `EventBus.publish` with an ad-hoc, uncapped payload — now rides the same spine as its two new siblings. IMAP does not exist in this codebase yet (only `GmailProvider` is implemented, despite the module's provider-neutral design); the catalog's provider-caveats section notes what a future IMAP provider would need to fill. `mail` 0.13.0→0.14.0 (MINOR). - **Calendar & Tasks: lifecycle + lead-time events** (#664, stacked on #663) — "two modules, one pattern," including the new lead-time scheduler both now share the shape of. **Calendar** gains `event_created`/`event_updated`/`event_cancelled` on `CollectionRouter`'s create/update/delete_event — the **provider-write** seam only, since calendar has no sync/reconcile layer analogous to mail's (ADR-0096); a change made directly in Google Calendar's own UI is never observed. `event_updated`'s `time_changed` flag is a real before/after comparison (the router snapshots prior state first), and its `dedup_key` folds in a change hash of the event's mutable fields, so a genuinely different edit is its own log entry while a retried write with identical resulting state dedups — the same "dedup provider id + change hash" posture `tasks.task_updated` now also uses. `invitation_received` / `attendee_responded` (Google-only, ADR-0030) are **deliberately not implemented**: both need the same kind of external-change detection a sync layer would provide, a materially larger feature than wiring emission into an existing seam — declaring either without actually publishing it would repeat mail's own "declared but never emitted" mistake rather than avoid it. **Tasks** gains `task_created`/`task_completed`/`task_updated` on `TasksRouter`'s add/complete/update_task, and `task_moved` on the ADR-0038/#257 cross-list seam (fired instead of `task_updated`, never alongside it — Google Tasks has no move API, so a move recreates in the target and deletes the source). A recurring task's auto-materialized successor (`_materialize`, ADR-0082) calls the inner provider directly, bypassing the router's own `add_task` — a deliberate scope limit, so it does not currently emit `task_created`. **The lead-time scheduler** is a new periodic background job — the first for either module — durably fire-once via a `(tenant, entity_id, marker)`-unique marker table (`BigInteger` epoch column) proven to survive a restart; each module also gains a settings-primitives-shaped tenant preference for its own lead (calendar: minutes, default 15; tasks: days, default 1 — storage only, no settings UI yet). Calendar's lead is pure instant math; tasks' `task_due_soon`/`task_overdue` evaluate against the *operator's local calendar day* (ADR-0039), reusing the exact `operator_clock` the overdue-recurrence sweep already resolves, so the two can never disagree about what day it is. `calendar` 0.16.0→0.17.0 (MINOR) · `tasks` 0.16.0→0.17.0 (MINOR). - **Notes, Knowledge, Files: content events + suggestion-decision events** (#665, stacked on #662) — the operator-content half of the emitter sweep. **Notes/knowledge doc events**: `note_created`/`doc_created` and `note_deleted`/`doc_deleted` fire immediately at the change (editor, file tree, or an approved suggestion — both authors converge on the same pages seams), but `note_updated`/`doc_updated` are **debounced to settled saves**: the ADR-0042 auto-save fires a PUT on every ~4s idle pause, so each save re-arms a per-document quiet window (`NOTES_EVENTS_DEBOUNCE_S` / `KNOWLEDGE_EVENTS_DEBOUNCE_S`, default **120s**) and one event fires when it passes untouched — carrying the *last save's* timestamp as `occurred_at` and the count of saves coalesced. The debounce is a swept dict driven by a pure `flush_due(now)` (the ADR-0092/0098 test idiom), duplicated per module rather than landed in the high-contention core lib (rule of three; the engine PR is already bumping it); pending entries flush on shutdown, deletes cancel them, renames re-key them (knowledge folder moves re-key by prefix). `knowledge.vault_synced` is **one batch event per watcher pass** with the pass's honest counts (`indexed` merges added+updated — the walk doesn't distinguish); no-op passes and the startup index emit nothing (no-firehose). `knowledge.index_failed` is rate-limited (`KNOWLEDGE_INDEX_FAILED_COOLDOWN_S`, 900s, the mail.sync_failed posture) and fires on the initial index giving up (#230's retry budget) or a watcher pass failing. **Files events are core-emitted** (the core owns the file space, #434) at the file-API seam: `files.file_added` (upload, or a module/agent write of a genuinely-new path), `files.file_deleted` (one per API action — a folder is one event), `files.file_moved` (file-space + object-store fallbacks). Deliberately **no `file_updated`**: an overwrite emits nothing, so mirrored module content doesn't double-signal its own `*_updated` here. **Suggestion decisions are core-emitted at the one review funnel** (`ModuleRegistry.review_action`): `core.suggestion_approved` / `core.suggestion_rejected` fire once per decision whether the operator used a module's review page (HTTP-proxied) or the core-hosted pseudo-module surface (ADR-0093 §2), with `operation`/`path` lifted from the surface's `ApplyResult`. Two legacy subjects retire: notes' bare `notes.saved` publish (no consumer — the mail.sent migration, #663) and knowledge's declared-but-never-published `knowledge.index.completed` (the manifest now only advertises events that fire). All emission is best-effort — a spine hiccup never fails the save, delete, or decision that already landed. `notes` 0.8.0→0.9.0 (MINOR) · `knowledge` 0.23.0→0.24.0 (MINOR) · `core-app` 0.86.0→0.87.0 (MINOR). starts **the event catalog**; real module emitters extend it. `epicurus-core` 0.28.0→0.29.0 (MINOR), `core-app` 0.85.0→0.86.0 (MINOR), `web` 0.113.0→0.114.0 (MINOR), `echo` 0.3.0→0.4.0 (MINOR). - **Push: web push end-to-end** (#670) — VAPID-signed browser push, the "push alerts" half of event-driven proactivity. A tenant's VAPID keypair is generated on first send and stored in OpenBao — no operator provisioning step. Per-category + quiet-hours preferences (`push_prefs`, shared with the notification center, #671) gate every send: category off skips it, quiet hours (tenant timezone, ADR-0039) queue it for one summary digest once the window ends rather than dropping it, and an in-memory per-tenant rate cap is the last gate. A subscription the push service reports Gone (404/410) is pruned automatically. `services/web/src/sw.ts` gains `push`/`notificationclick` handlers; a new Settings → Push notifications card handles this-device subscribe/unsubscribe, per-device management, category toggles, quiet hours, and a test-notification button. `PushService.notify()` is a core-internal contract (no HTTP route) for the automations engine's future push sink and system notices — today's only caller is the settings UI's test button. Desktop Chrome/Edge and installed Android/iOS PWA (16.4+) both work. Implements ADR-0102. `core-app` 0.83.0→0.84.0 (MINOR), `web` 0.111.0→0.112.0 (MINOR). - **Push: in-app notification center** (#671) — the durable record every push-worthy notification lands in, written by `PushService.notify()` itself the instant a category's `center` toggle is on — independent of whether push delivery fires, queues for quiet hours, or is itself disabled, so a quiet-hours-suppressed push still appears in the center immediately rather than waiting for the digest. A new **Notifications** page (list, category filter, unread-only filter, mark read / mark all read, `EntityRef` hover-cards + deep links via the existing `CardLink`) and a live unread-count badge on its own nav entry (polled every 15s, the same shape as the #492 "finished while you were away" watcher). Retention is a per-tenant row cap (500), not time-based. Stacks on #670's shared `{push, center}` prefs object — no second settings surface. Implements ADR-0104. `core-app` 0.84.0→0.85.0 (MINOR), `web` 0.112.0→0.113.0 (MINOR). - **Agent: nightly reflection proposes playbook/instruction edits** (#615) — the other half of governed playbooks: what actually notices a lesson worth keeping. A new `playbook-reflection` job on the maintenance orchestrator's nightly batch (additive — one entry appended to the registry, registered `nightly=True`, so it rides the orchestrator's existing schedule rather than a knob of its own). Per tenant it scans the sessions active since its last run and makes **one** gateway call over them, staging zero or more candidate edits — to the base instructions or a named playbook — as review proposals. It **cannot apply anything**: it is handed a proposal sink and a read-only playbook lookup, never the stores that own the documents, so ADR-0093's "nothing self-applies" is enforced by construction. The call is metered under **the tenant whose sessions it scanned**, never the default tenant (constraints #1/#8, the ADR-0051 drain's precedent). Recently **rejected** proposals are digested into the prompt as negative context from the ADR-0090 audit trail, so a declined idea isn't re-proposed unchanged; a document with a proposal still pending is skipped, so the queue can't stack drafts while the operator is away. The create-vs-update operation is derived from what exists rather than taken from the model — a mislabelled create would render an empty *current* side and hide what an approval would overwrite. A durable per-tenant watermark (`agent_reflection_state`) bounds the scan, snapshotted before it and advanced only on a completed pass. Junk costs nothing: a non-JSON reply, an unknown target, or a runaway generation stages nothing rather than raising, and a tenant with no new activity spends no gateway call at all. New `PLAYBOOK_REFLECTION_MODEL` (blank = the default chat model). Implements ADR-0093 §1/§5/§6. `core-app` 0.80.0→0.81.0 (MINOR). - **Agent: governed playbooks — storage + a core-hosted approval surface** (#616) — the agent's behaviour improved only when the operator hand-edited the base prompt; nothing captured what the system learns in use. **Playbooks** are named, independently enable-able blocks of guidance stored beside that prompt (`agent_playbooks`) and composed into every turn — base first, then each enabled playbook under its own heading. The composition happens **below** `AgentInstructionsStore.get_instructions`, so `Agent._assemble` is untouched and still leads the turn with one opaque string; a failed playbook read degrades to the base prompt rather than costing the turn. Nothing self-applies: an edit arrives as a `ReviewSuggestion` (ADR-0090) and only the operator's **Approve** writes it — through the existing instructions store for the base prompt (the same path a manual Settings edit uses), or the playbook store for a named one. Both halves gained ADR-0046 snapshot-on-save versioning (`agent_instructions_versions`, `agent_playbook_versions`; capped at 50, oldest pruned), snapshotting the body each save *replaced* so an approved agent-authored edit is always undoable. The approval UI is the existing, unmodified `ReviewView`/`SuggestionReviewModal`: the core registers a reserved **`core` pseudo-module** that `ModuleRegistry` answers **in-process** (no loopback HTTP), riding `GET /platform/v1/modules` so the shell discovers its `review` page like any module's. It is deliberately *not* a configured base, so it can never leak into the agent's MCP tool surface or the re-embed fan-out; `enabled` / `DELETE` / `suggestions-enabled` all **403** for it — its review is mandatory. Web-side, the Suggestions inbox shows *Always reviewed* instead of a toggle for that group, and the Modules screen filters the reserved name out. Implements ADR-0093 §2/§3/§4. `core-app` 0.79.0→0.80.0 (MINOR), `web` 0.109.0→0.109.1 (PATCH). ### Fixed - **Web: no more cold-mount flash on a folder/search/pagination switch** (#712) — mail's thread list blanked to a spinner on every folder change (search, and pagination too), because each switch is a new react-query key and, left at the defaults, that mounts cold with no data. `MailboxView`'s list query now keeps the previous folder's data on screen (`placeholderData: keepPreviousData`) with a small inline spinner as the only sign a fetch is under way, plus a tuned `staleTime`/`gcTime` so every folder visited in a session stays warm and a revisit renders instantly. A borrowed placeholder also has to be guarded wherever one query gates on another's success: the cache-first reconcile read (#623) waits on the list query, and a placeholder resolves that to `success` before the newly-selected folder's own read has landed, so the gate now also requires `!isPlaceholderData` — without it a first visit fired both provider reads at once, the exact double round-trip ADR-0096's gate exists to prevent. Auditing the other three archetypes for the same pattern found it live in two more places — `BrowserView` (a directory/search switch) and `EditorView` (opening a different document) — and fixed both; `EditorView`'s fix also guards the draft-seeding effect on `!doc.isPlaceholderData`, since without it the *previous* document's still-visible placeholder content would seed into the newly-selected path and strand the real content unread once it arrived. `CalendarView`/`BoardView` already did this (Calendar since #378/#379) and needed no change — they were the reference the audit checked the others against. `web` 0.118.0→0.119.0 (MINOR — the audit landed changes beyond mail). - **CI: no gate ever checked a docs/ cross-reference — a dead one reached shipped operator UI** (#692). Issue #661 existed because `docs/DEPLOYMENT.md` was referenced from shipped, operator-facing UI copy and a compose comment while the file doesn't exist in the public tree (the real doc is the gitignored `.workspace/docs/DEPLOYMENT.md`); nothing caught it, and a generic markdown-link checker wouldn't catch the next one either — it never looks inside a `.tsx` file or a compose comment. New **docs-linkcheck** CI job (`scripts/check_docs_links.py`, stdlib-only) checks (1) every relative link between `docs/` pages, anchors included, and (2) a repo-relative doc path quoted in shipped source (web UI copy, compose comments, other top-level READMEs, `.env.example`) — excluding test fixtures (which commonly use plausible-looking-but-synthetic doc-shaped paths that were never meant to resolve) and `CHANGELOG.md` (narrates *past* fixes, not live references). The gate's first real run found and fixed four genuine, previously-invisible cases: a `docs/services/core-app.md` anchor named in two other pages (`../services/core-app.md#push-notifications-adr-0102`, `#raw-events-feed` missing its `-adr-0103` suffix in two places) that had no matching heading at all — added the missing "Push notifications (ADR-0102)" section documenting the `push/` package's send path and HTTP surface (previously undocumented entirely), and corrected the `-adr-0103` suffix — plus one heading-renamed-out-from-under-it link in `docs/services/mail.md` (`#operator-setup-google` → `#google-cloud-setup`). The two offenders this issue named as still-live (`.env.example`, `docs/developer/releases.md`) were already fixed by #661 before this PR; verified no live occurrence remains. No component bump — CI/docs only. - **CI: no gate ever parsed a shell script with a POSIX shell** (#691). #675 shipped a bash array in `infra/cd/reconcile.sh` — invoked as `sh infra/cd/reconcile.sh` everywhere (its own header comment, the `task reconcile` Taskfile entry, and both scheduled-task lines in `docs/infrastructure/auto-deploy.md`), and on the deploy box's real `/bin/sh` (dash) a bash array is a parse error — but every check, CI included, ran under Git Bash's `sh`, which *is* bash, so the bug was invisible everywhere but production. New **shell-lint** CI job runs `shellcheck` over every `*.sh` (discovered via `git ls-files`, not a hardcoded list), with the shell it checks against inferred from each script's own shebang. Fixed the live mismatch this surfaced: `reconcile.sh`'s shebang said `bash` while every invocation and its own content (already rewritten to POSIX positional parameters, no arrays) said `sh` — corrected the shebang to match, and dropped `pipefail` from its `set` line (undefined in POSIX sh; Debian dash rejects it outright, which would have been the exact same silent-until-production failure this gate exists to catch). Also cleaned up the two other findings the gate's first real run turned up: `infra/backups/backup.sh`'s `ls | grep` (rewritten as a plain Python `os.listdir` filter) and a few intentional-but-unflagged-until-now `infra/ci/smoke.sh` patterns (a deliberately unquoted service-name word list standing in for POSIX sh's missing arrays, `CDPATH= cd`, sourcing a `mktemp` path) marked with `# shellcheck disable=SCxxxx` / `# shellcheck source=/dev/null` naming why. No component bump (CI/docs only). - **Knowledge/notes: a rejected write returned a success envelope, so the live document pane could open on content that was never written** (#690). `knowledge_create_document` / `knowledge_propose_edit`'s `_stage_doc_write` (a bad path, an already-existing path) and their shared `_finalize` (a failed review-off self-apply), plus notes' equivalent `_stage` (an invalid slug, the same failed self-apply), all caught the rejection and returned a normal `tool_envelope` — so the call read as `is_error=False` to the agent loop, and the document pane (#541, ADR-0101) keys `doc.failed` off exactly that structural signal, not the reply text. A rejected write with review off would open the pane's editor over stale (update) or nonexistent (create) content. These paths now raise instead, so FastMCP reports `isError` and the pane correctly shows "the write failed." The suggestion itself is unaffected — a failed self-apply still leaves it staged, nothing is lost. Swept mail, calendar, tasks, and storage for the same "error path reuses the success constructor" shape; none had it — their write tools already raise directly, and their only `tool_envelope`-on-empty-result usages are legitimate (no rejection involved). `knowledge` 0.24.0→0.24.1 (PATCH), `notes` 0.9.0→0.9.1 (PATCH). - **Infra: `docs/DEPLOYMENT.md` was referenced from shipped operator UI and a compose comment, but that file doesn't exist in the public tree** (#661). The real document was always the gitignored `.workspace/docs/DEPLOYMENT.md` — since the repo went public, anyone following the Modules page's Docker-status card (#652/#622) or the `docker-socket` compose comment to `docs/DEPLOYMENT.md` got a 404. Both now point at `docs/infrastructure/index.md`'s "Docker-socket access" section, which #652's own docs already established as the real home for that content. Also swept two conceptual "DEPLOYMENT.md" mentions (`.env.example`, `docs/developer/releases.md`) that cited the same gone file for the image-pinning / immutable-image principle — retargeted at `docs/infrastructure/auto-deploy.md`, mirroring the link `releases.md` already uses two sections below for the same document. `web` 0.111.2→0.111.3 (PATCH — the Modules page string). `core-app`'s `compose.yaml` touch is comment-only, no runtime effect — no bump. - **Web: confirming a mid-history edit could trim the transcript to a state the server was never asked to produce** (#660). Editing a user message further back than the last turn discards every real turn since it, so `saveEdit()` confirms the count first — but it only guards `chat.streaming`/a dropped connection at the moment the dialog *opens*. A run starting elsewhere (another tab, a scheduled turn) or the connection dropping while the dialog sat open went unchecked: clicking **Resend** still applied the optimistic trim and re-ran the edit regardless. The dialog's own confirm now re-checks both at click time, the same guard `saveEdit()` already applies — blocked exactly like Cancel, leaving the inline editor open with the draft intact to retry. `web` 0.111.1→0.111.2 (PATCH). - **Web: the document pane's "Review & approve" hard-reloaded the SPA, and its review-state query key missed the toggle's own invalidation** (#659). `Panel.tsx`'s `DocumentView` was the only SPA-internal hard navigation in the app (`window.location.assign`) — it dropped the live SSE stream for no reason (recoverable via ADR-0055 re-attach, but nothing to recover *from* if it just doesn't reload); now it navigates in-app via `useNavigate()` and explicitly dismisses the pane first (the panel is Shell-global and persists across routes, so — unlike the reload it replaces — nothing implicitly clears it). Separately, the pane's `["suggestionsEnabled", module]` query key was a duplicate camelCase cache entry alongside `ReviewView`/ `SuggestionsScreen`'s established `["suggestions-enabled", module]`, so toggling review while the pane was open missed the invalidation the toggle fires — self-healed on refetch, but could leave the pane's "applied vs. staged" branch briefly stale. Also fixed while in the area: the panel store's `replace()` accepted a title update, but silently discarded it; and `EditorView`'s `doc` prop (the pane's applied→editor handover) had no direct test coverage. `web` 0.111.0→ 0.111.1 (PATCH). - **Infra: `task reconcile` silently reverted the docker-socket opt-in on every deploy** (#655) — the #622 opt-in (`services/core-app/compose.docker-socket.yaml` + `DOCKER_GID`) only lasted until the next pull-based reconcile: `infra/cd/reconcile.sh` ran plain `docker compose up -d` with no overlay, so `core-app` was recreated without the socket mount, silently dropping back to deferred-teardown mode (fails safe, but the opt-in didn't stick — the pull-based reconcile is the actual deploy path, not a one-off `docker compose up`). Now `reconcile.sh` reads `DOCKER_GID` the same way it already reads `EPICURUS_VERSION` / `EPICURUS_TRACK_BRANCH` (env, falling back to `.env`) and includes the overlay on both the pull and the up when it's set — unset stays exactly as fail-safe as before. `docs/infrastructure/` updated to cover persisting the opt-in, not just the fresh-deploy default. Infra-only; no component version change. - **Agent: nightly reflection never showed the model the document it was asked to rewrite** (#658) — the `update` path's system prompt demanded "the FULL new text… it replaces what is there," but `_build_prompt` passed only transcripts and recent rejections, and `list_playbooks` was used for names only; `get_base` was never called. Since `instructions` is always an `update` (there's nothing to create), that path asked for a full regeneration of text the model had no access to — mostly rejectable noise, at the cost of a real gateway call per tenant per night. Now the prompt includes the current base instructions (`get_base`) and every existing playbook's current content — the model edits real text instead of reconstructing from nothing. Folded in from the same review: the out-of-window `continue` in the session scan is now a `break` (`sessions()` is DESC-ordered, so the first miss guarantees the rest miss), and `test_reflection.py`'s model-override test now constructs the reflector with `model=` instead of poking the private attribute, so `settings.playbook_reflection_model` reaching the constructor is actually covered. `core-app` 0.83.1→0.83.2 (PATCH). - **Core: a DB error on the reserved review page emptied the entire Suggestions inbox** (#657) — `ModuleRegistry._core_suggestions` caught only `HTTPException`, but the core review page is dispatched **in-process** (no loopback HTTP), so a storage failure — e.g. a degraded startup that left `playbook_proposals` uninitialized — surfaced as the driver's own exception and escaped the handler, 500ing `GET /platform/v1/suggestions` and taking every module's pending suggestions down with it, not just the core's own. Now catches bare `Exception` and logs a warning instead, matching the precedent in `agent/instructions.py`'s enrichment fallback — a broken core queue drops only its own entry, everything else in the feed survives. Also closed the matching read/write asymmetry: `GET .../core/suggestions-enabled` now 403s like `PUT` already did, rather than answering `true` for a toggle that can never exist (review of the agent's own instructions/playbooks is mandatory, ADR-0093). `core-app` 0.83.0→0.83.1 (PATCH). - **Infra: the "docker socket unavailable" message overstated the impact, and the socket was mounted by default without ever actually working** (#622, ADR-0099). Module removal was never disabled — an earlier fix (ADR-0056) already made it tombstone the module immediately either way, deferring only the container teardown — but the log line (surfaced via the Observability console) still said "module removal disabled," and the socket mount in `services/core-app/compose.yaml` was unconditional despite never being reachable on a real deployment anyway (the core drops to an unprivileged uid at startup, ADR-0069, with no group matching the host's docker-socket GID). Now: the message says what's actually deferred; a new `GET /platform/v1/modules/docker-status` lets the Modules page state that proactively, with the one-line enablement, instead of an operator finding out by attempting a removal; and the socket mount is an explicit opt-in (`services/core-app/compose.docker-socket.yaml` + `DOCKER_GID`) — losing no real capability (nothing worked by default before either) while closing a root-equivalent attack surface that was pure liability. `core-app` 0.78.0→0.79.0; `web` 0.108.0→0.109.0. - **Mail: paging pinned to the bottom, action row at the top + icon-only on phone** (#624, #626) — two mailbox UX fixes. The **Newer/Older** paging controls scrolled away with the message rows (attached mid-content); each mailbox view now owns its scroll, so the paging bar is a stable footer pinned to the bottom of the list (#624). The per-message **action row** (mark read/unread, archive, trash) sat at the bottom of the message and is now anchored at the **top**, and on a narrow viewport the buttons are **icon-only** — labels hidden from `sm:` down, with the aria-label + tooltip preserved so they stay named and accessible (#626). `web` 0.107.0→0.108.0. ### Added - **Chat: a live document pane — watch the agent write, then edit or review it in place** (#541, ADR-0101) — the agent writing a document showed only a `knowledge_create_doc ✓` chip; the artifact, usually the whole point of the turn, was invisible until you navigated to the module afterwards. Now an annotated call (the `writes_document` seam below) opens a **split pane beside the chat** — a resizable column on desktop, a sheet on phones — showing the document as it is written, read-only while the call is in flight so a user edit can't race the agent's. **What the pane becomes depends on what the write actually did.** Knowledge and notes *propose* documents rather than writing them (ADR-0033), and review defaults **on** — so with review on nothing was written, and the pane shows the proposal with **Review & approve** into that module's review page (where the draft is already editable, ADR-0090). With review **off** the change was applied, and the pane hands over to the **real editor archetype**: auto-save (ADR-0042), version history (ADR-0046), the same module document APIs, no second write path. The outcome is read from the core's own review setting — the same one the module consulted — rather than guessed, so the pane never claims a write landed when it didn't. Generic throughout: the module names the arguments and the shell reads the annotation (no module is named in web code, ADR-0018/0019), and the pane finds that module's editor/review pages from its own manifest, so a future adopter needs no web change. Best-effort by construction — a failed or slow lookup costs a pane, never the turn — and the body rides SSE only, never the persisted activity, so ADR-0041's caps are unchanged (the pane is a live-turn affordance; after a reload the turn's entity-ref chip is the way back, as it already is for un-annotated writes). `knowledge` and `notes` are the first adopters, on their four **full-body** write tools; `notes_append` is deliberately left out (its `text` is a fragment the server concatenates on approval, not a document). Also brings both modules' manifest versions back in line with their packages — they had drifted a minor behind, under-reporting on the Modules page. `core-app` 0.82.0→0.83.0; `web` 0.110.0→0.111.0; `knowledge` 0.22.0→0.23.0; `notes` 0.7.0→0.8.0. - **Modules: a `writes_document` tool annotation** (part of #541, ADR-0100) — the groundwork for the live document pane. A module declares, per tool, that it *writes a document* and names the arguments the document travels in — `writes_document: {content_arg, title_arg?, target_arg?}` on `ToolSpec`, declared through the usual decorator (`@module.tool(writes_document=…)`). The shell will use it to open the document beside the chat while the agent writes it, for **any** module, with no per-module web code (ADR-0018/0019) and no name-sniffing. It is an annotation, not a capability: the tool keeps its own name, schema, and behavior, gains no endpoint, and a tool that writes no document simply omits it. The named arguments are **validated against the tool's own input schema** at manifest-build time — a typo fails there instead of surfacing later as a pane that silently never fills — and declaring one for a tool that never registered is an error rather than a silent drop. Additive on the wire (`CONTRACT_VERSION` unmoved): a core predating the field ignores it, so a module can adopt it independently. No consumer yet — the pane, and knowledge + notes declaring the annotation, follow. `epicurus-core` 0.27.0→0.28.0. - **Chat: edit any user message in history, regenerating from that point** (#552) — editing was limited to the **last** user message (#302), so fixing a typo three turns back meant retyping the conversation. Every user message now carries the same inline **Edit** affordance (revealed on hover/focus on earlier turns, the #480 per-turn pattern), opening #302's in-place composer. Editing keeps #302's decision — revise in place and truncate, never branch — so editing turn *k* discards turns *k+1..n* and streams a fresh answer under it. Because that throws away **real** content, a mid-history edit now **confirms with the count** ("removes the N later messages"); editing the last message is unchanged and confirms nothing. `POST /sessions/{id}/edit` gains an optional `message_id` (absent ⇒ the last user message, so existing callers are untouched), and the transcript (`GET /sessions/{id}`) now carries each message's `id` as the anchor to name. Validation runs **before** any write — the anchor must be a **user** message of **this** session, and no turn may be running — so a rejected edit can never leave the conversation truncated. That ordering also fixes a latent bug inherited from #302: `/edit` revised and truncated *before* reaching the one-run 409, which for a mid-history edit would have destroyed real turns and then refused to re-answer. Nothing re-indexes the revised text — messages are not a recall corpus (ADR-0045), so `memory_search` reads the new text (and none of the discarded turns) straight from the store; the extraction queue (#326) is likewise unaffected, holding a **text snapshot** rather than a reference to a message that may no longer exist. `core-app` 0.81.0→0.82.0; `web` 0.109.1→0.110.0. - **Mail: mark message read on open** (#625) — a message stayed unread until acted on explicitly. Opening a conversation now marks its unread messages read at once: the shell flips the list row (and the messages' badges) **optimistically**, then calls a new operator-gated `POST /pages/mailbox/mark-read` in the background, which clears each message's flag at the provider (`set_unread`, the #277 seam) and **writes the read state through to the local cache** (ADR-0096) so the row converges immediately — no reconcile-race flicker — and reverts on a provider failure. Marking a whole thread read is the one case where a thread-level cache write-through is unambiguous (this gives #623's write-through primitive its first caller). `mail` 0.12.0→0.13.0; `core-app` 0.77.0→0.78.0; `web` 0.106.0→0.107.0. - **Core: maintenance schedule controls — on/off, cadence, time of day** (#621, ADR-0098) — the maintenance panel read "manual only — nightly schedule off" with no way to change it: the nightly trigger was a code-level default (`MAINTENANCE_SCHEDULE_ENABLED`/`MAINTENANCE_HOUR`), fixed for the process's whole lifetime, with no endpoint to edit it. Now a real, per-tenant, runtime-editable schedule — enable/disable, an hourly/daily/weekly cadence, an hour (+weekday for weekly), read in the tenant's timezone (ADR-0039) — governs the orchestrator's nightly batch as a whole (the job registry itself stays untouched and additive-only, so the incoming reflection job, #615, still rides this one shared hour per ADR-0093). Persisted per tenant (`maintenance_schedule_prefs`, the same settings-primitives shape as `timezone_prefs`/ `page_order_prefs`); a missing row falls back to the env-configured default, so a fresh install behaves exactly as before. The orchestrator now **polls** every `MAINTENANCE_POLL_INTERVAL_S` (default 60s) and re-reads the schedule fresh each tick — a fixed `sleep_until_hour` computed once at wake couldn't react to a schedule changed while it slept. `GET /platform/v1/maintenance` gains `schedule_cadence`/`schedule_weekday`/ `next_run_at` (an estimate for display); a new `PUT .../schedule` validates and persists the whole schedule at once (400 on an invalid shape, e.g. weekly with no weekday). The Settings panel grows enable/cadence/hour/weekday controls plus an effective-schedule + next-planned-run summary — a multi-field draft the operator edits and explicitly saves (auto-saving per field would fire invalid combinations mid-edit). `core-app` 0.76.0→0.77.0, `web` 0.105.1→0.106.0. - **Models: show the max context window with the other model info** (#618) — the Models page badged each model's capabilities (tools/vision/…) but never its trained context window, so choosing between two similarly-named variants meant guessing. Local models now carry a compact "128k"/"1M"-style chip alongside the existing capability badges, sourced from the same `/api/show` call the capabilities already come from (opt-in, one call per model — the chat picker stays light). Hosted (saved) models get the same chip, sourced from LiteLLM's own model-cost map — the identical lookup #633 added for hosted vision/tool capabilities — always included since it's a static lookup, not a network call. Omitted, never a fake default, when the runtime/map doesn't report a length. `core-app` 0.75.0→0.76.0, `web` 0.104.0→0.105.0. - **Chat: image input — vision models see the picture, end-to-end** (#633, ADR-0095) — attaching an image was silently mangled regardless of model: every `file` attachment was blindly `decode("utf-8")`'d into a text preamble, so a vision-capable model never received real pixel data, hosted or local. Now an image resolves separately (checked against the **stored** upload's real content-type) into multimodal content parts (`image_url`) spliced into the assembled turn just before the provider call — never into what gets persisted, so a stored turn never balloons with base64. Gated on the selected model's **actual** vision support (`gateway.supports_vision`) — stricter than the existing tool-capability check, since a mis-sent image either gets ignored or draws a provider 400: hosted models are checked against LiteLLM's own model-cost map (never assumed capable), and a local model with unreported capabilities defaults to **not** vision-capable. A non-vision model gets a clear explanation before any provider call, same shape as a normal answer, not a raw error. The same LiteLLM lookup also fills in **hosted-model context length + capabilities** for `GET /llm/models/details` (previously local-only), so the composer's "can't use tools"-style hint now works for hosted models too, alongside a new "can't see images" hint (advisory — Send still works; the server's own gate is the real enforcement). Web: the upload picker's accepted types now align with the server's #175 allowlist. `epicurus-core` 0.26.0→0.27.0, `core-app` 0.74.0→0.75.0, `web` 0.103.0→0.104.0. - **Calendar: toolbar reworked into one stretched row; calendars picker clamped on mobile** (#628, #629) — the control row read as cramped and unbalanced; it now follows the shell's toolbar convention (the board's `gap-x-3 gap-y-2` bar) — a **Today · ‹ › · period** navigation cluster on the left, the page actions + **Calendars** picker + view switch pushed right by `ml-auto` so the row stretches the full width; icon-only "New event"/Calendars keep it to one line on all but the narrowest phones (where it wraps to a tidy second line). The **Calendars** visibility popover opened **partly off a phone screen**; it is now **clamped to the viewport** — positioned `fixed` from its trigger, shifted horizontally to stay on-screen, flipped above when there's more room up than down, and height-capped with a scroll — so every calendar is reachable regardless of trigger position. Calendar-local components only; no shared shell component changed. `web` 0.102.0→0.103.0. - **Calendar: tap a month day to open its week; slim event lines on phone** (#630, #632) — in the month view, tapping a day **navigated into a half-started create**; it now **opens that day's week view** (with the day highlighted), making the month a navigator and putting legible detail one tap away in the hourly grid. Event **creation** moves to the explicit affordances — the toolbar **New event** and the week grid's **empty-slot tap** (the #473 slot-seed create, relocated from the month cell to the grid where Google-style calendars put it). On a **phone**, a busy day showed a few blank-looking chips plus a `+2 more`; it now renders **every** event as a **slim textless colour line** (density over labels — the tap-through carries the detail), collapsing to a `+N` marker only past what genuinely fits. **Desktop** keeps the labelled chips. `web` 0.101.0→0.102.0. - **Calendar: week view is now an hourly day-grid with drag-to-move** (#631) — the week view was a plain per-day list of event cards; it is now a Google-Calendar-like **hourly grid**: one column per day over hour rows, timed events **placed and sized by start/duration**, overlapping events **split into side-by-side lanes**, a **pinned all-day strip** (ADR-0037) that stays put while the hours scroll, a **current-time line**, and a default scroll to the morning. A timed event on a writable calendar is **dragged to move** (or its bottom edge dragged to **resize**), snapped to the quarter-hour and applied **optimistically**; the write goes through the event's *own* editable- calendar **Edit** action (`calendar_update_event`, #208/ADR-0034) — the same tool the Edit form calls, so **no module contract changes** (the module supplies data, the shell renders, ADR-0018) — and rolls back with a dismissible message on provider failure. A read-only event stays click-to-open. On a phone the grid **pans horizontally** with the time gutter and day headers pinned. Placement and drag maths are framework-free and unit-tested (`services/web/src/components/archetypes/calendarGrid.ts`). `web` 0.100.0→0.101.0. - **Mail: render HTML email properly (images, styling)** (#627, ADR-0097) — HTML mail rendered badly: the shell decoded every message to plain text (no images, no layout), because rendering raw mail HTML in the shell would be an XSS surface. The module now surfaces the message's **`body_html`** (plus inline images' `Content-ID`s, marked `inline`), and the shell renders it in a **sandboxed iframe** (`allow-same-origin allow-popups`, **never** `allow-scripts`) — so email JS can never run and the email's CSS can't bleed into or restyle the app shell. Two independent safety layers: the HTML is first sanitized by an **inert `DOMParser` pass** (strip `