# Lilith on Telegram — V1.0 buildout plan

> **Decision (2026-08-05).** The Telegram bot and Mini App are **in V1.0
> scope**, and the bar is "expose as much of Lilith through Telegram as
> possible" — the four shipped rooms, the assistant, notifications, reminders,
> deep links, and the richest interaction set Telegram supports.
>
> This document is the grounded gap analysis behind that work: what already
> exists in code, what is genuinely missing, and the order to build it in. It
> was written by reading the modules, not by assuming.

Related scope: [`V1/BRAND.md`](./BRAND.md) — V1.0 ships four rooms (Tara, Nyx,
Arete, Nisaba) and charges through crypto only.

---

## 1. What already exists

The Telegram surface is **not a stub**. The foundation is real and tested.

### Shared library — `libs/oshun/messaging-channels/src/telegram/`

| Module               | Lines | What it does                                                    |
| -------------------- | ----- | --------------------------------------------------------------- |
| `bot.ts`             | 680   | Command routing, reply composition, consent + disclosure gating |
| `security.ts`        | 245   | `initData` verification, webhook secret, replay/abuse defence   |
| `stt-provider.ts`    | 232   | Voice-note speech-to-text with a real provider seam             |
| `sophia-grounder.ts` | 122   | Evidence-grounded answers with citations                        |
| `payments.ts`        | 90    | Invoice creation, pre-checkout approval, receipt recording      |
| `publishing.ts`      | 70    | Editorial channel posts, per-domain                             |
| `rendering.ts`       | 70    | Message rendering                                               |
| `inline.ts`          | 60    | Inline-query share cards and Sophia results                     |
| `effects.ts`         | 57    | The side-effect port (`/save`, `/quiet`, `/stop`, `/voice`, …)  |
| `rate-limit.ts`      | 61    | Per-chat rate limiting                                          |

### Channel registry — `registry.ts`

`telegram-bot`, `telegram-channel`, and `telegram-miniapp` are first-class
channels with declared capabilities (`text`, `rich-cards`, `inline-buttons`,
`voice`, `files`, `payments`, `miniapp`), a `verified-opt-in-required` consent
model, residency, retention, and `crisisCapable: true` on the bot. Reminder
scheduling and the dispatcher already route by channel.

### BFF integration — `apps/oshun/bff/src/telegram/`

`webhook.ts` wires the bot with a **real** `TelegramEffectsPort`
(`effects-adapter.ts`): `/save` writes a durable capture with a provenance
bundle, `/quiet` writes to the shared notification-preference store (so it
suppresses delivery everywhere, not just Telegram), `/stop` and `/unlink` write
durably. There are integration specs for user-state durability.

### Mini App — `apps/oshun/telegram-miniapp/`

Six shipped surfaces (`today`, `sophia`, `nyx`, `arete`, `nisaba`,
`illustration`), each with evidence, provenance, disclosure, and a
domain-specific payload. A seventh (`veritas`) is built and stays typed and
tested behind the V1.2 room deferral.

---

## 2. The real gaps

Ordered by member-visible value.

### G1 — The standalone bot app never wires its effects port

