Skip to content

feat: atomic bean updates with body modifications#72

Merged
hmans merged 3 commits intohmans:mainfrom
matleh:feature/combine-body-modifications
Jan 30, 2026
Merged

feat: atomic bean updates with body modifications#72
hmans merged 3 commits intohmans:mainfrom
matleh:feature/combine-body-modifications

Conversation

@matleh
Copy link
Copy Markdown
Contributor

@matleh matleh commented Jan 22, 2026

Motivation

A previous PR introduced replaceInBody and appendToBody mutations for modifying bean body content. While these worked well for single operations, we quickly realized limitations:

  1. No way to combine multiple operations atomically - Applying multiple text replacements required separate mutation calls with etag chaining
  2. Couldn't combine body modifications with metadata updates - Changing status and updating body content required two mutations
  3. Race condition in etag validation - Etag validation happened before acquiring the write lock, allowing concurrent updates to both pass validation

This PR consolidates body modification capabilities into a single atomic operation and fixes the concurrency issue.

Changes

GraphQL API Improvements

Consolidated body modifications into updateBean:

  • Added BodyModification input type supporting multiple replace operations and append
  • Added ReplaceOperation input type with old and new fields
  • Added bodyMod field to UpdateBeanInput for structured body modifications
  • Removed replaceInBody and appendToBody mutations (redundant, breaking change)

New capabilities:

  • Apply multiple text replacements in a single transaction
  • Combine body modifications with metadata updates (status, priority, etc.)
  • All operations atomic with single etag validation
  • Sequential replacement execution (each operates on result of previous)
  • Transactional semantics (any failure = no changes saved)

Race Condition Fix

Moved etag validation under write lock:

  • Added ETagMismatchError and ETagRequiredError to beancore package
  • Updated Core.Update() signature to accept ifMatch parameter
  • Moved etag validation from GraphQL resolver into Core.Update() under write lock
  • Fixed Go pointer aliasing issue by validating etag against on-disk content
  • Updated all mutation call sites to pass ifMatch through to Core

The race condition occurred when two threads validated etags before either acquired the write lock, allowing both to pass validation. The second update would silently overwrite the first. Now validation happens atomically with the update operation.

CLI Updates

  • Updated flag mutual exclusivity to allow --body-replace-old/new and --body-append together
  • Fixed applyBodyMod() to properly check for bodyMod field
  • Maintained exclusivity with --body and --body-file (full replacement)
  • Multiple replacements available via beans query command

Documentation

  • Updated cmd/prompt.tmpl with concise body modification examples
  • Documented GraphQL bodyMod usage for multiple replacements
  • Explained execution order and transactional behavior

Examples

CLI - Combined operations:

beans update <id> \
  --status completed \
  --body-replace-old "- [ ] Deploy" \
  --body-replace-new "- [x] Deploy" \
  --body-append "## Summary\n\nDeployment completed"

GraphQL - Multiple replacements:

mutation {
  updateBean(id: "bean-xyz", input: {
    status: "completed"
    bodyMod: {
      replace: [
        { old: "- [ ] Write tests", new: "- [x] Write tests" }
        { old: "- [ ] Deploy", new: "- [x] Deploy" }
        { old: "Status: pending", new: "Status: done" }
      ]
      append: "## Summary\n\nAll tasks completed!"
    }
    ifMatch: "abc123"
  }) {
    id
    body
    etag
  }
}

Execution Order

  1. All replace operations applied sequentially (each operates on result of previous)
  2. append operation applied to final result
  3. Single etag validation for entire operation
  4. If any operation fails, entire mutation fails (transactional)

Breaking Changes

⚠️ GraphQL API:

  • replaceInBody mutation removed (use updateBean with bodyMod.replace)
  • appendToBody mutation removed (use updateBean with bodyMod.append)

Migration:

# Before - separate mutations
- replaceInBody(id: "bean-xyz", old: "foo", new: "bar", ifMatch: "abc")
- appendToBody(id: "bean-xyz", content: "## Notes\n\nDone")

# After - single atomic mutation
+ updateBean(id: "bean-xyz", input: {
+   bodyMod: {
+     replace: [{ old: "foo", new: "bar" }]
+     append: "## Notes\n\nDone"
+   }
+   ifMatch: "abc"
+ })

Testing

Manual testing performed:

  • ✅ 10 CLI test scenarios (single/combined operations, error handling)
  • ✅ 20 GraphQL test scenarios (mutations, queries, relationships, edge cases)
  • ✅ Transactional rollback verification
  • ✅ Etag validation and concurrency control
  • ✅ File persistence verification

