2026-09-04 19:21:08 +02:00
---
id: FT-WP-0002
type: workplan
title: "Provision the Telegram presence from a declared specification"
domain: infotech
repo: fluid-telegram
2026-10-08 00:49:20 +02:00
status: finished
2026-09-14 15:50:42 +02:00
flavor: implementation
2026-09-04 19:21:08 +02:00
owner: worsch
topic_slug: fluid-telegram
created: "2026-09-04"
2026-10-08 00:49:20 +02:00
updated: "2026-10-07"
2026-09-04 19:21:08 +02:00
planning_priority: high
planning_order: 2
2026-10-08 00:49:20 +02:00
depends_on: []
2026-09-04 19:21:08 +02:00
related_repos:
- pr-hall-of-helix
- helix-forge
2026-09-04 19:21:12 +02:00
state_hub_workstream_id: "f2373858-c932-5a4d-81b6-db9a3595135a"
2026-09-04 19:21:08 +02:00
---
# FT-WP-0002 — Declared presence provisioning
Replace the manual route of `FT-WP-0001` T01– T03 with a specification and a
reconciler. The design, its constraints and its guardrails are in
`docs/provisioning.md` ; this workplan implements it.
The shape of the problem, stated once: the Bot API cannot create a bot or a
channel. Both are client capabilities reachable only through MTProto with a user
account (Canon INT-03). So provisioning is an MTProto client acting as a
designated operator, and three things stay human — a phone number, the
`api_id` /`api_hash` web form, and the login code that mints the session. That is
one bounded bootstrap, after which the surface is declared and converges.
2026-10-08 00:49:20 +02:00
**Finished (2026-10-07).** T02– T08 were already done. The founder bootstrapped
the dedicated operator session. `session check` returned Coulomb Social, id
8934269972, and preflight now finds the session present. The credential lane
request remains `docs/requests/ops-warden-telegram-operator-lane.md` . Live
`provision apply` is the next act and belongs to the accepted bot and channel
reviews, not to another task in this plan.
Implement the R-1 adapter (FT-WP-0001 T05/T09); mark workplans blocked
Builds internal/adapter and cmd/adapter: the three contracts/r1.openapi.yaml
operations, idempotency on post_id, the reviewed_by/consent-basis refusals,
per-form (R-2/R-3) length limits, and Markdown-to-Telegram rendering, all
against a Sender interface so it's testable without a live bot token.
Every other open task in FT-WP-0001 and FT-WP-0002 needs something this repo
can't supply on its own: the human session bootstrap in FT-WP-0002 T01, the
fluid CLI from fluid-core, or a person's judgment (consent basis, editorial
review, publishing pace). Set both workplans to status: blocked and flagged
FT-WP-0002-T01 needs_human on the hub, since it's the single blocker gating
everything else.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 278411@bnt-lap001
Assistant-Session: 19568045-15bc-4b88-ba3d-8e95b4f9c6f1
2026-09-27 23:15:29 +02:00
2026-09-04 19:30:54 +02:00
## T01 — Write the seeding runbook and bootstrap the session
2026-09-04 19:21:08 +02:00
```task
id: FT-WP-0002-T01
2026-10-08 00:49:20 +02:00
status: done
2026-09-04 19:21:08 +02:00
priority: high
2026-09-04 19:21:12 +02:00
state_hub_task_id: "039e5358-07c1-5b01-a186-5ece9aab75e4"
2026-09-04 19:21:08 +02:00
```
2026-09-04 19:30:54 +02:00
**The seeding steps, done by a guided runbook.** `docs/seeding-runbook.md` is
the deliverable: register a dedicated operator account (never a person's own, so
a rate limit costs nothing personal), obtain `api_id` / `api_hash` , and run
`provision session bootstrap` to mint the MTProto session into OpenBao.
2026-09-04 19:21:08 +02:00
2026-09-04 19:30:54 +02:00
Manual is acceptable here because it is guided, repeatable and bounded — an
operator who has never done it follows it once and gets the same result as
anyone else. What makes it acceptable is the structure, not the shortness.
2026-09-04 19:21:08 +02:00
2026-09-04 19:30:54 +02:00
Each step in the runbook carries a **Why not automated** line, and that is the
part with a future. Telegram's three steps resist automation only incidentally —
nobody built the endpoints — as opposed to a control like KYC, which resists
automation by design and where a component weak enough to allow it would be a
defect rather than an opening. So these lines are re-read when the platform
changes, and steps are deleted when a reason expires.
`provision session bootstrap` is the only interactive command in the tool. Nothing
else in this workplan is interactive.
2026-09-04 19:21:08 +02:00
2026-10-08 00:49:20 +02:00
**Done (2026-10-07).** The founder created a dedicated account, stored the
application credentials, and ran `provision session bootstrap --campaign
hall-of-helix`. ` session check` validated Coulomb Social, id 8934269972. The
session is in OpenBao and was not written to disk.
2026-09-14 02:55:58 +02:00
2026-10-03 03:27:14 +02:00
2026-10-03: human gate routed to hub decision 7f455718-66c9-4049-9aaf-1143aea39d34 (D6: Telegram operator session: register the bots, create the channels, bootstrap the user session) by CUST-WP-0075; resolving that decision raises C-40 here.
2026-09-04 19:21:08 +02:00
## T02 — Publish the presence schema and the campaign's spec
```task
id: FT-WP-0002-T02
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
status: done
2026-09-04 19:21:08 +02:00
priority: high
2026-09-04 19:21:12 +02:00
state_hub_task_id: "01684264-37a5-5fe8-a491-37686e094428"
2026-09-04 19:21:08 +02:00
```
2026-09-04 19:30:54 +02:00
**Largely done.** The schema is in `presence/telegram.schema.yaml` with a worked
example beside it, and the real instance is at
`pr-hall-of-helix/presence/telegram.yaml` . Both validate.
2026-09-04 19:21:08 +02:00
The spec is editorial — a bot's name, its description, the channel titles a
stranger reads — so it belongs to the campaign. The provisioner takes a spec path
as input, which is what keeps this interface reusable by campaigns other than the
Hall of Helix.
2026-09-04 19:30:54 +02:00
The schema enforces rather than describes: an `admin_rights` entry other than
`post_messages` is rejected, a private channel may not declare a username, a
public one must, and unknown fields fail instead of being ignored. A silently
dropped field is how a spec stops describing what actually exists.
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
**Done.** The avatar is provided at `assets/helix-forge.png` and validated:
PNG, dimensions, size, and a content digest so replacing the file is what
triggers an update.
2026-09-04 19:21:08 +02:00
## T03 — Implement `provision plan`
```task
id: FT-WP-0002-T03
2026-09-14 02:55:58 +02:00
status: done
2026-09-04 19:21:08 +02:00
priority: high
2026-09-04 19:21:12 +02:00
state_hub_task_id: "c3923422-7f2e-5bf9-906c-6d59572b09c0"
2026-09-04 19:21:08 +02:00
```
Read the spec, validate it against the schema, read live state through MTProto,
read `presence/resolved/<campaign>.yaml` , print a diff. Write nothing.
Validation is where the rights clamp lives: an `admin_rights` entry other than
`post_messages` fails here, loudly, rather than being quietly dropped. The intent
forbids the system from holding a right it may not exercise, and a spec is the
cheapest place to catch someone asking for one.
`plan` must be honest about what it cannot see. A username's availability is not
knowable without attempting it, so the plan says "will attempt, with fallbacks"
rather than promising an outcome.
2026-09-14 02:55:58 +02:00
**Done.** Go, `cmd/provision` , with `internal/spec` , `internal/state` and
`internal/plan` . Live observation is behind `plan.Live` ; `internal/tg.Live`
implements it over MTProto. `--offline` still exists for a plan that does not
contact Telegram, and says so rather than presenting an unobserved plan as a
drift check.
A spec digest change is an `update` of `spec` . BotFather profile fields the tool
cannot observe are a `reassert` : apply will repeat them, `plan --check` will not
treat them as drift.
Implement provision plan: spec, resolved state, and the diff
Go, per the toolchain decision. cmd/provision with internal/spec,
internal/state and internal/plan; plan computation is pure and takes live
observation through an interface, so the refusal logic is testable without
a Telegram account. It runs against the real campaign spec today.
Separates two refusals the design had treated as one. A deferral is the
design working -- the public channel waiting on a checked rendering, normal
on every first run. A block is the world disagreeing with the state file:
drifted rights, a taken-over username, a bot that is no longer reachable.
Collapsed together, a first run could never apply anything, because it
always defers the public channel.
The rights clamp and the private/public username rule are enforced in Go
and asserted against the same cases the JSON schema rejects, since mirroring
the schema in code is a drift risk worth a test rather than a comment.
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
2026-09-04 21:25:49 +02:00
2026-09-14 02:55:58 +02:00
Refusals are two different things. A **deferral** is the design working — the
public channel waiting on a checked rendering, which is normal on every first
run. A **block** is the world disagreeing with the state file — drifted rights,
a taken-over username, an unreachable bot. Collapsing them meant a first run
could never apply anything, since it always defers the public channel. They are
distinct kinds: a deferral holds one action, a block stops the run.
Implement provision plan: spec, resolved state, and the diff
Go, per the toolchain decision. cmd/provision with internal/spec,
internal/state and internal/plan; plan computation is pure and takes live
observation through an interface, so the refusal logic is testable without
a Telegram account. It runs against the real campaign spec today.
Separates two refusals the design had treated as one. A deferral is the
design working -- the public channel waiting on a checked rendering, normal
on every first run. A block is the world disagreeing with the state file:
drifted rights, a taken-over username, a bot that is no longer reachable.
Collapsed together, a first run could never apply anything, because it
always defers the public channel.
The rights clamp and the private/public username rule are enforced in Go
and asserted against the same cases the JSON schema rejects, since mirroring
the schema in code is a drift risk worth a test rather than a comment.
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
2026-09-04 21:25:49 +02:00
2026-09-04 19:21:08 +02:00
## T04 — Implement `provision apply`
```task
id: FT-WP-0002-T04
2026-09-14 02:55:58 +02:00
status: done
2026-09-04 19:21:08 +02:00
priority: high
2026-09-04 19:21:12 +02:00
state_hub_task_id: "5659b72a-8309-58b6-96e9-79137ce41ed4"
2026-09-04 19:21:08 +02:00
```
Execute an approved plan. Idempotent, keyed on resolved ids rather than names.
- Bot: BotFather `/newbot` , then `/setabouttext` , `/setdescription` , `/setuserpic` .
Parse the token from the reply, write it straight to OpenBao, and hold it in
memory nowhere longer than that call. Walk `username_preference` on collision.
- Channels: `channels.createChannel` , test channel first. Set the public
username; a private channel that declares one is a validation error, not a
silent skip.
- Bot as administrator: `channels.editAdmin` with `post_messages` and nothing
else.
- Write `presence/resolved/<campaign>.yaml` with the spec digest.
Three refusals carry the weight, and each is a real failure mode rather than a
hypothetical: there is **no destroy path** — removing a channel from the spec
warns, because deleting one destroys its subscribers and history irreversibly;
**drift on a destructive field stops the run** — a demoted bot or a taken-over
username exits non-zero rather than being "fixed" by a tool that does not
understand what happened; and **the public channel is not touched** until the
test channel has recorded a successful publication.
2026-09-14 02:55:58 +02:00
**Done as implementation.** `internal/apply` executes the plan against a
`Surface` (live MTProto, or a fake in tests). It reasserts the bot profile on
every apply, updates channel title/description when the spec digest changes, and
writes the token to OpenBao before anything else can fail. The first live apply
still waits on T01's session.
2026-09-04 19:21:08 +02:00
## T05 — Provision the redaction salt
```task
id: FT-WP-0002-T05
Implement the MTProto client, session bootstrap and apply
Completes the provisioner's write path. internal/tg drives BotFather as a
conversation rather than pretending it is an endpoint, creates channels,
claims usernames with fallbacks, and grants post_messages. internal/apply
sequences it: bot before administrator, test channel before public, and
state saved after every step that changed the world -- a channel that
exists but is unrecorded is worse than one that does not exist, because
the next run creates a second.
The operator session lives in OpenBao, not on disk. gotd's FileStorage
would leave a full-account credential in the working directory, where it
outlives the run and can be committed by accident.
The bot token goes straight from BotFather's reply to OpenBao and is
cleared from memory; if that write fails the error says how to recover by
hand and warns against re-running, since a retry creates a second bot.
Closes T05: the redaction salt is create-if-absent with no overwrite path,
and the test asserts it, because rotating it invalidates every longitudinal
comparison with no visible failure.
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
2026-09-04 21:39:30 +02:00
status: done
2026-09-04 19:21:08 +02:00
priority: high
2026-09-04 19:21:12 +02:00
state_hub_task_id: "d5fd905a-6abd-5ae1-9105-abc4c2a7127e"
2026-09-04 19:21:08 +02:00
```
Implement the MTProto client, session bootstrap and apply
Completes the provisioner's write path. internal/tg drives BotFather as a
conversation rather than pretending it is an endpoint, creates channels,
claims usernames with fallbacks, and grants post_messages. internal/apply
sequences it: bot before administrator, test channel before public, and
state saved after every step that changed the world -- a channel that
exists but is unrecorded is worse than one that does not exist, because
the next run creates a second.
The operator session lives in OpenBao, not on disk. gotd's FileStorage
would leave a full-account credential in the working directory, where it
outlives the run and can be committed by accident.
The bot token goes straight from BotFather's reply to OpenBao and is
cleared from memory; if that write fails the error says how to recover by
hand and warns against re-running, since a retry creates a second bot.
Closes T05: the redaction salt is create-if-absent with no overwrite path,
and the test asserts it, because rotating it invalidates every longitudinal
comparison with no visible failure.
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
2026-09-04 21:39:30 +02:00
**Implemented.** `secrets.CreateIfAbsent` generates 32 bytes if and only if the
OpenBao path is empty, and the package has no counterpart that overwrites.
2026-09-04 19:21:08 +02:00
Supersedes `FT-WP-0001` T03.
The create-if-absent rule is not defensiveness. `docs/observation.md` explains
that rotating the salt silently invalidates every longitudinal comparison the
interface has made, and does so with no visible failure — the numbers keep
arriving and quietly stop meaning what they used to. A tool that can rewrite it
is a tool that will eventually rewrite it, so the only path to a new salt is a
deliberate human write, which is a decision rather than a run.
## T06 — Derive adapter configuration from the resolved state
```task
id: FT-WP-0002-T06
2026-09-14 02:55:58 +02:00
status: done
2026-09-04 19:21:08 +02:00
priority: medium
2026-09-04 19:21:12 +02:00
state_hub_task_id: "c305b83f-b05e-5f09-9182-9896ae137c38"
2026-09-04 19:21:08 +02:00
```
The adapter reads its chat id and bot token from the resolved state and OpenBao.
It gets no MTProto session and no provisioning code path, so a compromised
adapter cannot create, rename, delete or re-permission anything. That separation
is the point of running two planes rather than one tool with a flag.
2026-09-14 02:55:58 +02:00
**Done.** `internal/adaptercfg` maps the resolved state onto one channel's chat
id and the OpenBao ref of the bot token. `provision adapter-config` prints that
and never the token. `LoadToken` reads `bot-token` only; tests fail if the
operator session is requested.
2026-09-04 19:21:08 +02:00
## T07 — Drift check
```task
id: FT-WP-0002-T07
2026-09-14 02:55:58 +02:00
status: done
2026-09-04 19:21:08 +02:00
priority: low
2026-09-04 19:21:12 +02:00
state_hub_task_id: "f3f36415-ed55-5717-b752-706b04ba5458"
2026-09-04 19:21:08 +02:00
```
`provision plan --check` exits non-zero on drift, suitable for a scheduled run.
Report only; never auto-apply. An unattended reconciler that can change a public
2026-09-14 02:55:58 +02:00
channel is the thing IEI-2 §7 exists to prevent.
**Done.** `--check` uses `Plan.Drifted()` , which ignores warnings, deferrals and
profile reassertion. A changed spec digest is drift; a converged presence is
quiet. `--check` cannot be combined with `--offline` .
2026-09-04 19:21:08 +02:00
## T08 — Settle the §7 reading
```task
id: FT-WP-0002-T08
2026-09-14 02:55:58 +02:00
status: done
2026-09-04 19:21:08 +02:00
priority: medium
2026-09-04 19:21:12 +02:00
state_hub_task_id: "864bcd23-6357-5460-a8e6-98ec9a929e29"
2026-09-04 19:21:08 +02:00
```
`InterfaceEvolutionIntent.md` §7 lists channel and bot creation under explicit
non-authority without distinguishing the Daimon from an operator-run tool. The
intended reading is that §7 constrains *autonomous* action and an approved plan
2026-09-14 02:55:58 +02:00
is not autonomous — but that was a reading rather than something the document
said, and a constitutional document should not depend on one.
**Done.** IEI-2 adds: "These prohibitions bind the Daimon acting autonomously.
An operator-triggered provisioning tool that executes a plan a person has
approved is not the Daimon and is not autonomous: it is how the presence is
established, not a grant of authority to the request path." Recorded as a
governance amendment under §20. The request-path prohibitions are unchanged.