`apps/oshun/telegram-bot/src/index.ts` calls `handleTelegramUpdate` without an
`effects` argument, so every side-effect command falls through to the fail-loud
strings in `SIDE_EFFECT_NOT_CONNECTED` ("Saving from Telegram is not connected
yet, so I will not pretend it saved"). The BFF webhook path _is_ wired; the
standalone app is not.

**Decide and then make it true:** either the standalone app is retired in favour
of the BFF webhook, or it takes the same `createTelegramEffectsPort()`. Two
entry points where one is silently inert is the kind of thing that ships.

> **Decided (2026-08-07): kept as a library.** See §5.

### G2 — Command surface is far narrower than the four rooms

Today: `/start`, `/menu`, `/today`, `/sources`, `/help`, `/save`, `/quiet`,
`/stop`, `/voice`, `/unlink`.

Missing, one per shipped room plus the shell:

- `/sit` — start a Tara ritual, with breath pacing as timed message edits
- `/checkin` — an Arete check-in as an inline-keyboard flow, answers persisted
- `/tonight` — the Nyx sky window for the member's location
- `/passage` — the Nisaba daily passage, with a save button
- `/ask` — the assistant, grounded, in-chat (Sophia grounding already exists)
- `/library`, `/continue`, `/streak`, `/settings`

### G3 — Reminders and notifications do not originate from Telegram

The channel is registered and the dispatcher can route to it, but there is no
Telegram-side flow to _set_ a reminder. A member should be able to say "remind
me at 7" in chat and have it land in the same reminder store the web shell uses,
then arrive as a Telegram message with a snooze/complete keyboard whose taps
write back.

### G4 — Interaction richness Telegram supports and Lilith does not use

- **Inline keyboards** for check-ins, snooze/complete, room switching
- **Message editing** for live breath pacing and timers (no message spam)
- **Web App buttons** opening the Mini App at a specific surface with context
- **Deep links** (`t.me/<bot>?start=<payload>`) resuming an exact thread
- **Bot menu button + command scopes** so the command list is discoverable
- **Voice replies** (STT exists inbound; TTS outbound is not wired)
- **Chat actions** (`typing`, `record_voice`) so latency reads as presence

### G5 — Mini App surface parity with the shipped rooms

The Mini App has `nyx`, `arete`, `nisaba` but no Tara ritual player and no
library/continuity surface. `today` is a ritual card, not the shell's Today.

### G6 — Payments

`payments.ts` covers invoice → pre-checkout → receipt, and the bot has a crypto
invoice handler. Confirm the Telegram payment path is crypto-only in line with
the V1.0 rail cut, and that Telegram Stars / native invoices are not reachable
(they are a fiat rail).

---

## 3. Build order

1. **G1** — close the inert entry point. Small, and it is a correctness bug.
2. **G2 + G4** — the command surface with inline keyboards, message editing, and
   Web App buttons. This is the bulk of the member-visible win.
3. **G3** — reminders originating from and returning to Telegram.
4. **G5** — Mini App parity for Tara and library.
5. **G6** — confirm and lock the crypto-only rail on Telegram.

Each step ships with the same standard as the rest of the repo: real writes
behind the effects port, honest refusals where a dependency is absent, and tests
that assert the write happened rather than that a string was returned.

---

## 4. What the build found (2026-08-07)

All six gaps are closed. The order above held, but three things the gap analysis
could not see from the module list turned out to matter more than the items it
named — each the same shape as G1, and each invisible to a green test suite.

### The ports were real and reached nobody

`TelegramRoomsPort` and `TelegramRemindersPort` were declared, routed, and
tested — and implemented **only by test doubles**. Every room command and
`/remind` answered "not connected over Telegram yet" in every real deployment
while passing every unit test. G1 was this bug in one entry point; this was the
same bug one layer up, in both.

Closed by `telegram/rooms-adapter.ts` and `telegram/reminders-adapter.ts` over
the same sources the web shell reads, and by wiring them into **both** entry
points. `webhook-wiring.spec.ts` pins that wiring by its member-visible symptom:
drop a port from the runtime and a room command declines, which is what fails.

### The BFF webhook composed replies and never sent them

The route computed `BotResponse[]` and returned it in its own JSON body, which
Telegram does not read. Every reply the BFF composed was dropped. The grammY app
had a delivery path; the BFF had none. `telegram/deliver.ts` is that missing
half, and the route now sends — detached, because a paced `/sit` would otherwise
hold the webhook open and Telegram would retry the whole update.

### Three defects behind the features, not in them

- **Reminders fired early.** `runReminderCycle` computed `deliverAtIso` and
  never compared it to the clock. The planner clamps every future reminder into
  the 30-minute pre-session window, so its plan is a _delivery_ plan long before
  it should send — a reminder set for next week went out on the next tick. G3
  could not ship on top of that.
- **Telegram was not a delivery channel at all.** `deliverDispatchedMessage`
  returned `unsupported-channel` for `telegram-bot`, so a scheduled reminder
  could never reach Telegram. Unprompted sends now have a real transport,
  carrying the snooze/complete keyboard.
- **TTS was unimplementable, not merely unimplemented.** The seam demanded a
  Telegram `fileId`, and the one thing a speech provider cannot return is an id
  for audio Telegram has never seen. The contract now takes bytes, which the
  delivery layer uploads.

### Two smaller mismatches between a claim and the code

- `registerBotCommands` said in its own doc comment that it opened the menu
  button on the Mini App, and only ever published the command list.
- `/library`'s Web App button opened `today` — the Tara ritual timer — because
  the Mini App had no library surface, and the surface router silently falls
  back to `today` for an unknown slug, so nothing said so. There is now a
  library surface, and a test that every Web App button the bot offers names a
  slug the app actually routes.

### G6 — the answer is yes, and it is worth knowing why

Telegram Stars and the native invoice flow **are** closed: `release-scope.ts`
404s `/telegram/payments/{invoice,pre-checkout,success}` by path prefix, and
leaves `/refund` reachable so a pre-cut charge is not trapped. That is proven on
the built app in `telegram-crypto-only-rail.spec.ts` rather than on the
predicate alone — a correct predicate and an unregistered guard look identical
from a unit test.

That guard is doing more work than the rail cut. The three handlers behind it
carry no auth pre-handler, and `/telegram/payments/success` mints a `paid`
receipt from state supplied in the request body. It is safe today because the
guard is in front of it; **whoever opens this rail in V1.1 must add
authentication and server-side state before removing those path prefixes.**

---

## 5. The standalone bot: kept as a library (2026-08-07)

G1 asked for a decision. It is: **keep `@oshun/telegram-bot` as a library, and
do not wire it.**

Nothing runs it, and nothing should. It has no server entrypoint and no `serve`
target, and both deploy configs excluded it by name before this decision was
taken — `infra/hetzner/docker-compose.yml` says running it as a container "would
crash-loop". The live inbound path is, and stays, the BFF webhook.

**Why not retire it.** A webhook needs a public HTTPS address. Long polling does
not, and long polling is what this package is the kit for — it is the only way
to run a local loop against real Telegram, or to deploy somewhere with no public
ingress. That option is worth more than the ~440 lines it costs, and the lines
are typechecked and tested either way.

**Why not wire it.** The stores behind the ports — the reminder schedule, quiet
hours, voice settings, saved items — are in-memory singletons **inside the BFF
process**, not a shared database. A second process gets its own empty copies. A
`/quiet` set through a standalone bot would not suppress web delivery, and a
reminder set there would not be on the schedule the worker runs. It would look
wired and silently disagree with itself, which is strictly worse than declining
honestly. So if it is ever stood up it must be a **thin client of the BFF over
HTTP** — a messenger, not a second brain.

**Made true in this commit.** The decision was only recorded in two deploy
comments, while two configs still declared it deployable:

- `infra/terraform-v1/variables.tf` declared a `telegram-bot` ECS service with
  `autoscaling_max = 1` and the comment "Long-poll worker … so the poller is
  always up". A `terraform apply` would have provisioned a task that crash-loops
  forever. Removed, with the reason in its place.
- `.github/workflows/deploy-ecs.yml` offered `telegram-bot` as a
  human-selectable deploy target and included it in `ALL_SERVICES`. Removed.

`src/index.ts`, `package.json`, and the README now say what the package is
instead of calling it a "service". Should anyone stand it up regardless, the
`sideEffectsWired` / `roomsWired` / `remindersWired` flags on its webhook
response say in the first reply that it is talk-only.

---

## 6. What §5 cost, and what a browser found (2026-08-07, second pass)

§4 said all six gaps were closed. They were — in the library and in the BFF. The
vein it named ran one layer further out than it looked, and §5 is what opened
that layer: **deciding the standalone bot is never deployed stranded every
capability that lived only inside it.** Three did.

- **The command surface published nothing.** `setMyCommands` and
  `setChatMenuButton` were real, tested by `discoverability.spec.ts`, and called
  only from `createOshunGrammyBot`. On the live BFF webhook the `/` typeahead
  was empty and the menu button did nothing, so every command G2 added was
  built, wired, and unfindable — which §4 itself named as "most of why the
  surface went unused". Registration now lives in `telegram/command-surface.ts`,
  the BFF publishes it after `listen`, and the kit delegates to the same
  function.
- **Presence never reached the live path.** The `typing` action before grounding
  was emitted only by the grammY app. On the deployed path a member asked a
  question and watched a silent chat.
- **`/upgrade` answered "Command not recognized".** The whole crypto paywall —
  `handleUpgradeCryptoCommand`, QR, wallet deep links — was reachable only
  through an intercept in the retired package. The V1.0 rail G6 confirmed was
  closed to fiat was open to nobody. It is a routed command now, behind
  `TelegramUpgradePort`, with the kit's in-chat paywall and the BFF's
  send-them-to-billing as two implementations of one route.

The BFF adapter deliberately does **not** mint an invoice from a chat message,
and that is not caution: a Telegram message carries no session,
`buildCryptoQuote` needs an `entitlementPlan` and its price-book price or the
charge settles and grants nothing, and a Tier-B rail is gated on a click-through
with a recorded instant. All three live on the billing surface.

### Command scope was a privacy hole, not a menu preference

G4 asked for "command scopes". Scoping the published list turned out to be the
smaller half: the router never looked at the chat type, so `/settings` typed in
a **group** printed that member's quiet hours and linked account to everyone in
it, and `/streak`, `/library`, `/continue`, and `/checkin` did the same for
their own state. `PERSONAL_TELEGRAM_COMMANDS` is now both what the group menu
omits and what the router declines outside a private chat — one list, so the
menu cannot advertise what the bot refuses.

### The Mini App: everything it showed was an example, and it said saved anyway

This is the largest thing the first pass could not see, because a green suite
was pinning it.

- **Every surface rendered the Tara ritual player.** `params` is a Promise in
  this Next major and the page read it synchronously, so the slug was
  `undefined` on every request and the router's silent `today` fallback caught
  all of them. The built static export served a breath timer at `/nisaba/`,
  `/arete/`, `/nyx/`, and `/library/`. Every "Open in <room>" Web App button the
  bot offers opened the wrong room — including the library surface §4 added to
  fix exactly this symptom for `/library`. A **browser run** found it; nothing
  else could, because `library-surface.spec.ts` tested that the buttons name
  routed slugs and nothing tested that the app renders them.
- **Nothing was ever read, and nothing was ever written.** The app was a static
  export of fixture content: the same passage, the same ISS pass, the same 5-day
  streak for every member. `src/lib/telegram-webapp.ts` — the entire Telegram
  bridge, `initData` header builder included — had **zero callers** outside its
  own test. The app never authenticated as anybody because it never asked
  anybody for anything.
- **And it reported success.** "Annotation saved to notebook", "Habit tick
  saved", "tara.ritual.completed queued" — all `setStatus` calls over a no-op.
  The library surface's own disclosure claimed to show "what the rooms actually
  recorded" above a literal. **The e2e suite asserted those strings**, which is
  how a fabrication survives: a test that pins a false claim keeps it alive.

Closed by `/telegram/miniapp/surface/:slug` and `/telegram/miniapp/action`,
authenticated by `initData` (verified against the bot token; writes claimed once
against replay, reads not — a member switching surfaces re-sends the same
credential). Both go through the **same rooms and effects ports the bot uses**,
and the app imports the port types rather than re-declaring them, so the chat
and the app cannot disagree about what a ritual is. Where a read genuinely
cannot happen — outside Telegram, no BFF configured, a room with nothing
recorded — the surface says which of those it is and labels the example as
nobody's. No path through `performMiniAppAction` reports a save the server did
not confirm.

`/settings` also lost its "Open settings" Web App button, which pointed at
`today`. It is the same defect as `/library`'s and it survived that fix because
`today` **is** a routed slug: a check that every button names a routed surface
cannot see a button that names the wrong one. The test now asserts which surface
each command opens.

### `/today` was still reprinting the menu

The third thing G5 named — "`today` is a ritual card, not the shell's Today" —
was the one item the first pass did not close. `/today` returned the command
menu: a list of what a member **may ask for**, saying nothing whatever about
their day. It is a rooms-port read now, reporting each of the four rooms that
answered, leaving out the ones that had nothing (a `Nyx: —` line reads as a
broken room rather than as an unset location), declining outright when none of
them did, and offering to begin the sitting it just named.

### Two stale tests, on opposite sides of the rail cut

`telegram-route.test.ts` expected `POST /telegram/payments/invoice` to answer
200 while `telegram-crypto-only-rail.spec.ts` proved it 404s. Both shipped. The
route test predates the rail cut and was red; it now asserts the cut, and
carries the note that the crisis-suppression and chargeback-screening behaviour
**behind** that guard is unreachable until V1.1 opens the rail and must be
re-proven at the route then — alongside the missing auth §4 already flagged.

---

## 7. Everything was built, and the deployment could not run any of it (2026-08-07, third pass)

§6 fixed the app. This pass ran what the deployment actually runs, and found
that **almost none of the surface reached a member** — not because anything was
missing, but because each piece met the thing that hosts it and stopped there.
Every finding below sits on one boundary: the code and its deployment. That is
the layer a unit test cannot see by construction, and a browser run on localhost
misses because localhost is not the deployment.

Two of these are §6's own defects reappearing one layer out. The Mini App's
router was fixed to serve one page per surface; the container it ships in
rewrote every path back to one page. The Mini App was taught to read the
member's own state through `initData`; the page never loaded the script that
creates `initData`, so it could not have read anything for anybody.

### The image served the same page for every surface

`docker/Dockerfile.web` ran `serve -s` for static apps. `-s` is `--single`: it
rewrites **every** path to `/index.html`, which is right for a single-page app
with a client-side router and destructive for a static export with one HTML file
per route. `@oshun/telegram-miniapp` is the only static app in the repo (every
other front-end is `output: 'standalone'`), so that flag served exactly one app
and was wrong for it.

The effect is precisely the bug §6 fixed in `[surface]/page.tsx`:
`tg.<domain>/nyx`, `/arete`, `/nisaba` and `/library` all returned the index
page. `/bogus` answered 200 with it too, so nothing 404s and nothing looks
broken.

It survived a browser pass because the e2e suite serves `dist/` with
`python3 -m http.server`, which resolves directory indexes; the image does not.
**A test server that resolves paths differently from the deployment cannot see a
defect in the deployment.** `playwright.config.ts` now runs the image's own
command, pinned to the same `serve` version — under `serve -s`, 11 of its 13
tests fail, and they always would have. The mode is a `STATIC_ROUTING` build arg
that both deploy workflows set explicitly for the Mini App.

### The Telegram bridge could never exist

`window.Telegram.WebApp` comes from Telegram's own `telegram-web-app.js` and
from nowhere else — Telegram passes the signed credential in the URL fragment
and leaves it to that script to parse. **The app never loaded it.** So
`telegramBridge()` returned null on every load including inside Telegram, and
every surface told members who were inside Telegram that they were outside it:
_"Opened outside Telegram, so there is no account to read. This is example
content."_ Everything §6 built behind that check — the surface reads, the
actions, `initData` auth, the native buttons — was unreachable from the only
place it runs.

Nothing could catch it. The unit tests inject a bridge. The e2e suite runs
deliberately outside Telegram and asserts **exactly the copy the bug produced**
— the same shape as §6's fabricated-save strings: a test pinning the symptom as
if it were the specification. The suite now replaces Telegram's script with a
stand-in that defines a bridge, and asserts the app stops reporting the member
as outside Telegram.

### Which turned the theme on for the first time, and it was unreadable

Both halves had never run, because there had never been a theme to apply:

- The theme variables were set as an inline style on the surface's `<main>`,
  while the page background is painted by `body` — an ancestor, outside that
  subtree. Under a dark Telegram theme every piece of text took the theme's
  light colour over the light default background: **1.02:1**, which is not hard
  to read, it is invisible. The theme now applies at the document root, for
  every page including the index the menu button opens.
- Each surface's accent is a fixed brand colour chosen against this app's light
  default. On a dark theme they measure **3.4:1** as text, under the 4.5:1 that
  AA requires. Accents keep their brand value for fills and borders; as text
  they are blended toward the theme's own text colour by the smallest step that
  clears the ratio (`legibleAccent`, WCAG 2.1 relative luminance).

The e2e accessibility gate is what failed on both, once a theme finally reached
the page.

### The browser was never allowed to call the API

`infra/hetzner/docker-compose.yml` has been passing the `bff` service
`CORS_ORIGINS: https://<domain>,https://admin.…,https://tenant.…,https://tg.…`,
and `apps/oshun/bff/.env.example` documents the variable. **Nothing read it.**
`app.ts` registered `@fastify/cors` with four localhost literals, so in every
deployed stack the browser blocked every cross-origin call to the API: the web
shell, the admin apps, and the Mini App — whose `X-Telegram-InitData` header
forces a preflight on every single read. The Mini App reported it honestly ("I
could not reach Oshun") and it looked like a network problem.

A localhost literal is invisible in exactly this way: it is correct on the
machine where anyone would notice it being wrong. The list now comes from the
deployment (`browser-origins.ts`), shared with the Lilith write guard in
`domain-stubs.ts` — a **second** copy of the same literal, which had already
drifted: it allowed two production hosts the CORS layer refused, so a browser at
either could never reach those routes to be let in. `*` is refused outright,
because this API answers credentialed requests.

### Five settings the code reads and no deployment set

- **`OSHUN_TELEGRAM_MINIAPP_URL`** — read by the BFF for the chat menu button
  and every "Open in <room>" Web App button, and set nowhere. The bot degrades
  honestly without it, which means the Mini App was built, deployed at
  `tg.<domain>`, and unreachable from Telegram by design.
- **`OSHUN_REMINDER_WORKER_INTERVAL_MS`** — unset, so the BFF logged "in-process
  delivery worker disabled" and every reminder a member set with `/remind` sat
  on the schedule and never sent. G3 was inert at the last hop. Now on in the
  Hetzner stack, which runs exactly one `bff` container. Deliberately **not** on
  ECS: the cycle read-modify-writes reminder state with no cross-process fence
  and `bff` there scales to four tasks, so it needs a single-replica owner or an
  external cron on `/v1/reminders/run-cycle` first — written into
  `infra/terraform-v1/main.tf` rather than left to be rediscovered.
- **The webhook itself.** Registering it was a `curl` in a README. Nothing ran
  it, and nothing checked it — so the most total failure this surface has (no
  updates arriving, ever) was also its quietest, indistinguishable from a bot
  nobody has messaged. The BFF now **reads** the registration at boot and says
  which of the three states it is in, including "this bot token delivers
  somewhere else", which is silent on both sides when two stacks share a token.
  It does not write one: a bot has exactly one webhook, and a service that
  claimed it at boot would let stacks take it from each other on every deploy.
  Writing it is `infra/hetzner/scripts/telegram-webhook.sh register <stack>`.
- **`OSHUN_WEB_APP_URL` / `OSHUN_BILLING_URL`** — read by `resolveBillingUrl`,
  set by no deployment **and declared in no `.env.example`**. So `/upgrade`
  answered `unavailable` everywhere: §6 made the command routed and gave it a
  port, and the rail G6 confirmed was closed to fiat stayed open to nobody, one
  step further along than §6 left it. The adapter is right not to guess an
  origin — a payment button opening a page nobody deployed is worse than an
  honest refusal — so the fix belongs in the deployment, which now derives it
  from `PLATFORM_DOMAIN` (`/billing/crypto` is a real page in `@oshun/web`).
- **`OSHUN_PUBLIC_WEB_URL`** — unset, so Sophia's citation links in Telegram
  fell back to a literal `https://oshun.app`. Staging cited production. This one
  had a plausible default, which is why it is the least visible of the five and
  would have outlived all of them.

These were found by listing every environment variable the Telegram path reads
and checking each against what a deployment actually sets. That sweep is worth
repeating, and it has one trap: a resolver that takes `env` as a parameter
(`resolveBillingUrl(env)`) does not match a grep for `process.env.X`, and the
two variables it hides were the two that had never been declared anywhere.

### Two things this pass saw and did not change

- `apps/oshun/corporate-web` is the repo's other static export and its e2e has
  the same test-server mismatch. Nothing deploys it today, and the
  `STATIC_ROUTING` default is already correct for it.
- `deploy-ecs.yml` bakes the Mini App's `NEXT_PUBLIC_OSHUN_BFF_URL` from a
  GitHub repository variable. If that variable is unset, the ECS image ships
  with no server configured and every surface shows example content — visible
  only from the repository settings, not from here.

---

## 8. Telegram is a party to this, and nothing had ever asked it (2026-08-07, fourth pass)

§7 got the deployment to run the code. This pass asked the next question along:
**does Telegram accept what we send, and does the code hear what Telegram says
back?** Neither had ever been checked, and the reason is a single line repeated
in every spec in the surface — the test transport is a `fetchImpl` that answers
`{ok: true}` to any body at all. The Bot API does not. It refuses an over-long
message, a Web App button outside a private chat, a callback notification over
200 characters — and it refuses the **whole call**, so one illegal button costs
the member the entire message rather than the button.

Same shape as every pass before it, one layer further out: §4 the ports, §6 the
stranded entry point, §7 the deployment, §8 the platform. Each was invisible to
everything the previous layer could see.

The constraints are quoted from the published documentation in
`telegram/bot-api-limits.ts`, so the numbers have a source rather than a memory.

### `/passage` in a group failed completely, and it is the one command meant for groups

`InlineKeyboardButton.web_app` is "available only in private chats between a
user and the bot". Every room command attached one, which was harmless for the
eight that `PERSONAL_TELEGRAM_COMMANDS` already declines outside a private chat
— and fatal for the ninth. `/passage` is **deliberately** absent from that list,
with a comment saying why: today's passage is the same text for everyone, so it
is the one room command a group can share. It was therefore the one room command
a group could never receive: `BUTTON_TYPE_INVALID`, no message, no error anyone
would see.

`miniAppButton` is now the single place any Mini App affordance is built, and it
takes the chat type. In a group it degrades to a `t.me/<bot>?start=passage` link
— legal there, and it lands the member in the private chat where the real button
works. `buildTelegramDeepLink` had existed since G4 with **zero non-test
callers**; this is its first one.

The bot learns its own username from `getMe` at boot rather than from a
variable, because the username is a fact about the token and a hand-set one that
drifted would build links to somebody else's bot. That also fixed a fallback
already in the tree: `telegramBotUsername()` guessed `oshun_bot` for the
account-linking deep link whenever `OSHUN_TELEGRAM_BOT_USERNAME` was unset —
`t.me/oshun_bot` is a real address, and it is not necessarily ours. Same shape
as §7's `https://oshun.app` citation default: the plausible fallback is the one
that survives, because nothing looks broken.

### Long replies were not truncated — they were dropped

`sendMessage.text` is "1-4096 characters after entities parsing". Nothing in the
surface measured it. Four replies are unbounded by nature: `/ask` is as long as
the model writes, `/library` and `/streak` grow with use, and the Nisaba corpus
grows with editing. Over the cap Telegram refuses the call, so the member gets
**nothing** — not a clipped list.

Split, not truncated: a library that silently stopped at item 60 would be the
same fabrication as a save that never happened. Boundaries are tried blank line
→ line → space → hard cut, the keyboard rides the last piece where the buttons
act, and a surrogate pair is never split across two messages. The unprompted
path (`sendTelegramViaBotApi`) got the same treatment: a reminder carries a
member-supplied label plus the disclosure and provenance footer the boundary
requires, and over the cap it would simply never arrive, at the exact moment the
member asked to be reminded.

### And Telegram's answers were being discarded

Only _inbound_ rate limiting existed — the bot's own limiter on arriving
updates. Nothing read what the Bot API said back:

- **`429` carries `retry_after`, and it is an instruction, not information.** A
  sender that ignores it earns a longer ban. Honoured now, once, bounded: a
  second 429 after waiting means the chat is saturated and queueing against it
  makes things worse for everyone in the process.
- **`403` is the member's own stop**, and it is permanent. It was not merely
  unhandled — it was **unreportable**. In-app delivery runs first in the
  reminder cycle and marks the reminder reached, so every external provider
  refusal fell into a branch that recorded nothing anywhere. There was no field
  in the cycle result that could carry it. `externalFailed` is that field;
  `failed` keeps its exact meaning ("the recipient was not reached at all").

### `/stop` did not stop

The inbound half of the same boundary, and the worst of the five. `/stop` writes
a durable `deliveryStopped` flag. `/settings` reports it. The
notification-preferences summary reads it. **Nothing that sends ever asked it.**
So a member who told the bot to stop kept receiving every reminder already on
the schedule — the one thing `/stop` promises not to happen.

The SMS rail has had this gate all along (`smsRecipientSuppressed`, the STOP
compliance authority). Telegram is now asked the same question in the same
place, before the send. A member's `/stop` and Telegram's 403 are the same
instruction arriving from two directions, so the 403 now writes that same flag —
and the reply path honours it too, not just the reminder path, because honouring
it in one of two places is how the two stop agreeing about who may be written
to.

### The gate that would have caught all of it

`bot-api-conformance.spec.ts` walks every command the bot publishes, in **both**
chat types, expands each response into the Bot API calls it actually becomes,
and asserts every one of them is a call Telegram documents as acceptable. It is
not a mock of the Bot API's behaviour — it is the published limits, applied to
real payloads.

Both chat types, because the defect that prompted it was invisible in one of
them. It was written as a probe before any fix and reported **seven**
violations; reverting `webAppButtonsAllowedIn` to "always true" still fails nine
of its assertions today. That is the property §6 and §7 both found missing in
tests that pinned a symptom as the specification.

### What this pass saw and did not change

- **Telegram's global send limits** (roughly 30 messages/second overall, and far
  less per group) are not modelled. Nothing paces the reminder fan-out, so a
  large enough cycle will meet 429s and rely on the single retry above. That is
  a real ceiling and it wants a queue, not a retry — but it is a scale problem,
  not a correctness one, and it does not fail for one member today.
- **`answerCallbackQuery` is clamped, not split**, at 200 characters. A toast is
  not content; anything needing more room belongs in a follow-up message, which
  `followUps` already carries. Worth knowing it is a clamp.

---

## 9. Telegram also speaks, and the bot only listened for what it expected (2026-08-08, fifth pass)

§8 asked whether Telegram would accept what we send, and traced five defects to
one line repeated in every spec: a test transport that answers `{ok: true}` to
any body. This pass asked the other half of that question — **does the bot
understand what Telegram sends?** — and the answer has the same single cause,
hiding behind a different line: **every update in this surface is one we
wrote.** The router has only ever been handed the shapes its own authors had in
mind.

Telegram sends others. It addresses a command to a named bot. It announces that
a member blocked the bot, as an update rather than as an error on a send. It
delivers an edit of a message it already delivered. It carries a shared
location. Three of those four are the ordinary way the feature works, and the
router had never seen one of them.

`inbound-update-conformance.spec.ts` is the probe, written before any fix and
quoting the forms from the published documentation the way `bot-api-limits.ts`
quotes the limits. Against the shipping code it reported **26 failures out of
31**.

### `/command@botusername` was answered by nothing

The Bot API's own guide: commands "can be sent with the bot's username attached,
e.g. `/start@TriviaBot`. This is useful in group chats where several bots are
present." Telegram's client attaches it for the member whenever a group holds
more than one bot. `commandText` lower-cased the first whitespace token and
compared it directly, so `/passage@lilith_oshun_bot` matched no command at all
and got **"Command not recognized. Use /help for available actions."**

Which lands on `/passage` again. §8 found it was the one room command a group
could never receive, because it was the one carrying an illegal Web App button;
this pass found that the fix reaches a member only in a group with exactly one
bot in it. Every command has the same hole — the addressed form is the ordinary
form in precisely the chats a group command exists for.

Three parsers in the tree already handled the suffix — `askInChat` strips
`/^\/ask(@[\w]+)?/`, `stripLeadIn` strips `/^\/?remind(?:@[\w]+)?/`,
`saveCommandPayload` strips `/^\/save(@[\w]+)?/`. All three were
**unreachable**, because the token carrying an `@` never got past the router to
reach them. The intent was understood and was never carried the one step that
mattered.

The suffix is not noise to be stripped, either: it says who the command is for.
`resolveCommandAddressing` answers a bare command, answers one addressed to us,
and **declines an update entirely** when it names another bot — which is the
duplicate reply the convention exists to prevent. It is resolved before the rate
limiter, not after: a busy group with a second bot in it would otherwise spend
this chat's window on traffic that is not ours and start declining the member's
own commands with "this chat is moving quickly". With no `botUsername` known
(`getMe` failed at boot) it answers rather than going silent — the worse failure
there is silencing every group.

### A member blocking the bot was answered with a message

`my_chat_member` is the update Telegram sends when its own membership changes,
and "for private chats, this update is received only when the bot is blocked or
unblocked by the user". So it is the one signal that a member has stopped which
arrives **before a send has to fail to learn it**. The handler read neither the
old status nor the new one. It replied, to whatever chat the update came from:

> Telegram chat membership changed. Delivery preferences and revocation cascades
> will be rechecked before future sends.

In the case that matters that message goes to a chat that has just closed — a
guaranteed 403 — and the sentence describes a recheck no code performed. Worse,
the user-id chain (`message` → `edited_message` → `callback_query` →
`inline_query`) never read `my_chat_member.from`, so **every membership update
was attributed to a member called `unknown`**; any write it had made would have
silenced nobody while looking exactly like a write.

A block now writes the same durable stop `/stop` writes and Telegram's 403
writes — the third direction of one instruction. Removal from a **group** writes
nothing, because a group admin tidying a channel is not that admin asking to
stop receiving their own reminders. Being **added** to a group gets the one
message worth sending there: what the bot answers in a shared chat, and that
anything about one person stays in a private one.

### `/tonight` could never have worked, for anybody

`TelegramUserStateStore.setObservingLocationDurably` has existed as long as
`/tonight` has, and its documentation says the member "supplies it by sharing a
Telegram location". **Nothing in the product ever called it.**
`TelegramTextMessage` had no `location` field, so a shared location was not
merely unhandled — it was unrepresentable. Nyx's web components read the
browser's geolocation into component state and write nothing.

So `loadSkyWindow` returned `unavailable` for every member on every request, and
the only reply `/tonight` could give anyone was its decline — which said **"Set
a location in Nyx and try again"**, naming a control that does not exist. An
instruction a member cannot follow is indistinguishable from a broken room.

Closed end to end: `location` on the message type, a `recordObservingLocation`
method on the rooms port — **required**, not optional, because an optional
method is exactly how a port comes to be implemented only by the test doubles
that need it — the BFF adapter over the durable store, and copy that names the
thing a member can actually do (📎 → Location). The port carries the write next
to `loadSkyWindow` on purpose: `longitudeEast` is east-positive, and a west
longitude whose sign is dropped between the write and the read puts the member
on the far side of the planet and still returns a confident sky.
`webhook-wiring.spec.ts` now drives the whole trip — decline, share, real window
— and asserts that a latitude of 991 is refused rather than clamped, and that
the reply does not say "Saved".

### Editing a message re-ran the write

`commandText` read `update.message?.text ?? update.edited_message?.text`, so an
edit was a fresh instruction. Correcting a typo in `/remind at 19:00 …` left the
member with **two reminders** and a reply naming only the second. Reads still
re-run — answering a corrected question again writes nothing and is what the
member wants — but a `WRITE_COMMANDS` edit declines and says why rather than
going quiet.

### `/stop` had no way back, and said it did

The reply to `/stop` ended **"Message me again any time to resume."** Messaging
the bot resumes nothing. `resumeDelivery` / `resumeDeliveryDurably` have been on
the store since the stop was written and had **zero callers anywhere** — so the
stop was a one-way door, `/settings` reported it with no way out, and the copy
asserted a mechanism that did not exist. That last part is why nobody noticed
the dead method: the sentence stood in for it.

This is the copy half of finding 9 in
`docs/audits/V1_RESIDUAL_AUDIT_2026-06-11/12-messaging.md`, which named the
overclaim ("scheduled sends cancelled") in June. §8 closed the enforcement half
by making every send ask the flag. The wording outlived it by two months.

`/resume` is a routed command over a real port write, named in the `/stop`
reply, in `/settings` when the flag is set, in `/help`, and in the message the
bot sends when a member **unblocks** — which is deliberately not an automatic
resume, because unblocking to read back what you were sent is not asking to be
sent to again.

### And `/unlink` was the same control with the same defect

The third bullet of that June audit item is still true, and the gate §8 built is
where it belongs. `/unlink` says "Telegram is unlinked from your Lilith
account", marks the link record revoked — and the reminders kept arriving,
because the identity they are keyed on (`iris:<telegramId>`) is derived from the
Telegram id and outlives the binding. This channel's consent model is
`verified-opt-in-required`; a withdrawn verification is a withdrawn opt-in, and
`telegramRecipientSuppressed` now asks that too. A **null** link is not a
revoked one — most members here never bound a separate Oshun account at all, and
the control test that keeps this rule from silencing the whole surface is in
`delivery-stop.spec.ts` beside it.

### The gate, and that it fails

`inbound-update-conformance.spec.ts` drives real updates through the real
handler and asserts on the port calls and the replies. Each fix was reverted in
turn to confirm the probe is load-bearing rather than decorative: **the
addressing fix → 19 failures; the block handling → 2; the location path → 2; the
edit guard → 2**; and the unlink gate → 1 in `delivery-stop.spec.ts`.

One of its own assertions had to be repaired for the same reason. "attributes
the stop to the member, not to `unknown`" read `stop.mock.calls[0]?.[0]` — which
is `undefined` on an uncalled spy, and `undefined` is not `'unknown'` either, so
it passed against a bot that wrote nothing at all. A vacuous assertion is the
smallest version of the thing this whole sequence keeps finding.

Two tests elsewhere pinned the old behaviour as the specification, and both were
rewritten to assert the new: `rooms.spec.ts` required the unfollowable "Set a
location in Nyx", and the Playwright suite required the membership-changed line
verbatim, with a source comment citing the line numbers it came from.

### One thing this pass saw and did not change

- **`pre_checkout_query` was removed from `allowed_updates`.** The registration
  script asked Telegram for it and the router has no branch for it — asking for
  an update nothing handles is a claim that it is handled. It cannot arrive
  while the V1.0 rail is crypto-only, and V1.1 opening that rail must add it
  back _and_ answer it inside Telegram's 10-second window or the member's
  payment fails.

### The two this pass first deferred, and then closed

Both were written up as "not changed" and both turned out to be small once the
right existing thing was reused rather than a new one built.

**Duplicate updates ran the work twice.** Telegram redelivers an update whenever
the endpoint answers anything other than 2xx, and this route answers 503 on a
cold security store and 500 on an unexpected throw — either of which can land
after a room write or a reminder has been made, so a retried `/remind` left the
member with two and a reply naming one. `update_id` was on the type and read by
nothing.

The first attempt at this reused `TelegramAuthReplayStore` — one claim key, a
release on failure — and it was wrong in a way worth recording, because it is
the same mistake in a new place: **it made a member's message depend on a
network call succeeding on a path that was already failing.** If the release did
not land (a Redis blip, a killed container, an OOM between the claim and the
answer) the id stayed claimed for the whole suppression window and every retry
Telegram made was skipped. One bad second, and nobody is ever answered.

The fix is not a more reliable release. It is that a single TTL was serving two
different lifetimes at once, and splitting them makes a lost release heal
itself:

- **The attempt lease** — how long one attempt may hold the id before it is
  presumed dead. Three minutes: comfortably longer than the slowest honest
  webhook (grounding is a model call, a voice note is a download plus a
  transcription), and short enough that a crashed attempt costs a delay rather
  than an update.
- **The outcome window** — how long a _completed_ update is remembered, so a
  late redelivery is recognised. An hour, and written only by an attempt that
  finished.

So `telegram/update-claim-store.ts` holds a state rather than a bare key:
`in-flight:<token>` under the lease, replaced by `done` under the window on
success. The release is now an optimisation — it makes the retry immediate
instead of making it possible. Three more decisions carry it:

- **It fails open**, which is the opposite of the auth claim beside it. That one
  fails closed because a claim it cannot make means an unverified credential.
  Here a 503 makes Telegram retry, the retry meets the same cold store and 503s
  again, and a gate meant to stop a member being answered twice stops them being
  answered at all. A duplicate is bounded and occasional; silence is total.
- **`in-flight` is answered non-2xx, not 2xx.** Another attempt holding the
  lease is not the same as the work being done. Answering 2xx would tell
  Telegram the update is handled while the only attempt that can handle it may
  still fail.
- **Abandon is a compare-and-delete.** An attempt that stalled past its lease
  must not free the id under whoever claimed it next, or a third delivery
  processes alongside them — the exact double-write the gate exists to prevent.

Read-and-claim is one Lua step, because a `GET` then a `SET` from the client
lets two replicas both read "absent" and both claim. That is asserted against a
**real Redis** (`update-claim-store.integration.spec.ts`) rather than a fake
that recognises the script and does what the script is supposed to do — which
would assert nothing about the script. Eight concurrent `begin`s across two
store instances yield exactly one `claimed`.

Reverting the split lifetimes fails a test; reverting the compare-and-delete
fails one in each suite; reverting the duplicate skip fails two.

### Which found the last §7 variable, and it was the largest one

The gate is per-container without a shared Redis, and
`OSHUN_BFF_IDEMPOTENCY_REDIS_URL` was set only by the Hetzner stack. Chasing
that one line to ECS turned up what the variable actually gates, which is not
what it is named after. `server.ts` declares these stores in a fail-closed state
and only replaces them when that URL is present, so on ECS:

- **The Telegram webhook rate limiter was `unavailableTelegramRateLimiter`,
  which throws.** The route turns that into a 503 — so **every Telegram update
  on that plane was refused.** Telegram retried each one, backed off, and gave
  up. The entire bot surface, everything §4 through §9 built, was dead on ECS,
  and from the outside a bot that 503s every update is indistinguishable from a
  bot nobody has messaged. This is the fifth variable in the §7 family and the
  only one that took the whole surface with it.
- The initData replay store was fail-closed too, so Mini App session issuance
  503s alongside it.
- SSO and LTI login state, fail-closed by design.

Now set, as a **secret** rather than an environment variable, because the URL
carries the ElastiCache AUTH token: the redis module composes the full
connection string beside the token that goes in it and exposes it as an ECS
`valueFrom` JSON-key reference, so the task definition never renders the
credential and the application never assembles the URL from parts.

Two details that would each have produced a variable that is set and does not
work — the §7 failure mode exactly, one step further along:

- The scheme is **`rediss://`**, not `redis://`. The replication group sets
  `transit_encryption_enabled`, so a plaintext client is refused at the
  handshake.
- The task execution role's grant covers `oshun-<env>/*`, and the Redis secret
  is named `oshun-<env>-redis/auth`, which is **outside it**. A task definition
  may only reference a secret its execution role can read; without widening it
  the task fails to start with an opaque `ResourceInitializationError`. Listed
  as an explicit `additional_secret_arns` entry rather than a wider wildcard, so
  what that role can read stays enumerable.

`terraform validate` passes. What it cannot check is the part above that matters
— that the URL is reachable and the role may read it — so both are written down
where the next person changing this will meet them.

### The database URL had never been wired either, and it has three more of these

Same sweep, one resource over: the RDS module mints an admin secret and **no
service referenced it**, so nothing on ECS had database credentials at all. The
BFF reads `OSHUN_ADMIN_DATABASE_URL` → `OSHUN_V1_DATABASE_URL` → `DATABASE_URL`
in that order, plus the domain libraries' own names, and the Hetzner stack is
the working proof of which set is needed — all pointed at one database.

Wired the same way, as a JSON-key secret reference. Three traps, and every one
of them produces a URL that terraform applies happily:

- **The password is not URL-safe.** `override_special` on the generated password
  includes `#`, `?`, `&` and `%` — `#` truncates the URL at a fragment, `?`
  opens the query, `%` starts an escape sequence that is then invalid.
  Interpolated raw it does not merely mis-parse: fed to the real
  `pg-connection-string`, a password from that charset **throws**. `urlencode`
  is what makes a credential survive being put in a URL, and the round trip is
  verified rather than assumed — Terraform's own `urlencode` output parsed by
  the installed `pg`, giving back the exact password and the right host.
- **TLS is mandatory and `require` is the wrong spelling.** The parameter group
  sets `rds.force_ssl = 1`, so a plaintext connection is refused. But
  `sslmode=require` maps to `ssl: {}` in `pg-connection-string`, which verifies
  against Node's default CA bundle — and the Amazon RDS CA is not in it, so it
  would fail at the handshake instead. `sslmode=no-verify` is node-postgres's
  "encrypt, do not verify", which is what `libs/shared/database` already does
  (`rejectUnauthorized: false`). The traffic is encrypted; the **server is not
  authenticated** until the RDS CA bundle ships in the image and this becomes
  `verify-full`. That is a real remaining gap, not a finished job — it is
  written into the module beside the URL, along with the note that libpq has no
  `no-verify` and `psql` needs `require`.
- **`kms:Decrypt` as well as the secret ARN.** The Postgres secret is encrypted
  under the RDS module's own customer-managed key, so the ARN grant alone is not
  enough — and a missing key grant fails the task with the _same_ opaque
  `ResourceInitializationError` as a missing secret grant, which is why the two
  are easy to confuse for each other.

`psyche-*` is still deliberately unwired: it wants its own `psyche` database
over `postgresql+asyncpg://` and this instance provisions only `oshun`, so
pointing it here would connect it to the wrong schema rather than to nothing.

### `metis-*` and `content-service` did not want a database URL — they wanted the thing behind it

Asked to wire those two as well, the answer turned out to be that **neither
reads a database URL at all**, and setting one would have manufactured the §7
defect in reverse: a variable every deploy sets and nothing reads, plus the
false impression that these services are backed by a database.

- `content-service` reads `CONTENT_SERVICE_DATA_DIR` and three others; its
  library reads no environment at all and imports no `pg`.
- `metis-api-gateway` is a proxy — port, host, `METIS_PYTHON_BACKEND_URL`, JWT,
  CORS, rate limits, cache, WS, timeout.
- `metis-worker` reads `METIS_WORKER_*`; its queue is an explicitly in-memory
  priority queue.

What each actually needed was the thing it could not reach:

**`content-service` was durable onto storage that is discarded.**
`createDurableContentService` is a file-per-run JSON store under
`CONTENT_SERVICE_DATA_DIR`, defaulting to `./.content-service-data`. Nothing set
it, the `ecs-service` module defined **no volumes at all**, and a Fargate task's
filesystem dies with the task — so every deploy and every scale-in silently
discarded everything it had written, and two replicas each believed a different
set of runs existed. Now an encrypted EFS filesystem with mount targets in every
private subnet and an access point pinning uid/gid 1001 (the container's own
user, from `Dockerfile.node`) to its own subtree. EFS rather than S3 or RDS
because it is the only one of the three that needs no change to the store.

Two things that would each have left it mounting nothing: the mount is
authorised as the **task** role, not the execution role — a distinction that
costs a task which never goes healthy, with no permissions error anywhere
obvious — and transit encryption is on, because the mount crosses the VPC
network like any other traffic.

**`metis-api-gateway` proxied to a backend that was deployed nowhere.**
`METIS_PYTHON_BACKEND_URL` was unset, so it fell back to `http://localhost:8000`
— its own container — and every proxied request failed, which is the gateway's
entire job. The service that answers it, `apps/metis/service`, was **absent from
the ECS services map**: a FastAPI app on gunicorn with its own Dockerfile,
`EXPOSE 8000` and `/health`, that nothing deployed. It is a service now,
internal-only, and the gateway reaches it over the Cloud Map record every
service already gets.

That service is also the one thing in the repo that genuinely reads
`METIS_DATABASE_URL` — and it needs a **different URL string from the BFF's**,
which is the sharpest version of this whole family:

> SQLAlchemy's asyncpg dialect passes query parameters straight through to
> `asyncpg.connect()`, and asyncpg has no `sslmode` argument — it takes `ssl`.
> Handing it the BFF's `?sslmode=no-verify` does not degrade: it raises
> `TypeError: connect() got an unexpected keyword argument 'sslmode'` on the
> first connection. Verified against the installed sqlalchemy 2.0 / asyncpg
> 0.31, not assumed. So the secret carries a second field, `url_asyncpg`, with
> `?ssl=require` — the same posture, spelled the way that client understands.

And one thing found on the way that was already broken: the Python Dockerfiles
are written to be built from their own directory (`COPY pyproject.toml ./`,
`COPY src ./src`), while the workflow built every service with `context: .`.
Under a repo-root context those paths are not there, so the build fails on the
first `COPY` — which was true of **every psyche service** already. The context
is now per-service.

Left undone and worth naming: Alembic derives its URL by stripping `+asyncpg`
and keeps the query string, and psycopg2 wants `sslmode=require` rather than
`ssl=require`. Nothing runs migrations at boot, so this does not block the
service starting — but running them needs a third spelling, and that is written
into the RDS module rather than left to be discovered.

**"remind me to take the bread out at 19:00" now parses.** The head-anchored
parsers stay exactly as they were and run first, so every request that worked
before resolves identically — `/remind at 7pm to read the passage at noon` still
means 7pm. Only when both fail does a trailing pass run the **same** parsers
over the suffix, longest first, so "tomorrow at 8" is read whole rather than as
a bare 8 with "tomorrow at" left in the label.

Reusing the parsers rather than restating their rules is what makes the tail
search safe: the rule that rejects a bare number with no `at`, no meridiem and
no minutes is what keeps "remind me to read chapter 3" from becoming an 03:00
reminder to "read chapter", and a second copy of that rule is a second thing to
get wrong. The search is bounded to the last five words, because a time
expression is at most four ("tomorrow at 8:30 pm") and a number in the middle of
a sentence should never be a candidate.

---

## 10. The bot makes promises, and one deployment could never keep any of them (2026-08-08, sixth pass)

Every pass so far has asked a question about an update that **arrived**. Do the
ports reach anything (§4), is the entry point wired (§6), does the deployment
run it (§7), does Telegram accept what we send (§8), does the bot understand
what Telegram sends (§9). All five are request and response: the member writes,
the bot answers, and the answer is the whole event.

`/remind` is not that shape. Its reply is a **promise** — "Set: 12:45, take the
bread out" — and the thing promised happens later, with no update to trigger it,
in a different process tick, through code no inbound test ever reaches. That
half of the surface had never been driven end to end, and it has the same single
cause the others did, wearing new clothes: **every reminder in this surface is
one we wrote.** The specs that exist all construct a `ScheduledReminder` by hand
and run the cycle on it, exactly as §9 found the router had only ever been
handed updates its own authors composed.

`promise-kept.spec.ts` makes the trip nothing had made: a member's real
`/remind` message through the real webhook, the real port, the real schedule,
then the real cycle at the promised instant, asserting what reached the Bot API.
It passed on the first run. The code was right. What was missing was anything to
run it.

### Nothing on the ECS plane ran the cycle, and that was written down as a decision

`startReminderWorker` in `@oshun/messaging-channels` has zero non-test callers.
`runScheduledReminderCycle` runs from exactly two places: the
`/v1/reminders/run-cycle` route, and the opt-in in-process worker gated on
`OSHUN_REMINDER_WORKER_INTERVAL_MS`. The Hetzner stack sets that variable.
`terraform-v1` deliberately did not, with a comment explaining that the cycle
read-modify-writes reminder state with no cross-process fence while `bff` scales
to four tasks.

That comment was a correct reading of the code and the wrong place to stop. Its
own last sentence — "scheduled reminders (including every `/remind` set in
Telegram) do not deliver on this hosting plane" — describes a member being told
a time by a bot that cannot keep it. A deployment note had absorbed a product
outage, which is what makes this the same family as §7's five settings: written
down, true, and therefore invisible.

