Skip to content

Intentional read-only mode: run the app and database without writes #332

Description

@DutchJaFO

Background

Quotinator has no way to be told "run without writing." Every startup migrates, seeds, stamps
System_AppVersion, refreshes the changelog database and may download source updates; every admin
endpoint can reseed or reset the dataset. The only thing that stops a write today is the filesystem
refusing it, and that path is a failure state, not a mode — #326 makes an unwritable data directory
survivable, but degraded is still degraded: /health reports unhealthy and every non-exempt request
is gated to 503.

Two distinct needs want the same capability:

  1. Deliberate read-only operation on ordinary writable storage. An instance that serves reads and
    is guaranteed not to mutate its dataset — not by convention or by withholding the admin key, but
    structurally, because the database connection itself cannot write. Useful for an instance exposed
    more widely than the one you administer, or simply to be certain no restart reseeds or migrates
    anything.
  2. Genuinely read-only storage. A pre-seeded immutable volume shared by several containers, a
    Kubernetes readOnlyRootFilesystem deployment, or a curated dataset no running instance should be
    able to change.

The option is the feature; the storage is an environment it must work in. The mode is configured
explicitly and behaves identically either way — it is not inferred from whether the directory happens
to be writable. Where the two environments genuinely differ, that difference is named in requirement 12.

Two read-only modes, deliberately distinguished

Read-only is not one state. There are two, they are reached for opposite reasons, and an operator must
never have to guess which one they are looking at:

Intentional read-only Safe (degraded) read-only
How it is reached The operator configures it The app could not write, and fell back
What it means Working as asked An incident that happens to be serving
Health Healthy Not healthy — something needs attention
Owned by This issue #336

This issue builds the intentional mode. It does not build the fallback — but it does build the
mode-reporting and the UX indicator as two-state aware from the start, so #336 adds a trigger
rather than redesigning what the operator sees. Building a one-state indicator now guarantees rewriting
it later.

