Finish the provisioner's remaining plane: honest check, adapter config, IEI-2

plan --check treated BotFather profile reassertion as drift, so a scheduled
check could never go quiet. Reassertion is now its own kind; a changed spec
digest is the drift signal. apply reasserts the profile, updates channel copy
when the spec changes, and is tested against a fake Telegram surface.

The adapter reads its chat id from resolved state and its token from OpenBao,
never the operator session. IEI-2 records that an approved provisioning plan
is not autonomous Daimon action.

Assistant: grok
Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
This commit is contained in:
tegwick 2026-09-14 02:55:58 +02:00
parent f7af151f37
commit 1e9481e108
23 changed files with 995 additions and 128 deletions

View file

@ -1,6 +1,6 @@
---
repo: fluid-telegram
updated: "2026-09-04"
updated: "2026-09-14"
---
# INTENT
@ -35,14 +35,14 @@ shortens or embellishes. A post that will not fit is refused, not truncated.
## Governing principle
`InterfaceEvolutionIntent.md` (IEI-1) is the constitutional constraint on this
`InterfaceEvolutionIntent.md` (IEI-2) is the constitutional constraint on this
interface and takes precedence over this file where they touch. It is
subordinate in turn to `helix-forge/INTENT.md`.
Operational authority is **FLUID-2 (Advisory)** by deliberate choice: the Daimon
may observe, classify pressure and propose adaptations, but may not generate
implementations, run experiments unattended, or promote anything. Raising that
mode is a governance change under IEI-1 §20, not an operational decision.
mode is a governance change under IEI-2 §20, not an operational decision.
Work that changes this repository's authority, ownership boundaries, or
operational promises is captured in a workplan before implementation.

View file

@ -1,8 +1,8 @@
# Interface Evolution Intent — helix-forge-telegram-publishing
**Document version:** IEI-1
**Document version:** IEI-2
**Status:** active
**Last reviewed:** 2026-09-04
**Last reviewed:** 2026-09-14
---
@ -190,6 +190,11 @@ The Daimon must not autonomously:
- publish to any chat other than those declared in the routing configuration;
- publish a post about a person who has not consented, on either basis in §15.
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.
The last two matter most, and they are the reason composition sits outside this
interface. A post is a condensation written in HelixForge's voice about a named
person's work. It can flatter, it can misstate, and it can put a claim in
@ -566,7 +571,13 @@ of scope, the first 100 subscribers, or annually.
### Last reviewed
2026-09-04 (initial)
2026-09-14 (IEI-2)
### Amendments
- **IEI-2** (2026-09-14): §7 now states that an approved, operator-triggered
provisioning plan is not autonomous Daimon action. The request-path
prohibitions are unchanged. Previous version: IEI-1 (2026-09-04).
---

View file

@ -5,12 +5,14 @@ Fluid interface for telegram messaging and interaction platform.
## Status
The FLUID interface `helix-forge-telegram-publishing` is prepared but not yet
live. Governance, contract, seed hypotheses and the adapter's connector contract
are in place; the bot, the channel and the adapter itself are not.
live. Governance, contract, seed hypotheses, the adapter's connector contract
and the presence provisioner are in place; the bot, the channel and the adapter
itself are not. The first live apply waits on the operator seeding runbook.
- Governance: [`InterfaceEvolutionIntent.md`](InterfaceEvolutionIntent.md)
- The channel publishes composed posts written *from* hall entries, not the
entries themselves. Composition happens upstream and moves to
`pr-hall-of-helix` once the loop has closed once (FT-WP-0001 T13).
- Work: [`workplans/FT-WP-0001-telegram-identity-and-hall-channel.md`](workplans/FT-WP-0001-telegram-identity-and-hall-channel.md)
- Provisioning: [`workplans/FT-WP-0002-declared-presence-provisioning.md`](workplans/FT-WP-0002-declared-presence-provisioning.md)
- Handover from fluid-core: `fluid-core/docs/handover/fluid-telegram.md`

View file

@ -42,7 +42,7 @@ it is carried and proven* lives here.
and URL a post cites, which it renders as a link.
- **Autonomous administration.** Creating, renaming or deleting channels, groups
or bots, and changing membership or permissions, are forbidden to the Daimon
(IEI-1 §7). Where such acts are automated at all, they run in the separate,
(IEI-2 §7). Where such acts are automated at all, they run in the separate,
human-approved provisioning plane, never in the request path.
- Owning unrelated adjacent systems.
@ -62,7 +62,7 @@ not.
## Getting Oriented
- Why it exists: `INTENT.md`
- What may change and what may not: `InterfaceEvolutionIntent.md` (IEI-1)
- What may change and what may not: `InterfaceEvolutionIntent.md` (IEI-2)
- What the adapter owes: `docs/adapter-contract.md`
- Platform capability reference: `TelegramCapabilityCanon.md`
- Agent instructions: `AGENTS.md`

View file