### The sentence that made the unsafe fix look safe

`server.ts` said the opposite of the terraform comment, four lines from the same
variable:

> the cycle is idempotent on deliveredIds (duplicate instances waste work, never
> double-send)

It is not. `deliveredIds` lives in the clone each cycle takes of **this
process's** state, so two replicas ticking together both read a reminder as
undelivered and both send it, and the later snapshot write drops the delivered
set that would have stopped the next one. Two claims about the same behaviour
sat in the repo disagreeing, and the reassuring one was the one a person
enabling this would have read.

Settled by test rather than by argument: `promise-kept.spec.ts` loads two
independent copies of the reminders module — two module graphs, which is what
two processes are — schedules one reminder in each, runs both cycles, and counts
two `sendMessage` calls. The comment is corrected to say what the test shows.

### The fence

`reminders/cycle-lease.ts` is a single expiring key, taken before a tick and
dropped after it. Three decisions carry it and each is the opposite of an
obvious alternative:

- **It fails closed**, which is the opposite of the update-claim store §9 built
  beside it. That one fails open because a refused claim loses a member's
  inbound message forever. Here a skipped tick costs a delay — nothing is
  consumed, the next tick sends the same reminder — while a wrongly granted
  lease costs a duplicate message to a real person. Silence for one interval or
  two notifications, and the reminder survives either way.