Unit tests added:

  • Comprehensive UpdateBean with bodyMod test coverage (10 cases)
  • Etag validation tests for all GraphQL mutations
  • Concurrency and race condition tests in beancore

All tests passing ✅

Copy link
Copy Markdown
Owner

@hmans hmans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonderful PR, thank you very much! Also, apologies for taking a bit to respond -- just working on too much stuff in parallel. Aaaaaah!

@hmans
Copy link
Copy Markdown
Owner

hmans commented Jan 29, 2026

@matleh May I ask you to resolve the merge conflicts with main? You'll have a better idea of what to look out for. Other than that, ready to merge!

Agents can now atomically update status, metadata, and body content together
with a single etag validation, eliminating race conditions.

- Add BodyModification input type supporting multiple replacements and append
- Add bodyMod field to UpdateBeanInput for structured body modifications
- Remove separate replaceInBody/appendToBody mutations (consolidated)
- Update CLI to allow combining --body-replace-old and --body-append
- Implement transactional all-or-nothing semantics
- Add comprehensive test coverage
Move etag validation inside Core.Update() under write lock to prevent
lost updates in concurrent scenarios.

- Add ETagMismatchError and ETagRequiredError to beancore package
- Update Core.Update() signature to accept ifMatch parameter
- Validate etag against on-disk content to handle Go pointer aliasing
- Update all Core.Update() call sites to pass ifMatch
- Remove validateETag from graph resolver (now in Core)
- Add comprehensive tests for etag validation scenarios
- Add setupTestCoreWithRequireIfMatch helper for testing

The race condition occurred when two threads validated etags before either
acquired the write lock, allowing both to pass validation and the second
update to overwrite the first. Now validation happens atomically with the
update under the write lock, preventing lost updates.
@matleh matleh force-pushed the feature/combine-body-modifications branch from d1e9b77 to a186d7e Compare January 29, 2026 22:02
@matleh
Copy link
Copy Markdown
Contributor Author

matleh commented Jan 29, 2026

Rebase Completed - Conflicts Resolved

I've resolved the merge conflicts and rebased this PR on main.


Upstream Changes Incorporated

From PR #67: Added addBlockedBy and removeBlockedBy mutations with bidirectional cycle detection

From PR #60: Added replaceInBody and appendToBody standalone mutations


Conflict Resolution Decisions

1. Removed replaceInBody and appendToBody mutations

These are superseded by the bodyMod field in updateBean (the core feature of this PR). Keeping them would create API fragmentation - multiple ways to do the same thing.

The bodyMod approach is better because:

  • ✅ Atomic - all body modifications happen in one mutation with one ETag
  • ✅ No ETag chaining needed between separate mutations
  • ✅ Consistent with this PR's goal of consolidating operations

2. Applied ETag race condition fix to new upstream mutations

The upstream addBlockedBy and removeBlockedBy mutations didn't include the ETag race condition fix from commit f87e74d. I applied the fix to maintain consistency:

  • Removed manual ETag validation calls
  • Let Core.Update() handle validation under write lock (prevents race conditions)

Testing

✅ All tests passing
✅ Build succeeds
✅ No functionality lost - bodyMod provides same capabilities as removed mutations


Let me know if you'd like me to adjust anything about the conflict resolution approach!

@matleh
Copy link
Copy Markdown
Contributor Author

matleh commented Jan 29, 2026

also see follow-up PR #81

@hmans
Copy link
Copy Markdown
Owner

hmans commented Jan 30, 2026

There seems to be a driveby addition of a session Markdown file, may I ask you to remove it?

Add comprehensive etag validation tests for:
- updateBean
- setParent
- addBlocking
- removeBlocking

Each mutation now has tests verifying:
- Success with correct etag
- Failure with wrong etag returning ETagMismatchError
@matleh matleh force-pushed the feature/combine-body-modifications branch from a186d7e to 2b39d4b Compare January 30, 2026 11:18
@matleh
Copy link
Copy Markdown
Contributor Author

matleh commented Jan 30, 2026

There seems to be a driveby addition of a session Markdown file, may I ask you to remove it?

I am sorry - removed.

@hmans hmans merged commit 0845fde into hmans:main Jan 30, 2026
1 check passed
@hmans
Copy link
Copy Markdown
Owner

hmans commented Jan 30, 2026

Merged, thanks for the PR! 🙏

