diff --git a/.gitignore b/.gitignore index e4e0199..5a4a794 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ .claude/* !.claude/rules/ !.claude/rules/*.md + +# Go build artifacts +/provision diff --git a/cmd/provision/main.go b/cmd/provision/main.go new file mode 100644 index 0000000..03f79a9 --- /dev/null +++ b/cmd/provision/main.go @@ -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 [--check] show what would change; writes nothing + provision apply --spec 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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c73fa64 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/tegwick/fluid-telegram + +go 1.25.0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4bc0337 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/plan/plan.go b/internal/plan/plan.go new file mode 100644 index 0000000..e72aad2 --- /dev/null +++ b/internal/plan/plan.go @@ -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 +} diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go new file mode 100644 index 0000000..b008097 --- /dev/null +++ b/internal/plan/plan_test.go @@ -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()) + } +} diff --git a/internal/spec/spec.go b/internal/spec/spec.go new file mode 100644 index 0000000..a1061a3 --- /dev/null +++ b/internal/spec/spec.go @@ -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} } diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go new file mode 100644 index 0000000..52bd744 --- /dev/null +++ b/internal/spec/spec_test.go @@ -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) + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..9d94b2b --- /dev/null +++ b/internal/state/state.go @@ -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 +} diff --git a/workplans/FT-WP-0002-declared-presence-provisioning.md b/workplans/FT-WP-0002-declared-presence-provisioning.md index 9f1feea..5e538e6 100644 --- a/workplans/FT-WP-0002-declared-presence-provisioning.md +++ b/workplans/FT-WP-0002-declared-presence-provisioning.md @@ -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