- **It expires and is never renewed.** A heartbeat extending the lease while a
  cycle ran would look safer and be worse: a process wedged mid-cycle would
  renew forever and no replica could take over, so delivery would stop for
  everyone until someone noticed. Expiry makes a dead holder self-healing, which
  is the same trade `TELEGRAM_UPDATE_ATTEMPT_LEASE_MS` makes.
- **Release is compare-and-delete**, so a holder whose lease already expired
  cannot delete its successor's — which would open the fence at exactly the
  moment a slow cycle proved it was needed.

Producing runs **inside** the lease with the cycle it feeds, because producing
is itself a write to the shared schedule; leaving it outside would reintroduce
across replicas the concurrent mutation the lease exists to prevent.

The lease needs the Redis §9 wired, so this is one of that pass's dividends: the
"leader election" the terraform comment said did not exist had become buildable
one commit earlier. `OSHUN_REMINDER_WORKER_INTERVAL_MS` is now set on ECS, and
the boot log names which lease is in use rather than leaving it to be assumed.

### And then the two settings that plane had never had at all

The parity sweep that came out of this found something larger than the thing it
was looking for. **`OSHUN_TELEGRAM_BOT_TOKEN` and
`OSHUN_TELEGRAM_WEBHOOK_SECRET` were configured by neither form of the ECS
stack** — not as variables, not as secrets, nowhere. The Hetzner stack takes
both from its per-stack `.env`; ECS had no equivalent, and both halves fail
closed:

