@celilo/cli 0.14.4 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +19 -2
  3. package/drizzle/0018_drop_alert_policy_snapshot.sql +46 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +3 -3
  6. package/src/cli/commands/alerts-list.ts +10 -0
  7. package/src/cli/commands/alerts-poll.ts +12 -6
  8. package/src/cli/commands/alerts-sweep.ts +22 -81
  9. package/src/cli/commands/backup-sweep.ts +65 -0
  10. package/src/cli/commands/module-config.test.ts +77 -1
  11. package/src/cli/commands/module-config.ts +45 -3
  12. package/src/cli/commands/module-journal.test.ts +47 -0
  13. package/src/cli/commands/module-journal.ts +98 -0
  14. package/src/cli/commands/module-operations.test.ts +93 -0
  15. package/src/cli/commands/module-operations.ts +134 -0
  16. package/src/cli/commands/module-upgrade.test.ts +32 -20
  17. package/src/cli/commands/module-upgrade.ts +37 -32
  18. package/src/cli/commands/monitor.ts +26 -6
  19. package/src/cli/commands/system-audit.ts +3 -30
  20. package/src/cli/completion.ts +20 -1
  21. package/src/cli/generate-zsh-completion.ts +4 -0
  22. package/src/cli/index.ts +14 -0
  23. package/src/db/schema.ts +5 -3
  24. package/src/manifest/schema.ts +4 -1
  25. package/src/module/packaging/build.ts +4 -0
  26. package/src/services/alerting/builtin-source.ts +17 -2
  27. package/src/services/alerting/delivery-loop.test.ts +5 -1
  28. package/src/services/alerting/format.test.ts +0 -1
  29. package/src/services/alerting/inbound-poller.test.ts +235 -8
  30. package/src/services/alerting/inbound-poller.ts +95 -34
  31. package/src/services/alerting/inbound.test.ts +213 -2
  32. package/src/services/alerting/inbound.ts +161 -32
  33. package/src/services/alerting/interview-responder.test.ts +0 -32
  34. package/src/services/alerting/interview-responder.ts +6 -17
  35. package/src/services/alerting/notify-deps.ts +113 -0
  36. package/src/services/alerting/run-monitor.ts +0 -1
  37. package/src/services/alerting/store.test.ts +1 -1
  38. package/src/services/alerting/store.ts +0 -2
  39. package/src/services/alerting/sweep-runner.test.ts +11 -2
  40. package/src/services/alerting/sweep-runner.ts +14 -7
  41. package/src/services/alerting/tokens.ts +39 -1
  42. package/src/services/audit/backup-source.ts +54 -0
  43. package/src/services/audit/backups.test.ts +7 -2
  44. package/src/services/audit/backups.ts +10 -18
  45. package/src/services/backup-cipher.test.ts +188 -0
  46. package/src/services/backup-cipher.ts +178 -0
  47. package/src/services/backup-create.ts +20 -30
  48. package/src/services/backup-envelope-roundtrip.test.ts +6 -26
  49. package/src/services/backup-restore.ts +10 -16
  50. package/src/services/backup-schedule.ts +35 -0
  51. package/src/services/backup-sweep.test.ts +148 -0
  52. package/src/services/backup-sweep.ts +124 -0
  53. package/src/services/deploy-posture.ts +15 -2
  54. package/src/services/module-journal.test.ts +302 -0
  55. package/src/services/module-journal.ts +160 -0
  56. package/src/services/module-operations.test.ts +67 -6
  57. package/src/services/module-operations.ts +69 -19
  58. package/src/services/module-subscriptions.test.ts +33 -2
  59. package/src/services/module-subscriptions.ts +10 -1
  60. package/src/services/module-validator/typescript-build.test.ts +20 -1
  61. package/src/services/module-validator/typescript-build.ts +9 -5
  62. package/src/services/restore-from-file.ts +6 -21
  63. package/src/templates/generator.test.ts +88 -0
  64. package/src/templates/generator.ts +119 -16
@@ -20,17 +20,48 @@ import { loadCapabilityFunctions } from '../../hooks/capability-loader';
20
20
  import { createCapturingLogger } from '../../hooks/logger';
21
21
  import { type AckResult, acknowledgeAlert } from './ack';
