Provider-agnostic optimistic concurrency for .NET. Submit a base revision plus a diffgram; get an arbitrated merge.
Two writers. One row. One ruling.
Every .NET data stack detects concurrency conflicts the same way: compare a version token, and if it moved, throw. What happens next is left entirely to you — and in practice "next" is a reload-and-retry loop your users experience as lost work.
Tribunal takes the other half of the problem seriously. A caller submits the revision they read plus a diffgram of what they changed. Tribunal fetches current, performs a field-level three-way merge, and consults a policy only where two writers actually touched the same field. Everything else auto-merges, the way Git auto-merges non-overlapping hunks.
No ORM, no database, no serializer is assumed. Funcular.Tribunal.Abstractions has zero dependencies.
| Package | What's in it |
|---|---|
Funcular.Tribunal.Abstractions |
Contracts only: Diffgram, FieldChange, FieldConflict, Verdict, Ruling, and the seams — IRevisionedStore, IFieldAccessor, IConflictPolicy, IValueEqualityComparer, IValueCoercer. Reference this from adapters. |
Funcular.Tribunal |
The engine (MergeArbiter), the orchestrator (Tribunal), built-in policies, per-field policy builder, compiled-reflection accessor, value equality/coercion, revision comparers, snapshot diffgram builder, and an in-memory reference store. |
Targets netstandard2.0 and net8.0.
using Funcular.Tribunal;
using Funcular.Tribunal.Policies;
using Funcular.Tribunal.Reflection;
using Funcular.Tribunal.Revisions;
// 1. A policy. StoreWins for everything, except Balance must never be silently overruled.
var policy = ConflictPolicy.For<Account>()
.Default(ConflictPolicy.StoreWins)
.Field(a => a.Balance, ConflictPolicy.Abort)
.Build();
// 2. The engine: pure, no I/O. Give it a way to read/write fields and a way to order revisions.
var arbiter = new MergeArbiter<Account, long>(
ReflectionFieldAccessor<Account>.Instance,
ComparableRevisionComparer<long>.Instance,
policy);
// 3. The orchestrator: fetch → merge → compare-and-swap write, retried from the original diffgram.
ITribunal<Account, int, long> tribunal = new Tribunal<Account, int, long>(yourStore, arbiter);
// 4. A client read revision 41 and changed two fields.
var diffgram = new Diffgram<long>(41, new[]
{
new FieldChange("Name", originalValue: "Alice", proposedValue: "Alicia"),
new FieldChange("Notes", originalValue: null, proposedValue: "Prefers email"),
});
var result = await tribunal.SubmitAsync(key: 7, diffgram);
switch (result.Ruling.Outcome)
{
case RulingOutcome.Applied: /* everything landed */ break;
case RulingOutcome.AppliedWithDissent: /* landed; result.Ruling.Dissents says what lost */ break;
case RulingOutcome.Vanished: /* the row was deleted */ break;
case RulingOutcome.Exhausted: /* lost the CAS race MaxAttempts times */ break;
// Aborted throws MergeConflictException by default; Anomalous / Invalid mean the diffgram is not trustworthy.
}For every change in the diffgram Tribunal compares three values for that field: base (what the caller says they saw), store (what the row holds now) and proposed (what the caller wants).
| Base vs proposed | Store vs base | Store vs proposed | Meaning | What happens |
|---|---|---|---|---|
| equal | — | — | The caller didn't change it | Unchanged — store value stands, no policy |
| differs | — | equal | Someone already made the same change | Converged — nothing to do |
| differs | equal | differs | Nobody else touched it | Applied — no policy consulted |
| differs | differs | differs | Both writers changed it, differently | Contested — the policy decides |
Before any row applies: at an equal revision, store ≠ base on any changed field is Anomalous (the caller's
base snapshot isn't what it claims). "Equal" throughout means the configured value equality, tolerances included.
The policy returns one Verdict per contested field:
| Verdict | Git analogue | Disposition |
|---|---|---|
Accept |
--ours |
proposed value lands (Accepted) |
Reject |
--theirs |
store value kept, reported as a dissent (Rejected) |
Substitute(v) |
a manual resolution | a computed value lands (Substituted) — e.g. a numeric delta merge |
Abort(reason) |
stop and show the conflicts | nothing is written; the ruling lists every contested field |
Built-ins: ConflictPolicy.Abort, ConflictPolicy.ClientWins, ConflictPolicy.StoreWins,
ConflictPolicy.FromDelegate(...), and ConflictPolicy.For<T>() for per-field composition.
The merge is two-phase. Every change is classified — including whether its target is writable — and every
verdict collected before a single field is written. An Aborted, Anomalous or Invalid ruling never leaves a
half-mutated entity, and the outcome doesn't depend on the order of changes in the diffgram. The one thing no
library can promise: if a validating setter rejects a value after earlier fields were written, and undoing one of
those also fails, the entity is in an undefined state — Tribunal throws MergeRollbackException rather than hand
you an Invalid ruling over it, and nothing is written. Plain auto-properties never hit this.
Because the caller tells us what they saw, Tribunal can detect a field-level conflict without fetching or
storing the historical row. That is what keeps it provider-agnostic: any store that can do
UPDATE … WHERE key = @k AND revision = @expected can host it.
It also lets Tribunal fail closed on lies. At an equal revision the store must agree with every original value;
if it doesn't, the caller's base snapshot is not what it claims and the ruling is Anomalous. A base revision
newer than the store's is Anomalous too — that's a rolled-back store or the wrong replica, not a fast path.
Tribunal.SubmitAsync loops fetch → merge → CAS write. When the write loses a race it re-fetches and re-merges
the original diffgram against the fresh row. Nothing is rebased onto the previous attempt's result, so a
Substitute that computes a delta (store + (proposed − base)) yields the right answer on attempt 2 as well.
The budget is bounded (TribunalOptions.MaxAttempts, default 3) with a jittered backoff you can replace.
A ruling that changes nothing (empty diffgram, everything already converged or unchanged, or every contested field
resolved for the store) is not written at all; IsWritten is false and NewRevision is the revision that was read.
Two things this does not cover, because no library can:
- Your store's
FetchAsyncmust hand back an instance it doesn't share (no identity-map object, no reuse across fetches). The merge mutates in place; a shared instance would carry attempt 1's result into attempt 2. - If
TryWriteAsyncthrows after the row committed (a dropped acknowledgement), the exception tells the caller nothing. Re-read before resubmitting; don't replay the same diffgram blind.
Revisions are ordered through IRevisionComparer<TRevision> rather than an IComparable<T> constraint, because
the most common token of all — SQL Server rowversion — is a byte[]. Provided:
ComparableRevisionComparer<T>for anythingIComparable<T>(long,int,DateTime, …)ByteArrayRevisionComparerforrowversion/timestamp(unsigned big-endian)DelegateRevisionComparer<T>for the rest
Naive Equals manufactures phantom conflicts. DefaultValueEqualityComparer treats byte[], bounded
collections (order-free for sets and dictionaries; lazy enumerables are never enumerated) and plain POCOs (your own
classes and structs — anything not declared in a framework assembly — that don't override Equals, compared by the
public properties and fields you declared, recursively, so a cloned List<Address> is not a change; framework
handles like StringBuilder, Lazy<T> or an IDisposable are not values at all — the accessor leaves them out of
the catalog (a settable POCO parent whose only leaves are such handles is catalogued as a leaf itself, compared by its
members), and as members of a POCO they compare by reference, so distinct handles are contested rather than
silently converged; [NotMapped] opts a member out explicitly, on overrides too) structurally, numerics
across widths (int 5 == long 5 for the ten fixed-width CLR numeric primitives and decimal, eleven types in all; BigInteger/Half/Int128/UInt128/nint/nuint are not in the set; mixed decimal/double compared at decimal precision, NaN == NaN), DateTime
within a configurable tolerance (set ~4 ms for SQL Server datetime; ticks are compared regardless of Kind, so
normalise to UTC at the edges), and strings ordinally. It never throws. Use the same comparer instance for the
diffgram builder and the arbiter — a change one considers real, the other may consider Unchanged. DefaultValueCoercer converts
wire-shaped values (JSON long → int, string → Guid/enum/DateTime) to the field's CLR type first, and
refuses lossy or undefined conversions (5.5 → int, 99 → SomeEnum, true → SomeEnum). Offset-bearing date
strings become UTC. Both are seams (IValueEqualityComparer, IValueCoercer).
- Not a sync engine. It arbitrates one update against one store; it doesn't replicate.
- Not a CRDT. Convergence is a policy decision you make, not an algebraic guarantee.
- Not a lock. Nothing is held. If the store moves under you mid-merge, Tribunal re-hears the case.
Three seams, all in the Abstractions package, none of which require the ORM to change:
IFieldAccessor<T>/IFieldCatalog<T>— the ORM's cached property metadata, if it has any.IRevisionedStore<T, TKey, TRevision>— one read, one compare-and-swap write.IDiffgramBuilder<T, TRevision>— optional; the ORM's change tracking can emit diffgrams directly.
See docs/FUNKYORM-INTEGRATION.md for the FunkyORM plan — the test suite already
drives a Tribunal end-to-end through FunkyORM's SQLite provider (FunkyOrmSqliteStore).
- v0.2 keyed collection diffs (element identity, not ordinal index)
Funcular.Tribunal.Json—JsonElementcoercion and a stable wire format for diffgramsFuncular.Tribunal.FunkyOrm— the adapter described above
MIT © Funcular Labs, Inc.
