[Canonical page](<https://docs.dhtmlx.com/planning-engine/net/records-ids-and-dates/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Records, ids and dates

This guide covers the types you build input from and read results with: records, ids, dates, patch fields, unions and enums. Read it before you map your own models, database rows or API payloads to [`Plen.Core`](<https://docs.dhtmlx.com/planning-engine/net/api/core/overview.md>).

## How the data model works

Everything you pass in and get back is a plain .NET object in the `Plen` namespace. Nothing is `IDisposable`, nothing does I/O, and the engine keeps its own normalized copy of whatever you hand it.

There are five kinds of types:

- **Records** are mutable `sealed class`es with `{ get; set; }` properties: [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/project-data.md>), [`TaskData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md>), [`LinkData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/link-data.md>), options classes, change sets and results. Fields the engine can't do without are C# `required` members, so the compiler stops you from leaving them out. Every other field has a default, and a record with only its required fields set is a valid record.
- **Ids** ([`Id`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/id.md>)) are a number or a string. They compare like JavaScript object keys, so `1` and `"1"` are the same id.
- **Dates** ([`Moment`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/moment.md>)) are naive local date-times at minute precision. There is no timezone, no DST and no "now": a `Moment` is a wall-clock reading, stored as minutes since 1970-01-01 00:00.
- **Patch fields** ([`Patch<T>`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/patch.md>)) appear only in update records. Each one is in one of three states: leave the field alone, clear it, or set it.
- **Unions** ([`Reason`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/reason.md>), [`Conflict`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md>), [`Note`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md>), [`DayRule`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/worktime.md#dayrule>)) are abstract records with one nested sealed record per case. You read them with pattern matching.

Durations, lags, capacities and assignment values are `double`. Durations and lags count working time in a [`TimeUnit`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/time-unit.md>); `LevelingDelay` and pauses count elapsed time.

Data flows in one direction. [`Solve(data, …)`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) deep-copies the `ProjectData` you pass, so later changes to your object don't reach the project. Results flow the other way, but they aren't copies: the project keeps its last result as its baseline and can hand the same object out again. Treat results as read-only, and call [`DeepClone()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/i-deep-cloneable.md#deepclone>) on one before you change it.

## Working with the types

### Building records

Create records with object initializers. Records that have an id also have a constructor that takes it, such as `new TaskData(id)`, which fills every other field with its default:

```csharp
using Plen;

var start = Moment.FromYmdHm(2026, 8, 10, 8, 0)!.Value;

var data = new ProjectData
{
    Tasks =
    [
        new TaskData("design") { StartDate = start, Duration = 2 },
        new TaskData("build") { StartDate = start, Duration = 3 },
    ],
    Links = [new LinkData { Id = "l1", Source = "design", Target = "build" }],
    Resources = [new ResourceData { Id = "dev" }],
    Assignments = [new AssignmentData { Task = "build", Resource = "dev", Value = 1 }],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
    Console.WriteLine($"{update.Id}: {update.StartDate} - {update.EndDate}");
// build: 2026-08-12 08:00 - 2026-08-17 08:00
```

Only `build` is listed: the link moved it, and results report only the tasks that changed.

[`LinkData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/link-data.md>) needs `Id`, `Source` and `Target`. [`AssignmentData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/assignment-data.md>) needs `Task`, `Resource` and `Value`, and its `Id` is optional. Leave out a required member and the code doesn't compile.

Every list property starts empty and every nested record starts with its defaults. You can call `data.Tasks.Add(...)` or set `data.Config.DurationUnit` without creating anything first. Default values for each field are on the reference pages, for example [`TaskData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md>) and [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md>).

The compiler checks only the shape. The engine checks whether the data makes sense when you schedule it: a leaf task needs two of start, end and duration, and ids must be unique. Problems throw an [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) with a path such as `tasks[1].duration`. Paths use the JSON field names, so `StartDate` shows up as `start_date`.

Every public method throws `ArgumentNullException` when you pass `null` for an argument that isn't declared nullable.

### Ids

`string`, `int` and `double` convert to [`Id`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/id.md>) implicitly, so you can write `"a"` or `7` wherever an `Id` is expected. To be explicit, use [`Id.Str(...)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/id.md#str>) or [`Id.Num(...)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/id.md#num>):

```csharp
using Plen;

Id a = 7;
Id b = "7";

Console.WriteLine(a == b);              // True
Console.WriteLine(a.IsNum);             // True
Console.WriteLine(b.StrValue);          // 7
Console.WriteLine(Id.Num(1.5));         // 1.5
Console.WriteLine(Id.Num(1e21).Key());  // 1e+21
```

Equality and hashing follow [`Key()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/id.md#key>), the text JavaScript would use for the id as an object key. Ids work as `Dictionary<Id, T>` keys, and a task stored as `7` is found by the lookup `"7"`. As a result, `7` and `"7"` in one project are a duplicate id, not two tasks. `0` and `-0` both have the key `"0"`.

To get the original value back, check `IsNum` or `IsStr` and read `NumValue` (`double?`) or `StrValue` (`string?`). `ToString()` returns the key.

Numeric ids are `double`, so integers above 2^53 lose precision. If your database uses 64-bit keys, pass them as strings: `Id.Str(row.Id.ToString())`. Don't use infinite numbers as ids. `Key()` throws `InvalidOperationException` for them.

[`SchedulingConfig.RootId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#rootid>) (default `0`) names the virtual root. A task whose `Parent` equals it, by key, is a root task. So a `Parent` of `"0"` counts as no parent too. Set `RootId` if your data uses another root id.

### Moments

A [`Moment`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/moment.md>) is a `readonly record struct` that wraps one `long`, [`Minutes`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/moment.md#minutes>). You can create one from a date and time, from a `DateTime` or from raw minutes:

```csharp
using Plen;

Moment? maybe = Moment.FromYmdHm(2026, 2, 29, 9, 0);
Console.WriteLine(maybe is null);                  // True: 2026 isn't a leap year

var start = Moment.FromYmdHm(2026, 8, 11, 8, 30)!.Value;
Console.WriteLine(start);                          // 2026-08-11 08:30
Console.WriteLine(start.Minutes);                  // 29773950

var halfHourLater = new Moment(start.Minutes + 30);
Console.WriteLine(halfHourLater > start);          // True

DateTime forUi = start.ToDateTime();               // DateTimeKind.Unspecified
Moment back = Moment.FromDateTime(forUi);
Console.WriteLine(back == start);                  // True
```

[`FromYmdHm`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/moment.md#fromymdhm>) returns `null` for an invalid date or time, such as hour 24, minute 60 or February 29 in a common year. The examples on this page write `!.Value` for dates they know are valid. With input you don't control, check for `null` instead. [`FromDateTime`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/moment.md#fromdatetime>) drops seconds and fractions. Moments compare with `<`, `>`, `<=`, `>=` and `CompareTo`, and `ToString()` gives the `YYYY-MM-DD HH:MM` form.

Adding minutes to `Minutes` moves a moment in wall-clock time. It doesn't respect calendars, so leave date math in working time to the scheduler.

`FromDateTime` reads the date and time fields as they are and ignores `DateTime.Kind`. A UTC value gets scheduled as if it were local wall-clock time. Convert it to the zone the plan is in first:

```csharp
using Plen;

var utc = new DateTime(2026, 8, 10, 6, 30, 0, DateTimeKind.Utc);
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
var local = TimeZoneInfo.ConvertTimeFromUtc(utc, berlin);

Console.WriteLine(Moment.FromDateTime(local));     // 2026-08-10 08:30
```

[`ToDateTime()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/moment.md#todatetime>) throws `ArgumentOutOfRangeException` for a moment outside years 1-9999, which `System.DateTime` can't hold. For such dates, read `Minutes` directly.

`Moment` is a value type, so an unset `Moment` field is 1970-01-01 00:00, not "no date". Optional dates use `Moment?` instead, as [`TaskData.StartDate`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md>) does, and there `null` means "not given".

Calendar date exceptions in [`Worktime.Dates`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/worktime.md#dates>) are keyed by `Moment`, but only the date counts. Use midnight keys: a second key on the same day replaces the first without an error.

```csharp
using Plen;

var calendar = new CalendarData
{
    Id = "office",
    Worktime = new Worktime
    {
        Dates =
        {
            [Moment.FromYmdHm(2026, 12, 24, 0, 0)!.Value] = new DayRule.Off(),
            [Moment.FromYmdHm(2026, 12, 31, 0, 0)!.Value] = new DayRule.Hours([new TimeRange(480, 720)]),
        },
    },
};
```

[`TimeRange`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/worktime.md#timerange>) holds minutes since midnight, so `480`-`720` is 08:00-12:00. More on calendars in [Working-time calendars](<https://docs.dhtmlx.com/planning-engine/net/scheduling/working-time-calendars.md>).

### Patch fields

Update records ([`TaskUpdate`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/task-update.md>), [`LinkUpdate`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/link-update.md>), [`ResourceUpdate`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/resource-update.md>), [`AssignmentUpdate`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/assignment-update.md>)) have a [`Patch<T>`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/patch.md>) for every field except `Id`. A patch is in one of three states:

| State | How to write it | Effect |
|---|---|---|
| Missing | leave the field out (`default`) | the field stays as it is |
| Null | [`Patch<T>.Null`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/patch.md#null>) | clears an optional field, or restores a scalar field's default |
| Value | [`Patch<T>.Of(value)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/patch.md#of>), or assign a `T` | sets the field |

A new update record changes nothing until you set fields:

```csharp
using Plen;

var update = new TaskUpdate("build")
{
    StartDate = Moment.FromYmdHm(2026, 8, 17, 8, 0)!.Value, // set
    Duration = 4,                                           // set
    ConstraintType = Patch<ConstraintType>.Null,            // clear
    Parent = Id.Str("phase-2"),                             // set
};

var changes = new SchedulingChanges();
changes.Tasks.Update.Add(update);
```

`T` converts to `Patch<T>` implicitly, but C# applies only one user-defined conversion at a time. A string doesn't become a `Patch<Id>`, so write `Id.Str("phase-2")` or `(Id)"phase-2"`. A `Moment?` doesn't become a `Patch<Moment>` either: unwrap it with `.Value`, or pick the state yourself.

`Pauses` is a `Patch<List<Pause>>`. A value replaces the whole list and `Null` clears it. Clearing a field the record can't exist without, such as a link's `Source`, is rejected. [`Apply`](<https://docs.dhtmlx.com/planning-engine/net/api/project-apply.md>) reports it in [`ApplyReport.Rejected`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/apply-report.md#rejected>) instead of throwing.

To read a patch, switch on `Kind`, or use [`TryGetValue`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/patch.md#trygetvalue>):

```csharp
using Plen;

static string Describe(Patch<double> p) => p.Kind switch
{
    PatchKind.Missing => "unchanged",
    PatchKind.Null => "reset",
    _ => $"set to {p.Value}",
};

Console.WriteLine(Describe(default));            // unchanged
Console.WriteLine(Describe(Patch<double>.Null)); // reset
Console.WriteLine(Describe(2.5));                // set to 2.5
```

`Value` throws `InvalidOperationException` on a patch that isn't in the Value state. Check `HasValue` first, or use `TryGetValue(out var v)`.

A [`Mutation`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/mutation.md>) in a result lists only the fields that changed, and they map one-to-one onto a `TaskUpdate`. A changed field becomes a value, and a field that is `null` stays missing:

```csharp
using Plen;

static TaskUpdate ToUpdate(Mutation m) => new(m.Id)
{
    StartDate = m.StartDate is { } start ? start : Patch<Moment>.Missing,
    EndDate = m.EndDate is { } end ? end : Patch<Moment>.Missing,
    Duration = m.Duration is { } duration ? duration : Patch<double>.Missing,
    LevelingDelay = m.LevelingDelay is { } delay ? delay : Patch<double>.Missing,
    Pauses = m.Pauses is { } pauses ? pauses.ConvertAll(p => p.DeepClone()) : Patch<List<Pause>>.Missing,
};
```

The pauses are deep-cloned because the result is shared with the project (see [Copies and shared results](<https://docs.dhtmlx.com/planning-engine/net/records-ids-and-dates.md#copies-and-shared-results>)). The full edit workflow is in [Editing a project](<https://docs.dhtmlx.com/planning-engine/net/live-projects/editing-a-project.md>).

### Unions and pattern matching

[`Reason`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/reason.md>), [`Conflict`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md>), [`Note`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md>) and [`DayRule`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/worktime.md#dayrule>) each have a private constructor, so the nested records are the only cases. Match them with `switch`. The variants are positional records, so you can deconstruct them in the pattern:

```csharp
using Plen;

var result = Engine.Schedule(data, new SolveOptions { Reasons = true });

foreach (var conflict in result.Conflicts)
{
    string text = conflict switch
    {
        Conflict.ConstraintViolation v =>
            $"{v.Task}: {v.Constraint} wants {v.Required}, planned {v.Actual}",
        Conflict.CalendarNonWorking(var task, var proposed, var snapped) =>
            $"{task}: {proposed} is non-working, moved to {snapped}",
        _ => conflict.ToString(),
    };
    Console.WriteLine(text);
}

foreach (var update in result.Updates)
{
    string why = update.Reason switch
    {
        Reason.Asap(var link, var source) => $"pushed by {source} through link {link}",
        Reason.Alap(var link, var target) => $"pulled by {target} through link {link}",
        Reason.SummaryTranslation(var summary) => $"moved with summary {summary}",
        Reason.Constraint => "placed by its constraint",
        Reason.Preferred => "kept at its preferred date",
        _ => "no reason requested",
    };
    Console.WriteLine($"{update.Id}: {why}");
}
```

Here `data` is a [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/project-data.md>) like the one in [Building records](<https://docs.dhtmlx.com/planning-engine/net/records-ids-and-dates.md#building-records>). The compiler can't tell that a union is closed, so keep a discard arm `_` to avoid the non-exhaustive switch warning. `Reason` is `null` unless you set [`Reasons = true`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md#reasons>).

`Note` works the same way. [`Note.Repaired(var path, var message)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#repaired>) matches what [`Validation.Repair`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/validation.md#what-repair-changes>) fixed, and [`Note.DroppedLink`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedlink>), [`DroppedTask`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedtask>) and [`DroppedAssignment`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedassignment>) carry the id and a reason string. To build a `DayRule`, create a case directly: `new DayRule.Off()`, `new DayRule.On()` or `new DayRule.Hours(ranges)`.

`Reason`, `Conflict` and `Note` variants compare by value, and the ids inside them compare by key. Two [`CalendarNonWorking`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md#calendarnonworking>) conflicts for task `1` and task `"1"` with the same dates are equal.

### Enums

Option values are plain C# enums. The Default column shows what a new record or config uses when you don't set the field:

| Enum | Values | Default |
|---|---|---|
| [`TimeUnit`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/time-unit.md>) | `Minute`, `Hour`, `Day` | `Day` ([`SchedulingConfig.DurationUnit`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#durationunit>)) |
| [`TaskType`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md#tasktype>) | `Task`, `Summary`, `Milestone` | `Task` |
| [`LinkType`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/link-data.md#linktype-1>) | `Fs`, `Ss`, `Ff`, `Sf` | `Fs` |
| [`ConstraintType`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md#constrainttype>) | `Asap`, `Alap`, `Snet`, `Snlt`, `Fnet`, `Fnlt`, `Mso`, `Mfo` | none (`TaskData.ConstraintType` is `null`) |
| [`CalendarMode`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/calendar-mode.md>) | `Resource`, `Task`, `TaskInherited` | `Resource` |
| [`GapBehavior`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#gapbehavior-1>) | `Preserve`, `Compress` | `Preserve` |
| [`Validation`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/validation.md>) | `Strict`, `Repair` | `Strict` |

`TimeUnit` has no week, month or year. Express longer durations in days.

The constraint types other than `Asap` and `Alap` are dated, so a task that uses one also needs a `ConstraintDate`. They apply only when [`SchedulingConfig.ApplyConstraints`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#applyconstraints>) is `true`.

The enums have no parse or spelling helpers. `ToString()` gives the C# name (`"Fs"`, `"TaskInherited"`), not the JSON spelling (`"fs"`, `"task-inherited"`). The `Plen.Wire` package handles JSON spellings. If you map your own strings, write the mapping yourself:

```csharp
using Plen;

static LinkType ParseGanttLinkType(string type) => type switch
{
    "0" => LinkType.Fs,
    "1" => LinkType.Ss,
    "2" => LinkType.Ff,
    "3" => LinkType.Sf,
    _ => throw new ArgumentException($"unknown link type {type}"),
};
```

### Copies and shared results

Every record implements [`IDeepCloneable<T>`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/i-deep-cloneable.md>). [`DeepClone()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/i-deep-cloneable.md#deepclone>) returns a copy that shares no list or nested record with the original, so you can edit either side safely. The examples below use the `data` from [Building records](<https://docs.dhtmlx.com/planning-engine/net/records-ids-and-dates.md#building-records>):

```csharp
using Plen;

var variant = data.DeepClone();
variant.Tasks[0].Duration = 5;
variant.Tasks.Add(new TaskData("test") { StartDate = data.Tasks[0].StartDate, Duration = 1 });

Console.WriteLine(data.Tasks[0].Duration); // 2
Console.WriteLine(data.Tasks.Count);       // 2
```

You don't need to clone before solving: [`Solve(data, …)`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) already copies the data, and the project never holds your object.

Results are the opposite case. `Solve(null, …)` with no pending edits and equal options returns the cached result, the same instance. Clones of the project share it too, and [`Evaluate`](<https://docs.dhtmlx.com/planning-engine/net/api/project-evaluate.md>) and [`SuggestAssignments`](<https://docs.dhtmlx.com/planning-engine/net/api/project-suggest-assignments.md>) read it as the baseline. Changing it in place would change what the project sees, so clone it first:

```csharp
using Plen;

var project = new Project();
var first = project.Solve(data, new SolveOptions());
var again = project.Solve(null, new SolveOptions());

Console.WriteLine(ReferenceEquals(first, again)); // True

var mine = first.DeepClone();
mine.Updates.RemoveAll(u => u.Kind == MutationKind.Summary); // safe: a private copy
```

[`LevelingResult.DeepClone()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/resources/leveling-result.md#deepclone>) copies the leveling result but still shares its [`Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/core/resources/leveling-result.md#schedule>). Call `DeepClone()` on `Schedule` too if you want to edit it.

Equality depends on the type. Options and results ([`SolveOptions`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/solve-options.md>), [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md>), [`Mutation`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/mutation.md>), [`Pause`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/pause.md>), [`ApplyReport`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/apply-report.md>) and others) implement `IEquatable<T>` with value equality. They compare lists element by element and `double`s with `==`, so `NaN` never equals itself. Input records and change sets ([`ProjectData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/project-data.md>), [`TaskData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md>), [`LinkData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/link-data.md>), [`TaskUpdate`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/task-update.md>), [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/scheduling-changes.md>)) compare by reference. Compare their fields yourself if you need to detect changes.

`ToString()` on any record prints a debug dump of its fields. It's meant for logs, not for parsing.
