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/*
!.claude/rules/ !.claude/rules/
!.claude/rules/*.md !.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 ```task
id: FT-WP-0002-T03 id: FT-WP-0002-T03
status: todo status: progress
priority: high priority: high
state_hub_task_id: "c3923422-7f2e-5bf9-906c-6d59572b09c0" 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" knowable without attempting it, so the plan says "will attempt, with fallbacks"
rather than promising an outcome. 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` ## T04 — Implement `provision apply`
```task ```task