From f5cd36752dc4f60fd226f2df19ce83ae2c6df471 Mon Sep 17 00:00:00 2001 From: beads/crew/fang Date: Sat, 10 Jan 2026 23:42:58 -0800 Subject: [PATCH] feat: add crystallizes column to sqlite storage Adds crystallizes column for work economics (compounds vs evaporates) per Decision 006. Includes migration 036 and updates to all INSERT/SELECT queries in issues.go, queries.go, dependencies.go, and transaction.go. Co-Authored-By: Claude Opus 4.5 --- internal/storage/sqlite/dependencies.go | 20 ++++++++--- internal/storage/sqlite/issues.go | 24 +++++++++---- .../migrations/036_crystallizes_column.go | 36 +++++++++++++++++++ internal/storage/sqlite/queries.go | 22 +++++++++--- internal/storage/sqlite/schema.go | 2 ++ internal/storage/sqlite/transaction.go | 10 ++++-- 6 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 internal/storage/sqlite/migrations/036_crystallizes_column.go diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index d9e3dbc4..97f6980f 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -249,7 +249,7 @@ func (s *SQLiteStorage) GetDependenciesWithMetadata(ctx context.Context, issueID i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, 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.sender, i.ephemeral, i.pinned, i.is_template, i.crystallizes, i.await_type, i.await_id, i.timeout_ns, i.waiters, d.type FROM issues i @@ -272,7 +272,7 @@ func (s *SQLiteStorage) GetDependentsWithMetadata(ctx context.Context, issueID s i.status, i.priority, i.issue_type, i.assignee, i.estimated_minutes, 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.sender, i.ephemeral, i.pinned, i.is_template, i.crystallizes, i.await_type, i.await_id, i.timeout_ns, i.waiters, d.type FROM issues i @@ -879,6 +879,8 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type var pinned sql.NullInt64 // Template field var isTemplate sql.NullInt64 + // Crystallizes field (work economics) + var crystallizes sql.NullInt64 // Gate fields var awaitType sql.NullString var awaitID sql.NullString @@ -891,7 +893,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &wisp, &pinned, &isTemplate, + &sender, &wisp, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, ) if err != nil { @@ -948,6 +950,10 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type if isTemplate.Valid && isTemplate.Int64 != 0 { issue.IsTemplate = true } + // Crystallizes field (work economics) + if crystallizes.Valid && crystallizes.Int64 != 0 { + issue.Crystallizes = true + } // Gate fields if awaitType.Valid { issue.AwaitType = awaitType.String @@ -1005,6 +1011,8 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * var pinned sql.NullInt64 // Template field var isTemplate sql.NullInt64 + // Crystallizes field (work economics) + var crystallizes sql.NullInt64 // Gate fields var awaitType sql.NullString var awaitID sql.NullString @@ -1018,7 +1026,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &wisp, &pinned, &isTemplate, + &sender, &wisp, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, &depType, ) @@ -1073,6 +1081,10 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * if isTemplate.Valid && isTemplate.Int64 != 0 { issue.IsTemplate = true } + // Crystallizes field (work economics) + if crystallizes.Valid && crystallizes.Int64 != 0 { + issue.Crystallizes = true + } // Gate fields if awaitType.Valid { issue.AwaitType = awaitType.String diff --git a/internal/storage/sqlite/issues.go b/internal/storage/sqlite/issues.go index 1add9576..13b622fd 100644 --- a/internal/storage/sqlite/issues.go +++ b/internal/storage/sqlite/issues.go @@ -41,6 +41,10 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error if issue.IsTemplate { isTemplate = 1 } + crystallizes := 0 + if issue.Crystallizes { + crystallizes = 1 + } _, err := conn.ExecContext(ctx, ` INSERT OR IGNORE INTO issues ( @@ -48,7 +52,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error status, priority, issue_type, assignee, estimated_minutes, 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, + sender, ephemeral, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters, mol_type, event_kind, actor, target, payload, due_at, defer_until @@ -60,7 +64,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error 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, + issue.Sender, wisp, pinned, isTemplate, crystallizes, issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters), string(issue.MolType), issue.EventKind, issue.Actor, issue.Target, issue.Payload, @@ -99,6 +103,10 @@ func insertIssueStrict(ctx context.Context, conn *sql.Conn, issue *types.Issue) if issue.IsTemplate { isTemplate = 1 } + crystallizes := 0 + if issue.Crystallizes { + crystallizes = 1 + } _, err := conn.ExecContext(ctx, ` INSERT INTO issues ( @@ -106,7 +114,7 @@ func insertIssueStrict(ctx context.Context, conn *sql.Conn, issue *types.Issue) status, priority, issue_type, assignee, estimated_minutes, 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, + sender, ephemeral, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters, mol_type, event_kind, actor, target, payload, due_at, defer_until @@ -118,7 +126,7 @@ func insertIssueStrict(ctx context.Context, conn *sql.Conn, issue *types.Issue) 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, + issue.Sender, wisp, pinned, isTemplate, crystallizes, issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters), string(issue.MolType), issue.EventKind, issue.Actor, issue.Target, issue.Payload, @@ -138,7 +146,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er status, priority, issue_type, assignee, estimated_minutes, 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, + sender, ephemeral, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters, mol_type, event_kind, actor, target, payload, due_at, defer_until @@ -167,6 +175,10 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er if issue.IsTemplate { isTemplate = 1 } + crystallizes := 0 + if issue.Crystallizes { + crystallizes = 1 + } _, err = stmt.ExecContext(ctx, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, @@ -175,7 +187,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er 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, + issue.Sender, wisp, pinned, isTemplate, crystallizes, issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters), string(issue.MolType), issue.EventKind, issue.Actor, issue.Target, issue.Payload, diff --git a/internal/storage/sqlite/migrations/036_crystallizes_column.go b/internal/storage/sqlite/migrations/036_crystallizes_column.go new file mode 100644 index 00000000..33652406 --- /dev/null +++ b/internal/storage/sqlite/migrations/036_crystallizes_column.go @@ -0,0 +1,36 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateCrystallizesColumn adds the crystallizes column to the issues table. +// Crystallizes tracks whether work compounds over time (true: code, features) +// or evaporates (false: ops, support). Per Decision 006, this affects CV weighting. +// Default is false (conservative - work evaporates unless explicitly marked). +func MigrateCrystallizesColumn(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 = 'crystallizes' + `).Scan(&columnExists) + if err != nil { + return fmt.Errorf("failed to check crystallizes column: %w", err) + } + + if columnExists { + // Column already exists (e.g. created by new schema) + return nil + } + + // Add the crystallizes column + _, err = db.Exec(`ALTER TABLE issues ADD COLUMN crystallizes INTEGER DEFAULT 0`) + if err != nil { + return fmt.Errorf("failed to add crystallizes column: %w", err) + } + + return nil +} diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 691f73a8..eed43d44 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -273,6 +273,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, var pinned sql.NullInt64 // Template field var isTemplate sql.NullInt64 + // Crystallizes field (work economics) + var crystallizes sql.NullInt64 // Gate fields var awaitType sql.NullString var awaitID sql.NullString @@ -305,7 +307,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, 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, + sender, ephemeral, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters, hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type, event_kind, actor, target, payload, @@ -319,7 +321,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &wisp, &pinned, &isTemplate, + &sender, &wisp, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, &hookBead, &roleBead, &agentState, &lastActivity, &roleType, &rig, &molType, &eventKind, &actor, &target, &payload, @@ -392,6 +394,10 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, if isTemplate.Valid && isTemplate.Int64 != 0 { issue.IsTemplate = true } + // Crystallizes field (work economics) + if crystallizes.Valid && crystallizes.Int64 != 0 { + issue.Crystallizes = true + } // Gate fields if awaitType.Valid { issue.AwaitType = awaitType.String @@ -558,6 +564,8 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s var pinned sql.NullInt64 // Template field var isTemplate sql.NullInt64 + // Crystallizes field (work economics) + var crystallizes sql.NullInt64 // Gate fields var awaitType sql.NullString var awaitID sql.NullString @@ -571,7 +579,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s 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, + sender, ephemeral, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters FROM issues WHERE external_ref = ? @@ -582,7 +590,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRefCol, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &wisp, &pinned, &isTemplate, + &sender, &wisp, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, ) @@ -652,6 +660,10 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s if isTemplate.Valid && isTemplate.Int64 != 0 { issue.IsTemplate = true } + // Crystallizes field (work economics) + if crystallizes.Valid && crystallizes.Int64 != 0 { + issue.Crystallizes = true + } // Gate fields if awaitType.Valid { issue.AwaitType = awaitType.String @@ -1938,7 +1950,7 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t status, priority, issue_type, assignee, estimated_minutes, 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, + sender, ephemeral, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters FROM issues %s diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index fdd24eef..a790b808 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -37,6 +37,8 @@ CREATE TABLE IF NOT EXISTS issues ( pinned INTEGER DEFAULT 0, -- Template field (beads-1ra) is_template INTEGER DEFAULT 0, + -- Work economics field (bd-fqze8) - HOP Decision 006 + crystallizes INTEGER DEFAULT 0, -- Molecule type field (bd-oxgi) mol_type TEXT DEFAULT '', -- Work type field (Decision 006: mutex vs open_competition) diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index c22478f7..44ed0690 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -330,7 +330,7 @@ func (t *sqliteTxStorage) GetIssue(ctx context.Context, id string) (*types.Issue 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, + sender, ephemeral, pinned, is_template, crystallizes, await_type, await_id, timeout_ns, waiters FROM issues WHERE id = ? @@ -1308,6 +1308,8 @@ func scanIssueRow(row scanner) (*types.Issue, error) { var pinned sql.NullInt64 // Template field var isTemplate sql.NullInt64 + // Crystallizes field (work economics) + var crystallizes sql.NullInt64 // Gate fields var awaitType sql.NullString var awaitID sql.NullString @@ -1321,7 +1323,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) { &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, - &sender, &wisp, &pinned, &isTemplate, + &sender, &wisp, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, ) if err != nil { @@ -1387,6 +1389,10 @@ func scanIssueRow(row scanner) (*types.Issue, error) { if isTemplate.Valid && isTemplate.Int64 != 0 { issue.IsTemplate = true } + // Crystallizes field (work economics) + if crystallizes.Valid && crystallizes.Int64 != 0 { + issue.Crystallizes = true + } // Gate fields if awaitType.Valid { issue.AwaitType = awaitType.String