feat(sessions): soft interference detection via DAG dependency cones #166

Open
opened 2026-03-01 15:38:39 +00:00 by forbes · 0 comments
Owner

Context

Sub-issue of #125 (Context-Aware Part Subscription System).

Hard interference (same object, same context level) is enforced by the DB unique index from #163. This issue adds soft interference detection — warning when two users edit different objects whose dependency cones overlap.

How It Works

  1. User A is editing Sketch001. The server stores dependency_cone: [Sketch001, Pad003, Fillet005] (the forward cone from the DAG).
  2. User B tries to edit Sketch003. The server computes B's cone: [Sketch003, Pad003, Pocket002].
  3. Server compares B's cone against all active sessions on the same item.
  4. Pad003 appears in both cones → soft interference.
  5. B gets a 200 with interference: "soft" and details. The client can warn the user but still allows entry.

Server-Side Cone Computation

When a client acquires a session without providing dependency_cone, and the DAG module is enabled:

  1. Look up the item's latest DAG revision
  2. Find the node matching object_id by node_key
  3. Call dag.GetForwardCone(nodeID) (already implemented in internal/db/dag.go)
  4. Store the resulting node keys as dependency_cone in the session

If the DAG doesn't exist for this item, store empty cone and skip soft detection.

Changes to Acquire Handler