22
22
  import { interpretInbound } from './inbound';
23
- import { parseInterviewAnswer } from './interview-responder';
24
23
  import type { NotificationTransport } from './notifier';
25
24
  import { composeAckBroadcastBody } from './notifier';
26
25
  import { consumeDelivery } from './tokens';
27
26
 
27
+ /**
28
+ * What one attempt to read a transport produced.
29
+ *
30
+ * `unidirectional` and `failed` used to collapse into a bare null, and that
31
+ * cost a week: a transport whose read call was being REFUSED reported the same
32
+ * "0 message(s)" as a transport nobody had replied on. An operator could not
33
+ * tell "my reply never arrived" from "celilo cannot read this transport at
34
+ * all", and neither could anyone debugging it.
35
+ */
36
+ export type TransportReceive =
37
+ | { status: 'received'; messages: InboundMessage[]; cursor: string | null }
38
+ | { status: 'unidirectional' }
39
+ | { status: 'failed'; error: string };
40
+
41
+ /** A transport that could not be read, and why. */
42
+ export interface TransportFailure {
43
+ transportModuleId: string;
44
+ error: string;
45
+ }
46
+
47
+ /** A message that was read but not acted on, and why. */
48
+ export interface UnheardMessage {
49
+ senderAddress: string;
50
+ reason:
51
+ | 'unknown_sender'
52
+ | 'unknown_token'
53
+ | 'wrong_sender'
54
+ | 'ambiguous'
55
+ | 'unrecognised'
56
+ /** A deploy question was named, but with no value to answer it with. */
57
+ | 'needs_value'
58
+ /** The token was valid, but the alert it names no longer exists. */
59
+ | 'stale_target';
60
+ }
61
+
28
62
  export interface InboundPollDeps {
29
- /** Receive from one transport module, or null when it cannot be reached. */
30
- receiveFrom(
31
- transportModuleId: string,
32
- cursor: string | null,
33
- ): Promise<{ messages: InboundMessage[]; cursor: string | null } | null>;
63
+ /** Receive from one transport module. */
64
+ receiveFrom(transportModuleId: string, cursor: string | null): Promise<TransportReceive>;
34
65
  /** Persisted receive cursor per transport. */
35
66
  readCursor(transportModuleId: string): string | null;
36
67
  writeCursor(transportModuleId: string, cursor: string | null): void;
@@ -53,12 +84,14 @@ export interface InboundPollReport {
53
84
  transportsPolled: number;
54
85
  messagesRead: number;
55
86
  acked: number;
56
- ignored: number;
57
- rejected: number;
58
87
  /** Routes told that someone else took the alert. */
59
88
  broadcast: number;
60
89
  /** Deploy questions answered from a phone. */
61
90
  answered: number;
91
+ /** Transports that could not be read at all. Never silent — see above. */
92
+ failures: TransportFailure[];
93
+ /** Messages read and then discarded, with the reason each was discarded. */
94
+ unheard: UnheardMessage[];
62
95
  }
63
96
 
64
97
  /** Transports that have at least one route pointing at them. */
@@ -89,19 +122,24 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
89
122
  transportsPolled: 0,
90
123
  messagesRead: 0,
91
124
  acked: 0,
92
- ignored: 0,
93
- rejected: 0,
94
125
  broadcast: 0,
95
126
  answered: 0,
127
+ failures: [],
128
+ unheard: [],
96
129
  };
97
130
 
98
131
  for (const transportId of transportsWithRoutes(db)) {
99
132
  const received = await deps.receiveFrom(transportId, deps.readCursor(transportId));
100
133
  report.transportsPolled++;
101
- // A transport that cannot be reached is not an error here its own
102
- // health check is what reports that, and one dead transport must not stop
103
- // the others from being read.
104
- if (!received) continue;
134
+ // One dead transport must not stop the others being read but it is
135
+ // RECORDED rather than skipped in silence, because "cannot read" and
136
+ // "nothing to read" are the two things an operator most needs to tell
137
+ // apart at 3am.
138
+ if (received.status === 'failed') {
139
+ report.failures.push({ transportModuleId: transportId, error: received.error });
140
+ continue;
141
+ }
142
+ if (received.status === 'unidirectional') continue;
105
143
 
106
144
  for (const message of received.messages) {
107
145
  report.messagesRead++;
@@ -113,25 +151,26 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
113
151
  outstandingForRoute: (routeId) => outstandingAlertDeliveries(db, routeId, deps.now()),
114
152
  });
