Make the plan honest and the runbook's paths real

Two things that would have gone wrong on the first live run.

The plan promised "create bot.avatar" and apply had no avatar code at all:
/setuserpic needs a photo upload that is not implemented. A plan that
promises an action apply silently skips is worse than one that admits the
gap, because it makes every other line less trustworthy. The avatar is now
deferred, with instructions for setting it by hand.

The runbook wrote secrets to "<interface-path>/telegram/...", which is not
what the code computes. An operator following it would have put api_id and
api_hash somewhere the tool never looks, and found out at the first
connection attempt. Both documents now carry the real paths --
<mount>/fluid-telegram/<campaign>/telegram/<key> -- with a copy-pasteable
bao kv put for the one secret written by hand, and a verify step before
the session is minted.

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
This commit is contained in:
tegwick 2026-09-04 22:02:56 +02:00
parent 5ddfee8250
commit 014ee5c746
4 changed files with 90 additions and 18 deletions

View file

@ -118,12 +118,15 @@ Reconciliation is **converging, never destructive**:
Nothing sensitive enters the spec or the resolved state. Both are committed.
| Secret | Path | Rule |
Paths are computed as `<mount>/fluid-telegram/<campaign>/telegram/<key>`, so two
campaigns on one interface cannot read each other's credentials.
| Secret | Key | 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.** |
| `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,

View file

@ -41,12 +41,35 @@ becomes irreproducible.
- [ ] A phone number that can receive SMS, **not** a personal one
- [ ] A device with Telegram installed, to complete the first login
- [ ] Access to OpenBao (`bao.coulomb.social`) with write on the interface's path
- [ ] Access to OpenBao (`bao.coulomb.social`) with write on the campaign's subtree
- [ ] The campaign's presence spec merged (`pr-hall-of-helix/presence/telegram.yaml`)
- [ ] The avatar asset committed and referenced by the spec
Roughly 20 minutes, once.
## Where the secrets go
The provisioner computes these paths; they are not a convention you may vary.
For campaign `hall-of-helix` on the default `secret` mount:
```
secret/fluid-telegram/hall-of-helix/telegram/operator-app api_id, api_hash
secret/fluid-telegram/hall-of-helix/telegram/operator-session written by the tool
secret/fluid-telegram/hall-of-helix/telegram/bot-token written by the tool
secret/fluid-telegram/hall-of-helix/telegram/redaction-salt written by the tool, once
```
Set these before any command below. `BAO_MOUNT` defaults to `secret`.
```bash
export BAO_ADDR=https://bao.coulomb.social
export BAO_TOKEN=<your token> # not committed, not echoed
```
Only the first path is written by hand. The rest are the tool's, and writing
one yourself will be overwritten or -- for the salt -- silently honoured
forever.
---
## Step 1 — Register the operator account
@ -57,14 +80,16 @@ is to prove a person is present.
1. Install Telegram on the device and register with the operator phone number.
2. Set a display name that reads as an operator account, not a person.
3. Enable two-factor authentication and put the password in OpenBao at
`<interface-path>/telegram/operator-2fa` immediately.
3. Enable two-factor authentication and store the password somewhere you can
retrieve it. The tool prompts for it during Step 3 and does not read it from
OpenBao, so this is for you, not for the provisioner.
**Do not use a personal account.** If it is rate-limited or restricted — which
automation can cause — the loss should be a throwaway account, not someone's
messages.
**Record:** the phone number, in OpenBao at `<interface-path>/telegram/operator-phone`.
**Record:** the phone number where your team can find it. The tool prompts for
it rather than reading it, so it is not one of the paths above.
---
@ -80,7 +105,19 @@ exposes it, Step 2 disappears.
- Platform: Other
3. Copy `api_id` and `api_hash`.
**Record:** both, in OpenBao at `<interface-path>/telegram/operator-app`.
**Record:** both, in OpenBao. This is the one secret you write by hand, and
everything downstream reads it:
```bash
bao kv put secret/fluid-telegram/hall-of-helix/telegram/operator-app \
api_id=<the number> api_hash=<the hash>
```
Verify the tool can see it before going on:
```bash
bao kv get secret/fluid-telegram/hall-of-helix/telegram/operator-app
```
They are shown once per application. Losing them means creating another.
@ -92,12 +129,12 @@ They are shown once per application. Losing them means creating another.
command is interactive by necessity — but *only this command*, and only once.
```bash
provision session bootstrap --campaign hall-of-helix
go run ./cmd/provision session bootstrap --campaign hall-of-helix
```
It prompts for the phone number, the login code, and the 2FA password, then
writes the session string to OpenBao at
`<interface-path>/telegram/operator-session`.
`secret/fluid-telegram/hall-of-helix/telegram/operator-session`.
The session string is a **full-account credential** — it can do anything the
account can. It never leaves OpenBao, is never given to the adapter, and is used
@ -106,7 +143,7 @@ only by the provisioner. Treat losing it as losing the account.
**Verify:**
```bash
provision session check --campaign hall-of-helix # prints the account, no secrets
go run ./cmd/provision session check --campaign hall-of-helix # prints the account, no secrets
```
---
@ -116,15 +153,20 @@ provision session check --campaign hall-of-helix # prints the account, no sec
From here nothing is manual. The remaining work is `FT-WP-0002` T03T06:
```bash
provision plan --spec ../pr-hall-of-helix/presence/telegram.yaml # read the diff
provision apply --spec ../pr-hall-of-helix/presence/telegram.yaml # execute it
go run ./cmd/provision plan --spec ../pr-hall-of-helix/presence/telegram.yaml # read the diff
go run ./cmd/provision apply --spec ../pr-hall-of-helix/presence/telegram.yaml # execute it
```
`apply` registers the bot through BotFather, sets its name, about text,
description and avatar, creates both channels, adds the bot as administrator with
`apply` registers the bot through BotFather, sets its name, about text and
description, creates the test channel, adds the bot as administrator with
`post_messages` only, generates the redaction salt if it is absent, and writes
`presence/resolved/hall-of-helix.yaml`.
Two things it will *not* do on this first run, both by design and both reported
in the plan. The **public channel is held** until a rendering has been checked in
the test channel. The **avatar is deferred**: `/setuserpic` needs a photo upload
that is not implemented, so set it by hand in @BotFather, or wait.
**Read the plan before approving it.** That is the human judgement this design
keeps — not clicking through BotFather, but deciding whether the diff is what
was intended.

View file

@ -149,7 +149,14 @@ func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error {
sp.Bot.Name, len(sp.Bot.UsernamePreference), strings.Join(sp.Bot.UsernamePreference, ", ")))
p.add(Create, "bot.profile", "set name, about text and description")
if sp.Bot.Avatar != "" {
p.add(Create, "bot.avatar", sp.Bot.Avatar)
// apply does not set the avatar yet: /setuserpic needs a photo
// upload, which is FT-WP-0002 T04's remaining piece. Say so, rather
// than promising an action that would be silently skipped -- a plan
// nobody can trust line by line is not worth reading.
p.addWhy(Defer, "bot.avatar", sp.Bot.Avatar,
"Not applied yet: setting a bot's picture needs a photo upload, which "+
"is not implemented. Set it by hand in @BotFather with /setuserpic, "+
"or leave it until the upload lands.")
}
return nil
}

View file

@ -211,3 +211,23 @@ func TestVerifiedTestChannelReleasesPublic(t *testing.T) {
t.Errorf("public should no longer be deferred:\n%s", p.Render())
}
}
// The plan must not promise an action apply will silently skip. The avatar is
// the live case: /setuserpic needs an upload that is not implemented, so the
// plan defers it with an explanation instead of claiming a create.
func TestAvatarIsDeferredNotPromised(t *testing.T) {
s := sp()
s.Bot.Avatar = "presence/assets/avatar.png"
p, _ := Compute(s, "sha256:x", &state.Resolved{}, fake{})
if find(p, Create, "bot.avatar") != nil {
t.Fatal("plan promised an avatar create that apply does not perform")
}
a := find(p, Defer, "bot.avatar")
if a == nil {
t.Fatalf("expected the avatar to be deferred:\n%s", p.Render())
}
if !strings.Contains(a.Why, "setuserpic") {
t.Errorf("deferral should say how to do it by hand: %q", a.Why)
}
}