Rule identity, priority, and observed-date resolution
This guide covers the resolution semantics an author needs once a rule set grows beyond a handful of fixed dates: how occurrences are identified, how priority arbitrates same-day collisions, how the resource's ResolutionPolicy settles duplicates and collisions, and how the EmissionMode / ObservedDateRangePolicy pair governs observed-date range inclusion.
For the element-by-element reference, see NotableDateRule and adjustment-policy reference. For the end-to-end materialisation flow, see The resolution pipeline.
Rule identity
Every resolved NotableDate carries a NotableDateRuleIdentity on its Identity property that records exactly which authored recipe produced it. The identity has three parts:
| Part | Meaning |
|---|---|
ResourceId |
The resourceId of the <NotableDateResource> the rule was loaded from. |
NotableDateId |
The id of the <NotableDate> concept (e.g. easter-sunday). |
RuleId |
The id of the specific <Rule> within that concept (e.g. default, western, orthodox). |
NotableDate also exposes NotableDateId and RuleId directly as computed shortcuts onto the identity:
foreach (NotableDate date in service.Resolve(2026, "GB"))
{
NotableDateRuleIdentity id = date.Identity;
Console.WriteLine($"{date.DisplayName} [{id.ResourceId}/{id.NotableDateId}/{id.RuleId}]");
// equivalent shortcuts:
// date.NotableDateId == id.NotableDateId
// date.RuleId == id.RuleId
}
Because a concept may hold several rules, distinct rules share a NotableDateId but differ by RuleId - which is how one concept can carry, say, a Gregorian and an Orthodox Easter that both resolve for the same year:
<NotableDate id="easter-sunday" displayName="Easter Sunday" category="Religious">
<Rules>
<Rule id="western"><Strategy><Algorithm key="western-easter" /></Strategy></Rule>
<Rule id="orthodox"><Strategy><Algorithm key="orthodox-easter" /></Strategy></Rule>
</Rules>
</NotableDate>
Both rules survive import resolution, override application, and assembly, and both produce an occurrence - each with the same NotableDateId (easter-sunday) but a different RuleId (western / orthodox). The same notableDateRef + ruleRef pair is what <OffsetFromRule>, ReplaceWithRule, and <PatchRule> / <RemoveRule> overrides use to target a single rule unambiguously. Filtering by concept uses the id:
// Every occurrence produced by the easter-sunday concept (both variants).
IReadOnlyList<NotableDate> easters =
service.Resolve(2026, "GB", NotableDateFilter.WithId("easter-sunday"));
Priority and same-day collisions
Every rule carries a Priority that flows onto the resolved NotableDate.Priority. When several distinct occurrences fall on the same day - for example an adjusted holiday landing on another holiday - the resource's ResolutionPolicy arbitrates them. Two knobs decide the outcome:
- CollisionPolicy - what to do with the colliding set.
- PriorityDirection - which priority wins when the policy needs a winner.
PriorityDirection is HigherWins (default) or LowerWins; it tells the engine whether a larger or smaller Priority value is the more important one. For a single-day query, every occurrence that covers that day - including a multi-day span that started earlier - is arbitrated together.
Collision policies
CollisionPolicy |
Behaviour |
|---|---|
KeepAll (default) |
Every distinct occurrence is kept; nothing is suppressed. |
HighestPriorityOnly |
Only the occurrence(s) with the winning priority (per PriorityDirection) survive. |
CategoryPriority |
Occurrences are ranked by category precedence first, then by priority. |
Custom |
A supplied INotableDateCollisionResolver decides. |
Same-day collisions are governed by SameDayCollisionPolicy and overlapping multi-day spans by SpanCollisionPolicy - two independent CollisionPolicy knobs on the same resource, both defaulting to KeepAll. Single-day events that share one day are reconciled by SameDayCollisionPolicy; multi-day occurrences whose [Date, EndDate] ranges overlap are reconciled by SpanCollisionPolicy. The policies are authored on the resource's <ResolutionPolicy> element:
<ResolutionPolicy duplicatePolicy="KeepFirst"
sameDayCollisionPolicy="HighestPriorityOnly"
spanCollisionPolicy="KeepAll"
priorityDirection="HigherWins"
observedDateRangePolicy="ObservedOccurrenceControlsInclusion"
workingDays="0111110" /> <!-- Sunday-first; Mon-Fri working -->
The runtime ResolutionPolicy carries these as SameDayCollisionPolicy, SpanCollisionPolicy, PriorityDirection, DuplicatePolicy, ObservedDateRangePolicy, a WorkingWeek (a Bodu.Core WeekPattern), and a CategoryPrecedence list; ResolutionPolicy.Default is the all-defaults instance (DuplicatePolicy.Error, CollisionPolicy.KeepAll on both axes, PriorityDirection.HigherWins, Monday-Friday working week).
Category precedence (CategoryPriority)
CollisionPolicy.CategoryPriority ranks colliding occurrences by category first and only falls back to Priority within a category. The ranking is the resource's CategoryPrecedence list - authored as a <CategoryPrecedence> child of <ResolutionPolicy> whose <Category value="…"/> entries are ordered most-important-first:
<ResolutionPolicy sameDayCollisionPolicy="CategoryPriority" priorityDirection="HigherWins">
<CategoryPrecedence>
<Category value="PublicHoliday" />
<Category value="BankHoliday" />
<Category value="Cultural" />
</CategoryPrecedence>
</ResolutionPolicy>
It maps to ResolutionPolicy.CategoryPrecedence, an ordered read-only list of NotableDateCategory values; earlier entries outrank later ones. This is the policy to reach for when, say, a PublicHoliday should always win a shared day over a Cultural observance regardless of the numeric priorities the two rules happen to carry. PriorityDirection still breaks ties between two occurrences of the same category.
Custom collision resolution
Under CollisionPolicy.Custom, supply an INotableDateCollisionResolver to the NotableDateService constructor. Its single method receives the shared day and the colliding occurrences and returns the survivors:
using Bodu.Globalization.Calendar;
using Bodu.Globalization.Calendar.RangeResolution;
public sealed class HighestPriorityResolver : INotableDateCollisionResolver
{
public IReadOnlyList<NotableDate> Resolve(DateOnly date, IReadOnlyList<NotableDate> colliding)
{
// Keep only the highest-priority occurrence on the day.
NotableDate winner = colliding.OrderByDescending(d => d.Priority).First();
return new[] { winner };
}
}
var service = new NotableDateService(resource, new NotableDateServiceOptions
{
CollisionResolver = new HighestPriorityResolver(),
});
The collaborator slots (Algorithms, CollisionResolver, Handlers, TriggerHandlers, Providers) live on NotableDateServiceOptions, not as positional constructor parameters. The resolver is consulted only when a CollisionPolicy resolves to Custom; for the built-in policies the engine settles the day itself.
Duplicate reconciliation
Distinct from a collision (two different rules on one day), a duplicate is the same occurrence appearing more than once - most often when an import and a local concept both contribute the same rule, or two <Import> paths reach the same catalogue. DuplicatePolicy reconciles them:
DuplicatePolicy |
Behaviour |
|---|---|
Error (default) |
Duplicate occurrences are treated as an authoring error. |
KeepFirst |
The first occurrence is kept; later duplicates are dropped. |
KeepLast |
The last occurrence is kept. |
Merge |
Duplicates are merged into a single occurrence. |
Because local concepts already win over imported ones of the same id at load time, DuplicatePolicy is the safety net for the cases that survive that rule rather than the primary composition mechanism.
Observed dates and range inclusion
When an adjustment policy shifts a date (for example rolling a Saturday holiday to Monday), two independent decisions apply: what the policy emits, and which emitted occurrence controls range-query inclusion.
What is emitted - EmissionMode
The policy's <Emission mode="…"> selects an EmissionMode:
EmissionMode |
What is emitted |
|---|---|
ActualOnly |
Only the nominal date; the substitute is discarded. |
ObservedOnly |
Only the observed (adjusted) date; the nominal is not emitted separately. |
ActualAndObserved |
Both, as two occurrences. |
ObservedAsAdditional |
[Obsolete] - normalised to ActualAndObserved at load time; behaves identically. |
Suppress |
Nothing - the occurrence is dropped. |
EmissionMode is a property of the adjustment policy, authored per policy in <Emission> - it is not a service-wide option and not a per-query argument. See Observance adjustment rules.
Which date controls inclusion - ObservedDateRangePolicy
For a range query, the resource-level ObservedDateRangePolicy decides which date of an occurrence must fall inside the window for it to be returned:
ObservedDateRangePolicy |
An occurrence is included when … |
|---|---|
ObservedOccurrenceControlsInclusion (default) |
its observed Date falls inside the range. |
ActualOccurrenceControlsInclusion |
its nominal ActualDate falls inside the range. |
BothOccurrencesControlInclusion |
either its observed or its nominal date falls inside the range. |
This is what makes range results stable: a holiday whose substitute rolls just outside the queried window is included or excluded by a deliberate, resource-level rule rather than by accident. The single-day result for a given day is independent of any wider query window.
using Bodu.Globalization.Calendar;
// Late-December window in 2027, when Christmas (Sat 25th) is observed Mon 27th.
var window = new DateRange(new DateOnly(2027, 12, 26), new DateOnly(2027, 12, 31));
IReadOnlyList<NotableDate> dates = service.Resolve(window, "AU");
// Under the default ObservedOccurrenceControlsInclusion the observed 27 Dec occurrence
// is inside the window and is returned; the nominal 25 Dec falls outside it.
Emission and range inclusion are two separate concerns: emission is authored per adjustment policy (
EmissionMode), and range inclusion is authored once per resource (ObservedDateRangePolicy).
Validating a rule set
Identity collisions, dangling references, and unknown algorithm keys are caught at load time, not at query time. NotableDateResourceLoader validates the assembled resource and throws a NotableDateValidationException on any error-severity finding; its Diagnostics collection reports every duplicate id, missing or ambiguous <OffsetFromRule> / ReplaceWithRule reference, reference cycle, and unregistered algorithm key (BODU-CAL-ALGORITHM) - all errors; see Calendar validation diagnostics for the code catalogue. See The resolution pipeline - semantic validation:
using Bodu.Globalization.Calendar;
try
{
NotableDateResource resource = NotableDateResourceLoader.Load(documentXml, resolver);
}
catch (NotableDateValidationException ex)
{
foreach (NotableDateValidationDiagnostic d in ex.Diagnostics)
Console.WriteLine($"[{d.Severity}] {d.Code}: {d.Message}");
}
Where to go next
- NotableDateRule and adjustment-policy reference - the element-by-element schema for rules and policies.
- The resolution pipeline - how identity, priority, and emission are applied end to end.
- Observance adjustment rules - emission modes, triggers, actions, and custom handlers.
- RangeResolution API reference - the policy enums in full.
- Globalization & Calendars guides - every guide in this topic: the runtime, companions, data packs, and the notable-date catalogue.