fb7135e1d9
The brief tells the planner "a command outside its policy is refused when you seal, not later". It was not. The only caller of VerificationPolicy.Allows was PlanPhaseCommands, which runs when the implementer asks to verify: one phase, one session and one rotation after the planner could have fixed it. Run 9 sealed ["bash", "scripts/test_healthcheck.sh"] against a policy that allows neither shape, and the phase request was accepted. The check now runs beside citation resolution, on the coordinator, where the project is already in scope. A project with no verification policy can still seal a plan; it cannot seal one that declares run: lines, which matches what an absent policy already meant at verification time. Test fixtures gained a policy for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
183 lines
5.1 KiB
Go
183 lines
5.1 KiB
Go
package operations
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"orchestra/internal/authz"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/registry"
|
|
"orchestra/internal/store"
|
|
)
|
|
|
|
func gatedProject() registry.Project {
|
|
return registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}, TrajectoryGate: map[string]string{"plan_to_implement": "required"}}
|
|
}
|
|
|
|
func humanReply(t *testing.T, s *store.Store, taskID, id, value string) {
|
|
t.Helper()
|
|
task, _ := s.Task(taskID)
|
|
if err := s.Append(domain.Event{
|
|
ID: domain.NewID(), Type: domain.EventHumanDecisionRecorded, TaskID: taskID,
|
|
Version: task.Version + 1, Surface: string(authz.System),
|
|
Payload: mustJSONBytes(t, map[string]any{
|
|
"decision_id": id, "kind": "correction", "subject": "operator_instruction", "value": value,
|
|
"source": map[string]any{"provider": "gitea", "external_id": "c-" + id},
|
|
}),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func mustJSONBytes(t *testing.T, v any) []byte {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// The gate stops plan to implement, hands the human a packet built from state
|
|
// that already exists, and lets the work through once they answer.
|
|
func TestTrajectoryGateBlocksThenClears(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
project := gatedProject()
|
|
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// plan to implement is gated.
|
|
proposal := []byte("# Attribution cache plan\n" + `
|
|
## Overview
|
|
add the cache
|
|
|
|
## Current state
|
|
runs per figure, per research:r1.
|
|
|
|
## Desired end state
|
|
Aggregation is cached per person.
|
|
|
|
## Non-goals
|
|
No identity change.
|
|
|
|
## Approach
|
|
Memoise in the aggregation loop.
|
|
|
|
## Phase 1: Add the cache
|
|
|
|
### Files
|
|
- internal/attr/attr.go
|
|
|
|
### Changes
|
|
add the cache to internal/attr/attr.go
|
|
|
|
### Verification
|
|
|
|
#### Automated
|
|
- run: ["go", "test", "./internal/attr/"]
|
|
|
|
## Testing strategy
|
|
go test ./internal/attr/
|
|
|
|
## Risks and edge cases
|
|
cache invalidation on rename
|
|
|
|
## Migration
|
|
None.
|
|
|
|
## References
|
|
- research:r1
|
|
`)
|
|
_, err := AdvanceWorkPhase(s, project, id, proposal)
|
|
if !errors.Is(err, ErrTrajectoryGate) {
|
|
t.Fatalf("want ErrTrajectoryGate, got %v", err)
|
|
}
|
|
blocked, _ := s.Task(id)
|
|
if blocked.State != domain.StateBlocked || blocked.BlockReason != domain.BlockReasonTrajectoryGate {
|
|
t.Fatalf("task = %+v", blocked)
|
|
}
|
|
if blocked.WorkPhase != domain.WorkPhasePlan {
|
|
t.Fatalf("phase moved before the human answered: %q", blocked.WorkPhase)
|
|
}
|
|
// The packet carries the proposal that is not sealed yet, plus the
|
|
// research it came from.
|
|
for _, want := range []string{
|
|
"Trajectory gate: plan to implement",
|
|
"add the cache",
|
|
"go test ./internal/attr/",
|
|
"cache invalidation on rename",
|
|
"runs per figure",
|
|
} {
|
|
if !strings.Contains(blocked.Blocker, want) {
|
|
t.Fatalf("packet missing %q:\n%s", want, blocked.Blocker)
|
|
}
|
|
}
|
|
|
|
// Asking again while waiting must not re-raise the gate.
|
|
before := len(s.Events(0))
|
|
if _, err := AdvanceWorkPhase(s, project, id, proposal); !errors.Is(err, ErrTrajectoryGate) {
|
|
t.Fatalf("want ErrTrajectoryGate, got %v", err)
|
|
}
|
|
if len(s.Events(0)) != before {
|
|
t.Fatal("a second gate event was appended while waiting")
|
|
}
|
|
|
|
// The human answers. Any wording counts: an imported comment carries no
|
|
// gate-specific subject.
|
|
humanReply(t, s, id, "d1", "keep the per-person aggregation, but do not add the cache, add the index")
|
|
if _, err := AdvanceWorkPhase(s, project, id, proposal); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := s.Task(id)
|
|
if got.State != domain.StateLeased && got.State != domain.StateQueued {
|
|
t.Fatalf("state = %s, want queued after the gate cleared", got.State)
|
|
}
|
|
if got.WorkPhase != domain.WorkPhaseImplement {
|
|
t.Fatalf("phase = %q", got.WorkPhase)
|
|
}
|
|
if got.PlanRef == "" {
|
|
t.Fatal("the plan was not sealed once the gate cleared")
|
|
}
|
|
}
|
|
|
|
// An ungated project never stops.
|
|
func TestUngatedProjectAdvances(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
project := registry.Project{ID: "p", Verification: registry.VerificationPolicy{Allowed: [][]string{{"go", "test", "*"}}}}
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := AdvanceWorkPhase(s, project, id, planDoc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseImplement {
|
|
t.Fatalf("phase = %q", got.WorkPhase)
|
|
}
|
|
}
|
|
|
|
// A decision recorded before the gate was raised is not an answer to it.
|
|
func TestOlderDecisionDoesNotOpenTheGate(t *testing.T) {
|
|
s, id := phaseStore(t)
|
|
project := gatedProject()
|
|
humanReply(t, s, id, "d0", "an earlier instruction")
|
|
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := AdvanceWorkPhase(s, project, id, planDoc); !errors.Is(err, ErrTrajectoryGate) {
|
|
t.Fatalf("want ErrTrajectoryGate, got %v", err)
|
|
}
|
|
}
|