ops-warden routes API-key needs to railiance-platform, whose convention is platform/workloads/<domain>/<workload>/<bundle>. This repository invented secret/fluid-telegram/<campaign>/telegram instead, which is not its call to make -- a service that picks its own paths in someone else's store is how a policy ends up written around a mistake. Mount and prefix are now BAO_MOUNT and FLUID_BAO_PREFIX, with the old scheme kept as a development fallback. The runbook points at `warden access` for the current shape and at OIDC login rather than a plain token, and names the check that tells whether a login actually took. 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
189 lines
9.3 KiB
Markdown
189 lines
9.3 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.
|
||
|
||
Paths are `<mount>/<prefix>/<key>`, set by `BAO_MOUNT` and `FLUID_BAO_PREFIX`.
|
||
The default (`secret/fluid-telegram/<campaign>/telegram`) is a development
|
||
fallback, and the campaign is part of it so that two campaigns on one interface
|
||
cannot read each other's credentials.
|
||
|
||
**This repository does not get to invent the fleet's secret layout.** ops-warden
|
||
routes API-key needs to railiance-platform, whose convention is
|
||
`platform/workloads/<domain>/<workload>/<bundle>` — see
|
||
`ops-warden/wiki/CredentialRouting.md`. The final names are owner-confirmed, so
|
||
they are configuration here rather than a constant, and the production values
|
||
are settled with railiance-platform before the first apply.
|
||
|
||
| 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.
|
||
|
||
## 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.
|