diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index 1e9bfcbe..24bce118 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -221,8 +221,13 @@ func (s *SQLiteStorage) RemoveDependency(ctx context.Context, issueID, dependsOn return fmt.Errorf("failed to record event: %w", err) } - // Mark both issues as dirty for incremental export - if err := markIssuesDirtyTx(ctx, tx, []string{issueID, dependsOnID}); err != nil { + // Mark issues as dirty for incremental export + // For external refs, only mark the source issue (target doesn't exist locally) + issueIDsToMark := []string{issueID} + if !strings.HasPrefix(dependsOnID, "external:") { + issueIDsToMark = append(issueIDsToMark, dependsOnID) + } + if err := markIssuesDirtyTx(ctx, tx, issueIDsToMark); err != nil { return wrapDBError("mark issues dirty after removing dependency", err) } diff --git a/internal/storage/sqlite/dependencies_test.go b/internal/storage/sqlite/dependencies_test.go index c643ae2c..185c6207 100644 --- a/internal/storage/sqlite/dependencies_test.go +++ b/internal/storage/sqlite/dependencies_test.go @@ -1610,3 +1610,50 @@ func TestDetectCycles_RelatesTypeAllowsBidirectionalWithoutCycleReport(t *testin t.Error("Expected B to relate-to A") } } + +// TestRemoveDependencyExternal verifies that removing an external dependency +// doesn't cause FK violation (bd-a3sj). External refs like external:project:capability +// don't exist in the issues table, so we must not mark them as dirty. +func TestRemoveDependencyExternal(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create an issue + issue := &types.Issue{ + Title: "Issue with external dep", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test-user"); err != nil { + t.Fatalf("CreateIssue failed: %v", err) + } + + // Add an external dependency + externalRef := "external:other-project:some-capability" + dep := &types.Dependency{ + IssueID: issue.ID, + DependsOnID: externalRef, + Type: types.DepBlocks, + } + if err := store.AddDependency(ctx, dep, "test-user"); err != nil { + t.Fatalf("AddDependency failed: %v", err) + } + + // This should NOT cause FK violation (the bug was marking external ref as dirty) + err := store.RemoveDependency(ctx, issue.ID, externalRef, "test-user") + if err != nil { + t.Fatalf("RemoveDependency on external ref should succeed, got: %v", err) + } + + // Verify dependency was actually removed + deps, err := store.GetDependencyRecords(ctx, issue.ID) + if err != nil { + t.Fatalf("GetDependencyRecords failed: %v", err) + } + if len(deps) != 0 { + t.Errorf("Expected 0 dependencies after removal, got %d", len(deps)) + } +} diff --git a/internal/types/types.go b/internal/types/types.go index d005e73c..3fc6f9d1 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -4,6 +4,7 @@ package types import ( "crypto/sha256" "fmt" + "strings" "time" ) @@ -54,6 +55,10 @@ type Issue struct { // Bonding fields (bd-rnnr): compound molecule lineage BondedFrom []BondRef `json:"bonded_from,omitempty"` // For compounds: constituent protos + + // HOP fields (bd-7pwh): entity tracking for CV chains + Creator *EntityRef `json:"creator,omitempty"` // Who created this issue (human, agent, or org) + Validations []Validation `json:"validations,omitempty"` // Who validated/approved this work } // ComputeContentHash creates a deterministic hash of the issue's content. @@ -103,6 +108,38 @@ func (i *Issue) ComputeContentHash() string { h.Write([]byte(br.BondPoint)) h.Write([]byte{0}) } + // Hash creator for HOP entity tracking (bd-m7ib) + if i.Creator != nil { + h.Write([]byte(i.Creator.Name)) + h.Write([]byte{0}) + h.Write([]byte(i.Creator.Platform)) + h.Write([]byte{0}) + h.Write([]byte(i.Creator.Org)) + h.Write([]byte{0}) + h.Write([]byte(i.Creator.ID)) + h.Write([]byte{0}) + } + // Hash validations for HOP proof-of-stake (bd-du9h) + for _, v := range i.Validations { + if v.Validator != nil { + h.Write([]byte(v.Validator.Name)) + h.Write([]byte{0}) + h.Write([]byte(v.Validator.Platform)) + h.Write([]byte{0}) + h.Write([]byte(v.Validator.Org)) + h.Write([]byte{0}) + h.Write([]byte(v.Validator.ID)) + h.Write([]byte{0}) + } + h.Write([]byte(v.Outcome)) + h.Write([]byte{0}) + h.Write([]byte(v.Timestamp.Format(time.RFC3339))) + h.Write([]byte{0}) + if v.Score != nil { + h.Write([]byte(fmt.Sprintf("%f", *v.Score))) + } + h.Write([]byte{0}) + } return fmt.Sprintf("%x", h.Sum(nil)) } @@ -577,3 +614,118 @@ func (i *Issue) IsCompound() bool { func (i *Issue) GetConstituents() []BondRef { return i.BondedFrom } + +// EntityRef is a structured reference to an entity (human, agent, or org). +// This is the foundation for HOP entity tracking and CV chains. +// Can be rendered as a URI: entity://hop/// +// +// Example usage: +// +// ref := &EntityRef{ +// Name: "polecat/Nux", +// Platform: "gastown", +// Org: "steveyegge", +// ID: "polecat-nux", +// } +// uri := ref.URI() // "entity://hop/gastown/steveyegge/polecat-nux" +type EntityRef struct { + // Name is the human-readable identifier (e.g., "polecat/Nux", "mayor") + Name string `json:"name,omitempty"` + + // Platform identifies the execution context (e.g., "gastown", "github") + Platform string `json:"platform,omitempty"` + + // Org identifies the organization (e.g., "steveyegge", "anthropics") + Org string `json:"org,omitempty"` + + // ID is the unique identifier within the platform/org (e.g., "polecat-nux") + ID string `json:"id,omitempty"` +} + +// IsEmpty returns true if all fields are empty. +func (e *EntityRef) IsEmpty() bool { + if e == nil { + return true + } + return e.Name == "" && e.Platform == "" && e.Org == "" && e.ID == "" +} + +// URI returns the entity as a HOP URI. +// Format: entity://hop/// +// Returns empty string if Platform, Org, or ID is missing. +func (e *EntityRef) URI() string { + if e == nil || e.Platform == "" || e.Org == "" || e.ID == "" { + return "" + } + return fmt.Sprintf("entity://hop/%s/%s/%s", e.Platform, e.Org, e.ID) +} + +// String returns a human-readable representation. +// Prefers Name if set, otherwise returns URI or ID. +func (e *EntityRef) String() string { + if e == nil { + return "" + } + if e.Name != "" { + return e.Name + } + if uri := e.URI(); uri != "" { + return uri + } + return e.ID +} + +// Validation records who validated/approved work completion. +// This is core to HOP's proof-of-stake concept - validators stake +// their reputation on approvals. +type Validation struct { + // Validator is who approved/rejected the work + Validator *EntityRef `json:"validator"` + + // Outcome is the validation result: accepted, rejected, revision_requested + Outcome string `json:"outcome"` + + // Timestamp is when the validation occurred + Timestamp time.Time `json:"timestamp"` + + // Score is an optional quality score (0.0-1.0) + Score *float32 `json:"score,omitempty"` +} + +// Validation outcome constants +const ( + ValidationAccepted = "accepted" + ValidationRejected = "rejected" + ValidationRevisionRequested = "revision_requested" +) + +// IsValidOutcome checks if the outcome is a known validation outcome. +func (v *Validation) IsValidOutcome() bool { + switch v.Outcome { + case ValidationAccepted, ValidationRejected, ValidationRevisionRequested: + return true + } + return false +} + +// ParseEntityURI parses a HOP entity URI into an EntityRef. +// Format: entity://hop/// +// Returns nil and error if the URI is invalid. +func ParseEntityURI(uri string) (*EntityRef, error) { + const prefix = "entity://hop/" + if !strings.HasPrefix(uri, prefix) { + return nil, fmt.Errorf("invalid entity URI: must start with %q", prefix) + } + + rest := uri[len(prefix):] + parts := strings.SplitN(rest, "/", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, fmt.Errorf("invalid entity URI: expected entity://hop///, got %q", uri) + } + + return &EntityRef{ + Platform: parts[0], + Org: parts[1], + ID: parts[2], + }, nil +} diff --git a/internal/types/types_test.go b/internal/types/types_test.go index 0b93e77f..08dff97d 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -959,3 +959,311 @@ func TestSetDefaults(t *testing.T) { }) } } + +// EntityRef tests (bd-nmch: HOP entity tracking foundation) + +func TestEntityRefIsEmpty(t *testing.T) { + tests := []struct { + name string + ref *EntityRef + expect bool + }{ + {"nil ref", nil, true}, + {"empty ref", &EntityRef{}, true}, + {"only name", &EntityRef{Name: "test"}, false}, + {"only platform", &EntityRef{Platform: "gastown"}, false}, + {"only org", &EntityRef{Org: "steveyegge"}, false}, + {"only id", &EntityRef{ID: "polecat-nux"}, false}, + {"full ref", &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.ref.IsEmpty(); got != tt.expect { + t.Errorf("EntityRef.IsEmpty() = %v, want %v", got, tt.expect) + } + }) + } +} + +func TestEntityRefURI(t *testing.T) { + tests := []struct { + name string + ref *EntityRef + expect string + }{ + {"nil ref", nil, ""}, + {"empty ref", &EntityRef{}, ""}, + {"missing platform", &EntityRef{Org: "steveyegge", ID: "polecat-nux"}, ""}, + {"missing org", &EntityRef{Platform: "gastown", ID: "polecat-nux"}, ""}, + {"missing id", &EntityRef{Platform: "gastown", Org: "steveyegge"}, ""}, + {"full ref", &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, "entity://hop/gastown/steveyegge/polecat-nux"}, + {"with name", &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, "entity://hop/gastown/steveyegge/polecat-nux"}, + {"github platform", &EntityRef{Platform: "github", Org: "anthropics", ID: "claude-code"}, "entity://hop/github/anthropics/claude-code"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.ref.URI(); got != tt.expect { + t.Errorf("EntityRef.URI() = %q, want %q", got, tt.expect) + } + }) + } +} + +func TestEntityRefString(t *testing.T) { + tests := []struct { + name string + ref *EntityRef + expect string + }{ + {"nil ref", nil, ""}, + {"empty ref", &EntityRef{}, ""}, + {"only name", &EntityRef{Name: "polecat/Nux"}, "polecat/Nux"}, + {"full ref with name", &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, "polecat/Nux"}, + {"full ref without name", &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, "entity://hop/gastown/steveyegge/polecat-nux"}, + {"only id", &EntityRef{ID: "polecat-nux"}, "polecat-nux"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.ref.String(); got != tt.expect { + t.Errorf("EntityRef.String() = %q, want %q", got, tt.expect) + } + }) + } +} + +func TestParseEntityURI(t *testing.T) { + tests := []struct { + name string + uri string + expect *EntityRef + expectErr bool + }{ + { + name: "valid URI", + uri: "entity://hop/gastown/steveyegge/polecat-nux", + expect: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, + }, + { + name: "github URI", + uri: "entity://hop/github/anthropics/claude-code", + expect: &EntityRef{Platform: "github", Org: "anthropics", ID: "claude-code"}, + }, + { + name: "id with slashes", + uri: "entity://hop/gastown/steveyegge/polecat/nux", + expect: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat/nux"}, + }, + { + name: "wrong prefix", + uri: "beads://hop/gastown/steveyegge/polecat-nux", + expectErr: true, + }, + { + name: "missing hop", + uri: "entity://gastown/steveyegge/polecat-nux", + expectErr: true, + }, + { + name: "too few parts", + uri: "entity://hop/gastown/steveyegge", + expectErr: true, + }, + { + name: "empty platform", + uri: "entity://hop//steveyegge/polecat-nux", + expectErr: true, + }, + { + name: "empty org", + uri: "entity://hop/gastown//polecat-nux", + expectErr: true, + }, + { + name: "empty id", + uri: "entity://hop/gastown/steveyegge/", + expectErr: true, + }, + { + name: "empty string", + uri: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseEntityURI(tt.uri) + if tt.expectErr { + if err == nil { + t.Errorf("ParseEntityURI(%q) expected error, got nil", tt.uri) + } + return + } + if err != nil { + t.Errorf("ParseEntityURI(%q) unexpected error: %v", tt.uri, err) + return + } + if got.Platform != tt.expect.Platform || got.Org != tt.expect.Org || got.ID != tt.expect.ID { + t.Errorf("ParseEntityURI(%q) = %+v, want %+v", tt.uri, got, tt.expect) + } + }) + } +} + +func TestEntityRefRoundTrip(t *testing.T) { + // Test that URI() and ParseEntityURI() are inverses + original := &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"} + uri := original.URI() + parsed, err := ParseEntityURI(uri) + if err != nil { + t.Fatalf("ParseEntityURI(%q) error: %v", uri, err) + } + if parsed.Platform != original.Platform || parsed.Org != original.Org || parsed.ID != original.ID { + t.Errorf("Round trip failed: got %+v, want %+v", parsed, original) + } +} + +func TestComputeContentHashWithCreator(t *testing.T) { + // Test that Creator field affects the content hash (bd-m7ib) + issue1 := Issue{ + Title: "Test Issue", + Status: StatusOpen, + Priority: 2, + IssueType: TypeTask, + } + + issue2 := Issue{ + Title: "Test Issue", + Status: StatusOpen, + Priority: 2, + IssueType: TypeTask, + Creator: &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, + } + + hash1 := issue1.ComputeContentHash() + hash2 := issue2.ComputeContentHash() + + if hash1 == hash2 { + t.Error("Expected different hash when Creator is set") + } + + // Same creator should produce same hash + issue3 := Issue{ + Title: "Test Issue", + Status: StatusOpen, + Priority: 2, + IssueType: TypeTask, + Creator: &EntityRef{Name: "polecat/Nux", Platform: "gastown", Org: "steveyegge", ID: "polecat-nux"}, + } + + hash3 := issue3.ComputeContentHash() + if hash2 != hash3 { + t.Error("Expected same hash for identical Creator") + } +} + +// Validation tests (bd-du9h: HOP proof-of-stake) + +func TestValidationIsValidOutcome(t *testing.T) { + tests := []struct { + outcome string + valid bool + }{ + {ValidationAccepted, true}, + {ValidationRejected, true}, + {ValidationRevisionRequested, true}, + {"unknown", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.outcome, func(t *testing.T) { + v := &Validation{Outcome: tt.outcome} + if got := v.IsValidOutcome(); got != tt.valid { + t.Errorf("Validation{Outcome: %q}.IsValidOutcome() = %v, want %v", tt.outcome, got, tt.valid) + } + }) + } +} + +func TestComputeContentHashWithValidations(t *testing.T) { + // Test that Validations field affects the content hash (bd-du9h) + ts := time.Date(2025, 12, 22, 10, 30, 0, 0, time.UTC) + + issue1 := Issue{ + Title: "Test Issue", + Status: StatusClosed, + Priority: 2, + IssueType: TypeTask, + ClosedAt: &ts, + } + + issue2 := Issue{ + Title: "Test Issue", + Status: StatusClosed, + Priority: 2, + IssueType: TypeTask, + ClosedAt: &ts, + Validations: []Validation{ + { + Validator: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "refinery"}, + Outcome: ValidationAccepted, + Timestamp: ts, + }, + }, + } + + hash1 := issue1.ComputeContentHash() + hash2 := issue2.ComputeContentHash() + + if hash1 == hash2 { + t.Error("Expected different hash when Validations is set") + } + + // Same validations should produce same hash + issue3 := Issue{ + Title: "Test Issue", + Status: StatusClosed, + Priority: 2, + IssueType: TypeTask, + ClosedAt: &ts, + Validations: []Validation{ + { + Validator: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "refinery"}, + Outcome: ValidationAccepted, + Timestamp: ts, + }, + }, + } + + hash3 := issue3.ComputeContentHash() + if hash2 != hash3 { + t.Error("Expected same hash for identical Validations") + } + + // Test with score + score := float32(0.95) + issue4 := Issue{ + Title: "Test Issue", + Status: StatusClosed, + Priority: 2, + IssueType: TypeTask, + ClosedAt: &ts, + Validations: []Validation{ + { + Validator: &EntityRef{Platform: "gastown", Org: "steveyegge", ID: "refinery"}, + Outcome: ValidationAccepted, + Timestamp: ts, + Score: &score, + }, + }, + } + + hash4 := issue4.ComputeContentHash() + if hash2 == hash4 { + t.Error("Expected different hash when Score is added") + } +}