@ -17,6 +17,7 @@ import (
"fmt"
"os"
"github.com/tegwick/fluid-telegram/internal/adaptercfg"
"github.com/tegwick/fluid-telegram/internal/apply"
"github.com/tegwick/fluid-telegram/internal/plan"
"github.com/tegwick/fluid-telegram/internal/secrets"
@ -30,6 +31,8 @@ const usage = `provision -- reconcile a Telegram presence with its declared spec
provision preflight --spec <path> check everything short of contacting telegram
provision plan --spec <path> [--check] show what would change; writes nothing
provision apply --spec <path> execute an approved plan
provision adapter-config --spec <path> --channel test|public
print the adapter's non-secret configuration
provision session bootstrap --campaign <slug> mint the operator session (interactive)
provision session check --campaign <slug> verify the stored session
@ -57,6 +60,8 @@ func main() {
err = cmdReconcile(os.Args[2:], true)
case "preflight":
err = cmdPreflight(os.Args[2:])
case "adapter-config":
err = cmdAdapterConfig(os.Args[2:])
case "session":
err = cmdSession(os.Args[2:])
case "-h", "--help", "help":
@ -134,23 +139,23 @@ func cmdReconcile(args []string, doApply bool) error {
}
fmt.Print(p.Render())
if *check && !p.Empty() {
if *check && p.Drifted() {
return errors.New("presence has drifted from the spec")
}
if p.Blocked() {
os.Exit(1)
return errors.New("plan is blocked; nothing applied")
}
if !doApply {
return nil
}
if p.Empty() {
if !p.NeedsApply() {
fmt.Println("\nnothing to apply")
return nil
}
fmt.Println("\napplying:")
return apply.Run(ctx, p, apply.Options{
Spec: sp, SpecDigest: digest, State: rs, StatePath: statePath,
Store: store, Client: c, Out: os.Stdout,
Store: store, Surface: apply.Live(c), Out: os.Stdout,
})
})
}
@ -165,3 +170,28 @@ func (offlineLive) ChannelAdminRights(int64) ([]string, error) {
return []string{spec.PostMessages}, nil
}
func (offlineLive) ChannelUsername(int64) (string, error) { return "", nil }
func cmdAdapterConfig(args []string) error {
fs := flag.NewFlagSet("adapter-config", flag.ExitOnError)
specPath := fs.String("spec", "", "path to the presence spec")
root := fs.String("root", ".", "repo root holding presence/resolved/")
channel := fs.String("channel", "", "which channel this adapter instance publishes to (test or public)")
fs.Parse(args)
if *specPath == "" {
return errors.New("--spec is required")
}
sp, _, err := spec.Load(*specPath)
if err != nil {
return err
}
rs, err := state.Load(state.Path(*root, sp.Campaign))
if err != nil {
return err
}
cfg, err := adaptercfg.FromResolved(rs, *channel, secrets.RefFor(sp.Campaign, secrets.KeyBotToken))
if err != nil {
return err
}
fmt.Print(cfg.EnvFile())
return nil
}

View file

@ -115,10 +115,20 @@ from Telegram's channel statistics as aggregates, never per-reader.
## Configuration
Chat id and bot token are derived, not hand-written. `provision adapter-config`
reads `presence/resolved/<campaign>.yaml` for the chat id and names the OpenBao
path of the bot token; the runtime injects the token from that path. The adapter
never reads the operator session.
```bash
provision adapter-config --spec <path> --channel test # or public
```
| Variable | Meaning |
|---|---|
| `TELEGRAM_BOT_TOKEN` | Injected from OpenBao at start |
| `TELEGRAM_CHANNEL_ID` | The channel this adapter publishes to |
| `TELEGRAM_BOT_TOKEN` | Injected from OpenBao at start (`bot-token` under the campaign subtree) |
| `TELEGRAM_BOT_TOKEN_REF` | The OpenBao location of that token; printed by `adapter-config`, never the value |
| `TELEGRAM_CHANNEL_ID` | The channel this adapter publishes to, taken from the resolved state |
| `FLUID_CONTROL_URL` | Where to POST telemetry |
| `HALL_BASE_URL` | Base URL for links back to hall-of-helix |
| `ATTACH_VISUAL` | Whether this revision attaches the portrait (R-2 true, R-3 false) |

View file

@ -83,8 +83,9 @@ 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.
request path, and every run is a plan a person approved. IEI-2 states that
reading in the document: §7 constrains autonomous administration, not an
approved apply.
## plan / apply
@ -170,6 +171,16 @@ 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.
The adapter reads its chat id from this file and its bot token from OpenBao.
It gets neither the operator session nor any provisioning code path:
```bash
provision adapter-config --spec <path> --channel test # or public
```
That command prints `TELEGRAM_CHANNEL_ID` and `TELEGRAM_BOT_TOKEN_REF`. It never
prints the token.
## Rate and account safety
Automating a user account is not what Telegram's terms are written around. Three
@ -184,12 +195,9 @@ mitigations, all cheap:
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
## §7, settled
`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.
IEI-2 states the reading this design depends on: an operator-triggered
provisioning tool that executes a plan a person has approved is not the Daimon
and is not autonomous. The request path is unchanged — the adapter still holds
`post_messages` and nothing else.

View file

@ -23,7 +23,7 @@ fails with a Telegram `400`.
3. If the bot was removed entirely, re-add it and re-run one test publication.
A human must do this. The Daimon is forbidden from changing channel membership
or permissions (`InterfaceEvolutionIntent.md` §7).
or permissions (`InterfaceEvolutionIntent.md` IEI-2 §7).
## Rolling back a revision

View file

@ -0,0 +1,86 @@
// Package adaptercfg derives the adapter's configuration from the resolved
// presence and OpenBao.
//
// The adapter publishes. It holds a bot token with post_messages and a single
// chat id, and it has no MTProto session and no provisioning code path. That
// separation is the point of running two planes: a compromised adapter cannot
// create, rename, delete or re-permission anything.
package adaptercfg
import (
"context"
"fmt"
"strings"
"github.com/tegwick/fluid-telegram/internal/secrets"
"github.com/tegwick/fluid-telegram/internal/spec"
"github.com/tegwick/fluid-telegram/internal/state"
)
// Config is what one adapter instance needs to publish to one channel.
type Config struct {
Campaign string
Channel string
ChatID int64
TokenRef string
}
// FromResolved maps the committed state onto one adapter instance. It never
// reads a secret: the token stays in OpenBao, and TokenRef is where to ask.
func FromResolved(rs *state.Resolved, channel, tokenRef string) (Config, error) {
if rs == nil || rs.Bot.ID == 0 {
return Config{}, fmt.Errorf("presence is not provisioned; run `provision apply` first")
}
switch channel {
case spec.Test, spec.Live:
case "":
return Config{}, fmt.Errorf("--channel is required (%q or %q)", spec.Test, spec.Live)
default:
return Config{}, fmt.Errorf("channel %q is not %q or %q", channel, spec.Test, spec.Live)
}
ch, ok := rs.Channels[channel]
if !ok {
if channel == spec.Live && !rs.TestChannelVerified(spec.Test) {
return Config{}, fmt.Errorf("the public channel is not provisioned yet; " +
"it is held until the test channel has a checked rendering")
}
return Config{}, fmt.Errorf("channel %q is not in the resolved state", channel)
}
if ch.ChatID == 0 {
return Config{}, fmt.Errorf("channel %q has no chat id in the resolved state", channel)
}
return Config{
Campaign: rs.Campaign,
Channel: channel,
ChatID: ch.ChatID,
TokenRef: tokenRef,
}, nil
}
// EnvFile is the non-secret configuration an adapter process is started with.
// The token is named, not emitted: the runtime injects it from OpenBao.
func (c Config) EnvFile() string {
var b strings.Builder
fmt.Fprintf(&b, "# adapter configuration for %s / %s\n", c.Campaign, c.Channel)
fmt.Fprintf(&b, "# derived from presence/resolved/%s.yaml; no secrets in this file\n", c.Campaign)
fmt.Fprintf(&b, "TELEGRAM_CHANNEL_ID=%d\n", c.ChatID)
fmt.Fprintf(&b, "TELEGRAM_BOT_TOKEN_REF=%s\n", c.TokenRef)
return b.String()
}
// LoadToken reads the bot token and nothing else. The operator session is a
// full-account credential; the adapter must never request it.
func LoadToken(ctx context.Context, store *secrets.Store) (string, error) {
fields, found, err := store.Get(ctx, secrets.KeyBotToken)
if err != nil {
return "", err
}
if !found {
return "", fmt.Errorf("no bot token at %s; run `provision apply`", store.Ref(secrets.KeyBotToken))
}
token := fields["token"]
if token == "" {
return "", fmt.Errorf("bot token at %s is empty", store.Ref(secrets.KeyBotToken))
}
return token, nil
}

View file

@ -0,0 +1,114 @@
package adaptercfg
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tegwick/fluid-telegram/internal/secrets"
"github.com/tegwick/fluid-telegram/internal/spec"
"github.com/tegwick/fluid-telegram/internal/state"
)
func provisioned() *state.Resolved {
now := time.Now()
return &state.Resolved{
Campaign: "hall-of-helix",
Bot: state.Bot{Username: "HelixForgeBot", ID: 42},
Channels: map[string]state.Channel{
spec.Test: {ChatID: -1001, AdminRights: []string{spec.PostMessages}, TestPublicationAt: &now},
spec.Live: {ChatID: -2002, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}},
},
}
}
func TestFromResolvedPicksOneChannel(t *testing.T) {
cfg, err := FromResolved(provisioned(), spec.Test, "bao:platform/x/bot-token")
if err != nil {
t.Fatal(err)
}
if cfg.ChatID != -1001 || cfg.Channel != spec.Test {
t.Fatalf("got %+v", cfg)
}
cfg, err = FromResolved(provisioned(), spec.Live, "bao:platform/x/bot-token")
if err != nil {
t.Fatal(err)
}
if cfg.ChatID != -2002 {
t.Fatalf("public chat id = %d", cfg.ChatID)
}
}
func TestFromResolvedRefusesUnprovisioned(t *testing.T) {
if _, err := FromResolved(&state.Resolved{}, spec.Test, "ref"); err == nil {
t.Fatal("accepted an empty state")
}
rs := provisioned()
delete(rs.Channels, spec.Live)
rs.Channels[spec.Test] = state.Channel{ChatID: -1001, AdminRights: []string{spec.PostMessages}}
if _, err := FromResolved(rs, spec.Live, "ref"); err == nil {
t.Fatal("accepted a public channel that has not been provisioned")
}
}
func TestEnvFileHasNoToken(t *testing.T) {
cfg, _ := FromResolved(provisioned(), spec.Test, "bao:platform/x/bot-token")
out := cfg.EnvFile()
for _, line := range strings.Split(out, "\n") {
if strings.HasPrefix(line, "TELEGRAM_BOT_TOKEN=") {
t.Fatal("env file emitted a token value")
}
}
if !strings.Contains(out, "TELEGRAM_CHANNEL_ID=-1001") {
t.Fatalf("missing chat id:\n%s", out)
}
if !strings.Contains(out, "TELEGRAM_BOT_TOKEN_REF=bao:platform/x/bot-token") {
t.Fatalf("missing token ref:\n%s", out)
}
if strings.Contains(out, "operator-session") {
t.Fatal("adapter config mentioned the operator session")
}
}
func TestLoadTokenReadsOnlyTheBotToken(t *testing.T) {
var requested []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = append(requested, r.URL.Path)
if strings.Contains(r.URL.Path, secrets.KeyOperatorSession) {
t.Errorf("adapter requested the operator session: %s", r.URL.Path)
w.WriteHeader(http.StatusForbidden)
return
}
if !strings.HasSuffix(r.URL.Path, "/"+secrets.KeyBotToken) {
t.Errorf("unexpected path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{"data": map[string]string{"token": "123:abc"}},
})
}))
t.Cleanup(srv.Close)
t.Setenv("BAO_ADDR", srv.URL)
t.Setenv("BAO_TOKEN", "test-token")
store, err := secrets.NewFromEnv("hall-of-helix")
if err != nil {
t.Fatal(err)
}
got, err := LoadToken(context.Background(), store)
if err != nil {
t.Fatal(err)
}
if got != "123:abc" {
t.Fatalf("token = %q", got)
}
for _, p := range requested {
if strings.Contains(p, secrets.KeyOperatorSession) || strings.Contains(p, secrets.KeyOperatorApp) {
t.Fatalf("adapter store was asked for %s", p)
}
}
}