115
153
 
116
- if (outcome.action === 'ignored') {
117
- report.ignored++;
118
- continue;
119
- }
120
- if (outcome.action === 'rejected') {
121
- report.rejected++;
154
+ if (outcome.action === 'ignored' || outcome.action === 'rejected') {
155
+ report.unheard.push({ senderAddress: message.senderAddress, reason: outcome.reason });
122
156
  continue;
123
157
  }
124
158
 
125
159
  // An interview reply IS the answer rather than an acknowledgement —
126
160
  // same token table, same sender check, different meaning for the body.
127
- if (outcome.delivery.kind === 'interview') {
128
- const value = parseInterviewAnswer(message.body, outcome.delivery.token);
129
- if (!value || !deps.answerInterview) {
130
- report.rejected++;
161
+ // The value was extracted by `interpretInbound`, which is the only place
162
+ // that knows the delivery's kind; re-parsing it here is what left the
163
+ // real grammar unreachable for months (#533).
164
+ if (outcome.action === 'answer') {
165
+ if (!deps.answerInterview) {
166
+ // Nothing is attached to publish the answer against, so the question
167
+ // is still unanswered. Say so rather than consuming the token: the
168
+ // operator's next attempt has to be able to work.
169
+ report.unheard.push({ senderAddress: message.senderAddress, reason: 'unrecognised' });
131
170
  continue;
132
171
  }
133
172
  consumeDelivery(db, outcome.delivery.id, deps.now());
134
- deps.answerInterview(outcome.delivery.targetId, value);
173
+ deps.answerInterview(outcome.delivery.targetId, outcome.value);
135
174
  report.answered++;
136
175
  continue;
137
176
  }
@@ -145,12 +184,23 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
145
184
  outcome.route.personId,
146
185
  deps.now(),
147
186
  );
187
+
188
+ // A live token can outlive the alert it names. When the row is gone
189
+ // there is nothing to acknowledge, and saying "1 acked" anyway is a lie
190
+ // the operator has no way to catch — observed live: a reply reported
191
+ // `1 acked` while every alert still read `ackedBy: null`. Count what
192
+ // HAPPENED, never what was attempted.
193
+ if (!ack) {
194
+ report.unheard.push({ senderAddress: message.senderAddress, reason: 'stale_target' });
195
+ continue;
196
+ }
197
+
148
198
  report.acked++;
149
199
 
150
200
  // Everyone else who was paged is still expecting to act. Telling them is
151
201
  // the entire point of a per-delivery token — it is what makes the reply
152
202
  // identify a PERSON rather than just an alert.
153
- if (ack) report.broadcast += await broadcastAck(db, ack, deps);
203
+ report.broadcast += await broadcastAck(db, ack, deps);
154
204
  }
155
205
 
156
206
  deps.writeCursor(transportId, received.cursor);
@@ -213,12 +263,23 @@ function outstandingAlertDeliveries(db: DbClient, routeId: string, now: Date) {
213
263
  *
214
264
  * A transport with no `receive` is unidirectional — that is not an error, it
215
265
  * just means replies cannot arrive, which the route's `can_ack` already
216
- * records.
266
+ * records. Anything else going wrong IS an error and is returned as one.
267
+ *
268
+ * This used to be a bare `catch {}` returning null, on the reasoning that the
269
+ * transport's own health check would report an unreachable daemon. That was
270
+ * wrong twice over: a health check that only proves the daemon answers cannot
271
+ * see a read call being REFUSED by a daemon that is otherwise perfectly
272
+ * healthy, and the swallowed error was the only place the reason existed. The
273
+ * live signal-cli case was exactly that shape — `receive` refused with
274
+ * "Receive command cannot be used if messages are already being received."
275
+ * while every health check passed and every poll reported zero messages.
217
276
  */
218
277
  export function makeReceiver(db: DbClient) {
219
- return async (transportModuleId: string, cursor: string | null) => {
278
+ return async (transportModuleId: string, cursor: string | null): Promise<TransportReceive> => {
220
279
  const module = db.select().from(modules).where(eq(modules.id, transportModuleId)).get();
221
- if (!module) return null;
280
+ if (!module) {
281
+ return { status: 'failed', error: `no module '${transportModuleId}' is installed` };
282
+ }
222
283
 
223
284
  try {
224
285
  const { logger } = createCapturingLogger();
@@ -226,11 +287,11 @@ export function makeReceiver(db: DbClient) {
226
287
  const notification = (capabilities as Record<string, unknown>).notification as
227
288
  | NotificationCapability
228
289
  | undefined;
229
- if (!notification?.receive) return null;
230
- return await notification.receive(cursor);
231
- } catch {
232
- // Unreachable transport: the transport's own health check reports it.
233
- return null;
290
+ if (!notification?.receive) return { status: 'unidirectional' };
291
+ const result = await notification.receive(cursor);
292
+ return { status: 'received', messages: result.messages, cursor: result.cursor };
293
+ } catch (error) {
294
+ return { status: 'failed', error: error instanceof Error ? error.message : String(error) };
234
295
  }
235
296
  };
236
297
  }
@@ -33,8 +33,37 @@ describe('parseInbound', () => {
33
33
  expect(parseInbound('K7QM2X ack')).toEqual({ kind: 'ack', token: 'K7QM2X' });
34
34
  });
35
35
 
36
- test.each(['', ' ', 'what is going on', 'K7QM', 'K7QM2XY'])('%p is unrecognised', (body) => {
37
- expect(parseInbound(body)).toEqual({ kind: 'unrecognised' });
36
+ test.each(['', ' ', 'what is going on', 'K7QM2XY', 'ack now please'])(
37
+ '%p is unrecognised',
38
+ (body) => {
39
+ expect(parseInbound(body)).toEqual({ kind: 'unrecognised' });
40
+ },
41
+ );
42
+
43
+ // The bug that started this: the page says "reply BPRJEH", and a human types
44
+ // "ack BPRJEH". The old grammar read the VERB as the token, failed the length
45
+ // check, and rejected a real acknowledgement over word order.
46
+ test.each([
47
+ ['ack K7QM2X', 'K7QM2X'],
48
+ ['ACK K7QM2X', 'K7QM2X'],
49
+ ['ok k7qm2x', 'K7QM2X'],
50
+ ['K7QM2X ack', 'K7QM2X'],
51
+ ['k7qm2x OK', 'K7QM2X'],
52
+ ['👍 K7QM2X', 'K7QM2X'],
53
+ [' ack k7qm2x ', 'K7QM2X'],
54
+ ])('%p yields token %p — the verb may lead, trail, or be absent', (body, token) => {
55
+ expect(parseInbound(body)).toEqual({ kind: 'ack', token });
56
+ });
57
+
58
+ // A prefix is legal INPUT; whether it identifies anything is the delivery
59
+ // table's question, not the parser's.
60
+ test.each([
61
+ ['bp', 'BP'],
62
+ ['ack bp', 'BP'],
63
+ ['BP ack', 'BP'],
64
+ ['b', 'B'],
65
+ ])('%p parses as the prefix %p', (body, token) => {
66
+ expect(parseInbound(body)).toEqual({ kind: 'ack', token });
38
67
  });
39
68
  });
40
69
 
@@ -128,6 +157,69 @@ describe('interpretInbound', () => {
128
157
  expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' });
129
158
  });
130
159
 
160
+ describe('an abbreviated token', () => {
161
+ // How much an operator must type depends on what is actually outstanding.
162
+ test('resolves when the prefix is unique among live deliveries', () => {
163
+ const delivery = mintFor('r-peter');
164
+ const prefix = delivery.token.slice(0, 2);
165
+ const outcome = interpretInbound(db, context(PETER, `ack ${prefix.toLowerCase()}`));
166
+ expect(outcome).toMatchObject({ action: 'ack' });
167
+ expect(outcome.action === 'ack' && outcome.delivery.id).toBe(delivery.id);
168
+ });
169
+
170
+ test('a single character is enough when only one alert is live', () => {
171
+ const delivery = mintFor('r-peter');
172
+ // One character in 32 is `K`, which is ALSO an ack synonym — so that
173
+ // token parses as a bare ack rather than as a prefix. Either reading
174
+ // must land on the single live delivery, so the harness reflects it as
175
+ // outstanding as well; without this the test failed ~3% of runs with
176
+ // `unknown_token`, because the bare-ack path saw nothing outstanding.
177
+ outstanding.push(delivery);
178
+ const outcome = interpretInbound(db, context(PETER, delivery.token.slice(0, 1)));
179
+ expect(outcome).toMatchObject({ action: 'ack' });
180
+ });
181
+
182
+ // Never a guess: acknowledging the wrong alert is worse than asking again.
183
+ test('is refused as ambiguous when two live deliveries share the prefix', () => {
184
+ const a = mintFor('r-peter', 'alert-a');
185
+ let b = mintFor('r-peter', 'alert-b');
186
+ // Force a shared prefix rather than hoping two random tokens collide.
187
+ for (let i = 0; i < 40 && b.token[0] !== a.token[0]; i++) {
188
+ b = mintFor('r-peter', `alert-b-${i}`);
189
+ }
190
+ if (b.token[0] !== a.token[0]) return; // vanishingly unlikely; do not flake
191
+ // As above: a shared first character of `K` parses as a bare ack, and
192
+ // that path reads `outstandingForRoute` rather than the prefix search.
193
+ // Both readings must answer `ambiguous` here, so both are given two
194
+ // candidates to be ambiguous between.
195
+ outstanding.push(a, b);
196
+ expect(interpretInbound(db, context(PETER, a.token[0]))).toEqual({
197
+ action: 'rejected',
198
+ reason: 'ambiguous',
199
+ });
200
+ });
201
+
202
+ // Uniqueness is scoped to what is LIVE, so yesterday's tokens cannot make
203
+ // today's prefix ambiguous.
204
+ test('an expired delivery does not make a prefix ambiguous', () => {
205
+ const delivery = mintFor('r-peter');
206
+ const late = {
207
+ ...context(PETER, delivery.token.slice(0, 2)),
208
+ now: new Date(NOW.getTime() + TTL_MS + 1),
209
+ };
210
+ expect(interpretInbound(db, late)).toEqual({ action: 'rejected', reason: 'unknown_token' });
211
+ });
212
+
213
+ // The second factor still holds for a prefix.
214
+ test('a prefix from the WRONG sender is still refused', () => {
215
+ const delivery = mintFor('r-peter');
216
+ expect(interpretInbound(db, context(WIFE, delivery.token.slice(0, 3)))).toEqual({
217
+ action: 'rejected',
218
+ reason: 'wrong_sender',
219
+ });
220
+ });
221
+ });
222
+
131
223
  test('gibberish from a known sender is rejected as unrecognised', () => {
132
224
  expect(interpretInbound(db, context(PETER, 'what is going on'))).toEqual({
133
225
  action: 'rejected',
@@ -168,6 +260,125 @@ describe('interpretInbound', () => {
168
260
  });
169
261
  });
170
262
  });
263
+
264
+ /**
265
+ * Answering a deploy question.
266
+ *
267
+ * These ran against `parseInterviewAnswer` in isolation and all passed while
268
+ * the feature was dead in production (#533) — the alert grammar rejected
269
+ * every one of these bodies before the answer path was ever reached. They
270
+ * live here now, against `interpretInbound`, because the wiring is the part
271
+ * that was broken and the part worth guarding.
272
+ */
273
+ describe('a deploy question', () => {
274
+ const askFor = (routeId: string, targetId = 'evt-1') =>
275
+ mintDelivery(db, { kind: 'interview', targetId, routeId, now: NOW, ttlMs: TTL_MS });
276
+
277
+ test('takes everything after the token as the answer', () => {
278
+ const q = askFor('r-peter');
279
+ expect(interpretInbound(db, context(PETER, `${q.token} www.example.com`))).toMatchObject({
280
+ action: 'answer',
281
+ value: 'www.example.com',
282
+ });
283
+ });
284
+
285
+ test('is case-insensitive on the token', () => {
286
+ const q = askFor('r-peter');
287
+ expect(
288
+ interpretInbound(db, context(PETER, `${q.token.toLowerCase()} www.example.com`)),
289
+ ).toMatchObject({ action: 'answer', value: 'www.example.com' });
290
+ });
291
+
292
+ // An answer may legitimately contain spaces and punctuation. Second-guessing
293
+ // it would corrupt exactly the values that are painful to retype. This is
294
+ // the shape that was refused as `unrecognised` for months.
295
+ test('preserves an answer containing spaces and punctuation verbatim', () => {
296
+ const q = askFor('r-peter');
297
+ expect(
298
+ interpretInbound(db, context(PETER, `${q.token} my value: with, punctuation`)),
299
+ ).toMatchObject({ action: 'answer', value: 'my value: with, punctuation' });
300
+ });
301
+
302
+ // The word that must NOT acknowledge an alert is a perfectly good answer to
303
+ // a question. That the same text means different things is the whole reason
304
+ // the token is resolved before the grammar is chosen.
305
+ test('an answer that looks like a terminal verb is still an answer', () => {
306
+ const q = askFor('r-peter');
307
+ expect(interpretInbound(db, context(PETER, `${q.token} resolve`))).toMatchObject({
308
+ action: 'answer',
309
+ value: 'resolve',
310
+ });
311
+ });
312
+
313
+ test('a token with no value says so, rather than being unrecognised', () => {
314
+ const q = askFor('r-peter');
315
+ for (const body of [q.token, `${q.token} `]) {
316
+ expect(interpretInbound(db, context(PETER, body))).toEqual({
317
+ action: 'rejected',
318
+ reason: 'needs_value',
319
+ });
320
+ }
321
+ });
322
+
323
+ // A verb-led message names no value, so it is not an answer.
324
+ test('a leading ack verb is not an answer', () => {
325
+ const q = askFor('r-peter');
326
+ expect(interpretInbound(db, context(PETER, `ack ${q.token}`))).toEqual({
327
+ action: 'rejected',
328
+ reason: 'unrecognised',
329
+ });
330
+ });
331
+
332
+ // Both factors still apply — a question is no less sensitive than a page.
333
+ test('an answer from the wrong number is refused', () => {
334
+ const q = askFor('r-peter');
335
+ expect(interpretInbound(db, context(WIFE, `${q.token} www.example.com`))).toEqual({
336
+ action: 'rejected',
337
+ reason: 'wrong_sender',
338
+ });
339
+ });
340
+
341
+ // Reported as `unrecognised` rather than `unknown_token`, and that is
342
+ // deliberate: `ZZZZZZ value` and `what is going on` are the same shape to
343
+ // celilo — a token-shaped word followed by text — because the token
344
+ // alphabet is most of the Latin one. Nothing corroborates that the leading
345
+ // word was meant as a token, so claiming the operator got a TOKEN wrong
346
+ // would send them hunting for a typo they may not have made.
347
+ test('an unknown leading token is not answerable', () => {
348
+ askFor('r-peter');
349
+ expect(interpretInbound(db, context(PETER, 'ZZZZZZ value'))).toEqual({
350
+ action: 'rejected',
351
+ reason: 'unrecognised',
352
+ });
353
+ });
354
+ });
355
+
356
+ /**
357
+ * The two grammars share a table, so the same body must mean different
358
+ * things depending on what the token names. Asserting both readings side by
359
+ * side is the guard against one of them quietly swallowing the other again.
360
+ */
361
+ describe('the two grammars do not bleed into each other', () => {
362
+ test('`<token> resolve` answers a question and REFUSES an alert', () => {
363
+ const question = mintDelivery(db, {
364
+ kind: 'interview',
365
+ targetId: 'evt-1',
366
+ routeId: 'r-peter',
367
+ now: NOW,
368
+ ttlMs: TTL_MS,
369
+ });
370
+ const alert = mintFor('r-peter');
371
+
372
+ expect(interpretInbound(db, context(PETER, `${question.token} resolve`))).toMatchObject({
373
+ action: 'answer',
374
+ value: 'resolve',
375
+ });
376
+ expect(interpretInbound(db, context(PETER, `${alert.token} resolve`))).toEqual({
377
+ action: 'rejected',
378
+ reason: 'unrecognised',
379
+ });
380
+ });
381
+ });
171
382
  });
172
383
 
173
384
  /**