@celilo/cli 0.15.0 → 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.
@@ -20,7 +20,6 @@ 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';
@@ -48,7 +47,16 @@ export interface TransportFailure {
48
47
  /** A message that was read but not acted on, and why. */
49
48
  export interface UnheardMessage {
50
49
  senderAddress: string;
51
- reason: 'unknown_sender' | 'unknown_token' | 'wrong_sender' | 'ambiguous' | 'unrecognised';
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';
52
60
  }
53
61
 
54
62
  export interface InboundPollDeps {
@@ -150,14 +158,19 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
150
158
 
151
159
  // An interview reply IS the answer rather than an acknowledgement —
152
160
  // same token table, same sender check, different meaning for the body.
153
- if (outcome.delivery.kind === 'interview') {
154
- const value = parseInterviewAnswer(message.body, outcome.delivery.token);
155
- if (!value || !deps.answerInterview) {
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.
156
169
  report.unheard.push({ senderAddress: message.senderAddress, reason: 'unrecognised' });
157
170
  continue;
158
171
  }
159
172
  consumeDelivery(db, outcome.delivery.id, deps.now());
160
- deps.answerInterview(outcome.delivery.targetId, value);
173
+ deps.answerInterview(outcome.delivery.targetId, outcome.value);
161
174
  report.answered++;
162
175
  continue;
163
176
  }
@@ -171,12 +184,23 @@ export async function pollInbound(db: DbClient, deps: InboundPollDeps): Promise<
171
184
  outcome.route.personId,
172
185
  deps.now(),
173
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
+
174
198
  report.acked++;
175
199
 
176
200
  // Everyone else who was paged is still expecting to act. Telling them is
177
201
  // the entire point of a per-delivery token — it is what makes the reply
178
202
  // identify a PERSON rather than just an alert.
179
- if (ack) report.broadcast += await broadcastAck(db, ack, deps);
203
+ report.broadcast += await broadcastAck(db, ack, deps);
180
204
  }
181
205
 
182
206
  deps.writeCursor(transportId, received.cursor);
@@ -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
  /**
@@ -12,20 +12,52 @@
12
12
  * A message from an address matching no route is discarded and logged WITHOUT
13
13
  * a reply. Replying would confirm the number is live and that this is a celilo
14
14
  * instance, which is free reconnaissance for anyone probing.
15
+ *
16
+ * ── ORDER MATTERS: RESOLVE THE TOKEN, THEN PICK THE GRAMMAR ────────────────
17
+ *
18
+ * The same table carries alert pages and deploy questions, and they want
19
+ * OPPOSITE parsing. An alert reply is a token and at most a verb; anything
20
+ * wordier is refused, because `<token> resolve` silently acknowledging an
21
+ * alert the operator meant to escalate is the worst outcome here. An interview
22
+ * answer is a token followed by arbitrary text, because the text IS the answer.
23
+ *
24
+ * Parsing before resolving cannot serve both, and #533 is what that cost: the
25
+ * alert grammar ran first, read every real answer as a sentence, and rejected
26
+ * it before the poller could ever see it was a question. The feature was dead
27
+ * for months and the operator was told `unrecognised`, which reads as their
28
+ * typing being wrong.
29
+ *
30
+ * A delivery knows its own kind, so the token is resolved FIRST and the
31
+ * grammar chosen from what it named. Which rule applies was never a property
32
+ * of the text.
15
33
  */
16
34
 
17
35
  import { eq } from 'drizzle-orm';
18
36
  import type { DbClient } from '../../db/client';
19
37
  import { type NotificationDelivery, type Route, routes } from '../../db/schema';
20
- import { findLiveDelivery, normaliseToken } from './tokens';
38
+ import { findLiveDeliveriesByPrefix, normaliseToken } from './tokens';
21
39
 
22
40
  /**
23
- * v1 grammar: a bare token acknowledges. `snooze` and `resolve` verbs are
24
- * deferred — ack is what an operator does at 3am, and every extra verb is
25
- * another thing to mistype under stress.
41
+ * v1 grammar: a token acknowledges. `snooze` and `resolve` verbs are deferred —
42
+ * ack is what an operator does at 3am, and every extra verb is another thing to
43
+ * mistype under stress.
26
44
  *
27
- * A bare `ack` with no token is accepted when the sender has exactly one
28
- * outstanding delivery, since the token adds nothing when there is no ambiguity.
45
+ * Deliberately forgiving about everything EXCEPT what the message means. An
46
+ * earlier cut accepted `<token> ack` but not `ack <token>`, so an operator who
47
+ * read "reply BPRJEH" and typed the natural "ack BPRJEH" was refused — the verb
48
+ * was parsed as the token, failed the length check, and the reply was rejected.
49
+ * That is a real page going unacknowledged over word order, so:
50
+ *
51
+ * - the ack verb may come BEFORE the token, AFTER it, or not at all
52
+ * - case never matters, for the verb or the token
53
+ * - the token may be shortened to any unambiguous prefix (see below)
54
+ *
55
+ * `token` here is a CANDIDATE, not necessarily a whole token: resolution
56
+ * against what is actually outstanding happens in `interpretInbound`, because
57
+ * only the database knows which prefixes are unique right now.
58
+ *
59
+ * A bare ack with no token at all is accepted when the sender has exactly one
60
+ * outstanding delivery, since a token adds nothing when there is no ambiguity.
29
61
  */
30
62
  export type InboundIntent =
31
63
  | { kind: 'ack'; token: string }
@@ -33,37 +65,87 @@ export type InboundIntent =
33
65
  | { kind: 'unrecognised' };
34
66
 
35
67
  const ACK_VERB = /^(ack|ok|k|👍)$/iu;
36
- const TOKEN_SHAPE = /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{6}$/;
68
+ /**
69
+ * One to six characters of the token alphabet. Not anchored to the full
70
+ * length: a prefix is legal input, and whether it identifies something is a
71
+ * question for the delivery table, not for a regex.
72
+ */
73
+ const TOKEN_SHAPE = /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{1,6}$/;
37
74
 
38
75
  export function parseInbound(body: string): InboundIntent {
39
- const trimmed = body.trim();
40
- if (!trimmed) return { kind: 'unrecognised' };
76
+ const words = body.trim().split(/\s+/).filter(Boolean);
77
+ if (words.length === 0) return { kind: 'unrecognised' };
78
+
79
+ // Strip ack synonyms wherever they appear. What remains must be the token,
80
+ // or nothing at all.
81
+ const remainder = words.filter((word) => !ACK_VERB.test(word));
82
+ if (remainder.length === 0) return { kind: 'bare_ack' };
83
+
84
+ // More than one non-verb word is not a token with politeness around it, it
85
+ // is a sentence — and a sentence may be a request celilo does not implement.
86
+ // Guessing at it risks acknowledging an alert the operator meant to escalate:
87
+ // an earlier cut let `<token> resolve` silently ACK, so the operator believed
88
+ // they had cleared an alert that was still firing. Doing nothing and saying
89
+ // so is strictly better than doing the wrong thing quietly.
90
+ if (remainder.length > 1) return { kind: 'unrecognised' };
91
+
92
+ const token = normaliseToken(remainder[0]);
93
+ if (!TOKEN_SHAPE.test(token)) return { kind: 'unrecognised' };
41
94
 
42
- if (ACK_VERB.test(trimmed)) return { kind: 'bare_ack' };
95
+ return { kind: 'ack', token };
96
+ }
43
97
 
44
- const [first, ...rest] = trimmed.split(/\s+/);
45
- const token = normaliseToken(first);
46
- if (!TOKEN_SHAPE.test(token)) return { kind: 'unrecognised' };
98
+ /** A message that leads with a token, and whatever text followed it. */
99
+ export interface TokenLedMessage {
100
+ token: string;
101
+ /** Everything after the token, trimmed. Empty when the token stood alone. */
102
+ rest: string;
103
+ }
47
104
 
48
- // A verb after the token must be an ack synonym or nothing. An earlier cut
49
- // ignored the verb entirely, which meant `<token> resolve` silently
50
- // ACKNOWLEDGED — the operator believes they cleared the alert and it is
51
- // still firing. Doing nothing and saying so is strictly better than doing
52
- // the wrong thing quietly.
53
- //
54
- // `resolve` and `silence` are terminal operations, not reply verbs (design
55
- // D10): every extra verb is another thing to mistype at 3am.
56
- if (rest.length > 0 && !ACK_VERB.test(rest.join(' '))) {
57
- return { kind: 'unrecognised' };
58
- }
105
+ /**
106
+ * Split a message that LEADS with a token, or null if it does not.
107
+ *
108
+ * This is the interview grammar and it is deliberately not the alert one: the
109
+ * page says `reply <TOKEN> <value>`, and the value is whatever follows,
110
+ * verbatim. It may contain spaces, punctuation, or a word that happens to look
111
+ * like a verb second-guessing it would corrupt exactly the values an operator
112
+ * cannot easily retype.
113
+ *
114
+ * `parseInbound` cannot serve both. Its "more than one non-verb word is a
115
+ * sentence" rule is what protects an ALERT from `<token> resolve`, and it is
116
+ * also what made every real interview answer unrecognised (#533): a value is
117
+ * a sentence by construction. Which rule applies is not a property of the text
118
+ * — it is a property of what the token names, and only the delivery table
119
+ * knows that. So both readings are produced here and `interpretInbound`
120
+ * chooses between them AFTER resolving the token.
121
+ */
122
+ export function parseTokenLed(body: string): TokenLedMessage | null {
123
+ const trimmed = body.trim();
124
+ if (!trimmed) return null;
59
125
 
60
- return { kind: 'ack', token };
126
+ const boundary = trimmed.search(/\s/);
127
+ const head = boundary === -1 ? trimmed : trimmed.slice(0, boundary);
128
+ const token = normaliseToken(head);
129
+ if (!TOKEN_SHAPE.test(token)) return null;
130
+
131
+ return { token, rest: boundary === -1 ? '' : trimmed.slice(boundary).trim() };
61
132
  }
62
133
 
63
134
  export type InboundOutcome =
64
135
  | { action: 'ack'; delivery: NotificationDelivery; route: Route }
136
+ /** An answer to a deploy question. `value` is the operator's text, verbatim. */
137
+ | { action: 'answer'; delivery: NotificationDelivery; route: Route; value: string }
65
138
  | { action: 'ignored'; reason: 'unknown_sender' }
66
- | { action: 'rejected'; reason: 'unknown_token' | 'wrong_sender' | 'ambiguous' | 'unrecognised' };
139
+ | {
140
+ action: 'rejected';
141
+ reason:
142
+ | 'unknown_token'
143
+ | 'wrong_sender'
144
+ | 'ambiguous'
145
+ | 'unrecognised'
146
+ /** A question was named, but no value was supplied to answer it with. */
147
+ | 'needs_value';
148
+ };
67
149
 
68
150
  function routeForAddress(db: DbClient, senderAddress: string): Route | undefined {
69
151
  return db.select().from(routes).where(eq(routes.address, senderAddress)).get();
@@ -91,10 +173,9 @@ export function interpretInbound(db: DbClient, context: InboundContext): Inbound
91
173
 
92
174
  const intent = parseInbound(context.body);
93
175
 
94
- if (intent.kind === 'unrecognised') {
95
- return { action: 'rejected', reason: 'unrecognised' };
96
- }
97
-
176
+ // A bare ack names no token, so there is nothing to resolve and nothing a
177
+ // question could be answered with. It is an ALERT reply by construction —
178
+ // `outstandingForRoute` supplies alert deliveries only.
98
179
  if (intent.kind === 'bare_ack') {
99
180
  const outstanding = context.outstandingForRoute(route.id);
100
181
  if (outstanding.length === 0) return { action: 'rejected', reason: 'unknown_token' };
@@ -102,11 +183,59 @@ export function interpretInbound(db: DbClient, context: InboundContext): Inbound
102
183
  return { action: 'ack', delivery: outstanding[0], route };
103
184
  }
104
185
 
105
- const delivery = findLiveDelivery(db, intent.token, context.now);
106
- if (!delivery) return { action: 'rejected', reason: 'unknown_token' };
186
+ // Both readings of the text, neither yet privileged. The alert reading is
187
+ // the more constrained one, so it names the token when it applies; otherwise
188
+ // a leading token does.
189
+ const led = parseTokenLed(context.body);
190
+ const namesAToken = intent.kind === 'ack';
191
+ const candidate = namesAToken ? intent.token : led?.token;
192
+
193
+ // Nothing token-shaped anywhere: not a reply celilo can act on.
194
+ if (!candidate) return { action: 'rejected', reason: 'unrecognised' };
195
+
196
+ // A prefix is resolved against what is LIVE right now, so how much of the
197
+ // token an operator must type depends on what is actually outstanding — one
198
+ // alert, one character. Matching is deliberately global rather than scoped to
199
+ // this route: it keeps `wrong_sender` a distinguishable answer below, and a
200
+ // globally-unique prefix is a stricter bar than a per-route one.
201
+ const matches = findLiveDeliveriesByPrefix(db, candidate, context.now);
202
+ if (matches.length === 0) {
203
+ // Ordinary words are token-shaped surprisingly often — the alphabet is
204
+ // most of the Latin one, so `what is going on` leads with a perfectly
205
+ // well-formed `WHAT`. Only call it a token the operator got WRONG when the
206
+ // alert grammar agreed it was one; otherwise this is a sentence, and
207
+ // saying `unknown_token` would send them hunting for a typo they did not
208
+ // make.
209
+ return { action: 'rejected', reason: namesAToken ? 'unknown_token' : 'unrecognised' };
210
+ }
211
+
212
+ // Ambiguous is its own answer, never a guess. Taking the first match would
213
+ // acknowledge an alert the operator did not name.
214
+ if (matches.length > 1) return { action: 'rejected', reason: 'ambiguous' };
215
+
216
+ const delivery = matches[0];
107
217
 
108
218
  // Factor two: a valid token replayed from a different number is refused.
219
+ // Checked before the grammar so a stranger learns nothing about which of the
220
+ // two a token names.
109
221
  if (delivery.routeId !== route.id) return { action: 'rejected', reason: 'wrong_sender' };
110
222
 
223
+ // NOW the kind is known, so the right grammar can be applied to the body.
224
+ if (delivery.kind === 'interview') {
225
+ // The value must follow the token the question was asked with; a verb-led
226
+ // message (`ack <TOKEN>`) names no value and is not an answer.
227
+ if (!led || led.token !== candidate) return { action: 'rejected', reason: 'unrecognised' };
228
+ // Named the question but supplied nothing to answer it with. Distinct from
229
+ // `unrecognised`: the operator got the token right and stopped too soon,
230
+ // and telling them that is the difference between retyping six characters
231
+ // and giving up.
232
+ if (!led.rest) return { action: 'rejected', reason: 'needs_value' };
233
+ return { action: 'answer', delivery, route, value: led.rest };
234
+ }
235
+
236
+ // An alert. The strict grammar applies, which is what keeps `<token> resolve`
237
+ // from silently acknowledging something the operator meant to escalate.
238
+ if (intent.kind !== 'ack') return { action: 'rejected', reason: 'unrecognised' };
239
+
111
240
  return { action: 'ack', delivery, route };
112
241
  }
@@ -4,7 +4,6 @@ import {
4
4
  decideInterviewDelivery,
5
5
  describeQuestion,
6
6
  isRefusedFamily,
7
- parseInterviewAnswer,
8
7
  } from './interview-responder';
9
8
 
10
9
  const headless = { hasTty: false, hasBidirectionalRoute: true };
@@ -98,37 +97,6 @@ describe('composeInterviewBody', () => {
98
97
  });
99
98
  });
