Decisions, lessons, blockers. Append-only. Update as we go.
Decided
git3.techno-world.net/lrosales/broad-announce (private).companies.fcm_shared).info | warning | critical | inminent_colapse.
Only inminent_colapse bypasses quiet hours.(source_id, dedupe_key), attach
dedupe_count so user sees "×N in 60s" not N pushes.Open (was) → Resolved 2026-06-13
individuals row + generates one-time invite code;
user runs /start <invite_code> in the Telegram bot; bot
matches the code, links telegram_chat_id to the
pre-existing individual, and burns the code. Stricter path:
prevents drive-by bot self-registration, keeps
individuals auditable.title/body strings (or use the locale on the
subscription to key into their own lookup table before
calling our API). Alert schema carries locale and
title/body already-resolved. We can add a translation
layer in v2 if customers ask.Lessons (already)
alerts.<company_id> is fine; alerts.<company_id>.<source_id>
would let us scale router consumer groups per source. Locked
in §6 of ARCHITECTURE.md.2026-06-13 — added gRPC ingest (option a)
buf generate build step.StreamAlerts(Alert) → Ack carries the same dedupe_count
contract as HTTP/WS/MQTT.proto/broadannounce/v1/ingest.proto; server
stub in internal/grpcserver/, reusable client in
internal/grpcclient/. Auth = API key in metadata + optional
mTLS. Per-stream rate limit + 256 in-flight cap = natural
backpressure.2026-06-13 — M10 split: option C
loadgen/ tool is the new home for that.2026-06-13 — three open questions resolved
testfakes/). M10 must not burn 50k FCM credits.deliverd binary in the bench profile, not a NATS
subject drop. More realistic: we exercise the real broker +
router + the message shape deliverd would consume.loadgen ships as a single Docker image with all
four binaries; entrypoint picks one via the image's
command: field in the compose / k8s spec.2026-06-13 — Source protection (throttling) is in
rate_limit_per_sec, max_payload_bytes,
max_concurrent_connections, quarantine_*).2026-06-13 — M0 shipped (12 commits, 2888 LoC)
What landed:
cmd/{ingestd,routerd,deliverd,admind}/ — four Go service mainscmd/ingestd/http.go — HTTP POST handler implementing
SPEC §22 layers 1, 3, 4, 5 + Stripe-style HMAC authinternal/alert — Alert v1 type + Validate() (183 LoC + 103
LoC tests)internal/broker — NATS JetStream wrapper, three streams
(ALERTS/DELIVERIES/DLQ) auto-createdinternal/dedupe — 60s SET NX EX + INCR (Redis-required tests
pass against a host-local Redis)internal/ratelimit — per-second INCR bucket (Redis-required
tests pass)internal/observability — slog + Prometheus registry, the
IngestdMetrics struct matches SPEC §22 metric namesinternal/httpserver — shared /health + /metrics scaffoldinternal/config — env-driven Common + Ingestdloadgen/cmd/http/ — loadgen-http with --mode normal,
HMAC signing, 70/25/4/1 severity mix, dedupe-pct knobloadgen/go.mod — separate module per SPEC §21, replace
directive points at the parent moduledocker-compose.yml + Dockerfile — single-host stack, all
5 binaries in one imageM0_VERIFICATION.md — 8-step smoke testdeploy/prometheus/prometheus.yml — scrapes all 5 servicesWhat's NOT in M0 (and not supposed to be):
Module path: git3.techno-world.net/lrosales/broad-announce.
Loadgen module path: git3.techno-world.net/lrosales/broad-announce/loadgen.
All pushed: 7cd922c..49b2dba on master.
2026-06-13 — Port convention
Project rule: app HTTP services use 8800–8899 (ingestd 8800,
routerd 8801, deliverd 8802, admind 8803, loadgen metrics 8891,
fakefcmd 8820). Canonical ports stay (5432, 4222, 6379, 1883,
9090, 3000). Reason: 8080+ collides with workstation tooling.
SPEC §18 now has a port-conventions sub-section. Commit: d76aa0b.
2026-06-13 — M1 code-complete (8 commits, awaiting live smoke)
What landed:
migrations/001_init.up.sql — companies, individuals,
fcm_tokens (the M1 minimum schema; full SPEC §4 lands in M2
as additional migrations)migrations/002_deliveries.up.sql — deliveries table
(status: pending|sent|failed|dlq; payload jsonb for M8 replay)migrations/seed.sql — idempotent; 1 company, 1 individual,
1 tokeninternal/postgres — pgxpool wrapper with retry-on-startupcmd/seed — applies *.up.sql in lexical order, then seed.sqltestfakes/fakefcmd — 70 lines, /health + /v1/.../messages:send,
--fail-rate knobinternal/routing — Resolver with ResolveTokens (M1 broadcast
via single SQL join; M2 swaps for the rules engine)cmd/routerd — M1 main: subscribes to alerts.>, resolves
recipients, publishes one deliveries.fcm. per token
cmd/deliverd — M1 main: subscribes to deliveries.fcm.>,
builds FCM HTTP v1 message body (M3 swap is a no-op at this
layer), posts to BA_FAKECMD_URL, writes a deliveries rowWhat we agreed to defer (per the user):
What's NOT in M1 (and not supposed to be):
Pushed: 264d284..9cf68a1 on master (5 commits for M1 code,
plus the d76aa0b port shift).
The user still has to actually run docker compose up and the
M1 verification steps before M1 is fully done.
2026-06-13 — M2 shipped (recipient resolution, rules engine)
What landed (3 commits, ~600 LoC Go + ~250 LoC SQL):
migrations/003_subscriptions_groups.up.sql (and .down.sql):
5 new tables — sources, groups, group_members,
subscriptions, routing_rules. FKs to companies /
individuals. Idempotent.migrations/seed_m2.sql: 2 more individuals (Bob, Carol), 1
group (sre: Alice+Bob), 1 source row, 3 subscriptions
covering the 3 scenarios, 1 routing rule.cmd/seed/main.go: runner now applies seed.sql then
seed_m2.sql (lexically ordered) so re-running is safe.internal/routing/routing.go: full rewrite. New Resolver
with ResolveTargets(ctx, *alert.Alert) ([]Target, error).
Single SQL round-trip via a 5-CTE query:
internal/alert/alert.go: added Severity.Rank() and
MinSeverityRank() helpers. Two new unit tests in
alert_test.go.internal/routing/routing_test.go: new file, 10 subtests
for inQuietHours (same-day, wrap-around, always-quiet,
edge cases at window start/end).cmd/routerd/main.go: M2 main. Calls ResolveTargets(alert),
hard-fails on zero recipients (logs WARN, acks — no DLQ for
M2; that's M3+). Emits the new envelope shape with
Channel + Endpoint instead of the M1 FCMToken+Locale.cmd/deliverd/main.go: envelope struct updated to match
the M2 routerd output. Only field renames; the FCM HTTP v1
body shape is unchanged.M2_VERIFICATION.md: 7-step plan + hard-fail scenario.scripts/m2_smoke.sh: automated runner for steps 2-6.M2_SMOKE_LOG.md: per-step results, honest flags.Live smoke results (all green):
WARN msg="zero recipients, dropping", no delivery
9 deliveries, 0 failures, 0 retries.Per the user's M2 Q1/Q2/Q3 answers:
dlq.no_recipients.<company> subject).WHERE f.channel = 'fcm' in the resolver).What's NOT in M2 (and not supposed to be):
time.LoadLocation(tz) fails)Pushed: 280e048 on master (3 commits: a29c2d8 + 1e9eca9 + 280e048).
2026-06-14 — M3 shipped (Telegram delivery + bot commands)
What landed (~2k LoC Go + ~80 LoC SQL across these commits):
migrations/004_telegram.up.sql (+ .down.sql):
telegram_bots table (one row per (company, bot); M3
supports one bot per company), and 4 new columns on
individuals: telegram_chat_id, telegram_user_id,
telegram_invite_code, mute_until. Two partial unique
indexes for fast /start <code> lookup and "is this
telegram_user_id already linked" check.migrations/seed_m3.sql: inserts the per-company
telegram_bots row, sets telegram_invite_code on all
three individuals, and pre-links Alice to a fake
Telegram account (chat_id=1001, user_id=900001) so the
very first POST exercises both fcm and telegram delivery
paths. Also expands every subscription's channel_mask
to ["fcm","telegram"] (M2 was ["fcm"]).cmd/seed/main.go: runner now applies seed.sql,
seed_m2.sql, seed_m3.sql in lexical order. All three
are idempotent.cmd/deliverd/ → cmd/deliverd-fcm/: rename. The M2
deliverd is now deliverd-fcm — per the user's M3 Q2
answer (two per-channel binaries, not one with a registry).cmd/deliverd-telegram/: new binary. Subscribes to
deliveries.telegram.<company_id>, builds a
severity-prefixed text message, posts to the Bot API's
sendMessage endpoint, writes a deliveries row.
Single-attempt (no retry, no DLQ). M9 adds the chain.cmd/telegramd/: new binary. Loads the active bot list
from telegram_bots at startup, long-polls
getUpdates, dispatches commands to the handler, and
replies via sendMessage. One process per deployment;
per-bot sharding can come later if needed.internal/telegram/: 3 files, ~640 LoC.
client.go — BotClient interface + HTTP impl.commands.go — text → Command parser
(/start, /subscribe, /unsubscribe,
/preferences, /status, /mute, /unmute).
/mute accepts 2h, 30m, 90s, or until 18:00.handler.go — Command → DB updates. /start is
atomic claim-by-invite-code; /subscribe upserts a
subscription with the requested min_severity.commands_test.go — 7 subtests, 100% pass.internal/routing/routing.go: the resolver's single CTE
now UNION-ALLs an fcm_rows branch (joins on
fcm_tokens) and a tg_rows branch (filters on
telegram_chat_id IS NOT NULL). Same cost as M2; one
extra row in the join key set. Hard-fail on zero
targets unchanged.testfakes/faketgmd/: ~350 LoC. Fake Bot API server
with /admin/queue (queue an incoming update),
/admin/sent (read every sendMessage call), and
/admin/reset (clear state). In-memory only. Used
purely for the smoke test.docker-compose.yml + Dockerfile: add
deliverd-telegram (port 8821), telegramd (port 8822),
faketgmd (port 8830). Grafana is now 3001:3000
because :3000 is held by gogs on this host.M3_VERIFICATION.md: 8-step manual + automated walkthrough.M3_SMOKE_LOG.md: per-step results, honest flags.scripts/m3_smoke.sh: automated runner for steps 2–8.Live smoke results (all green):
/start acme-bob-002 via faketgmd admin queue
→ handler atomically claims the code, telegram_user_id=900002,
bot reply "Linked. Welcome, Bob SRE."/preferences → bot reply "Your subscriptions: …"14 deliveries across 4 unique alert_ids, 0 failures, 0 retries.
Per the user's M3 Q1/Q2/Q3 answers:
["fcm"] → M3's ["fcm","telegram"] via the
seed migration).What's NOT in M3 (and not supposed to be):
Pushed:
2026-06-14 — M4 shipped (MQTT ingest + per-source ACL)
What landed (~750 LoC Go + ~60 LoC config across these commits):
internal/mqttclient/: new package. Thin wrapper around
paho.MQTT that hides the token-on-publish option behind a
single error-returning Publish(), sets consistent
LastWill / MaxInflight / AutoReconnect defaults, and
formats client_id as <service>-mqtt-<host> so EMQX
/admin/clients shows them cleanly. Used by both
cmd/ingestd (subscriber) and loadgen/cmd/mqtt (publisher).cmd/ingestd/mqtt.go: new file. The MQTT subscriber runs
every message through the same processDeps.ProcessAlert
pipeline as the HTTP POST handler — parse → validate →
HMAC verify → rate-limit → dedupe → publish to NATS. The
only MQTT-specific code is the topic parser
(parseIncomingTopic → ba/<co>/<src>/incoming, 4
segments, last = incoming) and the envelope sniffer
(accepts both {alert, auth} envelope and bare alert
bodies for future broker-native clients).cmd/ingestd/main.go: wires up the MQTT path. New env vars
BA_INGESTD_MQTT_BROKER, BA_INGESTD_MQTT_USERNAME,
BA_INGESTD_MQTT_PASSWORD, BA_INGESTD_MQTT_SUBSCRIBE
(ba/+/+/incoming default).internal/observability/metrics.go: adds the
MQTTMessages CounterVec (labels: result=received |
accepted | deduped | bad_topic | bad_signature |
invalid_json | <other RejectReason>). Exposed as
ba_ingestd_mqtt_messages_total.loadgen/cmd/mqtt/: new binary. Same severity mix, dedupe
ratio, and burst mode as loadgen-http. Publishes to
ba/<co>/<src>/incoming with the <src>-<co> user and
HMAC secret for auth.deploy/emqx/acl.conf: Erlang-term ACL rules. Each source
can only publish to its own ba/<co>/<src>/incoming;
ingestd can subscribe to ba/+/+/incoming; default
deny on #. Re-read on EMQX SIGHUP.deploy/emqx/auth-built-in-db-bootstrap.csv: per-username
auth. <source_id>-<company_id> for sources, ingestd
for the subscriber. Password == HMAC secret so the same
secret serves both MQTT auth and per-message HMAC.deploy/emqx/README.md: explains the auth model, the
env-var-vs-emqx.conf precedence (env vars win in EMQX
5.x), and the emqx_ctl listeners restart command for
hot-reloading acl.conf.docker-compose.yml: switches the emqx service from
volume-mounting emqx.conf (which EMQX 5.x rejects as a
partial config with node.cookie required_field) to
env-var config. The EMQX_AUTHENTICATION__1__* and
EMQX_AUTHORIZATION__* env vars are the only way to
inject chain-level config in 5.x. acl.conf and the
bootstrap CSV stay as volume mounts (their files are
partials, which is fine).scripts/m4_smoke.sh: end-to-end smoke. 5 steps (1 alert,
5 alerts with dedupe, bad sig, ACL violation, bad json).
Builds loadgen-mqtt and the 3 failure-path test
binaries into /tmp/ on first run. 12 deliveries, 0
failures, 3 consecutive green runs.M4_VERIFICATION.md + M4_SMOKE_LOG.md: the spec-style
step-by-step + the live run results.Why MQTT in v1: SPEC §18 calls for HTTP+MQTT as the
v1 transports. M0–M3 ship HTTP; M4 ships MQTT. Sources that
prefer a broker (Prometheus alertmanager webhook-bridge,
Grafana, IoT) can publish to ba/<co>/<src>/incoming
without writing a webhook client.
Why per-source ACL: the broker is the first line of defense. A source that gets compromised can only spam its own topic — which still goes through the HMAC gate at ingestd, so a forged message without the secret is rejected in step 4. ACL stops cross-tenant spam; HMAC stops forged messages; rate-limit stops flood; dedupe stops burst; M9 adds the circuit breaker and quarantine.
Why env-var config beats emqx.conf: EMQX 5.x's HOCON
config is layered as emqx.conf → base.hocon →
cluster.hocon → env vars (highest precedence). The
double-underscore separator in env-var names maps to nested
HOCON keys. emqx.conf is a full config — partial
overrides get rejected with node.cookie required_field.
The env-var approach lets us set just the auth + authz
chains without restating the entire base.hocon.
What's NOT in M4 (and not supposed to be):
auth field in the JSON envelope is the equivalent)Pushed: bc907d9 on master (1 commit for the code; this PROMPT
bump + README + SPEC bump + the EMQX env-var refactor
consolidated into a single follow-up commit).
2026-06-14 — M5 shipped (WebSocket ingest + live tail + per-IP cap)
What landed (~650 LoC Go + 2 SQL files across these commits):
migrations/005_ws.up.sql: adds max_concurrent_connections
column to sources and companies (defaults 32 and 64).internal/concurrency/perip.go: tiny sync.Map[ip]atomic.Int64
with Acquire/Release/InUse and a janitor goroutine that
prunes entries idle > 5 min. No Redis on this hot path —
would self-DoS.internal/tailhub/hub.go: in-process pub/sub. Subscribe
returns a *Subscription with a buffered chan *Event
(size 64) and a per-sub Drops counter; Publish is
best-effort, drops on full. Filter.CompanyID matches
single-company or all (empty).internal/wsclient/client.go: thin gorilla/websocket
wrapper. Connect does the auth round-trip and stashes
the auth reply; SendAlert does a single WS write; Close
sends CloseMessage(1000) then TCP close. Used by
loadgen-ws and the smoke drivers.loadgen/cmd/ws/main.go: loadgen-ws binary. Default
normal profile (30% dedupe). 3 modes: normal, burst.loadgen/cmd/m5drivers/tail/main.go: /tmp/m5-tail-test
driver used by scripts/m5_smoke.sh steps 6 + 7.cmd/ingestd/ws.go: WS ingest at GET /v1/ingest/ws.
Auth via first frame {api_key}. The handler does
per-IP Acquire (rejects + 503 on cap exceeded), then
loops on alert frames, sniffs {alert, auth} envelope
vs bare body, and calls the shared scoped.ProcessAlert.
Per-IP Release runs in the handler's defer.cmd/ingestd/wstail.go: live tail at GET /v1/tail/ws.
Token via Authorization: Bearer, X-BA-Tail-Token, or
?token=. Optional ?company_id= filter. Subscribe
happens BEFORE the upgrade so events in the
dial→subscribe window are not lost (a real bug we
caught in M5 verification — see PROMPT bug #1 below).cmd/ingestd/process.go: processDeps now has
Tail *tailhub.Hub and Transport string. ProcessAlert
calls d.Tail.Publish(tailhub.FromAlert(&a, d.Transport))
on the accepted path, so the tail fans out alerts
from HTTP, MQTT, and WS through the same hub.cmd/ingestd/main.go: wires concurrency.NewPerIP,
tailhub.NewHub, and the two new dep structs.internal/observability/metrics.go: 5 new metrics —
WSMessages, WSConnections, ConnectionRejected,
TailSubscribers, TailDropped — all registered with
the ingestd service label.docker-compose.yml: BA_INGESTD_TAIL_TOKEN and
BA_INGESTD_MAX_CONCURRENT_PER_IP env vars on ingestd.scripts/m5_smoke.sh: 7-step live driver. Auto-builds
/tmp/loadgen-ws, /tmp/m5-tail-test, /tmp/m5-perip-test,
/tmp/m5-badkey on first run.Bug #1 — Tail subscribe vs upgrade race
The first M5 verification run showed the live tail seeing
subs=0 at publish time even though the tail's gauge
was 1. Root cause: Subscribe was called after the
WS upgrade, leaving a window where the client thought
it was connected but ingestd hadn't subscribed yet.
The WS alert sent during that window was published to
0 subscribers. Fix: subscribe first, then upgrade
(so the client's dial-completion implies the subscription
is in place). This is documented inline in
cmd/ingestd/wstail.go.
Bug #2 — Tail defer ordering The first run of the gauge-update defer fired while the subscription was still in the hub's map (defers run LIFO, so the metric defer fired before the unsubscribe defer). Result: the gauge stayed at 1 after the client disconnected. Fix: combine unsubscribe + gauge update into a single defer that runs after the stream loop exits.
Bug #3 — Badkey envelope shape
The first M5 step-4 test sent the HMAC as
auth: {"hmac": "v1=..."} (a map). The server's envelope
parser declared auth as a string, so the unmarshal
failed, the parser fell back to using the whole envelope
as the alert body, and validation rejected the alert
with "company_id missing". Fix: send auth as a string
"t=...,v1=...", same shape as the MQTT envelope.
scripts/m4_smoke.sh already had this correct — copy-
pasted the pattern into M5.
What's NOT in M5 (and not supposed to be):
POST /v1/ingest per-IP cap (M10 — the M5-bump
in SPEC §22; we ship the gate now and the wire-up to
HTTP is its own small PR)wscat and a loadgen-ws --count 1)Pushed: 3 commits on master mirroring M0–M4's pattern:
2026-06-14 — M6 shipped (Dedupe + ×N suffix + sliding TTL + free-for-dupes)
What landed (~350 LoC Go + 1 SQL-friendly column + 2 new metrics across these commits):
internal/dedupe/dedupe.go: rewritten with a single Lua
script that does SET-NX-or-INCR-EXPIRE atomically. The
EXPIRE call on every duplicate is what makes the window
slide — a steady stream of duplicates keeps the key alive.
Sub-second windows in tests are handled by math.ceil
in Lua (Redis EX requires an int).internal/observability/maxseen.go: a new
MaxSeen type that tracks per-key monotonic max. The
gauge API doesn't expose Get(), so the canonical
Prometheus pattern is to use a custom Collector; instead
we keep the max in a sync.Map[source]uint32 and
Set() the gauge only on growth. CAS-safe under
concurrent writers.cmd/ingestd/process.go: the order of operations is
now HMAC verify → dedupe → rate limit (per-source,
per-company). A duplicate (isNew=false) does not burn
a token in either bucket — the rate limit gate is
"free for dupes" as agreed. The duplicate still
propagates to NATS so the router can fan out the alert
with the updated dedupe_count. The M2 router, M3
deliverers, and the M5 tail hub all see the count climb.cmd/ingestd/main.go: wires observability.NewMaxSeen()
into processDeps so HTTP, MQTT, and WS transports
share one in-process state and one max-observed gauge.cmd/deliverd-telegram/main.go: formatMessage takes
a dedupeCount uint32 and appends (×N) to the title
when N > 1. Below 2, the title is unchanged.cmd/deliverd-fcm/main.go: notification.title gets
the same suffix. The raw count also goes into
data.dedupe_count so a native Android client can
render it however it likes.loadgen/cmd/{http,mqtt,ws}/main.go: all three
loadgens gain a --dedupe-key flag that forces a
specific key on every alert. This is what the ×N
smoke needs (one key, N copies, N=600 in the rate-limit
test).internal/config/config.go + docker-compose.yml:
BA_INGESTD_DEDUPE_TTL_SECONDS env var, default 300s
(was 60s hard-coded).internal/observability/metrics.go: 2 new metrics —
ba_ingestd_dedupe_collapsed_total{source} (counter
that ticks on every isNew=false hit) and
ba_ingestd_dedupe_count_max_observed{source} (gauge
that climbs to the highest dedupe_count seen for that
source since process start).Bug #1 — int-seconds truncation broke sub-second test windows
The first dedupe test with a 500ms window failed: the Lua
script received 0 for the TTL and returned an error
("invalid ttl"). Go's int(500ms.Seconds()) truncates to
0, which is below the script's > 0 guard. Fix: pass the
TTL as a float (Go's d.window.Seconds() returns float64)
and round up in Lua with math.ceil. Test sleeps were
also bumped to 1.3s to account for the rounded TTL.
Bug #2 — Prometheus Gauge doesn't expose Get()
The "max dedupe count" gauge needs to only tick upward,
but Prometheus Gauges can be Set() to any value. The
canonical pattern is a custom Collector that knows the
in-process state. That's a lot of boilerplate for one
metric. The pragmatic alternative: keep the max in a
sync.Map ourselves, and call Set() on the gauge only
when the new value strictly exceeds the previous max.
The MaxSeen type is thread-safe (CAS on conflict) and
the gauge becomes a read-only view of our in-process
state. 5 race-tested unit tests cover the corner cases.
Decision: free-for-dupes applies to BOTH rate-limit buckets The user picked "both buckets free" over "per-source only" in the M6 design conversation. The reasoning: a duplicate is a duplicate, and the rate limit exists to backpressure new alert volume, not to charge attempt volume. A dupe storm of 600/s through a 100/s bucket now costs 1 token instead of 600. The downside (a bad source with a valid HMAC could pump dupes for free) doesn't apply because they already have full source-level access — rate limit isn't a security control, it's a backpressure mechanism.
What's NOT in M6 (and not supposed to be):
dedupe_key — a true
"one message per burst" would require the router to
collapse before fanning out, instead of fanning out
N alerts with climbing dedupe_count. M6.5+ when the
M9 dashboard traffic pattern justifies it.max_concurrent_connections —
the migration added the column but the M6 path still
reads the env default (32). M6.5 or M10.Pushed: 3 commits on master mirroring M0–M5's pattern:
2026-06-14 — M6.5 shipped (Router-level dedupe collapse)
The natural follow-on to M6, falling out of the M6 "What's NOT" list. M6 gave us "the recipient sees ×N" (100 messages, the last one tagged). M6.5 gives us "the recipient sees 1 message" (collapsed by the router, not 100).
New code (~700 LoC Go + 1 new test binary + 1 new env var):
internal/dedupe/collapser.go (NEW, ~200 LoC): the
Collapser. Public API: NewCollapser(flushMs, onFlush),
Observe(sourceID, dedupeKey, alert) Decision,
Run(ctx), FlushAll(). The Decision is one of:
Passthrough — empty dedupe_key; deliver immediatelyCollapseNew — first arrival of (source, key);
caller does the expensive work (resolve recipients)
and caches itCollapseDupe — subsequent arrival within the
window; stored alert's dedupe_count already updated
Per-source isolation enforced by keying on
(source_id, dedupe_key), not on dedupe_key alone.internal/dedupe/collapser_test.go (NEW, ~250 LoC, 7
tests, all green): passthrough-on-empty-key,
single-collapse-100-alerts, per-source-isolation,
max-wait-re-flush, empty-key-doesn't-block-real-key,
8-goroutine-concurrent-same-key-storm, FlushAll-drain.
cmd/routerd/collapse.go (NEW, ~250 LoC): wires the
Collapser into routerd. The router holds a tiny
fanoutState (source+key → cached resolved targets) so
duplicate alerts skip the DB-bound ResolveTargets
call. The flush callback reads the cached targets and
publishes one delivery per (target, channel) with the
final dedupe_count.
cmd/routerd/main.go (refactored): handleOne routes
through observeAndFanout. The Collapser's Run loop
starts in a goroutine in consume(). FlushAll() is
called on graceful shutdown so the last few pending
collapses still get delivered. Config now loaded via
config.LoadRouterd() (was LoadCommon).
internal/config/config.go: new Routerd struct with
DedupeFlushMs field. Env var
BA_ROUTERD_DEDUPE_FLUSH_MS, default 2000ms.
testfakes/tailcount/main.go (NEW, ~70 LoC): a tiny Go
helper that subscribes to the M5 tail WS and counts
events, filtering on a substring. Used only by the
smoke; no production code touches it.
loadgen/cmd/ws/main.go (small change): makeAlert
injects the --dedupe-key into the title as
LG M5 burst <key> #N when forced. The compact tail
event includes title but NOT dedupe_key (by design),
so the smoke needs to filter on something the operator
can see.
docker-compose.yml + .env.example:
BA_ROUTERD_DEDUPE_FLUSH_MS=2000 documented.
Two real bugs caught + fixed in M6.5 verification:
Tail filter was on dedupe_key but the compact tail
event doesn't include it. The tail event JSON is just
{alert_id, company_id, source_id, severity, title,
received_at, transport, dedupe_count} — no
dedupe_key field by design (M5 spec keeps the event
small). Fixed by making the loadgen put the key in the
title, and the smoke filters on the title prefix
instead. The dedupe_key field is preserved in the
payload (for the deliverers) but not in the tail
event.
Globex had no recipients in the dev DB. The dev schema only seeds acme; the m6 smoke never sent globex alerts. Step 5's per-source-isolation test surfaced this ("no recipients resolved" for grafana). The smoke now auto-seeds the globex company, source, individual, and subscription at the top (idempotent INSERT ... ON CONFLICT DO NOTHING). This is a smoke helper, not a migration — the real seed lives in the M2 migration files. The smoke needs globex to exist for Step 5; the production code doesn't care.
Step 3 (tail + collapse) needed the burst to fit inside one flush window. First version sent 25 alerts at 10/s = 2.5s, which crossed the 2s flush boundary and produced 2 messages for one burst. Bumped the rate to 50/s = 0.5s, so the whole burst is inside a single window. The test still proves the headline behavior (1 message + tail sees the storm), but the timing has to be tuned to the configured flush window.
Design choice recap (per the M6.5 scope conversation):
ResolveTargets call. Subsequent dupes
update the dedupe_count on the stored alert and
return. Net: 100-alert burst produces 1 DB call, not
What's NOT in M6.5 (deferred):
dedupe_count, but we don't have a
ba_routerd_collapse_total{source} counter yet. M7
dashboard work.BA_ROUTERD_DEDUPE_FLUSH_MS —
a noisy source might want a longer window. The
migration added sources.dedupe_flush_ms but the M6.5
path uses the env default for all sources.collapsed_count: N field to the payload so
deliverers can render "5 messages collapsed" in
addition to the (×N) suffix.Pushed: 3 commits on master mirroring M0–M6's pattern:
What's in M7:
migrations/006_timescale.{up,down}.sql — deliveries
→ hypertable on created_at, 1d chunks, 7-day retention
policy. PK rebased from (id) to (id, created_at)
because Timescale requires the partition column in any
UNIQUE/PK constraint. Down-migration uses the shadow-table
rename pattern so it's reversible without data loss.migrations/clickhouse_schema.sql — hand-runnable ops
reference for ba_archive.deliveries_archive (MergeTree,
partition by toYYYYMM(created_at), TTL 365 days) +
deliveries_per_company_daily_mv (SummingMergeTree
aggregate for M9 dashboards). Renamed from
007_clickhouse.up.sql to keep the file out of the seed's
*.up.sql glob (the seed applies files via Postgres
pool.Exec, which would have failed on the pure ClickHouse
DDL). The runtime source of truth is
archiverd.ensureCHSchema (idempotent via IF NOT EXISTS).internal/archiver/archiver.go (~350 LoC) —
RunOnce(ctx, opts) → Report. Drain loop, advisory lock
(pg_try_advisory_lock(0xBA21B0DA)), FOR UPDATE SKIP
LOCKED select, CH HTTP POST INSERT, idempotent
ensureCHSchema. Time marshalling via chTime /
chTimeOrEmpty helpers — CH 24.10 cannot parse Go's
RFC3339Nano for DateTime64(3, 'UTC').cmd/archiverd/main.go (~150 LoC) — hourly loop
(first run immediate), /health (200 if last run <
2× cadence, 503 otherwise), /metrics
(ba_archiverd_rows_archived_total{table},
ba_archiverd_last_run_*).internal/config/config.go — Archiverd struct +
LoadArchiverd().docker-compose.yml — archiverd service on host
port 8804.Dockerfile — archiverd in the build chain..env.example — BA_ARCHIVERD_RUN_EVERY_SECONDS=3600,
BA_ARCHIVERD_OLDER_THAN_HOURS=168,
BA_ARCHIVERD_BATCH_SIZE=10000.scripts/m7_smoke.sh — 4-step, 9-check smoke.scripts/m7_smoke_runner.sh — wrapper that stops the
archiverd compose service first to release the advisory
lock (so the smoke's one-shot exec is the deterministic
holder).M7_VERIFICATION.md + M7_SMOKE_LOG.md — three
green runs, 9/9 checks each, on the remote playground
parres (192.168.44.94).SPEC drift noted (not a bug):
alerts as a hypertable
alongside deliveries. Code inspection: alerts is
never written to Postgres — alerts flow through NATS
JetStream only (cmd/ingestd/process.go →
nc.Publish("alerts", ...)). The alerts table is
absent from migrations/001_init.up.sql. The routerd
dispatcher writes per-recipient rows to deliveries,
not alerts. Decision: ship M7 with deliveries
only. Document drift in verification.Bugs caught during M7 build (all fixed in this milestone):
DateTime64 rejected. `TTL created_at
→TTL toDateTime(created_at) +
INTERVAL 365 DAY. CH requiresDateTimein TTL, not
DateTime64`.time.Time JSON format. Default RFC3339Nano
with T and Z not parseable by CH. Fix:
chTime(t) → "2006-01-02 15:04:05.000".SET LOCAL lock_timeout. SKIP LOCKED
is non-blocking by definition. Removed.RUN chain masks errors. Chained && RUN
builds all binaries in one cache layer. Failure
produces generic "exit code 1" with no stderr. Root
cause: loadgen/cmd/mqtt/main.go referenced
*dedupeKey on line 114 without declaring the flag.
The same fix that the M6.5 postmortem flagged for
cmd/http/main.go had not been extended to
cmd/mqtt/main.go. Fix: add the --dedupe-key flag
to the mqtt var block.curl
"$URL/?query=..." returns 405 for INSERTs in CH 24.10.
Use http.NewRequestWithContext(ctx, "POST", chURL+"/",
bytes.NewBufferString(s)).seed would have failed on ClickHouse DDL. The
seed service globs migrations/*.up.sql and applies
every file via pool.Exec. 007_clickhouse.up.sql is
pure ClickHouse DDL. Renamed to
migrations/clickhouse_schema.sql (no .up.sql
suffix) so the seed glob skips it. The file is
preserved as a hand-runnable ops reference; the
runtime source of truth is archiverd.ensureCHSchema.Remote-playground deployment notes:
docker compose plugin in the remote's apt repo.
Standalone v2.27.0 binary at
/usr/local/lib/docker/cli-plugins/docker-compose (with
a symlink to /usr/local/bin/docker-compose for shell
compat).sysctl -w
net.ipv6.conf.all.disable_ipv6=1 fixes it. All 9 base
images pulled in 66 s after the disable.docker compose build on the remote
compiles the 12 Go binaries in 11 seconds. Build on
the remote, do not transfer pre-built images from
local.M8 (DLQ + replay UI) — shipped 2026-06-14
SPEC §23 M8 = DLQ for failed deliveries + replay UI. shipped. 6 commits mirroring M0–M7 (1/3 + 1b/3 + 1c/3 + 1d/3 are all code; 2/3 is the verification doc
m8_smoke.sh on the
local docker-compose stack; see M8_VERIFICATION.md +
M8_SMOKE_LOG.md. Remote parres (192.168.44.94) was
unreachable from this dev host (TCP RST on 22/80) so
the live re-run there is left to the operator.What landed:
migrations/008_dlq.{up,down}.sql —
deliveries_dlq Timescale hypertable, 1d chunks,
7d retention, PK (id, created_at). Extra columns:
original_subject (for replay), discarded /
discarded_at / discarded_by (for the operator's
discard action).internal/retry — bounded exp-backoff retry helper
(10 attempts, base 100ms, cap 2s, budget 30s,
~12s total wall clock for a fully failing target).
PermanentError short-circuit. 7 unit tests, 7/7 PASS.internal/dlq — single Write() that INSERTs one
row into deliveries_dlq. Per-attempt deliveries
rows are left as-is so the audit trail is preserved.cmd/deliverd-fcm + cmd/deliverd-telegram —
refactored handleOne() to use retry.Run. On
success, a 'sent' row. On exhaustion, a per-attempt
'failed' row + a final dlq.Write(). PermanentError
on FCM/Telegram 4xx (excluding 408/429) so we don't
burn the retry budget on a bad token or a missing
chat.cmd/admind — DLQ surface:
GET /v1/dlq?company_id=&channel=&alert_id=
&include=all&limit=100&offset=0 (30d window,
hides discarded by default)GET /v1/dlq/{id} (single row w/ payload)POST /v1/dlq/{id}/replay (re-publishes the
original NATS envelope onto the original subject,
then marks the row discarded)POST /v1/dlq/{id}/discard (mark discarded;
idempotent)GET /dlq — minimal HTML page embedded via
go:embed, light/dark theming, filter form,
inline replay/discard buttons, plain ES5 JS.internal/archiver — RunOptions now carries a
[]TableSpec; default plan covers both deliveries
and deliveries_dlq (7d Postgres hot window). CH
schema adds ba_archive.deliveries_dlq_archive
(2y TTL) + ba_archive.deliveries_dlq_per_company_daily_mv
(SummingMergeTree) for M9 dashboards.testfakes/fakefcmd — runtime /control?fail=0|1
endpoint to flip failure mode without restarting
the container (saves 30s+ per smoke cycle on the
1-CPU QEMU host).docker-compose.yml + .env.example — wired
BA_DELIVERD_* on both deliverd-fcm and
deliverd-telegram.scripts/m8_smoke.sh — 4-step, 12-check live smoke.Loose ends addressed:
archiverd drains deliveries_dlq (2y CH TTL,
same 7d PG hot window as live deliveries).Loose ends still open (deferred):
deliveries_per_company_daily_mv
could be exposed via admind for the dashboard
(deferred to M9 observability).sysctl net.ipv6.conf.all.disable_ipv6=1
in /etc/sysctl.d/99-disable-ipv6.conf on the
remote playground so it survives reboots.SPEC drift:
SPEC §9 literally calls for backoff of 1s, 2s, 4s, …
512s = 1023s total. M8 ships defaults that cap per-
attempt wait at 2s and total budget at 30s, terminating
in ~12s. Operators who want the SPEC-literal behavior
can opt in via the four BA_DELIVERD_* env vars.
The defaults trade literal SPEC compliance for fast
failure detection.
Pushed: 6 commits on master mirroring the M0–M7 pattern (1/3 + 1b/3 + 1c/3 + 1d/3 are all code; 2/3 is the verification doc + smoke log; 3/3 is this PROMPT + README + SPEC bump):
*.up.sql glob (bug 6 above)--dedupe-key flag in
loadgen-mqtt (bug 4 above)parres 192.168.44.94)M8 (this section supersedes the 'next' placeholder above):