Align AGENTS.md with the FT-WP- prefix the hub already registered, and rewrite INTENT.md and SCOPE.md, which were still the generated stubs and carried none of the composed-post framing the interface is built around. Fix two artifact inconsistencies: R-1's retry comment still described POST /v1/hall-entries idempotent on entry_id, from before the reframe to composed posts; and the seed pressure record listed only one of the two hypotheses that cite it. Rename it to match its id. Replace the manual T01-T03 route with FT-WP-0002. The Bot API cannot create a bot or a channel, so provisioning is an MTProto client acting as a designated operator account (Canon INT-03), driven from a declared presence spec owned by the campaign. One bounded human bootstrap remains and docs/provisioning.md says why it cannot be removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa Assistant: claude-code Assistant-Model: opus Assistant-Process: 1361245@bnt-lap001 Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
7.8 KiB
Declared presence — provisioning the Telegram surface
FT-WP-0001 T01–T03 were written as human steps: register a bot through
BotFather, create two channels, generate a salt. This document replaces that
route with a declared specification and a reconciler, and states honestly which
part of it cannot be automated and why.
The constraint that shapes everything
The Bot API cannot create a bot or a channel. It can only act as one. Both acts are client capabilities, reachable only through MTProto with a user account — Canon INT-03, "capabilities unavailable through the Bot API".
So the provisioner is an MTProto client acting as a designated operator account.
It messages BotFather the way a person would, and it calls channels.createChannel
the way a client would.
Three things genuinely cannot be derived from a specification:
| Human step | Why | Frequency |
|---|---|---|
| A Telegram account for the operator | Requires a phone number and a device | Once |
api_id / api_hash |
Issued by a web form at my.telegram.org | Once |
| The login code (and 2FA password) that mints the MTProto session | Telegram sends it out of band, by design | Once, and again if the session is revoked |
Everything else — bot registration, naming, description, avatar, channel creation, visibility, administrator rights, the redaction salt — is declared and reconciled.
That is the honest boundary. This is not "no human steps"; it is one bounded bootstrap, then a declared surface that converges. The steps that remain are the ones Telegram places outside any API on purpose.
Where the specification lives
The presence spec is editorial, not mechanical. It carries the bot's name, its description, its avatar, and the channel titles a stranger will read. That is brand, and brand belongs to the campaign:
pr-hall-of-helix/presence/telegram.yaml # the declared presence (campaign owns)
fluid-telegram/presence/telegram.schema.yaml # the schema it validates against
fluid-telegram/presence/resolved/<campaign>.yaml # what provisioning produced
The provisioner takes a spec path as its input, so this interface stays reusable
by any HelixForge campaign rather than being about the Hall of Helix. It follows
the split FT-WP-0001 T13 already draws: what to say belongs to the campaign;
how it is carried and proven belongs here.
The two planes
provisioning plane request path
------------------ ------------
operator (human) triggers pr-hall-of-helix
| |
v v POST /v1/channel-posts
provision plan ──▶ human reads ──▶ apply fluid-core ──▶ adapter ──▶ Bot API
| (post_messages only)
v
MTProto as operator account
BotFather · channels.createChannel · channels.editAdmin
They share nothing but OpenBao and the resolved-state file. The adapter has no
provisioning code path and no MTProto session. It holds a bot token with
post_messages and nothing else, so a compromised adapter cannot create,
rename, delete or re-permission anything.
This is what keeps the scheme inside InterfaceEvolutionIntent.md §7, which
forbids the Daimon from creating channels or bots or changing permissions. The
provisioner is not the Daimon: it is operator-triggered, it is outside the
request path, and every run is a plan a person approved. §7's prohibition is on
autonomous administration, and nothing here is autonomous.
plan / apply
Terraform's shape, deliberately, because the semantics are already understood.
planreads the spec, reads live Telegram state through MTProto, reads the resolved-state file, and prints a diff. It writes nothing.applyexecutes a plan the operator approved. It is idempotent: a second apply over an unchanged spec is a no-op, and every action is keyed on a resolved id rather than on a name.
Reconciliation is converging, never destructive:
- The provisioner has no destroy path. Deleting a channel destroys its subscribers and its post history irreversibly, and no specification is worth trusting with that. Removing a channel from the spec produces a warning, not a deletion.
- Drift on a destructive field reports and stops. If a channel's username has
been taken over, or the bot has been demoted,
applysays so and exits non-zero. It does not "fix" its way through a situation it does not understand. - Administrator rights are clamped, not declared. The provisioner grants
post_messagesand refuses to grant anything else even if the spec asks for it. A spec requestingcan_delete_messagesfails validation. The intent forbids the system from holding a right it is not allowed to exercise, so the safe place to enforce that is where the right is granted. - The private test channel is created before the public one, and
applyrefuses to touch the public channel until the private one has recorded a successful test publication. Canon PUB-01, andFT-WP-0001T02.
Secrets
Nothing sensitive enters the spec or the resolved state. Both are committed.
| Secret | Path | Rule |
|---|---|---|
| MTProto session string | bao:.../telegram/operator-session |
Provisioning only. Never given to the adapter. |
api_id / api_hash |
bao:.../telegram/operator-app |
Provisioning only. |
| Bot token | bao:.../telegram/bot-token |
Written by apply the moment BotFather issues it, read by the adapter at start. Never logged, never echoed, not even in error text. |
| Redaction salt | bao:.../telegram/redaction-salt |
Create-if-absent, never overwrite. |
The salt rule is a hard one. docs/observation.md explains why: rotating it
silently invalidates every longitudinal comparison the interface has ever made,
and it does so without any visible failure. So the provisioner generates it once
if it is missing and will not replace it — the only way to change it is a
deliberate human write to OpenBao, which is a decision, not a run.
What provisioning produces
presence/resolved/<campaign>.yaml, committed, no secrets:
resolved:
campaign: hall-of-helix
spec_digest: "sha256:..." # the spec this state came from
provisioned_at: "..."
bot:
username: "..."
id: 123456789
channels:
test: { chat_id: -100..., username: null, admin_rights: [post_messages] }
public: { chat_id: -100..., username: "...", admin_rights: [post_messages] }
This is what the adapter's configuration is derived from, and what plan diffs
against. spec_digest is the link that makes drift detectable: if the spec
changed and the digest did not, provisioning is behind.
Rate and account safety
Automating a user account is not what Telegram's terms are written around. Three mitigations, all cheap:
- Use a dedicated operator account, never a person's own. If it is limited, nothing personal is lost.
- The provisioner runs at human pace — it is a reconciler that runs on change, not a loop. BotFather conversations are rate-limited to one exchange at a time with real delays between messages.
- The session publishes nothing. All ordinary traffic goes through the bot token on the Bot API, which is the supported surface. The user session exists only to do the three things the Bot API cannot.
Open question for the operator
InterfaceEvolutionIntent.md §7 lists channel and bot creation under "explicit
non-authority" without distinguishing the Daimon from an operator-run tool. The
reading above — that §7 constrains autonomous action, and an approved plan is not
autonomous — is the intended one, but it is currently a reading rather than
something the document says. A one-sentence clarification in §7 would settle it.
That is a governance edit to a constitutional document, so it is proposed here
rather than made.