- `requireWebhookSecret` returns false when the expected secret is unset, so the
  route answers **401 to every update**. That is correct as security — the
  header is the only thing separating a real update from anyone's POST — and it
  means the bot on that plane replied to nobody.
- With no token there is no outbound call to make: no reply, no reminder. And
  `botToken()` returns null in a production runtime rather than the
  publicly-known dev constant, so Mini App session issuance fails closed too.

§9 found the Redis fault that had "taken the whole surface with it" on ECS and
fixed it. This was a **second, independent cause of the same total silence,
sitting directly behind the first** — and it would have been found the moment
anyone looked at that plane after the Redis fix, except that the only way to
look is to read a `.tf` file next to a `.yml` file and notice an absence.

Terraform now owns the container and the grant and never the values: a bot token
comes from BotFather and a webhook secret is chosen by whoever registers the
webhook, so both arrive out of band exactly like the registration step itself.
The initial version is **empty strings rather than placeholder text**, because
both readers treat empty as unset — so an unpopulated secret behaves exactly as
the state before it existed, dead and fail-closed. A placeholder would instead
be a credential that is present and wrong, which is the failure this same file
already warns about: configured and wrong is much harder to see than configured
and absent. The secret sits under `oshun-<env>/`, which is the execution role's
default grant, so unlike Redis and Postgres it needs no `additional_secret_arns`
entry.

