Orchestration state
Internal historical build log — not current documentation, and not binding on anything. It records how oto was built, phase by phase, and is kept for that reason alone. For what oto is and how it works, read
docs/concepts.md,CONTEXT.md, and the binding pairdocs/design/SPEC.mdanddocs/design/SCOPE-BOUNDARY.md. Where this file and any of those disagree, this file is the one that is out of date. It is deliberately absent from the documentation site’s navigation.
Build log for the agent orchestrating oto’s implementation. A session picking up
the build itself should read this file first, then CONTEXT.md, then
docs/design/SPEC.md (binding) and docs/design/SCOPE-BOUNDARY.md (binding).
How this build is run
Section titled “How this build is run”A thin orchestrator spawns purpose-built subagents. Each subagent owns a disjoint set of paths, reads the SPEC as binding, and returns a short report.
For phases 0–3 subagents did not write or run tests, on the theory that a
later review phase would. That rule is now withdrawn: it produced a
repository with zero tests, a CI ui job that failed on the commit that
introduced it, and eight of thirty-six live API operations diverging from the
contract — every one of them the kind a single smoke test catches on day one.
From phase 4 onward an agent tests what it writes, and the review phase reviews
rather than backfills.
Rules that have held so far and should keep holding:
- One agent, one module, disjoint paths. Concurrency is safe only because of this.
- The SPEC is the contract between agents. If an agent finds the SPEC wrong,
it reports the defect rather than silently diverging; the judge amends the
SPEC and the change is applied as a numbered
§Pwork item. go build ./...,go vet ./...andgolangci-lint run ./...must pass before an agent reports done.golangci-lintenforces the layering viadepguard, so an import complaint means the import is wrong, not the linter.
Phase log
Section titled “Phase log”Phase 0 — design (done)
Section titled “Phase 0 — design (done)”Architect proposal, verified Alertmanager/Slack domain research, and a red-team
memo, reconciled by a judge into the binding SPEC, 13 ADRs and CONTEXT.md.
Findings that changed the design:
- Alertmanager drops suppressed alerts before the webhook, so suppression is only learnable from API v2 — the pull reconciler is mandatory.
- 4xx/429 to Alertmanager is permanent notification loss; only 5xx retries.
groupKeyembeds the route path, so a config reload changes it. Not durable.chat.updateis ~50x cheaper thanchat.postMessage;notification_reason(AM 0.32+) decides update-vs-post.
Phase 1 — skeleton (done, commit 56bbe95)
Section titled “Phase 1 — skeleton (done, commit 56bbe95)”Domain-first Go tree, platform packages, goose migrations, docker-compose with Postgres + Alertmanager, SolidJS/Vite/Tailwind web app, CI, depguard layering rules proven to fire against planted violations.
Phase 2 — kernel, schema, contract, doctrine (done, commit 0eb50e8)
Section titled “Phase 2 — kernel, schema, contract, doctrine (done, commit 0eb50e8)”internal/platform/errs+validate;internal/alerts/domainwith the lifecycle state machine T1–T10, pure and clock-injected.- 30 tables, 201 named CHECKs, 94 indexes, partitioned event tables, verified reversible round trip. 36/36 constraint-rejection tests behaved.
api/openapi/openapi.yaml, 71 operations, lints clean.docs/design/SCOPE-BOUNDARY.md— the Flight Recorder Test, answering the owner’s “don’t become an incident management platform” constraint.
Phase 3 — module implementation (in progress)
Section titled “Phase 3 — module implementation (in progress)”Done: Alertmanager/Prometheus clients + rule matching; River job queue, per-thread ordering primitive, SSE streaming with durable resume; Slack Block Kit renderer + provider, generic webhook provider, channel registry.
Also done: alerts + grouping repository/service, ingestion, enrichment + rules, notification.
The blockage recorded here — “identity, every api sub-package, internal/app
and the UI are NOT implemented; nothing runs end-to-end” — is resolved and this
paragraph was stale for some time. The binary serves traffic today: go run ./cmd/oto migrate && go run ./cmd/oto bootstrap && go run ./cmd/oto serve brings
up an API that answers all 15 tags, ingests Alertmanager webhooks, dispatches
notifications and streams SSE. An audit exercised 36 operations against it.
Phase 4 — review and test (in progress)
Section titled “Phase 4 — review and test (in progress)”A conformance audit against the running binary is done and its findings are being worked. What it established, so it is not re-established:
- Structure is as documented. Layering holds under
golangci-lint; the four scope-boundary doors are shut in the live schema and the route tree; ADRs 0007, 0014, 0016 and 0017 conform exactly.CONTEXT.mdis a trustworthy map. - Behaviour was optimistic. Eight of thirty-six operations diverged from
api/openapi/openapi.yaml, including two that made the product unusable (no source could be created; the SSE stream sent noContent-Type, so no browser could attach). - Enforcement was false. §L.8.1 claimed “four gates, all in CI”. G1, G2 and G4 did not exist; G3 existed, was not in CI, and failed when run. The AC-49 vocabulary lint did not exist. There were zero tests.
Gates now in CI: all four of §L.8.1 — G1 (test/contract/), G2
(test/contract/server/), G3 (npm run generate:check) and G4
(npm run gen:validators:check) — plus the AC-49 vocabulary lint
(tools/lintvocab), the reachability lint (tools/lintreach) and
TestValidatorMatchesDDL. just ci lists them in the order CI runs them.
G2 deviates from the SPEC, which names schemathesis: it is Go-native, driving
the real container over a real Postgres and validating the bytes each handler
writes against the contract. test/contract/server/doc.go carries the argument
— a Python runtime for one check, in a repository whose server cannot be started
without the Go test harness, costs more than it closes — and the exhaustiveness
schemathesis would have supplied is recovered by a ratchet: an operation the
contract declares and the probe table does not drive fails the gate.
Each gate was demonstrated to fail against a deliberately planted drift before being declared done, which is the standard the depguard rules were held to.
Open work handed on from the phase-4 audit
Section titled “Open work handed on from the phase-4 audit”Closed the hard way (git-bugescalate_after_secondsstill ships on the wire frominternal/notification/api/{dto,mapper}.go; the Go DTO is the last holdout of theescalation→unacked_reminderrename and is listed intools/lintvocab/baseline.txt.bd0fb1d): the owner withdrew the unacked reminder, so the field is gone from the wire, the DB and the UI rather than renamed.escalationremains a banned word — it was banned for dragging in rotas and ownership, which is true whether or not oto reminds anyone.G1 (Go DTO → OpenAPI), G2 (schemathesis against a running server) and G4 (generated valibot validators) are unbuilt.Closed. All three are built and in CI; see the gate table inREADME.md. G2 is Go-native rather than schemathesis, for the reasons intest/contract/server/doc.go.deploy/helm/oto/now holds the chart of SPEC acceptance criterion 31: API and worker Deployments, Service, optional Ingress, ServiceAccount, ConfigMap, Secret withexistingSecretsupport,oto migrateas a pre-install/pre-upgrade hook (the subcommand, so River’s own migrator runs after goose) and an optionaloto bootstrappost-install hook. Postgres stays external per ADR 0014.The criterion is still not fully met: no oto container image is published.Closed..github/workflows/release.ymlbuilds the rootDockerfileon everyv[0-9]+.[0-9]+.[0-9]+*tag and pushesghcr.io/thulasi-ram/oto(linux/amd64 + linux/arm64) taggedX.Y.Z— the tagappVersionresolves to — plusvX.Y.Z,X.Yandsha-<commit>, with the three linker stamps passed soGET /api/v1/versionanswers with a version and notdev. Nolatestis published while the chart is prerelease and tells operators to pin. ⚠️ The chart’s defaultimage.repositorynamed the ownerthulasiram, and the repository isgithub.com/thulasi-ram/oto— GHCR accepts the built-inGITHUB_TOKENonly for a package under the repository’s OWN owner, so that path was one no workflow could ever have written to. It was corrected toghcr.io/thulasi-ram/otoin the same change, and the workflow derives its path fromgithub.repositoryrather than repeating a string that can drift.The sameWrong, twice over.thulasiramspelling survives in the Go module path and inChart.yaml’shome/sourceslinks; those are cosmetic where the image path was fatal, and which handle is canonical is the owner’s call, not this change’s.Chart.yaml’shome,sourcesandmaintainers[0].urlwere not cosmetic: the release workflow makes the chart page reachable, and Artifacthub renderssourcesas its “go to source” button — a reader following either lands on a 404. They now readgithub.com/thulasi-ram/oto. All 23runbook_urlvalues indeploy/prometheus/oto-rules.yamlcarried the same misspelling and are fixed too — an on-call engineer clicking through from a firing alert would have hit the same 404. What survives, correctly, is the Go module path itself (go.modand every import) plus everything that quotes it verbatim (.golangci.yml’sdepguardrules,tools/lintreach/baseline.txt,CONTEXT.md, and the module-path line and import sample indocs/design/SPEC.md) — renaming that is a large, invasive change out of scope here, anddeploy/helm/oto/values.yaml’s comment, which quotes the old brokenghcr.io/thulasiram/otodeliberately, as history of the mistake already fixed there. What remains is operational, not missing code: nov*tag has been pushed yet, and a GHCR package is private until somebody makes it public (orimagePullSecretsis set, which the chart already projects into all four pod specs).deploy/prometheus/is no longer empty:prometheus.ymlplusoto-rules.yaml(the path SPEC §H.8 names) are wired intodocker-compose.ymlandjust infra, and every rule’srunbook_urlpoints at a page indocs/runbooks/, which now holds one page peroto_*metric the binary registers.- SPEC AC-34 promises
oto_reconcile_divergence,oto_source_degraded_holds_total,oto_notification_suppressed_total,oto_delivery_attempts_total,oto_delivery_dead_total,oto_thread_recovered_total,oto_render_invalid_totalandoto_check_violation_total. Two of those eight are no longer gaps:oto_thread_recovered_totalshipped under the nameoto_thread_gap_recovered_total, andoto_render_invalid_totalis now built ({provider,renderer,mode}) because the fact its struck entry pointed at was false —oto_jobs_dead_totalnever fires for a render failure. No collector constructs the other six — those facts exist in tables and logs instead.docs/runbooks/README.mdlists where each one lives today so the gap stays visible rather than being discovered by an alert that never fires.
Open defects found by implementers, awaiting SPEC amendment
Section titled “Open defects found by implementers, awaiting SPEC amendment”AMENDED.§G.7.2ordering switch tests “root has not landed” before “thread is dead”§G.7now states the implemented order (dead → sequence → root; thefrozenarm was removed by git-bug e5c060b and migration 00066, because nothing could ever enter that state), the boundedMaxWaitescalation and its dead-letter outcome, and the three-phase send with the claim durable before the provider call. Reasoning is in ADR 0023.CLOSED, BY DELETION. git-bugmention_on_reminderlives in Slack channel config but neitherNotificationViewnorRenderOptionscan carry it to the renderer. Worked around with a channel-scoped renderer copy; may want a port field instead.bd0fb1d: the owner withdrew the unacked reminder and ruled the mention goes with it, so there is no mention surface anywhere in oto and nothing left to carry to the renderer. The channel-scoped renderer copy,RenderOptions.Mentions,WithMentionsand theFormethod all went with it — the port field this item wanted was the right answer to a question that no longer has a subject.V11’s bare-UUID assertion was scoped tobutton.valueonly, because§H.3’s own overflow option carries"labels|<group_id>".
Decisions taken on the owner’s behalf
Section titled “Decisions taken on the owner’s behalf”Recorded here so they can be reversed cheaply. Full reasoning in the ADRs.
- Snooze is in v1 — suppresses oto’s own notifications, writes nothing to the cluster. It was missing from the SPEC entirely rather than deferred.
- Individual Slack
@-mentions are permitted, capped at 10, with an explicit never-a-rota clause. Overruled the scope agent’s usergroup-only restriction. internal/<domain>/{api,service,repository,domain}rather than a literalsrc/, for compiler-enforced encapsulation. Same domain-first shape.org_idin the schema from day one; no RBAC or SSO in v1.- Silences are a read-only mirror in v1. No write path into the cluster.
pkg/alertkeydeleted;internal/alerts/domainis the shared kernel and the single sanctioned cross-domaindomainimport.- Retention defaults and cold storage (ADR 0024) — was open question 5.
Raw payloads 14d → 30d, derived from the
alert_event_keysidempotency horizon: past it a stored batch cannot be replayed without appending the timeline twice, so a longer window keeps bytes nothing can act on. Events stay 13 months, but on ADR 0014’s scale ceiling rather than the year-on-year reason that was recorded — year-on-year is served byalert_quality_daily, which is never reaped. Retention destroys the narrative, never the record:alerts,alert_cases,rule_snapshots,notifications,notification_deliveriesandchannel_threadshave no reaper at all, so every clause of README’s promise outlives both windows. What does die at 13 months is human comments and unack notes, which live nowhere else — which is why cold-storage export is scoped and unbuilt rather than ruled out. Also fixed:partitions.manageread a process-global config and ignoredorgs.settingsentirely, so the per-org keys were enforced nowhere. It now drops at the maximum of every org’s window — retention is a floor, never a ceiling. - Tuning defaults derived from a real rule corpus (ADR 0026) — was open
question 4.
refire_grace600s → 1200s,group_close_delay300s → 1200s,flap_window1800s → 7200s;flap_thresholdstays 5 andflap_digest_intervalstays 900s. The evidence is two measured tables, not a plausible cluster:group_interval: 5mis the one Alertmanager number the ecosystem does not override (upstream, kube-prometheus-stack, kube-prometheus, OpenShift, Grafana all ship it), andfor: 15mis the mode and median of the 155 rules kube-prometheus-stack 88.2.0 ships, with 15m+10m+5m being 75.5% of them. Three defects, all arithmetic rather than taste: (a)refire_grace’s clock starts at the UPSTREAMended_at, so a re-fire must pay the rule’s wholefor:again — 600s was unreachable for 76% of real rules; (b)group_close_delaywas shorter thanrefire_grace, so the grace reopened the case and the closed generation posted a new Slack root anyway, which is exactly what the grace exists to prevent; (c) the flap ceiling has a TRANSPORT floor the old arithmetic missed — a cycle costsgroup_interval + max(group_interval, for), so a 30-minute window held at most 6 transitions even for a rule with nofor:, and 5-in-30m could never be crossed by anything. Bounds were re-checked and NONE moved: theflap_windowfloor of 300s is inert atgroup_interval: 5mand is kept because the one real capture in this repo runsgroup_interval: 30s, where it is exactly right. Also found and NOT fixed here: ADR 0020 grantsrefireda broadcast because its quiet form is invisible, and §H.6’s verbosity table then drops that reply entirely atfiring_only/firing_and_resolved. On those channels a re-fire inside the grace is silent. Recorded as an open defect in ADR 0026 — changing whatfiring_onlymeans is a product decision, not a tuning one. That defect was overtaken rather than fixed: ADR 0040 retired T8, so nothing producesrefiredat all and there is no reply for verbosity to drop. The product question it named — how loud a re-fire should be on a quiet channel — is still open, and it is no longer a question aboutgroup_close_delay— Deleted: that setting is gone (see decision 9’s follow-up). A conversation now holds exactly one Case, so a re-fire is always a new root: loud, always, with no knob. Whether that is right is the open ruling recorded in SPEC §B.5. - A Case is
openorclosed, and it is never reopened (ADR 0040) — and this one reverses shipped behaviour rather than tuning it, so read §6 of that ADR before touching it.alert_cases.stateheld all four §B.2 words; three of them were facts about the Alert or restatements of a neighbouring column, so the column is nowopen | closedand the four-way reading is derived (migration 00054, lossless in both directions). With it went transition T8: a re-fire used to reopen the closed episode when it landed insiderefire_grace, carrying its acknowledgement across a gap in the firing. Every re-fire is now T7 — the nextseq, unacknowledged — because an acknowledgement is a receipt for one firing and the second firing is not the one that was signed for.reopen_count/reopen_ofare dropped,case.reopenedis retired on 00051’s terms, andrefire_gracesurvives as an inert setting whose future is deliberately undecided. OVERTAKEN —refire_grace_sIS DELETED (git-bug7287b28, migration00071). “Deliberately undecided” was decided: the setting had no reader outside its own CRUD, and the owner’s standing ruling is delete, do not retire, because a knob that clamps, validates and reports an origin while changing no outcome is a vocabulary entry the next person has to rule out.group_close_delay_swent with it — it timed the close of analert_groupsgeneration, and00069deleted the entity. Decision 8’s two headline numbers therefore describe settings that no longer exist; the derivation stands as the record of how they were reached.
Questions that needed the owner — all now settled
Section titled “Questions that needed the owner — all now settled”Collected for a single decision session rather than interrupting piecemeal. The build was never blocked on any of them, and every one has since been answered by an ADR. Each is kept below with the decision that closed it, because the question is part of the record of how the answer was reached. Nothing in this section is open.
- RESOLVED by ADR 0016 — cluster context arrives via MCP, on demand, and oto never reads the Kubernetes event firehose. The question was whether oto is k8s-enriched or only k8s-shaped: all Kubernetes API enrichment was deferred — it needs cluster RBAC and it is Robusta’s turf — leaving oto grouping by namespace and cluster and installing by Helm without ever reading the cluster, which looked like it might contradict “k8s cloud native” as intended.
- RESOLVED by ADR 0018 — a customer-built internal app with credentials in config; no Marketplace listing and no oto-operated service. The question was which of Marketplace, per-customer internal app or BYO-token to build for, since it sets the rate-limit tier. The assumption recorded here at the time was “BYO-token self-hosted, Socket Mode default”; the first half held and the second did not — Socket Mode was never built, and slack.md says why.
- RESOLVED by ADR 0019 — MIT, and
LICENSEis committed. The question was whether AGPL was worth being the most restrictive option in a permissive field; Keep and Robusta are both MIT-cored.
Retention defaults were question 5 here. They are now decision 7 above (ADR
0024). refire_grace and the flap thresholds were question 4. They are now
decision 8 above (ADR 0026). Both were decided without the owner and both are
written to be cheap to overturn — and refire_grace was in fact overturned:
see the OVERTAKEN follow-up on decision 9.
- RESOLVED by
ADR 0045
— one Slack thread per alert is the accepted cost, and the opt-in digest is
the only collapse mechanism. The question was how loud a re-fire should be,
and it was the successor to the one decision 8 left open: with
alert_groupsdeleted a conversation holds exactly one Case, so 500 firing alerts open 500 Slack threads by construction.test/load’sO(groups)bound and its “chatter ≤ alerts/10” ratio were deleted with tombstones. It needed a product ruling rather than tuning, because there was no longer a number to tune, and ADR 0045 is that ruling.