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¶
- Caller invokes sync_to — Target namespace first, keyword
source_namespacesecond.sync_fromis the symmetric inverse. - Namespace validation —
NamespaceRegistry.validaterejects unknown namespaces immediately; aValueErroraborts the sync. - 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.
- Policy resolution — The target namespace's
NamespaceConfig.conflict_policygoverns reconciliation: LWW, DOMAIN_OWNER_AUTHORITY, or MANUAL_MERGE. - Per-change apply — No existing target state OR same value: apply directly. Otherwise build a
Conflictand callreconcile. - 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. - Inbound mirroring — Every applied change is also recorded as an inbound event on the target tracker so reverse syncs see it as outbound.
- Report —
SyncReportcaptures counts (applied / conflicts / resolved / queued), per-entity resolutions, andsync_lag_ms. - 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