fluid-telegram/internal/plan/plan.go
tegwick 10018a99b6 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

282 lines
9.1 KiB
Go

// Package plan computes what provisioning would do, without doing any of it.
//
// A plan is read by a person before it is applied, so it is written to be read:
// it says what it will attempt, what it cannot know in advance, and what it
// refuses. The refusals are the important part -- a reconciler that quietly
// works around a situation it does not understand is worse than one that stops.
package plan
import (
"fmt"
"strings"
"github.com/tegwick/fluid-telegram/internal/spec"
"github.com/tegwick/fluid-telegram/internal/state"
)
type Kind int
const (
// Create makes something that does not exist.
Create Kind = iota
// Update changes something that does, in a way that is safe to repeat.
Update
// Attempt is a Create whose outcome cannot be known in advance -- claiming a
// username, for instance. The plan promises the attempt, not the result.
Attempt
// Warn is something the operator should know that the tool will not act on.
Warn
// Defer is an action held back until a precondition the design expects to be
// met later. It is not an error: the rest of the plan still applies. The
// public channel waiting on a checked rendering is the case this exists for,
// and it is normal on every first run.
Defer
// Block is a refusal caused by the world disagreeing with the state file.
// A plan containing one applies nothing, because the tool no longer knows
// what it is looking at.
Block
)
func (k Kind) String() string {
switch k {
case Create:
return "create"
case Update:
return "update"
case Attempt:
return "attempt"
case Warn:
return "warn"
case Defer:
return "defer"
case Block:
return "BLOCK"
}
return "?"
}
type Action struct {
Kind Kind
Target string
Detail string
// Why is present on Warn and Block, where the reason matters more than the
// action. An operator who is told only "blocked" will look for a way around.
Why string
}
type Plan struct {
Campaign string
SpecDigest string
Actions []Action
}
func (p *Plan) add(k Kind, target, detail string) {
p.Actions = append(p.Actions, Action{k, target, detail, ""})
}
func (p *Plan) addWhy(k Kind, target, detail, why string) {
p.Actions = append(p.Actions, Action{k, target, detail, why})
}
// Blocked reports whether the plan may not be applied.
func (p *Plan) Blocked() bool {
for _, a := range p.Actions {
if a.Kind == Block {
return true
}
}
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.
func (p *Plan) Empty() bool {
for _, a := range p.Actions {
if a.Kind != Warn && a.Kind != Defer {
return false
}
}
return true
}
// Live is what the provisioner can observe about the presence right now. It is
// an interface so that plan computation stays pure and testable: the MTProto
// client is one implementation, a fake is another.
type Live interface {
// BotExists reports whether a bot with this username is ours and reachable.
BotExists(username string) (bool, error)
// ChannelAdminRights returns the rights our bot actually holds on a chat.
ChannelAdminRights(chatID int64) ([]string, error)
// ChannelUsername returns the username a chat currently carries.
ChannelUsername(chatID int64) (string, error)
}
// Compute diffs the spec against the resolved state and live observation.
func Compute(sp *spec.Presence, digest string, rs *state.Resolved, live Live) (*Plan, error) {
p := &Plan{Campaign: sp.Campaign, SpecDigest: digest}
if rs.Campaign != "" && rs.Campaign != sp.Campaign {
p.addWhy(Block, "campaign", fmt.Sprintf("state is for %q, spec is for %q", rs.Campaign, sp.Campaign),
"The campaign slug names the state file and the OpenBao subtree. Changing it "+
"does not rename a presence, it points at a different one, so the tool will "+
"not guess which was meant.")
return p, nil
}
if err := planBot(p, sp, rs, live); err != nil {
return nil, err
}
if err := planChannels(p, sp, rs, live); err != nil {
return nil, err
}
// Nothing here deletes. Removing a channel from the spec destroys its
// subscribers and its post history irreversibly, and no spec is trusted
// with that.
for name := range rs.Channels {
if _, ok := sp.Channels[name]; !ok {
p.addWhy(Warn, "channel."+name, "present in state, absent from spec",
"Not deleted. Deleting a channel destroys its subscribers and history "+
"irreversibly; remove it by hand if that is genuinely what you want.")
}
}
return p, nil
}
func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error {
if rs.Bot.ID == 0 {
p.add(Attempt, "bot", fmt.Sprintf("register %q via BotFather, username from %d candidate(s): %s",
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)
}
return nil
}
ok, err := live.BotExists(rs.Bot.Username)
if err != nil {
return fmt.Errorf("check bot: %w", err)
}
if !ok {
p.addWhy(Block, "bot", fmt.Sprintf("@%s is in the resolved state but is not reachable", rs.Bot.Username),
"Either the bot was deleted or the operator session no longer has access to it. "+
"Both mean the state file is describing something that is not there, and "+
"re-creating the bot would silently orphan the token in OpenBao.")
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")
return nil
}
func planChannels(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error {
// Test first, always. The ordering is the guarantee, not a convention.
for _, name := range []string{spec.Test, spec.Live} {
c, ok := sp.Channels[name]
if !ok {
continue
}
rc, provisioned := rs.Channels[name]
if name == spec.Live && !rs.TestChannelVerified(spec.Test) {
p.addWhy(Defer, "channel.public", "held until the test channel has a checked rendering",
"Nothing reaches the public channel until a person has seen a rendering in "+
"the test channel. This is expected on a first run and does not stop the "+
"rest of the plan; publish once to the test channel, then plan again.")
continue
}
if !provisioned {
p.add(Create, "channel."+name, fmt.Sprintf("%s channel %q", c.Visibility, c.Title))
if c.Visibility == spec.Public {
p.add(Attempt, "channel."+name+".username",
"claim from: "+strings.Join(c.UsernamePreference, ", "))
}
p.add(Create, "channel."+name+".admin",
"add bot as administrator with post_messages only")
continue
}
rights, err := live.ChannelAdminRights(rc.ChatID)
if err != nil {
return fmt.Errorf("check rights on %s: %w", name, err)
}
if !hasOnly(rights, spec.PostMessages) {
p.addWhy(Block, "channel."+name+".admin",
fmt.Sprintf("bot holds %v, expected [%s]", rights, spec.PostMessages),
"Rights drifted. Widening is a permission this system is not allowed to "+
"exercise; losing post_messages means it cannot publish. Either way a "+
"person decides what happened before a tool changes it back.")
}
if c.Visibility == spec.Public {
cur, err := live.ChannelUsername(rc.ChatID)
if err != nil {
return fmt.Errorf("check username on %s: %w", name, err)
}
if cur != rc.Username {
p.addWhy(Block, "channel."+name+".username",
fmt.Sprintf("is @%s, state says @%s", cur, rc.Username),
"A channel's public username changing under us is not something to "+
"reconcile. It may have been taken over, or the state may be stale.")
}
}
}
return nil
}
func hasOnly(got []string, want string) bool {
return len(got) == 1 && got[0] == want
}
// Render writes the plan the way an operator reads it: refusals first, because
// they decide whether the rest matters.
func (p *Plan) Render() string {
var b strings.Builder
fmt.Fprintf(&b, "presence plan for %q\n", p.Campaign)
fmt.Fprintf(&b, "spec %s\n\n", p.SpecDigest)
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 _, a := range p.Actions {
if a.Kind != k {
continue
}
fmt.Fprintf(&b, " %-8s %-26s %s\n", a.Kind, a.Target, a.Detail)
if a.Why != "" {
for _, line := range wrap(a.Why, 72) {
fmt.Fprintf(&b, " %s\n", line)
}
}
}
}
if p.Blocked() {
b.WriteString("\nnothing will be applied while a BLOCK stands: the presence does not\n" +
"match the state file, and a person decides what happened before a tool acts\n")
}
return b.String()
}
func wrap(s string, width int) []string {
var out []string
line := ""
for _, w := range strings.Fields(s) {
if line != "" && len(line)+1+len(w) > width {
out = append(out, line)
line = ""
}
if line == "" {
line = w
} else {
line += " " + w
}
}
if line != "" {
out = append(out, line)
}
return out
}