### One transient failure lost a reminder permanently

Found by the probe once it could see the whole trip. In-app delivery runs first
in the cycle and marked the reminder delivered outright, so a single 500 from
Telegram ended it: the id was in `deliveredIds`, the next cycle skipped it, the
provider recovered a minute later, and the member never heard anything. The
reply had already named a time.

One set was serving two facts. `alreadyInAppDelivered` separates them, so a
retry sends again without writing a second identical copy into the inbox. Only
`provider-error` is retried — every other reason (`missing-config`,
`missing-recipient`, `unsupported-channel`, `suppressed`, `policy-blocked`) is a
fact about this deployment or this member that another attempt cannot change,
and retrying those would turn a clean "not configured" into a loop that never
delivers.

The bound is **lateness, not attempts**. "Take the bread out at 19:00" delivered
at 23:00 is not a reminder that finally arrived, it is a message about a burnt
loaf, so the retry window is an hour past due — long enough to cross any outage
a retry could help with, short enough that nothing arrives at an hour that would
confuse the person who asked for it. This one is not Telegram-specific: every
external channel had it, and every one of them is fixed by the same change.

### The gate

`deployment-parity.spec.ts` reads `infra/terraform-v1/main.tf`, the Hetzner
compose file and its `.env.example`, and asserts that the settings the Telegram
surface depends on are configured in **both** planes. It carries what each one
breaks, because a bare name tells the next person nothing about why it may not
be dropped.

