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#
- G1 — close the inert entry point. Small, and it is a correctness bug.
- G2 + G4 — the command surface with inline keyboards, message editing, and Web App buttons. This is the bulk of the member-visible win.
- G3 — reminders originating from and returning to Telegram.
- G5 — Mini App parity for Tara and library.
- 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.
runReminderCyclecomputeddeliverAtIsoand 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.
deliverDispatchedMessagereturnedunsupported-channelfortelegram-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#
registerBotCommandssaid 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 openedtoday— the Tara ritual timer — because the Mini App had no library surface, and the surface router silently falls back totodayfor 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.tfdeclared atelegram-botECS service withautoscaling_max = 1and the comment "Long-poll worker … so the poller is always up". Aterraform applywould have provisioned a task that crash-loops forever. Removed, with the reason in its place..github/workflows/deploy-ecs.ymlofferedtelegram-botas a human-selectable deploy target and included it inALL_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.
setMyCommandsandsetChatMenuButtonwere real, tested bydiscoverability.spec.ts, and called only fromcreateOshunGrammyBot. 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 intelegram/command-surface.ts, the BFF publishes it afterlisten, and the kit delegates to the same function. - Presence never reached the live path. The
typingaction before grounding was emitted only by the grammY app. On the deployed path a member asked a question and watched a silent chat. /upgradeanswered "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, behindTelegramUpgradePort, 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.
paramsis a Promise in this Next major and the page read it synchronously, so the slug wasundefinedon every request and the router's silenttodayfallback 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, becauselibrary-surface.spec.tstested 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,initDataheader 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
setStatuscalls 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 bybody— 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/remindsat on the schedule and never sent. G3 was inert at the last hop. Now on in the Hetzner stack, which runs exactly onebffcontainer. Deliberately not on ECS: the cycle read-modify-writes reminder state with no cross-process fence andbffthere scales to four tasks, so it needs a single-replica owner or an external cron on/v1/reminders/run-cyclefirst — written intoinfra/terraform-v1/main.tfrather than left to be rediscovered.- The webhook itself. Registering it was a
curlin 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 isinfra/hetzner/scripts/telegram-webhook.sh register <stack>. OSHUN_WEB_APP_URL/OSHUN_BILLING_URL— read byresolveBillingUrl, set by no deployment and declared in no.env.example. So/upgradeansweredunavailableeverywhere: §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 fromPLATFORM_DOMAIN(/billing/cryptois a real page in@oshun/web).OSHUN_PUBLIC_WEB_URL— unset, so Sophia's citation links in Telegram fell back to a literalhttps://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-webis the repo's other static export and its e2e has the same test-server mismatch. Nothing deploys it today, and theSTATIC_ROUTINGdefault is already correct for it.deploy-ecs.ymlbakes the Mini App'sNEXT_PUBLIC_OSHUN_BFF_URLfrom 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:
429carriesretry_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.403is 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.externalFailedis that field;failedkeeps 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.
answerCallbackQueryis clamped, not split, at 200 characters. A toast is not content; anything needing more room belongs in a follow-up message, whichfollowUpsalready 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_querywas removed fromallowed_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-flightis 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://, notredis://. The replication group setstransit_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 namedoshun-<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 opaqueResourceInitializationError. Listed as an explicitadditional_secret_arnsentry 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_specialon 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 realpg-connection-string, a password from that charset throws.urlencodeis what makes a credential survive being put in a URL, and the round trip is verified rather than assumed — Terraform's ownurlencodeoutput parsed by the installedpg, giving back the exact password and the right host. - TLS is mandatory and
requireis the wrong spelling. The parameter group setsrds.force_ssl = 1, so a plaintext connection is refused. Butsslmode=requiremaps tossl: {}inpg-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-verifyis node-postgres's "encrypt, do not verify", which is whatlibs/shared/databasealready does (rejectUnauthorized: false). The traffic is encrypted; the server is not authenticated until the RDS CA bundle ships in the image and this becomesverify-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 nono-verifyandpsqlneedsrequire. kms:Decryptas 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 opaqueResourceInitializationErroras 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-servicereadsCONTENT_SERVICE_DATA_DIRand three others; its library reads no environment at all and imports nopg.metis-api-gatewayis a proxy — port, host,METIS_PYTHON_BACKEND_URL, JWT, CORS, rate limits, cache, WS, timeout.metis-workerreadsMETIS_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 nosslmodeargument — it takesssl. Handing it the BFF's?sslmode=no-verifydoes not degrade: it raisesTypeError: 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_MSmakes. - 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:
requireWebhookSecretreturns 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#
startReminderWorkerin 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 anddeliveredIdsgrows 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:
/checkinfiled againstnow.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./passagerotated 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./tonightprinted a sky in UTC to a member whose coordinates we hold. An observer in Los Angeles was told their observation window ran02:52 → 12:45. Never a fabrication — every string carried "(UTC)" — buttonight-card.tssaid "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
observationWindowin UTC too. The card can now be built in a zone and no web caller passes one, sonyx/tonighton 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_codeis 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/endTimewith no way to set them from Telegram./quietis 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.