Application library
Load records from your store, map them to the runtime's history contract, keep the history in memory, and call query or comparison APIs directly.
Adopt existing history
If your system already materializes intervals, you do not need to replay
raw events through a Spanfold pipeline. Map those intervals into a
WindowHistory, then use the same query, snapshot, and
comparison APIs as pipeline-recorded history.
Choose the boundary
Use the library when your application already owns loading and mapping. Use fixture or flat-window files when a portable command-line workflow is the better boundary. Neither route makes Spanfold your database.
Load records from your store, map them to the runtime's history contract, keep the history in memory, and call query or comparison APIs directly.
Use the versioned spanfold.contract-fixture JSON envelope when windows and a comparison plan should travel together for examples or regression contracts.
Use spanfold audit-windows when each line is already one processing-position window and you want comparison artifacts without application code.
Rust's import-events and audit-events map event JSONL or CSV into windows. They are event-reconstruction tools, not the route for data that is already materialized.
Contract fixture and flat-window formats are tool contracts, not a
promised persistence schema for WindowHistory. The Rust CLI
owns event-map import; the .NET CLI does not provide an equivalent event-map route.
Map the evidence
| Concern | C# / .NET | Rust |
|---|---|---|
| Record shape | Public ClosedWindow and OpenWindow constructors. | ClosedWindow and OpenWindow are public but #[non_exhaustive] and have no public constructors. |
| Record identity | WindowRecord.Id is derived deterministically from the record data; it is not supplied by the importer or a distributed global ID. | Each record carries an explicit non-blank WindowRecordId. |
| Range | Every record has non-negative processing positions. Optional DateTimeOffset bounds and a timestamp clock add an event-time view. | A closed record owns one half-open TemporalRange; an open record owns one TemporalPoint. The chosen axis is processing position or opaque timestamp ticks. |
| Lane and scope | WindowName, object Key, optional object Source, and optional object Partition. | Non-blank string window_name and key, plus optional non-blank string source and partition. |
| Availability | Imported records do not carry a separate known-at field. A processing-position known-at comparison excludes records that start later and clips records still active at the horizon; annotations may carry their own KnownAt. | Records can carry an explicit known_at point in the same temporal domain. When absent, closed records default to their end for known-at comparison; an open record defaults to its start. |
| Context | WindowSegment and WindowTag accept object values. Segments are analytical boundaries; tags are descriptive metadata. | Segments and tags use JSON-compatible PrimitiveValues. Names must be unique within a record; a parent segment must appear before the child that names it. |
| Close evidence | Closed records may preserve a boundary reason and segment boundary changes. | Closed records expose the equivalent optional boundary reason and boundary-change list. |
Both temporal ranges are half-open: start is included and end is excluded.
C# creates points with TemporalPoint.ForPosition(...) or
ForTimestamp(...), then ranges with
TemporalRange.Closed(...) or Open(...). Rust uses
TemporalPoint::position(...),
timestamp_ticks(...), or
timestamp_ticks_with_clock(...), and validates closed ranges
through TemporalRange::new(...) or positions(...).
Current source contract
public static WindowHistory FromRecords(
IEnumerable<ClosedWindow> closedWindows,
IEnumerable<OpenWindow> openWindows)
Both sequences are required. Record constructors validate their own window identity, key, position, timestamp, and clock fields. Import copies closed records and indexes open records by window name, key, source, partition, and segment context; a duplicate open key fails when that index is built.
pub fn from_records(
closed: impl IntoIterator<Item = ClosedWindow>,
open: impl IntoIterator<Item = OpenWindow>,
) -> Result<WindowHistory, WindowHistoryImportError>
The function rebuilds the open-record index and returns
DuplicateOpenRecordId for a repeated open ID. However,
external crates cannot construct the non-exhaustive record structs
with literals, and no public constructors currently bridge that gap.
Window names cannot be blank, keys cannot be null, positions cannot be negative, ends cannot precede starts, end timestamps require start timestamps, and timestamp clocks cannot be blank or exist without a start timestamp. WindowSegment, WindowTag, and WindowBoundaryChange metadata is not checked for blank or duplicate names, parent consistency, or equivalent domain rules; validate those fields in your adapter.
serde_json::from_str::<WindowHistory>(...) applies strict unknown-field and domain validation to the history and window-record payloads, including ranges, identity fields, known-at domains, segment/tag metadata, and duplicate open record IDs. Annotation structs are less strict: they do not deny unknown fields or apply the same name and temporal-domain validation. WindowHistory::annotate only appends and assigns a revision; even when using it, validate annotation names, targets, and known-at domains in your adapter.
FromRecords/from_records is not a global deduplication or business-consistency pass. Closed-record duplication and overlap remain questions for your mapping policy or later analysis.
Both record-import functions create a history with no annotations. Add append-only annotations after import; importing records does not infer them from tags or replace the original window.
Until Rust exposes public record constructors or another typed adoption
boundary, use validated WindowHistory deserialization for
exact externally materialized records, or use
WindowHistoryFixture/ContractFixture for
processing-position fixture data. Do not copy private struct construction
from crate tests into application code.
Paired provider example
Both examples represent provider A active over [10, 20) and
provider B over [12, 22) for the same device and partition.
They preserve one segment and one tag, query provider A, snapshot at
position 25, and produce overlap, residual, and missing rows.
using Spanfold;
using Spanfold.Comparison;
var providerARange = TemporalRange.Closed(
TemporalPoint.ForPosition(10),
TemporalPoint.ForPosition(20));
var providerBRange = TemporalRange.Closed(
TemporalPoint.ForPosition(12),
TemporalPoint.ForPosition(22));
var segments = new[] { new WindowSegment("region", "eu-west") };
var tags = new[] { new WindowTag("service", "checkout") };
var providerA = new ClosedWindow(
"DeviceOffline", "device-17",
providerARange.Start.Position, providerARange.End!.Value.Position,
Source: "provider-a", Partition: "fleet-1",
Segments: segments, Tags: tags);
var providerB = new ClosedWindow(
"DeviceOffline", "device-17",
providerBRange.Start.Position, providerBRange.End!.Value.Position,
Source: "provider-b", Partition: "fleet-1",
Segments: segments, Tags: tags);
var history = WindowHistory.FromRecords(
new[] { providerA, providerB },
Array.Empty<OpenWindow>());
var lane = history.Query()
.Window("DeviceOffline")
.Lane("provider-a")
.Partition("fleet-1")
.Segment("region", "eu-west")
.ClosedWindows();
var snapshot = history.SnapshotAt(TemporalPoint.ForPosition(25));
var comparison = history.Compare("Provider QA")
.Target("provider-a", side => side.Source("provider-a"))
.Against("provider-b", side => side.Source("provider-b"))
.Within(scope => scope.Window("DeviceOffline"))
.Using(rows => rows.Overlap().Residual().Missing())
.Run();
use spanfold::{TemporalPoint, WindowHistory};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{
"closed": [
{
"id": "provider-a-device-17-10",
"window_name": "DeviceOffline",
"key": "device-17",
"range": {
"start": { "axis": "ProcessingPosition", "magnitude": 10, "clock": null },
"end": { "axis": "ProcessingPosition", "magnitude": 20, "clock": null }
},
"known_at": { "axis": "ProcessingPosition", "magnitude": 20, "clock": null },
"source": "provider-a",
"partition": "fleet-1",
"segments": [{ "name": "region", "value": "eu-west", "parent_name": null }],
"tags": [{ "name": "service", "value": "checkout" }],
"boundary_reason": null,
"boundary_changes": []
},
{
"id": "provider-b-device-17-12",
"window_name": "DeviceOffline",
"key": "device-17",
"range": {
"start": { "axis": "ProcessingPosition", "magnitude": 12, "clock": null },
"end": { "axis": "ProcessingPosition", "magnitude": 22, "clock": null }
},
"known_at": { "axis": "ProcessingPosition", "magnitude": 22, "clock": null },
"source": "provider-b",
"partition": "fleet-1",
"segments": [{ "name": "region", "value": "eu-west", "parent_name": null }],
"tags": [{ "name": "service", "value": "checkout" }],
"boundary_reason": null,
"boundary_changes": []
}
],
"open": [],
"annotations": []
}"#;
let history: WindowHistory = serde_json::from_str(json)?;
let lane = history.query()
.where_window("DeviceOffline")
.where_source("provider-a")
.where_partition("fleet-1")
.where_segment("region", "eu-west")
.closed_windows();
let snapshot = history.snapshot_at(TemporalPoint::position(25))?;
let comparison = history.compare("Provider QA")
.target_source("provider-a")
.against_source("provider-b")
.scope_window("DeviceOffline")
.overlap()
.residual()
.missing()
.run();
Ok(())
}
The Rust example depends on serde_json in the application.
The serialized WindowHistory shape is the crate's current
serde contract, not a cross-language or durable-storage schema promise.
Put your own versioned DTO in front of it when long-lived compatibility matters.
Open records
An open record is not a closed interval with a missing value. It is evidence whose end is still unknown. Keep its source, partition, segment context, start domain, and record identity stable.
SnapshotAt/snapshot_at clips records active at a compatible horizon and marks their snapshot records provisional without mutating the imported history.
Final comparison rejects open windows by default. Use RunLive(horizon) in C# or run_live(horizon)/clip_open_windows_to_position(...) in Rust when provisional evidence belongs in the answer.
If the source cannot distinguish “still active” from “not yet refreshed,” keep that uncertainty in the source system. A fabricated close converts uncertainty into false final evidence.
An imported history is an analysis snapshot. Importing an open record does not attach it to an event pipeline that will later close it.
Annotations
Both runtimes support append-only annotations targeted at a stable window-start identity. Repeated names receive increasing revision numbers. Known-at queries include only annotations with a compatible point at or before the requested horizon; annotations without known-at are excluded from that point-in-time-safe view.
Tags are part of the imported window and can participate in query and comparison scope. Annotations are later external metadata. Do not use an annotation as a silent correction to the interval itself.
Correction and recovery
Version the adapter from your domain records into Spanfold fields. Decide how source IDs become keys, lanes, partitions, segments, tags, clocks, and Rust record IDs.
Resolve duplicate source rows and corrections before import. Spanfold can analyze overlaps; it does not decide which conflicting persisted row should survive.
Create a new history from the corrected authoritative snapshot and rerun the query or comparison. There is no public merge, upsert, or retroactive window-rewrite API at this boundary.
Persist your source DTOs, checkpoints, and result versions outside Spanfold. The core history is in-memory and does not claim a database schema, migration path, repository, or distributed recovery protocol.