Loosen the all-or-nothing stance on provisioning: where a platform has no API to seed access, a guided runbook is the right answer. docs/seeding-runbook.md covers the three Telegram steps that cannot be automated, and each carries a "why not automated" line so the judgement can be revisited rather than inherited. Telegram's steps resist automation incidentally -- nobody built the endpoints -- unlike a control such as KYC, which resists by design and where a weak component would be a defect rather than an opening. Fix the schema's $id, which was a relative path and broke $ref resolution in ordinary validators, and add the visibility/username constraint the field descriptions already claimed. Both specs now validate, and the rejections are tested. 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
177 lines
8.5 KiB
Markdown
177 lines
8.5 KiB
Markdown
# 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.**
|
||
|
||
Those steps are not left to improvisation. `docs/seeding-runbook.md` guides them:
|
||
what to have ready, what to do, what to record and where, and — for each one — why
|
||
it could not be automated. A manual step is acceptable when it is guided,
|
||
repeatable and bounded, and unacceptable when it is merely undocumented.
|
||
|
||
Worth distinguishing two reasons a step resists automation, because they age
|
||
differently. A KYC check resists *by design*; automating it would defeat its
|
||
purpose, and a component weak enough to permit it is a defect rather than an
|
||
opportunity. A web form that mints an API key resists only *incidentally*,
|
||
because nobody built the endpoint. All three Telegram steps are the second kind —
|
||
none is a fraud control — so each is a candidate for removal the moment the
|
||
platform changes.
|
||
|
||
## 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.
|
||
|
||
- **`plan`** reads the spec, reads live Telegram state through MTProto, reads the
|
||
resolved-state file, and prints a diff. It writes nothing.
|
||
- **`apply`** executes 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, `apply` says 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_messages` and refuses to grant anything else *even if the spec asks for
|
||
it*. A spec requesting `can_delete_messages` fails 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 `apply`
|
||
refuses to touch the public channel until the private one has recorded a
|
||
successful test publication. Canon PUB-01, and `FT-WP-0001` T02.
|
||
|
||
## 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:
|
||
|
||
```yaml
|
||
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.
|