Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 9 additions & 16 deletions backend/adapter/a2a_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,13 +270,11 @@ func loadConversationResolutions(tx *gorm.DB, messages []*entity.ConversationMes
out := make(map[string]*entity.Resolution, len(models))
for i := range models {
model := &models[i]
out[model.CaseID] = &entity.Resolution{
ID: model.ID, CaseID: model.CaseID, ExecutionGeneration: model.ExecutionGeneration,
Consensus: fromJSON[entity.ConsensusResult](model.ConsensusJSON),
FinalDecision: entity.VoteDecision(model.FinalDecision), FinalReport: model.FinalReport,
KeyEvidenceIDs: fromJSON[[]string](model.KeyEvidenceIDsJSON), KeyClaimIDs: fromJSON[[]string](model.KeyClaimIDsJSON),
VoteIDs: fromJSON[[]string](model.VoteIDsJSON), Evaluation: fromJSON[*entity.Evaluation](model.EvaluationJSON), CreatedAt: model.CreatedAt,
resolution, err := resolutionFromModel(model)
if err != nil {
return nil, err
}
out[model.CaseID] = resolution
}
return out, nil
}
Expand Down Expand Up @@ -687,7 +685,11 @@ func loadTaskRecord(db *gorm.DB, binding A2ASubmissionModel, includeArtifacts bo
if includeArtifacts {
var resolution ResolutionModel
if err := db.Where("case_id = ?", binding.TaskID).First(&resolution).Error; err == nil {
record.Resolution = resolutionFromModel(&resolution)
resolutionEntity, rerr := resolutionFromModel(&resolution)
if rerr != nil {
return nil, rerr
}
record.Resolution = resolutionEntity
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
Expand Down Expand Up @@ -724,15 +726,6 @@ func loadTaskRecord(db *gorm.DB, binding A2ASubmissionModel, includeArtifacts bo
return record, nil
}

func resolutionFromModel(m *ResolutionModel) *entity.Resolution {
return &entity.Resolution{
ID: m.ID, CaseID: m.CaseID, Consensus: fromJSON[entity.ConsensusResult](m.ConsensusJSON),
FinalDecision: entity.VoteDecision(m.FinalDecision), FinalReport: m.FinalReport,
KeyEvidenceIDs: fromJSON[[]string](m.KeyEvidenceIDsJSON), KeyClaimIDs: fromJSON[[]string](m.KeyClaimIDsJSON),
VoteIDs: fromJSON[[]string](m.VoteIDsJSON), Evaluation: fromJSON[*entity.Evaluation](m.EvaluationJSON), CreatedAt: m.CreatedAt,
}
}

func voteFromModel(m *VoteModel) *entity.Vote {
return &entity.Vote{
ID: m.ID, CaseID: m.CaseID, AgentRunID: m.AgentRunID, Round: m.Round, Decision: entity.VoteDecision(m.Decision),
Expand Down
45 changes: 43 additions & 2 deletions backend/adapter/artifact_generation_orchestration_mysql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package magi_test

import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
Expand Down Expand Up @@ -35,9 +36,22 @@ type t3ScriptedModel struct {
calls int
}

func (m *t3ScriptedModel) Generate(context.Context, []*schema.Message, ...model.Option) (*schema.Message, error) {
func (m *t3ScriptedModel) Generate(_ context.Context, in []*schema.Message, _ ...model.Option) (*schema.Message, error) {
m.mu.Lock()
defer m.mu.Unlock()
var prompt strings.Builder
for _, msg := range in {
if msg != nil {
prompt.WriteString(msg.Content)
prompt.WriteString("\n")
}
}
// The report call cites the first authoritative ID in the prompt. The
// strict citation validator (issue #13) rejects the old canned "EV-001"
// because the frozen set holds namespaced IDs the script cannot know.
if strings.Contains(prompt.String(), "FinalReportData") {
return schema.AssistantMessage(t3CitingReportJSON(prompt.String()), nil), nil
}
if m.calls >= len(m.responses) {
return nil, fmt.Errorf("t3 scripted model exhausted after %d calls", m.calls)
}
Expand All @@ -46,6 +60,34 @@ func (m *t3ScriptedModel) Generate(context.Context, []*schema.Message, ...model.
return msg, nil
}

func t3CitingReportJSON(prompt string) string {
body := map[string]any{"summary": "decision report summary", "key_reasons": []string{"r1"}, "risks": []string{}, "next_steps": []string{}}
if id := t3FirstListedID(prompt, "Available evidence IDs: ["); id != "" {
body["key_evidence_ids"] = []string{id}
} else if id := t3FirstListedID(prompt, "Available claim IDs: ["); id != "" {
body["key_claim_ids"] = []string{id}
}
raw, _ := json.Marshal(body)
return string(raw)
}

func t3FirstListedID(prompt, marker string) string {
i := strings.Index(prompt, marker)
if i < 0 {
return ""
}
rest := prompt[i+len(marker):]
j := strings.IndexByte(rest, ']')
if j < 0 {
return ""
}
fields := strings.Fields(rest[:j])
if len(fields) == 0 {
return ""
}
return fields[0]
}

func (m *t3ScriptedModel) Stream(context.Context, []*schema.Message, ...model.Option) (*schema.StreamReader[*schema.Message], error) {
return nil, fmt.Errorf("stream not implemented")
}
Expand Down Expand Up @@ -106,7 +148,6 @@ func t3Commander(t *testing.T) *service.Commander {
t.Helper()
script := &t3ScriptedModel{responses: []*schema.Message{
schema.AssistantMessage(`{"canonical_question":"compute"}`, nil),
schema.AssistantMessage(`{"decision":"approve","summary":"decision report summary","key_reasons":["r1"],"risks":[],"next_steps":[],"key_evidence_ids":["EV-001"]}`, nil),
}}
cmd, err := service.NewCommander(
service.CommanderConfig{Model: entity.ModelRef{ModelID: 1}, Persona: "commander"},
Expand Down
10 changes: 7 additions & 3 deletions backend/adapter/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,13 @@ type ResolutionModel struct {
FinalReport string `gorm:"type:text"`
KeyEvidenceIDsJSON string `gorm:"type:text"`
KeyClaimIDsJSON string `gorm:"type:text"`
VoteIDsJSON string `gorm:"type:text"`
EvaluationJSON string `gorm:"type:text"`
CreatedAt time.Time
// DissentJSON is nullable on purpose: NULL means the minority record was
// never persisted (legacy/uncommitted), while "[]" is an explicit recorded
// "no dissent". The distinction is part of the REPORT-01 contract.
DissentJSON *string `gorm:"type:text"`
VoteIDsJSON string `gorm:"type:text"`
EvaluationJSON string `gorm:"type:text"`
CreatedAt time.Time
}

func (ResolutionModel) TableName() string { return "resolution" }
Expand Down
62 changes: 61 additions & 1 deletion backend/adapter/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,54 @@ func fromJSON[T any](s string) T {
return v
}

// encodeDissent persists the tri-state minority record. A nil slice is stored
// as SQL NULL ("not recorded"); a non-nil slice is stored as its JSON form
// ("[]" or "[...]") so an explicit "no dissent" survives a round trip.
//
// The struct is marshal-only (strings/slices/bools), so encoding cannot fail in
// practice. If it ever did, writing SQL NULL would silently turn a real
// minority record into "not recorded"; the sentinel below instead makes the
// read fail closed.
func encodeDissent(d []entity.Dissent) *string {
if d == nil {
return nil
}
b, err := json.Marshal(d)
if err != nil {
bad := "!dissent-encode-error"
return &bad
}
s := string(b)
return &s
}

// decodeDissent restores the tri-state. SQL NULL is the only legal "not
// recorded" representation. Every other value must be a well-formed JSON array:
// "[]" is an explicit recorded-none and "[...]" is a recorded minority.
//
// A malformed or non-array value is reported as an error instead of being
// downgraded to "not recorded": silently returning nil would drop a stored
// minority record from the HTTP/A2A/export projections and hide the corruption.
func decodeDissent(raw *string) ([]entity.Dissent, error) {
if raw == nil {
return nil, nil
}
trimmed := strings.TrimSpace(*raw)
var out []entity.Dissent
if err := json.Unmarshal([]byte(trimmed), &out); err != nil {
return nil, fmt.Errorf("resolution.dissent_json is corrupt: %w", err)
}
if out == nil {
return nil, fmt.Errorf(`resolution.dissent_json must be a JSON array; use SQL NULL for "not recorded", got %q`, trimmed)
}
for i, d := range out {
if err := entity.ValidateDissent(d); err != nil {
return nil, fmt.Errorf("resolution.dissent_json element %d is invalid: %w", i, err)
}
}
return out, nil
}

// --- aggregate ---

type magiRepository struct {
Expand Down Expand Up @@ -1022,18 +1070,30 @@ func resolutionModel(res *entity.Resolution) ResolutionModel {
return ResolutionModel{
ID: res.ID, CaseID: res.CaseID, ExecutionGeneration: res.ExecutionGeneration, ConsensusJSON: toJSON(res.Consensus), FinalDecision: string(res.FinalDecision),
FinalReport: res.FinalReport, KeyEvidenceIDsJSON: toJSON(res.KeyEvidenceIDs), KeyClaimIDsJSON: toJSON(res.KeyClaimIDs),
VoteIDsJSON: toJSON(res.VoteIDs), EvaluationJSON: toJSON(res.Evaluation), CreatedAt: res.CreatedAt,
DissentJSON: encodeDissent(res.Dissent), VoteIDsJSON: toJSON(res.VoteIDs), EvaluationJSON: toJSON(res.Evaluation), CreatedAt: res.CreatedAt,
}
}
func (r *resolutionRepo) Get(ctx context.Context, caseID string) (*entity.Resolution, error) {
var m ResolutionModel
if err := r.db.WithContext(ctx).First(&m, "case_id = ?", caseID).Error; err != nil {
return nil, err
}
return resolutionFromModel(&m)
}

// resolutionFromModel maps a persisted resolution row to its domain entity.
// A row whose dissent column is SQL NULL loads as UNKNOWN (never recorded); a
// corrupt non-NULL value is an error, not a silent UNKNOWN.
func resolutionFromModel(m *ResolutionModel) (*entity.Resolution, error) {
dissent, err := decodeDissent(m.DissentJSON)
if err != nil {
return nil, fmt.Errorf("resolution %s: %w", m.CaseID, err)
}
return &entity.Resolution{
ID: m.ID, CaseID: m.CaseID, ExecutionGeneration: m.ExecutionGeneration, Consensus: fromJSON[entity.ConsensusResult](m.ConsensusJSON),
FinalDecision: entity.VoteDecision(m.FinalDecision), FinalReport: m.FinalReport,
KeyEvidenceIDs: fromJSON[[]string](m.KeyEvidenceIDsJSON), KeyClaimIDs: fromJSON[[]string](m.KeyClaimIDsJSON),
Dissent: dissent,
VoteIDs: fromJSON[[]string](m.VoteIDsJSON), Evaluation: fromJSON[*entity.Evaluation](m.EvaluationJSON), CreatedAt: m.CreatedAt,
}, nil
}
Expand Down
148 changes: 148 additions & 0 deletions backend/adapter/resolution_dissent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package magi_test

import (
"context"
"strings"
"testing"

magi "github.com/jamespud/magi/backend/adapter"
"github.com/jamespud/magi/backend/domain/entity"
)

// The dissent tri-state must survive a persistence round trip: NULL (legacy,
// not recorded), an explicit non-nil empty slice (recorded none) and a
// non-empty slice (recorded minority) are three distinct facts.
func TestResolutionRepo_DissentTriStateRoundTrip(t *testing.T) {
db := openConversationDB(t)
repo := magi.NewRepository(db)
ctx := context.Background()

// Simulate a legacy row written before the dissent column existed. The
// column is nullable, so an unset value is SQL NULL.
legacy := &entity.Resolution{ID: "res-legacy", CaseID: "c-legacy", FinalDecision: entity.VoteDecisionApprove, FinalReport: "# legacy"}
if err := repo.ResolutionRepo().Create(ctx, legacy); err != nil {
t.Fatalf("create legacy: %v", err)
}
if err := db.Exec("UPDATE resolution SET dissent_json = NULL WHERE case_id = ?", "c-legacy").Error; err != nil {
t.Fatalf("null out dissent: %v", err)
}

none := &entity.Resolution{ID: "res-none", CaseID: "c-none", FinalDecision: entity.VoteDecisionApprove, FinalReport: "# none", Dissent: []entity.Dissent{}}
if err := repo.ResolutionRepo().Create(ctx, none); err != nil {
t.Fatalf("create none: %v", err)
}
present := &entity.Resolution{ID: "res-present", CaseID: "c-present", FinalDecision: entity.VoteDecisionApprove, FinalReport: "# present",
Dissent: []entity.Dissent{{AgentCode: entity.MagiCodeCasper, Decision: entity.VoteDecisionReject, Reasoning: "risk",
Conditions: []entity.DecisionCondition{{Statement: "rollback", MustHold: true}}}}}
if err := repo.ResolutionRepo().Create(ctx, present); err != nil {
t.Fatalf("create present: %v", err)
}

for caseID, want := range map[string]entity.DissentState{
"c-legacy": entity.DissentUnknown, "c-none": entity.DissentNone, "c-present": entity.DissentPresent,
} {
got, err := repo.ResolutionRepo().Get(ctx, caseID)
if err != nil || got == nil {
t.Fatalf("get %s: %+v err=%v", caseID, got, err)
}
if got.DissentState() != want {
t.Fatalf("%s state = %s, want %s (dissent=%+v)", caseID, got.DissentState(), want, got.Dissent)
}
}
got, _ := repo.ResolutionRepo().Get(ctx, "c-present")
if len(got.Dissent) != 1 || got.Dissent[0].Reasoning != "risk" || len(got.Dissent[0].Conditions) != 1 {
t.Fatalf("present dissent content lost: %+v", got.Dissent)
}
}

// A non-NULL dissent_json that is not a well-formed JSON array is corruption,
// not "not recorded". It must fail closed instead of being downgraded to
// UNKNOWN, otherwise a stored minority record would silently vanish from the
// HTTP/A2A/export projections.
func TestResolutionRepo_CorruptDissentRecordFailsClosed(t *testing.T) {
db := openConversationDB(t)
repo := magi.NewRepository(db)
ctx := context.Background()
res := &entity.Resolution{ID: "res-corrupt", CaseID: "c-corrupt", FinalDecision: entity.VoteDecisionApprove, FinalReport: "# r",
Dissent: []entity.Dissent{{AgentCode: entity.MagiCodeCasper, Decision: entity.VoteDecisionReject, Reasoning: "risk"}}}
if err := repo.ResolutionRepo().Create(ctx, res); err != nil {
t.Fatalf("create: %v", err)
}
for _, bad := range []string{"{not json", "null", "", `{"agent_code":"casper"}`} {
if err := db.Exec("UPDATE resolution SET dissent_json = ? WHERE case_id = ?", bad, "c-corrupt").Error; err != nil {
t.Fatalf("corrupt row: %v", err)
}
got, err := repo.ResolutionRepo().Get(ctx, "c-corrupt")
if err == nil {
t.Fatalf("dissent_json=%q must fail closed, got %+v (state=%s)", bad, got, got.DissentState())
}
if got != nil {
t.Fatalf("dissent_json=%q must not return a resolution, got %+v", bad, got)
}
}
}

// A well-formed JSON array can still contain elements that are not valid
// dissent records. Those must fail closed, and for a specific reason: the JSON
// keys below match the persisted encoding (entity.Dissent json tags), so each
// case really reaches its validation branch.
func TestResolutionRepo_InvalidDissentElementsFailClosed(t *testing.T) {
db := openConversationDB(t)
repo := magi.NewRepository(db)
ctx := context.Background()
res := &entity.Resolution{ID: "res-elem", CaseID: "c-elem", FinalDecision: entity.VoteDecisionApprove, FinalReport: "# r",
Dissent: []entity.Dissent{{AgentCode: entity.MagiCodeCasper, Decision: entity.VoteDecisionReject, Reasoning: "risk"}}}
if err := repo.ResolutionRepo().Create(ctx, res); err != nil {
t.Fatalf("create: %v", err)
}
cases := []struct{ name, json, want string }{
{"null element", `[null]`, "not a voting persona"},
{"empty object", `[{}]`, "not a voting persona"},
{"unknown persona", `[{"agent_code":"nobody","decision":"reject"}]`, "not a voting persona"},
{"commander does not vote", `[{"agent_code":"commander","decision":"reject"}]`, "not a voting persona"},
{"undefined decision", `[{"agent_code":"casper","decision":"maybe"}]`, "not a valid vote decision"},
{"missing decision", `[{"agent_code":"casper"}]`, "not a valid vote decision"},
{"abstention is not dissent", `[{"agent_code":"casper","decision":"abstain"}]`, "abstain"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := db.Exec("UPDATE resolution SET dissent_json = ? WHERE case_id = ?", tc.json, "c-elem").Error; err != nil {
t.Fatalf("corrupt row: %v", err)
}
got, err := repo.ResolutionRepo().Get(ctx, "c-elem")
if err == nil {
t.Fatalf("dissent_json=%s must fail closed, got %+v (state=%s)", tc.json, got, got.DissentState())
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("dissent_json=%s error %q does not mention %q (wrong branch)", tc.json, err, tc.want)
}
})
}
}

// Lock the persisted encoding: resolution.dissent_json must use the explicit
// snake_case keys (matching the HTTP/A2A projections), not Go's default
// PascalCase, so hand-written and generated records agree.
func TestResolutionRepo_DissentJSONUsesSnakeCaseKeys(t *testing.T) {
db := openConversationDB(t)
repo := magi.NewRepository(db)
ctx := context.Background()
res := &entity.Resolution{ID: "res-keys", CaseID: "c-keys", FinalDecision: entity.VoteDecisionApprove, FinalReport: "# r",
Dissent: []entity.Dissent{{AgentCode: entity.MagiCodeCasper, Decision: entity.VoteDecisionReject, Reasoning: "risk",
EvidenceIDs: []string{"EV-1"}, Conditions: []entity.DecisionCondition{{Statement: "rollback", MustHold: true}}}}}
if err := repo.ResolutionRepo().Create(ctx, res); err != nil {
t.Fatalf("create: %v", err)
}
var stored string
if err := db.Raw("SELECT dissent_json FROM resolution WHERE case_id = ?", "c-keys").Scan(&stored).Error; err != nil {
t.Fatalf("read raw: %v", err)
}
for _, want := range []string{`"agent_code":"casper"`, `"decision":"reject"`, `"evidence_ids":["EV-1"]`, `"conditions"`} {
if !strings.Contains(stored, want) {
t.Fatalf("stored dissent_json missing %s: %s", want, stored)
}
}
if strings.Contains(stored, "AgentCode") || strings.Contains(stored, "Decision") {
t.Fatalf("stored dissent_json uses PascalCase keys: %s", stored)
}
}
Loading
Loading