the catalogue shapes a commander's words, not the commander

"it should always say Commander: <name>" was left to whether the commander
remembered to write it. that is not a structure, it is a hope — and the
producer's title reached the lock screen untouched, because both transports
built the alert from n.title rather than from the renderer.

so the code owns the shape and the producer only supplies values.
officer/core:commander is `Commander: {title}` / `{body}`; the commander
sends "Voice Memos", never the prefix. interpolation gains {title} and
{body} alongside {n} and {id}, for the same reason the others exist.

apns-payload and fcm now send the RENDERED text. that was the actual
delivery bug: the catalogue could say whatever it liked and neither
transport read it.

a placeholder that resolves to nothing takes its punctuation with it, or
`Commander: {title}` with no title ships `Commander: ` and every template
has to assume its values are always present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-24 15:24:17 +00:00
co-authored by Claude Opus 5
parent 478ddb13e9
commit 2d5e9835f5
7 changed files with 91 additions and 21 deletions
+25 -10
View File
@@ -16,11 +16,26 @@ import type { Notification } from './types';
/** The generic shown when a device cannot render for itself. Never a decoy sentence — see the doc. */
export const FALLBACK_TITLE = 'Officer';
/*
`{n}` and `{id}` are the numbers a producer sends. `{title}` and `{body}` are its own words, and they
interpolate for the same reason the others do: so the catalogue decides the SHAPE and the producer only
supplies values.
That distinction is the whole answer to "where is the structure defined". A commander sends its name and
a sentence; the catalogue is what turns those into `Commander Voice Memos`. Without this the producer's
title reached the lock screen untouched, and "it should always say Commander" was left to whether the
commander remembered — which is not a structure, it is a hope.
*/
const interpolate = (template: string, n: Notification): string =>
template.replace(/\{(n|id)\}/g, (_, key: 'n' | 'id') => {
const value = key === 'n' ? n.n : n.id;
return value === undefined ? '' : String(value);
});
template
.replace(/\{(n|id|title|body)\}/g, (_, key: 'n' | 'id' | 'title' | 'body') => {
const value = n[key];
return value === undefined ? '' : String(value);
})
// A placeholder that resolved to nothing leaves the punctuation that introduced it — `Commander: {title}`
// with no title renders `Commander: `. Trimming a dangling separator is what makes a template safe to
// write with one, rather than every template having to assume its values are always present.
.replace(/[\s:\u2014-]+$/, '');
export type Rendered = { title: string; body?: string; thread: string };
@@ -33,12 +48,12 @@ export type Rendered = { title: string; body?: string; thread: string };
export function renderNotification(n: Notification, catalogue: Catalogue): Rendered {
const entry = catalogue.entries[n.code];
// The producer's own words win. Only it knows what happened — a commander's whole output is a sentence
// the catalogue could not have held. Falls back to the code's template for everything else, which is
// almost everything.
if (n.title) return { title: n.title, ...(n.body ? { body: n.body } : {}), thread: threadFor(n, catalogue) };
if (!entry) return { title: FALLBACK_TITLE, thread: n.code };
// A code the catalogue does not know, but with words of its own: send them as they came. There is no
// shape to impose and dropping a real event over a missing mapping would be worse.
if (!entry) {
if (n.title) return { title: n.title, ...(n.body ? { body: n.body } : {}), thread: n.code };
return { title: FALLBACK_TITLE, thread: n.code };
}
const body = entry.body ? interpolate(entry.body, n) : undefined;
return {