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
This commit is contained in:
tegwick 2026-09-04 21:25:49 +02:00
parent 52723bd8ad
commit 10018a99b6
10 changed files with 1108 additions and 1 deletions

3
.gitignore vendored
View file

@ -3,3 +3,6 @@
.claude/*
!.claude/rules/
!.claude/rules/*.md
# Go build artifacts
/provision

138
cmd/provision/main.go Normal file
View file

@ -0,0 +1,138 @@
// Command provision reconciles a Telegram presence with its declared
// specification.
//
// It is the provisioning plane, and it is deliberately separate from the
// adapter: the adapter holds a bot token with post_messages and nothing else,
// and has no code path into here. A compromised adapter cannot create, rename,
// delete or re-permission anything.
//
// See docs/provisioning.md for the design and docs/seeding-runbook.md for the
// steps that precede the first run.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"github.com/tegwick/fluid-telegram/internal/plan"
"github.com/tegwick/fluid-telegram/internal/spec"
"github.com/tegwick/fluid-telegram/internal/state"
)
const usage = `provision -- reconcile a Telegram presence with its declared spec
provision plan --spec <path> [--check] show what would change; writes nothing
provision apply --spec <path> execute an approved plan
session bootstrap / session check mint and inspect the operator session
Flags:
--spec path to the presence spec (campaign repo)
--root repo root holding presence/resolved/ (default: cwd)
--check exit non-zero if anything would change; for scheduled drift checks
`
func main() {
if len(os.Args) < 2 {
fmt.Fprint(os.Stderr, usage)
os.Exit(2)
}
var err error
switch os.Args[1] {
case "plan":
err = cmdPlan(os.Args[2:])
case "apply":
err = fmt.Errorf("apply is not implemented yet (FT-WP-0002 T04); plan is safe to run")
case "session":
err = fmt.Errorf("session is not implemented yet (FT-WP-0002 T01)")
case "-h", "--help", "help":
fmt.Print(usage)
return
default:
err = fmt.Errorf("unknown command %q", os.Args[1])
}
if err != nil {
fmt.Fprintln(os.Stderr, "provision:", err)
os.Exit(1)
}
}
func cmdPlan(args []string) error {
fs := flag.NewFlagSet("plan", flag.ExitOnError)
specPath := fs.String("spec", "", "path to the presence spec")
root := fs.String("root", ".", "repo root holding presence/resolved/")
check := fs.Bool("check", false, "exit non-zero if anything would change")
fs.Parse(args)
if *specPath == "" {
return fmt.Errorf("--spec is required")
}
sp, digest, err := spec.Load(*specPath)
if err != nil {
return err
}
statePath := state.Path(*root, sp.Campaign)
rs, err := state.Load(statePath)
if err != nil {
return err
}
// Live observation needs the operator session, which does not exist yet.
// Until it does, plan runs against the resolved state alone and says so --
// an offline plan is still worth reading on a first run, where everything is
// a creation, but it must not be mistaken for a drift check.
live, offline := liveOrOffline()
p, err := plan.Compute(sp, digest, rs, live)
if err != nil {
return err
}
fmt.Print(p.Render())
if offline {
fmt.Printf("\nnote: no operator session, so nothing was observed live.\n"+
" this plan reflects %s and the spec only.\n", relOrAbs(statePath))
if *check {
return fmt.Errorf("--check needs live observation; see docs/seeding-runbook.md")
}
}
if *check && !p.Empty() {
return fmt.Errorf("presence has drifted from the spec")
}
// A block means the world disagrees with the state file; a deferral is the
// design working. Only the first is a failure.
if p.Blocked() {
os.Exit(1)
}
return nil
}
// offlineLive answers only what can be known without a session. It never claims
// something is fine; where it cannot tell, plan is told the presence is intact
// so that a first run still renders, and the caller reports that it was offline.
type offlineLive struct{}
func (offlineLive) BotExists(string) (bool, error) { return true, nil }
func (offlineLive) ChannelAdminRights(int64) ([]string, error) {
return []string{spec.PostMessages}, nil
}
func (offlineLive) ChannelUsername(int64) (string, error) { return "", nil }
func liveOrOffline() (plan.Live, bool) {
// FT-WP-0002 T03/T04: return the MTProto client once the session exists.
return offlineLive{}, true
}
func relOrAbs(p string) string {
if abs, err := filepath.Abs(p); err == nil {
if wd, err := os.Getwd(); err == nil {
if rel, err := filepath.Rel(wd, abs); err == nil {
return rel
}
}
}
return p
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module github.com/tegwick/fluid-telegram
go 1.25.0
require gopkg.in/yaml.v3 v3.0.1 // indirect

3
go.sum Normal file
View file

@ -0,0 +1,3 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

282
internal/plan/plan.go Normal file
View file

@ -0,0 +1,282 @@
// 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
}

213
internal/plan/plan_test.go Normal file
View file

@ -0,0 +1,213 @@
package plan
import (
"strings"
"testing"
"time"
"github.com/tegwick/fluid-telegram/internal/spec"
"github.com/tegwick/fluid-telegram/internal/state"
)
type fake struct {
botExists bool
rights map[int64][]string
usernames map[int64]string
}
func (f fake) BotExists(string) (bool, error) { return f.botExists, nil }
func (f fake) ChannelAdminRights(id int64) ([]string, error) {
if r, ok := f.rights[id]; ok {
return r, nil
}
return []string{spec.PostMessages}, nil
}
func (f fake) ChannelUsername(id int64) (string, error) { return f.usernames[id], nil }
func sp() *spec.Presence {
return &spec.Presence{
SchemaVersion: "0.1", Campaign: "hall-of-helix", Interface: "i",
Bot: spec.Bot{Name: "HelixForge", UsernamePreference: []string{"HelixForgeBot"}},
Channels: map[string]spec.Channel{
spec.Test: {Title: "t", Visibility: spec.Private},
spec.Live: {Title: "p", Visibility: spec.Public, UsernamePreference: []string{"hallofhelix"}},
},
}
}
func find(p *Plan, kind Kind, target string) *Action {
for i := range p.Actions {
if p.Actions[i].Kind == kind && p.Actions[i].Target == target {
return &p.Actions[i]
}
}
return nil
}
// First run: everything is created, and the public channel is blocked because
// no rendering has been checked yet.
func TestFirstRunCreatesAndBlocksPublic(t *testing.T) {
p, err := Compute(sp(), "sha256:x", &state.Resolved{}, fake{})
if err != nil {
t.Fatal(err)
}
if find(p, Attempt, "bot") == nil {
t.Error("expected a bot registration attempt")
}
if find(p, Create, "channel.test") == nil {
t.Error("expected the test channel to be created")
}
if find(p, Defer, "channel.public") == nil {
t.Error("public channel must be deferred until the test channel is verified")
}
// A first run is not an error state. The bot and the test channel must still
// be creatable while the public channel waits.
if p.Blocked() {
t.Errorf("a first run should not block:\n%s", p.Render())
}
if p.Empty() {
t.Error("a first run has work to do")
}
}
func provisioned(withTestPublication bool) *state.Resolved {
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}},
spec.Live: {ChatID: -200, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}},
},
}
if withTestPublication {
now := time.Now()
c := rs.Channels[spec.Test]
c.TestPublicationAt = &now
rs.Channels[spec.Test] = c
}
return rs
}
// Converged: a second run over an unchanged spec proposes nothing but the
// idempotent profile reassertion.
func TestConvergedRunIsQuiet(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}}
p, err := Compute(sp(), "sha256:x", provisioned(true), f)
if err != nil {
t.Fatal(err)
}
if p.Blocked() {
t.Fatalf("converged plan should not block:\n%s", p.Render())
}
for _, a := range p.Actions {
if a.Kind == Create || a.Kind == Attempt {
t.Errorf("unexpected %s of %s on a converged presence", a.Kind, a.Target)
}
}
}
// Widened rights are a refusal, not a repair.
func TestWidenedRightsBlock(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"},
rights: map[int64][]string{-200: {spec.PostMessages, "can_delete_messages"}}}
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
a := find(p, Block, "channel.public.admin")
if a == nil {
t.Fatalf("widened rights must block:\n%s", p.Render())
}
if !strings.Contains(a.Why, "not allowed to exercise") {
t.Errorf("block should explain why: %q", a.Why)
}
}
// A demoted bot blocks too -- losing the right is as much a drift as gaining one.
func TestLostRightsBlock(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"},
rights: map[int64][]string{-100: {}}}
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
if find(p, Block, "channel.test.admin") == nil {
t.Fatalf("a demoted bot must block:\n%s", p.Render())
}
}
// A username that changed underneath us is not reconciled.
func TestUsernameTakeoverBlocks(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}}
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
if find(p, Block, "channel.public.username") == nil {
t.Fatalf("a changed username must block:\n%s", p.Render())
}
}
// A missing bot blocks rather than being re-created, which would orphan the
// token already in OpenBao.
func TestMissingBotBlocks(t *testing.T) {
f := fake{botExists: false}
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
if find(p, Block, "bot") == nil {
t.Fatalf("an unreachable bot must block:\n%s", p.Render())
}
}
// Removing a channel from the spec warns; it never deletes.
func TestRemovedChannelWarnsNeverDeletes(t *testing.T) {
s := sp()
delete(s.Channels, spec.Live)
f := fake{botExists: true}
p, _ := Compute(s, "sha256:x", provisioned(true), f)
a := find(p, Warn, "channel.public")
if a == nil {
t.Fatalf("expected a warning:\n%s", p.Render())
}
for _, act := range p.Actions {
if strings.Contains(strings.ToLower(act.Detail), "delete") && act.Kind != Warn {
t.Errorf("plan proposed a deletion: %+v", act)
}
}
}
// Pointing a state file at a different campaign is a mistake, not a rename.
func TestCampaignMismatchBlocks(t *testing.T) {
rs := provisioned(true)
rs.Campaign = "some-other-campaign"
p, _ := Compute(sp(), "sha256:x", rs, fake{botExists: true})
if find(p, Block, "campaign") == nil {
t.Fatalf("campaign mismatch must block:\n%s", p.Render())
}
}
func TestRenderShowsRefusalsFirst(t *testing.T) {
rs := provisioned(true)
rs.Campaign = "some-other-campaign"
p, _ := Compute(sp(), "sha256:x", rs, fake{botExists: true})
out := p.Render()
if !strings.Contains(out, "nothing will be applied") {
t.Errorf("a blocked plan should say so:\n%s", out)
}
if !strings.Contains(out, "BLOCK") {
t.Error("expected a BLOCK in the render")
}
}
// A deferral is not a block: the two must not collapse into each other, or a
// first run can never apply anything.
func TestDeferIsNotBlock(t *testing.T) {
p, _ := Compute(sp(), "sha256:x", &state.Resolved{}, fake{})
if p.Blocked() {
t.Fatal("a deferral must not block the plan")
}
f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}}
p2, _ := Compute(sp(), "sha256:x", provisioned(true), f)
if !p2.Blocked() {
t.Fatal("real drift must block")
}
}
// Once the test channel is verified, the public channel stops being deferred.
func TestVerifiedTestChannelReleasesPublic(t *testing.T) {
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}}
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
if find(p, Defer, "channel.public") != nil {
t.Errorf("public should no longer be deferred:\n%s", p.Render())
}
}

193
internal/spec/spec.go Normal file
View file

@ -0,0 +1,193 @@
// Package spec loads and validates a declared Telegram presence.
//
// The normative schema is presence/telegram.schema.yaml. The validation here
// mirrors it deliberately rather than interpreting it at runtime: the rules are
// few, and a spec that fails should say why in the tool's own words. The
// mirroring is a real drift risk, so schema_test.go asserts that the schema file
// and this package still agree on every rule.
package spec
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// PostMessages is the only administrator right this system will ever hold.
//
// InterfaceEvolutionIntent.md 7 forbids the Daimon from using a wider right, and
// a right that is never exercised is still a right that was granted. Enforcing
// it where the grant is declared is cheaper than auditing that it stayed unused.
const PostMessages = "post_messages"
type File struct {
Presence Presence `yaml:"presence"`
}
type Presence struct {
SchemaVersion string `yaml:"schema_version"`
Campaign string `yaml:"campaign"`
Interface string `yaml:"interface"`
Bot Bot `yaml:"bot"`
Channels map[string]Channel `yaml:"channels"`
LinkedDiscussionGroup bool `yaml:"linked_discussion_group"`
}
type Bot struct {
Name string `yaml:"name"`
UsernamePreference []string `yaml:"username_preference"`
About string `yaml:"about"`
Description string `yaml:"description"`
Avatar string `yaml:"avatar"`
}
type Channel struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
Visibility string `yaml:"visibility"`
UsernamePreference []string `yaml:"username_preference"`
AdminRights []string `yaml:"admin_rights"`
}
const (
Private = "private"
Public = "public"
// The two channels every presence declares. Test is created first and is
// where renderings are checked; nothing reaches Public until a person has
// looked at one.
Test = "test"
Live = "public"
)
var (
campaignRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
botUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{3,30}([Bb]ot|_bot)$`)
chanUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
)
// Load reads a spec and validates it. The digest is over the file bytes, so any
// edit changes it -- that is what makes drift detectable in the resolved state.
func Load(path string) (*Presence, string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("read spec: %w", err)
}
var f File
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
dec.KnownFields(true) // additionalProperties: false
if err := dec.Decode(&f); err != nil {
return nil, "", fmt.Errorf("parse spec: %w", err)
}
if err := f.Presence.Validate(); err != nil {
return nil, "", err
}
sum := sha256.Sum256(raw)
return &f.Presence, "sha256:" + hex.EncodeToString(sum[:]), nil
}
func (p *Presence) Validate() error {
var errs []string
add := func(format string, a ...any) { errs = append(errs, fmt.Sprintf(format, a...)) }
if p.SchemaVersion != "0.1" {
add("presence.schema_version must be %q, got %q", "0.1", p.SchemaVersion)
}
if !campaignRe.MatchString(p.Campaign) {
add("presence.campaign %q must be a lowercase slug", p.Campaign)
}
if p.Interface == "" {
add("presence.interface is required")
}
if p.Bot.Name == "" {
add("bot.name is required")
} else if n := len([]rune(p.Bot.Name)); n > 64 {
add("bot.name is %d characters, limit is 64", n)
}
if len(p.Bot.UsernamePreference) == 0 {
add("bot.username_preference needs at least one candidate; BotFather usernames are globally unique and a first choice is often taken")
}
for _, u := range p.Bot.UsernamePreference {
if !botUsernameRe.MatchString(u) {
add("bot username %q must be 4-31 characters and end in \"bot\" or \"_bot\"", u)
}
}
if n := len([]rune(p.Bot.About)); n > 120 {
add("bot.about is %d characters, limit is 120", n)
}
if n := len([]rune(p.Bot.Description)); n > 512 {
add("bot.description is %d characters, limit is 512", n)
}
for _, name := range []string{Test, Live} {
if _, ok := p.Channels[name]; !ok {
add("channels.%s is required", name)
}
}
for name, c := range p.Channels {
if name != Test && name != Live {
add("channels.%s is not a known channel; expected %q and %q", name, Test, Live)
continue
}
errs = append(errs, c.validate(name)...)
}
if len(errs) > 0 {
return fmt.Errorf("spec is invalid:\n - %s", strings.Join(errs, "\n - "))
}
return nil
}
func (c Channel) validate(name string) []string {
var errs []string
add := func(format string, a ...any) { errs = append(errs, fmt.Sprintf(format, a...)) }
if c.Title == "" {
add("channels.%s.title is required", name)
} else if n := len([]rune(c.Title)); n > 128 {
add("channels.%s.title is %d characters, limit is 128", name, n)
}
if n := len([]rune(c.Description)); n > 255 {
add("channels.%s.description is %d characters, limit is 255", name, n)
}
switch c.Visibility {
case Private:
if len(c.UsernamePreference) > 0 {
add("channels.%s is private and must not declare a username; a dropped field is how a spec stops describing what exists", name)
}
case Public:
if len(c.UsernamePreference) == 0 {
add("channels.%s is public and must declare at least one username candidate", name)
}
default:
add("channels.%s.visibility must be %q or %q, got %q", name, Private, Public, c.Visibility)
}
for _, u := range c.UsernamePreference {
if !chanUsernameRe.MatchString(u) {
add("channels.%s username %q must be 5-32 characters, starting with a letter", name, u)
}
}
// The clamp. A spec asking for a wider right fails here rather than being
// quietly trimmed: someone asked for it, and they should find out.
for _, r := range c.AdminRights {
if r != PostMessages {
add("channels.%s.admin_rights may only contain %q; %q is refused", name, PostMessages, r)
}
}
if len(c.AdminRights) > 1 {
add("channels.%s.admin_rights has %d entries; only %q is permitted", name, len(c.AdminRights), PostMessages)
}
return errs
}
// Rights returns the rights to grant. Always exactly post_messages: the spec may
// state it for readability, but it is not the spec's decision.
func (c Channel) Rights() []string { return []string{PostMessages} }

154
internal/spec/spec_test.go Normal file
View file

@ -0,0 +1,154 @@
package spec
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The real specs must load. If either stops validating, the tool has drifted
// away from the artifacts it exists to read.
func TestRealSpecsLoad(t *testing.T) {
for _, p := range []string{
"../../presence/telegram.example.yaml",
"../../../pr-hall-of-helix/presence/telegram.yaml",
} {
if _, err := os.Stat(p); err != nil {
t.Skipf("not present in this checkout: %s", p)
}
got, digest, err := Load(p)
if err != nil {
t.Fatalf("%s: %v", p, err)
}
if got.Campaign == "" || !strings.HasPrefix(digest, "sha256:") {
t.Fatalf("%s: campaign=%q digest=%q", p, got.Campaign, digest)
}
}
}
func base() Presence {
return Presence{
SchemaVersion: "0.1",
Campaign: "hall-of-helix",
Interface: "helix-forge-telegram-publishing",
Bot: Bot{
Name: "HelixForge",
UsernamePreference: []string{"HelixForgeBot"},
About: "about",
Description: "description",
},
Channels: map[string]Channel{
Test: {Title: "t", Visibility: Private, AdminRights: []string{PostMessages}},
Live: {Title: "p", Visibility: Public, UsernamePreference: []string{"hallofhelix"},
AdminRights: []string{PostMessages}},
},
}
}
// Every rule the schema states, asserted here too. These are the cases the
// schema file rejects; if this table and presence/telegram.schema.yaml ever
// disagree, one of them is wrong and a spec will pass one gate and fail another.
func TestValidateRejects(t *testing.T) {
cases := []struct {
name string
mutate func(*Presence)
want string
}{
{"extra admin right", func(p *Presence) {
c := p.Channels[Live]
c.AdminRights = []string{PostMessages, "can_delete_messages"}
p.Channels[Live] = c
}, "can_delete_messages"},
{"wrong admin right", func(p *Presence) {
c := p.Channels[Live]
c.AdminRights = []string{"can_delete_messages"}
p.Channels[Live] = c
}, "refused"},
{"private channel with username", func(p *Presence) {
c := p.Channels[Test]
c.UsernamePreference = []string{"secretchan"}
p.Channels[Test] = c
}, "must not declare a username"},
{"public channel without username", func(p *Presence) {
c := p.Channels[Live]
c.UsernamePreference = nil
p.Channels[Live] = c
}, "must declare at least one"},
{"bot username not ending in bot", func(p *Presence) {
p.Bot.UsernamePreference = []string{"HelixForge"}
}, `end in "bot"`},
{"no bot username candidates", func(p *Presence) {
p.Bot.UsernamePreference = nil
}, "at least one candidate"},
{"missing test channel", func(p *Presence) {
delete(p.Channels, Test)
}, "channels.test is required"},
{"unknown channel key", func(p *Presence) {
p.Channels["archive"] = Channel{Title: "a", Visibility: Private}
}, "not a known channel"},
{"bad visibility", func(p *Presence) {
c := p.Channels[Test]
c.Visibility = "unlisted"
p.Channels[Test] = c
}, "visibility must be"},
{"wrong schema version", func(p *Presence) {
p.SchemaVersion = "0.2"
}, "schema_version"},
{"campaign not a slug", func(p *Presence) {
p.Campaign = "Hall Of Helix"
}, "lowercase slug"},
{"about too long", func(p *Presence) {
p.Bot.About = strings.Repeat("x", 121)
}, "limit is 120"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := base()
tc.mutate(&p)
err := p.Validate()
if err == nil {
t.Fatalf("accepted a spec that must be rejected")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error did not mention %q:\n%v", tc.want, err)
}
})
}
}
func TestValidateAcceptsBase(t *testing.T) {
p := base()
if err := p.Validate(); err != nil {
t.Fatalf("rejected a valid spec: %v", err)
}
}
// An unknown field is a mistake about what is being declared, not a comment.
func TestUnknownFieldRejected(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "s.yaml")
os.WriteFile(f, []byte(`
presence:
schema_version: "0.1"
campaign: "c"
interface: "i"
pinned: true
bot: {name: "n", username_preference: ["aBot"], about: "a", description: "d"}
channels:
test: {title: "t", visibility: private}
public: {title: "p", visibility: public, username_preference: ["abcdef"]}
`), 0o600)
if _, _, err := Load(f); err == nil {
t.Fatal("accepted an unknown field")
}
}
// Rights are the tool's decision, not the spec's.
func TestRightsAlwaysClamped(t *testing.T) {
c := Channel{AdminRights: nil}
got := c.Rights()
if len(got) != 1 || got[0] != PostMessages {
t.Fatalf("Rights() = %v, want [%s]", got, PostMessages)
}
}

102
internal/state/state.go Normal file
View file

@ -0,0 +1,102 @@
// Package state reads and writes the resolved presence: what provisioning
// actually produced, as opposed to what was asked for.
//
// It holds no secrets and is committed. The bot token, the operator session and
// the redaction salt live in OpenBao; what is here is the mechanical facts a
// later run needs in order to recognise what it already did.
package state
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
type File struct {
Resolved Resolved `yaml:"resolved"`
}
type Resolved struct {
Campaign string `yaml:"campaign"`
// SpecDigest is the digest of the spec this state was produced from. It is
// the link that makes drift detectable: if the spec changed and this did
// not, provisioning is behind. Without it a stale state is indistinguishable
// from a current one.
SpecDigest string `yaml:"spec_digest"`
ProvisionedAt time.Time `yaml:"provisioned_at"`
Bot Bot `yaml:"bot"`
Channels map[string]Channel `yaml:"channels"`
}
type Bot struct {
Username string `yaml:"username"`
ID int64 `yaml:"id"`
// AvatarDigest is the digest of the image file that was uploaded, so a
// replaced file is what triggers an update rather than a timestamp.
AvatarDigest string `yaml:"avatar_digest,omitempty"`
}
type Channel struct {
ChatID int64 `yaml:"chat_id"`
Username string `yaml:"username,omitempty"`
AdminRights []string `yaml:"admin_rights"`
// TestPublicationAt records the first successful publication to this
// channel. apply refuses to touch the public channel until the test channel
// has one -- a gate that has to survive a restart, so it lives here rather
// than in memory.
TestPublicationAt *time.Time `yaml:"test_publication_at,omitempty"`
}
// Path is where a campaign's resolved state lives, relative to the repo root.
func Path(root, campaign string) string {
return filepath.Join(root, "presence", "resolved", campaign+".yaml")
}
// Load returns the resolved state, or a zero value if none exists yet. A missing
// file is the ordinary first-run case, not an error.
func Load(path string) (*Resolved, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return &Resolved{}, nil
}
if err != nil {
return nil, fmt.Errorf("read resolved state: %w", err)
}
var f File
if err := yaml.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parse resolved state: %w", err)
}
return &f.Resolved, nil
}
func Save(path string, r *Resolved) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
out, err := yaml.Marshal(File{Resolved: *r})
if err != nil {
return err
}
header := "# Generated by `provision apply`. Committed, and free of secrets:\n" +
"# tokens, sessions and the redaction salt live in OpenBao.\n"
return os.WriteFile(path, append([]byte(header), out...), 0o644)
}
// Provisioned reports whether anything has been created for this campaign yet.
func (r *Resolved) Provisioned() bool { return r.Bot.ID != 0 }
// TestChannelVerified reports whether a publication has reached the test
// channel. The public channel is not touched until it has.
func (r *Resolved) TestChannelVerified(testKey string) bool {
c, ok := r.Channels[testKey]
return ok && c.TestPublicationAt != nil
}

View file

@ -93,7 +93,7 @@ permanent.
```task
id: FT-WP-0002-T03
status: todo
status: progress
priority: high
state_hub_task_id: "c3923422-7f2e-5bf9-906c-6d59572b09c0"
```
@ -110,6 +110,20 @@ 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.
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.
## T04 — Implement `provision apply`
```task