feat: add owner field for human attribution in HOP CV chains

Add 'owner' field to Issue struct for tracking the human responsible
for the issue, distinct from 'created_by' which tracks the executor.
Owner is populated from git author email (GIT_AUTHOR_EMAIL or git
config user.email), per Decision 008 for CV accumulation.

Changes:
- Add Owner field to types.Issue with omitempty JSON tag
- Include Owner in content hash computation
- Add owner column migration (036_owner_column.go)
- Update all SQL queries to include owner field
- Add getOwner() helper using git author email fallback chain
- Populate owner in bd create command
- Add owner to RPC CreateArgs protocol

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
beads/crew/dave
2026-01-10 19:28:08 -08:00
committed by Steve Yegge
parent 1039a69186
commit ceb5769c75
13 changed files with 109 additions and 23 deletions

View File

@@ -247,7 +247,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID
rows, err := s.db.QueryContext(ctx, `
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.created_by, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
i.created_at, i.created_by, i.owner, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters,
@@ -270,7 +270,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s
rows, err := s.db.QueryContext(ctx, `
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.created_by, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
i.created_at, i.created_by, i.owner, i.updated_at, i.closed_at, i.external_ref, i.source_repo,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters,
@@ -864,6 +864,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var owner sql.NullString
var externalRef sql.NullString
var sourceRepo sql.NullString
var closeReason sql.NullString
@@ -888,7 +889,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.CreatedBy, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason,
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
&awaitType, &awaitID, &timeoutNs, &waiters,
@@ -910,6 +911,9 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type
if assignee.Valid {
issue.Assignee = assignee.String
}
if owner.Valid {
issue.Owner = owner.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
@@ -987,6 +991,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var owner sql.NullString
var externalRef sql.NullString
var sourceRepo sql.NullString
var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually
@@ -1011,7 +1016,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.CreatedBy, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
&awaitType, &awaitID, &timeoutNs, &waiters,
@@ -1034,6 +1039,9 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows *
if assignee.Valid {
issue.Assignee = assignee.String
}
if owner.Valid {
issue.Owner = owner.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}

View File

@@ -46,18 +46,18 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error
INSERT OR IGNORE INTO issues (
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, created_by, updated_at, closed_at, external_ref, source_repo, close_reason,
created_at, created_by, owner, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters, mol_type,
event_kind, actor, target, payload,
due_at, defer_until
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
issue.Priority, issue.IssueType, issue.Assignee,
issue.EstimatedMinutes, issue.CreatedAt, issue.CreatedBy, issue.UpdatedAt,
issue.EstimatedMinutes, issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,
@@ -104,18 +104,18 @@ func insertIssueStrict(ctx context.Context, conn *sql.Conn, issue *types.Issue)
INSERT INTO issues (
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, created_by, updated_at, closed_at, external_ref, source_repo, close_reason,
created_at, created_by, owner, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters, mol_type,
event_kind, actor, target, payload,
due_at, defer_until
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
issue.Priority, issue.IssueType, issue.Assignee,
issue.EstimatedMinutes, issue.CreatedAt, issue.CreatedBy, issue.UpdatedAt,
issue.EstimatedMinutes, issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,
@@ -136,13 +136,13 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
INSERT OR IGNORE INTO issues (
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, created_by, updated_at, closed_at, external_ref, source_repo, close_reason,
created_at, created_by, owner, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters, mol_type,
event_kind, actor, target, payload,
due_at, defer_until
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return fmt.Errorf("failed to prepare statement: %w", err)
@@ -172,7 +172,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er
issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design,
issue.AcceptanceCriteria, issue.Notes, issue.Status,
issue.Priority, issue.IssueType, issue.Assignee,
issue.EstimatedMinutes, issue.CreatedAt, issue.CreatedBy, issue.UpdatedAt,
issue.EstimatedMinutes, issue.CreatedAt, issue.CreatedBy, issue.Owner, issue.UpdatedAt,
issue.ClosedAt, issue.ExternalRef, sourceRepo, issue.CloseReason,
issue.DeletedAt, issue.DeletedBy, issue.DeleteReason, issue.OriginalType,
issue.Sender, wisp, pinned, isTemplate,

View File

@@ -52,6 +52,7 @@ var migrationsList = []Migration{
{"event_fields", migrations.MigrateEventFields},
{"closed_by_session_column", migrations.MigrateClosedBySessionColumn},
{"due_defer_columns", migrations.MigrateDueDeferColumns},
{"owner_column", migrations.MigrateOwnerColumn},
}
// MigrationInfo contains metadata about a migration for inspection
@@ -111,6 +112,7 @@ func getMigrationDescription(name string) string {
"event_fields": "Adds event fields (event_kind, actor, target, payload) for operational state change beads",
"closed_by_session_column": "Adds closed_by_session column for tracking which Claude Code session closed an issue",
"due_defer_columns": "Adds due_at and defer_until columns for time-based task scheduling (GH#820)",
"owner_column": "Adds owner column for human attribution in HOP CV chains (Decision 008)",
}
if desc, ok := descriptions[name]; ok {

View File

@@ -0,0 +1,34 @@
package migrations
import (
"database/sql"
"fmt"
)
// MigrateOwnerColumn adds the owner column to the issues table.
// This tracks the human owner responsible for the issue, using git author email
// for HOP CV (curriculum vitae) attribution chains. See Decision 008.
func MigrateOwnerColumn(db *sql.DB) error {
// Check if column already exists
var columnExists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('issues')
WHERE name = 'owner'
`).Scan(&columnExists)
if err != nil {
return fmt.Errorf("failed to check owner column: %w", err)
}
if columnExists {
return nil
}
// Add the owner column
_, err = db.Exec(`ALTER TABLE issues ADD COLUMN owner TEXT DEFAULT ''`)
if err != nil {
return fmt.Errorf("failed to add owner column: %w", err)
}
return nil
}

View File

@@ -298,10 +298,11 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
var contentHash sql.NullString
var compactedAtCommit sql.NullString
var owner sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, created_by, updated_at, closed_at, external_ref,
created_at, created_by, owner, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
@@ -315,7 +316,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.CreatedBy, &issue.UpdatedAt, &closedAt, &externalRef,
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
@@ -345,6 +346,9 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue,
if assignee.Valid {
issue.Assignee = assignee.String
}
if owner.Valid {
issue.Owner = owner.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
@@ -560,10 +564,11 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
var timeoutNs sql.NullInt64
var waiters sql.NullString
var owner sql.NullString
err := s.db.QueryRowContext(ctx, `
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, created_by, updated_at, closed_at, external_ref,
created_at, created_by, owner, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
@@ -574,7 +579,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.CreatedBy, &issue.UpdatedAt, &closedAt, &externalRefCol,
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRefCol,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
@@ -601,6 +606,9 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s
if assignee.Valid {
issue.Assignee = assignee.String
}
if owner.Valid {
issue.Owner = owner.String
}
if externalRefCol.Valid {
issue.ExternalRef = &externalRefCol.String
}
@@ -1928,7 +1936,7 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
querySQL := fmt.Sprintf(`
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, created_by, updated_at, closed_at, external_ref, source_repo, close_reason,
created_at, created_by, owner, updated_at, closed_at, external_ref, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
await_type, await_id, timeout_ns, waiters

View File

@@ -152,7 +152,7 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
query := fmt.Sprintf(`
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.created_by, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.created_at, i.created_by, i.owner, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters
@@ -744,7 +744,7 @@ func (s *SQLiteStorage) GetNewlyUnblockedByClose(ctx context.Context, closedIssu
query := `
SELECT i.id, i.content_hash, i.title, i.description, i.design, i.acceptance_criteria, i.notes,
i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes,
i.created_at, i.created_by, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.created_at, i.created_by, i.owner, i.updated_at, i.closed_at, i.external_ref, i.source_repo, i.close_reason,
i.deleted_at, i.deleted_by, i.delete_reason, i.original_type,
i.sender, i.ephemeral, i.pinned, i.is_template,
i.await_type, i.await_id, i.timeout_ns, i.waiters

View File

@@ -17,6 +17,7 @@ CREATE TABLE IF NOT EXISTS issues (
estimated_minutes INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT DEFAULT '',
owner TEXT DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
closed_by_session TEXT DEFAULT '',

View File

@@ -320,7 +320,7 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue
row := t.conn.QueryRowContext(ctx, `
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, created_by, updated_at, closed_at, external_ref,
created_at, created_by, owner, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
@@ -1249,7 +1249,7 @@ func (t *sqliteTxStorage) SearchIssues(ctx context.Context, query string, filter
querySQL := fmt.Sprintf(`
SELECT id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, created_by, updated_at, closed_at, external_ref,
created_at, created_by, owner, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size, source_repo, close_reason,
deleted_at, deleted_by, delete_reason, original_type,
sender, ephemeral, pinned, is_template,
@@ -1283,6 +1283,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var owner sql.NullString
var externalRef sql.NullString
var compactedAt sql.NullTime
var originalSize sql.NullInt64
@@ -1310,7 +1311,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.CreatedBy, &issue.UpdatedAt, &closedAt, &externalRef,
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef,
&issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &wisp, &pinned, &isTemplate,
@@ -1333,6 +1334,9 @@ func scanIssueRow(row scanner) (*types.Issue, error) {
if assignee.Valid {
issue.Assignee = assignee.String
}
if owner.Valid {
issue.Owner = owner.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}