oto — orientation for implementers
Read this first. Then read docs/design/SPEC.md §A–§C and the section for your module.
SPEC.md is binding; this file is the map.
1. What oto is
Section titled “1. What oto is”The alert history layer your Prometheus stack does not have — self-hosted, with a UI, without adopting an AIOps platform or a paid SaaS.
For every alert that has ever fired, oto shows: when it first appeared, every episode since, what the rule said at that moment, who was told, on which channel, in which thread, who acknowledged it, and how it ended — as one continuous, replayable timeline.
Why this exists. Alertmanager holds no history (it is a silence console). Grafana’s history covers only Grafana-managed rules. Robusta’s UI is SaaS-or-enterprise. Keep is incident-first and generic. Grafana OnCall OSS was archived 2026-03-24, leaving a documented vacuum for a self-hostable OSS Slack-first alert layer over an existing Prometheus/Alertmanager stack.
What oto is not — permanently: an incident manager, an on-call/paging system, a workflow engine, a rule editor, or anything that tracks who owes work. oto draws Incidents — a set of Cases told as one story (ADR 0052) — but it does not manage one: the response (status, lead, severity, comms, write-up) lives in the incident tool the Incident is declared to, and oto only sends that tool facts. oto is a flight recorder. It records the aircraft. It does not fly the plane, roster the crew, decide who is in command, or write the accident report — and it is trusted precisely because it does none of those things.
1b. THE LINE — read this before adding any feature
Section titled “1b. THE LINE — read this before adding any feature”docs/design/SCOPE-BOUNDARY.md and ADR 0013 are binding. Cite them by clause number.
FR-1, the Flight Recorder Test
Section titled “FR-1, the Flight Recorder Test”Complete this sentence about the row your feature writes: “This row is a fact about ______.”
- A signal — Alert, AlertCase, RuleSnapshot, Notification, Delivery, AlertSource, or an aggregate over those → IN.
- A person, team, rota, responsibility, response effort, ticket, or customer-facing statement → OUT.
Human actions are IN only as timestamped annotations on a signal’s timeline, carrying an actor as metadata. In one line: ACTOR, NEVER SUBJECT.
case.acked_by = alice is a fact about the case — it was acknowledged, by whom.
case.assigned_to = alice is a fact about Alice — she owes work. Identical columns;
opposite products. That is the whole line.
Secondary heuristics, in order, when FR-1 does not decide cleanly:
- H-1 Obligation — does it create, route to, or discharge an obligation on a named human? → OUT.
- H-2 Orphan — delete every alert; does the artefact still mean something? → OUT. (Config exempt.)
- H-3 Write-path — does it change any system outside oto’s DB and its channels? → OUT of v1, on safety grounds. A separate axis from FR-1, and the only one that is earnable.
FR-1 refusals are permanent. H-3 refusals (e.g. R3, silence-write) are earnable. Do not conflate them; they have different expiry dates.
⚠️ One H-3 refusal has been earned, and one FR-1 noun admitted with it (ADR 0054, accepted
2026-10-02): a cluster write is IN as a Remedy — proposed by an Investigator, executed by the
operator’s ToolServer only after human approval, every transition a fact — which is SCOPE-BOUNDARY
verdict #23 earned on SS-4’s terms (an audit trail and a confirmation UX). Remedy is the only
response-effort noun FR-1 now admits. Silence-write (R3) is still unearned.
The four doors that must stay shut
Section titled “The four doors that must stay shut”| Door | Clause |
|---|---|
No person-reference column on a signal row. No assigned_to, owner_id, watchers, incident_id, ticket_id, sla_due_at. acked_by is past-tense attribution and is the only exception. |
SPEC §D.4.0 |
No unprompted reminder, at all. The one reminder stage was withdrawn (git-bug bd0fb1d); oto sends nothing nobody asked for. This SUPERSEDES “one stage, forever” and is strictly stronger — there is no first stage for a second to follow. Re-adding any unprompted reminder needs an ADR arguing against FR-1 by name. escalation remains a banned word. |
SPEC §G.9.1 |
No human writes a signal’s state. There is no POST /alerts/{id}/resolve, ever — nor close, merge, dismiss, reopen. ack_state is the only state axis a human may write. |
SPEC §E.1.1 |
notification_policies routes to destinations, never to people. No user_ids, schedule_id, time_of_day. |
SPEC §D.8 |
Permanently out of scope, with hand-offs: SPEC §I.1.1.
2. The three commitments
Section titled “2. The three commitments”- Append-only truth. Every observation and action is an immutable
AlertEvent; current state is a projection. If you would ever want toUPDATEit, it is not an Event. - Ingestion is decoupled from everything. The webhook path does two things in one transaction — durably record the raw payload, enqueue work. Slack, Prometheus and enrichment are never on it.
- Interfaces before implementations.
Channel,Renderer,Enricher,AlertmanagerClientare ports with registries. Slack and the generic webhook are one-of-N implementations.
3. Domain language (use these exact words)
Section titled “3. Domain language (use these exact words)”| Term | Means |
|---|---|
| Alert | The identity of a label set within (org, cluster). Created on first sight, survives resolution forever. oto’s answer to Sentry’s Issue. |
| AlertCase | One contiguous firing episode of an Alert, (alert_id, seq). What you ack; whose FIRING DURATION is measured. Never “MTTR” — banned (§A.1). Strictly terminal: open → closed, once. A re-fire opens the next seq, never revives this one (ADR 0040). Two numbers, and they are not interchangeable: seq is the firing ordinal within ONE alert, number is the case’s name within the ORG — unique, monotonic, what a human quotes, and what /cases leads each row with (migration 00081). Forty alerts that have each fired once carry forty numbers and forty seq of 1. |
| AlertEvent | One immutable thing that happened at one instant. The timeline. Append-only. |
| Incident | A set of one or more Cases drawn together as one story — an Alert has Cases, an Incident spans Cases. A machine (by a stated rule) or a human may draw one. Inside oto it is a fact about signals: it is active while any of its Cases is open and quiet otherwise — read off its Cases, never set by a hand. Its response — status, lead, severity, comms, write-up — is managed in an external tool it is declared to, and oto holds only the outbound mapping to that tool’s object; nothing about the response is read back (ADR 0052). Avoid: arc, group, cluster, episode, storm, outage, correlation (that names the relationship, not the container). |
| Correlator | An operator-written definition that draws Incidents — matchers over Cases, optionally with a count over a window — so that why a Case is in an Incident always has an answer someone can read back. Only a Correlator or a human draws an Incident; a model may propose membership, never decide it (ADR 0052). Avoid: rule (that is the Prometheus alerting rule), incident rule, policy (that routes notifications). |
| RuleSnapshot | A content-addressed capture of a Prometheus alerting rule at a point in time. The differentiator. |
| AlertSource | One configured Alertmanager (+ optional Prometheus). HA replicas share a Cluster. |
| Cluster | Identity/failure domain. cluster_key participates in alert identity. |
| Channel | A configured destination instance (“Slack workspace T123, #sre-alerts”). Not a channel type. |
| Payload mapping | An optional document on a webhook Connection that turns the oto.notification.v1 envelope into the request a particular incident tool expects, and says where its response names the external incident. It is destination setup, not wording: a failure is a failed delivery, never a fallback. Without one, the plain envelope is sent. (ADR 0055) Avoid: template (that is wording, ADR 0050), plugin (a mapping is data, not code), provider. |
| Notification | The channel-agnostic intent to communicate one fact. Idempotent. |
| NotificationDelivery | One materialisation of a Notification on one Channel. Owns retry state and provider ids. |
| Conversation | What a channel_threads row is about, as the pair (conversation_kind, conversation_id): a Case, a digest, or an Incident whose Correlator says its Incidents are conversations (ADR 0052 §6). A Case conversation holds exactly one Case — a new Case always means a new thread, unless it joins an Incident that is a conversation, in which case later facts about it share the Incident’s thread; nothing already posted moves, and nothing is held back to wait for a Correlator. A digest’s conversation is keyed by the policy that asked for it, one per policy per channel. It is what decides which facts share a message, and that decision belongs to the notification layer and to what an operator wrote, never to a stored grouping oto derived on its own (git-bug 7570090, migration 00069). The Incident kind is the operator-written exception to ADR 0045’s “N alerts, N conversations”, not a return of AlertGroup. |
| ChannelThread | The binding of a Conversation to (slack_channel_id, root_ts). |
| Enrichment | One typed, provenanced result from one named, versioned Enricher. |
| Investigator | A named, versioned configuration of a model-driven investigation: which model, which prompt, which Tools it may call, and its budgets. The AI-for-SRE category word “agent” may describe it in marketing, never name it. (ADR 0053, accepted 2026-10-02.) Avoid: agent (that is vmagent in a VictoriaMetrics stack, and the in-cluster daemon ADR 0016 rejected), bot, assistant. |
| ToolServer | One configured MCP server an Investigator may read through. It is where trust stops: oto holds no cluster credential, the ToolServer’s operator does — oto keeps only the ToolServer’s own access token, sealed. Its access is declared read or write: only a read ToolServer’s Tools may be on an Investigator’s allowlist, and a write one is never offered to a model (it is where a Remedy’s write Tool is bound). Reached over HTTP only — oto starts no process. (ADR 0053, accepted 2026-10-02; git-bug 2e9a086.) Avoid: connection (that is a channel’s org-wide setup, ADR 0047). |
| Tool | One capability a ToolServer exposes that an Investigator is allowed to call, named <toolserver>__<tool> on an allowlist and to a model. oto’s own history — prior Findings, a Case’s timeline, the rule as it stood at fire time — is offered as built-in Tools on the same footing. A Tool an Investigator holds while investigating is read-only; a write Tool is called only to execute an approved Remedy. (ADR 0053, accepted 2026-10-02.) Avoid: action (that is the Slack button row). |
| Investigation | One run of one Investigator against one subject — a Case, an Incident, a digest or a policy — frozen once it ends. A subject may have many over time; the latest one’s Finding is the one shown. (ADR 0053, accepted 2026-10-02.) Avoid: analysis, triage (banned), probe (a source health check). |
| Step | One immutable entry in an Investigation’s transcript: a model turn, a Tool call, or a Tool’s result. If you would ever UPDATE it, it is not a Step. (ADR 0053, accepted 2026-10-02.) Avoid: event (unqualified is banned), observation (that is ingestion’s). |
| Finding | What an Investigation concluded about its subject — a summary, a classification, and the Steps it rests on. A snapshot of what was seen at that time, never a live view, and never an input to whether a notification is sent. It may cite other Cases; citing is not drawing an Incident. (ADR 0053, accepted 2026-10-02.) Avoid: verdict, evidence (both are drills’), insight, recommendation. |
| Remedy | A change to a cluster that an Investigator proposes and oto executes only once a human approves it — any write Tool or command the ToolServer’s permissions allow. A Remedy names the write Tool that would carry it out, or says that no configured Tool can — and then it cannot be approved. It is proposed, then approved and executed or failed, or declined, or expired, each by a named actor. Only a user granted approval on that Remedy’s ToolServer may approve, and a Remedy judged risky needs two different such users. Every transition is sent to the declared Incident as a fact. Responsibility rests with the approvers and with the permissions the ToolServer was granted. (ADR 0054, accepted 2026-10-02, which supersedes FR-1’s “response effort” refusal for this one noun. No Remedy ships before the one authz approval grant its §4 names.) Avoid: action (the Slack button row), fix, remediation, runbook, workflow. |
| Classification | The class a Finding places its subject in, chosen from a closed set the operator wrote — or unclassified, which is always admissible and is the right answer under doubt. oto ships no classes of its own. A Finding keeps the class it was given even if the set later changes. (ADR 0053, accepted 2026-10-02.) Avoid: severity, priority (both banned as human-set axes), category, label (that is a Prometheus label), noise (oto’s opinion of someone else’s signal). |
| Suggestion | A change a Finding proposes and only a human can apply: to oto’s own configuration (a policy’s count condition), or to an Incident’s membership. It is applied or it lapses; there is no reject verb. Never a change to anything outside oto. (ADR 0053, accepted 2026-10-02.) Avoid: proposal, recommendation, action, fix, remediation. |
| Silence | A read-only mirror of an Alertmanager silence. |
| Stanza | One named, ordered unit of a rendered message: title, body, fields, members, trail, rule, actions, footer (SPEC §H.7’s block budget). ⚠️ IT IS AN INTERNAL NOUN AGAIN. It named a customer-facing override slot under ADR 0037; ADR 0050 withdrew that, and a Stanza is now only how the Slack renderer talks about blocks Go builds. Nothing an operator writes names one. It is not a block — that is Slack Block Kit’s own type name, and it cannot name the same unit on the webhook renderer. |
| NotificationTemplate | One whole notification message an operator wrote — a document you can read top to bottom, not a set of overrides (ADR 0050). Three formats: card is Markdown plus :::fields and {{ actions }}, parsed to oto’s own document IR and compiled per provider, and the only PORTABLE structured one; text is one flat string; raw is literal Slack Block Kit JSON, pinned to Slack. It carries no when clause — notification_policies.template_id names it, because the policy already has the matchers. Every interpolated value is markdown-escaped unconditionally in card, with no opt-out, so a label cannot become syntax. Any failure falls back to oto’s built-in card, which is what makes it impossible for a template to mark a delivery dead. It is not a “Wording” — that was the retired per-Stanza override. |
Ambiguity bans: unqualified event; issue; “notification” meaning a Slack message (that is a
Delivery); “group” meaning a UI grouping (that is a view); “alert” meaning a case or a
Slack message.
⛔ AlertGroup IS A RETIRED NOUN AND MUST NOT COME BACK (git-bug 7570090, migration 00069).
It named one generation of a notification grouping, keyed by (org, cluster, alertname, namespace-or-∅) from the alert’s own labels (ADR 0038), and its glossary row said what it was for in
the same breath as it named the object: it “decides which facts share a message” and “owns exactly
one Slack thread”. Deciding which facts share a message is the definition of a notification
policy, and the row held no fact an operator supplied and none upstream sent — it held a decision
oto made, identically for every org forever. alert_groups is deleted, alert_cases.group_id with
it, and the conversation is the Case. Three consequences worth stating because each is a place
the noun tries to return: the axes above are no longer any entity’s identity (see SPEC §C.4);
alert_group is gone from the subject_kind and conversation_kind vocabularies, and a value
still spelling it is REJECTED, not canonicalised — ConversationKind.SubjectKind()
(internal/notification/domain/notification.go) is total over the two kinds that exist and returns
the empty SubjectKind for anything else, which threads_subjkind_ck refuses at the write. That
is a loud failure rather than a thread silently opened under the wrong key, and it is what the tests
assert: rejection, not a quiet rewrite into case. ⛔ Do not add a canonicalising arm. Mapping
the old spelling onto case would be widening the kind to mean “generation or case”, which is the one
thing the retired constant’s own comment warned against — a generation could hold many Cases and a
conversation may not, so the cardinality changed with the spelling. Migration 00069 answered the old
spelling by narrowing the CHECKs (threads_subjkind_ck, notifications_subjkind_ck,
notifications_convkind_ck), not by giving the decoder an arm. And the plurality Reasons
new_alerts and some_resolved
left with it, because arithmetic over a set of one has no answer to give.
Scope bans — these MUST NOT appear in a Go identifier, a table or column name, a JSON field, an API path or UI copy. AC-49 greps for them in CI:
escalation · on-call/oncall · rota · schedule · assignee/assigned_to ·
owner_id · responder · triage · postmortem · war room · SLA · MTTA · MTTR ·
severity override · close (of an alert) · merge · dismiss · watcher/subscriber
Say firing duration, not MTTR. ⚠️ incident was on this list until ADR 0052 made an Incident
an oto noun. What it guarded is still shut, and by the right instrument: incident_id stays banned
on a signal row (alerts, alert_cases, notifications, notification_deliveries —
tools/lintvocab enforces exactly that), and the incident’s response is PERMANENTLY OUT below.
⛔ escalation has no
replacement term and this line used to offer one: it said “say unacked reminder”, and the one
reminder stage escalation had been reshaped into was itself withdrawn (git-bug bd0fb1d) — see
the No unprompted reminder, at all row above. The word stays banned with nothing to say
instead, which is the point: there is no oto concept in that shape.
The four states and THREE orthogonal axes
Section titled “The four states and THREE orthogonal axes”firing · suppressed · resolved · expired, plus two orthogonal axes:
ack_state (unacked|acked) and snooze (§B.8). An acked alert is still firing. A snoozed
alert is still firing and must still be rendered as firing — colouring it calm would be a lie.
The four words are the ALERT’s. They live on alerts.state. alert_cases.state holds
open | closed and nothing else (ADR 0040): an episode’s only fact about itself is whether it is
still running. The four-way reading of a Case is derived and total — open + no suppression_reason is
firing, open + one is suppressed, closed + resolve_reason='upstream' is resolved, closed +
'timeout', 'silent' or 'source_removed' (ADR 0056) is expired. Say an episode is open or closed, and an alert is firing, suppressed,
resolved or expired; the two vocabularies are not interchangeable and swapping them is how the
column acquired four values in the first place.
Snooze suppresses oto’s own notifications for one alert_key until T. It is stored only in
oto (alert_snoozes), auto-expires (5 min…30 days, never indefinite), is attributed and visible,
and touches nothing in the cluster. It is nearer to channels.verbosity than to a silence.
snoozed is NOT a suppression_reason and NOT a state — alert_cases.suppression_reason
mirrors Alertmanager’s four reasons and nothing else. Snooze records itself as
notifications.suppressed_reason='snoozed'. A snoozed and silenced alert shows both facts.
Rules you must not get wrong:
expired≠resolved.resolvedrequires an explicit upstreamstatus="resolved".expiredmeans we stopped hearing about it. Never fabricate a resolution.suppressedis invisible to webhooks. Alertmanager’sMuteStagedrops muted alerts before the webhook, so only the reconciler can entersuppressed(T3). But either the reconciler or ingest can leave it (T4): a webhook arrival is positive proof of non-suppression, because a suppressed alert would never have been sent. The asymmetry is deliberate.- A closed case never reopens. A re-fire always opens the next
seq, unacknowledged, whatever the clock says (T7; there is no T8 — ADR 0040). An acknowledgement is a receipt for one firing, and the second firing is not the one that was signed for.refire_graceused to decide it and is now deleted outright — the setting, its bounds, its derived default and its two API surfaces (git-bug7287b28, migration00071). ADR 0040 removed the transition; the knob outlived it by a release, validating a number nothing read. ended_atis clamped tostarted_at. A backward-skewed upstream clock must never abort an ingest transaction. Clamp, flagclamped: true, measure the skew — never reject.- Losing sight of an alert is not the alert resolving. The reaper is blocked unless the
case’s cluster has a live source and every live source’s
source_health.status = 'healthy'— fortimeoutandsilentalike, so one unhealthy HA replica holds the whole cluster (ADR 0056 Amendment 1).source_removedis the one expiry it does not ask, because there is no source left to be blind to: it fires only when no live source has fed the case’s cluster for a resolve grace, so deleting one HA replica ends nothing.silentandsource_removedship behindjobs.expire_silent_and_removed, off by default (ADR 0056).
4. Module map
Section titled “4. Module map”| Module | Mark | Responsibility |
|---|---|---|
platform |
CORE | Config, logging, telemetry, two DB pools + tx, httpx, authn, jobs, secrets, ratelimit, errs, clock, id, and tuning — the one home of the shipped §D.1 defaults (§5.2c). Not a domain. |
identity |
CORE | Orgs, users, sessions, PATs and ingest tokens → Principal + TenantScope. |
sources |
CORE | Alertmanager/Prometheus registry, credentials, health; owns the AM v2 + Prom v1 clients. |
ingestion |
CORE | Durably accept raw batches and normalise them to Observations. Nothing else — and not the reconciler: SPEC §I.2’s tree draws ingestion/worker/reconcile_source, but it is implemented in sources (internal/sources/service/reconcile.go, which says so), because every collaborator it needs is owned there. |
alerts |
CORE | Identity/dedup, the case state machine, the append-only timeline. The heart. |
rules |
CORE | Fetch, content-address, version and diff rule definitions at fire time. |
enrichment |
CORE | The Enricher port, the budgeted pipeline, caching, provenanced results. |
notification |
CORE | Policy matching, idempotent intents, fan-out, thread ordering, throttle/damping. |
channels |
CORE | Channel/Provider/Renderer ports + Slack and generic-webhook impls. |
streaming |
CORE | ui_events, LISTEN/NOTIFY bridge, SSE hub with resume. |
silences |
PERIPHERAL | Read-only mirror of AM silences. No write path. |
stats |
PERIPHERAL | Alert-hygiene accounting. Never per-person. |
drill |
PERIPHERAL | Synthetic end-to-end delivery drills. It imports no other module: it reaches five of them — alerts, ingestion, notification, channels, rules — by writing their table names into SQL (alerts, alert_cases, alert_events, ingest_batches, ingest_rejections, notifications, notification_deliveries, notification_policies, channels, channel_threads, rule_snapshots). Those eleven, plus its own delivery_drills, are DECLARED in test/arch/sqltables_test.go with their owner and how far the drill may go against each. The reads stay SQL on purpose — a port satisfied by the owning module’s service would have the drill ask the code under test whether the code under test worked. |
⛔ grouping |
DELETED | It owned durable groups, generations, derived membership and group lifecycle — 20 non-test files, 5 243 LOC — and it is gone (git-bug 7570090, migration 00069). ⭐ The measurement is the lesson: deleting it produced four build errors, all in internal/app. notification never imported it and alerts carried only a uuid. The module coupling was thin and the concept coupling was broad, which is why the change touched 187 files and almost none of them were the module. The conversation is now the Case (see the glossary), so nothing replaced it. |
app |
WIRING | The composition root. Constructs every concrete, satisfies every port, registers the workers and routes. THE one place allowed to know every module, and deliberately outside every cross-domain rule. Not a domain. |
incidents |
ADR 0052 | Incidents drawn over Cases by a Correlator or a human; active/quiet read off member Cases; declared outbound as a notification, facts only. Replaces the deferred correlation row. |
investigator |
ADR 0053, 0054 | Investigators, Investigations, Steps and Findings over oto’s own history and operator-configured ToolServers, asynchronously and off the notification path; Remedies once the authz grant exists. Models are reached through one port and one OpenAI-compatible adapter; ToolServers through the official MCP client; oto’s own loop records Steps and enforces budgets. ⛔ “anything AI” was DEFERRED-POST-V1 in the row below until 2026-10-02, when ADRs 0053 and 0054 were accepted. |
k8scontext, changefeed, views, audit (config changes only), authz (except the one Remedy approval grant ADR 0054 §4 needs — remedy_approver_grants, read through identity, written only by oto grant / oto revoke from the host shell, never by a route), extra channel providers |
DEFERRED-POST-V1 | Do not build. Do not stub beyond the ports that already exist. ⛔ investigator above. |
Incident response (status, lead, severity, comms, write-up), oncall, assignment, multi-stage escalation, paging, status pages, postmortems, SLA/MTTA, manual resolve/merge/close, watchers |
PERMANENTLY OUT | There is no version of oto containing these. Adding one needs an ADR arguing against FR-1 by name. See SPEC §I.1.1 for the hand-offs. |
Dependency direction
Section titled “Dependency direction”⚠️ Four different mechanisms cross a module boundary and only one of them is an import. An earlier version of this section drew all of them as one kind of arrow, which made it wrong about direction in one place and wrong about being enforced nearly everywhere. They are drawn apart now.
1. Compile-time imports — the real DAG, and the only edges anything checks.
enrichment ──► rules ──► alerts ▲ ▲ silences ───┘ └─── sources⛔ alerts imports no other module. Everything it needs from enrichment, notification
and streaming is a port it declares itself in alerts/service/deps.go. This is
what lets oto run with notifications entirely disabled — which is how the first correctness tests
run — and it is why alerts is the sink of this graph, not its source.
⛔ alerts ◄── grouping WAS DRAWN HERE AND BOTH THE ARROW AND ITS MODULE ARE GONE, and the
paragraph it carried is kept because the lesson outlived the edge. It read: “alerts ──► grouping
was drawn here for a long time and it is BACKWARDS — grouping/service and grouping/api import
alerts/service; alerts imports neither. A contributor who trusted the old arrow would have
written the import that this arrangement exists to prevent.” A drawn edge pointing the wrong way is
worse than no edge, because it reads as permission.
⚠️ A COMMENT THAT DESCRIBES DELETED MACHINERY IN THE PRESENT TENSE IS A DEFECT, AND THIS IS THE
STANDING INSTRUCTION RATHER THAN A LIST. internal/app/adapters.go’s alertObserver was the worst
case — it called itself “the one place that may know both alerts and grouping” and described
resolving an alert_groups generation as something it does — and it is now written in the past tense.
That is the shape to copy: say what the seam used to do, name what deleted it (git-bug 7570090,
migration 00069), and keep the argument for the seam when the argument outlived the work. There is
no grouping to know and no group to resolve, so a present-tense sentence about either is simply
false.
⛔ DO NOT READ THAT AS “THE LAST ONE IS FIXED”, AND NO COUNT IS RECORDED HERE ON PURPOSE.
7570090 deleted an entity that was named in prose across the tree, so present-tense references to
grouping, alert_groups, a group id, a member set or an /alert-groups/* route survive in every
file nobody has re-read yet. They are found by reading, not by grep — the same words are correct
in a past-tense sentence — and a number written down here would be stale the moment somebody fixes one
more site, which is how “the last one” got written in the first place. Fix what you find, in the file
you are already in.
Two cross-module imports are not module dependencies and are drawn nowhere above:
- RULE K — every module may import
alerts/domain, the shared domain kernel (§5.2b).ingestion,rules,silences,sourcesandnotificationall do. - RULE V —
notificationmay importchannels/domain, because §F.2 hasnotification/servicebuildchannels/domain.NotificationViewand hand it to aRendererwhose concrete it never names.channels/serviceis injected.
2. Ports — the consumer declares the interface, internal/app/container.go satisfies it.
No import exists in either direction, and nothing enforces the arrow:
| Consumer declares | Satisfied from | Wired at |
|---|---|---|
ingestion/service.AlertObserver |
alerts |
app.alertObserver (adapters.go) |
alerts/service.EnrichmentReader |
enrichment |
container.go |
alerts/service.NotificationReader |
notification |
container.go |
alerts/service.StreamAppender |
streaming |
adapters.go |
notification/service.ChannelRegistry |
channels |
container.go |
rules/service.RuleLookup |
sources/service.ResolveRule |
adapters.go |
silences/service.SilenceSource, silences/api.SourceBaseURLs |
sources |
app/silencesource.go |
alerts/service.CaseOpenings |
the outbox (incidents.correlate) — never incidents itself |
app.caseOpenings (adapters.go) |
incidents/service.Announcer |
the outbox (notify.incident, and investigations.incident for a draw or a membership change) — never notification or investigator itself |
app.incidentAnnouncers (adapters.go) |
sources/api.CaseCounts — open and held Cases per source (ADR 0056 §1) |
alerts |
app.sourceCases (adapters.go) |
3. River job enqueues — a STRING in internal/platform/jobs/kinds.go, not a call. The
producer never names the consumer, so there is nothing to enforce at all:
| Producer | Kind | Handled by |
|---|---|---|
alerts, ingestion, enrichment |
notify.evaluate |
notification |
alerts, enrichment |
enrich.run |
enrichment |
alerts (through CaseOpenings) |
incidents.correlate — on lifecycle, never notify |
incidents (the Correlators) |
incidents (through Announcer) |
investigations.incident — on lifecycle, never notify or investigate; not on quiet (ADR 0053 §4) |
investigator |
investigator (through FindingDeclarer, RemedyDeclarer) |
notify.incident with Reason finding or remedy_* — an Incident’s new Finding or a Remedy transition, declared, never a decision about delivery. Numbered like every Incident fact: the adapter takes the Incident’s next sequence from incidents/service.NextFactSequence in the same transaction (migration 00093) |
notification |
4. Table names in SQL — no Go edge whatsoever. drill reads five other modules’ tables by
name (see its row above); notification/repository/snapshot.go joins alert_sources to learn a
source’s kind so it can decide whether an Alertmanager silence URL is one oto can vouch for.
alerts/repository/case.go reads sources’ alert_sources and source_health by name too: the
reaper’s guarded scans pre-filter on “every live source on the cluster has a healthy row”
(liveSourcesHealthySQL, ADR 0056 Amendment 1), and the case-sources reads join alert_sources for
the live set and its max silence. ⚠️ The pre-filter is not the verdict — the §B.4 guard is still
asked through the SourceHealth port and that answer decides — but a rename of either table or of
source_health.status’s 'healthy' breaks the reaper at runtime, not at build time.
No compiler, no depguard rule and no import graph can see any of these — test/arch/arch_test.go
says so itself: “COMPILE-TIME EDGES ONLY.”
test/arch/sqltables_test.go is the gate that reads the SQL instead, and it covers drill
only. Every table internal/drill/** names is declared there with its owning module and its
permitted access: a fourteenth table fails CI the way a new import does, a declared table nothing
names any more fails as stale, a SELECT that grows into a DELETE fails even though the table was
already declared, and a table its owner renames fails on the OWNER’s side rather than at runtime on
the drill path. It also holds dispose.go’s two stated invariants — every DELETE scoped by an id
and not merely by org_id and a predicate, and AND synthetic still on alerts — which were argued in a comment on a file nothing in the build system knew was
special.
⚠️ notification‘s alert_sources join, alerts’ alert_sources/source_health reads and
stats’ ten borrowed tables are not declared anywhere. For those a rename still breaks at runtime, and gating them means writing their claims.
⛔ notification ──► silences used to be drawn and is not a relationship: notification
neither imports silences, nor declares a port onto it, nor enqueues to it. The silence links it
renders point at Alertmanager’s own console, from the source kind read in (4). The real silence
edges point the other way — silences ──► alerts (import) and silences ──► sources (port).
What actually enforces this
Section titled “What actually enforces this”test/arch/arch_test.gois the only gate on direction. It reads the real import graph, fails on any cross-module edge diagram (1) does not draw, fails on a declared edge the code no longer has, and refuses an allow-list containing a cycle — so the cheap fix of adding the offending line back does not work either..golangci.yml’s<module>-must-not-reach-into-other-domainsrules are symmetric. Each one re-allows every other module’s/service, so they enforce layering (you may reach only<other>/service, never itsapi/repository/domain) and say nothing about which way an edge points. The only directional depguard rule isdependency-direction-alerts-and-ingestion—alertsandingestionmay not importnotificationorchannels(SPEC §I.1).platform-must-not-import-domainskeepsplatformout of the graph entirely.test/arch/sqltables_test.gois the only gate on TABLE OWNERSHIP, and only overdrill. It reads string literals, not imports, so it is the one check in this repo that can see a module reaching across a boundary without an edge to reach it with.- All three skip
_test.go. Test files do cross module lines on purpose, and a fixture that seeds another module’s table to set a test up is not the module reaching across a boundary. - Mechanisms 2 and 3 are enforced by nothing, and mechanism 4 only for
drill. That is the price of the decoupling, and it is the reason they are listed rather than drawn as arrows.
5. Layering rules (mechanically enforced — do not work around them)
Section titled “5. Layering rules (mechanically enforced — do not work around them)”internal/<domain>/ api/ HTTP handlers, DTOs, mappers, routes service/ business logic + PORT INTERFACES it declares itself repository/ SQL, row structs, mappers domain/ pure Go: entities, invariants, state machine. NO I/O IMPORTS AT ALL.apiMUST NOT importrepository.repositoryMUST NOT importapi.domainimports nopgx,database/sql,net/http,os,time.Now(useplatform/clock),slack-goorclient-go.encoding/jsonIS permitted — it does no I/O.json:"…"struct tags indomainare forbidden; tags are what would quietly turn a domain type into a DTO. 2b.internal/alerts/domainis the SHARED DOMAIN KERNEL and is the only sanctioned cross-domaindomainimport: it ownsLabelSet,AlertKey,GroupKey,RuleFingerprint,IdempotencyKey,ClusterKey,SlackTS,Severity,State,AckState. It must import no other domain package.pkg/alertkeydoes not exist —pkg/is reserved forotoclient. It is the only kernel but not the only exception: depguard’s RULE V also letsnotificationimportchannels/domain, because §F.2 hasnotification/serviceBUILDchannels/domain.NotificationView(§4, mechanism 1). That grant is one module, one package, view types only. There are exactly two such imports in the tree and a third needs an ADR. 2c.internal/platform/tuningis the ONE home of the shipped §D.1 tuning defaults. It is constants and nothing else — no types, no behaviour, no import buttime.identity/domainstill OWNS the tenant’s tuning (the keys, the bounds, the provenance, theSettingsstruct); what moved is only the shipped NUMBER, becausealerts/domain,alerts/serviceandplatform/configall need it and rule 4 forbids every one of them importing identity. They used to keep copies with a ⚠️ comment each, ADR 0026 moved three defaults at once and two copies were missed, and the miss is silent — a stale fallback is what an org gets when its settings row fails to load. Every declaration outside this package is now a REFERENCE to it, which the compiler keeps honest; a default only one package reads stays where it lives. This is not a general dumping ground: platform may import no domain (rule 7), so nothing with a domain type in it can come here.serviceimportsdomainand its own port interfaces. Concretes are injected byinternal/app/container.go.- Cross-domain calls are service → service, through an interface declared by the consumer. Never repository → repository.
- Three model sets, and the compiler can tell them apart:
api.XxxDTO(json + validate tags) ←domain.Xxx(pure) ←repository.xxxRow(unexported). No DTO may embed a domain type or a row type. - Every repository method is
(ctx, db.TenantScope, …).TenantScopehas an unexported field and can only be built from an authenticated principal. There is no repository method without one. - Every composite index starts with
org_id. - List methods take
db.Keysetand returndb.Cursor. There is noOFFSETin this codebase. - Transactions travel in
ctx(db.FromContext). There are noWithTx(tx)method variants.
5b. Validation — seven layers, two opposite trust models (SPEC §L)
Section titled “5b. Validation — seven layers, two opposite trust models (SPEC §L)”| # | Layer | Library | Failure |
|---|---|---|---|
| 1 | API DTOs | go-playground/validator/v10 via httpx.Bind[T] only |
422 + violations[] |
| 2 | Untrusted inbound (webhook, reconciler) | hand-written bounds B1–B19 | ingest_rejections row, still 202 |
| 3 | Domain invariants | value objects + New…() (T, error) |
typed error; a bug |
| 4 | Provider config | santhosh-tekuri/jsonschema/v6 |
422 mapped from schema errors |
| 5 | Outbound render | 18 Block Kit checks (V1–V18) | dead delivery, payload persisted |
| 6 | Persistence | CHECK / NOT NULL / UNIQUE / FK |
500 + alert (means 1–3 have a hole) |
| 7 | Frontend | valibot |
inline field error / dev-time throw |
The rule that matters most: trusted user input and untrusted upstream payloads have
opposite policies. API bodies use DisallowUnknownFields and reject loudly. Alertmanager
bodies decode leniently (routeLabels is undocumented; Grafana sends a superset), reject
per-alert into ingest_rejections, and still return 202 — a 4xx deletes the alert forever.
Other binding rules:
- No handler calls
json.NewDecoderorvalidate.Structdirectly.httpx.Bindis the only door. - Violation
fieldpaths are JSON names,/-separated (matchers/0/name), never Go names. - A bound (length, count, enum, regex) lives in three places — DTO tag, domain constructor, DDL
CHECK— and they must be identical.TestValidatorMatchesDDLenforces it. - No optional
Validate()indomain. If you can construct it, it is valid. - One JSON Schema per provider serves both server validation and the settings form.
- Never send a Slack payload that has not passed
render/slack.Validate. - The repository never validates a business rule (service’s job) but does reject malformed row
models and is the single place SQLSTATEs become
errs.Kind. - Error taxonomy: validation (422) · conflict (409) · precondition (412, wrong entity state) · upstream-failure (502/504) · unavailable (503, our backpressure). A unique violation on an oto-computed key is not an error — it is the idempotency mechanism.
5c. Brand and UI (SPEC §M)
Section titled “5c. Brand and UI (SPEC §M)”oto (音) is Japanese for sound — a chime. Make one clear sound at the right moment. Calm by default, unmistakable when it matters.
Two tiers, strictly separated:
- Tier A — chrome is pastel. Surfaces, borders, nav, tables, forms, gridlines. Never a state hue.
Brand accent is periwinkle (
#5B54D6/#A6A0FF). - Tier B — saturated colour means alert state and nothing else. No decorative accent, no chart series, no hover. Scarcity is what makes it loud.
Each state ships four tokens: -fill (pastel surface), -border (≥3:1), -text (≥4.5:1 on its
fill), -solid (saturated, ≥3:1). A critical row = pastel fill + saturated 3 px left bar + dark red
text. Tokens and measured contrast ratios: SPEC §M.4–M.5.
- Colour is never the only channel — every state carries ≥2 of {colour, icon, text label}.
- Severity → icon. State → colour. Dark mode is the default. No flashing, ever.
- Colour is not the only axis with a vocabulary any more (§M.8, ADR 0029). Type is six steps —
text-micro10 /text-meta11 /text-body12 /text-item13 /text-title14 /text-page18 — and radius is three:rounded-chip3 (inline things),rounded-control4 (things you operate),rounded-surface6 (things that hold controls). Both were read off 342 existing bracket literals rather than drawn, which is why they are that short. A size written at a call site is a violation, not a shortcut — bracket, Tailwind’s owntext-sm/rounded-mdladder, or a raw declaration in a stylesheet alike;web/src/design/scales.test.tsis the gate. - The Slack palette (§H.2, Grafana OnCall hexes) is a SEPARATE, UNCHANGED system — do not
harmonise it. Different substrate, different contrast contract, and those values are the
best-tested alert palette that exists. A renderer must not read a
--oto-*token; a stylesheet must not contain a Slack hex. Both halves are gates —test/design/boundary_test.go, plusTestSlackPaletteUnchanged, which pins the six hexes to §H.2 itself rather than topalette.go.
6. Conventions
Section titled “6. Conventions”Go
- Module
github.com/thulasiram/oto. Go 1.24. Postgres 16+. IDs are UUIDv7 fromplatform/id.New(). - All timestamps
TIMESTAMPTZUTC. Every AlertEvent carries two:occurred_at(upstream claim, displayed) andrecorded_at(oto clock, ordered by). Never conflate them. - Errors: stdlib
errors+platform/errs; mapped to RFC 9457 problem+json in exactly one place. - Enums are
TEXT+CHECKin SQL, closed value-object types in Go. No PostgresENUMtypes. - Constraint and index names are a runtime contract — §L.9 returns them as
errs.Error.Codeon 23505/23503.<table>_<purpose>_ck|_uniq|_idx. Nock_/ix_/uq_prefixes. Every constraint is named; the realised schema has 201 named CHECKs across 30 tables. - Severity is strict in the domain, lenient at the boundary.
NewSeverity(strict, for API and config) vsSeverityFromLabel(total, never fails, for upstream labels).alerts.severitystores the raw label value so users can filter on their own vocabulary (sev1,P1,page). errsownsKind/Error/ProblemDTO/HTTPStatus()with no I/O imports sodomaincan import it;httpx.WriteProblemdoes the writing.- Hand-written SQL over
pgx/v5. No sqlc, no ORM.squirrelonly for the alert-list filter builder. - Migrations are goose
.sqlindb/migrations/, expand/contract only. Never a destructive migration in one release. Assume N and N+1 run simultaneously. - Every job payload carries
"v": <int>. A worker meeting an unknown version parks it. slack-go/slackis pinned to an exact version, floor v0.23.1 (earlier versions accept an empty signing secret and therefore forged requests).
Testing
- Real Postgres via
testcontainers-go. Mocked DBs lie about SQL semantics. - Renderers are pure functions with checked-in
testdata/*.golden.json+ a CI Block Kit validator. - Real upstream payloads live in
test/fixtures/and are replayed deterministically. - Write
test/load/burst_test.go(a 5 000-alert batch) before the feature that handles it.
Ingest path — the rules that must never be broken
- 202 on accept. 503 +
Retry-Afterfor anything transient. NEVER 429. NEVER 4xx. 4xx means Alertmanager permanently deletes the alert. - No outbound network call on the ingest path. p99 < 250 ms.
- Two connection pools:
ingest(25 %, 2 s statement timeout, 500 ms acquire) andgeneral. UI queries must never starve ingestion. - A 2xx is a promise. Never return 2xx for a payload we failed to persist.
Slack
- Title is a
section, never aheader(header isplain_text— no bold, no links). - Exactly one attachment wraps all blocks. Colour = STATE. Emoji = severity.
chat.updatein place is primary; thread replies are the exception.repeat interval elapsed→ update only, never post.tsis TEXT, never a float. The durable handle is(channel_id, ts), andchannel_idcomes from the API response.- Top-level
textis a complete sentence — it is the push notification, the search snippet and the only thing screen readers read. - Button
valueis an opaque UUID. Never a payload, never trusted. - Every URL button still delivers an interaction payload you must ack (
oto.noop.*). - oto never reads Slack back. Our DB is the memory of Slack.
Product and ethics (requirements, not aspirations)
- Default to quiet, but never silent. Grouping and flap damping ON by default; snooze is the manual sibling. All of them are visible UI states, never silent suppression — silence destroys trust. ⛔ Storm collapse was removed for failing exactly that test (ADR 0042): oto never decides a real firing was not worth mentioning.
- Never claim resolved when we mean expired.
- Delivery failure must be visible per alert. oto’s silence must never be indistinguishable from “no alert”.
- Ack identity IS stored (operationally necessary). No per-person response-time metrics, leaderboards or aggregates. A feature you do not build cannot be misused.
- Label redaction runs before the raw payload is persisted. Never log full payloads at info
level. Retention: raw 30 days, events 13 months, configurable — and it deletes the
narrative, never the record (ADR 0024).
alerts,alert_cases,rule_snapshots,notifications,notification_deliveriesandchannel_threadshave no reaper. What dies at 13 months is the timeline, including human comments, which live nowhere else. - Surface alert hygiene. The best alert is the one that no longer exists.
7. Where to start reading
Section titled “7. Where to start reading”| You are building | Read |
|---|---|
| Migrations | SPEC §D in full, §C for the key formats |
ingestion |
SPEC §C.5, §G.1–G.4, §G.8; ADRs 0006, 0007 |
alerts |
SPEC §A, §B, §C.2–C.3, §C.8, §D.4; ADRs 0003, 0004 |
rules |
SPEC §C.6, §D.6, §F.4; ADR 0009 |
notification + channels |
SPEC §F.1–F.2, §G.5–G.7, §H in full; ADRs 0008, 0023 |
streaming |
SPEC §E.4, §D.10; ADR 0010 |
| HTTP API | SPEC §E in full, §L.1–L.2, §L.9, §J |
| The SolidJS UI | SPEC §E, §L.8, §M in full; ADR 0012 |
| Anything at all | SPEC §L (validation) and SPEC §I.1.1 + SCOPE-BOUNDARY (the line); ADRs 0011, 0013 |
| Work already queued | SPEC §P — pending code amendments, 22 numbered items |
docs/design/domain-research.md is verified ground truth about Alertmanager and Slack. When
your instinct disagrees with it, your instinct is wrong. docs/design/architect-proposal.md is
superseded and is kept only for rationale; where it conflicts with SPEC.md, SPEC.md wins.
8. If the SPEC is ambiguous
Section titled “8. If the SPEC is ambiguous”Raise it. Do not choose. An undocumented choice made by one implementer is a compile error or
a silent behavioural divergence for the next four. Amendments go through docs/adr/ plus an edit
to the affected SPEC section in the same commit.