platform/workloads/infotech/fluid-telegram/<campaign>/<key>, confirmed with the owner. The campaign sits between workload and bundle so two campaigns on one interface cannot read each other's credentials, and both halves stay overridable because the layout belongs to railiance-platform. Records what ops-warden is for while it is fresh: it issues SSH certificates and routes every other credential need to its owner, so it answers how to authenticate to OpenBao and never holds what is stored there. 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
195 lines
9.4 KiB
Markdown
195 lines
9.4 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 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.
|
||
|
||
## 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.
|