It would have caught §7's five, §9's Redis URL, and both of this pass's secrets.
Every one of those was invisible to every test in the repo for the same reason:
no test had ever read a deployment. The property is not that the values are
correct — nothing here can know that — but the far weaker and still sufficient
one that the two planes **agree about which settings exist**, which is
mechanically checkable and is the entire failure mode.

Its own load-bearing detail is that it strips comment lines before looking for
an assignment. Both files discuss these variables at length in prose, which is
how the reasoning behind each survives, so a plain substring search would have
found every name in both files and passed forever — including for the bot token
that neither plane set. A gate that cannot tell a setting from a mention of one
is the vacuous assertion §9 found in its own probe, at deployment scale.

### That each gate fails without its fix

Reverted in turn, as every pass here does: the lease check in the worker → the
"runs no cycle on a tick it did not win" assertion fails; the compare-and-delete
in the Lua → the stale-holder assertion fails **on live Redis**; the retry
predicate → the member loses their reminder again; the two terraform secrets →
two parity assertions fail.

The Lua revert is worth its own line, because the unit suite passed against the
broken script. Its fake implements compare-and-delete by hand, so the assertion
was about the fake rather than about the code — precisely the thing
`update-claim-store.integration.spec.ts` was written to avoid. That assertion is
now stated as what a unit test can honestly prove (the release presents the
holder's token) and the property itself is proven only on real Redis.

### What this pass saw and did not change

- **`startReminderWorker` in the messaging library still has no callers.** It is
  real, tested code — a second implementation of the job the BFF now does its
  own way, over a different store interface. Not a stub and not wrong; simply
  the road not taken, and worth deleting or adopting deliberately rather than as
  a side effect of this pass.
- **The reminder schedule is never pruned.** Nothing removes a delivered
  reminder from `scheduledReminders`, so every cycle re-plans every reminder
  ever set and `deliveredIds` grows without bound. Correct today and a real
  ceiling: it is a cost that rises with total reminders rather than with due
  ones.
- **A cycle that outlives its lease can be joined by the next tick.** The TTL is
  three intervals with a two-minute floor, which is far past a normal cycle, and
  the alternative — renewal — is the failure mode described above. Worth knowing
  the window exists.

---

## 11. The bot knew what the member said, and never knew when they were (2026-08-08, seventh pass)

Every pass so far has asked a question about something an update **carried**. Do
the ports reach anything (§4), is the entry point wired (§6), does the
deployment run it (§7), does Telegram accept what we send (§8), does the bot
understand what Telegram sends (§9), does the promise get kept (§10). Six
questions about bytes that exist — the last two are the two halves of one
conversation, and all six are answerable by reading harder.

"At 7pm" is not carried by anything. It is a wall-clock reading, and it is not a
moment until you know which clock the member reads. Telegram puts that in no
field of any update it will ever send: `Update.message` has a `date`, a `from`
and a `chat`; `initData` has an id, a name, a `language_code` and a signature.
There is no zone and no offset anywhere in the platform. So this fact has to be
supplied or admitted as missing, and the surface did neither.

`reminder-requests.ts` says so at the top of the file, and says it exactly
right:

> What it deliberately does NOT do is produce an instant. "7pm" is not a moment
> until you know which zone the member is in, and this library has no way to
> know that — guessing UTC would set a Ghanaian member's reminder correctly and
> a Californian's eight hours wrong. So the intent stays as the member expressed
> it … and the port, which can resolve the member's zone, turns it into an
> instant.

The split is correct and the other half was never built. `zoneFor` in the port
read `quietHours.timezone` straight off the member's notification preferences,
which is right for someone who has used the web shell — the browser writes a
real zone there, in **seven** places — and for a Telegram-only member returns
`createDefaultPreferences()`, whose zone is
`Intl.DateTimeFormat().resolvedOptions().timeZone` evaluated **inside the
container**. The library refused to guess UTC and handed the job to a party that
had nothing to resolve with, so the guess happened one module later and came out
as the server's wall clock. On ECS that is UTC, and the Californian is eight
hours wrong exactly as the comment predicted.

The Mini App is the sharpest form of it. It runs in a browser that knows the
answer, calls the BFF on every surface open, and reported the zone **nowhere** —
while the web shell reads the same one-line expression in seven components. The
signal was free, present, and thrown away.

`member-local-time.spec.ts` plants a member seven hours from the server and
drives the surface as they would use it. It reported **12 of 14 failing**
against shipping code.

### The reply confirmed the wrong instant, convincingly

This is why nothing caught it. The member's confirmation is rendered with
`formatScheduledFor(at, zone)` — the same zone the reminder was mis-scheduled in
— so the wrong instant is described in the wrong clock and the two agree
perfectly. A member in Los Angeles at 19:30 on Thursday who sent
`/remind at 21:00 take the bread out` was answered:

> Reminder set for Fri 22 May, 21:00.

Every character of that is what the bot believed. The hour is the hour they
asked for. The only tell is the day, and a member reading "Fri" for a reminder
they meant for tonight is far more likely to assume the bot rounded oddly than
to suspect it is standing on a different meridian. Seventeen hours later it
fires, and by then "take the bread out" is a message about a burnt loaf — §10's
own sentence, arrived at from the other side.

The probe nearly shipped that assertion vacuously. It first checked
`toContain('21')`, which matches the `21:00` in the server's own wrong answer
and passes against the defect. It now names the whole date. That is §9's
`mock.calls[0]?.[0]` again, one pass later, and it is worth expecting: a probe
written to catch a wrong TIME will keep accidentally matching the right one.

### `/timezone`, and the two places a zone can honestly come from

A fact the platform will not send has exactly two honest sources, and this pass
wires both.

**The member says it.** `/timezone Europe/Lisbon` goes through the effects port
like every other write, published in `setMyCommands` rather than hidden in
`/help` — a member has no reason to guess that a chat app does not already know
their clock, so leaving it undiscoverable would leave the fact unsupplied. It is
a personal command, refused in groups: it rewrites one member's own clock, and a
shared chat is not where anyone should be able to do that.

It takes what people type, not only what `Intl` takes. `UTC+5:30`, `GMT-8`,
`+0530` are all rejected outright by `Intl.DateTimeFormat`, and all of them are
how a phone's clock screen shows the same thing, so they are normalised to the
`±HH:MM` form. Offsets are bounded to the real civil range, −12:00 to +14:00,
because +19 is a typo and Kiritimati genuinely is +14. And a bare offset is
**told to the member as one**: it is a fact about today rather than about the
year, so it will not follow daylight saving, and the moment they choose it is
the only moment they can act on that. By October, a reminder an hour out looks
like a broken bot.

**The Mini App reports it.** `X-Telegram-Timezone` rides every Mini App request
beside the signed `initData`. Sent on every request rather than once at session
issuance, so a member who flies somewhere is right again on their next tap. It
is unsigned and therefore never trusted for identity: it is applied only to the
member the signed credential already established, so the worst a forged header
can do is print that member's own times in the wrong clock.

Both write to **two** places — the Telegram profile partition, where it carries
its provenance and is covered by erasure and export, and the shared notification
preferences, which is the `/quiet` precedent. A zone that lived only on the
Telegram side would leave the quiet-hours check and the digest planner running
on the container's clock for the same member who had just said otherwise.

### The deduction that keeps it from interrogating people who already told us

A member who linked from the web already has a real zone stored. Asking them
again would be its own defect, so `resolveMemberZone` reads their preferences —
but it may only believe them under one condition: **the stored zone is not the
container's**.

That is a deduction rather than a guess, and the direction is what makes it
sound. The default is stamped with `serverZone()`, so a stored value that is
_not_ that string cannot have come from the default and was written by someone —
a browser, or a consumer profile. A stored value that _is_ that string is
indistinguishable from never having been set, and is treated as unknown even
though it may well be right.

The cost of that reading is one needless question to members genuinely standing
in the container's own zone. The cost of the other reading is a reminder at the
wrong hour, silently, for everybody else. Both directions are pinned by tests,
because the failure modes are opposite and each looks reasonable alone.

### The refusal, and what it deliberately does not refuse

With no zone, a wall-clock reminder is now refused, and the refusal names the
control that fixes it — the lesson `/tonight` learned in §9, when its decline
pointed at a Nyx setting that did not exist.

A relative reminder is **not** refused. "In 45 minutes" is an offset from now,
and now is the same instant everywhere, so the absence of a zone cannot make it
wrong — only unlabelled, which the ` UTC` suffix fixes. Refusing it too would
have been the tidier rule and would have punished the member for a fact their
request does not depend on.

### Every "today" on this surface was the container's

The reminder was the loudest case and not the only one. Three more, all the same
sentence with a different noun:

- **`/checkin` filed against `now.toISOString().slice(0, 10)`.** For a member
  seven hours west that is the next day for the whole of their evening, so an
  18:00 check-in was filed under tomorrow while a 09:00 one was filed correctly
  — and a member who keeps a habit in the morning and then again the following
  evening produces rows on the 7th and the 9th. The fold walks back from today
  and an unmarked day breaks the run, so the 8th became a miss they had not had
  and a two-day streak was reported as one. That is worse than showing no
  streak: it is a claim about their practice that their own memory contradicts,
  and it is written into the check-in table where the web shell reads it too.
- **`/passage` rotated on the UTC day number.** Today's passage turned over at
  five in the afternoon in Los Angeles and at one in the afternoon in Auckland —
  the daily text changing in the middle of the day it is named after. The
  implementation took no member id, with a comment explaining that the passage
  is the same text for everyone so there was no per-member choice to honour. The
  text is shared; the DAY is not, and that reasoning is exactly what made
  dropping the member look safe. The port had been passing one all along.
- **`/tonight` printed a sky in UTC to a member whose coordinates we hold.** An
  observer in Los Angeles was told their observation window ran `02:52 → 12:45`.
  Never a fabrication — every string carried "(UTC)" — but `tonight-card.ts`
  said _"Times are UTC (the surface localises)"_ and **no surface did**. The web
  page, the in-process adapter and the Telegram rooms port all render those
  strings verbatim. A clause describing work with no implementor is the same
  shape as a port implemented only by test doubles, and it survived because it
  was honest: nobody was lying, so nobody was looking. Localisation now lives
  beside the arithmetic that produces the instants, the card names the zone it
  used, and the events are still sorted on the INSTANT — in a zone whose night
  crosses midnight, ordering on the rendered clock string prints the night
  backwards.

`/settings` reported quiet hours, voice replies, delivery and the linked
account, and not the one setting whose wrong value is invisible. Every time the
bot prints is rendered _in_ the zone, so "19:00" cannot be checked against
anything. It is shown now, and an unknown one says so and names `/timezone`,
because "not set" alone leaves a member with a fact and nothing to do about it.

### The gate

`member-local-time.spec.ts` drives the real webhook for a member in
`America/Los_Angeles` at an instant where their calendar date and the server's
disagree. That last part is the load-bearing detail: the existing specs all run
at midday UTC, where the two agree, and every one of them would pass against a
surface that ignores the zone completely. A probe for this defect that picks a
convenient hour proves nothing.

Its base instant is 2026-05-22T02:30Z, which is the evening of the 21st for the
member, and the assertions are on the resolved INSTANT — the cycle is run at
21:00 Los Angeles and asked whether anything was sent — never on the rendered
string, which is wrong in the same direction as the schedule and would agree
with it.

`member-zone.spec.ts` pins the reasoning underneath: what counts as a zone
someone typed, and when a stored preference may be believed.

### That each gate fails without its fix

Eleven reverts, each run against its own gate:

| reverted                              | what fails                                                                     |
| ------------------------------------- | ------------------------------------------------------------------------------ |
| `/timezone` out of the command set    | 9 of 14 — the fact becomes unsupplied again                                    |
| the wall-clock refusal                | the bot schedules on the container's hour                                      |
| `resolveMemberZone` → raw preferences | the reminder fires seventeen hours late                                        |
| `memberToday` → `toISOString()`       | an evening check-in files under tomorrow, and a two-day run is reported as one |
| the passage day key → UTC             | two passages inside one of the member's days                                   |
| the `/settings` zone line             | the clock is unobservable again                                                |
| the card's zone → UTC                 | the sky prints on the prime meridian                                           |
| the deduction, off                    | a member who told the web is asked again                                       |
| the deduction, unconditional          | the container's zone is believed as the member's                               |
| the Mini App header                   | the free, accurate signal is thrown away again                                 |
| the erasure line                      | a zone survives a profile deletion                                             |

The erasure revert is the one worth its own line, because it is §10's trap
again: the first version of that test asserted on the READ and passed against
the broken code. `deleteProfileForSubject` adds the subject to a deletion fence
that makes every profile read null regardless of what is still written down, so
the assertion was about the fence rather than about the erasure. The member in
that test now keeps a capture — a separate partition with its own fence, so the
state entry survives — and the assertion is on the **snapshot**, which is where
the data would actually have remained.

One small thing checked rather than assumed: `Intl` does **not** fold
tz-database aliases. Since ES2024 it preserves the identifier it was given, so
`Asia/Calcutta` stays itself rather than becoming `Asia/Kolkata`. That is a
difference in the stored label only — both format to identical instants, which
is asserted — but the opposite belief would have been a quiet way to give two
members in one city two different zones.

### What this pass saw and did not change

- **The web shell renders `observationWindow` in UTC too.** The card can now be
  built in a zone and no web caller passes one, so `nyx/tonight` on the web is
  still the prime meridian's clock — with the label that says so. The browser
  there knows its zone the same way the Mini App does; this pass fixed the
  surface it was auditing and left a one-argument change for whoever owns that
  page.
- **`language_code` is the other field Telegram sends and nothing reads.**
  Unlike the zone it IS in every update, and the bot answers in English
  regardless. That is a product decision about localisation rather than a
  defect, but it is the same shape one step further out: a fact the platform
  hands us that no consumer takes.
- **A stated zone is never re-confirmed.** A member who moves permanently and
  never opens the Mini App keeps the zone they typed. The Mini App path
  self-heals on the next tap and the chat path does not, which is a real gap for
  chat-only members and a much smaller one than the container's clock.
- **Quiet hours still carry their own `startTime`/`endTime` with no way to set
  them from Telegram.** `/quiet` is on/off only, so a member whose zone is now
  right still gets the default 22:00–07:00 window. Correct, and narrower than
  the control they would want.
