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).