fluid-telegram/docs/adapter-contract.md
tegwick 3aa8cb7eaf Reframe the interface around composed posts, not republished entries
The channel publishes a post written from a hall entry -- condensed,
personal, reach-optimized -- not the entry itself. That changes what
this interface is for and where its boundary sits.

Composition is deliberately outside the interface. Composing is a
probabilistic editorial task, and Blueprint 48.1 forbids an LLM in the
mandatory request path, so an interface that composed on demand would be
non-deterministic exactly where determinism is the whole claim. It is
also editorial: a post speaks about a named person's work in
HelixForge's voice, and a review step inside the request path is a
review step nobody performs.

The contract now takes a composed post with a required reviewer, a cited
source entry and a consent basis. A post carrying none of those is
refused. Absence of objection is not consent, and in an audit trail an
unfaithful post looks exactly like a faithful one -- the defence is that
a human read it, the source is named, and both are recorded.

The first experiment is rebuilt accordingly. The old competition asked
how to fit a whole entry into Telegram; that question no longer exists.
The new one asks whether the portrait earns the 3072 characters of
caption limit it costs, which is a delivery question this interface
actually owns. Competition over editorial voice belongs to the campaign
and runs on the per-variant engagement this interface reports.

Adds T05b (composition and review), T05c (consent basis for the 94
existing participants, blocking public publication) and T11 (extracting
the campaign into pr-hall-of-helix).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 11:57:28 +02:00

5.8 KiB

Telegram adapter — connector capability contract

The adapter is an ordinary HTTP service. fluid-core sits in front of it and asks nothing of it beyond the endpoints below, so it may be written in whatever suits the platform — grammY on Node is the obvious choice for the Telegram Bot API.

What the adapter owns

  • Rendering a composed post into a Telegram message.
  • Attaching the post's visual, where the revision calls for one.
  • Talking to the Telegram Bot API.
  • Custody of the bot token for the duration of a call.
  • Idempotency: recognising a post_id it has already published.

What the adapter must not do

  • Create, rename or delete channels, groups or bots.
  • Change membership, administrators or permissions.
  • Post to any chat other than the one it is configured with.
  • Compose, rewrite, shorten or embellish post text. The post arrives written. If it does not fit, refuse it: a composition that exceeds the limit was not condensed, and deciding what to cut is the composer's judgement.
  • Publish a post carrying no reviewed_by, or none of the recognised consent bases. Refuse with 403.
  • Log, echo or forward the bot token, including in error messages.

Endpoints

Exactly the three operations in contracts/r1.openapi.yaml, at the same paths. The gateway validates requests against that contract before the adapter sees them, so the adapter may assume shape but must still assume hostile content.

Note what the contract does not accept: a hall entry. The adapter never reads hall-of-helix. Its only knowledge of an entry is the id and URL a post cites, which it renders as a link.

Backend capability contract

Property Value
Backend identity Telegram Bot API (api.telegram.org)
Authentication Bot token, injected at start from OpenBao; never in the image
Timeout 30s per Telegram call
Retry On 429 only, honouring retry_after. Never retry a sendMessage that may have succeeded
Circuit breaker 5 consecutive failures opens for 60s
Rate limits ~20 messages/minute to a channel; ~30 requests/second overall
Error mapping 429 → 502 with retry_after preserved; 400 → 400; network failure → 502
Tenant propagation None; single tenant

The idempotency requirement

POST /v1/channel-posts is idempotent on post_id. The caller is automation that may be retried, and a duplicate post is not something you can quietly undo — subscribers have already seen it.

The adapter keeps its own mapping from post_id to the Telegram message id it produced. On a repeat call it returns 200 with the existing publication rather than posting again. If the body has changed, it edits the existing message.

A recomposition of the same source entry is a new post_id, not an edit. It is a different piece of writing about the same work, and collapsing the two would lose the comparison between them.

This state is the adapter's, not fluid-core's. The evidence store records that a publication happened; the adapter records which Telegram messages implement it.

Rendering rules

Telegram formatting is not Markdown. Post Markdown must be converted, not passed through.

Post construct Telegram
# Heading Bold line
**bold**, _italic_ <b>, <i> in HTML parse mode
Fenced code <pre>
Tables Flattened to lines; Telegram has no table
Links <a href>
Source link Appended as an attributed link to source_entry_url

Anything the converter cannot represent faithfully must fail the publication rather than degrade it silently.

Two constants regardless of revision: every post carries a link to its source entry, and every post is attributable. missing_source_attribution is a hard guardrail, and a post that cannot be checked against what the person actually wrote is the failure this whole arrangement exists to prevent.

Caption limit. When the revision attaches a visual (R-2), Telegram caps the caption at 1024 characters rather than 4096. The adapter must refuse a post that exceeds the limit for the form it is publishing in, rather than truncating it — the difference between those two limits is the entire subject of the first experiment, and silently trimming would destroy the measurement.

Telemetry the adapter should emit

The gateway observes the request path already. The adapter adds what only it can see, by POSTing to the control plane's /control/v1/telemetry:

  • variant — the composition strategy label the post carried, so engagement can be attributed to it. This is what lets the campaign compare strategies it owns without this interface understanding them.
  • unreviewed_publication — always false in practice, since such a post is refused; emitted so the guardrail has a series rather than an absence.
  • missing_source_attribution, missing_subject_consent — likewise.
  • Telegram rate-limit encounters, as error events with class backend_failure.

It must not emit subscriber identity in any form, and it must not emit post body text as telemetry — the body belongs in the publication record, not in the observation stream.

Engagement metrics (engagement_rate, source_link_rate, forward_rate) come from Telegram's channel statistics as aggregates, never per-reader.

Configuration

Variable Meaning
TELEGRAM_BOT_TOKEN Injected from OpenBao at start
TELEGRAM_CHANNEL_ID The channel this adapter publishes to
FLUID_CONTROL_URL Where to POST telemetry
HALL_BASE_URL Base URL for links back to hall-of-helix
ATTACH_VISUAL Whether this revision attaches the portrait (R-2 true, R-3 false)

One adapter instance serves one channel. The private test channel and the public channel are separate instances with separate configuration, which is what keeps a test publication from reaching subscribers by accident.