Prior work this builds on

  • temp_store=MEMORY is already applied on every connection (SQLite migration statement-journal temp file fails to open in HA add-on runtime (SQLITE_CANTOPEN) #294,
    SqliteConnectionFactory.CreateConnection). SQLite writes statement journals to disk even under WAL,
    which would otherwise make even a pure SELECT fail on read-only media. That hazard is already
    removed.
  • SqliteConnectionFactory hardcodes Data Source={dbPath} with no mode parameter, so opening
    connections read-only is a change to a public Quotinator.Data type, not just Api wiring.
  • Program.cs already resolves the data directory through a documented fallback chain
    (Quotinator:DataDir → HaFallbackDir()'s /data → {AppContext.BaseDirectory}/data).
    Requirement 10 applies the same established shape to the DataProtection keys directory rather than
    inventing a new mechanism.
  • On read-only storage only, WAL is a blocker. SQLite cannot open a WAL database read-only when it
    must create the -shm wal-index. This does not arise on writable storage, where the wal-index can
    still be created and Mode=ReadOnly alone gives the guarantee.

What needs to be done

  1. A configuration option (default off) enables intentional read-only mode — flat key,
    environment-variable reachable, following the existing Quotinator:* conventions. Set explicitly by
    the operator; the configured mode is never inferred from filesystem permissions and never switches
    itself on because a write failed. Falling back automatically is a real and wanted behaviour, but it
    is the other mode, with its own trigger and its own reporting — see Safe read-only fallback: serve reads when the data directory is unwritable but the database is usable #336, and "Follow-on" below.
    Not exposed as a Home Assistant add-on option unless there is a reason to run the add-on this
    way — decide that rather than defaulting to mirroring every option into addon/config.yaml.
  2. The active mode is an enum, not a bool. There are three states — normal, intentional
    read-only, and (later) safe/degraded read-only — and a boolean cannot express the third, so every
    consumer would end up pairing it with a second flag and getting the combination wrong somewhere. An
    enum makes an unhandled state a compile-time visible one. Own Enums/ folder per ADR 016; if it is
    persisted or reported through a persisted column, ADR 008's CHECK-constraint checklist applies.
  3. SqliteConnectionFactory opens connections read-only when the mode is on. This is what makes the
    guarantee structural rather than a matter of which endpoints were remembered — a write attempt
    fails at the database, not only at the handler that was supposed to check first.
  4. Startup performs no write of any kind when the mode is on. Each of these is skipped explicitly, not
    left to fail and be caught: EnableWal; migrations and their pre-migration backup; content seeding
    and its pre-seed backup; AppVersionTracker.RecordCurrentAsync; the Standardise endpoint naming (WithName/WithSummary) across CRUD and action endpoints #279/Squash unshipped migrations since v1.8.2 into one Consumer + one Data migration, add schema-version-overshoot detection + notification #289/Startup notification: import warnings and what's new after upgrade #81 notification
    seeders; Move changelog content to database-backed System_Changelog table #309's changelog-database rebuild; source auto-update downloads (their content could not be
    imported anyway).
  5. A schema version that does not match what the build expects is a clear, stated refusal — read-only
    mode cannot migrate, so the existing "migrate it" path must not be reached.
  6. /health reports healthy in intentional read-only mode, and names the active mode in its
    response so the two read-only states are distinguishable by a monitoring check, not only by eye.
    The response shape is a documented API surface: update docs/api-endpoints.md and the endpoint's
    [Description] in the same commit, per CLAUDE.md.
  7. Every GET endpoint serves normally, and the OpenAPI surface stays reachable.
  8. Every write path returns a clear, documented status naming read-only mode as the reason — never a
    500, never a raw SQLite exception, never a silent no-op. This includes the ones easy to overlook:
    notification dismiss, import-action decisions, and the admin reset/reseed/import endpoints.
  9. The startup log states the active mode plainly, and states the resolved keys location from
    requirement 10, so an operator reading the container log can tell an intentional read-only run from
    Startup crashes instead of degrading when the data directory is read-only and a migration is pending #326's degraded one at a glance.
  10. The DataProtection keys directory resolves through a fallback chain, so the UI keeps working when
    the preferred location is not writable.
    Same shape as the existing data-directory chain: an
    explicit configuration option first (no Quotinator:KeysPath exists today, unlike
    Quotinator:BackupPath and Quotinator:ImportsPath), then {dataDir}/keys as now, then a writable
    fallback location. Three constraints:
  • Keys persist across restarts wherever that is achievable. The chain's job is to find a
    location that survives; only when none exists does the app run with a key ring that will not.
  • A key ring that will not survive a restart says so, loudly. Log it, and surface it in the UX
    alongside the mode indicator. An unannounced non-persistent key ring reproduces exactly the
    antiforgery-decryption and Blazor-circuit failures after restart that {dataDir}/keys exists to
    avoid — which is the whole point of having a chain rather than a single location.
  • What the last resort actually is stays open, deliberately. CLAUDE.md currently says not to use
    UseEphemeralDataProtectionProvider (line 357), but that rule has a one-sentence rationale, no
    ADR behind it, and a stated reason — restart survival — that does not distinguish it from an
    equally non-persistent filesystem location. Implement the chain, gather what the modes actually
    do to the UI in practice, and settle it under requirement 13 rather than inheriting the rule
    unexamined.
  1. The UX states which read-only mode is active, in plain language, wherever the user can see it.
    Persistent, not a dismissible one-off — the mode lasts the whole run. It must distinguish
    intentional from safe/degraded on its face, not merely say "read-only": the first means working as
    asked, the second means something needs attention. All strings go in i18ntext/UI.*.json in all
    three languages, per CLAUDE.md's localisation checklist — never hardcoded in .razor markup.
  2. The mode behaves identically on writable and read-only storage. The one storage-dependent
    caveat: a database written by a normal run is left in WAL mode and cannot be opened read-only from
    read-only storage. Document how to produce a database file that can be — this is a prerequisite of
    the environment, not of the mode. With requirement 10 in place, the UI no longer needs a separate
    answer per storage type.
  3. Documentation: the option, what is and is not available in the mode, the keys fallback chain and
    how to tell which location is in use, and the WAL caveat above.
  4. Feed the ADR; do not write it yet. One ADR covers both read-only modes and the DataProtection
    key-storage question above, and it is written after both this issue and Safe read-only fallback: serve reads when the data directory is unwritable but the database is usable #336 are finished, when
    there is real evidence to support the choices rather than reasoning from first principles
    (developer decision, 2026-08-20). What this issue owes it: record what was
    actually observed — which key location each environment ended up using, what a non-persistent key
    ring actually costs the UI in practice, and which of the two modes' behaviours turned out to need
    a rule rather than a default. Keep those findings in the plan doc and the closing comment so the
    ADR has something to cite.

