You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Intentional read-only mode: run the app and database without writes #332
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:
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.
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:
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.
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
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.
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.
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.
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.
/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.
Every GET endpoint serves normally, and the OpenAPI surface stays reachable.
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.
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.
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.
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.
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.
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.
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.
#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.
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
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 adminendpoint 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:
/healthreports unhealthy and every non-exempt requestis gated to 503.
Two distinct needs want the same capability:
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.
Kubernetes
readOnlyRootFilesystemdeployment, or a curated dataset no running instance should beable 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:
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=MEMORYis 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
SELECTfail on read-only media. That hazard is alreadyremoved.
SqliteConnectionFactoryhardcodesData Source={dbPath}with no mode parameter, so openingconnections read-only is a change to a public
Quotinator.Datatype, not just Api wiring.Program.csalready 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.
must create the
-shmwal-index. This does not arise on writable storage, where the wal-index canstill be created and
Mode=ReadOnlyalone gives the guarantee.What needs to be done
environment-variable reachable, following the existing
Quotinator:*conventions. Set explicitly bythe 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.enum, not abool. There are three states — normal, intentionalread-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 ispersisted or reported through a persisted column, ADR 008's CHECK-constraint checklist applies.
SqliteConnectionFactoryopens connections read-only when the mode is on. This is what makes theguarantee 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.
left to fail and be caught:
EnableWal; migrations and their pre-migration backup; content seedingand 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 notificationseeders; 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).
mode cannot migrate, so the existing "migrate it" path must not be reached.
/healthreports healthy in intentional read-only mode, and names the active mode in itsresponse 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.mdand the endpoint's[Description]in the same commit, per CLAUDE.md.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.
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.
the preferred location is not writable. Same shape as the existing data-directory chain: an
explicit configuration option first (no
Quotinator:KeysPathexists today, unlikeQuotinator:BackupPathandQuotinator:ImportsPath), then{dataDir}/keysas now, then a writablefallback location. Three constraints:
location that survives; only when none exists does the app run with a key ring that will not.
alongside the mode indicator. An unannounced non-persistent key ring reproduces exactly the
antiforgery-decryption and Blazor-circuit failures after restart that
{dataDir}/keysexists toavoid — which is the whole point of having a chain rather than a single location.
UseEphemeralDataProtectionProvider(line 357), but that rule has a one-sentence rationale, noADR 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.
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.*.jsonin allthree languages, per CLAUDE.md's localisation checklist — never hardcoded in
.razormarkup.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.
how to tell which location is in use, and the WAL caveat above.
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.
/healthmust not call it healthy, or the failurebecomes invisible to monitoring at exactly the moment requests start succeeding again. This project
already has the right announcement mechanism — an
ActionRequirednotification — so the fallbackcannot 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 byrequirement 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
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 modeenabled, 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