ADR 0214 — A store contract ships with its check, and secrets are masked by shape
Context
The first consuming bot moved onto the primitives of ADR 0213 and reported where it still had to build, switch off or work around them:
- The update store was written again, over Prisma, about 150 lines with a
raw
UPDATE … RETURNINGfor the claim.TelegramUpdateStoreis five conditional statements, and each is easy to write almost right: a claim read and then written in two statements passes every test with one process and runs one update twice in production. The interface said what the rules were; nothing let an implementation outside this repository show it kept them. - A retry policy saw grammY’s wrapper.
bot.handleUpdatethrows aBotErroraround what the middleware threw, so a policy asking “is this my terminal error?” never matched and retried it. - The dedupe window was reachable only through the channel, switched off with parameters around it by a bot that sends alerts itself.
- The journal leaked by shape. Measured on
createJsonLogger: a bot token in a request URL, a password in a database address and a?token=were written as they were — no key name marks a URL — and an error’s own fields, aTelegramBotApiError’serror_codeandretry_after, were dropped. The bot masked tokens itself by rebuilding the exported pattern as global. The report asked for a pino-compatible mode; the line already is pino’s shape, and the bot’s readers match onlymsgand fields, so a second line format would have answered the wrong question. - A user arrived in two conventions: camelCase from initData, snake_case
from
ctx.from, and oneensureAccountneeded a translator.
Decision
A contract an application implements ships with its check.
checkTelegramUpdateStore(make) runs every rule on a fresh store, races eight
claims against each other, and resolves with the violations. It depends on no
test framework, so an application calls it from its own tests; the memory,
SQLite and Postgres stores pass the same check in this repository, and a
deliberately careless store is shown to fail it. postgresTelegramUpdateStore
takes one query(text, parameters) returning rows — Bun, Prisma and pg each
give it in one line — every statement conditional and returning rows, with
explicit casts, because drivers disagree on how a number is sent and how a
bigint comes back. It runs against a real server in the test:postgres-stores
lane, beside the agent store.
The intake hands the handler’s own error on. A BotError, recognised by
shape without importing grammY, is unwrapped before retry, onFailure and
the stored error see it.
A piece a primitive is built from is exported when a consumer needs it
alone. createTelegramOperatorDedupe is the window the channel uses, not a
copy of it.
Secrets are masked by shape in redact, for every sink. The secret half of
a bot token — the bot’s id stays, it is public and tells two bots apart — the
password of an address, and a query parameter named as a secret. An error keeps
its own fields, masked by the same rules; a class instance held by one (grammY’s
BotError.ctx) is named, not written, so an error cannot push its line past the
bound and lose its stack. TELEGRAM_BOT_TOKEN_PATTERN now matches the secret
half, so any masking done with it keeps the id too.
One user type. TelegramUser is what initData returns and what
parseTelegramUser makes of a Bot API user.
Consequences
- A store written over another ORM is checked by the same rules as the shipped ones; a new rule in the contract reaches every implementation’s tests.
- A value that merely looks like a secret by shape is masked —
?key=in a URL that was a lookup key — a loss judged smaller than a leaked credential. TelegramInitDataUseris renamed and the token pattern narrowed: a minor release with migration notes.
Not done
- A second journal line format. The line is pino’s; a consumer’s own format is its readers’ concern.
- A Prisma model shipped with the store. The table is plain SQL any migration
tool takes;
postgresTelegramUpdateStoreSchemagives it.