What needs a decision reaches a person by itself (#167) - #184
Merged
Conversation
The R360 collector had been reporting two frame sets nobody's record names for two days, into a container's stdout, and the report reached a person exactly once — because someone went in over SSH to look. The same silence covered the disk, whether the site answers, the nightly copy, server errors and the mail provider. The design kept leaving decisions to a human, and no code path ever carried one to the human. Now, two watchers, because a box cannot report its own death: - deploy/ops-check.sh, hourly on the instance: the disk, the containers, whether the site answers, the age of the nightly copy, preview copies of dev's database that nothing owns, the collector's findings, database deadlines, unhandled errors, and what the mail provider did with ours. It mails OPS_EMAIL when something needs acting on — once, again a day later if it is still true, and once when it stops — and every morning either way, so an empty inbox means "checked" and a missing report is itself the signal. - .github/workflows/watch.yml, every thirty minutes from GitHub: whether the site answers from outside. It opens an `outage` issue and closes it when the site is back, so the label's state is the answer. Three tiers and no more. The reports carry counts and the instance's own figures, never an address, a handle or a line of any log: the mailbox may sit outside the EU. No new provider and no new cost — the mail goes through the transactional provider the application already uses, with the same key. The application's and the proxy's logs move to the host journal, capped at 300 MB. A log inside the container's directory was deleted with the container on every deploy, so the hour before a bad deploy was always gone by the time anyone looked. Preview containers keep a file log, capped. Its first dry run on dev found two real things at once: the disk at 92% (#119) and a copy of dev's database that no preview owned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The worst of it was the failure path the check exists for. `send` was only ever called as an `if` condition, and bash switches `set -e` off inside a function called that way — so a mail the provider refused ran on to "mailed", returned success, and was recorded as sent: the next attempt a day later, the daily report marked done. The same session caught this trap in remote-deploy.sh, and it was written again here. Every step in send now says itself what happens when it fails, and a missing OPS_EMAIL no longer counts as delivered, so the first real recipient still gets the standing alert. The rest was about ending in silence exactly when it mattered: - Docker unreachable made the first pipeline fail and the script stop before writing a report, and `Requires=docker.service` would have kept the check from starting at all. Now Docker not answering is itself an ACT NOW line, every call that can hang has a time limit, and the unit only Wants Docker. - A full disk would have stopped the mail at the header file. The key now reaches curl through a file descriptor — no disk, no argv, and exported to nothing else the script runs. - An unreadable journal read as "no unhandled server errors". A read that fails says so; zero lines from a container up longer than the collector's interval says the log is not being read. - A mail provider error reset the blocklist baseline to 0, and the next good read reported a rise that never happened. Only a count actually read becomes the baseline, matched to the sending domain by name. - The subject was always the first check's line, so a backup stopping while the disk alert stood would have arrived looking like the disk alert again. It is named after what is new, and each condition resolves on its own. - One slow answer on one core would have mailed ACT NOW and "resolved" an hour apart. The site, the containers and Docker need two looks in a row. - The daily report retries the next hour if its hour's run failed or was late. - The body is one function, so a deploy rewriting the file mid-run cannot make bash run half of each version. And from the security lane: the journal is kept two weeks with a file a day, so the retention is real rather than "a month, give or take"; the application's lines are kept out of /var/log/syslog, where Ubuntu would have made a second, uncapped copy; the unit runs with NoNewPrivileges and a read-only system; the watcher checks https addresses only, holds its token in the one step that needs it, locks the issue it opens, and a hand-run drill has its own label so it can never open or close dev's real alarm. Failed queries being logged with their parameters — session tokens among them — is filed as #186. Verified on the instance under the unit's exact sandbox (systemd-run with the same properties): Docker answers, the journal is readable through the supplementary group, the mail domain is found by name, and no alert state is recorded while there is no recipient. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #167 — the issue stays open until
OPS_EMAILis set on the instance andthe first daily report has arrived (the recipient is the owner's to choose).
The R360 collector had been reporting two frame sets that no record names for
two days, into a container's stdout, and the report reached a person exactly
once — because someone went in over SSH to look. The same silence covered the
disk, whether the site answers, the nightly copy, server errors and the mail
provider. The design kept leaving decisions to a human, and no code path ever
carried one to the human.
Two watchers, because a box cannot report its own death
deploy/ops-check.shplatform-ops.timer.github/workflows/watch.ymlThree tiers and no more. ACT NOW is mailed when a condition starts, again a
day later if it is still true, and once when it stops — keyed on the condition,
not the numbers inside it, so a disk going from 91% to 92% is one alert, not
one an hour. THIS WEEK and RECORD go in the report that arrives every
morning either way: an empty inbox means "checked", and a missing report is
itself the signal.
The outside check speaks through an
outageissue — opened when the sitestops answering (three tries a minute apart; a 4xx is an answer), closed with
the time it came back. No new provider, no cost while the repository is
public, the owner's phone through GitHub's own notifications, and the label's
state is the answer.
Logs move to the host journal, capped at 300 MB and thirty days. A log in
the container's own directory died with the container, which is every deploy —
the hour before a bad deploy was always gone by the time anyone looked.
Preview containers keep a file log, capped at three 10 MB files.
What it never sends
No address, handle, name or line of any log — counts and the instance's own
figures only. The mailbox may be outside the EU (§7). The mail statistics and
the provider's blocklist are read as
total_count/failed_countfor exactlythat reason (shapes read from the real API on 12.09.2026, not assumed).
Decisions taken
means a new provider, a recurring cost and log lines carrying personal data
out of the EU — all §7 "ask first", and the need is covered without them.
Nothing new to pay for or to sign up to.
(SPEC §7 "ask first"), and it is the one Operations: the state of an environment, and the reports that reach nobody #167's acceptance criteria require:
"from somewhere that is not the instance". If the repository goes private
(Take the repository private again — what it costs and what must be in place first #163) it costs minutes: 48 runs a day, well under a minute each.
Verified
The check was run on dev by hand before this pull request, with no recipient
set so nothing could be sent. It found two real things at once:
To verify after merge: the deploy installs the timer and the journal cap, dev's
app and proxy log to the journal (
docker inspectshowsjournald), thecollector's startup run lands in the journal and the next check reports it,
and the
Watchworkflow run by hand againsthttps://down.invalid/opens anoutageissue that the next run against dev closes.Not verified, on purpose: stopping dev to watch the outside check notice (the
hand-run against an address that cannot answer tests the same path without an
outage), and a hard bounce (sending to a bouncing address costs the sending
domain reputation).
🤖 Generated with Claude Code
What the two review lanes changed
A refused mail was recorded as sent.
sendwas only ever called as anifcondition, and bash switches
set -eoff inside a function called that way —the trap
remote-deploy.shalready documents, written again here. A providererror ran on to "mailed", returned success, and the state said delivered: no
retry for a day, the daily report marked done. Every step in
sendnow saysitself what happens on failure (0 sent, 1 failed, 2 nowhere to send), and a
missing
OPS_EMAILis no longer recorded as delivered — so the first realrecipient still receives the alert that was standing before they existed.
Each of the rest ended in silence exactly when the check was needed:
report;
Requires=docker.servicewould have stopped it starting. Now "Dockeris not answering" is an ACT NOW line, every call that can hang has a time
limit, the unit
WantsDocker, and its timeout is the sum of the call limits.through a file descriptor: no disk, no argv, exported to nothing else.
says so, and zero lines from a container up longer than the collector's
twelve-hour interval says the log is not being read.
reported a rise that never happened. Only a count actually read becomes the
baseline, and the domain is matched to
EMAIL_FROMby name.the disk alert stood would have looked like the disk alert re-sent. Subjects
name what is new; each condition resolves on its own.
hour apart. The site, the containers and Docker need two looks in a row.
late; the body is one function, so a deploy rewriting the file mid-run cannot
make bash execute half of each version; numbers from the API and from state
files are validated before they reach shell arithmetic.
From the security lane:
file covers a month, so "30 days" meant up to sixty. Now one file a day, two
weeks, and
SystemKeepFree=500M— the default 15% is already broken by a diskat 92%, and journald would otherwise keep almost nothing.
/var/log/syslog. Ubuntu forwards the journal torsyslog (confirmed on the instance: active,
ForwardToSyslog=yes, weeklyrotation for a month, no size cap). The containers now log under a tag and an
rsyslog rule drops those lines, and only those —
auth.logis untouched.NoNewPrivileges,ProtectSystem=strict,ProtectHome=read-only,PrivateDevices,RestrictSUIDSGID,MemoryMax=128M.--proto '=https' --url;the token lives in the one step that needs it;
permissionsisissues: writealone; the issue it opens is locked, since the thread notifies theowner; and a hand-run drill uses its own
outage-drilllabel, so it can neveropen or close dev's real alarm.
addresses among them. Two weeks bounds that, it does not remove it: filed as
Failed queries are logged with their parameters — session tokens and addresses in the journal #186, with a "never paste a log line" rule in
docs/operations.mduntil then.Verified on the instance under the unit's exact sandbox (
systemd-runwiththe same properties, then removed): Docker answers under
NoNewPrivilegesanda read-only system, the journal is readable through the supplementary group,
the mail domain is found by name, the state directory is written, and no alert
state is recorded while there is no recipient.