View file

@ -16,8 +16,6 @@ import (
"io"
"time"
"github.com/gotd/td/tg"
"github.com/tegwick/fluid-telegram/internal/plan"
"github.com/tegwick/fluid-telegram/internal/secrets"
"github.com/tegwick/fluid-telegram/internal/spec"
@ -30,8 +28,8 @@ type Options struct {
SpecDigest string
State *state.Resolved
StatePath string
Store *secrets.Store
Client *tgc.Client
Store SecretStore
Surface Surface
Out io.Writer
}
@ -50,11 +48,10 @@ func Run(ctx context.Context, p *plan.Plan, o Options) error {
if err := ensureSalt(ctx, o); err != nil {
return err
}
botUser, err := ensureBot(ctx, o, p)
if err != nil {
if err := ensureBot(ctx, o, p); err != nil {
return err
}
if err := ensureChannels(ctx, o, botUser); err != nil {
if err := ensureChannels(ctx, o); err != nil {
return err
}
@ -85,22 +82,21 @@ func ensureSalt(ctx context.Context, o Options) error {
return nil
}
func ensureBot(ctx context.Context, o Options, p *plan.Plan) (tg.InputUserClass, error) {
func ensureBot(ctx context.Context, o Options, p *plan.Plan) error {
if o.State.Bot.ID != 0 {
fmt.Fprintf(o.Out, " ok bot @%s\n", o.State.Bot.Username)
user, err := resolveBot(ctx, o, o.State.Bot.Username)
if err != nil {
return nil, err
if _, err := o.Surface.ResolveBot(ctx, o.State.Bot.Username); err != nil {
return err
}
if err := ensureAvatar(ctx, o, p, nil); err != nil {
return nil, err
if err := reassertProfile(ctx, o); err != nil {
return err
}
return user, nil
return ensureAvatar(ctx, o, p)
}
conv, err := o.Client.BotFather(ctx)
conv, err := o.Surface.OpenBotFather(ctx)
if err != nil {
return nil, err
return err
}
var username, token string
@ -113,17 +109,17 @@ func ensureBot(ctx context.Context, o Options, p *plan.Plan) (tg.InputUserClass,
fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", candidate)
continue
}
return nil, err
return err
}
if token == "" {
return nil, fmt.Errorf("every username candidate was taken; add another to "+
return fmt.Errorf("every username candidate was taken; add another to "+
"bot.username_preference in the spec (tried %d)", len(o.Spec.Bot.UsernamePreference))
}
// Straight to OpenBao, before anything else can fail. The token is not
// printed, not logged, and not returned past this point.
if err := o.Store.Put(ctx, secrets.KeyBotToken, map[string]string{"token": token}); err != nil {
return nil, fmt.Errorf("the bot @%s was created but its token could not be stored: %w\n"+
return fmt.Errorf("the bot @%s was created but its token could not be stored: %w\n"+
"recover it from the @BotFather chat and write it to %s by hand; do not "+
"re-run, which would create a second bot", username, err,
o.Store.Ref(secrets.KeyBotToken))
@ -135,37 +131,48 @@ func ensureBot(ctx context.Context, o Options, p *plan.Plan) (tg.InputUserClass,
o.State.Bot.Username = username
if err := state.Save(o.StatePath, o.State); err != nil {
return nil, err
return err
}
if err := conv.SetProfile(ctx, username, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil {
return nil, err
if err := conv.SetProfile(ctx, username, o.Spec.Bot.Name, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil {
return err
}
fmt.Fprintln(o.Out, " set bot about text and description")
fmt.Fprintln(o.Out, " set bot name, about text and description")
if err := ensureAvatar(ctx, o, p, conv); err != nil {
return nil, err
if err := ensureAvatar(ctx, o, p); err != nil {
return err
}
user, err := resolveBot(ctx, o, username)
id, err := o.Surface.ResolveBot(ctx, username)
if err != nil {
return nil, err
return err
}
return user, state.Save(o.StatePath, o.State)
o.State.Bot.ID = id
return state.Save(o.StatePath, o.State)
}
func reassertProfile(ctx context.Context, o Options) error {
conv, err := o.Surface.OpenBotFather(ctx)
if err != nil {
return err
}
if err := conv.SetProfile(ctx, o.State.Bot.Username, o.Spec.Bot.Name, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil {
return err
}
fmt.Fprintln(o.Out, " set bot name, about text and description")
return nil
}
// ensureAvatar sends the picture when the plan found one that differs from what
// is recorded. The digest is written only after BotFather confirms, so a failed
// upload is retried next run rather than being remembered as done.
func ensureAvatar(ctx context.Context, o Options, p *plan.Plan, conv *tgc.Conversation) error {
func ensureAvatar(ctx context.Context, o Options, p *plan.Plan) error {
if p.Avatar == nil || o.State.Bot.AvatarDigest == p.Avatar.Digest {
return nil
}
if conv == nil {
var err error
if conv, err = o.Client.BotFather(ctx); err != nil {
return err
}
conv, err := o.Surface.OpenBotFather(ctx)
if err != nil {
return err
}
if err := conv.SetAvatar(ctx, o.State.Bot.Username, p.Avatar.Path); err != nil {
return err
@ -175,23 +182,8 @@ func ensureAvatar(ctx context.Context, o Options, p *plan.Plan, conv *tgc.Conver
return state.Save(o.StatePath, o.State)
}
func resolveBot(ctx context.Context, o Options, username string) (tg.InputUserClass, error) {
res, err := o.Client.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
Username: username,
})
if err != nil {
return nil, fmt.Errorf("resolve @%s: %w", username, err)
}
for _, u := range res.Users {
if user, ok := u.(*tg.User); ok {
o.State.Bot.ID = user.ID
return &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash}, nil
}
}
return nil, fmt.Errorf("@%s did not resolve to a user", username)
}
func ensureChannels(ctx context.Context, o Options, bot tg.InputUserClass) error {
func ensureChannels(ctx context.Context, o Options) error {
specChanged := o.State.SpecDigest != "" && o.State.SpecDigest != o.SpecDigest
// Test first, always. Not a convention a caller may reorder.
for _, name := range []string{spec.Test, spec.Live} {
sc, ok := o.Spec.Channels[name]
@ -202,26 +194,35 @@ func ensureChannels(ctx context.Context, o Options, bot tg.InputUserClass) error
fmt.Fprintln(o.Out, " held public channel, until the test channel has a checked rendering")
continue
}
if _, done := o.State.Channels[name]; done {
if rec, done := o.State.Channels[name]; done {
fmt.Fprintf(o.Out, " ok channel %s\n", name)
if specChanged {
if err := o.Surface.EditChannelTitle(ctx, rec.ChatID, sc.Title); err != nil {
return err
}
if err := o.Surface.EditChannelAbout(ctx, rec.ChatID, sc.Description); err != nil {
return err
}
fmt.Fprintf(o.Out, " set channel %s title and description\n", name)
}
continue
}
ch, err := o.Client.CreateChannel(ctx, sc.Title, sc.Description)
chatID, err := o.Surface.CreateChannel(ctx, sc.Title, sc.Description)
if err != nil {
return err
}
rec := state.Channel{ChatID: ch.ChatID, AdminRights: []string{spec.PostMessages}}
rec := state.Channel{ChatID: chatID, AdminRights: []string{spec.PostMessages}}
o.State.Channels[name] = rec
if err := state.Save(o.StatePath, o.State); err != nil {
return err
}
fmt.Fprintf(o.Out, " created channel %s (%d)\n", name, ch.ChatID)
fmt.Fprintf(o.Out, " created channel %s (%d)\n", name, chatID)
if sc.Visibility == spec.Public {
claimed := ""
for _, cand := range sc.UsernamePreference {
ok, err := o.Client.SetUsername(ctx, ch, cand)
ok, err := o.Surface.ClaimUsername(ctx, chatID, cand)
if err != nil {
return err
}
@ -243,7 +244,7 @@ func ensureChannels(ctx context.Context, o Options, bot tg.InputUserClass) error
fmt.Fprintf(o.Out, " claimed @%s\n", claimed)
}
if err := o.Client.PromoteBot(ctx, ch, bot, o.State.Bot.Username); err != nil {
if err := o.Surface.PromoteBot(ctx, chatID, o.State.Bot.Username); err != nil {
return err
}
fmt.Fprintf(o.Out, " granted post_messages to @%s on %s\n", o.State.Bot.Username, name)

View file

@ -0,0 +1,310 @@
package apply
import (
"bytes"
"context"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
"github.com/tegwick/fluid-telegram/internal/plan"
"github.com/tegwick/fluid-telegram/internal/secrets"
"github.com/tegwick/fluid-telegram/internal/spec"
"github.com/tegwick/fluid-telegram/internal/state"
tgc "github.com/tegwick/fluid-telegram/internal/tg"
)
type memStore struct {
data map[string]map[string]string
}
func (s *memStore) CreateIfAbsent(_ context.Context, key string, fields map[string]string) (bool, error) {
if s.data == nil {
s.data = map[string]map[string]string{}
}
if _, ok := s.data[key]; ok {
return false, nil
}
s.data[key] = fields
return true, nil
}
func (s *memStore) Put(_ context.Context, key string, fields map[string]string) error {
if s.data == nil {
s.data = map[string]map[string]string{}
}
s.data[key] = fields
return nil
}
func (s *memStore) Ref(key string) string { return "bao:test/" + key }
type fakeConv struct {
taken map[string]bool
profiles int
}
func (c *fakeConv) RegisterBot(_ context.Context, _, username string) (string, string, error) {
if c.taken[username] {
return "", "", fmt.Errorf("%w: @%s", tgc.ErrUsernameTaken, username)
}
return username, "123:token-for-" + username, nil
}
func (c *fakeConv) SetProfile(context.Context, string, string, string, string) error {
c.profiles++
return nil
}
func (c *fakeConv) SetAvatar(context.Context, string, string) error { return nil }
type fakeSurface struct {
conv *fakeConv
nextChat int64
created []string
claimed []string
promoted []string
titles map[int64]string
abouts map[int64]string
botID int64
}
func (f *fakeSurface) OpenBotFather(context.Context) (BotConversation, error) {
if f.conv == nil {
f.conv = &fakeConv{}
}
return f.conv, nil
}
func (f *fakeSurface) ResolveBot(_ context.Context, _ string) (int64, error) {
if f.botID == 0 {
f.botID = 42
}
return f.botID, nil
}
func (f *fakeSurface) CreateChannel(_ context.Context, title, about string) (int64, error) {
f.nextChat--
id := f.nextChat
f.created = append(f.created, title)
if f.titles == nil {
f.titles = map[int64]string{}
f.abouts = map[int64]string{}
}
f.titles[id] = title
f.abouts[id] = about
return id, nil
}
func (f *fakeSurface) ClaimUsername(_ context.Context, _ int64, username string) (bool, error) {
f.claimed = append(f.claimed, username)
return true, nil
}
func (f *fakeSurface) PromoteBot(_ context.Context, _ int64, botUsername string) error {
f.promoted = append(f.promoted, botUsername)
return nil
}
func (f *fakeSurface) EditChannelTitle(_ context.Context, chatID int64, title string) error {
if f.titles == nil {
f.titles = map[int64]string{}
}
f.titles[chatID] = title
return nil
}
func (f *fakeSurface) EditChannelAbout(_ context.Context, chatID int64, about string) error {
if f.abouts == nil {
f.abouts = map[int64]string{}
}
f.abouts[chatID] = about
return nil
}
func presence() *spec.Presence {
return &spec.Presence{
SchemaVersion: "0.1", Campaign: "hall-of-helix", Interface: "i",
Bot: spec.Bot{Name: "HelixForge", UsernamePreference: []string{"HelixForgeBot"},
About: "about", Description: "desc"},
Channels: map[string]spec.Channel{
spec.Test: {Title: "test-title", Description: "test-about", Visibility: spec.Private},
spec.Live: {Title: "live-title", Description: "live-about", Visibility: spec.Public,
UsernamePreference: []string{"hallofhelix"}},
},
}
}
func opts(t *testing.T, rs *state.Resolved, surf *fakeSurface, store *memStore) Options {
t.Helper()
if rs == nil {
rs = &state.Resolved{}
}
if surf == nil {
surf = &fakeSurface{}
}
if store == nil {
store = &memStore{}
}
return Options{
Spec: presence(), SpecDigest: "sha256:x", State: rs,
StatePath: filepath.Join(t.TempDir(), "resolved.yaml"),
Store: store, Surface: surf, Out: &bytes.Buffer{},
}
}
func TestApplyRefusesBlockedPlan(t *testing.T) {
p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Block, Target: "bot", Detail: "gone"}}}
err := Run(context.Background(), p, opts(t, nil, nil, nil))
if err == nil {
t.Fatal("applied a blocked plan")
}
}
func TestFirstRunCreatesBotAndTestChannelOnly(t *testing.T) {
surf := &fakeSurface{}
store := &memStore{}
o := opts(t, nil, surf, store)
p := &plan.Plan{Actions: []plan.Action{
{Kind: plan.Attempt, Target: "bot"},
{Kind: plan.Create, Target: "channel.test"},
{Kind: plan.Defer, Target: "channel.public"},
}}
if err := Run(context.Background(), p, o); err != nil {
t.Fatal(err)
}
if store.data[secrets.KeyBotToken]["token"] == "" {
t.Fatal("bot token was not stored")
}
if _, ok := store.data[secrets.KeyRedactionSalt]; !ok {
t.Fatal("redaction salt was not created")
}
if o.State.Bot.Username != "HelixForgeBot" || o.State.Bot.ID == 0 {
t.Fatalf("bot state = %+v", o.State.Bot)
}
if _, ok := o.State.Channels[spec.Test]; !ok {
t.Fatal("test channel was not recorded")
}
if _, ok := o.State.Channels[spec.Live]; ok {
t.Fatal("public channel was created before the test channel was verified")
}
if len(surf.promoted) != 1 {
t.Fatalf("promotions = %v", surf.promoted)
}
}
func TestWalksUsernameCandidates(t *testing.T) {
surf := &fakeSurface{conv: &fakeConv{taken: map[string]bool{"HelixForgeBot": true}}}
sp := presence()
sp.Bot.UsernamePreference = []string{"HelixForgeBot", "HelixForgePublishBot"}
o := opts(t, nil, surf, nil)
o.Spec = sp
p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Attempt, Target: "bot"}}}
if err := Run(context.Background(), p, o); err != nil {
t.Fatal(err)
}
if o.State.Bot.Username != "HelixForgePublishBot" {
t.Fatalf("username = %s", o.State.Bot.Username)
}
}
func TestExistingBotReassertsProfile(t *testing.T) {
surf := &fakeSurface{}
now := time.Now()
rs := &state.Resolved{
Campaign: "hall-of-helix", SpecDigest: "sha256:x",
Bot: state.Bot{Username: "HelixForgeBot", ID: 42},
Channels: map[string]state.Channel{
spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}, TestPublicationAt: &now},
spec.Live: {ChatID: -200, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}},
},
}
o := opts(t, rs, surf, &memStore{data: map[string]map[string]string{
secrets.KeyRedactionSalt: {"salt": "already"},
}})
p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Reassert, Target: "bot.profile"}}}
if err := Run(context.Background(), p, o); err != nil {
t.Fatal(err)
}
if surf.conv == nil || surf.conv.profiles == 0 {
t.Fatal("existing bot did not reassert its profile")
}
if len(surf.created) != 0 {
t.Fatalf("recreated channels: %v", surf.created)
}
}
func TestSpecChangeUpdatesChannelCopy(t *testing.T) {
surf := &fakeSurface{}
now := time.Now()
rs := &state.Resolved{
Campaign: "hall-of-helix", SpecDigest: "sha256:old",
Bot: state.Bot{Username: "HelixForgeBot", ID: 42},
Channels: map[string]state.Channel{
spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}, TestPublicationAt: &now},
spec.Live: {ChatID: -200, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}},
},
}
o := opts(t, rs, surf, &memStore{data: map[string]map[string]string{
secrets.KeyRedactionSalt: {"salt": "already"},
}})
p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Update, Target: "spec"}}}
if err := Run(context.Background(), p, o); err != nil {
t.Fatal(err)
}
if surf.titles[-100] != "test-title" || surf.titles[-200] != "live-title" {
t.Fatalf("titles = %v", surf.titles)
}
}
func TestSaltIsCreatedOnce(t *testing.T) {
store := &memStore{}
p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Attempt, Target: "bot"}}}
if err := Run(context.Background(), p, opts(t, nil, nil, store)); err != nil {
t.Fatal(err)
}
first := store.data[secrets.KeyRedactionSalt]["salt"]
rs := &state.Resolved{
Campaign: "hall-of-helix",
Bot: state.Bot{Username: "HelixForgeBot", ID: 42},
Channels: map[string]state.Channel{
spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}},
},
}
if err := Run(context.Background(), p, opts(t, rs, nil, store)); err != nil {
t.Fatal(err)
}
if store.data[secrets.KeyRedactionSalt]["salt"] != first {
t.Fatal("redaction salt was rotated")
}
}
func TestPublicReleasedAfterTestPublication(t *testing.T) {
now := time.Now()
rs := &state.Resolved{
Campaign: "hall-of-helix",
Bot: state.Bot{Username: "HelixForgeBot", ID: 42},
Channels: map[string]state.Channel{
spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}, TestPublicationAt: &now},
},
}
surf := &fakeSurface{}
o := opts(t, rs, surf, &memStore{data: map[string]map[string]string{
secrets.KeyRedactionSalt: {"salt": "already"},
}})
p := &plan.Plan{Actions: []plan.Action{{Kind: plan.Create, Target: "channel.public"}}}
if err := Run(context.Background(), p, o); err != nil {
t.Fatal(err)
}
pub, ok := o.State.Channels[spec.Live]
if !ok {
t.Fatal("public channel was not created")
}
if pub.Username != "hallofhelix" {
t.Fatalf("username = %s", pub.Username)
}
if !strings.Contains(strings.Join(surf.promoted, ","), "HelixForgeBot") {
t.Fatalf("bot was not promoted on public: %v", surf.promoted)
}
}

