diff --git a/.gitignore b/.gitignore
index 7361c97..e7c353e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -402,3 +402,7 @@ FodyWeavers.xsd
*.received.*
*.lscache
+
+# Agent skills (local-only)
+.scratch/
+docs/agents/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..6ec05a8
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,13 @@
+## Agent skills
+
+### Issue tracker
+
+Issues live as local markdown files under `.scratch/` (gitignored). See `docs/agents/issue-tracker.md`.
+
+### Triage labels
+
+Default triage role strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`). See `docs/agents/triage-labels.md`.
+
+### Domain docs
+
+Single-context layout — `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
diff --git a/harmony.sln b/harmony.sln
index a44dc0d..129cce0 100644
--- a/harmony.sln
+++ b/harmony.sln
@@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Tests", "src\SI
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Linq2db", "src\SIL.Harmony.Linq2db\SIL.Harmony.Linq2db.csproj", "{8EB807F6-C548-4016-856E-2ACCA5603036}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SIL.Harmony.Refs", "src\SIL.Harmony.Refs\SIL.Harmony.Refs.csproj", "{C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{321635F9-11D9-4B45-A2F8-F3B70E9A4196}"
ProjectSection(SolutionItems) = preProject
src\Directory.Build.props = src\Directory.Build.props
@@ -55,5 +57,9 @@ Global
{8EB807F6-C548-4016-856E-2ACCA5603036}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8EB807F6-C548-4016-856E-2ACCA5603036}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8EB807F6-C548-4016-856E-2ACCA5603036}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C1BAB3FF-7B5B-4847-B5DF-7B87BB0DA744}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/src/SIL.Harmony.Refs/BranchAssignment.cs b/src/SIL.Harmony.Refs/BranchAssignment.cs
new file mode 100644
index 0000000..468ed05
--- /dev/null
+++ b/src/SIL.Harmony.Refs/BranchAssignment.cs
@@ -0,0 +1,32 @@
+namespace SIL.Harmony.Refs;
+
+///
+/// Where a newly authored commit should be assigned.
+///
+public readonly record struct BranchAssignment
+{
+ private BranchAssignment(BranchAssignmentKind kind, Guid? branchId)
+ {
+ Kind = kind;
+ BranchId = branchId;
+ }
+
+ public BranchAssignmentKind Kind { get; }
+ public Guid? BranchId { get; }
+
+ /// Use the current local checkout (default).
+ public static BranchAssignment FromCheckout { get; } = new(BranchAssignmentKind.FromCheckout, null);
+
+ /// Force main line (no branch metadata).
+ public static BranchAssignment Main { get; } = new(BranchAssignmentKind.Main, null);
+
+ /// Force a specific branch.
+ public static BranchAssignment ToBranch(Guid branchId) => new(BranchAssignmentKind.Branch, branchId);
+}
+
+public enum BranchAssignmentKind
+{
+ FromCheckout,
+ Main,
+ Branch
+}
diff --git a/src/SIL.Harmony.Refs/Changes/CreateBranchChange.cs b/src/SIL.Harmony.Refs/Changes/CreateBranchChange.cs
new file mode 100644
index 0000000..f994fc2
--- /dev/null
+++ b/src/SIL.Harmony.Refs/Changes/CreateBranchChange.cs
@@ -0,0 +1,21 @@
+using SIL.Harmony.Changes;
+using SIL.Harmony.Entities;
+using SIL.Harmony.Refs.Entities;
+
+namespace SIL.Harmony.Refs.Changes;
+
+public class CreateBranchChange(Guid entityId, string name) : CreateChange(entityId), IPolyType
+{
+ public string Name { get; } = name;
+
+ public override ValueTask NewEntity(Commit commit, IChangeContext context)
+ {
+ return ValueTask.FromResult(new Branch
+ {
+ Id = EntityId,
+ Name = Name
+ });
+ }
+
+ public static string TypeName => "create:branch";
+}
diff --git a/src/SIL.Harmony.Refs/Changes/CreateTagChange.cs b/src/SIL.Harmony.Refs/Changes/CreateTagChange.cs
new file mode 100644
index 0000000..479c752
--- /dev/null
+++ b/src/SIL.Harmony.Refs/Changes/CreateTagChange.cs
@@ -0,0 +1,23 @@
+using SIL.Harmony.Changes;
+using SIL.Harmony.Entities;
+using SIL.Harmony.Refs.Entities;
+
+namespace SIL.Harmony.Refs.Changes;
+
+public class CreateTagChange(Guid entityId, string name, Guid targetCommitId) : CreateChange(entityId), IPolyType
+{
+ public string Name { get; } = name;
+ public Guid TargetCommitId { get; } = targetCommitId;
+
+ public override ValueTask NewEntity(Commit commit, IChangeContext context)
+ {
+ return ValueTask.FromResult(new Tag
+ {
+ Id = EntityId,
+ Name = Name,
+ TargetCommitId = TargetCommitId
+ });
+ }
+
+ public static string TypeName => "create:tag";
+}
diff --git a/src/SIL.Harmony.Refs/Changes/MergeBranchChange.cs b/src/SIL.Harmony.Refs/Changes/MergeBranchChange.cs
new file mode 100644
index 0000000..cdb0f20
--- /dev/null
+++ b/src/SIL.Harmony.Refs/Changes/MergeBranchChange.cs
@@ -0,0 +1,20 @@
+using SIL.Harmony.Changes;
+using SIL.Harmony.Entities;
+using SIL.Harmony.Refs.Entities;
+
+namespace SIL.Harmony.Refs.Changes;
+
+///
+/// Main-line commit that incorporates a branch into main visibility and deletes the branch entity.
+/// Does not rewrite branch commit payloads; materialization starts including those commits after this change.
+///
+public class MergeBranchChange(Guid entityId) : EditChange(entityId), IPolyType
+{
+ public override ValueTask ApplyChange(Branch entity, IChangeContext context)
+ {
+ entity.DeletedAt = context.Commit.DateTime;
+ return ValueTask.CompletedTask;
+ }
+
+ public static string TypeName => "merge:branch";
+}
diff --git a/src/SIL.Harmony.Refs/Changes/MoveTagChange.cs b/src/SIL.Harmony.Refs/Changes/MoveTagChange.cs
new file mode 100644
index 0000000..02a226b
--- /dev/null
+++ b/src/SIL.Harmony.Refs/Changes/MoveTagChange.cs
@@ -0,0 +1,18 @@
+using SIL.Harmony.Changes;
+using SIL.Harmony.Entities;
+using SIL.Harmony.Refs.Entities;
+
+namespace SIL.Harmony.Refs.Changes;
+
+public class MoveTagChange(Guid entityId, Guid targetCommitId) : EditChange(entityId), IPolyType
+{
+ public Guid TargetCommitId { get; } = targetCommitId;
+
+ public override ValueTask ApplyChange(Tag entity, IChangeContext context)
+ {
+ entity.TargetCommitId = TargetCommitId;
+ return ValueTask.CompletedTask;
+ }
+
+ public static string TypeName => "move:tag";
+}
diff --git a/src/SIL.Harmony.Refs/CheckoutMaterializationFilter.cs b/src/SIL.Harmony.Refs/CheckoutMaterializationFilter.cs
new file mode 100644
index 0000000..a2dac44
--- /dev/null
+++ b/src/SIL.Harmony.Refs/CheckoutMaterializationFilter.cs
@@ -0,0 +1,107 @@
+using Microsoft.EntityFrameworkCore;
+using SIL.Harmony.Db;
+using SIL.Harmony.Refs.Changes;
+
+namespace SIL.Harmony.Refs;
+
+///
+/// Materializes commits visible for the current local checkout:
+/// main line (no branch id), commits for incorporated (merged) branches on main,
+/// plus commits scoped to the checked-out branch when on a branch,
+/// or commits at-or-before a tag tip with visibility evaluated at that tip.
+///
+public sealed class CheckoutMaterializationFilter : ICommitMaterializationFilter, IMaterializationApplyWindow
+{
+ private readonly HashSet _incorporatedBranchIds = [];
+ private Commit? _asOfTip;
+
+ public RefCheckout Checkout { get; set; } = RefCheckout.Main;
+
+ public bool HasAsOfTip => _asOfTip is not null;
+
+ ///
+ /// The commit the current tag checkout is pinned to, or null when not pinned to a tip.
+ /// Lets the roll-forward listener skip rematerialization when the tag tip has not moved.
+ ///
+ public Guid? AsOfTipId => _asOfTip?.Id;
+
+ ///
+ /// When set (tag checkout), only commits at or before this tip are included,
+ /// and merge incorporation is evaluated only through this tip.
+ ///
+ public void SetAsOfTip(Commit tip) => _asOfTip = tip;
+
+ public void ClearAsOfTip() => _asOfTip = null;
+
+ public bool Include(Commit commit)
+ {
+ // Keep the active tag entity materializable even when create/move commits are after the tip.
+ if (Checkout is TagCheckout tag && TouchesTag(commit, tag.TagId))
+ return true;
+
+ if (_asOfTip is { } tip && commit.CompareKey.CompareTo(tip.CompareKey) > 0)
+ return false;
+
+ var branchId = RefMetadata.GetBranchId(commit.Metadata);
+ if (branchId is null) return true;
+
+ return Checkout switch
+ {
+ BranchCheckout branch => branchId == branch.BranchId || _incorporatedBranchIds.Contains(branchId.Value),
+ TagCheckout when _asOfTip is { } asOfTip && RefMetadata.GetBranchId(asOfTip.Metadata) is Guid tipBranch =>
+ branchId == tipBranch || _incorporatedBranchIds.Contains(branchId.Value),
+ _ => _incorporatedBranchIds.Contains(branchId.Value)
+ };
+ }
+
+ async Task> IMaterializationApplyWindow.PrepareApplyWindowAsync(
+ CrdtRepository repo,
+ SortedSet commitsToApply)
+ {
+ await RefreshIncorporatedBranchIds(repo);
+
+ var mergedInWindow = commitsToApply
+ .SelectMany(MergesIn)
+ .Select(m => m.EntityId)
+ .ToHashSet();
+ if (mergedInWindow.Count == 0) return commitsToApply;
+
+ // CurrentCommits is DefaultOrder (oldest first). First hit is the window start:
+ // earliest merged-branch commit, or the apply-window head if that is older.
+ var applyMinId = commitsToApply.Min!.Id;
+ var allCommits = await repo.CurrentCommits()
+ .Include(c => c.ChangeEntities)
+ .ToListAsync();
+
+ for (var i = 0; i < allCommits.Count; i++)
+ {
+ var commit = allCommits[i];
+ var branchId = RefMetadata.GetBranchId(commit.Metadata);
+ if (commit.Id == applyMinId ||
+ (branchId is Guid id && mergedInWindow.Contains(id)))
+ {
+ return allCommits.Skip(i).ToSortedSet();
+ }
+ }
+
+ return commitsToApply;
+ }
+
+ private async Task RefreshIncorporatedBranchIds(CrdtRepository repo)
+ {
+ _incorporatedBranchIds.Clear();
+ // Scoping to the tip bounds GetEntityIdsForChangeType to merges at or before it (see issue 15),
+ // so a single query serves both the tag (as-of) and unscoped cases.
+ if (_asOfTip is { } tip) repo = repo.GetScopedRepository(tip);
+ _incorporatedBranchIds.UnionWith(await repo.GetEntityIdsForChangeType());
+ }
+
+ private static IEnumerable MergesIn(Commit commit) =>
+ commit.ChangeEntities
+ .Select(ce => ce.Change)
+ .OfType();
+
+ private static bool TouchesTag(Commit commit, Guid tagId) =>
+ commit.ChangeEntities.Any(ce => ce.EntityId == tagId &&
+ (ce.Change is CreateTagChange or MoveTagChange));
+}
diff --git a/src/SIL.Harmony.Refs/CheckoutRefsHandler.cs b/src/SIL.Harmony.Refs/CheckoutRefsHandler.cs
new file mode 100644
index 0000000..e1ef19f
--- /dev/null
+++ b/src/SIL.Harmony.Refs/CheckoutRefsHandler.cs
@@ -0,0 +1,58 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace SIL.Harmony.Refs;
+
+///
+/// The refs bridge into 's generic extension points, so clients can author
+/// and sync through directly and still get branch/tag behaviour without the
+/// wrapper.
+///
+/// - As an it stamps the current checkout's branch assignment
+/// on locally-authored commits (or rejects authoring on a tag).
+/// - As an it rolls an active tag checkout forward after
+/// any apply — local or synced — when the tag's tip moved.
+///
+/// is resolved lazily (only when rolling forward) because the roll-forward
+/// depends on it, yet it is already constructed by the time an apply completes; requiring it in the
+/// constructor would form a cycle, since this same instance is also the constructor-injected interceptor.
+///
+public sealed class CheckoutRefsHandler(
+ CheckoutMaterializationFilter filter,
+ IOptions config,
+ IServiceProvider serviceProvider)
+ : ICommitInterceptor, ICommitAppliedListener
+{
+ public void OnCommitAuthored(Commit commit)
+ {
+ // Explicit per-call override or a ref-lifecycle assignment already decided this commit.
+ // ConsumeAssignment also clears the transient marker so it is never persisted or synced.
+ if (RefMetadata.ConsumeAssignment(commit.Metadata)) return;
+
+ switch (filter.Checkout)
+ {
+ case BranchCheckout branch:
+ RefMetadata.SetBranchId(commit.Metadata, branch.BranchId);
+ break;
+ case TagCheckout when !config.Value.AllowAuthoringOnTagToMain:
+ throw new InvalidOperationException(
+ "Authoring is not allowed while checked out on a tag. Set AllowAuthoringOnTagToMain to write to main, "
+ + "author with an explicit BranchAssignment, or checkout main/branch first.");
+ // MainCheckout, or a tag checkout with AllowAuthoringOnTagToMain: author to main (no branch id).
+ }
+ }
+
+ public async Task OnCommitsAppliedAsync(IReadOnlyCollection commits)
+ {
+ // Roll-forward only concerns tag checkouts; a main/branch view never moves on apply.
+ if (filter.Checkout is not TagCheckout tag) return;
+
+ var dataModel = serviceProvider.GetRequiredService();
+ var tip = await TagTipResolver.ResolveTagTip(dataModel, tag.TagId);
+ // Skip rematerialization unless the tag's tip actually moved.
+ if (filter.AsOfTipId == tip.Id) return;
+
+ filter.SetAsOfTip(tip);
+ await dataModel.RegenerateSnapshots();
+ }
+}
diff --git a/src/SIL.Harmony.Refs/Entities/Branch.cs b/src/SIL.Harmony.Refs/Entities/Branch.cs
new file mode 100644
index 0000000..46e332e
--- /dev/null
+++ b/src/SIL.Harmony.Refs/Entities/Branch.cs
@@ -0,0 +1,26 @@
+using SIL.Harmony.Entities;
+
+namespace SIL.Harmony.Refs.Entities;
+
+///
+/// A named line of work. Display is not unique; identity is .
+///
+public class Branch : IObjectBase
+{
+ public Guid Id { get; init; }
+ public required string Name { get; set; }
+ public DateTimeOffset? DeletedAt { get; set; }
+
+ public Guid[] GetReferences() => [];
+
+ public void RemoveReference(Guid id, CommitBase commit)
+ {
+ }
+
+ public IObjectBase Copy() => new Branch
+ {
+ Id = Id,
+ Name = Name,
+ DeletedAt = DeletedAt
+ };
+}
diff --git a/src/SIL.Harmony.Refs/Entities/Tag.cs b/src/SIL.Harmony.Refs/Entities/Tag.cs
new file mode 100644
index 0000000..9ad2b45
--- /dev/null
+++ b/src/SIL.Harmony.Refs/Entities/Tag.cs
@@ -0,0 +1,31 @@
+using SIL.Harmony.Entities;
+
+namespace SIL.Harmony.Refs.Entities;
+
+///
+/// A named movable pointer to a commit. Display is not unique; identity is .
+/// Polymorphic type name is harmony:tag to avoid colliding with app-domain tag types.
+///
+public class Tag : IObjectBase
+{
+ static string IPolyType.TypeName => "harmony:tag";
+
+ public Guid Id { get; init; }
+ public required string Name { get; set; }
+ public Guid TargetCommitId { get; set; }
+ public DateTimeOffset? DeletedAt { get; set; }
+
+ public Guid[] GetReferences() => [];
+
+ public void RemoveReference(Guid id, CommitBase commit)
+ {
+ }
+
+ public IObjectBase Copy() => new Tag
+ {
+ Id = Id,
+ Name = Name,
+ TargetCommitId = TargetCommitId,
+ DeletedAt = DeletedAt
+ };
+}
diff --git a/src/SIL.Harmony.Refs/HarmonyRefsKernel.cs b/src/SIL.Harmony.Refs/HarmonyRefsKernel.cs
new file mode 100644
index 0000000..96ecc89
--- /dev/null
+++ b/src/SIL.Harmony.Refs/HarmonyRefsKernel.cs
@@ -0,0 +1,43 @@
+using SIL.Harmony.Refs.Changes;
+using SIL.Harmony.Refs.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace SIL.Harmony.Refs;
+
+public static class HarmonyRefsKernel
+{
+ ///
+ /// Registers branch/tag ref entities and change types. Sets a main-line-only materialization filter
+ /// as a fallback when is not used.
+ ///
+ public static CrdtConfig AddHarmonyRefs(this CrdtConfig config)
+ {
+ config.ObjectTypeListBuilder.DefaultAdapter().Add();
+ config.ObjectTypeListBuilder.DefaultAdapter().Add(builder => builder.ToTable("HarmonyTags"));
+ config.ChangeTypeListBuilder.Add();
+ config.ChangeTypeListBuilder.Add();
+ config.ChangeTypeListBuilder.Add();
+ config.ChangeTypeListBuilder.Add();
+ config.CommitMaterializationFilter = MainLineOnlyMaterializationFilter.Instance;
+ return config;
+ }
+
+ ///
+ /// Registers checkout-aware materialization and .
+ /// Prefer this for apps that switch between main and branch views.
+ ///
+ public static IServiceCollection AddHarmonyRefsDataModel(this IServiceCollection services)
+ {
+ services.AddScoped();
+ services.AddScoped(sp =>
+ sp.GetRequiredService());
+ // One handler instance serves both extension points: the authoring interceptor and the
+ // post-apply roll-forward listener.
+ services.AddScoped();
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped(sp => sp.GetRequiredService());
+ services.AddScoped();
+ return services;
+ }
+}
diff --git a/src/SIL.Harmony.Refs/MainLineOnlyMaterializationFilter.cs b/src/SIL.Harmony.Refs/MainLineOnlyMaterializationFilter.cs
new file mode 100644
index 0000000..d67ec94
--- /dev/null
+++ b/src/SIL.Harmony.Refs/MainLineOnlyMaterializationFilter.cs
@@ -0,0 +1,14 @@
+namespace SIL.Harmony.Refs;
+
+///
+/// Materializes only main-line commits (no ).
+/// Used as the fallback when checkout-aware DI is not registered.
+/// Prefer via AddHarmonyRefsDataModel for branch views.
+///
+public sealed class MainLineOnlyMaterializationFilter : ICommitMaterializationFilter
+{
+ public static MainLineOnlyMaterializationFilter Instance { get; } = new();
+
+ public bool Include(Commit commit) =>
+ RefMetadata.GetBranchId(commit.Metadata) is null;
+}
diff --git a/src/SIL.Harmony.Refs/RefCheckout.cs b/src/SIL.Harmony.Refs/RefCheckout.cs
new file mode 100644
index 0000000..9b1a93b
--- /dev/null
+++ b/src/SIL.Harmony.Refs/RefCheckout.cs
@@ -0,0 +1,19 @@
+namespace SIL.Harmony.Refs;
+
+///
+/// Local checkout selection. Not synced; affects authoring defaults and materialization views.
+///
+public abstract record RefCheckout
+{
+ public static RefCheckout Main { get; } = new MainCheckout();
+
+ public static RefCheckout ForBranch(Guid branchId) => new BranchCheckout(branchId);
+
+ public static RefCheckout ForTag(Guid tagId) => new TagCheckout(tagId);
+}
+
+public sealed record MainCheckout : RefCheckout;
+
+public sealed record BranchCheckout(Guid BranchId) : RefCheckout;
+
+public sealed record TagCheckout(Guid TagId) : RefCheckout;
diff --git a/src/SIL.Harmony.Refs/RefMetadata.cs b/src/SIL.Harmony.Refs/RefMetadata.cs
new file mode 100644
index 0000000..5a3346b
--- /dev/null
+++ b/src/SIL.Harmony.Refs/RefMetadata.cs
@@ -0,0 +1,74 @@
+namespace SIL.Harmony.Refs;
+
+///
+/// Well-known keys for the refs layer.
+///
+public static class RefMetadata
+{
+ ///
+ /// Immutable branch assignment for a commit. Absent or empty means main line.
+ ///
+ public const string BranchIdKey = "harmony.branchId";
+
+ ///
+ /// Transient authoring-time marker: distinguishes an explicit assignment (including to main,
+ /// which has no ) from an unassigned commit, so the authoring
+ /// interceptor knows to leave the commit alone rather than derive from the checkout. It is
+ /// consumed (removed) by the interceptor via before the commit
+ /// is persisted, so it never lands in stored or synced metadata.
+ ///
+ private const string BranchAssignedKey = "harmony.branchAssigned";
+
+ public static Guid? GetBranchId(CommitMetadata metadata)
+ {
+ var value = metadata[BranchIdKey];
+ return Guid.TryParse(value, out var id) ? id : null;
+ }
+
+ public static void SetBranchId(CommitMetadata metadata, Guid? branchId)
+ {
+ if (branchId is null)
+ {
+ metadata.ExtraMetadata.Remove(BranchIdKey);
+ return;
+ }
+
+ metadata[BranchIdKey] = branchId.Value.ToString();
+ }
+
+ ///
+ /// Reads and clears the transient assignment marker, returning whether the commit carried a
+ /// deliberate assignment. Called once by the authoring interceptor so the marker is consumed
+ /// before persistence and never synced.
+ ///
+ internal static bool ConsumeAssignment(CommitMetadata metadata) =>
+ metadata.ExtraMetadata.Remove(BranchAssignedKey);
+
+ ///
+ /// Records an explicit per-call branch assignment on so it takes
+ /// precedence over the current checkout when authoring through .
+ /// and mark the commit;
+ /// leaves it unmarked so the checkout is used.
+ /// The marker is transient — the authoring interceptor consumes it, so it never persists.
+ /// Returns the same metadata for fluent use.
+ ///
+ public static CommitMetadata SetAssignment(CommitMetadata metadata, BranchAssignment assignment)
+ {
+ switch (assignment.Kind)
+ {
+ case BranchAssignmentKind.Main:
+ SetBranchId(metadata, null);
+ metadata[BranchAssignedKey] = bool.TrueString;
+ break;
+ case BranchAssignmentKind.Branch:
+ SetBranchId(metadata, assignment.BranchId);
+ metadata[BranchAssignedKey] = bool.TrueString;
+ break;
+ case BranchAssignmentKind.FromCheckout:
+ // Leave unmarked: the interceptor derives the branch id from the checkout.
+ break;
+ }
+
+ return metadata;
+ }
+}
diff --git a/src/SIL.Harmony.Refs/RefsDataModel.cs b/src/SIL.Harmony.Refs/RefsDataModel.cs
new file mode 100644
index 0000000..5fe2978
--- /dev/null
+++ b/src/SIL.Harmony.Refs/RefsDataModel.cs
@@ -0,0 +1,77 @@
+using Microsoft.Extensions.Options;
+using SIL.Harmony.Refs.Changes;
+using SIL.Harmony.Refs.Entities;
+
+namespace SIL.Harmony.Refs;
+
+///
+/// Checkout switching and ref lifecycle over .
+/// Authoring and sync go through directly; the refs handler stamps
+/// branch assignment on commit and rolls tag checkouts forward after apply.
+/// Checkout state lives on (single source of truth);
+/// changing it rematerializes snapshots for the new view.
+///
+public class RefsDataModel(DataModel dataModel, CheckoutMaterializationFilter filter, IOptions config)
+{
+ public DataModel DataModel { get; } = dataModel;
+
+ public RefCheckout Checkout => filter.Checkout;
+
+ ///
+ /// When true, authoring on a tag checkout writes to main. Default is false (rejected).
+ /// Configured via .
+ ///
+ public bool AllowAuthoringOnTagToMain => config.Value.AllowAuthoringOnTagToMain;
+
+ public async Task CheckoutMain()
+ {
+ if (Checkout is MainCheckout && !filter.HasAsOfTip) return;
+ filter.ClearAsOfTip();
+ filter.Checkout = RefCheckout.Main;
+ await DataModel.RegenerateSnapshots();
+ }
+
+ public async Task CheckoutBranch(Guid branchId)
+ {
+ if (Checkout is BranchCheckout current && current.BranchId == branchId && !filter.HasAsOfTip)
+ return;
+
+ filter.ClearAsOfTip();
+ filter.Checkout = RefCheckout.ForBranch(branchId);
+ await DataModel.RegenerateSnapshots();
+ }
+
+ public async Task CheckoutTag(Guid tagId)
+ {
+ var tip = await TagTipResolver.ResolveTagTip(DataModel, tagId);
+ filter.Checkout = RefCheckout.ForTag(tagId);
+ filter.SetAsOfTip(tip);
+ await DataModel.RegenerateSnapshots();
+ }
+
+ public Task CreateBranch(Guid clientId, Guid branchId, string name) =>
+ DataModel.AddChange(clientId, new CreateBranchChange(branchId, name), RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ public Task CreateTag(Guid clientId, Guid tagId, string name, Guid targetCommitId) =>
+ DataModel.AddChange(clientId, new CreateTagChange(tagId, name, targetCommitId),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ public Task MoveTag(Guid clientId, Guid tagId, Guid targetCommitId) =>
+ DataModel.AddChange(clientId, new MoveTagChange(tagId, targetCommitId),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ public IAsyncEnumerable ListBranches() => DataModel.QueryLatest();
+
+ public IAsyncEnumerable ListTags() => DataModel.QueryLatest();
+
+ ///
+ /// Incorporates into main visibility and deletes the branch entity.
+ /// Authored as a main-line commit; materialization expands from the earliest branch commit.
+ ///
+ public async Task MergeBranch(Guid clientId, Guid branchId)
+ {
+ await CheckoutMain();
+ return await DataModel.AddChange(clientId, new MergeBranchChange(branchId),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+ }
+}
diff --git a/src/SIL.Harmony.Refs/SIL.Harmony.Refs.csproj b/src/SIL.Harmony.Refs/SIL.Harmony.Refs.csproj
new file mode 100644
index 0000000..1bebbfd
--- /dev/null
+++ b/src/SIL.Harmony.Refs/SIL.Harmony.Refs.csproj
@@ -0,0 +1,17 @@
+
+
+
+ SIL.Harmony.Refs
+ true
+ Optional branches and tags layer for SIL.Harmony.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SIL.Harmony.Refs/TagTipResolver.cs b/src/SIL.Harmony.Refs/TagTipResolver.cs
new file mode 100644
index 0000000..ed6a307
--- /dev/null
+++ b/src/SIL.Harmony.Refs/TagTipResolver.cs
@@ -0,0 +1,14 @@
+using SIL.Harmony.Refs.Entities;
+
+namespace SIL.Harmony.Refs;
+
+internal static class TagTipResolver
+{
+ public static async Task ResolveTagTip(DataModel dataModel, Guid tagId)
+ {
+ var tag = await dataModel.GetLatest(tagId)
+ ?? throw new InvalidOperationException($"Tag {tagId} was not found.");
+ return await dataModel.GetCommit(tag.TargetCommitId);
+ }
+}
+
diff --git a/src/SIL.Harmony.Tests/CommitMaterializationFilterTests.cs b/src/SIL.Harmony.Tests/CommitMaterializationFilterTests.cs
new file mode 100644
index 0000000..bca227b
--- /dev/null
+++ b/src/SIL.Harmony.Tests/CommitMaterializationFilterTests.cs
@@ -0,0 +1,108 @@
+using SIL.Harmony.Changes;
+using SIL.Harmony.Core;
+using SIL.Harmony.Sample.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace SIL.Harmony.Tests;
+
+public class CommitMaterializationFilterTests : DataModelTestBase
+{
+ public CommitMaterializationFilterTests() : base(configure: services =>
+ {
+ services.Configure(config =>
+ {
+ config.CommitMaterializationFilter = new ExcludeMarkedCommitsFilter();
+ });
+ })
+ {
+ }
+
+ [Fact]
+ public async Task ExcludedCommitDoesNotAffectGetLatest()
+ {
+ var entityId = Guid.NewGuid();
+
+ await DataModel.AddChange(_localClientId, SetWord(entityId, "included"));
+ var excluded = await DataModel.AddChange(
+ _localClientId,
+ SetWord(entityId, "excluded"),
+ new CommitMetadata { ["materialize"] = "false" });
+
+ var word = await DataModel.GetLatest(entityId);
+ word!.Text.Should().Be("included");
+ // Excluded commits are still stored; only materialization skips them
+ DbContext.Commits.Should().Contain(c => c.Id == excluded.Id);
+ }
+
+ [Fact]
+ public async Task LateArrivingIncludedCommitReplaysFilteredHistory()
+ {
+ var entityId = Guid.NewGuid();
+
+ var first = await DataModel.AddChange(_localClientId, SetWord(entityId, "first"));
+ await DataModel.AddChange(
+ _localClientId,
+ SetWord(entityId, "skipped"),
+ new CommitMetadata { ["materialize"] = "false" });
+
+ (await DataModel.GetLatest(entityId))!.Text.Should().Be("first");
+
+ var late = new Commit
+ {
+ ClientId = _localClientId,
+ HybridDateTime = new HybridDateTime(first.DateTime.AddHours(1), 0),
+ Metadata = new CommitMetadata()
+ };
+ late.ChangeEntities.Add(new ChangeEntity
+ {
+ Change = SetWord(entityId, "late"),
+ Index = 0,
+ CommitId = late.Id,
+ EntityId = entityId
+ });
+ await AddCommitsViaSync([late]);
+
+ var word = await DataModel.GetLatest(entityId);
+ // Included apply order: first → late; skipped never materializes
+ word!.Text.Should().Be("late");
+ }
+
+ [Fact]
+ public async Task LateArrivingExcludedCommitDoesNotDuplicateLaterSnapshots()
+ {
+ var entityId = Guid.NewGuid();
+
+ var first = await DataModel.AddChange(_localClientId, SetWord(entityId, "first"));
+ await DataModel.AddChange(_localClientId, SetWord(entityId, "third"));
+ (await DataModel.GetLatest(entityId))!.Text.Should().Be("third");
+ var snapshotCountBefore = DbContext.Snapshots.Count(s => s.EntityId == entityId);
+
+ var excluded = new Commit
+ {
+ ClientId = _localClientId,
+ HybridDateTime = new HybridDateTime(first.DateTime.AddHours(1), 0),
+ Metadata = new CommitMetadata { ["materialize"] = "false" }
+ };
+ excluded.ChangeEntities.Add(new ChangeEntity
+ {
+ Change = SetWord(entityId, "excluded-middle"),
+ Index = 0,
+ CommitId = excluded.Id,
+ EntityId = entityId
+ });
+ await AddCommitsViaSync([excluded]);
+
+ (await DataModel.GetLatest(entityId))!.Text.Should().Be("third");
+ DbContext.Snapshots.Count(s => s.EntityId == entityId).Should().Be(snapshotCountBefore);
+ DbContext.Commits.Should().Contain(c => c.Id == excluded.Id);
+ }
+
+ ///
+ /// Excludes commits marked with ExtraMetadata materialize=false.
+ ///
+ private sealed class ExcludeMarkedCommitsFilter : ICommitMaterializationFilter
+ {
+ public bool Include(Commit commit) =>
+ commit.Metadata["materialize"] != "false";
+ }
+}
diff --git a/src/SIL.Harmony.Tests/DataModelTestBase.cs b/src/SIL.Harmony.Tests/DataModelTestBase.cs
index 80402eb..57b73aa 100644
--- a/src/SIL.Harmony.Tests/DataModelTestBase.cs
+++ b/src/SIL.Harmony.Tests/DataModelTestBase.cs
@@ -20,6 +20,10 @@ public class DataModelTestBase : IAsyncLifetime
public readonly SampleDbContext DbContext;
protected readonly MockTimeProvider MockTimeProvider = new();
+ public Guid LocalClientId => _localClientId;
+
+ public T GetRequiredService() where T : notnull => _services.GetRequiredService();
+
public DataModelTestBase(bool saveToDisk = false, bool alwaysValidate = true,
Action? configure = null, bool performanceTest = false) : this(saveToDisk
? new SqliteConnection("Data Source=test.db")
diff --git a/src/SIL.Harmony.Tests/Refs/BranchCheckoutViewTests.cs b/src/SIL.Harmony.Tests/Refs/BranchCheckoutViewTests.cs
new file mode 100644
index 0000000..70c1565
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/BranchCheckoutViewTests.cs
@@ -0,0 +1,96 @@
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Sample.Models;
+
+namespace SIL.Harmony.Tests.Refs;
+
+public class BranchCheckoutViewTests : DataModelTestBase
+{
+ private readonly RefsDataModel _refs;
+
+ public BranchCheckoutViewTests() : base(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ })
+ {
+ _refs = _services.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task BranchCheckoutShowsMainPlusBranchCommits()
+ {
+ var branchId = Guid.NewGuid();
+ var wordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature");
+ await DataModel.AddChange(_localClientId, SetWord(wordId, "main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ await _refs.CheckoutBranch(branchId);
+ await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch"));
+
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("on-branch");
+
+ await _refs.CheckoutMain();
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("main");
+ }
+
+ [Fact]
+ public async Task OtherBranchCommitsStayInvisible()
+ {
+ var featureId = Guid.NewGuid();
+ var otherId = Guid.NewGuid();
+ var featureWordId = Guid.NewGuid();
+ var otherWordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, featureId, "feature");
+ await _refs.CreateBranch(_localClientId, otherId, "other");
+
+ await _refs.CheckoutBranch(featureId);
+ await DataModel.AddChange(_localClientId, SetWord(featureWordId, "feature-word"));
+
+ await _refs.CheckoutBranch(otherId);
+ await DataModel.AddChange(_localClientId, SetWord(otherWordId, "other-word"));
+
+ await _refs.CheckoutBranch(featureId);
+ (await DataModel.GetLatest(featureWordId))!.Text.Should().Be("feature-word");
+ (await DataModel.GetLatest(otherWordId)).Should().BeNull();
+ }
+
+ [Fact]
+ public async Task BranchAndMainCommitsInterleaveByAuthorTime()
+ {
+ var branchId = Guid.NewGuid();
+ var earlyId = Guid.NewGuid();
+ var lateId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature");
+
+ // t1 main
+ await DataModel.AddChange(_localClientId, SetWord(earlyId, "early-main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+ await _refs.CheckoutBranch(branchId);
+ // t2 branch (between main commits chronologically once late main is written)
+ await DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "mid-branch"));
+ await _refs.CheckoutMain();
+ // t3 main
+ await DataModel.AddChange(_localClientId, SetWord(lateId, "late-main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ await _refs.CheckoutBranch(branchId);
+ var words = DataModel.QueryLatest()
+ .ToBlockingEnumerable(TestContext.Current.CancellationToken)
+ .Select(w => w.Text)
+ .OrderBy(t => t)
+ .ToArray();
+
+ // All three domain commits visible on branch; main-only would miss mid-branch
+ words.Should().BeEquivalentTo(["early-main", "late-main", "mid-branch"]);
+
+ await _refs.CheckoutMain();
+ var mainWords = DataModel.QueryLatest()
+ .ToBlockingEnumerable(TestContext.Current.CancellationToken)
+ .Select(w => w.Text)
+ .OrderBy(t => t)
+ .ToArray();
+ mainWords.Should().BeEquivalentTo(["early-main", "late-main"]);
+ }
+}
diff --git a/src/SIL.Harmony.Tests/Refs/CreateBranchTests.cs b/src/SIL.Harmony.Tests/Refs/CreateBranchTests.cs
new file mode 100644
index 0000000..c5b7fd2
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/CreateBranchTests.cs
@@ -0,0 +1,51 @@
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Refs.Entities;
+
+namespace SIL.Harmony.Tests.Refs;
+
+public class CreateBranchTests : DataModelTestBase
+{
+ private readonly RefsDataModel _refs;
+
+ public CreateBranchTests() : base(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ })
+ {
+ _refs = _services.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task CanCreateAndReadBranch()
+ {
+ var branchId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature-x");
+
+ var branch = await DataModel.GetLatest(branchId);
+ branch.Should().NotBeNull();
+ branch!.Id.Should().Be(branchId);
+ branch.Name.Should().Be("feature-x");
+ }
+
+ [Fact]
+ public async Task DuplicateBranchNamesAreAllowed()
+ {
+ var firstId = Guid.NewGuid();
+ var secondId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, firstId, "dup");
+ await _refs.CreateBranch(_localClientId, secondId, "dup");
+
+ var first = await DataModel.GetLatest(firstId);
+ var second = await DataModel.GetLatest(secondId);
+ first!.Name.Should().Be("dup");
+ second!.Name.Should().Be("dup");
+ first.Id.Should().NotBe(second.Id);
+
+ var both = _refs.ListBranches()
+ .ToBlockingEnumerable(TestContext.Current.CancellationToken)
+ .ToArray();
+ both.Should().HaveCount(2);
+ }
+}
diff --git a/src/SIL.Harmony.Tests/Refs/CreateTagTests.cs b/src/SIL.Harmony.Tests/Refs/CreateTagTests.cs
new file mode 100644
index 0000000..5cac3d6
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/CreateTagTests.cs
@@ -0,0 +1,51 @@
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Refs.Entities;
+
+namespace SIL.Harmony.Tests.Refs;
+
+public class CreateTagTests : DataModelTestBase
+{
+ private readonly RefsDataModel _refs;
+
+ public CreateTagTests() : base(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ })
+ {
+ _refs = _services.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task CanCreateAndReadTag()
+ {
+ var wordId = Guid.NewGuid();
+ var tip = await DataModel.AddChange(_localClientId, SetWord(wordId, "at-tip"));
+ var tagId = Guid.NewGuid();
+
+ await _refs.CreateTag(_localClientId, tagId, "release", tip.Id);
+
+ var tag = await DataModel.GetLatest(tagId);
+ tag.Should().NotBeNull();
+ tag!.Id.Should().Be(tagId);
+ tag.Name.Should().Be("release");
+ tag.TargetCommitId.Should().Be(tip.Id);
+ }
+
+ [Fact]
+ public async Task DuplicateTagNamesAreAllowed()
+ {
+ var tip = await DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "x"));
+ var firstId = Guid.NewGuid();
+ var secondId = Guid.NewGuid();
+ await _refs.CreateTag(_localClientId, firstId, "dup", tip.Id);
+ await _refs.CreateTag(_localClientId, secondId, "dup", tip.Id);
+
+ var both = _refs.ListTags()
+ .ToBlockingEnumerable(TestContext.Current.CancellationToken)
+ .ToArray();
+ both.Should().HaveCount(2);
+ both.Select(t => t.Name).Should().AllBe("dup");
+ }
+}
diff --git a/src/SIL.Harmony.Tests/Refs/MergeBranchTests.cs b/src/SIL.Harmony.Tests/Refs/MergeBranchTests.cs
new file mode 100644
index 0000000..2a23d08
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/MergeBranchTests.cs
@@ -0,0 +1,107 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Changes;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Refs.Changes;
+using SIL.Harmony.Refs.Entities;
+using SIL.Harmony.Sample.Models;
+
+namespace SIL.Harmony.Tests.Refs;
+
+public class MergeBranchTests : DataModelTestBase
+{
+ private readonly RefsDataModel _refs;
+
+ public MergeBranchTests() : base(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ })
+ {
+ _refs = _services.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task MergeInterleavesBranchCommitsIntoMainByAuthorTime()
+ {
+ var branchId = Guid.NewGuid();
+ var wordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature");
+
+ // main: 1, 2, 3 — branch: A, B, C interleaved by time on the same entity
+ await AddAt(_localClientId, NextDate(), SetWord(wordId, "1"));
+ await _refs.CheckoutBranch(branchId);
+ await AddAt(_localClientId, NextDate(), SetWord(wordId, "A"), BranchMeta(branchId));
+ await _refs.CheckoutMain();
+ await AddAt(_localClientId, NextDate(), SetWord(wordId, "2"));
+ await _refs.CheckoutBranch(branchId);
+ await AddAt(_localClientId, NextDate(), SetWord(wordId, "B"), BranchMeta(branchId));
+ await _refs.CheckoutMain();
+ await AddAt(_localClientId, NextDate(), SetWord(wordId, "3"));
+ await _refs.CheckoutBranch(branchId);
+ await AddAt(_localClientId, NextDate(), SetWord(wordId, "C"), BranchMeta(branchId));
+
+ await _refs.CheckoutMain();
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("3");
+
+ var merge = await _refs.MergeBranch(_localClientId, branchId);
+ RefMetadata.GetBranchId(merge.Metadata).Should().BeNull();
+ merge.ChangeEntities.Should().ContainSingle(ce => ce.Change is MergeBranchChange);
+
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("C");
+
+ var branchScoped = await DbContext.Commits.AsNoTracking()
+ .ToListAsync(TestContext.Current.CancellationToken);
+ branchScoped.Where(c => RefMetadata.GetBranchId(c.Metadata) == branchId)
+ .Should().HaveCount(3);
+
+ var deleted = await DataModel.GetLatest(branchId);
+ deleted!.DeletedAt.Should().NotBeNull();
+ DataModel.QueryLatest().ToBlockingEnumerable(TestContext.Current.CancellationToken)
+ .Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task MergeExpandsReplayFromEarliestBranchCommit()
+ {
+ var branchId = Guid.NewGuid();
+ var earlyId = Guid.NewGuid();
+ var lateId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature");
+
+ await _refs.CheckoutBranch(branchId);
+ var earlyBranch = await AddAt(_localClientId, NextDate(), SetWord(earlyId, "early-branch"),
+ BranchMeta(branchId));
+ await _refs.CheckoutMain();
+ await AddAt(_localClientId, NextDate(), SetWord(lateId, "late-main"));
+
+ (await DataModel.GetLatest(earlyId)).Should().BeNull();
+ (await DataModel.GetLatest(lateId))!.Text.Should().Be("late-main");
+
+ await _refs.MergeBranch(_localClientId, branchId);
+
+ (await DataModel.GetLatest(earlyId))!.Text.Should().Be("early-branch");
+ (await DataModel.GetLatest(lateId))!.Text.Should().Be("late-main");
+
+ var storedEarly = await DbContext.Commits.AsNoTracking()
+ .SingleAsync(c => c.Id == earlyBranch.Id, TestContext.Current.CancellationToken);
+ RefMetadata.GetBranchId(storedEarly.Metadata).Should().Be(branchId);
+ }
+
+ private static CommitMetadata BranchMeta(Guid branchId)
+ {
+ var metadata = new CommitMetadata();
+ RefMetadata.SetBranchId(metadata, branchId);
+ return metadata;
+ }
+
+ private async Task AddAt(
+ Guid clientId,
+ DateTimeOffset dateTime,
+ IChange change,
+ CommitMetadata? commitMetadata = null)
+ {
+ MockTimeProvider.SetNextDateTime(dateTime);
+ return await DataModel.AddChange(clientId, change, commitMetadata);
+ }
+}
diff --git a/src/SIL.Harmony.Tests/Refs/ScopedAuthoringMainIsolationTests.cs b/src/SIL.Harmony.Tests/Refs/ScopedAuthoringMainIsolationTests.cs
new file mode 100644
index 0000000..27654e4
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/ScopedAuthoringMainIsolationTests.cs
@@ -0,0 +1,82 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Sample.Models;
+
+namespace SIL.Harmony.Tests.Refs;
+
+public class ScopedAuthoringMainIsolationTests : DataModelTestBase
+{
+ private readonly RefsDataModel _refs;
+
+ public ScopedAuthoringMainIsolationTests() : base(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ })
+ {
+ _refs = _services.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task BranchAuthoringIsHiddenOnMainCheckout()
+ {
+ var branchId = Guid.NewGuid();
+ var wordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature");
+
+ await _refs.CheckoutBranch(branchId);
+ await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch"));
+
+ await _refs.CheckoutMain();
+ var word = await DataModel.GetLatest(wordId);
+ word.Should().BeNull();
+ DataModel.QueryLatest().ToBlockingEnumerable(TestContext.Current.CancellationToken)
+ .Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task BranchAuthoringSetsImmutableBranchMetadata()
+ {
+ var branchId = Guid.NewGuid();
+ var wordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature");
+
+ await _refs.CheckoutBranch(branchId);
+ var commit = await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch"));
+
+ RefMetadata.GetBranchId(commit.Metadata).Should().Be(branchId);
+
+ // later commits do not rewrite earlier assignment
+ await DataModel.AddChange(_localClientId, SetWord(wordId, "again"));
+ var stored = await DbContext.Commits.AsNoTracking().SingleAsync(c => c.Id == commit.Id, TestContext.Current.CancellationToken);
+ RefMetadata.GetBranchId(stored.Metadata).Should().Be(branchId);
+ }
+
+ [Fact]
+ public async Task AuthoringOverrideCanForceMainOrAnotherBranch()
+ {
+ var featureId = Guid.NewGuid();
+ var otherId = Guid.NewGuid();
+ var mainWordId = Guid.NewGuid();
+ var otherWordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, featureId, "feature");
+ await _refs.CreateBranch(_localClientId, otherId, "other");
+
+ await _refs.CheckoutBranch(featureId);
+
+ var mainCommit = await DataModel.AddChange(
+ _localClientId,
+ SetWord(mainWordId, "forced-main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+ RefMetadata.GetBranchId(mainCommit.Metadata).Should().BeNull();
+ (await DataModel.GetLatest(mainWordId))!.Text.Should().Be("forced-main");
+
+ var otherCommit = await DataModel.AddChange(
+ _localClientId,
+ SetWord(otherWordId, "other-branch"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.ToBranch(otherId)));
+ RefMetadata.GetBranchId(otherCommit.Metadata).Should().Be(otherId);
+ (await DataModel.GetLatest(otherWordId)).Should().BeNull();
+ }
+}
diff --git a/src/SIL.Harmony.Tests/Refs/TagCheckoutTests.cs b/src/SIL.Harmony.Tests/Refs/TagCheckoutTests.cs
new file mode 100644
index 0000000..e9f4273
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/TagCheckoutTests.cs
@@ -0,0 +1,120 @@
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Sample.Models;
+using RefTag = SIL.Harmony.Refs.Entities.Tag;
+
+namespace SIL.Harmony.Tests.Refs;
+
+public class TagCheckoutTests : DataModelTestBase
+{
+ private readonly RefsDataModel _refs;
+
+ public TagCheckoutTests() : base(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ })
+ {
+ _refs = _services.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task TagCheckoutShowsStateAsOfTip()
+ {
+ var wordId = Guid.NewGuid();
+ var tip = await DataModel.AddChange(_localClientId, SetWord(wordId, "at-tag"));
+ var tagId = Guid.NewGuid();
+ await _refs.CreateTag(_localClientId, tagId, "v1", tip.Id);
+ await DataModel.AddChange(_localClientId, SetWord(wordId, "after-tag"));
+
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("after-tag");
+
+ await _refs.CheckoutTag(tagId);
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("at-tag");
+
+ await _refs.CheckoutMain();
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("after-tag");
+ }
+
+ [Fact]
+ public async Task AuthoringOnTagCheckoutIsRejectedByDefault()
+ {
+ var tip = await DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "x"));
+ var tagId = Guid.NewGuid();
+ await _refs.CreateTag(_localClientId, tagId, "v1", tip.Id);
+ await _refs.CheckoutTag(tagId);
+
+ var act = () => DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "nope"));
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task MoveTagWhileCheckedOutRematerializes()
+ {
+ var wordId = Guid.NewGuid();
+ var first = await DataModel.AddChange(_localClientId, SetWord(wordId, "first"));
+ var second = await DataModel.AddChange(_localClientId, SetWord(wordId, "second"));
+ var tagId = Guid.NewGuid();
+ await _refs.CreateTag(_localClientId, tagId, "moving", first.Id);
+
+ await _refs.CheckoutTag(tagId);
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("first");
+
+ await _refs.MoveTag(_localClientId, tagId, second.Id);
+
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("second");
+ (await DataModel.GetLatest(tagId))!.TargetCommitId.Should().Be(second.Id);
+ _refs.Checkout.Should().BeOfType().Which.TagId.Should().Be(tagId);
+ }
+
+ [Fact]
+ public async Task AuthoringOnTagCheckoutWritesToMainWhenConfigured()
+ {
+ // Fresh model with AllowAuthoringOnTagToMain enabled via CrdtConfig.
+ await using var withConfig = new DataModelTestBase(configure: services =>
+ {
+ services.Configure(config =>
+ {
+ config.AddHarmonyRefs();
+ config.AllowAuthoringOnTagToMain = true;
+ });
+ services.AddHarmonyRefsDataModel();
+ });
+ var refs = withConfig.GetRequiredService();
+ refs.AllowAuthoringOnTagToMain.Should().BeTrue();
+
+ var tip = await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(Guid.NewGuid(), "at-tag"));
+ var tagId = Guid.NewGuid();
+ await refs.CreateTag(withConfig.LocalClientId, tagId, "v1", tip.Id);
+ await refs.CheckoutTag(tagId);
+
+ var newWordId = Guid.NewGuid();
+ await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(newWordId, "authored-to-main"));
+
+ // The change was authored to main (no branch id), so it is visible on the main checkout.
+ await refs.CheckoutMain();
+ (await withConfig.DataModel.GetLatest(newWordId))!.Text.Should().Be("authored-to-main");
+ }
+
+ [Fact]
+ public async Task TagOnBranchTipShowsBranchViewAsOfTip()
+ {
+ var branchId = Guid.NewGuid();
+ var wordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature");
+ await DataModel.AddChange(_localClientId, SetWord(wordId, "main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ await _refs.CheckoutBranch(branchId);
+ var branchTip = await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch"));
+
+ var tagId = Guid.NewGuid();
+ await _refs.CheckoutMain();
+ await _refs.CreateTag(_localClientId, tagId, "wip", branchTip.Id);
+ await DataModel.AddChange(_localClientId, SetWord(wordId, "later-main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ await _refs.CheckoutTag(tagId);
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("on-branch");
+ }
+}
diff --git a/src/SIL.Harmony.Tests/Refs/TagRollForwardListenerTests.cs b/src/SIL.Harmony.Tests/Refs/TagRollForwardListenerTests.cs
new file mode 100644
index 0000000..39d49e0
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/TagRollForwardListenerTests.cs
@@ -0,0 +1,190 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Refs.Changes;
+using SIL.Harmony.Sample.Models;
+
+namespace SIL.Harmony.Tests.Refs;
+
+///
+/// Ticket 05: with refs registered, an active tag checkout rolls forward automatically after any
+/// apply — through for a synced tag move and through
+/// for a local one — driven by the post-apply listener rather than
+/// a wrapper call. Roll-forward is skipped when the tip did not move.
+///
+public class TagRollForwardListenerTests : IAsyncLifetime
+{
+ private readonly DataModelTestBase _client1;
+ private readonly DataModelTestBase _client2;
+ private readonly RefsDataModel _refs1;
+ private readonly RefsDataModel _refs2;
+
+ public TagRollForwardListenerTests()
+ {
+ static void Configure(IServiceCollection services)
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ }
+
+ _client1 = new DataModelTestBase(configure: Configure);
+ _client2 = new DataModelTestBase(configure: Configure);
+ _refs1 = _client1.GetRequiredService();
+ _refs2 = _client2.GetRequiredService();
+ }
+
+ public async ValueTask InitializeAsync()
+ {
+ await _client1.InitializeAsync();
+ await _client2.InitializeAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _client1.DisposeAsync();
+ await _client2.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task SyncedTagMoveRollsForwardActiveCheckout_ViaDataModelSyncWith()
+ {
+ var wordId = Guid.NewGuid();
+ var first = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "first"));
+ var second = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "second"));
+ var tagId = Guid.NewGuid();
+ await _refs1.CreateTag(_client1.LocalClientId, tagId, "release", first.Id);
+
+ await _client1.DataModel.SyncWith(_client2.DataModel);
+
+ await _refs2.CheckoutTag(tagId);
+ (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("first");
+
+ // Move the tag on client1, then sync through the plain DataModel API — the listener, not a
+ // RefsDataModel.SyncWith wrapper, must roll client2's active tag view forward.
+ await _refs1.MoveTag(_client1.LocalClientId, tagId, second.Id);
+ await _client2.DataModel.SyncWith(_client1.DataModel);
+
+ (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("second");
+ }
+
+ [Fact]
+ public async Task LocalTagMoveRollsForwardActiveCheckout_ViaListener()
+ {
+ var wordId = Guid.NewGuid();
+ var first = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "first"));
+ var second = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "second"));
+ var tagId = Guid.NewGuid();
+ await _refs1.CreateTag(_client1.LocalClientId, tagId, "release", first.Id);
+
+ await _refs1.CheckoutTag(tagId);
+ (await _client1.DataModel.GetLatest(wordId))!.Text.Should().Be("first");
+
+ // Author the move straight through DataModel (explicit main assignment, so it is allowed on a
+ // tag checkout and lands on main). Only the post-apply listener can roll the view forward here.
+ await _client1.DataModel.AddChange(
+ _client1.LocalClientId,
+ new MoveTagChange(tagId, second.Id),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ (await _client1.DataModel.GetLatest(wordId))!.Text.Should().Be("second");
+ }
+
+ [Fact(Timeout = 30_000)]
+ public async Task LocalTagMoveRollsForwardOnPersistentDatabase()
+ {
+ // A file-backed database uses a stable, shared apply lock (unlike :memory:, which normalizes
+ // to a fresh lock per repository). The lock is not reentrant, so if the post-apply
+ // notification fired while the apply lock was held, the listener's roll-forward would deadlock
+ // trying to take that same lock. This exercises that path; a regression would hang and trip
+ // the timeout.
+ var dbPath = Path.Combine(Path.GetTempPath(), $"harmony-rollforward-{Guid.NewGuid():N}.db");
+ var connection = new SqliteConnection($"Data Source={dbPath}");
+ var client = new DataModelTestBase(connection, configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ });
+ try
+ {
+ var refs = client.GetRequiredService();
+
+ var wordId = Guid.NewGuid();
+ var first = await client.DataModel.AddChange(client.LocalClientId, client.SetWord(wordId, "first"));
+ var second = await client.DataModel.AddChange(client.LocalClientId, client.SetWord(wordId, "second"));
+ var tagId = Guid.NewGuid();
+ await refs.CreateTag(client.LocalClientId, tagId, "release", first.Id);
+
+ await refs.CheckoutTag(tagId);
+ (await client.DataModel.GetLatest(wordId))!.Text.Should().Be("first");
+
+ await client.DataModel.AddChange(
+ client.LocalClientId,
+ new MoveTagChange(tagId, second.Id),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ (await client.DataModel.GetLatest(wordId))!.Text.Should().Be("second");
+ }
+ finally
+ {
+ await client.DisposeAsync();
+ SqliteConnection.ClearAllPools();
+ if (File.Exists(dbPath)) File.Delete(dbPath);
+ }
+ }
+
+ [Fact]
+ public async Task NoRollForwardWhenActiveTagTipDidNotMove()
+ {
+ var wordId = Guid.NewGuid();
+ var laterId = Guid.NewGuid();
+ var first = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "first"));
+ var tagId = Guid.NewGuid();
+ await _refs1.CreateTag(_client1.LocalClientId, tagId, "release", first.Id);
+
+ await _refs1.CheckoutTag(tagId);
+ (await _client1.DataModel.GetLatest(wordId))!.Text.Should().Be("first");
+
+ // A main-line commit that does not touch the tag: the tip is unchanged, so the view stays
+ // pinned at the tag tip and the new commit remains invisible in the tag view.
+ await _client1.DataModel.AddChange(
+ _client1.LocalClientId,
+ _client1.SetWord(laterId, "after-tip"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ (await _client1.DataModel.GetLatest(wordId))!.Text.Should().Be("first");
+ (await _client1.DataModel.GetLatest(laterId)).Should().BeNull();
+ }
+}
+
+///
+/// Regression: with refs not registered, 's apply paths (local author + sync)
+/// behave exactly as before — no listener runs.
+///
+public class CoreOnlyApplyTests : IAsyncLifetime
+{
+ private readonly DataModelTestBase _client1 = new();
+ private readonly DataModelTestBase _client2 = new();
+
+ public async ValueTask InitializeAsync()
+ {
+ await _client1.InitializeAsync();
+ await _client2.InitializeAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _client1.DisposeAsync();
+ await _client2.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task AuthorAndSyncBehaveAsBeforeWithoutRefs()
+ {
+ var wordId = Guid.NewGuid();
+ await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "hello"));
+
+ await _client1.DataModel.SyncWith(_client2.DataModel);
+
+ (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("hello");
+ }
+}
diff --git a/src/SIL.Harmony.Tests/Refs/TagSyncRollForwardTests.cs b/src/SIL.Harmony.Tests/Refs/TagSyncRollForwardTests.cs
new file mode 100644
index 0000000..bcf7a62
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/TagSyncRollForwardTests.cs
@@ -0,0 +1,59 @@
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Sample.Models;
+
+namespace SIL.Harmony.Tests.Refs;
+
+public class TagSyncRollForwardTests : IAsyncLifetime
+{
+ private readonly DataModelTestBase _client1;
+ private readonly DataModelTestBase _client2;
+ private readonly RefsDataModel _refs1;
+ private readonly RefsDataModel _refs2;
+
+ public TagSyncRollForwardTests()
+ {
+ static void Configure(IServiceCollection services)
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ }
+
+ _client1 = new DataModelTestBase(configure: Configure);
+ _client2 = new DataModelTestBase(configure: Configure);
+ _refs1 = _client1.GetRequiredService();
+ _refs2 = _client2.GetRequiredService();
+ }
+
+ public async ValueTask InitializeAsync()
+ {
+ await _client1.InitializeAsync();
+ await _client2.InitializeAsync();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _client1.DisposeAsync();
+ await _client2.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task SyncMoveTagRollsForwardActiveCheckout()
+ {
+ var wordId = Guid.NewGuid();
+ var first = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "first"));
+ var second = await _client1.DataModel.AddChange(_client1.LocalClientId, _client1.SetWord(wordId, "second"));
+ var tagId = Guid.NewGuid();
+ await _refs1.CreateTag(_client1.LocalClientId, tagId, "release", first.Id);
+
+ await _client1.DataModel.SyncWith(_client2.DataModel);
+
+ await _refs2.CheckoutTag(tagId);
+ (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("first");
+
+ await _refs1.MoveTag(_client1.LocalClientId, tagId, second.Id);
+ await _client2.DataModel.SyncWith(_client1.DataModel);
+
+ (await _client2.DataModel.GetLatest(wordId))!.Text.Should().Be("second");
+ }
+}
diff --git a/src/SIL.Harmony.Tests/Refs/TransparentAuthoringTests.cs b/src/SIL.Harmony.Tests/Refs/TransparentAuthoringTests.cs
new file mode 100644
index 0000000..a515264
--- /dev/null
+++ b/src/SIL.Harmony.Tests/Refs/TransparentAuthoringTests.cs
@@ -0,0 +1,210 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SIL.Harmony.Refs;
+using SIL.Harmony.Sample.Models;
+
+namespace SIL.Harmony.Tests.Refs;
+
+///
+/// Ticket 04: authoring through directly (no RefsDataModel
+/// wrapper) transparently applies the current checkout's branch assignment, and a
+/// per-call override via takes precedence.
+///
+public class TransparentAuthoringTests : DataModelTestBase
+{
+ private readonly RefsDataModel _refs;
+
+ public TransparentAuthoringTests() : base(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ })
+ {
+ _refs = _services.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task DirectAuthoringOnBranchCheckoutStampsBranchAndIsHiddenOnMain()
+ {
+ var branchId = Guid.NewGuid();
+ var wordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, branchId, "feature");
+
+ await _refs.CheckoutBranch(branchId);
+ var commit = await DataModel.AddChange(_localClientId, SetWord(wordId, "on-branch"));
+
+ RefMetadata.GetBranchId(commit.Metadata).Should().Be(branchId);
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("on-branch");
+
+ await _refs.CheckoutMain();
+ (await DataModel.GetLatest(wordId)).Should().BeNull();
+ }
+
+ [Fact]
+ public async Task DirectAuthoringOnMainCheckoutAuthorsToMain()
+ {
+ var wordId = Guid.NewGuid();
+ var commit = await DataModel.AddChange(_localClientId, SetWord(wordId, "on-main"));
+
+ RefMetadata.GetBranchId(commit.Metadata).Should().BeNull();
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("on-main");
+ }
+
+ [Fact]
+ public async Task DirectAuthoringOnTagCheckoutThrowsByDefault()
+ {
+ var tip = await DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "at-tag"));
+ var tagId = Guid.NewGuid();
+ await _refs.CreateTag(_localClientId, tagId, "v1", tip.Id);
+ await _refs.CheckoutTag(tagId);
+
+ var act = () => DataModel.AddChange(_localClientId, SetWord(Guid.NewGuid(), "nope"));
+ await act.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task DirectAuthoringOnTagCheckoutWritesToMainWhenConfigured()
+ {
+ await using var withConfig = new DataModelTestBase(configure: services =>
+ {
+ services.Configure(config =>
+ {
+ config.AddHarmonyRefs();
+ config.AllowAuthoringOnTagToMain = true;
+ });
+ services.AddHarmonyRefsDataModel();
+ });
+ var refs = withConfig.GetRequiredService();
+
+ var tip = await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(Guid.NewGuid(), "at-tag"));
+ var tagId = Guid.NewGuid();
+ await refs.CreateTag(withConfig.LocalClientId, tagId, "v1", tip.Id);
+ await refs.CheckoutTag(tagId);
+
+ var newWordId = Guid.NewGuid();
+ var commit = await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(newWordId, "authored-to-main"));
+ RefMetadata.GetBranchId(commit.Metadata).Should().BeNull();
+
+ await refs.CheckoutMain();
+ (await withConfig.DataModel.GetLatest(newWordId))!.Text.Should().Be("authored-to-main");
+ }
+
+ [Fact]
+ public async Task PerCallOverrideForcesMainOrAnotherBranchRegardlessOfCheckout()
+ {
+ var featureId = Guid.NewGuid();
+ var otherId = Guid.NewGuid();
+ var mainWordId = Guid.NewGuid();
+ var otherWordId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, featureId, "feature");
+ await _refs.CreateBranch(_localClientId, otherId, "other");
+
+ await _refs.CheckoutBranch(featureId);
+
+ var mainCommit = await DataModel.AddChange(
+ _localClientId,
+ SetWord(mainWordId, "forced-main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+ RefMetadata.GetBranchId(mainCommit.Metadata).Should().BeNull();
+ (await DataModel.GetLatest(mainWordId))!.Text.Should().Be("forced-main");
+
+ var otherCommit = await DataModel.AddChange(
+ _localClientId,
+ SetWord(otherWordId, "other-branch"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.ToBranch(otherId)));
+ RefMetadata.GetBranchId(otherCommit.Metadata).Should().Be(otherId);
+ (await DataModel.GetLatest(otherWordId)).Should().BeNull();
+ }
+
+ [Fact]
+ public async Task AllRegisteredInterceptorsAreInvoked()
+ {
+ await using var tb = new DataModelTestBase(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ services.AddScoped();
+ services.AddScoped(sp => sp.GetRequiredService());
+ });
+ var recording = tb.GetRequiredService();
+ var refs = tb.GetRequiredService();
+
+ var branchId = Guid.NewGuid();
+ await refs.CreateBranch(tb.LocalClientId, branchId, "feature");
+ await refs.CheckoutBranch(branchId);
+ var commit = await tb.DataModel.AddChange(tb.LocalClientId, tb.SetWord(Guid.NewGuid(), "x"));
+
+ // The extra interceptor ran alongside the checkout interceptor...
+ recording.Count.Should().BeGreaterThan(0);
+ // ...and the checkout interceptor still applied the branch assignment.
+ RefMetadata.GetBranchId(commit.Metadata).Should().Be(branchId);
+ }
+
+ [Fact]
+ public async Task ExplicitAssignmentMarkerIsNotPersisted()
+ {
+ var featureId = Guid.NewGuid();
+ await _refs.CreateBranch(_localClientId, featureId, "feature");
+ await _refs.CheckoutBranch(featureId);
+
+ var wordId = Guid.NewGuid();
+ var commit = await DataModel.AddChange(
+ _localClientId,
+ SetWord(wordId, "forced-main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+
+ // Explicit main leaves no branch id and no transient marker behind.
+ var stored = await DbContext.Commits.AsNoTracking()
+ .SingleAsync(c => c.Id == commit.Id, TestContext.Current.CancellationToken);
+ RefMetadata.GetBranchId(stored.Metadata).Should().BeNull();
+ stored.Metadata.ExtraMetadata.Should().BeEmpty();
+ }
+
+ private sealed class RecordingInterceptor : ICommitInterceptor
+ {
+ public int Count { get; private set; }
+ public void OnCommitAuthored(Commit commit) => Count++;
+ }
+
+ [Fact]
+ public async Task PerCallOverrideToMainOnTagCheckoutDoesNotThrow()
+ {
+ await using var withConfig = new DataModelTestBase(configure: services =>
+ {
+ services.Configure(config => config.AddHarmonyRefs());
+ services.AddHarmonyRefsDataModel();
+ });
+ var refs = withConfig.GetRequiredService();
+
+ var tip = await withConfig.DataModel.AddChange(withConfig.LocalClientId, withConfig.SetWord(Guid.NewGuid(), "at-tag"));
+ var tagId = Guid.NewGuid();
+ await refs.CreateTag(withConfig.LocalClientId, tagId, "v1", tip.Id);
+ await refs.CheckoutTag(tagId);
+
+ var wordId = Guid.NewGuid();
+ // Explicit main assignment bypasses the tag-authoring rejection even though
+ // AllowAuthoringOnTagToMain is false, because the caller opted in per-call.
+ var commit = await withConfig.DataModel.AddChange(
+ withConfig.LocalClientId,
+ withConfig.SetWord(wordId, "explicit-main"),
+ RefMetadata.SetAssignment(new(), BranchAssignment.Main));
+ RefMetadata.GetBranchId(commit.Metadata).Should().BeNull();
+ }
+}
+
+///
+/// Regression: with refs not registered, authoring through
+/// behaves exactly as before — no interceptor, no branch metadata.
+///
+public class CoreOnlyAuthoringTests : DataModelTestBase
+{
+ [Fact]
+ public async Task AuthoringWithoutRefsAppliesNoBranchMetadata()
+ {
+ var wordId = Guid.NewGuid();
+ var commit = await DataModel.AddChange(_localClientId, SetWord(wordId, "plain"));
+
+ commit.Metadata.ExtraMetadata.Should().BeEmpty();
+ (await DataModel.GetLatest(wordId))!.Text.Should().Be("plain");
+ }
+}
diff --git a/src/SIL.Harmony.Tests/RepositoryTests.cs b/src/SIL.Harmony.Tests/RepositoryTests.cs
index 3a697ea..4d50832 100644
--- a/src/SIL.Harmony.Tests/RepositoryTests.cs
+++ b/src/SIL.Harmony.Tests/RepositoryTests.cs
@@ -242,6 +242,48 @@ await _repository.AddSnapshots([
commit.Id.Should().Be(commitIds[1], $"commit order: [{string.Join(", ", commitIds)}]");
}
+ [Fact]
+ public async Task GetEntityIdsForChangeType_ReturnsAllMatchingChangesWhenUnscoped()
+ {
+ var commit1 = Commit(Guid.NewGuid(), Time(1, 0));
+ var commit2 = Commit(Guid.NewGuid(), Time(2, 0));
+ var commit3 = Commit(Guid.NewGuid(), Time(3, 0));
+ await _repository.AddCommits([commit1, commit2, commit3]);
+
+ var ids = await _repository.GetEntityIdsForChangeType();
+
+ ids.Should().BeEquivalentTo(new[]
+ {
+ commit1.ChangeEntities[0].EntityId,
+ commit2.ChangeEntities[0].EntityId,
+ commit3.ChangeEntities[0].EntityId,
+ });
+ }
+
+ [Fact]
+ public async Task GetEntityIdsForChangeType_ScopedRepoIgnoresChangesAfterTip()
+ {
+ var commit1 = Commit(Guid.NewGuid(), Time(1, 0));
+ var commit2 = Commit(Guid.NewGuid(), Time(2, 0));
+ var commit3 = Commit(Guid.NewGuid(), Time(3, 0));
+ await _repository.AddCommits([commit1, commit2, commit3]);
+
+ var scopedIds = await _repository.GetScopedRepository(commit2)
+ .GetEntityIdsForChangeType();
+
+ // commit2 is the tip: at-or-before is included (inclusive), the later commit3 change is not.
+ scopedIds.Should().BeEquivalentTo(new[]
+ {
+ commit1.ChangeEntities[0].EntityId,
+ commit2.ChangeEntities[0].EntityId,
+ });
+ scopedIds.Should().NotContain(commit3.ChangeEntities[0].EntityId);
+
+ // the unscoped repo still sees the post-tip change.
+ var allIds = await _repository.GetEntityIdsForChangeType();
+ allIds.Should().Contain(commit3.ChangeEntities[0].EntityId);
+ }
+
[Fact]
public async Task DeleteStaleSnapshots_Works()
{
diff --git a/src/SIL.Harmony.Tests/SIL.Harmony.Tests.csproj b/src/SIL.Harmony.Tests/SIL.Harmony.Tests.csproj
index f542263..3af010d 100644
--- a/src/SIL.Harmony.Tests/SIL.Harmony.Tests.csproj
+++ b/src/SIL.Harmony.Tests/SIL.Harmony.Tests.csproj
@@ -34,6 +34,7 @@
+
diff --git a/src/SIL.Harmony/CrdtConfig.cs b/src/SIL.Harmony/CrdtConfig.cs
index 2ae0895..dc7ae2d 100644
--- a/src/SIL.Harmony/CrdtConfig.cs
+++ b/src/SIL.Harmony/CrdtConfig.cs
@@ -21,6 +21,17 @@ public class CrdtConfig
/// after adding any commit validate the commit history, not great for performance but good for testing.
///
public bool AlwaysValidateCommits { get; set; } = true;
+ ///
+ /// Selects which stored commits are applied when materializing snapshots.
+ /// Default includes every commit (unchanged behaviour). Commits are still persisted and synced regardless.
+ ///
+ public ICommitMaterializationFilter CommitMaterializationFilter { get; set; } =
+ IncludeAllCommitsFilter.Instance;
+ ///
+ /// When true, authoring while checked out on a tag writes to main instead of being rejected.
+ /// Only relevant when using SIL.Harmony.Refs (RefsDataModel). Default false.
+ ///
+ public bool AllowAuthoringOnTagToMain { get; set; }
public ChangeTypeListBuilder ChangeTypeListBuilder { get; } = new();
public IEnumerable ChangeTypes => ChangeTypeListBuilder.Types.Select(t => t.DerivedType);
public ObjectTypeListBuilder ObjectTypeListBuilder { get; } = new();
diff --git a/src/SIL.Harmony/CrdtKernel.cs b/src/SIL.Harmony/CrdtKernel.cs
index 2d1c95d..d777aa2 100644
--- a/src/SIL.Harmony/CrdtKernel.cs
+++ b/src/SIL.Harmony/CrdtKernel.cs
@@ -40,7 +40,10 @@ public static IServiceCollection AddCrdtDataCore(this IServiceCollection service
provider.GetRequiredService(),
provider.GetRequiredService(),
provider.GetRequiredService>(),
- provider.GetRequiredService>()
+ provider.GetRequiredService>(),
+ provider.GetService(),
+ provider.GetServices(),
+ () => provider.GetServices()
));
//must use factory method because ResourceService constructor is internal
services.AddScoped(provider => new ResourceService(
diff --git a/src/SIL.Harmony/DataModel.cs b/src/SIL.Harmony/DataModel.cs
index 8707fcb..5b93edb 100644
--- a/src/SIL.Harmony/DataModel.cs
+++ b/src/SIL.Harmony/DataModel.cs
@@ -23,21 +23,36 @@ public class DataModel : ISyncable, IAsyncDisposable
private readonly IHybridDateTimeProvider _timeProvider;
private readonly IOptions _crdtConfig;
private readonly ILogger _logger;
+ private readonly ICommitMaterializationFilter? _materializationFilter;
+ private readonly IReadOnlyList _commitInterceptors;
+ private readonly Func>? _commitAppliedListenersFactory;
//constructor must be internal because CrdtRepository is internal
internal DataModel(CrdtRepositoryFactory crdtRepositoryFactory,
JsonSerializerOptions serializerOptions,
IHybridDateTimeProvider timeProvider,
IOptions crdtConfig,
- ILogger logger)
+ ILogger logger,
+ ICommitMaterializationFilter? materializationFilter = null,
+ IEnumerable? commitInterceptors = null,
+ Func>? commitAppliedListenersFactory = null)
{
_crdtRepositoryFactory = crdtRepositoryFactory;
_serializerOptions = serializerOptions;
_timeProvider = timeProvider;
_crdtConfig = crdtConfig;
_logger = logger;
+ _materializationFilter = materializationFilter;
+ _commitInterceptors = commitInterceptors?.ToArray() ?? [];
+ // Listeners are resolved lazily (at apply time) rather than in the constructor: a listener's
+ // roll-forward depends transitively on this DataModel, so eager resolution would form a
+ // constructor cycle. The interceptor above has no such dependency and is resolved eagerly.
+ _commitAppliedListenersFactory = commitAppliedListenersFactory;
}
+ private ICommitMaterializationFilter MaterializationFilter =>
+ _materializationFilter ?? _crdtConfig.Value.CommitMaterializationFilter;
+
///
/// add a change to the model, snapshots will be updated
@@ -62,20 +77,27 @@ public async Task AddManyChanges(Guid clientId,
Func commitMetadata,
int changesPerCommitMax = 100)
{
- await using var repo = await _crdtRepositoryFactory.CreateRepository();
- var commits = changes
- .Chunk(changesPerCommitMax)
- .Select(chunk => NewCommit(clientId, commitMetadata(), chunk))
- .ToArray();
- if (commits is []) return;
- using var locked = await repo.Lock();
- repo.ClearChangeTracker();
+ Commit[] commits;
+ await using (var repo = await _crdtRepositoryFactory.CreateRepository())
+ {
+ commits = changes
+ .Chunk(changesPerCommitMax)
+ .Select(chunk => NewCommit(clientId, commitMetadata(), chunk))
+ .ToArray();
+ if (commits is []) return;
+ using var locked = await repo.Lock();
+ repo.ClearChangeTracker();
- await using var transaction = await repo.BeginTransactionAsync();
- var updatedCommits = await repo.AddCommits(commits);
- await UpdateSnapshots(repo, updatedCommits);
- await ValidateCommits(repo);
- await transaction.CommitAsync();
+ await using var transaction = await repo.BeginTransactionAsync();
+ var updatedCommits = await repo.AddCommits(commits);
+ await UpdateSnapshots(repo, updatedCommits);
+ await ValidateCommits(repo);
+ await transaction.CommitAsync();
+ }
+ // Notify after the repo (and its lock) is released: a listener's roll-forward opens its own
+ // repository, and the apply lock is not reentrant, so notifying while holding it would deadlock
+ // on a persistent database.
+ await NotifyCommitsApplied(commits);
}
///
@@ -98,24 +120,49 @@ private Commit NewCommit(Guid clientId, CommitMetadata? commitMetadata, IEnumera
Metadata = commitMetadata ?? new()
};
commit.ChangeEntities.AddRange(changes.Select((c, i) => ToChangeEntity(c, i, commit.Id)));
+ // Single author choke point: let any registered interceptors (e.g. refs branch assignment)
+ // stamp metadata or reject authoring before the commit is persisted. Metadata is not part
+ // of the commit hash, so this is safe to do here. Sync-applied commits never pass through
+ // NewCommit, so their assignment is left untouched.
+ foreach (var interceptor in _commitInterceptors)
+ interceptor.OnCommitAuthored(commit);
return commit;
}
private async Task Add(Commit commit)
{
- await using var repo = await _crdtRepositoryFactory.CreateRepository();
- using var locked = await repo.Lock();
- if (await repo.HasCommit(commit.Id)) return;
- repo.ClearChangeTracker();
+ await using (var repo = await _crdtRepositoryFactory.CreateRepository())
+ {
+ using var locked = await repo.Lock();
+ if (await repo.HasCommit(commit.Id)) return;
+ repo.ClearChangeTracker();
- await using var transaction = repo.IsInTransaction ? null : await repo.BeginTransactionAsync();
- var updatedCommits = await repo.AddCommit(commit);
- await UpdateSnapshots(repo, updatedCommits);
+ await using var transaction = repo.IsInTransaction ? null : await repo.BeginTransactionAsync();
+ var updatedCommits = await repo.AddCommit(commit);
+ await UpdateSnapshots(repo, updatedCommits);
- if (AlwaysValidate) await ValidateCommits(repo);
+ if (AlwaysValidate) await ValidateCommits(repo);
+ // Nested apply: the caller owns the transaction and the post-apply notification, so a
+ // listener never observes uncommitted state. Bail before notifying.
+ if (transaction is null) return;
+ await transaction.CommitAsync();
+ }
+ // See NotifyCommitsApplied / AddManyChanges: fire only after the lock is released.
+ await NotifyCommitsApplied([commit]);
+ }
- if (transaction is not null) await transaction.CommitAsync();
+ ///
+ /// Fires registered s after an apply transaction commits and
+ /// the repository lock is released. Only invoked from the genuine apply entrypoints (author +
+ /// sync) — never from — so a listener that rematerializes
+ /// cannot re-enter.
+ ///
+ private async Task NotifyCommitsApplied(IReadOnlyCollection commits)
+ {
+ if (_commitAppliedListenersFactory is null || commits.Count == 0) return;
+ foreach (var listener in _commitAppliedListenersFactory())
+ await listener.OnCommitsAppliedAsync(commits);
}
public ValueTask DisposeAsync()
@@ -136,19 +183,25 @@ async Task ISyncable.AddRangeFromSync(IEnumerable commits)
commits = commits.ToArray();
try
{
- await using var repo = await _crdtRepositoryFactory.CreateRepository();
- using var locked = await repo.Lock();
- repo.ClearChangeTracker();
- _timeProvider.TakeLatestTime(commits.Select(c => c.HybridDateTime));
- var (oldestChange, newCommits) = await repo.FilterExistingCommits(commits.ToArray());
- //no changes added
- if (oldestChange is null || newCommits is []) return;
-
- await using var transaction = await repo.BeginTransactionAsync();
- var updatedCommits = await repo.AddCommits(newCommits);
- await UpdateSnapshots(repo, updatedCommits);
- await ValidateCommits(repo);
- await transaction.CommitAsync();
+ Commit[] newCommits;
+ await using (var repo = await _crdtRepositoryFactory.CreateRepository())
+ {
+ using var locked = await repo.Lock();
+ repo.ClearChangeTracker();
+ _timeProvider.TakeLatestTime(commits.Select(c => c.HybridDateTime));
+ Commit? oldestChange;
+ (oldestChange, newCommits) = await repo.FilterExistingCommits(commits.ToArray());
+ //no changes added
+ if (oldestChange is null || newCommits is []) return;
+
+ await using var transaction = await repo.BeginTransactionAsync();
+ var updatedCommits = await repo.AddCommits(newCommits);
+ await UpdateSnapshots(repo, updatedCommits);
+ await ValidateCommits(repo);
+ await transaction.CommitAsync();
+ }
+ // Fire after the lock is released so a listener's roll-forward can take it (see NotifyCommitsApplied).
+ await NotifyCommitsApplied(newCommits);
}
catch (DbUpdateException e)
{
@@ -186,13 +239,25 @@ ValueTask ISyncable.ShouldSync()
private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commitsToApply)
{
if (commitsToApply.Count == 0) return;
- var oldestAddedCommit = commitsToApply.First();
- await repo.DeleteStaleSnapshots(oldestAddedCommit);
+ var filter = MaterializationFilter;
+ if (filter is IMaterializationApplyWindow applyWindow)
+ commitsToApply = await applyWindow.PrepareApplyWindowAsync(repo, commitsToApply);
+
+ var commitsToMaterialize = commitsToApply
+ .Where(filter.Include)
+ .ToSortedSet();
+ if (commitsToMaterialize.Count == 0) return;
+
+ // Stale boundary is the unfiltered apply-window head. If that head is excluded,
+ // DeleteStaleSnapshots(filtered.First()) would not clear already-materialized later
+ // commits (WhereAfter is strict), and re-applying them would duplicate snapshots.
+ var oldestInApplyWindow = commitsToApply.First();
+ await repo.DeleteStaleSnapshots(oldestInApplyWindow);
Dictionary snapshotLookup = [];
- if (commitsToApply.Count > 10)
+ if (commitsToMaterialize.Count > 10)
{
// Bulk-load relevant snapshots to minimize DB queries
- var entityIds = commitsToApply
+ var entityIds = commitsToMaterialize
.SelectMany(c => c.ChangeEntities.Select(ce => ce.EntityId))
.Distinct();
@@ -204,7 +269,7 @@ private async Task UpdateSnapshots(CrdtRepository repo, SortedSet commit
}
var snapshotWorker = new SnapshotWorker(snapshotLookup, repo, _crdtConfig.Value);
- await snapshotWorker.UpdateSnapshots(commitsToApply);
+ await snapshotWorker.UpdateSnapshots(commitsToMaterialize);
}
private async Task ValidateCommits(CrdtRepository repo)
@@ -227,6 +292,12 @@ private async Task ValidateCommits(CrdtRepository repo)
}
}
+ public async Task GetCommit(Guid commitId)
+ {
+ await using var repo = await _crdtRepositoryFactory.CreateRepository();
+ return await repo.CurrentCommits().AsNoTracking().SingleAsync(c => c.Id == commitId);
+ }
+
public async Task RegenerateSnapshots()
{
await using var repo = await _crdtRepositoryFactory.CreateRepository();
diff --git a/src/SIL.Harmony/Db/CrdtRepository.cs b/src/SIL.Harmony/Db/CrdtRepository.cs
index 6312044..2ebba7c 100644
--- a/src/SIL.Harmony/Db/CrdtRepository.cs
+++ b/src/SIL.Harmony/Db/CrdtRepository.cs
@@ -9,6 +9,7 @@
using Microsoft.Extensions.Options;
using Nito.AsyncEx;
using SIL.Harmony.Changes;
+using SIL.Harmony.Entities;
using SIL.Harmony.Resource;
namespace SIL.Harmony.Db;
@@ -51,12 +52,14 @@ internal class CrdtRepository : IDisposable, IAsyncDisposable
private readonly ICrdtDbContext _dbContext;
private readonly IOptions _crdtConfig;
private readonly ILogger _logger;
+ private readonly Commit? _ignoreChangesAfter;
public CrdtRepository(ICrdtDbContext dbContext, IOptions crdtConfig,
ILogger logger,
Commit? ignoreChangesAfter = null)
{
_crdtConfig = crdtConfig;
+ _ignoreChangesAfter = ignoreChangesAfter;
_dbContext = ignoreChangesAfter is not null ? new ScopedDbContext(dbContext, ignoreChangesAfter) : dbContext;
_logger = logger;
//we can't use the scoped db context is it prevents access to the DbSet for the Snapshots,
@@ -247,6 +250,64 @@ public async Task GetCommitsAfter(Commit? commit)
.ToArrayAsync();
}
+ ///
+ /// Distinct entity ids from ChangeEntities whose JSON change has the given polymorphic type name.
+ ///
+ public async Task GetEntityIdsForChangeType(string changeTypeName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(changeTypeName);
+ // Discriminator key is "$type"; SQLite requires a quoted JSON path for the leading $.
+ var typePath = $"$.\"{CrdtConstants.ChangeDiscriminatorProperty}\"";
+ // When scoped (as-of / tag tip), only count changes on commits at or before the tip, matching the
+ // before/at-tip ordering ScopedDbContext applies to Commits/Snapshots (WhereBefore inclusive).
+ // A null tip disables the bound, so this one query serves both scoped and unscoped repos.
+ var ignoreAfterDate = _ignoreChangesAfter?.HybridDateTime.DateTime.UtcDateTime;
+ var ignoreAfterCounter = _ignoreChangesAfter?.HybridDateTime.Counter;
+ var ignoreAfterCommitId = _ignoreChangesAfter?.Id;
+ return await _dbContext.Database
+ .SqlQuery(
+ $"""
+ SELECT DISTINCT "ce"."EntityId" AS "Value"
+ FROM "ChangeEntities" AS "ce"
+ INNER JOIN "Commits" AS "c" ON "ce"."CommitId" = "c"."Id"
+ WHERE "ce"."Change" ->> {typePath} = {changeTypeName}
+ AND ({ignoreAfterDate} IS NULL
+ OR "c"."DateTime" < {ignoreAfterDate}
+ OR ("c"."DateTime" = {ignoreAfterDate} AND "c"."Counter" < {ignoreAfterCounter})
+ OR ("c"."DateTime" = {ignoreAfterDate} AND "c"."Counter" = {ignoreAfterCounter} AND "c"."Id" < {ignoreAfterCommitId})
+ OR "c"."Id" = {ignoreAfterCommitId})
+ """)
+ .ToArrayAsync();
+ }
+
+ ///
+ /// Distinct entity ids from ChangeEntities for the given change type's .
+ ///
+ public Task GetEntityIdsForChangeType() where TChange : IPolyType =>
+ GetEntityIdsForChangeType(TChange.TypeName);
+
+ ///
+ /// Distinct entity ids from ChangeEntities for a change type that implements .
+ ///
+ public Task GetEntityIdsForChangeType(Type changeType)
+ {
+ ArgumentNullException.ThrowIfNull(changeType);
+ if (!typeof(IPolyType).IsAssignableFrom(changeType))
+ {
+ throw new ArgumentException($"Change type {changeType} must implement {nameof(IPolyType)}.", nameof(changeType));
+ }
+
+ var typeName = changeType
+ .GetProperty(nameof(IPolyType.TypeName), BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)
+ ?.GetValue(null) as string;
+ if (string.IsNullOrWhiteSpace(typeName))
+ {
+ throw new ArgumentException($"Change type {changeType} does not expose a static {nameof(IPolyType.TypeName)}.", nameof(changeType));
+ }
+
+ return GetEntityIdsForChangeType(typeName);
+ }
+
public async Task FindSnapshot(Guid id, bool tracking = false)
{
return await Snapshots
diff --git a/src/SIL.Harmony/ICommitAppliedListener.cs b/src/SIL.Harmony/ICommitAppliedListener.cs
new file mode 100644
index 0000000..6ef8db4
--- /dev/null
+++ b/src/SIL.Harmony/ICommitAppliedListener.cs
@@ -0,0 +1,13 @@
+namespace SIL.Harmony;
+
+///
+/// Optional hook invoked after commits are applied — for both local authoring and sync — once the
+/// apply transaction has committed. The method is asynchronous so implementations can do database
+/// work (e.g. rolling a tag checkout forward). It is not invoked from
+/// or scoped read paths; this entrypoint boundary is what prevents a listener that rematerializes
+/// from re-entering.
+///
+public interface ICommitAppliedListener
+{
+ Task OnCommitsAppliedAsync(IReadOnlyCollection commits);
+}
diff --git a/src/SIL.Harmony/ICommitInterceptor.cs b/src/SIL.Harmony/ICommitInterceptor.cs
new file mode 100644
index 0000000..d2e3298
--- /dev/null
+++ b/src/SIL.Harmony/ICommitInterceptor.cs
@@ -0,0 +1,11 @@
+namespace SIL.Harmony;
+
+///
+/// Optional hook invoked once per locally-authored commit, before it is persisted.
+/// Implementations may mutate and may throw to reject authoring.
+/// It is not invoked for commits received via sync — those keep the assignment their author gave them.
+///
+public interface ICommitInterceptor
+{
+ void OnCommitAuthored(Commit commit);
+}
diff --git a/src/SIL.Harmony/ICommitMaterializationFilter.cs b/src/SIL.Harmony/ICommitMaterializationFilter.cs
new file mode 100644
index 0000000..e3aca6c
--- /dev/null
+++ b/src/SIL.Harmony/ICommitMaterializationFilter.cs
@@ -0,0 +1,17 @@
+namespace SIL.Harmony;
+
+///
+/// Decides which commits are applied when materializing snapshots.
+/// Commits are still stored and synced regardless of this filter.
+///
+public interface ICommitMaterializationFilter
+{
+ bool Include(Commit commit);
+}
+
+public sealed class IncludeAllCommitsFilter : ICommitMaterializationFilter
+{
+ public static IncludeAllCommitsFilter Instance { get; } = new();
+
+ public bool Include(Commit commit) => true;
+}
diff --git a/src/SIL.Harmony/IMaterializationApplyWindow.cs b/src/SIL.Harmony/IMaterializationApplyWindow.cs
new file mode 100644
index 0000000..ab97ebb
--- /dev/null
+++ b/src/SIL.Harmony/IMaterializationApplyWindow.cs
@@ -0,0 +1,12 @@
+using SIL.Harmony.Db;
+
+namespace SIL.Harmony;
+
+///
+/// Optional materialization filter hook to expand the commit apply window
+/// (e.g. when a merge makes earlier branch-scoped commits newly visible).
+///
+internal interface IMaterializationApplyWindow
+{
+ Task> PrepareApplyWindowAsync(CrdtRepository repo, SortedSet commitsToApply);
+}
diff --git a/src/SIL.Harmony/SIL.Harmony.csproj b/src/SIL.Harmony/SIL.Harmony.csproj
index 094fe40..95fbd63 100644
--- a/src/SIL.Harmony/SIL.Harmony.csproj
+++ b/src/SIL.Harmony/SIL.Harmony.csproj
@@ -8,6 +8,7 @@
+