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
|
|
|
package plan
|
|
|
|
|
|
|
|
|
|
import (
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
"os"
|
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
|
|
|
"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) {
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, err := Compute("", sp(), "sha256:x", &state.Resolved{}, fake{})
|
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
|
|
|
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"}}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, err := Compute("", sp(), "sha256:x", provisioned(true), f)
|
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
|
|
|
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"}}}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", sp(), "sha256:x", provisioned(true), f)
|
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
|
|
|
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: {}}}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", sp(), "sha256:x", provisioned(true), f)
|
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
|
|
|
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"}}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", sp(), "sha256:x", provisioned(true), f)
|
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
|
|
|
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}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", sp(), "sha256:x", provisioned(true), f)
|
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
|
|
|
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}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", s, "sha256:x", provisioned(true), f)
|
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
|
|
|
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"
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", sp(), "sha256:x", rs, fake{botExists: true})
|
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
|
|
|
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"
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", sp(), "sha256:x", rs, fake{botExists: true})
|
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
|
|
|
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) {
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", sp(), "sha256:x", &state.Resolved{}, fake{})
|
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
|
|
|
if p.Blocked() {
|
|
|
|
|
t.Fatal("a deferral must not block the plan")
|
|
|
|
|
}
|
|
|
|
|
f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p2, _ := Compute("", sp(), "sha256:x", provisioned(true), f)
|
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
|
|
|
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"}}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
p, _ := Compute("", sp(), "sha256:x", provisioned(true), f)
|
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
|
|
|
if find(p, Defer, "channel.public") != nil {
|
|
|
|
|
t.Errorf("public should no longer be deferred:\n%s", p.Render())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-04 22:02:56 +02:00
|
|
|
|
|
|
|
|
// The plan must not promise an action apply will silently skip. The avatar is
|
|
|
|
|
// the live case: /setuserpic needs an upload that is not implemented, so the
|
|
|
|
|
// plan defers it with an explanation instead of claiming a create.
|
|
|
|
|
func TestAvatarIsDeferredNotPromised(t *testing.T) {
|
|
|
|
|
s := sp()
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
s.Bot.Avatar = "does-not-exist.png"
|
|
|
|
|
p, _ := Compute("", s, "sha256:x", &state.Resolved{}, fake{})
|
2026-09-04 22:02:56 +02:00
|
|
|
|
|
|
|
|
if find(p, Create, "bot.avatar") != nil {
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
t.Fatal("plan promised an avatar it could not load")
|
2026-09-04 22:02:56 +02:00
|
|
|
}
|
|
|
|
|
a := find(p, Defer, "bot.avatar")
|
|
|
|
|
if a == nil {
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
t.Fatalf("a missing avatar should defer, not block:\n%s", p.Render())
|
2026-09-04 22:02:56 +02:00
|
|
|
}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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 22:12:56 +02:00
|
|
|
if !strings.Contains(a.Why, "not there") {
|
|
|
|
|
t.Errorf("deferral should say the file is missing: %q", a.Why)
|
|
|
|
|
}
|
|
|
|
|
if p.Blocked() {
|
|
|
|
|
t.Error("a missing avatar must not stop everything else applying")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A real image is planned as a create, content-addressed.
|
|
|
|
|
func TestAvatarPlannedFromRealFile(t *testing.T) {
|
|
|
|
|
const real = "../../../pr-hall-of-helix/presence/telegram.yaml"
|
|
|
|
|
if _, err := os.Stat(real); err != nil {
|
|
|
|
|
t.Skip("campaign repo not in this checkout")
|
|
|
|
|
}
|
|
|
|
|
s := sp()
|
|
|
|
|
s.Bot.Avatar = "assets/helix-forge.png"
|
|
|
|
|
p, _ := Compute(real, s, "sha256:x", &state.Resolved{}, fake{})
|
|
|
|
|
if p.Avatar == nil {
|
|
|
|
|
t.Fatalf("expected a loaded avatar:\n%s", p.Render())
|
|
|
|
|
}
|
|
|
|
|
if find(p, Create, "bot.avatar") == nil {
|
|
|
|
|
t.Fatalf("expected an avatar create:\n%s", p.Render())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Once recorded, the same file must not be re-sent: the digest is what
|
|
|
|
|
// decides, so an unchanged picture is silence.
|
|
|
|
|
rs := &state.Resolved{Bot: state.Bot{AvatarDigest: p.Avatar.Digest}}
|
|
|
|
|
p2, _ := Compute(real, s, "sha256:x", rs, fake{})
|
|
|
|
|
if find(p2, Create, "bot.avatar") != nil || find(p2, Update, "bot.avatar") != nil {
|
|
|
|
|
t.Errorf("an unchanged avatar should propose nothing:\n%s", p2.Render())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A different digest means the file was replaced, which is an update.
|
|
|
|
|
rs.Bot.AvatarDigest = "sha256:something-else"
|
|
|
|
|
p3, _ := Compute(real, s, "sha256:x", rs, fake{})
|
|
|
|
|
if find(p3, Update, "bot.avatar") == nil {
|
|
|
|
|
t.Errorf("a replaced avatar should update:\n%s", p3.Render())
|
2026-09-04 22:02:56 +02:00
|
|
|
}
|
|
|
|
|
}
|