docs: Add critical bug warning banner to README (bd-228)
CRITICAL: Auto-import silently overwrites local changes without collision detection. Changes: - Added prominent warning banner at top of README - Documents data corruption risk for multi-developer workflows - Provides --no-auto-import workaround - References bd-228 for tracking and updates This affects anyone using bd with multiple developers or agent swarms. Local updates/closes can be silently reverted by auto-import after git pull. Also includes: - ULTRATHINK_BD224.md analysis document (bd-224, bd-225) - Updated issues.jsonl with bd-224, bd-225, bd-226, bd-227, bd-228 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+215
-210
File diff suppressed because one or more lines are too long
@@ -2,6 +2,22 @@
|
||||
|
||||
**Give your coding agent a memory upgrade**
|
||||
|
||||
> **🚨 CRITICAL BUG - DATA CORRUPTION RISK (bd-228)**
|
||||
>
|
||||
> Auto-import silently overwrites local changes when pulling from Git, without collision detection or warning. This affects multi-developer workflows and agent swarms. **Your local work may be silently discarded.**
|
||||
>
|
||||
> **Impact**: Manual updates and closes can be reverted by auto-import after `git pull`.
|
||||
>
|
||||
> **Workaround**: Disable auto-import with `--no-auto-import` flag until fixed:
|
||||
> ```bash
|
||||
> bd --no-auto-import ready
|
||||
> bd --no-auto-import update bd-1 --status closed
|
||||
> ```
|
||||
>
|
||||
> **Status**: P0 bug being investigated. See issue bd-228 for details and progress.
|
||||
>
|
||||
> ---
|
||||
|
||||
> **⚠️ Alpha Status**: This project is in active development. The core features work well, but expect API changes before 1.0. Use for development/internal projects first.
|
||||
|
||||
Beads is a lightweight memory system for coding agents, using a graph-based issue tracker. Four kinds of dependencies work to chain your issues together like beads, making them easy for agents to follow for long distances, and reliably perform complex task streams in the right order.
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
# Ultrathink: Solving status/closed_at Inconsistency (bd-224)
|
||||
|
||||
**Date**: 2025-10-15
|
||||
**Context**: Individual devs, small teams, future agent swarms, new codebase
|
||||
**Problem**: Data model allows `status='open'` with `closed_at != NULL` (liminal state)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Recommended Solution**: **Hybrid approach - Database CHECK constraint + Application enforcement**
|
||||
|
||||
This provides defense-in-depth perfect for agent swarms while keeping the model simple for individual developers.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Where closed_at is Used
|
||||
|
||||
1. **GetIssue**: Returns it to callers
|
||||
2. **CloseIssue**: Sets `closed_at = now()` when closing
|
||||
3. **SearchIssues**: Includes in results
|
||||
4. **GetStatistics** ⚠️ **CRITICAL**:
|
||||
```sql
|
||||
SELECT AVG((julianday(closed_at) - julianday(created_at)) * 24)
|
||||
FROM issues
|
||||
WHERE closed_at IS NOT NULL
|
||||
```
|
||||
Uses `closed_at IS NOT NULL` NOT `status='closed'` for lead time calculation!
|
||||
|
||||
### Impact of Inconsistency
|
||||
|
||||
- **Statistics are wrong**: Issues with `status='open'` but `closed_at != NULL` pollute lead time metrics
|
||||
- **User confusion**: bd ready shows "closed" issues
|
||||
- **Agent workflows**: Unpredictable behavior when agents query by status vs closed_at
|
||||
- **Data integrity**: Can't trust the data model
|
||||
|
||||
### Root Cause: Import & Update Don't Manage Invariant
|
||||
|
||||
**Import** (cmd/bd/import.go:206-207):
|
||||
```go
|
||||
if _, ok := rawData["status"]; ok {
|
||||
updates["status"] = issue.Status // ← Updates status
|
||||
}
|
||||
// ⚠️ Does NOT clear/set closed_at
|
||||
```
|
||||
|
||||
**UpdateIssue** (internal/storage/sqlite/sqlite.go:509-624):
|
||||
- Updates any field in the map
|
||||
- Does NOT automatically manage closed_at when status changes
|
||||
- Records EventClosed but doesn't enforce the invariant
|
||||
|
||||
**Concrete example (bd-89)**:
|
||||
1. Issue closed properly: `status='closed'`, `closed_at='2025-10-15 08:13:08'`
|
||||
2. JSONL had old state: `status='open'`, `closed_at='2025-10-14 02:58:22'` (inconsistent!)
|
||||
3. Auto-import updated status to 'open' but left closed_at set
|
||||
4. Result: Inconsistent state in database
|
||||
|
||||
---
|
||||
|
||||
## Solution Options
|
||||
|
||||
### Option A: Database CHECK Constraint ⭐ **RECOMMENDED FOUNDATION**
|
||||
|
||||
```sql
|
||||
ALTER TABLE issues ADD CONSTRAINT chk_closed_at_status
|
||||
CHECK ((status = 'closed') = (closed_at IS NOT NULL));
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- ✅ Enforces invariant at database level (most robust)
|
||||
- ✅ Catches bugs in ANY code path (future-proof)
|
||||
- ✅ Works across all clients (CLI, MCP, future integrations)
|
||||
- ✅ Simple to understand for developers
|
||||
- ✅ **Perfect for agent swarms**: Can't break it with buggy code
|
||||
- ✅ Prevents inconsistent exports (can't write bad data to JSONL)
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ Requires migration
|
||||
- ⚠️ Need to fix existing inconsistent data first
|
||||
- ⚠️ Update operations must manage closed_at (but we should do this anyway!)
|
||||
|
||||
**Migration complexity**: LOW - few users, can break things
|
||||
|
||||
### Option B: Application-Level Enforcement Only
|
||||
|
||||
Make UpdateIssue and Import smart about status changes.
|
||||
|
||||
**Pros:**
|
||||
- ✅ No schema change needed
|
||||
- ✅ Flexible for edge cases
|
||||
|
||||
**Cons:**
|
||||
- ❌ Easy to forget in new code paths
|
||||
- ❌ Doesn't protect against direct SQL manipulation
|
||||
- ❌ Multiple places to maintain (import, update, close, etc.)
|
||||
- ❌ **Bad for agent swarms**: One buggy agent breaks the model
|
||||
- ❌ Still allows export of inconsistent data
|
||||
|
||||
**Verdict**: Not robust enough alone
|
||||
|
||||
### Option C: Add Explicit Reopened Support
|
||||
|
||||
Add `bd reopen` command that uses EventReopened and manages closed_at.
|
||||
|
||||
**Pros:**
|
||||
- ✅ Makes reopening explicit and trackable
|
||||
- ✅ EventReopened already defined (types.go:150) but unused
|
||||
|
||||
**Cons:**
|
||||
- ⚠️ Doesn't solve the fundamental invariant problem
|
||||
- ⚠️ Still need to decide: clear closed_at or keep it?
|
||||
- ⚠️ More complex model if we keep historical closed_at
|
||||
|
||||
**Verdict**: Good addition, but doesn't solve root cause
|
||||
|
||||
### Option D: Remove closed_at Entirely
|
||||
|
||||
Make events table the single source of truth.
|
||||
|
||||
**Pros:**
|
||||
- ✅ Simplest data model
|
||||
- ✅ No invariant to maintain
|
||||
- ✅ Events are authoritative
|
||||
|
||||
**Cons:**
|
||||
- ❌ **Performance**: Lead time calculation requires JOIN + subquery
|
||||
```sql
|
||||
SELECT AVG(
|
||||
(julianday(e.created_at) - julianday(i.created_at)) * 24
|
||||
)
|
||||
FROM issues i
|
||||
JOIN events e ON i.id = e.issue_id
|
||||
WHERE e.event_type = 'closed'
|
||||
```
|
||||
- ❌ Events could be missing/corrupted (no referential integrity on event_type)
|
||||
- ❌ More complex queries throughout codebase
|
||||
- ❌ **Statistics would be slower** (critical for dashboard UIs)
|
||||
|
||||
**Verdict**: Too much complexity/performance cost for the benefit
|
||||
|
||||
---
|
||||
|
||||
## Recommended Solution: **Hybrid Approach**
|
||||
|
||||
Combine **Option A (DB constraint)** + **Application enforcement** + **Option C (reopen command)**
|
||||
|
||||
### Part 1: Database Constraint (Foundation)
|
||||
|
||||
```sql
|
||||
-- Migration: First clean up existing inconsistent data
|
||||
UPDATE issues
|
||||
SET closed_at = NULL
|
||||
WHERE status != 'closed' AND closed_at IS NOT NULL;
|
||||
|
||||
UPDATE issues
|
||||
SET closed_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'closed' AND closed_at IS NULL;
|
||||
|
||||
-- Add the constraint
|
||||
ALTER TABLE issues ADD CONSTRAINT chk_closed_at_status
|
||||
CHECK ((status = 'closed') = (closed_at IS NOT NULL));
|
||||
```
|
||||
|
||||
### Part 2: UpdateIssue Smart Status Management
|
||||
|
||||
Modify `internal/storage/sqlite/sqlite.go:509-624`:
|
||||
|
||||
```go
|
||||
func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[string]interface{}, actor string) error {
|
||||
// ... existing validation ...
|
||||
|
||||
// Smart closed_at management based on status changes
|
||||
if statusVal, ok := updates["status"]; ok {
|
||||
newStatus := statusVal.(string)
|
||||
|
||||
if newStatus == string(types.StatusClosed) {
|
||||
// Changing to closed: ensure closed_at is set
|
||||
if _, hasClosedAt := updates["closed_at"]; !hasClosedAt {
|
||||
updates["closed_at"] = time.Now()
|
||||
}
|
||||
} else {
|
||||
// Changing from closed to something else: clear closed_at
|
||||
if oldIssue.Status == types.StatusClosed {
|
||||
updates["closed_at"] = nil // This will set it to NULL
|
||||
eventType = types.EventReopened
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
### Part 3: Import Enforcement
|
||||
|
||||
Modify `cmd/bd/import.go:206-231`:
|
||||
|
||||
```go
|
||||
if _, ok := rawData["status"]; ok {
|
||||
updates["status"] = issue.Status
|
||||
|
||||
// Enforce closed_at invariant
|
||||
if issue.Status == types.StatusClosed {
|
||||
// Status is closed: ensure closed_at is set
|
||||
if issue.ClosedAt == nil {
|
||||
now := time.Now()
|
||||
updates["closed_at"] = now
|
||||
} else {
|
||||
updates["closed_at"] = *issue.ClosedAt
|
||||
}
|
||||
} else {
|
||||
// Status is not closed: ensure closed_at is NULL
|
||||
updates["closed_at"] = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Part 4: Add Reopen Command
|
||||
|
||||
Create `cmd/bd/reopen.go`:
|
||||
|
||||
```go
|
||||
var reopenCmd = &cobra.Command{
|
||||
Use: "reopen [id...]",
|
||||
Short: "Reopen one or more closed issues",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := context.Background()
|
||||
reason, _ := cmd.Flags().GetString("reason")
|
||||
if reason == "" {
|
||||
reason = "Reopened"
|
||||
}
|
||||
|
||||
for _, id := range args {
|
||||
// Use UpdateIssue which now handles closed_at automatically
|
||||
updates := map[string]interface{}{
|
||||
"status": "open",
|
||||
}
|
||||
if err := store.UpdateIssue(ctx, id, updates, getUser()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reopening %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Add comment explaining why
|
||||
if reason != "" {
|
||||
store.AddComment(ctx, id, getUser(), reason)
|
||||
}
|
||||
}
|
||||
|
||||
markDirtyAndScheduleFlush()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
reopenCmd.Flags().StringP("reason", "r", "", "Reason for reopening")
|
||||
rootCmd.AddCommand(reopenCmd)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Why This Solution Wins
|
||||
|
||||
### For Individual Devs & Small Teams
|
||||
- **Simple mental model**: `closed_at` is set ⟺ issue is closed
|
||||
- **Hard to break**: DB constraint catches mistakes
|
||||
- **Explicit reopen**: `bd reopen bd-89` is clearer than `bd update bd-89 --status open`
|
||||
- **No manual management**: Don't think about closed_at, it's automatic
|
||||
|
||||
### For Agent Swarms
|
||||
- **Robust**: DB constraint prevents any agent from creating inconsistent state
|
||||
- **Race-safe**: Constraint is atomic, checked at commit time
|
||||
- **Self-healing**: UpdateIssue automatically fixes the relationship
|
||||
- **Import-safe**: Can't import inconsistent JSONL (constraint rejects it)
|
||||
|
||||
### For New Codebase
|
||||
- **Can break things**: Migration is easy with few users
|
||||
- **Sets precedent**: Shows we value data integrity
|
||||
- **Future-proof**: New features can't violate the invariant
|
||||
- **Performance**: No query changes needed, closed_at stays fast
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Step 1: Clean Existing Data
|
||||
```sql
|
||||
-- Find inconsistent issues
|
||||
SELECT id, status, closed_at FROM issues
|
||||
WHERE (status = 'closed') != (closed_at IS NOT NULL);
|
||||
|
||||
-- Fix them (choose one strategy)
|
||||
-- Strategy A: Trust status field
|
||||
UPDATE issues SET closed_at = NULL
|
||||
WHERE status != 'closed' AND closed_at IS NOT NULL;
|
||||
|
||||
UPDATE issues SET closed_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'closed' AND closed_at IS NULL;
|
||||
|
||||
-- Strategy B: Trust closed_at field
|
||||
UPDATE issues SET status = 'closed'
|
||||
WHERE status != 'closed' AND closed_at IS NOT NULL;
|
||||
|
||||
UPDATE issues SET status = 'open'
|
||||
WHERE status = 'closed' AND closed_at IS NULL;
|
||||
```
|
||||
|
||||
**Recommendation**: Use Strategy A (trust status) since status is more often correct.
|
||||
|
||||
### Step 2: Add Constraint
|
||||
```sql
|
||||
-- Test first
|
||||
SELECT id FROM issues
|
||||
WHERE (status = 'closed') != (closed_at IS NOT NULL);
|
||||
-- Should return 0 rows
|
||||
|
||||
-- Add constraint
|
||||
ALTER TABLE issues ADD CONSTRAINT chk_closed_at_status
|
||||
CHECK ((status = 'closed') = (closed_at IS NOT NULL));
|
||||
```
|
||||
|
||||
### Step 3: Update Code
|
||||
1. Modify UpdateIssue (sqlite.go)
|
||||
2. Modify Import (import.go)
|
||||
3. Add reopen command
|
||||
4. Add migration function to schema.go
|
||||
|
||||
### Step 4: Test
|
||||
```bash
|
||||
# Test the constraint rejects bad writes
|
||||
sqlite3 .beads/bd.db "UPDATE issues SET closed_at = NULL WHERE id = 'bd-1' AND status = 'closed';"
|
||||
# Should fail with constraint violation
|
||||
|
||||
# Test update handles it automatically
|
||||
bd update bd-89 --status open
|
||||
bd show bd-89 --json | jq '{status, closed_at}'
|
||||
# Should show: {"status": "open", "closed_at": null}
|
||||
|
||||
# Test reopen
|
||||
bd create "Test issue" -p 1
|
||||
bd close bd-226
|
||||
bd reopen bd-226 --reason "Need more work"
|
||||
# Should work without errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternative Considered: Soft Close with closed_at History
|
||||
|
||||
Keep closed_at as "first time closed" and use events for current state.
|
||||
|
||||
**Why rejected**:
|
||||
- More complex model (two sources of truth)
|
||||
- Unclear semantics (what does closed_at mean?)
|
||||
- Lead time calculation becomes ambiguous (first close? most recent?)
|
||||
- Doesn't simplify the problem
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The **hybrid approach** (DB constraint + smart UpdateIssue + import enforcement + reopen command) is the best solution because:
|
||||
|
||||
1. **Defense in depth**: Multiple layers prevent inconsistency
|
||||
2. **Hard to break**: Perfect for agent swarms
|
||||
3. **Simple for users**: Automatic management of closed_at
|
||||
4. **Low migration cost**: Can break things in new codebase
|
||||
5. **Clear semantics**: closed_at = "currently closed" (not historical)
|
||||
6. **Performance**: No query changes needed
|
||||
|
||||
The DB constraint is the key insight: it makes the system robust against future bugs, new code paths, and agent mistakes. Combined with smart application code, it creates a self-healing system that's hard to misuse.
|
||||
|
||||
This aligns perfectly with beads' goals: simple for individual devs, robust for agent swarms.
|
||||
Reference in New Issue
Block a user