138
internal/apply/surface.go Normal file
View file

@ -0,0 +1,138 @@
package apply
import (
"context"
"fmt"
"github.com/gotd/td/tg"
"github.com/tegwick/fluid-telegram/internal/secrets"
tgc "github.com/tegwick/fluid-telegram/internal/tg"
)
// Surface is the Telegram operations apply needs. The real client is one
// implementation; tests supply another so apply can be checked without a session.
type Surface interface {
OpenBotFather(ctx context.Context) (BotConversation, error)
ResolveBot(ctx context.Context, username string) (id int64, err error)
CreateChannel(ctx context.Context, title, about string) (chatID int64, err error)
ClaimUsername(ctx context.Context, chatID int64, username string) (bool, error)
PromoteBot(ctx context.Context, chatID int64, botUsername string) error
EditChannelTitle(ctx context.Context, chatID int64, title string) error
EditChannelAbout(ctx context.Context, chatID int64, about string) error
}
// BotConversation is the BotFather exchange apply drives.
type BotConversation interface {
RegisterBot(ctx context.Context, displayName, username string) (issuedUsername, token string, err error)
SetProfile(ctx context.Context, username, name, about, description string) error
SetAvatar(ctx context.Context, username, path string) error
}
// SecretStore is the OpenBao operations apply needs. *secrets.Store satisfies it.
type SecretStore interface {
CreateIfAbsent(ctx context.Context, key string, fields map[string]string) (bool, error)
Put(ctx context.Context, key string, fields map[string]string) error
Ref(key string) string
}
var (
_ Surface = (*live)(nil)
_ BotConversation = (*tgc.Conversation)(nil)
_ SecretStore = (*secrets.Store)(nil)
)
type live struct {
c *tgc.Client
bot tg.InputUserClass
chats map[int64]tgc.Channel
}
// Live wraps a connected MTProto client.
func Live(c *tgc.Client) Surface {
return &live{c: c, chats: map[int64]tgc.Channel{}}
}
func (s *live) OpenBotFather(ctx context.Context) (BotConversation, error) {
return s.c.BotFather(ctx)
}
func (s *live) ResolveBot(ctx context.Context, username string) (int64, error) {
res, err := s.c.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
Username: username,
})
if err != nil {
return 0, fmt.Errorf("resolve @%s: %w", username, err)
}
for _, u := range res.Users {
if user, ok := u.(*tg.User); ok {
s.bot = &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash}
return user.ID, nil
}
}
return 0, fmt.Errorf("@%s did not resolve to a user", username)
}
func (s *live) CreateChannel(ctx context.Context, title, about string) (int64, error) {
ch, err := s.c.CreateChannel(ctx, title, about)
if err != nil {
return 0, err
}
s.chats[ch.ChatID] = ch
return ch.ChatID, nil
}
func (s *live) channel(ctx context.Context, chatID int64) (tgc.Channel, error) {
if ch, ok := s.chats[chatID]; ok {
return ch, nil
}
ch, err := s.c.ResolveChannel(ctx, chatID)
if err != nil {
return tgc.Channel{}, err
}
s.chats[chatID] = ch
return ch, nil
}
func (s *live) ClaimUsername(ctx context.Context, chatID int64, username string) (bool, error) {
ch, err := s.channel(ctx, chatID)
if err != nil {
return false, err
}
ok, err := s.c.SetUsername(ctx, ch, username)
if err != nil {
return false, err
}
if ok {
ch.Username = username
s.chats[chatID] = ch
}
return ok, nil
}
func (s *live) PromoteBot(ctx context.Context, chatID int64, botUsername string) error {
if s.bot == nil {
return fmt.Errorf("promote @%s: bot has not been resolved", botUsername)
}
ch, err := s.channel(ctx, chatID)
if err != nil {
return err
}
return s.c.PromoteBot(ctx, ch, s.bot, botUsername)
}
func (s *live) EditChannelTitle(ctx context.Context, chatID int64, title string) error {
ch, err := s.channel(ctx, chatID)
if err != nil {
return err
}
return s.c.EditTitle(ctx, ch, title)
}
func (s *live) EditChannelAbout(ctx context.Context, chatID int64, about string) error {
ch, err := s.channel(ctx, chatID)
if err != nil {
return err
}
return s.c.EditAbout(ctx, ch, about)
}

