From 014ee5c746af259bcaad6e569d683d151226f068 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 4 Sep 2026 22:02:56 +0200 Subject: [PATCH] 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 "/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 -- /fluid-telegram//telegram/ -- 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 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 --- docs/provisioning.md | 13 +++++--- docs/seeding-runbook.md | 66 +++++++++++++++++++++++++++++++------- internal/plan/plan.go | 9 +++++- internal/plan/plan_test.go | 20 ++++++++++++ 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/docs/provisioning.md b/docs/provisioning.md index 34149c8..ac430e8 100644 --- a/docs/provisioning.md +++ b/docs/provisioning.md @@ -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 `/fluid-telegram//telegram/`, 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, diff --git a/docs/seeding-runbook.md b/docs/seeding-runbook.md index 36738ff..4123b14 100644 --- a/docs/seeding-runbook.md +++ b/docs/seeding-runbook.md @@ -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= # 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 - `/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 `/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 `/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= api_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 -`/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` T03–T06: ```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. diff --git a/internal/plan/plan.go b/internal/plan/plan.go index e72aad2..62dc3a3 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -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 } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index b008097..480f064 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -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) + } +}