ADR 0256 — A Telegram attempt is fenced by a persisted claim
Invariants: I8, I9, I10, I13, I14. Amends ADR 0213’s durable webhook intake.
Context
A renewed lease prevents a second worker from taking an update while its owner is healthy, but it
cannot revoke work that the old handler already started. A domain write therefore needs an
ownership check inside the same database transaction as the write. The pair
{ updateId, attempt } is insufficient: after a settled row is pruned, the same updateId can be
recorded again and its attempt counter starts at one, allowing an old identity to match by accident.
A terminal handler failure has a second durability boundary. Reporting it through an observer and then settling the update can lose the final obligation when the process dies between those steps. Putting it in a second queue duplicates the update’s identity and transaction owner.
The existing handle(update) also accepts direct grammY handlers. grammY may use an optional second
argument for its own webhook envelope, so passing framework attempt metadata there would silently
change an established call.
Decision
- Attempt-aware handling is explicit. Existing
handle(update)stays unary and uses the baseTelegramUpdateStore. A consumer that needs ownership metadata chooseshandleAttempt(update, context)and aTelegramUpdateFencedStore; runtime validation rejects an unfenced store. - A claim has a persisted, globally unique identity.
claimOwnedreturns{ updateId, attempt, claimId }.claimIdis generated for every successful claim and stored with the row.renewOwned,ownsandsettleOwnedcompare all three fields, so neither a later attempt nor a pruned and recreatedupdateIdcan adopt an old capability. - The database decides write authority.
ownerLostasks cooperative work to stop when renewal fails or the last proven lease expires. It is advisory.owns(identity, at)is the authority for a domain transition and runs inside that transition’s transaction. PostgreSQL locks the update row withFOR UPDATE, so reclaim cannot pass between the ownership check and commit. - Terminal delivery remains in the update row. With
handleExhaustion,exhaustatomically turns the exact live claim into an unacknowledged terminal record. Ordered cursor pages recover every such row after restart. OnlyacknowledgeExhaustionwith the same full identity makes it prunable. Delivery is at least once, and the full identity is its idempotency key. - The base contract remains source and storage compatible. The original six methods and
TelegramUpdateStoreStepdo not grow. Legacy PostgreSQLclaim,renewandsettledo not reference the new column, so acreateTable: falseconsumer can keep its old table while it useshandle(update). Opting into a fenced method,handleExhaustion, or a base claim withdurableExhaustion: truerequires the generated schema migration. SQLite and auto-created PostgreSQL stores addclaim_id; SQLite rechecks after a failedALTERso two rolling processes may safely race the upgrade. - One conformance check covers capability levels. Base rules run for every store, fenced rules
also run for a store without exhaustion methods, and durable rules add terminal persistence,
exact acknowledgement and cursor paging. Positive and stale-identity controls cover expiry,
reclaim, pruning and
updateIdreuse.
Consequences
- Consumers that keep
handle(update)need no code or database change. - A custom attempt-aware store implements
claimOwned,renewOwned,ownsandsettleOwned; a durable store additionally implementsexhaust,dueExhaustionsandacknowledgeExhaustion. - A PostgreSQL consumer with
createTable: falseappliespostgresTelegramUpdateStoreSchema(table)before enabling attempt-aware or exhaustion handling. claimIdis opaque. Consumers persist the complete{ updateId, attempt, claimId }key and do not derive meaning from the token.