carlos / platform Public

console: key alert_seen per recipient so one dead grant pages one operator

All-or-nothing recording meant a single revoked keymail grant re-sent the
whole transition to every healthy operator, every minute, for as long as the
alert stayed up. The state is now keyed (box, rule, address): each mailable
operator is diffed on their own, a success records that operator's row, and
a failure leaves only theirs unrecorded — so the next pass retries exactly
the operator who missed out.

The old two-column table exists only on unreleased dev consoles, so the
migration is replaced in place rather than chained.

When no operator holds a grant at all there is nothing to send, and the
transition is recorded against the synthetic empty address so it stops
replaying and re-logging forever. That drops the alert: an operator who
gains a grant afterwards is not caught up on it, because an unknown address
falls back to the synthetic row. That is a real hole, so it is named in the
comments and asserted in a test rather than left to be discovered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5cb1999df6ae6b391d511af0aefe72002bd558d5 parent aa7ef08
4 files changed, +257 −93
  • internal/console/alertwatch.go +92 −41
  • internal/console/alertwatch_test.go +105 −19
  • internal/console/store.go +33 −21
  • internal/console/store_test.go +27 −12
diff --git a/internal/console/alertwatch.go b/internal/console/alertwatch.go
index 70df565d8..e0023ebad 100644
--- a/internal/console/alertwatch.go
+++ b/internal/console/alertwatch.go
@@ -17,14 +17,20 @@ import (
// OAuth grant (Keymail.SendApp) and the agent has no keymail identity. The
// box judges itself and publishes alerts/boxes/<box>.json; this watcher
// polls that prefix, diffs each rule's firing state against what it last
-// told operators (the alert_seen table), and mails the difference — each
-// operator with that operator's OWN grant token, so nobody is sent mail on
-// a credential they did not personally issue.
+// told each operator (the alert_seen table), and mails the difference —
+// each operator with that operator's OWN grant token, so nobody is sent
+// mail on a credential they did not personally issue.
+//
+// The diff is per RECIPIENT, not per rule, because delivery fails per
+// recipient: a success records that operator's row and a failure leaves
+// only theirs unrecorded, so the next pass retries exactly the operator
+// who missed out. One operator's revoked grant can no longer re-page every
+// healthy operator on the fleet, tick after tick.
//
// The failure posture throughout is "never go silent": an unreadable or
-// unparsable alert object is logged and skipped, a send failure leaves the
-// transition unrecorded so the next pass retries the whole thing, and no
-// path here can panic a console that is otherwise serving fine.
+// unparsable alert object is logged and skipped, an undelivered transition
+// stays pending for that operator until it lands, and no path here can
+// panic a console that is otherwise serving fine.
// alertWatchInterval is the poll cadence from the spec: a minute is fast
// enough for a disk filling up and slow enough to be free.
@@ -69,20 +75,27 @@ type alertRun struct {
noted bool
}
-// recipients returns the mailable operators, logging the token-less ones the
-// first time a pass actually needs to send.
-func (r *alertRun) recipients() []operatorGrant {
- if !r.noted && len(r.missing) > 0 {
- r.noted = true
- log.Printf("console: alert watch: no keymail grant for %s — not notified (they must sign in to the console once)",
- strings.Join(r.missing, ", "))
+// noteMissing names the operators the console cannot write as, at most once
+// per pass, called only when the pass actually has something to deliver.
+func (r *alertRun) noteMissing() {
+ if r.noted || len(r.missing) == 0 {
+ return
}
- return r.to
+ r.noted = true
+ log.Printf("console: alert watch: no keymail grant for %s — not notified (they must sign in to the console once)",
+ strings.Join(r.missing, ", "))
}
-// alertPass reads every alerts/boxes/*.json, diffs each rule's firing state
-// against alert_seen, and for each transition mails every operator holding a
-// grant and records the new state. It returns an error only for a failure
+// noMailboxAddress is the synthetic alert_seen recipient standing for "no
+// operator was mailable when this transition happened". Recording it stops
+// the transition replaying — and being re-logged — every minute forever,
+// at the honest cost that the alert is simply lost: an operator who gains a
+// grant later is not caught up on it (see alertSeenFor).
+const noMailboxAddress = ""
+
+// alertPass reads every alerts/boxes/*.json and, for each rule, diffs its
+// firing state against what alert_seen says each operator was last told,
+// mailing and recording per recipient. It returns an error only for a failure
// that makes the whole pass meaningless (listing the bucket, or reading the
// operator list); per-box and per-rule trouble is logged and stepped over.
func (s *Server) alertPass(ctx context.Context) error {
@@ -154,42 +167,80 @@ func (s *Server) alertRecipients() (*alertRun, error) {
return run, nil
}
-// alertRule handles one rule of one box: nothing to do unless its firing
-// state differs from what operators were last told.
+// alertRule handles one rule of one box: every mailable operator whose
+// recorded state differs from the published one gets a keymail, and each
+// send is recorded on its own.
func (s *Server) alertRule(ctx context.Context, run *alertRun, box, rule string, rs metrics.RuleState, updated time.Time) {
- seen, known, err := s.store.AlertSeen(box, rule)
- if err != nil {
- log.Printf("console: alert watch: alert_seen %s/%s: %v", box, rule, err)
- return
- }
- if known && seen == rs.Firing {
+ if len(run.to) == 0 {
+ // Nobody the console can write as. Record the transition against the
+ // synthetic recipient so it stops replaying, and say so out loud —
+ // this is the one path where an alert is genuinely dropped.
+ if s.alertDue(box, rule, noMailboxAddress, rs.Firing) {
+ run.noteMissing()
+ log.Printf("console: alert watch: %s/%s transition dropped — no operator holds a keymail grant", box, rule)
+ s.recordAlertSeen(box, rule, noMailboxAddress, rs.Firing)
+ }
return
}
- if !known && !rs.Firing {
- // First sighting of a quiet rule. Record it so a later fire is a
- // transition; say nothing — "everything is fine" is not a page.
- if err := s.store.SetAlertSeen(box, rule, false); err != nil {
- log.Printf("console: alert watch: record %s/%s: %v", box, rule, err)
+ var due []operatorGrant
+ for _, op := range run.to {
+ if s.alertDue(box, rule, op.address, rs.Firing) {
+ due = append(due, op)
}
+ }
+ if len(due) == 0 {
return
}
+ run.noteMissing()
subject, body := alertMessage(box, rule, rs, updated)
- delivered := true
- for _, op := range run.recipients() {
+ for _, op := range due {
if _, err := s.sendApp(ctx, op.token, op.address, subject, body); err != nil {
- // The address is safe to log; the token never is.
+ // The address is safe to log; the token never is. Leaving this
+ // operator's row unrecorded is the retry: the next pass mails
+ // them and nobody else.
log.Printf("console: alert watch: keymail %s/%s to %s: %v", box, rule, op.address, err)
- delivered = false
+ continue
}
+ s.recordAlertSeen(box, rule, op.address, rs.Firing)
}
- if !delivered {
- // Leave the transition unrecorded: the next pass retries the whole
- // thing, which may re-mail an operator who did get it. A duplicate
- // page is cheap; a missed one is what this system exists to prevent.
- return
+}
+
+// alertDue reports whether address must be told that (box, rule) is now in
+// this firing state, recording a quiet first sighting as it goes — nobody
+// wants mail saying the disk is fine.
+//
+// An address the console has never written to about this rule falls back to
+// the synthetic no-mailbox row: if the transition was already dropped for
+// want of any grant at all, an operator who gains a grant afterwards is not
+// retroactively paged about it.
+func (s *Server) alertDue(box, rule, address string, firing bool) bool {
+ seen, known, err := s.store.AlertSeen(box, rule, address)
+ if err != nil {
+ log.Printf("console: alert watch: alert_seen %s/%s for %s: %v", box, rule, address, err)
+ return false
+ }
+ if !known && address != noMailboxAddress {
+ seen, known, err = s.store.AlertSeen(box, rule, noMailboxAddress)
+ if err != nil {
+ log.Printf("console: alert watch: alert_seen %s/%s: %v", box, rule, err)
+ return false
+ }
}
- if err := s.store.SetAlertSeen(box, rule, rs.Firing); err != nil {
- log.Printf("console: alert watch: record %s/%s: %v", box, rule, err)
+ if known && seen == firing {
+ return false
+ }
+ if !known && !firing {
+ // First sighting of a quiet rule. Record it so a later fire is a
+ // transition; say nothing.
+ s.recordAlertSeen(box, rule, address, false)
+ return false
+ }
+ return true
+}
+
+func (s *Server) recordAlertSeen(box, rule, address string, firing bool) {
+ if err := s.store.SetAlertSeen(box, rule, address, firing); err != nil {
+ log.Printf("console: alert watch: record %s/%s for %s: %v", box, rule, address, err)
}
}
diff --git a/internal/console/alertwatch_test.go b/internal/console/alertwatch_test.go
index 02ad99d71..75ac99fa2 100644
--- a/internal/console/alertwatch_test.go
+++ b/internal/console/alertwatch_test.go
@@ -17,30 +17,25 @@ type sentMail struct {
token, to, subject, body string
}
-// alertFixture is a console fixture with two operators — one holding a
-// keymail grant, one not — and a captured sendApp.
+// alertFixture is a console fixture with a captured sendApp. err makes
+// sends fail; failFor narrows the failure to one recipient, which is how
+// the per-recipient retry is pinned.
type alertFixture struct {
*consoleFixture
- sent []sentMail
- err error // what the captured sendApp returns
+ sent []sentMail
+ err error
+ failFor string
}
-func newAlertFixture(t *testing.T) *alertFixture {
+// newCapturedFixture wires the capture and nothing else — no operators, so
+// a test can build its own recipient shape.
+func newCapturedFixture(t *testing.T) *alertFixture {
t.Helper()
fx := newConsoleFixture(t)
af := &alertFixture{consoleFixture: fx}
- for _, addr := range []string{"ops-a@keymail.dev", "ops-b@keymail.dev"} {
- if err := fx.st.SetOperator(addr, true); err != nil {
- t.Fatal(err)
- }
- }
- // Only ops-a ever granted the console send access.
- if err := fx.st.SetGrantToken("ops-a@keymail.dev", "grant-a", time.Now()); err != nil {
- t.Fatal(err)
- }
fx.srv.sendApp = func(_ context.Context, token, to, subject, body string) (string, error) {
af.sent = append(af.sent, sentMail{token, to, subject, body})
- if af.err != nil {
+ if af.err != nil && (af.failFor == "" || af.failFor == to) {
return "", af.err
}
return "msg-1", nil
@@ -48,6 +43,22 @@ func newAlertFixture(t *testing.T) *alertFixture {
return af
}
+// newAlertFixture is the common shape: two operators, only one of whom ever
+// granted the console send access.
+func newAlertFixture(t *testing.T) *alertFixture {
+ t.Helper()
+ af := newCapturedFixture(t)
+ for _, addr := range []string{"ops-a@keymail.dev", "ops-b@keymail.dev"} {
+ if err := af.st.SetOperator(addr, true); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := af.st.SetGrantToken("ops-a@keymail.dev", "grant-a", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ return af
+}
+
func (af *alertFixture) writeAlert(t *testing.T, st metrics.AlertState) {
t.Helper()
b, err := json.Marshal(st)
@@ -118,7 +129,7 @@ func TestAlertPassFiresOnceThenGoesQuiet(t *testing.T) {
}
// Recorded: the same state on the next pass says nothing.
- firing, known, err := af.st.AlertSeen("box-a", "disk")
+ firing, known, err := af.st.AlertSeen("box-a", "disk", "ops-a@keymail.dev")
if err != nil || !known || !firing {
t.Fatalf("AlertSeen = %v,%v,%v — want true,true,nil", firing, known, err)
}
@@ -161,7 +172,7 @@ func TestAlertPassRetriesAfterSendFailure(t *testing.T) {
if len(af.sent) != 1 {
t.Fatalf("failing pass attempted %d sends, want 1", len(af.sent))
}
- if _, known, err := af.st.AlertSeen("box-a", "disk"); err != nil || known {
+ if _, known, err := af.st.AlertSeen("box-a", "disk", "ops-a@keymail.dev"); err != nil || known {
t.Fatalf("AlertSeen recorded despite a send failure (known=%v, err=%v)", known, err)
}
@@ -170,11 +181,86 @@ func TestAlertPassRetriesAfterSendFailure(t *testing.T) {
if len(af.sent) != 1 {
t.Fatalf("retry pass sent %d keymails, want 1", len(af.sent))
}
- if firing, known, err := af.st.AlertSeen("box-a", "disk"); err != nil || !known || !firing {
+ if firing, known, err := af.st.AlertSeen("box-a", "disk", "ops-a@keymail.dev"); err != nil || !known || !firing {
t.Fatalf("AlertSeen = %v,%v,%v after a successful retry", firing, known, err)
}
}
+// TestAlertPassRetriesOnlyTheFailedOperator is why alert_seen is keyed per
+// recipient: one operator's dead grant must not re-page every healthy
+// operator on the fleet, tick after tick.
+func TestAlertPassRetriesOnlyTheFailedOperator(t *testing.T) {
+ af := newAlertFixture(t)
+ if err := af.st.SetGrantToken("ops-b@keymail.dev", "grant-b", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ af.writeAlert(t, firingDisk(true))
+ af.err = context.DeadlineExceeded
+ af.failFor = "ops-b@keymail.dev"
+
+ af.pass(t)
+ if len(af.sent) != 2 {
+ t.Fatalf("first pass attempted %d sends, want 2: %+v", len(af.sent), af.sent)
+ }
+ if firing, known, err := af.st.AlertSeen("box-a", "disk", "ops-a@keymail.dev"); err != nil || !known || !firing {
+ t.Fatalf("ops-a AlertSeen = %v %v %v — a healthy delivery was not recorded", firing, known, err)
+ }
+ if _, known, err := af.st.AlertSeen("box-a", "disk", "ops-b@keymail.dev"); err != nil || known {
+ t.Fatalf("ops-b AlertSeen recorded despite a failed send (known=%v, err=%v)", known, err)
+ }
+
+ af.err = nil
+ af.pass(t)
+ if len(af.sent) != 1 || af.sent[0].to != "ops-b@keymail.dev" {
+ t.Fatalf("retry pass sent %+v, want exactly one mail to ops-b", af.sent)
+ }
+ if af.sent[0].token != "grant-b" {
+ t.Fatalf("retry used token %q, want ops-b's own grant", af.sent[0].token)
+ }
+ af.pass(t)
+ if len(af.sent) != 0 {
+ t.Fatalf("pass after the retry sent %d keymails, want 0", len(af.sent))
+ }
+}
+
+// TestAlertPassNoMailableOperator pins the one path where an alert is
+// genuinely dropped: nobody holds a grant, so there is nothing to send and
+// the transition is recorded against the synthetic recipient so it does not
+// replay every minute forever. The honest cost — documented, and asserted
+// here rather than left to be discovered — is that an operator who gains a
+// grant afterwards is never told about that transition.
+func TestAlertPassNoMailableOperator(t *testing.T) {
+ af := newCapturedFixture(t)
+ if err := af.st.SetOperator("ops-b@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ af.writeAlert(t, firingDisk(true))
+
+ af.pass(t)
+ if len(af.sent) != 0 {
+ t.Fatalf("sent %d keymails with no grant anywhere: %+v", len(af.sent), af.sent)
+ }
+ if firing, known, err := af.st.AlertSeen("box-a", "disk", ""); err != nil || !known || !firing {
+ t.Fatalf("synthetic AlertSeen = %v %v %v, want true true nil — the drop was not recorded", firing, known, err)
+ }
+
+ // The grant arrives after the fact. The alert is still firing, but the
+ // console has already recorded the transition as handled: no catch-up.
+ if err := af.st.SetGrantToken("ops-b@keymail.dev", "grant-b", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ af.pass(t)
+ if len(af.sent) != 0 {
+ t.Fatalf("a newly-granted operator was retroactively paged: %+v", af.sent)
+ }
+ // But the NEXT transition reaches them normally.
+ af.writeAlert(t, firingDisk(false))
+ af.pass(t)
+ if len(af.sent) != 1 || af.sent[0].to != "ops-b@keymail.dev" {
+ t.Fatalf("clear after the grant arrived sent %+v, want one mail to ops-b", af.sent)
+ }
+}
+
// TestAlertPassQuietFirstSighting: a rule the console has never seen that is
// NOT firing is recorded silently. Nobody wants a keymail saying the disk is
// fine.
@@ -186,7 +272,7 @@ func TestAlertPassQuietFirstSighting(t *testing.T) {
if len(af.sent) != 0 {
t.Fatalf("quiet first sighting sent %d keymails, want 0: %+v", len(af.sent), af.sent)
}
- if firing, known, err := af.st.AlertSeen("box-a", "disk"); err != nil || !known || firing {
+ if firing, known, err := af.st.AlertSeen("box-a", "disk", "ops-a@keymail.dev"); err != nil || !known || firing {
t.Fatalf("AlertSeen = %v,%v,%v — want false,true,nil", firing, known, err)
}
}
diff --git a/internal/console/store.go b/internal/console/store.go
index 07e471c65..301e154e0 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -162,15 +162,22 @@ func (s *Store) migrate() error {
accepted_at INTEGER NOT NULL DEFAULT 0
);`,
// The alert watcher's memory (alertwatch.go): the firing state the
- // console last told operators about, per (box, rule). A row's absence
- // means "never seen", which is why AlertSeen reports known separately
- // — a first sighting of a firing rule is news, a first sighting of a
- // quiet one is not.
+ // console last told ONE operator about, per (box, rule, address). A
+ // row's absence means "never told them", which is why AlertSeen
+ // reports known separately — a first sighting of a firing rule is
+ // news, a first sighting of a quiet one is not.
+ //
+ // Per-recipient rather than per-rule because delivery fails per
+ // recipient: one operator's revoked keymail grant must not re-page
+ // every healthy operator on the next pass. The empty address is a
+ // synthetic row meaning "nobody was mailable when this transition
+ // happened" (see alertwatch.go).
`CREATE TABLE IF NOT EXISTS alert_seen (
- box TEXT NOT NULL,
- rule TEXT NOT NULL,
- firing INTEGER NOT NULL,
- PRIMARY KEY (box, rule)
+ box TEXT NOT NULL,
+ rule TEXT NOT NULL,
+ address TEXT NOT NULL,
+ firing INTEGER NOT NULL,
+ PRIMARY KEY (box, rule, address)
);`,
} {
if _, err := s.db.Exec(stmt); err != nil {
@@ -1097,13 +1104,18 @@ func (s *Store) Operators() ([]string, error) {
// ---- alert watcher state (alertwatch.go) ------------------------------------
-// AlertSeen reports the firing state the console last NOTIFIED operators
-// about for (box, rule), and whether it has ever seen that rule at all.
-// known=false is not the same as firing=false: a rule the console has never
-// seen fires as news, while one it has seen quiet stays quiet.
-func (s *Store) AlertSeen(box, rule string) (firing bool, known bool, err error) {
+// AlertSeen reports the firing state the console last NOTIFIED address
+// about for (box, rule), and whether it has ever told them anything about
+// that rule. known=false is not the same as firing=false: a rule this
+// operator has never been told about fires as news, while one they were
+// last told was quiet stays quiet.
+//
+// The empty address is the watcher's synthetic "nobody was mailable"
+// recipient (alertwatch.go), not a real user.
+func (s *Store) AlertSeen(box, rule, address string) (firing bool, known bool, err error) {
var v int
- err = s.db.QueryRow(`SELECT firing FROM alert_seen WHERE box = ? AND rule = ?`, box, rule).Scan(&v)
+ err = s.db.QueryRow(`SELECT firing FROM alert_seen WHERE box = ? AND rule = ? AND address = ?`,
+ box, rule, address).Scan(&v)
if err == sql.ErrNoRows {
return false, false, nil
}
@@ -1113,17 +1125,17 @@ func (s *Store) AlertSeen(box, rule string) (firing bool, known bool, err error)
return v != 0, true, nil
}
-// SetAlertSeen records that operators have been told (box, rule) is in this
-// state. Written only AFTER every send for a transition succeeded, so a
-// delivery failure leaves the transition pending and the next pass retries
-// it — a duplicate page beats a missed one.
-func (s *Store) SetAlertSeen(box, rule string, firing bool) error {
+// SetAlertSeen records that address has been told (box, rule) is in this
+// state. Written per recipient, only after THAT recipient's send succeeded,
+// so one dead grant leaves one row unrecorded and the next pass retries
+// that operator alone.
+func (s *Store) SetAlertSeen(box, rule, address string, firing bool) error {
v := 0
if firing {
v = 1
}
- _, err := s.db.Exec(`INSERT INTO alert_seen (box, rule, firing) VALUES (?, ?, ?)
-ON CONFLICT(box, rule) DO UPDATE SET firing = excluded.firing`, box, rule, v)
+ _, err := s.db.Exec(`INSERT INTO alert_seen (box, rule, address, firing) VALUES (?, ?, ?, ?)
+ON CONFLICT(box, rule, address) DO UPDATE SET firing = excluded.firing`, box, rule, address, v)
return err
}
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
index c59908ebe..4becf8bfd 100644
--- a/internal/console/store_test.go
+++ b/internal/console/store_test.go
@@ -361,34 +361,49 @@ func TestOperatorsList(t *testing.T) {
}
}
-// TestAlertSeenRoundTrip: the watcher's memory. "Never seen" and "seen, not
-// firing" are different answers — the first is news on a fire, the second is
-// what makes the second pass quiet.
+// TestAlertSeenRoundTrip: the watcher's memory, one row per recipient.
+// "Never told them" and "told them it was quiet" are different answers —
+// the first is news on a fire, the second is what makes the second pass
+// quiet — and one operator's row must never answer for another's.
func TestAlertSeenRoundTrip(t *testing.T) {
s := openTestStore(t)
- if firing, known, err := s.AlertSeen("box-a", "disk"); err != nil || known || firing {
+ const amy, zoe = "amy@keymail.dev", "zoe@keymail.dev"
+ if firing, known, err := s.AlertSeen("box-a", "disk", amy); err != nil || known || firing {
t.Fatalf("AlertSeen before anything = %v %v %v, want false false nil", firing, known, err)
}
- if err := s.SetAlertSeen("box-a", "disk", true); err != nil {
+ if err := s.SetAlertSeen("box-a", "disk", amy, true); err != nil {
t.Fatal(err)
}
- if firing, known, err := s.AlertSeen("box-a", "disk"); err != nil || !known || !firing {
+ if firing, known, err := s.AlertSeen("box-a", "disk", amy); err != nil || !known || !firing {
t.Fatalf("AlertSeen after fire = %v %v %v, want true true nil", firing, known, err)
}
- // Same (box, rule) upserts rather than duplicating.
- if err := s.SetAlertSeen("box-a", "disk", false); err != nil {
+ // Same (box, rule, address) upserts rather than duplicating.
+ if err := s.SetAlertSeen("box-a", "disk", amy, false); err != nil {
t.Fatal(err)
}
- if firing, known, err := s.AlertSeen("box-a", "disk"); err != nil || !known || firing {
+ if firing, known, err := s.AlertSeen("box-a", "disk", amy); err != nil || !known || firing {
t.Fatalf("AlertSeen after clear = %v %v %v, want false true nil", firing, known, err)
}
- // Rules and boxes are independent keys.
- if _, known, _ := s.AlertSeen("box-a", "mem"); known {
+ // Rules, boxes and addresses are all independent parts of the key.
+ if _, known, _ := s.AlertSeen("box-a", "mem", amy); known {
t.Fatal("AlertSeen(box-a, mem) is known — the rule key is not being used")
}
- if _, known, _ := s.AlertSeen("box-b", "disk"); known {
+ if _, known, _ := s.AlertSeen("box-b", "disk", amy); known {
t.Fatal("AlertSeen(box-b, disk) is known — the box key is not being used")
}
+ if _, known, _ := s.AlertSeen("box-a", "disk", zoe); known {
+ t.Fatal("AlertSeen(box-a, disk, zoe) is known — amy's row is answering for zoe")
+ }
+ // The synthetic "nobody was mailable" recipient is its own row.
+ if err := s.SetAlertSeen("box-a", "disk", "", true); err != nil {
+ t.Fatal(err)
+ }
+ if firing, known, err := s.AlertSeen("box-a", "disk", ""); err != nil || !known || !firing {
+ t.Fatalf("AlertSeen for the empty address = %v %v %v, want true true nil", firing, known, err)
+ }
+ if firing, _, _ := s.AlertSeen("box-a", "disk", amy); firing {
+ t.Fatal("the synthetic row overwrote amy's")
+ }
}
func TestCreateAppClaimOnce(t *testing.T) {