View file

@ -37,6 +37,10 @@ const (
// A plan containing one applies nothing, because the tool no longer knows
// what it is looking at.
Block
// Reassert repeats an idempotent write the tool cannot observe, such as
// BotFather profile fields. It is work apply will do, but it is not drift:
// a scheduled check that treated it as a change would never go quiet.
Reassert
)
func (k Kind) String() string {
@ -53,6 +57,8 @@ func (k Kind) String() string {
return "defer"
case Block:
return "BLOCK"
case Reassert:
return "reassert"
}
return "?"
}
@ -95,16 +101,34 @@ func (p *Plan) Blocked() bool {
return false
}
// Empty reports whether applying would change nothing. Warnings and deferrals do
// not count as changes: a converged presence with a standing warning, or with the
// public channel still waiting on its first checked rendering, is still converged.
// Empty reports whether a scheduled drift check should stay quiet. Warnings,
// deferrals and reassertions do not count: a converged presence that still
// reasserts editorial fields on apply has not drifted.
func (p *Plan) Empty() bool {
return !p.Drifted()
}
// Drifted reports whether the declared presence and the world disagree in a
// way `plan --check` must fail on. Reassertion is not drift.
func (p *Plan) Drifted() bool {
for _, a := range p.Actions {
if a.Kind != Warn && a.Kind != Defer {
return false
switch a.Kind {
case Create, Update, Attempt, Block:
return true
}
}
return true
return false
}
// NeedsApply reports whether apply has work, including idempotent reassertion.
func (p *Plan) NeedsApply() bool {
for _, a := range p.Actions {
switch a.Kind {
case Create, Update, Attempt, Reassert:
return true
}
}
return false
}
// Live is what the provisioner can observe about the presence right now. It is
@ -133,6 +157,10 @@ func Compute(specPath string, sp *spec.Presence, digest string, rs *state.Resolv
return p, nil
}
if rs.SpecDigest != "" && rs.SpecDigest != digest {
p.add(Update, "spec", "declared spec has changed since last apply; editorial fields will be reasserted")
}
if err := planBot(p, sp, rs, live); err != nil {
return nil, err
}
@ -174,8 +202,10 @@ func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error {
return nil
}
// Profile fields are safe to reassert: BotFather takes them idempotently and
// the tool does not know what a person may have changed by hand.
p.add(Update, "bot.profile", "reassert name, about text and description from the spec")
// the tool does not know what a person may have changed by hand. That is
// apply work, not drift -- a check that treated it as a change would never
// go quiet.
p.add(Reassert, "bot.profile", "reassert name, about text and description from the spec")
planAvatar(p, sp, rs)
return nil
}
@ -288,7 +318,7 @@ func (p *Plan) Render() string {
if p.Empty() && !p.Blocked() {
b.WriteString(" no changes -- the declared presence matches what exists\n")
}
for _, k := range []Kind{Block, Warn, Defer, Attempt, Create, Update} {
for _, k := range []Kind{Block, Warn, Defer, Attempt, Create, Update, Reassert} {
for _, a := range p.Actions {
if a.Kind != k {
continue

View file

@ -90,23 +90,54 @@ func provisioned(withTestPublication bool) *state.Resolved {
}
// Converged: a second run over an unchanged spec proposes nothing but the
// idempotent profile reassertion.
// idempotent profile reassertion, which is not drift.
func TestConvergedRunIsQuiet(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}}
p, err := Compute("", sp(), "sha256:x", provisioned(true), f)
rs := provisioned(true)
rs.SpecDigest = "sha256:x"
p, err := Compute("", sp(), "sha256:x", rs, f)
if err != nil {
t.Fatal(err)
}
if p.Blocked() {
t.Fatalf("converged plan should not block:\n%s", p.Render())
}
if p.Drifted() {
t.Fatalf("a converged presence must not count as drift:\n%s", p.Render())
}
if !p.Empty() {
t.Fatalf("plan --check must stay quiet on a converged presence:\n%s", p.Render())
}
if !p.NeedsApply() {
t.Fatal("apply still reasserts the profile even when the check is quiet")
}
if find(p, Reassert, "bot.profile") == nil {
t.Fatalf("expected a profile reassertion:\n%s", p.Render())
}
for _, a := range p.Actions {
if a.Kind == Create || a.Kind == Attempt {
if a.Kind == Create || a.Kind == Attempt || a.Kind == Update {
t.Errorf("unexpected %s of %s on a converged presence", a.Kind, a.Target)
}
}
}
// A changed spec is drift even when live observation still matches the old state.
func TestSpecDigestChangeIsDrift(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}}
rs := provisioned(true)
rs.SpecDigest = "sha256:old"
p, err := Compute("", sp(), "sha256:new", rs, f)
if err != nil {
t.Fatal(err)
}
if find(p, Update, "spec") == nil {
t.Fatalf("a changed spec must be an update:\n%s", p.Render())
}
if !p.Drifted() {
t.Fatal("plan --check must fail after the spec changes")
}
}
// Widened rights are a refusal, not a repair.
func TestWidenedRightsBlock(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"},
@ -213,9 +244,9 @@ func TestVerifiedTestChannelReleasesPublic(t *testing.T) {
}
}
// 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.
// The plan must not promise an action apply will silently skip. A declared
// avatar whose file is missing is deferred, not created: everything else can
// still apply, and the picture is set on a later run once the file is there.
func TestAvatarIsDeferredNotPromised(t *testing.T) {
s := sp()
s.Bot.Avatar = "does-not-exist.png"

View file

@ -85,6 +85,15 @@ func (s *Store) Ref(key string) string {
return fmt.Sprintf("bao:%s/%s/%s", s.mount, s.prefix, key)
}
// RefFor is Ref without a live store, so the adapter-config command can print
// the token's location without opening OpenBao or holding a token.
func RefFor(campaign, key string) string {
mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "platform")
prefix := firstNonEmpty(os.Getenv("FLUID_BAO_PREFIX"),
"workloads/infotech/fluid-telegram/"+campaign)
return fmt.Sprintf("bao:%s/%s/%s", mount, strings.Trim(prefix, "/"), key)
}
type kvPayload struct {
Data struct {
Data map[string]string `json:"data"`

View file

@ -25,6 +25,16 @@ func testStore(t *testing.T, h http.Handler) *Store {
// The salt rule. Rotating it silently invalidates every longitudinal comparison
// the interface has made, with no visible failure -- so the tool must have no
// path that replaces an existing one.
func TestRefForDoesNotNeedAStore(t *testing.T) {
t.Setenv("BAO_MOUNT", "")
t.Setenv("FLUID_BAO_PREFIX", "")
got := RefFor("hall-of-helix", KeyBotToken)
want := "bao:platform/workloads/infotech/fluid-telegram/hall-of-helix/bot-token"
if got != want {
t.Fatalf("RefFor = %q, want %q", got, want)
}
}
func TestCreateIfAbsentNeverOverwrites(t *testing.T) {
var writes int
existing := map[string]string{"salt": "the-original"}

View file

@ -144,6 +144,26 @@ presence:
}
}
// The Go validator mirrors presence/telegram.schema.yaml. If a rule leaves the
// schema file, a spec can pass one gate and fail the other.
func TestSchemaStillStatesTheClamp(t *testing.T) {
raw, err := os.ReadFile("../../presence/telegram.schema.yaml")
if err != nil {
t.Fatal(err)
}
s := string(raw)
for _, want := range []string{
"additionalProperties: false",
"const: post_messages",
"must not declare",
"maxItems: 1",
} {
if !strings.Contains(s, want) {
t.Errorf("schema no longer states %q", want)
}
}
}
// Rights are the tool's decision, not the spec's.
func TestRightsAlwaysClamped(t *testing.T) {
c := Channel{AdminRights: nil}

View file

@ -171,8 +171,9 @@ func (conv *Conversation) RegisterBot(ctx context.Context, displayName, username
// SetProfile applies the spec's editorial fields. Each is idempotent, so it is
// safe to reassert on every apply.
func (conv *Conversation) SetProfile(ctx context.Context, username, about, description string) error {
func (conv *Conversation) SetProfile(ctx context.Context, username, name, about, description string) error {
steps := []struct{ cmd, arg, label string }{
{"/setname", name, "name"},
{"/setabouttext", about, "about text"},
{"/setdescription", description, "description"},
}

View file

@ -168,6 +168,36 @@ func (c *Client) ChannelUsername(ctx context.Context, ch Channel) (string, error
return fresh.Username, nil
}
// EditTitle sets a channel's title. Idempotent: Telegram accepts the current
// title without treating it as a change.
func (c *Client) EditTitle(ctx context.Context, ch Channel, title string) error {
pause()
_, err := c.api.ChannelsEditTitle(ctx, &tg.ChannelsEditTitleRequest{
Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash},
Title: title,
})
if err != nil {
return fmt.Errorf("set title on channel %d: %w", ch.ChatID, err)
}
return nil
}
// EditAbout sets a channel's description.
func (c *Client) EditAbout(ctx context.Context, ch Channel, about string) error {
pause()
ok, err := c.api.MessagesEditChatAbout(ctx, &tg.MessagesEditChatAboutRequest{
Peer: &tg.InputPeerChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash},
About: about,
})
if err != nil {
return fmt.Errorf("set description on channel %d: %w", ch.ChatID, err)
}
if !ok {
return fmt.Errorf("set description on channel %d: telegram refused the change", ch.ChatID)
}
return nil
}
func resolveReq(username string) *tg.ContactsResolveUsernameRequest {
return &tg.ContactsResolveUsernameRequest{Username: username}
}

View file

@ -36,7 +36,7 @@ revision:
reset_after_ms: 60000
intent:
version: "IEI-1"
version: "IEI-2"
routing:
eligible_cohorts:

View file

@ -8,7 +8,7 @@ status: active
owner: worsch
topic_slug: fluid-telegram
created: "2026-09-04"
updated: "2026-09-04"
updated: "2026-09-14"
planning_priority: high
planning_order: 1
depends_on:
@ -131,7 +131,7 @@ state_hub_task_id: "13a92a96-2f87-5de2-a69c-7edc21f3a0c7"
```bash
export FLUID_INTERFACE=helix-forge-telegram-publishing
fluid intent put --version IEI-1 --file InterfaceEvolutionIntent.md --activate
fluid intent put --version IEI-2 --file InterfaceEvolutionIntent.md --activate
# fill the digest in revisions/R-1.yaml, then
fluid revision publish --file revisions/R-1.yaml --key-file "$FLUID_SIGNING_KEY" \
--adaptation-classes presentation --approved-by "$USER" --traffic-share 1.0

View file

@ -4,11 +4,11 @@ type: workplan
title: "Provision the Telegram presence from a declared specification"
domain: infotech
repo: fluid-telegram
status: proposed
status: active
owner: worsch
topic_slug: fluid-telegram
created: "2026-09-04"
updated: "2026-09-04"
updated: "2026-09-14"
planning_priority: high
planning_order: 2
depends_on:
@ -36,7 +36,7 @@ one bounded bootstrap, after which the surface is declared and converges.
```task
id: FT-WP-0002-T01
status: progress
status: wait
priority: high
state_hub_task_id: "039e5358-07c1-5b01-a186-5ece9aab75e4"
```
@ -60,6 +60,11 @@ 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.
**Tooling is done.** `docs/seeding-runbook.md` and `provision session bootstrap`
exist. Remaining is the operator following the runbook: a dedicated phone
number, `api_id`/`api_hash`, and the login code that mints the session. The
credential lane request is in `docs/requests/ops-warden-telegram-operator-lane.md`.
## T02 — Publish the presence schema and the campaign's spec
```task
@ -91,7 +96,7 @@ triggers an update.
```task
id: FT-WP-0002-T03
status: progress
status: done
priority: high
state_hub_task_id: "c3923422-7f2e-5bf9-906c-6d59572b09c0"
```
@ -108,25 +113,28 @@ cheapest place to catch someone asking for one.
knowable without attempting it, so the plan says "will attempt, with fallbacks"
rather than promising an outcome.
**Implemented (2026-09-04), less live observation.** Go, `cmd/provision`, with
`internal/spec`, `internal/state` and `internal/plan`. Runs against the real
campaign spec today; live observation is behind a `plan.Live` interface that
returns an offline stub until T01 mints a session, and the command says so rather
than presenting an unobserved plan as a drift check.
**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.
One thing the design got wrong and the implementation surfaced: 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 now distinct
kinds: a deferral holds one action, a block stops the run.
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.
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.
## T04 — Implement `provision apply`
```task
id: FT-WP-0002-T04
status: progress
status: done
priority: high
state_hub_task_id: "5659b72a-8309-58b6-96e9-79137ce41ed4"
```
@ -151,6 +159,12 @@ 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.
**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.
## T05 — Provision the redaction salt
```task
@ -175,7 +189,7 @@ deliberate human write, which is a decision rather than a run.
```task
id: FT-WP-0002-T06
status: todo
status: done
priority: medium
state_hub_task_id: "c305b83f-b05e-5f09-9182-9896ae137c38"
```
@ -185,24 +199,33 @@ 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.
**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.
## T07 — Drift check
```task
id: FT-WP-0002-T07
status: progress
status: done
priority: low
state_hub_task_id: "f3f36415-ed55-5717-b752-706b04ba5458"
```
`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
channel is the thing IEI-1 §7 exists to prevent.
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`.
## T08 — Settle the §7 reading
```task
id: FT-WP-0002-T08
status: todo
status: done
priority: medium
state_hub_task_id: "864bcd23-6357-5460-a8e6-98ec9a929e29"
```
@ -210,8 +233,11 @@ state_hub_task_id: "864bcd23-6357-5460-a8e6-98ec9a929e29"
`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
is not autonomous — but that is currently a reading rather than something the
document says, and a constitutional document should not depend on one.
is not autonomous — but that was a reading rather than something the document
said, and a constitutional document should not depend on one.
Add a one-sentence clarification, as a governance change under §20, before T04
runs against anything real.
**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.