plan --check treated BotFather profile reassertion as drift, so a scheduled check could never go quiet. Reassertion is now its own kind; a changed spec digest is the drift signal. apply reasserts the profile, updates channel copy when the spec changes, and is tested against a fake Telegram surface. The adapter reads its chat id from resolved state and its token from OpenBao, never the operator session. IEI-2 records that an approved provisioning plan is not autonomous Daimon action. Assistant: grok Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
203 lines
9.5 KiB
Markdown
203 lines
9.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. IEI-2 states that
|
||
reading in the document: §7 constrains autonomous administration, not an
|
||
approved apply.
|
||
|
||
## 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.
|
||
|
||
Paths follow the fleet convention
|
||
`platform/workloads/<domain>/<workload>/<bundle>`, owned by railiance-platform
|
||
and routed to by ops-warden (`ops-warden/wiki/CredentialRouting.md`):
|
||
|
||
```
|
||
platform/workloads/infotech/fluid-telegram/<campaign>/<key>
|
||
```
|
||
|
||
The campaign sits between the workload and the bundle so that two campaigns on
|
||
one interface cannot read each other's credentials; the bundle then names the
|
||
secret itself. `BAO_MOUNT` and `FLUID_BAO_PREFIX` override both halves, because
|
||
the layout belongs to its owner and not to this repository.
|
||
|
||
ops-warden does not hold any of these. It **issues** SSH certificates and
|
||
**routes** every other credential need to the subsystem that owns it, so it is
|
||
where to ask *how* to authenticate to OpenBao — `warden access 'openbao token
|
||
for reading a kv secret'` — and never where the secret lives.
|
||
|
||
| Secret | Key | Rule |
|
||
|---|---|---|
|
||
| `api_id` / `api_hash` | `operator-app` | The only one written by hand. Provisioning only. |
|
||
| MTProto session string | `operator-session` | Written by `session bootstrap`. A full-account credential: provisioning only, never given to the adapter, never on disk. |
|
||
| Bot token | `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 | `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.
|
||
|
||
The adapter reads its chat id from this file and its bot token from OpenBao.
|
||
It gets neither the operator session nor any provisioning code path:
|
||
|
||
```bash
|
||
provision adapter-config --spec <path> --channel test # or public
|
||
```
|
||
|
||
That command prints `TELEGRAM_CHANNEL_ID` and `TELEGRAM_BOT_TOKEN_REF`. It never
|
||
prints the token.
|
||
|
||
## 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.
|
||
|
||
## §7, settled
|
||
|
||
IEI-2 states the reading this design depends on: an operator-triggered
|
||
provisioning tool that executes a plan a person has approved is not the Daimon
|
||
and is not autonomous. The request path is unchanged — the adapter still holds
|
||
`post_messages` and nothing else.
|