Skip to content

Conversation

@matleh
Copy link
Contributor

@matleh matleh commented Jan 29, 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 feat: atomic bean updates with body modifications #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:

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:

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

# ❌ 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):

# ✅ 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 feat: atomic bean updates with body modifications #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:

# 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?

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.
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
- Added parent, addBlocking, removeBlocking, addBlockedBy, removeBlockedBy fields to UpdateBeanInput
- Enables atomic updates of relationships alongside metadata and body changes
- Updated CLI to use atomic relationship updates instead of separate mutations
- Added comprehensive test coverage for relationship updates
- Added ETag validation tests for all GraphQL mutations

This resolves the limitation where updating relationships required separate
mutations, making multi-field updates non-atomic and more complex for clients.
- Added addTags and removeTags fields to UpdateBeanInput
- Made tags consistent with relationship fields (granular add/remove)
- Updated CLI to use addTags/removeTags instead of client-side merging
- Added mutual exclusivity validation between tags and addTags/removeTags
- Added 4 comprehensive tests for tag operations
- All tests passing

This resolves the API inconsistency where:
- Tags used full replacement (fetch-modify-update required)
- Relationships used granular add/remove (simpler for AI agents)

Now both follow the same pattern for a cleaner mental model.
@matleh matleh force-pushed the feature/atomic-relationship-updates branch from c501b77 to 2639f12 Compare January 30, 2026 11:31
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.

1 participant