Oshun Platform · Reference & analysis

Lilith on Telegram — V1.0 buildout plan

The Telegram surface is not a stub.

11sections64 minread2tables

On this page

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 — 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 " 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 " 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 (messageedited_messagecallback_queryinline_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.

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 begins 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_URLOSHUN_V1_DATABASE_URLDATABASE_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
memberTodaytoISOString() 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.