100
99
 
101
- describe('parseInterviewAnswer', () => {
102
- test('takes everything after the token as the answer', () => {
103
- expect(parseInterviewAnswer('K7QM2X www.example.com', 'K7QM2X')).toBe('www.example.com');
104
- });
105
-
106
- test('is case-insensitive on the token', () => {
107
- expect(parseInterviewAnswer('k7qm2x www.example.com', 'K7QM2X')).toBe('www.example.com');
108
- });
109
-
110
- // An answer may legitimately contain spaces and punctuation. Second-guessing
111
- // it would corrupt exactly the values that are painful to retype.
112
- test('preserves an answer containing spaces and punctuation verbatim', () => {
113
- expect(parseInterviewAnswer('K7QM2X my value: with, punctuation', 'K7QM2X')).toBe(
114
- 'my value: with, punctuation',
115
- );
116
- });
117
-
118
- test('an answer that looks like a verb is still an answer', () => {
119
- expect(parseInterviewAnswer('K7QM2X resolve', 'K7QM2X')).toBe('resolve');
120
- });
121
-
122
- test('a token with no value is not an answer', () => {
123
- expect(parseInterviewAnswer('K7QM2X', 'K7QM2X')).toBeNull();
124
- expect(parseInterviewAnswer('K7QM2X ', 'K7QM2X')).toBeNull();
125
- });
126
-
127
- test('a different token does not match', () => {
128
- expect(parseInterviewAnswer('ZZZZZZ value', 'K7QM2X')).toBeNull();
129
- });
130
- });
131
-
132
100
  /**
133
101
  * The families disagree on payload shape, and reading the wrong field means an
134
102
  * operator gets the raw event type instead of the question.
@@ -136,23 +136,12 @@ export function composeInterviewBody(input: {
136
136
  return lines.join('\n');
137
137
  }
138
138
 
139
- /**
140
- * Extract the answer from an inbound reply.
141
- *
142
- * Everything after the token is the value, verbatim and unparsed — an answer
143
- * may legitimately contain spaces, punctuation, or something that looks like a
144
- * verb, and second-guessing it would corrupt exactly the values an operator
145
- * cannot easily retype.
146
- */
147
- export function parseInterviewAnswer(body: string, token: string): string | null {
148
- const trimmed = body.trim();
149
- const upper = trimmed.toUpperCase();
150
- const normalisedToken = token.toUpperCase();
151
- if (!upper.startsWith(normalisedToken)) return null;
152
-
153
- const answer = trimmed.slice(token.length).trim();
154
- return answer.length > 0 ? answer : null;
155
- }
139
+ // `parseInterviewAnswer` lived here and is gone. It was correct and had the
140
+ // tests to prove it, and it was never reachable: the caller only ran it for a
141
+ // body the ALERT grammar had already accepted, which no real answer ever was
142
+ // (#533). Extraction now happens in `interpretInbound` the one place that
143
+ // knows a delivery's kind before it parses so the reply grammar exists once
144
+ // rather than in two halves that could disagree.
156
145
 
157
146
  /** Severity an interview question is delivered at — never a page-worthy one. */
158
147
  export const INTERVIEW_SEVERITY: AlertSeverity = 'warning';