fluid-telegram/internal/state/state.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

102 lines
3.2 KiB
Go

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