Extend HandleAcquireEditSession (from #163):

func (s *Server) HandleAcquireEditSession(...) {
    // 1. Resolve dependency cone if not provided
    cone := req.DependencyCone
    if len(cone) == 0 && s.registry.IsEnabled(modules.DAG) {
        cone = s.computeDependencyCone(ctx, item.ID, req.ObjectID)
    }

    // 2. Attempt INSERT (hard interference = unique constraint error)
    session.DependencyCone = cone
    err := s.editSessions.Acquire(ctx, session)
    if isUniqueViolation(err) {
        // return 409 with holder info (existing behavior)
    }

    // 3. Check soft interference against other sessions
    others, _ := s.editSessions.ListForItem(ctx, item.ID)
    conflicts := detectSoftInterference(session, others)

    // 4. Return result
    resp := AcquireResponse{
        SessionID:    session.ID,
        Interference: "none",
        Conflicts:    []ConflictInfo{},
    }
    if len(conflicts) > 0 {
        resp.Interference = "soft"
        resp.Conflicts = conflicts
    }
}

Soft Interference Detection Logic

func detectSoftInterference(incoming *EditSession, others []*EditSession) []ConflictInfo {
    if len(incoming.DependencyCone) == 0 {
        return nil // no cone = can't detect
    }
    incomingSet := toSet(incoming.DependencyCone)

    var conflicts []ConflictInfo
    for _, other := range others {
        if other.ID == incoming.ID { continue }
        if len(other.DependencyCone) == 0 { continue }

        shared := intersect(incomingSet, toSet(other.DependencyCone))
        if len(shared) > 0 {
            conflicts = append(conflicts, ConflictInfo{
                SessionID:    other.ID,
                User:         other.UserID,  // resolve to username
                ContextLevel: other.ContextLevel,
                ObjectID:     other.ObjectID,
                SharedNodes:  shared,
                Message:      fmt.Sprintf("%s is editing %s, which shares downstream features", ...),
            })
        }
    }
    return conflicts
}

Interference Resolved Events

When a session is released and it had soft interference with another session, notify the remaining session:

// On release, check if released session was in any conflict
func (s *Server) notifyInterferenceResolved(released *EditSession, remaining []*EditSession) {
    for _, other := range remaining {
        if hasOverlap(released.DependencyCone, other.DependencyCone) {
            s.broker.PublishToItem(released.ItemID, "edit.interference_resolved", ...)
        }
    }
}

SSE event: edit.interference_resolved

{
  "item_id": "uuid",
  "session_id": "remaining_session_uuid",
  "resolved_user": "alice",
  "resolved_object": "Sketch001"
}

Fallback Behavior

DAG State Soft Detection
DAG exists, cone computed Full soft interference detection
DAG exists, client provides cone Client cone trusted, stored as-is
No DAG for item Context-level-only (hard interference only)
DAG module disabled No cone computation, hard interference only

Files to Modify

  • internal/api/session_handlers.go — extend acquire with cone computation and soft detection
  • New helper functions for set intersection and cone computation

Acceptance Criteria

  • Session acquire computes dependency cone from DAG when not provided by client
  • Overlapping cones between sessions produce interference: "soft" response
  • Soft interference response includes shared node keys and human-readable message
  • Non-overlapping cones produce interference: "none"
  • No DAG = no soft detection (hard only)
  • edit.interference_resolved SSE event when conflicting session releases
  • Client-provided cones are stored as-is without server recomputation

Depends On

  • #163 (edit session acquire/release)
  • DAG module (internal/db/dag.go GetForwardCone() already exists)

Part Of

#125

## Context Sub-issue of #125 (Context-Aware Part Subscription System). Hard interference (same object, same context level) is enforced by the DB unique index from #163. This issue adds **soft interference detection** — warning when two users edit different objects whose dependency cones overlap. ## How It Works 1. User A is editing `Sketch001`. The server stores `dependency_cone: [Sketch001, Pad003, Fillet005]` (the forward cone from the DAG). 2. User B tries to edit `Sketch003`. The server computes B's cone: `[Sketch003, Pad003, Pocket002]`. 3. Server compares B's cone against all active sessions on the same item. 4. `Pad003` appears in both cones → **soft interference**. 5. B gets a 200 with `interference: "soft"` and details. The client can warn the user but still allows entry. ## Server-Side Cone Computation When a client acquires a session without providing `dependency_cone`, and the DAG module is enabled: 1. Look up the item's latest DAG revision 2. Find the node matching `object_id` by `node_key` 3. Call `dag.GetForwardCone(nodeID)` (already implemented in `internal/db/dag.go`) 4. Store the resulting node keys as `dependency_cone` in the session If the DAG doesn't exist for this item, store empty cone and skip soft detection. ## Changes to Acquire Handler Extend `HandleAcquireEditSession` (from #163): ```go func (s *Server) HandleAcquireEditSession(...) { // 1. Resolve dependency cone if not provided cone := req.DependencyCone if len(cone) == 0 && s.registry.IsEnabled(modules.DAG) { cone = s.computeDependencyCone(ctx, item.ID, req.ObjectID) } // 2. Attempt INSERT (hard interference = unique constraint error) session.DependencyCone = cone err := s.editSessions.Acquire(ctx, session) if isUniqueViolation(err) { // return 409 with holder info (existing behavior) } // 3. Check soft interference against other sessions others, _ := s.editSessions.ListForItem(ctx, item.ID) conflicts := detectSoftInterference(session, others) // 4. Return result resp := AcquireResponse{ SessionID: session.ID, Interference: "none", Conflicts: []ConflictInfo{}, } if len(conflicts) > 0 { resp.Interference = "soft" resp.Conflicts = conflicts } } ``` ### Soft Interference Detection Logic ```go func detectSoftInterference(incoming *EditSession, others []*EditSession) []ConflictInfo { if len(incoming.DependencyCone) == 0 { return nil // no cone = can't detect } incomingSet := toSet(incoming.DependencyCone) var conflicts []ConflictInfo for _, other := range others { if other.ID == incoming.ID { continue } if len(other.DependencyCone) == 0 { continue } shared := intersect(incomingSet, toSet(other.DependencyCone)) if len(shared) > 0 { conflicts = append(conflicts, ConflictInfo{ SessionID: other.ID, User: other.UserID, // resolve to username ContextLevel: other.ContextLevel, ObjectID: other.ObjectID, SharedNodes: shared, Message: fmt.Sprintf("%s is editing %s, which shares downstream features", ...), }) } } return conflicts } ``` ## Interference Resolved Events When a session is released and it had soft interference with another session, notify the remaining session: ```go // On release, check if released session was in any conflict func (s *Server) notifyInterferenceResolved(released *EditSession, remaining []*EditSession) { for _, other := range remaining { if hasOverlap(released.DependencyCone, other.DependencyCone) { s.broker.PublishToItem(released.ItemID, "edit.interference_resolved", ...) } } } ``` SSE event: `edit.interference_resolved` ```json { "item_id": "uuid", "session_id": "remaining_session_uuid", "resolved_user": "alice", "resolved_object": "Sketch001" } ``` ## Fallback Behavior | DAG State | Soft Detection | |-----------|---------------| | DAG exists, cone computed | Full soft interference detection | | DAG exists, client provides cone | Client cone trusted, stored as-is | | No DAG for item | Context-level-only (hard interference only) | | DAG module disabled | No cone computation, hard interference only | ## Files to Modify - `internal/api/session_handlers.go` — extend acquire with cone computation and soft detection - New helper functions for set intersection and cone computation ## Acceptance Criteria - [ ] Session acquire computes dependency cone from DAG when not provided by client - [ ] Overlapping cones between sessions produce `interference: "soft"` response - [ ] Soft interference response includes shared node keys and human-readable message - [ ] Non-overlapping cones produce `interference: "none"` - [ ] No DAG = no soft detection (hard only) - [ ] `edit.interference_resolved` SSE event when conflicting session releases - [ ] Client-provided cones are stored as-is without server recomputation ## Depends On - #163 (edit session acquire/release) - DAG module (`internal/db/dag.go` `GetForwardCone()` already exists) ## Part Of #125
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: kindred/silo#166