Skip to content

M7-08 Maintenance mode read_only

No Date Ruling Reason
D-M7-08-1 2026-10-25 Exception table (design note 1, ADR 0065 item 4): one table in platform/httpserver (MaintenanceExceptions), held against docs/spec/actions.yaml by TestExceptionsAreCatalogueWrites. Always: auth.token.refresh, auth.step_up, session.revoke, device.revoke, token.revoke, user.disable, run.cancel. For an agent with a run in state running at request time (port runs.RunningRuns, one indexed read): agent.heartbeat and agent.events.consume with any running run, run.update and run.finish for the run of the path, message.create in the thread of the path and file.upload in the channel of the path when a running run's thread lies in it (runs has no channel_id, the thread's channel comes from chat.Threads.Locate). The upload routes that name only file_id (uploadPart, getUpload, completeUpload) pass for an agent with any running run: they continue a session createUpload opened, and the file reaches a thread only through message.create, which checks the thread. gateway.connect is no entry: its effect is read, so the layer never blocks it; the writes of the gateway follow D-M7-08-9. Every other action with effect write, also an action the catalogue does not know, gets 503 maintenance. The brief lists gateway.connect among the exceptions, but the catalogue marks it read and the test of design note 1 fails on an exception of a read action; uploads are bound to a channel, not a thread, in the contract (createUpload on /channels/{channel_id}/uploads), so the run's thread is matched through its channel.
D-M7-08-2 2026-10-25 Exemption API (design note 2, ADR 0065 item 5): txn.WithWriteInMaintenance(ctx) marks a context whose units of work may write, txn.ExemptInMaintenance(uow) a unit of work that always may. The maintenance stage marks the requests of an exception; the job runtime marks the kinds that run; the worker consumers get an exempt unit of work. pg.UnitConfig.ReadOnly (set only by bothy serve, never by the commands) begins every other outermost unit READ ONLY; its transaction records a 25006 wherever the error surfaces (statement, row, rows, batch), and the unit then fails with 503 maintenance and Retry-After from server.maintenance_retry_after, whatever the use case made of the error. Enforce instead of assume: a repository that maps the error or a use case that swallows it must not turn a refused write into a 500 or a silent success. The commands (bothy setup, bothy restore) keep writing, so a relocation can finalize on a target whose copied configuration still names the mode.
D-M7-08-3 2026-10-25 Refresh in maintenance mode (design note 3, 08 7.4): a due rotation becomes a reissue: the checks run as always (token, family, DPoP key, device, person, OIDC re-check), then a new access token with the normal lifetime and the presented refresh token with its stored expiry, without a write. A reuse still revokes the family and the session. The generation before the current one inside auth.refresh_reuse_grace still rotates. Returning the old generation unchanged would make the next refresh after the mode a reuse that revokes the family; the grace case only follows a lost answer of a rotation, so the write is rare and its loss on a target only costs a login there.
D-M7-08-4 2026-10-25 New key server.maintenance_retry_after (default 1m, 1s to 1h, chapter 16): the Retry-After of 503 maintenance from the stage and from the second line, and the pause after which the worker looks at a held job again. server.maintenance_mode becomes active as an enum off, read_only, read once at the start of api and worker. A move takes minutes, not the seconds of http.unavailable_retry_after; one key for both keeps clients and paused jobs on the same rhythm.
D-M7-08-5 2026-10-25 Metric bothy.maintenance.rejected (counter, {request}, only the attribute bothy.action, signals.md 4.1): one per request the stage refuses; the second line is not counted there (its 503 shows in http.server.request.duration). 05 rule 1.9 forbids an audit entry for the rejection; the action alone tells the owner what clients still try.
D-M7-08-6 2026-10-25 Worker (design note 4, 02 12.2): the job runtime fetches only from the queues delivery and maintenance, registers no periodic job, and runs every kind of the queue delivery and runs.cancel_timeout; a job of another kind on the queue maintenance returns River's snooze for server.maintenance_retry_after, which keeps its attempts, so a restart without the mode runs it as before. The consumers push and webhooks keep running. River fetches by queue, not by kind, and runs.cancel_timeout shares the queue maintenance with the jobs that must pause; a missing worker would count attempts and finally discard the job. River's own tables (schema jobs) are infrastructure like the delivery tables.
D-M7-08-7 2026-10-25 Side writes: in maintenance mode last_seen_at of app sessions and devices and last_used_at of API tokens are not written, and the own measurement writes nothing to stats_minutes. A console session keeps writing last_seen_at, because the console ends after auth.console_session_idle_timeout without it and a new login is a write the mode refuses. ADR 0065 item 5 drops the side writes; the measurement would also count every 503 maintenance against sli.api_availability. The console idle clock is the one side write without which the owner would lose the console in a long window.
D-M7-08-8 2026-10-25 Status page (ADR 0065 item 6): the read model shows every component that belongs to no agent as maintenance unless its stored state is worse, and overall_state at least maintenance, derived from the configuration on every read; nothing is written and status.probe does not run in the mode. An outage must stay visible behind the maintenance; the rows of status_components belong to the probe and the owner.
D-M7-08-9 2026-10-25 Gateway in maintenance mode: the op run.update patches with the exemption when the run is the agent's running run, like patchRun through the stage (wire.maintenancePatcher); the stored presence of the agents (agents.reported_runtime_state, last_heartbeat_at and the audit entry of a changed mode) and the presence rows of the gateway sessions (mode multi) keep being written over an exempt unit of work; the holder and the cursor of an agent consumer (identify, resume, ack) are written on the pool as before. The ops of the gateway are not behind the HTTP stage, so the ack of an agent without a running run is not refused. An adapter that reports over the gateway must be able to finish its run (ADR 0065 item 4). Presence, holder and cursor are runtime state of the connections: on a target the next heartbeat and the next ack replace them, and a lost ack only delivers events again (at least once, ADR 0051).
D-M7-08-10 2026-10-25 Audit in maintenance mode: the audit recorder runs its own units of work (a denial written after a rollback, the aggregated denials, a new IP key of the period) over txn.ExemptInMaintenance; an entry inside a caller's unit of work is written or refused with that unit. The rejection of the maintenance stage itself is never audited. The denials of the exceptions and of reads (for example gateway.connect) are security records a window must not lose; the aggregator writes in the background, outside every exempt request.
D-M7-08-11 2026-10-25 Start in maintenance mode: the reconcile of the terminal sessions of a single node at the start (M2-44 design note 14) waits for the next start without the mode, like it waits while the start lock of R-DM-50 holds; the consumers ensure their cursor rows over their exempt unit of work. Every other start step that would have to write (for example a missing signing key) fails the start with maintenance. The reconcile writes audit entries and events for sessions the restart ended; in the mode they would refuse the start. The sessions stay attached in the copy, and the next start without the mode (or the first start of the target) ends them.
D-M7-08-12 2026-10-25 Session ends of the authentication: the units of work in which the authentication ends a console session it found expired or idle, clears a view as a person past authz.view_as_max_duration, or revokes a session whose trusted header binding failed, write in maintenance mode too (txn.WithWriteInMaintenance). They run before the maintenance stage, so without the exemption a console whose view expired in the window would answer every request, also an emergency revocation, with 503 maintenance; ADR 0065 item 4 keeps revocations possible.
D-M7-08-13 2026-10-25 Agent deliveries in maintenance mode: runs.Delivery.Prepare holds every invocation-bearing event whose run is not a run of the agent in state running at that moment, and every command.invoked (DeliveryOutcome.Hold): nothing is checked, signed or written. The gateway consumer then stops before the event, keeps its read position and reads again after a pause (gatewayapp.ErrDeliveryHeld, the backoff of a failed page read), without closing the connection; a REST poll answers the events before it with a next_cursor before it. The delivery of a running run of the agent (its follow-up, an interaction of its card) goes on and may write. Later events of the same consumer wait behind a held event, also a run.cancel_requested; a cancelled running run then ends through runs.cancel_timeout after agent.cancel_grace, which runs in the mode. Queued and waiting runs wait unchanged (ADR 0065 item 4): delivering their invocation writes the jti and the trigger state, and skipping it would lose the invocation for good once the agent acknowledges past it. Order is the contract of a consumer (cumulative acknowledgement), so nothing may overtake the held event.
D-M7-08-14 2026-10-25 Follow-ups: when a running run ends in the mode (run.finish, run.cancel, runs.cancel_timeout), its queued follow-up messages open their run as always (M2-11). That run stays queued (its invocation is held, run.start is refused) and is part of the copy when it was opened before the final backup. The follow-up rule belongs to ending a run, which the mode allows; the runbook waits for "Running now" to be empty before the final backup, so the new queued run is in the copy and starts on the target.