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:
parent
52723bd8ad
commit
10018a99b6
10 changed files with 1108 additions and 1 deletions
193
internal/spec/spec.go
Normal file
193
internal/spec/spec.go
Normal 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
154
internal/spec/spec_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue