diff --git a/brain/decision.go b/brain/decision.go index 2a7fe0c..b7412ef 100644 --- a/brain/decision.go +++ b/brain/decision.go @@ -2,7 +2,6 @@ package brain import ( "context" - "log" "slices" "time" ) @@ -46,8 +45,7 @@ func (e *BaseDecision) Compose(sv SignVerifier, d Decision) error { for k := range other { e.AllToolResponses[k] = append(e.AllToolResponses[k], other[k]...) } - log.Printf("test Verify: %s", e.Verify(sv)) - return nil + return e.Verify(sv) } func (e *BaseDecision) IsError() error { diff --git a/brain/errords.go b/brain/errords.go index f05063f..98a1595 100644 --- a/brain/errords.go +++ b/brain/errords.go @@ -60,6 +60,10 @@ type ErrorDecision struct { E error } +func (e ErrorDecision) Clone() Decision { + return &ErrorDecision{E: e.E} +} + func (e ErrorDecision) Compose(SignVerifier, Decision) error { return e.E } diff --git a/brain/mockdecision_test.go b/brain/mockdecision_test.go index 8d4c2b7..7c26c29 100644 --- a/brain/mockdecision_test.go +++ b/brain/mockdecision_test.go @@ -8,6 +8,10 @@ type MockDecision struct { setErr error } +func (m *MockDecision) Clone() Decision { + return &MockDecision{Mock: m.Mock, setErr: m.setErr} +} + func (m *MockDecision) Cots() map[string][][]Signed { return nil } func (m *MockDecision) Prompts() map[string][]Signed { return nil } diff --git a/brain/whole.go b/brain/whole.go index 7065254..9ef2348 100644 --- a/brain/whole.go +++ b/brain/whole.go @@ -144,7 +144,9 @@ type Decision interface { SetError(error) SetAudits(Audits) GetAudits() Audits + // Compose must not modify the second argument or Fold will no longer be idempotent Compose(SignVerifier, Decision) error + Clone() Decision } type Decisions []Decision @@ -153,7 +155,7 @@ func (d Decisions) Fold(sv SignVerifier) Decision { if len(d) == 0 { return nil } - root := d[0] // TODO: change this to be a deep copy, or we end up losing idempotency of Fold() + root := d[0].Clone() var mErr error for i := 1; i < len(d); i++ { err := root.Compose(sv, d[i]) @@ -167,6 +169,49 @@ func (d Decisions) Fold(sv SignVerifier) Decision { return root } +func (d *BaseDecision) Clone() Decision { + newD := &BaseDecision{ + PublicKey: d.PublicKey, + ChainOfThoughts: make(map[string][][]Signed), + AllPrompts: make(map[string][]Signed), + AllTexts: make(map[string][]Signed), + AllToolRequests: make(map[string][]Signed), + AllToolResponses: make(map[string][]Signed), + Source: d.Source, + Audits: make(Audits, 0, len(d.Audits)), + } + + // Deep copy ChainOfThoughts + for k, v := range d.ChainOfThoughts { + newD.ChainOfThoughts[k] = make([][]Signed, len(v)) + for i, inner := range v { + newD.ChainOfThoughts[k][i] = make([]Signed, len(inner)) + copy(newD.ChainOfThoughts[k][i], inner) + } + } + + // Deep copy single-depth slices + newD.AllPrompts = cloneMapSlice(d.AllPrompts) + newD.AllTexts = cloneMapSlice(d.AllTexts) + newD.AllToolRequests = cloneMapSlice(d.AllToolRequests) + newD.AllToolResponses = cloneMapSlice(d.AllToolResponses) + + for _, n := range d.Audits { + newD.Audits = append(newD.Audits, n) // n is safe to shallow copy + } + return newD +} + +// Internal unselfish helper to handle the common map[string][]Signed pattern +func cloneMapSlice(m map[string][]Signed) map[string][]Signed { + newM := make(map[string][]Signed) + for k, v := range m { + newM[k] = make([]Signed, len(v)) + copy(newM[k], v) + } + return newM +} + func (d Decisions) LastAudit() (string, Audit, error) { if len(d) == 0 { return "", Audit{}, ErrNoDecisionFound diff --git a/brain/whole_test.go b/brain/whole_test.go index 1c9474f..48e4d1d 100644 --- a/brain/whole_test.go +++ b/brain/whole_test.go @@ -12,7 +12,30 @@ import ( "github.com/stretchr/testify/mock" ) -// --- Mocks for the Orchestrator --- +func TestFold_Immutability(t *testing.T) { + t.Run("Fold mutation test", func(t *testing.T) { + original := &BaseDecision{ + Source: "Anchor", + AllTexts: map[string][]Signed{ + "gemini": {{Data: "Original Content"}}, + }, + } + contributor := &BaseDecision{ + Source: "Contributor", + AllTexts: map[string][]Signed{ + "claude": {{Data: "Contribution"}}, + }, + } + decisions := Decisions{original, contributor} + sv := &MockSignVerifier{} + folded := decisions.Fold(sv) + // We try to mutate the 'folded' result and see if it bleeds back + foldedBase := folded.(*BaseDecision) + foldedBase.AllTexts["gemini"][0].Data = "MUTATED" + assert.NotEqual(t, "MUTATED", original.AllTexts["gemini"][0].Data) + assert.Equal(t, "Contribution", contributor.AllTexts["claude"][0].Data, "[FAILURE]: Input preservation failed. Compose mutated the input decision d[1].") + }) +} func TestWhole_Orchestration(t *testing.T) { mockSV := new(MockSignVerifier) // Using the mock from previous turn