Follow-on — safe (degraded) read-only (#336)

Once the intentional mode exists, #326's degraded state has a better option available to it than
503-ing every non-exempt request. When the data directory turns out to be unwritable but the database
is otherwise usable — it opens, its schema matches this build, and it has data — the app can fall back
into read-only operation and keep serving reads, instead of serving nothing.

That is deliberately not part of this issue, for the reason the mode table states: it is a
different state that must report itself differently. /health must not call it healthy, or the failure
becomes invisible to monitoring at exactly the moment requests start succeeding again. This project
already has the right announcement mechanism — an ActionRequired notification — so the fallback
cannot pass unnoticed.

What this issue owes it: the read-only paths built here must be reachable from a runtime decision
rather than wired exclusively to a configuration value read once at startup, and the mode reporting in
requirements 6, 9 and 11 must already have somewhere to put the second state.

Relationship to #326 and #327

#326's failure-reason text tells the operator that an unwritable data directory is a problem to fix.
That stays correct for the unintended case, but must not contradict the intentional one — check the
wording when this lands.

#326's own handling of an uncreatable keys/ directory (marking the app degraded) is superseded by
requirement 10's fallback chain for the case where a fallback location exists. #326 ships first and is
not blocked by this; its plan doc should carry a pointer once this issue is filed.

#327's degradation smoke-test suite enumerates "data directory not writable" as a startup problem. An
intentional read-only run is not one, and must not be asserted as such.

Expected tests

Test class Test method Starts
SqliteConnectionFactoryTests CreateConnection_ReadOnlyMode_OpensExistingDatabase ❌
SqliteConnectionFactoryTests CreateConnection_ReadOnlyMode_RejectsWriteStatementsOnWritableStorage ❌
DatabaseInitializerReadOnlyTests InitialiseAsync_ReadOnlyMode_PerformsNoWriteAndAppliesNoMigration ❌
DatabaseInitializerReadOnlyTests InitialiseAsync_ReadOnlyMode_SchemaVersionMismatch_RefusesInsteadOfMigrating ❌
DataProtectionKeyPathTests Resolve_ConfiguredPathSet_PrefersItOverTheDataDirectory ❌
DataProtectionKeyPathTests Resolve_PreferredDirectoryNotWritable_FallsBackToAWritableLocation ❌
DataProtectionKeyPathTests Resolve_FallbackLocationUsed_ReportsThatItWillNotSurviveARestart ❌
ReadOnlyModeTests Startup_IntentionalReadOnly_ReportsHealthyRatherThanDegraded ❌
ReadOnlyModeTests Startup_IntentionalReadOnly_HealthNamesTheActiveMode ❌
ReadOnlyModeTests Startup_IntentionalReadOnly_GetEndpointsServeNormally ❌
ReadOnlyModeTests Startup_IntentionalReadOnly_OpenApiRemainsReachable ❌
ReadOnlyModeTests Startup_IntentionalReadOnly_WriteEndpointsRefuseWithAStatedReasonRatherThanFailing ❌
ReadOnlyModeTests Startup_IntentionalReadOnly_NotificationDismissRefusesWithAStatedReason ❌
ReadOnlyModeTests Startup_IntentionalReadOnly_UiStatesTheModeIsIntentionalRatherThanDegraded ❌
ReadOnlyModeTests Startup_ConfiguredReadOnlyMode_IsNotEnabledByAnUnwritableDirectoryAlone ❌

The unit tests above run on writable storage, which is the mode's primary environment. A live/T2 pass
additionally covers the second: a container run against a genuinely :ro-mounted volume with the mode
enabled, proving both environments are actually supported rather than only the convenient one, and that
the keys fallback works against a real read-only filesystem.

Definition of done

  • All expected tests listed above start red before implementation
  • All requirements implemented
  • All expected tests pass (green)
  • No regression in related tests
  • Findings summarised in a closing comment

Activity

  1. added this to the v1.9.0 milestone on Aug 20, 2026
  2. added 4 commits that reference this issue on Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions