Skip to content

Bidirectional Federation Sync

This diagram traces a single bidirectional sync pass between two registered namespaces. The engine at src/fcc/federation/bidirectional.py:117 holds one ChangeTracker per namespace and materializes per-entity state in a (namespace, entity_id) -> ModelChange map. On each sync the engine drains the source tracker's outbound queue, applies the changes against the target's state, and reconciles conflicts according to the target's ConflictPolicy. v1.5.0 Pillar E introduced this layer; ADR-013 captures the decision to make sync symmetric (both directions run through the same _run_sync internal) while preserving per-namespace conflict policies.

The sequence below shows one end-to-end pass: outbound queue drain on the source, conflict detection on the target, policy-driven reconciliation, inbound mirroring, and the final :class:SyncReport.

sequenceDiagram
    participant Caller
    participant BidiSync as BidirectionalSync
    participant SrcTracker as ChangeTracker (source)
    participant NSReg as NamespaceRegistry
    participant TgtState as state[(target, *)]
    participant TgtTracker as ChangeTracker (target)

    Caller->>BidiSync: sync_to(target, source_namespace=source)
    BidiSync->>NSReg: validate(source), validate(target)
    NSReg-->>BidiSync: ok
    BidiSync->>SrcTracker: outbound()
    SrcTracker-->>BidiSync: pending: list[ModelChange]
    BidiSync->>NSReg: get(target).conflict_policy
    NSReg-->>BidiSync: ConflictPolicy (LWW / DOMAIN_OWNER / MANUAL_MERGE)

    loop for each change in pending
        BidiSync->>TgtState: get((target, entity_id))
        alt no existing OR same value
            BidiSync->>TgtState: apply(change)
            BidiSync->>TgtTracker: track_inbound(change)
            Note over BidiSync: applied += 1
        else conflict
            Note over BidiSync: build Conflict(source, target, src_change, tgt_change, policy)
            BidiSync->>BidiSync: reconcile(conflict)
            alt LAST_WRITE_WINS
                Note over BidiSync: newer ISO-8601 timestamp wins then apply to losers namespace
                BidiSync->>TgtTracker: track_inbound(winner)
            else DOMAIN_OWNER_AUTHORITY
                Note over BidiSync: apply owners change else fall back to LWW when owner unset
            else MANUAL_MERGE
                BidiSync->>BidiSync: merge_queue.append(conflict)
                Note over BidiSync: Resolution.QUEUED_FOR_MERGE
            end
        end
    end

    BidiSync->>SrcTracker: _outbound.clear()
    Note over BidiSync: now = utcnow then _last_sync_at source = now and target = now
    BidiSync-->>Caller: SyncReport(direction, source, target, applied, conflicts, resolved, queued, sync_lag_ms, resolutions)

The three policy paths have different operational feels. LAST_WRITE_WINS is automatic but depends on clock-adjacent ISO-8601 timestamps being trustworthy across namespaces — fine within one organization, fragile across partners. DOMAIN_OWNER_AUTHORITY is deterministic when the owner is set on the target's NamespaceConfig, but silently degrades to LWW when it isn't, so misconfigured owners produce surprising results. MANUAL_MERGE is the safe default when neither of the automatic paths is trustworthy — conflicts queue up, the federation dashboard surfaces the count via BidirectionalSync.health(), and an operator calls resolve_manual with the winning ModelChange.

Every successful apply mirrors the change as an inbound event on the target tracker, so a subsequent sync_to in the reverse direction sees the update as outbound and propagates it back. That symmetry is what makes the topology bidirectional — a one-directional sync would leave the target's tracker silent.

Steps in detail

  1. Caller invokes sync_to — Target namespace first, keyword source_namespace second. sync_from is the symmetric inverse.
  2. Namespace validationNamespaceRegistry.validate rejects unknown namespaces immediately; a ValueError aborts the sync.
  3. Drain outbound queue — The source tracker's pending changes are collected; the queue is cleared at the end so repeated calls don't double-apply.
  4. Policy resolution — The target namespace's NamespaceConfig.conflict_policy governs reconciliation: LWW, DOMAIN_OWNER_AUTHORITY, or MANUAL_MERGE.
  5. Per-change apply — No existing target state OR same value: apply directly. Otherwise build a Conflict and call reconcile.
  6. Reconciliation — LWW compares ISO-8601 timestamps; DOMAIN_OWNER picks the owner's namespace (falling back to LWW when unset); MANUAL_MERGE queues and returns Resolution.QUEUED_FOR_MERGE.
  7. Inbound mirroring — Every applied change is also recorded as an inbound event on the target tracker so reverse syncs see it as outbound.
  8. ReportSyncReport captures counts (applied / conflicts / resolved / queued), per-entity resolutions, and sync_lag_ms.
  9. Manual resolution path — An operator later calls resolve_manual(entity_id, winner) to drain queued conflicts.

See also

  • Source: src/fcc/federation/bidirectional.py:117 (BidirectionalSync), src/fcc/federation/change_tracking.py (ChangeTracker, ModelChange), src/fcc/federation/namespaces.py (ConflictPolicy)
  • Related sequence: federation-entity-resolution.md
  • Related DFD: ../data-flow-diagrams/cross-project-entity-resolution.md
  • ADR: docs/decisions/ADR-013_*.md (Bidirectional federation)
  • Pillar overview: ../class-diagrams/six-pillars-overview.md