@matleh matleh deleted the feature/combine-body-modifications branch February 3, 2026 21:11
hmans pushed a commit that referenced this pull request Feb 7, 2026
### Motivation

**Depends on:** #72

PR #72 added atomic body modifications via the `bodyMod` field in
`updateBean`. This PR extends the same pattern to relationship fields
(parent, blocking, blockedBy).

Currently, updating relationships requires separate mutations with
manual ETag extraction:

1. **No way to combine relationship updates atomically** - Setting
parent and adding blocking relationships requires separate mutations
with ETag chaining
2. **Cannot combine relationships with other updates** - Changing
status, updating body, and setting parent requires multiple separate
mutations
3. **ETag chaining complexity** - Client must extract ETag from each
response and pass to next mutation
4. **Race conditions with \`--if-match\`** - Sequential mutations can
have intermediate states visible to other clients
5. **Harder for AI agents** - Must orchestrate multiple mutations and
handle ETag extraction, increasing cognitive load and error potential

This PR consolidates relationship management into \`updateBean\` for
truly atomic operations.

### Changes

#### GraphQL API Improvements

**Added relationship fields to \`UpdateBeanInput\`:**
- \`parent: String\` - Set parent bean (validates type hierarchy and
cycles)
- \`addBlocking: [String!]\` - Add beans that this bean blocks
- \`removeBlocking: [String!]\` - Remove beans from blocking list
- \`addBlockedBy: [String!]\` - Add beans that block this bean
- \`removeBlockedBy: [String!]\` - Remove beans from blocked-by list

**New capabilities:**
- Update parent, blocking, and blocked-by relationships atomically
- Combine relationship updates with metadata changes (status, priority,
tags, etc.)
- Combine relationship updates with body modifications (via \`bodyMod\`
from #72)
- All operations atomic with single ETag validation
- All existing validations preserved (type hierarchy, cycle detection,
existence checks)

#### Implementation Details

**Extracted validation helpers to \`resolver.go\`:**
- \`validateAndSetParent()\` - Type hierarchy validation and parent
cycle detection
- \`validateAndAddBlocking()\` - Self-reference check, existence check,
bidirectional cycle detection
- \`validateAndAddBlockedBy()\` - Same validations for blocked-by
relationships
- \`removeBlockingRelationships()\` - Safe removal without validation
- \`removeBlockedByRelationships()\` - Safe removal without validation

**Updated \`updateBean\` resolver:**
- Handles all relationship fields before calling \`Core.Update()\`
- All validations run before any changes are saved (transactional)
- Single ETag check at the end under write lock (atomic)

**Updated CLI (\`cmd/update.go\`):**
- Modified \`buildUpdateInput()\` to populate relationship fields in
\`UpdateBeanInput\`
- Updated \`hasFieldUpdates()\` to check for relationship fields
- Removed separate mutation calls (\`SetParent\`, \`AddBlocking\`,
\`RemoveBlocking\`, \`AddBlockedBy\`, \`RemoveBlockedBy\`)
- All updates now happen atomically via single \`UpdateBean\` mutation
- Fixes ETag chaining bugs when using \`--if-match\` with relationship
flags

#### Backward Compatibility

Standalone relationship mutations remain available:
- \`setParent\` - Still works as before
- \`addBlocking\` / \`removeBlocking\` - Still works as before
- \`addBlockedBy\` / \`removeBlockedBy\` - Still works as before

No breaking changes. The new fields in \`updateBean\` are purely
additive.

### Examples

**CLI - Combined operations:**
```bash
beans update task-123 \\
  --status completed \\
  --parent epic-456 \\
  --blocking task-789 \\
  --body-replace-old "- [ ] Deploy" \\
  --body-replace-new "- [x] Deploy" \\
  --body-append "## Summary\\n\\nAll done!" \\
  --if-match "abc123"
