feat: add type: event for operational state changes (bd-ecmd)

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 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
Steve Yegge
2025-12-30 15:53:50 -08:00
parent 407e75b363
commit 6d292f6a0f
7 changed files with 106 additions and 7 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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