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
This commit is contained in:
tegwick 2026-09-04 11:57:28 +02:00
parent aa95a09ab6
commit 3aa8cb7eaf
14 changed files with 793 additions and 521 deletions

View file

@ -7,18 +7,22 @@ API.
## What the adapter owns
- Rendering a hall entry into Telegram's message forms.
- 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 an `entry_id` it has already published.
- 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.
- Edit entry content to make it fit. If an entry cannot be published
faithfully, refuse it — that refusal is the pressure signal.
- **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
@ -27,6 +31,10 @@ 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 |
@ -42,49 +50,68 @@ them, so the adapter may assume shape but must still assume hostile content.
## The idempotency requirement
`POST /v1/hall-entries` is idempotent on `entry_id`. The caller is automation
that may be retried, and a duplicate hall entry in the channel is not something
you can quietly undo — subscribers have already seen it.
`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 `entry_id` to the Telegram message ids 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 entry content has changed, it edits the existing
messages rather than posting new ones.
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. Entry Markdown must be converted, not
Telegram formatting is not Markdown. Post Markdown must be converted, not
passed through.
| Entry construct | Telegram |
| 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>` |
| Frontmatter | Stripped; `display_name` and `recorded_at` surfaced in the header |
| 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. `entry_content_loss` is a hard guardrail in
both competing hypotheses, and it can only be honest if the adapter refuses
rather than approximates.
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`:
- `messages_per_entry` — how many Telegram messages one entry became.
- `entry_content_loss` — whether the conversion dropped anything.
- `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. Engagement metrics
(`read_through_rate`, `reaction_rate`) come from Telegram's channel statistics
as aggregates, never per-reader.
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
@ -94,6 +121,7 @@ as aggregates, never per-reader.
| `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

View file

@ -7,8 +7,8 @@ enough to compare over time and no more granular than the analysis requires.
| Cohort | Assigned by | Why it is distinct |
|---|---|---|
| `hall-publishing-jobs` | `X-FLUID-Consumer` prefix `hall-` | Batch behaviour; publishes many entries at once |
| `helix-forge-agents` | `X-FLUID-Consumer` prefix `agent-` | One entry at a time, at session end; the richest pressure signal |
| `hall-publishing-jobs` | `X-FLUID-Consumer` prefix `hall-` | Batch behaviour; publishes many posts at once. Becomes the `pr-hall-of-helix` campaign job |
| `helix-forge-agents` | `X-FLUID-Consumer` prefix `agent-` | One post at a time, at session end |
| `operators` | `X-FLUID-Consumer` prefix `op-` | Rare, deliberate, often corrective |
| `telegram-subscribers` | Not a caller | An observed population, never an API consumer |
@ -31,9 +31,10 @@ Beyond the fluid-core defaults, this interface adds:
usernames, display names of subscribers — must not reach the evidence store
in any form, redacted or otherwise. There is no legitimate analysis that
needs them, so the safe rule is that they never arrive.
- **Entry content is not telemetry.** An entry body may appear in a request; it
must not appear in a telemetry event. What is recorded is its length, its
section count, and whether conversion succeeded.
- **Post text is not telemetry.** The body appears in the request and is kept
in the publication record, because it is what was said in HelixForge's name.
It must not appear in a telemetry event: observation is for measuring how
posts performed, not for accumulating a second copy of what they said.
- **The bot token appears nowhere.** Not in errors, not in traces, not in
redacted form.
@ -55,16 +56,32 @@ loss and start a fresh evidence baseline, not to rotate quietly.
| Metric | Role | Source |
|---|---|---|
| `read_through_rate` | primary | Telegram channel statistics, aggregate |
| `messages_per_entry` | guardrail | Adapter |
| `entry_content_loss` | guardrail | Adapter |
| `engagement_rate` | primary | Telegram channel statistics, aggregate |
| `unreviewed_publication` | guardrail | Adapter |
| `missing_source_attribution` | guardrail | Adapter |
| `missing_subject_consent` | guardrail | Adapter |
| `error_rate` | guardrail | Gateway |
| `reaction_rate` | secondary | Telegram channel statistics, aggregate |
| `time_to_first_read` | learning | Telegram channel statistics, aggregate |
| `source_link_rate` | secondary | Telegram channel statistics, aggregate |
| `forward_rate` | secondary | Telegram channel statistics, aggregate |
| `time_to_first_view` | learning | Telegram channel statistics, aggregate |
`read_through_rate` is the one to be careful about. Telegram reports view counts
per message, not per reader. For the teaser form it is link clicks over views;
for the serialized form it is views of the last part over views of the first.
Those are not the same measurement, and comparing them directly is the weakest
part of the first experiment. Say so in the experiment's amendment record rather
than letting the comparison look cleaner than it is.
`engagement_rate` is the one to be careful about. Telegram reports views,
reactions and forwards per message, not per reader, and a "rate" therefore has
subscribers as its denominator rather than people who saw the post. It is a
proxy, and a noisy one at small subscriber counts. Say so in the experiment's
amendment record rather than letting the comparison look cleaner than it is.
## Variant attribution
Every post carries a `variant` label naming the composition strategy that
produced it. This interface treats it as opaque: it records the label, and
reports engagement grouped by it.
That is deliberately the whole of the interface's involvement in composition.
The campaign compares its own strategies using this data, running its own FLUID
experiments over its own artifacts. The interface does not know what
`variant: warm-specific-v2` means and must not acquire an opinion about it.
Without this field the campaign would have to infer which post came from which
strategy by timestamp, which is exactly the kind of reconstruction that quietly
stops being accurate.