Maintenance mode read_only¶
Context and Problem Statement¶
A move to another target and a major version upgrade of PostgreSQL copy the state of an
instance (16-operations.md, sections 10 and 11;
cloud-migration.md). During the final backup the
old instance must not accept anything that would be missing on the target. People should still be
able to read, and running agent runs should end cleanly. The core contract specifies the layer and
the error (05-actions.md, rule 1.9: 503 maintenance with Retry-After,
reading and catch-up continue). What is still open is the exact meaning: which action counts as
writing, which writes remain allowed, and what prevents an overlooked path from writing
after all?
Decision Drivers¶
- After the switch, no state with a business effect is created, except for the defined exceptions; otherwise it is missing on the target.
- Reading, catch-up and file retrieval continue (05, rule 1.9).
- Running runs may end, new ones are not created (PLAN.md, build ticket M7-08).
- Clients recognize the state and keep their outbox (
Retry-After). - Enforce instead of assume: an overlooked write path fails visibly.
- Every action is classified unambiguously. The column "Permission" from 05 is not enough for this:
settings.readandsettings.updateboth have the permissionself.
Considered Options¶
- Configuration value
server.maintenance_mode = read_only; classification of every action in the catalogue, fixed exceptions for finishing runs, token refresh without rotation and deliveries; read-only transactions as a second line - Stop the server entirely
- Set only the database to read-only (
default_transaction_read_only) - A switch in the database that the owner flips through the API
- Maintenance per module (for example only messages blocked)
Decision Outcome¶
Chosen option: "server.maintenance_mode = read_only with classification in the catalogue, fixed
exceptions and read-only transactions", because only it allows reading, lets runs end and catches
every other write attempt in two places.
- Switch.
server.maintenance_mode(offorread_only) is configuration of the instance operator and takes effect at the start ofapiandworker(R-AR-15). Reason against a switch in the database: flipping it would itself be a write, the switch would have to exempt itself, and maintenance is a step of the instance operator around a copy, not a product switch. - Classification. Every action in the catalogue (ADR 0049) carries
effect: readoreffect: write;writemeans: the action can change durable state. This replaces the derivation from the column "Permission" in 05, rule 1.9. - Layer. After the rate limit and before
authorize(05, section 2.5), the layer answers every action witheffect: writethat has no exception with503and problem codemaintenancetogether withRetry-After. It writes no audit entry for this (that would be a write and not a permission decision); a metric counts the rejections. - Exceptions.
- Finishing running runs: for an agent with a run in state
running,run.update,run.finish,agent.heartbeatandagent.events.consume(ack) remain allowed, plusmessage.createandfile.uploadin the thread of this run (the answer).run.cancelremains allowed for people so that the owner can end hanging runs; the jobruns.cancel_timeoutkeeps running.run.startis blocked: runs inqueuedandwaiting_inputwait and survive the copy because their state is in the database. - Token refresh:
auth.token.refreshissues a new access token without rotating the refresh token and without writing. Reason: otherwise reading ends afterauth.access_token_ttl; a rotation after the final backup would be missing on the target and would log the person out there. Refresh tokens are bound to the device key; without rotation the sender binding remains, which RFC 9700 names as an alternative to rotation (08-identity.md, section 7.4). - Security reactions: the revocation of a refresh family on detected reuse runs
as always, and the emergency actions
auth.step_up,session.revoke,device.revoke,token.revokeanduser.disableremain allowed. Reason: a stolen device must be revocable in the maintenance window too. If such an action falls after the final backup, the owner repeats it on the target. - Deliveries: in the
worker, the consumerspushandwebhooksand their jobs keep running (ADR 0051), as doesruns.cancel_timeout. All other queues and periodic jobs pause. gateway.connectremains allowed; it may keep writing the fencing column of the agent consumers in modemulti, because that column has no meaning on the target.
- Finishing running runs: for an agent with a run in state
- Second line. In mode
read_onlythe Unit of Work opens every transaction asREAD ONLY, except for the exceptions from item 4. PostgreSQL then rejectsINSERT,UPDATE,DELETEand DDL with SQLSTATE25006[Q1, verified]; the server answers503 maintenance. Side writes such aslast_used_atof tokens and sessions are explicitly dropped in this mode. - Display. The status page and the console report the maintenance, derived from the
configuration without a write. On
503 maintenanceapps show a notice instead of an error and resend their outbox afterRetry-After.
The mode is not meant for a point-in-time restore: there the database is
replaced, and api and worker are stopped
(backup-restore.md, section 5.2).
Consequences¶
- Good, because people can read, search and retrieve files during the move and runs can still deliver their answer.
- Good, because the state after waiting for the runs matches the state on the target; only deliveries can still change rows afterwards.
- Good, because an overlooked write path fails at PostgreSQL instead of silently creating data that would be missing on the target.
- Bad, because the catalogue gets another attribute and the exceptions for agents depend on the
run state; for this the layer needs a query to the module
runs. - Bad, because switching on and off needs a restart; clients reconnect with
4013. - Bad, because deliveries after the final backup can happen a second time on the target
(push twice, webhook with the same
webhook-id); delivery is at least once anyway. - Bad, because no new devices can be registered in the window and logging out only has a local effect.
- Bad, because emergency revocations after the final backup are missing on the target; the runbook of the move must remind the owner to repeat them there.
- Neutral, because 08 gets an exception from rotation that applies only in this mode.
Confirmation¶
- Build ticket M7-08 in PLAN.md: a message results in
503withRetry-After; a running run can end; new runs are not created; the status page shows maintenance. - Table test over the catalogue: every action has
effect; every action witheffect: writewithout an exception results in503 maintenancein the mode, no action witheffect: readdoes. - Test of the second line: a test handler with an unclassified write fails in the
mode with
503, and the database is unchanged. - Test of the refresh: in the mode
auth.token.refreshreturns a new access token and the same refresh token; the rows of the session and the refresh family are the same before and after. - Test in the
worker: in the mode only delivery jobs andruns.cancel_timeoutrun. - Test of the security reactions: in the mode
device.revokewith step-up takes effect (the device's connection closes with4012), and reuse of a refresh token revokes the family. - Rehearsal of the move (cloud-migration.md, section 3): the manifest of the final backup equals the state at the end of the window, apart from the delivery tables.
Pros and Cons of the Options¶
Configuration with classification, exceptions and a second line¶
- Good, because reading remains possible, runs end and two independent places prevent writes.
- Bad, because it is the most work of these options: catalogue attribute, exceptions, mode of the Unit of Work, token path.
Stop the server entirely¶
- Good, because it is the simplest and safely without any write.
- Bad, because nobody can read, clients show "Server unreachable" instead of maintenance and running runs abort; their work and their costs are lost.
Only the database in read-only mode¶
- Good, because PostgreSQL enforces it [Q1, verified].
- Bad, because errors occur only after work in the handler has begun and arrive without
Retry-After; exceptions for runs would still need a classification per action. Therefore only as the second line.
Switch in the database¶
- Good, because no restart is needed.
- Bad, because flipping it writes and the switch would have to exempt itself; an owner switch in the interface invites using it without the copy procedure.
Maintenance per module¶
- Good, because it can be controlled more finely.
- Bad, because a copy needs the whole state; partial blocks leave write paths open and make the comparison of the manifests impossible.
More Information¶
- Source (retrieved 2026-10-10): Q1 PostgreSQL 18,
SET TRANSACTION(read mode) and error code25006 read_only_sql_transaction, https://www.postgresql.org/docs/18/sql-set-transaction.html and https://www.postgresql.org/docs/18/errcodes-appendix.html [verified]. - Problem code
maintenance: 06-api.md, section 5. - Related: ADR 0015 (error format), ADR 0020 (DPoP), ADR 0049 (action catalogue), ADR 0051 (worker consumers), ADR 0058 (backup), ADR 0059 (sync epoch).