fluid-telegram/internal/spec/spec.go
tegwick 10018a99b6 Implement provision plan: spec, resolved state, and the diff
Go, per the toolchain decision. cmd/provision with internal/spec,
internal/state and internal/plan; plan computation is pure and takes live
observation through an interface, so the refusal logic is testable without
a Telegram account. It runs against the real campaign spec today.

Separates two refusals the design had treated as one. A deferral is the
design working -- the public channel waiting on a checked rendering, normal
on every first run. A block is the world disagreeing with the state file:
drifted rights, a taken-over username, a bot that is no longer reachable.
Collapsed together, a first run could never apply anything, because it
always defers the public channel.

The rights clamp and the private/public username rule are enforced in Go
and asserted against the same cases the JSON schema rejects, since mirroring
the schema in code is a drift risk worth a test rather than a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1361245@bnt-lap001
Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
2026-09-04 21:25:49 +02:00

193 lines
6.3 KiB
Go

// 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} }