From 6d292f6a0f48bcd6cd5c6d13ded7ee825c881995 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 15:53:50 -0800 Subject: [PATCH] feat: add type: event for operational state changes (bd-ecmd) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for event beads that capture operational state transitions as immutable records. Events are a new issue type with fields: - event_kind: namespaced category (patrol.muted, agent.started) - actor: entity URI who caused the event - target: entity URI or bead ID affected - payload: event-specific JSON data This enables: - bd activity --follow showing events - bd list --type=event --target=agent:deacon - Full audit trail for operational state - HOP-compatible transaction records 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Executed-By: beads/crew/dave Rig: beads Role: crew --- internal/storage/sqlite/issues.go | 12 +++-- internal/storage/sqlite/migrations.go | 3 ++ .../sqlite/migrations/033_event_fields.go | 50 +++++++++++++++++++ internal/storage/sqlite/migrations_test.go | 6 ++- internal/storage/sqlite/queries.go | 22 +++++++- internal/storage/sqlite/schema.go | 5 ++ internal/types/types.go | 15 +++++- 7 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 internal/storage/sqlite/migrations/033_event_fields.go diff --git a/internal/storage/sqlite/issues.go b/internal/storage/sqlite/issues.go index 29e88db2..db22daee 100644 --- a/internal/storage/sqlite/issues.go +++ b/internal/storage/sqlite/issues.go @@ -47,8 +47,9 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error created_at, created_by, 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + await_type, await_id, timeout_ns, waiters, mol_type, + event_kind, actor, target, payload + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, issue.AcceptanceCriteria, issue.Notes, issue.Status, @@ -59,6 +60,7 @@ func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error issue.Sender, wisp, pinned, isTemplate, issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters), string(issue.MolType), + issue.EventKind, issue.Actor, issue.Target, issue.Payload, ) if err != nil { // INSERT OR IGNORE should handle duplicates, but driver may still return error @@ -80,8 +82,9 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er created_at, created_by, 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + await_type, await_id, timeout_ns, waiters, mol_type, + event_kind, actor, target, payload + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) @@ -117,6 +120,7 @@ func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) er issue.Sender, wisp, pinned, isTemplate, issue.AwaitType, issue.AwaitID, int64(issue.Timeout), formatJSONStringArray(issue.Waiters), string(issue.MolType), + issue.EventKind, issue.Actor, issue.Target, issue.Payload, ) if err != nil { // INSERT OR IGNORE should handle duplicates, but driver may still return error diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index bf2e13d6..0d3c217c 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -49,6 +49,7 @@ var migrationsList = []Migration{ {"agent_fields", migrations.MigrateAgentFields}, {"mol_type_column", migrations.MigrateMolTypeColumn}, {"hooked_status_migration", migrations.MigrateHookedStatus}, + {"event_fields", migrations.MigrateEventFields}, } // MigrationInfo contains metadata about a migration for inspection @@ -104,6 +105,8 @@ func getMigrationDescription(name string) string { "created_by_column": "Adds created_by column to track issue creator", "agent_fields": "Adds agent identity fields (hook_bead, role_bead, agent_state, etc.) for agent-as-bead pattern", "mol_type_column": "Adds mol_type column for molecule type classification (swarm/patrol/work)", + "hooked_status_migration": "Migrates blocked hooked issues to in_progress status", + "event_fields": "Adds event fields (event_kind, actor, target, payload) for operational state change beads", } if desc, ok := descriptions[name]; ok { diff --git a/internal/storage/sqlite/migrations/033_event_fields.go b/internal/storage/sqlite/migrations/033_event_fields.go new file mode 100644 index 00000000..662f46d9 --- /dev/null +++ b/internal/storage/sqlite/migrations/033_event_fields.go @@ -0,0 +1,50 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +// MigrateEventFields adds event-specific fields to the issues table. +// These fields support type: event beads for operational state changes. +// Fields: +// - event_kind: namespaced event type (e.g., patrol.muted, agent.started) +// - actor: entity URI who caused this event +// - target: entity URI or bead ID affected +// - payload: event-specific JSON data +func MigrateEventFields(db *sql.DB) error { + columns := []struct { + name string + def string + }{ + {"event_kind", "TEXT DEFAULT ''"}, + {"actor", "TEXT DEFAULT ''"}, + {"target", "TEXT DEFAULT ''"}, + {"payload", "TEXT DEFAULT ''"}, + } + + for _, col := range columns { + // Check if column already exists + var columnExists bool + err := db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('issues') + WHERE name = ? + `, col.name).Scan(&columnExists) + if err != nil { + return fmt.Errorf("failed to check %s column: %w", col.name, err) + } + + if columnExists { + continue + } + + // Add the column + _, err = db.Exec(fmt.Sprintf(`ALTER TABLE issues ADD COLUMN %s %s`, col.name, col.def)) + if err != nil { + return fmt.Errorf("failed to add %s column: %w", col.name, err) + } + } + + return nil +} diff --git a/internal/storage/sqlite/migrations_test.go b/internal/storage/sqlite/migrations_test.go index 38dd82e5..a4dee837 100644 --- a/internal/storage/sqlite/migrations_test.go +++ b/internal/storage/sqlite/migrations_test.go @@ -503,9 +503,13 @@ func TestMigrateContentHashColumn(t *testing.T) { role_type TEXT DEFAULT '', rig TEXT DEFAULT '', mol_type TEXT DEFAULT '', + event_kind TEXT DEFAULT '', + actor TEXT DEFAULT '', + target TEXT DEFAULT '', + payload TEXT DEFAULT '', CHECK ((status = 'closed') = (closed_at IS NOT NULL)) ); - INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, '', updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '', '', '', '', NULL, '', '', '' FROM issues_backup; + INSERT INTO issues SELECT id, title, description, design, acceptance_criteria, notes, status, priority, issue_type, assignee, estimated_minutes, created_at, '', updated_at, closed_at, external_ref, compaction_level, compacted_at, original_size, compacted_at_commit, source_repo, '', NULL, '', '', '', '', 0, 0, 0, '', '', '', '', '', '', 0, '', '', '', '', NULL, '', '', '', '', '', '', '' FROM issues_backup; DROP TABLE issues_backup; `) if err != nil { diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 8e53382b..02bdc0ab 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -281,6 +281,11 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, var rig sql.NullString // Molecule type field var molType sql.NullString + // Event fields + var eventKind sql.NullString + var actor sql.NullString + var target sql.NullString + var payload sql.NullString var contentHash sql.NullString var compactedAtCommit sql.NullString @@ -292,7 +297,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, deleted_at, deleted_by, delete_reason, original_type, sender, ephemeral, pinned, is_template, await_type, await_id, timeout_ns, waiters, - hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type + hook_bead, role_bead, agent_state, last_activity, role_type, rig, mol_type, + event_kind, actor, target, payload FROM issues WHERE id = ? `, id).Scan( @@ -305,6 +311,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, &sender, &wisp, &pinned, &isTemplate, &awaitType, &awaitID, &timeoutNs, &waiters, &hookBead, &roleBead, &agentState, &lastActivity, &roleType, &rig, &molType, + &eventKind, &actor, &target, &payload, ) if err == sql.ErrNoRows { @@ -406,6 +413,19 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, if molType.Valid { issue.MolType = types.MolType(molType.String) } + // Event fields + if eventKind.Valid { + issue.EventKind = eventKind.String + } + if actor.Valid { + issue.Actor = actor.String + } + if target.Valid { + issue.Target = target.String + } + if payload.Valid { + issue.Payload = payload.String + } // Fetch labels for this issue labels, err := s.GetLabels(ctx, issue.ID) diff --git a/internal/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go index 111194f9..49bd3acb 100644 --- a/internal/storage/sqlite/schema.go +++ b/internal/storage/sqlite/schema.go @@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS issues ( is_template INTEGER DEFAULT 0, -- Molecule type field (bd-oxgi) mol_type TEXT DEFAULT '', + -- Event fields (bd-ecmd) + event_kind TEXT DEFAULT '', + actor TEXT DEFAULT '', + target TEXT DEFAULT '', + payload TEXT DEFAULT '', -- NOTE: replies_to, relates_to, duplicate_of, superseded_by removed per Decision 004 -- These relationships are now stored in the dependencies table -- closed_at constraint: closed issues must have it, tombstones may retain it from before deletion diff --git a/internal/types/types.go b/internal/types/types.go index 09cd2143..ed460871 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -100,6 +100,12 @@ type Issue struct { // ===== Molecule Type Fields (swarm coordination) ===== MolType MolType `json:"mol_type,omitempty"` // Molecule type: swarm|patrol|work (empty = work) + + // ===== Event Fields (operational state changes) ===== + EventKind string `json:"event_kind,omitempty"` // Namespaced event type: patrol.muted, agent.started + Actor string `json:"actor,omitempty"` // Entity URI who caused this event + Target string `json:"target,omitempty"` // Entity URI or bead ID affected + Payload string `json:"payload,omitempty"` // Event-specific JSON data } // ComputeContentHash creates a deterministic hash of the issue's content. @@ -162,6 +168,12 @@ func (i *Issue) ComputeContentHash() string { // Molecule type w.str(string(i.MolType)) + // Event fields + w.str(i.EventKind) + w.str(i.Actor) + w.str(i.Target) + w.str(i.Payload) + return fmt.Sprintf("%x", h.Sum(nil)) } @@ -395,12 +407,13 @@ const ( TypeAgent IssueType = "agent" // Agent identity bead TypeRole IssueType = "role" // Agent role definition TypeConvoy IssueType = "convoy" // Cross-project tracking with reactive completion + TypeEvent IssueType = "event" // Operational state change record ) // IsValid checks if the issue type value is valid func (t IssueType) IsValid() bool { switch t { - case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole, TypeConvoy: + case TypeBug, TypeFeature, TypeTask, TypeEpic, TypeChore, TypeMessage, TypeMergeRequest, TypeMolecule, TypeGate, TypeAgent, TypeRole, TypeConvoy, TypeEvent: return true } return false