```

**GraphQL - Atomic update:**
```graphql
mutation {
  updateBean(
    id: "task-123"
    input: {
      status: "completed"
      parent: "epic-456"
      addBlocking: ["task-789", "task-790"]
      bodyMod: {
        replace: [
          { old: "- [ ] Write tests", new: "- [x] Write tests" }
          { old: "- [ ] Deploy", new: "- [x] Deploy" }
        ]
        append: "## Summary\\n\\nDeployment completed!"
      }
      ifMatch: "abc123"
    }
  ) {
    id
    status
    parentId
    blocking
    body
    etag
  }
}
```

**Before (multiple mutations with ETag chaining):**
```graphql
# ❌ Complex, error-prone, not atomic
mutation {
  m1: updateBean(id: "task-123", input: { status: "done", ifMatch: "abc" }) {
    etag  # Returns "def"
  }
}
# Extract etag from m1 response, make new request...
mutation {
  m2: setParent(id: "task-123", parentId: "epic-456", ifMatch: "def") {
    etag  # Returns "ghi"
  }
}
# Extract etag from m2 response, make new request...
mutation {
  m3: addBlocking(id: "task-123", targetId: "task-789", ifMatch: "ghi") {
    id
  }
}
```

**After (single atomic mutation):**
```graphql
# ✅ Simple, atomic, single ETag
mutation {
  updateBean(id: "task-123", input: {
    status: "done"
    parent: "epic-456"
    addBlocking: ["task-789"]
    ifMatch: "abc"
  }) {
    id
    etag
  }
}
```

### Execution Order

1. Metadata updates applied (title, status, type, priority, tags)
2. Body modifications applied (via \`bodyMod\` if provided)
3. Relationship updates applied:
   - Parent relationship (with validation)
   - Add blocking relationships (with validation)
   - Remove blocking relationships
   - Add blocked-by relationships (with validation)
   - Remove blocked-by relationships
4. Single ETag validation and save under write lock (atomic)
5. If any step fails, entire mutation fails (transactional)

### Validations

All existing validations preserved:

**Parent validation:**
- ✅ Type hierarchy rules enforced (e.g., task can have
epic/feature/milestone as parent)
- ✅ Cycle detection (cannot create parent cycles)

**Blocking/Blocked-by validation:**
- ✅ Self-reference check (bean cannot block itself)
- ✅ Target existence check
- ✅ Bidirectional cycle detection (checks both blocking and blocked-by
paths)

### Testing

**Unit tests added:**
- 16 comprehensive test cases for relationship updates
- Atomic operations (status + parent + blocking + bodyMod combined)
- Parent type hierarchy validation
- Parent removal (empty string)
- Blocking self-reference validation
- Blocking cycle detection
- Blocking target existence validation
- Multiple blocking additions at once
- Relationship removals
- Combined add/remove operations
- BlockedBy self-reference validation
- BlockedBy cycle detection
- BlockedBy target existence validation
- All relationship types combined in one update

**Manual testing:**
- ✅ CLI atomic updates confirmed working
- ✅ ETag validation working correctly
- ✅ All validations functioning as expected

All tests passing ✅ (42 graph tests total)

### Benefits

- ✅ **Truly atomic updates** - All changes succeed or fail together
- ✅ **No ETag chaining bugs** - Single ETag for entire operation
- ✅ **Simpler for AI agents** - One mutation with clear input structure
instead of orchestrating multiple mutations with manual ETag extraction
and state management
- ✅ **Better UX** - Combine any fields in a single operation
- ✅ **Prevents race conditions** - All validations and updates happen
atomically
- ✅ **Backward compatible** - Standalone mutations still work
- ✅ **Consistent API design** - Follows same pattern as \`bodyMod\` from
#72
- ✅ **Fixes CLI bugs** - No more ETag chaining failures when using
\`--if-match\` with relationship flags

### Questions for Consideration

**Should we deprecate and remove standalone relationship mutations?**

Following the same reasoning as in #72 (where we removed
\`replaceInBody\` and \`appendToBody\`), we could deprecate the
standalone relationship mutations to:

- ✅ **Keep the schema slim** - Fewer mutations means less API surface
area to maintain
- ✅ **Cleaner mental model for AI agents** - One clear way to update
beans instead of multiple overlapping approaches
- ✅ **Consistency** - Matches the decision made for body modifications
- ✅ **Simpler documentation** - One pattern to explain and understand

**Affected mutations:**
- \`setParent\` → use \`updateBean\` with \`parent\` field
- \`addBlocking\` / \`removeBlocking\` → use \`updateBean\` with
\`addBlocking\` / \`removeBlocking\` fields
- \`addBlockedBy\` / \`removeBlockedBy\` → use \`updateBean\` with
\`addBlockedBy\` / \`removeBlockedBy\` fields

**Migration would be straightforward:**
```graphql
# Before
setParent(id: "task-123", parentId: "epic-456", ifMatch: "abc")

# After
updateBean(id: "task-123", input: { parent: "epic-456", ifMatch: "abc" })
```

Alternatively, we could keep both approaches available if there's value
in having specialized mutations for specific use cases.

**What's your preference?**
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants