Time
Ship separates exact elapsed time, wall-clock instants, calendar dates, monotonic measurement, and future civil time. These are related concepts, but they should not share one loose integer or string representation.
Standard Library Invariant
Standard library APIs pass time around as Instant, LocalDate, Duration, or an opaque runtime marker such as MonotonicInstant. They do not use plain Int values for timeouts, delays, timestamps, retry schedules, deadlines, elapsed time, or stored time fields.
Plain integers are allowed only at explicit conversion edges:
constructors such as
Duration.millis(250);measured numeric construction such as
Duration.from(50<ms>);conversion helpers such as
Duration.to_millis(duration);wire, database, runtime, or generated-code internals where the representation is not the Ship API.
The public
Duration,Instant, andSchedulemodule surfaces should live in Ship stdlib source wherever the language can express them. Host primitives use#builtincontracts on those same public modules for opaque representation, parsing/formatting, overflow-checked arithmetic, and system clock access; source-backed wrappers are ordinary.shp. Do not hide remaining host ops behind anIntrinsicsmodule or path-installedprimitive_*twins.Duration.microsstays#builtin: it is the i64-nanosecond scale bridge, somillis/seconds/hourscan construct values past the tagged 63-bitIntintermediate.Duration.fromis source-backed overmillis(measuredInt<ms>uses#builtin.magicat the representation edge). Remaining host edges (nanos,to_nanos_checked, ISO / RFC3339,:overflowarithmetic,compare,to_millis,display) stay#builtinonDuration/Instant. External projects load those modules from the compiler-matched external Core artifact rather than reparsingship/stdlibon each run. Further wrapper migrations are tracked in../questions/176-stdlib-source-backed-migrations.md. Provider runtime availability for time APIs follows the same use-site rule as other builtins:ship checkrejects a call whose provider does not run on the program's target (book/100-LanguageReference/009-RejectedPrograms.shp).
LocalDate
LocalDate is a calendar date without a time of day or time zone. Workflow dates such as filming, publishing, and contractual due dates use LocalDate instead of midnight-shaped Instant values.
let due = LocalDate.parse_iso("2026-07-31");
let text = LocalDate.to_iso(due);
let order = LocalDate.compare(due, LocalDate.parse_iso("2026-08-01"));
Parsing is strict YYYY-MM-DD, including Gregorian leap-year and month-length validation. Years range from 0001 through 9999. Display, inspect, and wire output use the same canonical text shape.
Duration
Duration is the public stdlib type for exact elapsed time. Public APIs accept Duration, not raw millisecond Int values.
let quick = Duration.millis(250);
let slow = Duration.seconds(2);
let total = Duration.add(quick, slow);
let from_measured = Duration.from(50<ms>);
Measured numerics are a construction and conversion aid. Duration.from accepts Int<ms> so a boundary can be explicit about units, but the resulting value is a Duration.
Rules:
Durationis exact elapsed time, not calendar time.Months and years do not belong in
Duration.Prefer
Duration.hours(24)for exact elapsed time. A futurePeriod.days(1)will carry calendar semantics.Arithmetic that can overflow is fallible and uses Ship's expected-error channel with
:overflow.Stable serialization uses
Duration.to_iso; human display usesDuration.display.
Instant
Instant is an absolute wall-clock timestamp for logs, database timestamps, and wire formats.
provide { Clock = Clock.SystemClock; } {
let created_at = Clock.now();
let parsed = Instant.parse_rfc3339("2026-06-02T12:00:00Z");
let later = Instant.add(parsed, Duration.minutes(30));
}
Wire output is strict RFC3339 with UTC-normalized Z output:
Instant.to_rfc3339(later)
Instant is not a calendar date or local/civil date-time. Date-only values use LocalDate; future local scheduling should use separate types such as LocalDateTime, TimeZone, and ZonedDateTime.
Clock
Clock is an ambient standard service: ordinary signatures omit it, while an executable scope installs a checked provider explicitly. The standard native provider is Clock.SystemClock; tests and sandboxes install deterministic providers through the same provide path.
provide { Clock = Clock.SystemClock; } {
let mark = Clock.mark();
work();
let elapsed = Clock.elapsed_since(mark);
}
Clock.now() returns wall-clock Instant. Clock.mark() returns an opaque MonotonicInstant for elapsed measurement; it is not serializable and should not be stored in domain data.
Clock.SystemClock implements Clock.now, Clock.mark, and Clock.elapsed_since through checked TimeTransport builtins. Runtime dispatch remains the ordinary provider-cell path; it does not match the Clock service or operation names.
Clock.virtual(start) is the deterministic provider for tests and sandboxes. now starts at start; marks come from the runtime's virtual timeline, which advances only when every fiber of a scheduler is parked on a virtual deadline.
Sleep
Clock.sleep(duration) parks the current fiber until the provided Clock passes Clock.mark() + duration. It is a source function over TimeTransport.sleep_after(mark, duration), the one suspending time primitive; the mark's timeline (system or virtual) selects the deadline queue.
Sleeping never blocks a host thread while another fiber of the same scheduler is runnable.
An idle scheduler with system deadlines waits for the earliest one, polling host cancellation, instead of spinning. With virtual deadlines it advances virtual time to the earliest one immediately.
Pending deadlines keep the scheduler alive; deadlock detection fires only when no fiber is runnable and no deadline is pending.
A zero or negative duration parks until every runnable fiber has had a turn.
Interrupting a sleeping fiber cancels its deadline and runs its cleanup.
Fiber code reached through a native callback, such as the function given to
Vector.map, runs on a nested scheduler. While it sleeps, the outer scheduler's fibers do not run, on either timeline.Timers run on the native scheduler. The browser runtime implements no
TimeTransportprovider yet, so everyClockoperation,Clock.sleepincluded, is native-only. Browser builds do not reject these calls at check time; they fail when reached.Executable coverage lives in Timers.
Schedule
Retry/repeat policies use Duration, not raw integer millisecond fields.
let retry_schedule =
Schedule.exponential(Duration.millis(100))
|> Schedule.jitter(factor = 0.2)
|> Schedule.cap_delay(Duration.seconds(5))
|> Schedule.max_elapsed(Duration.seconds(30))
|> Schedule.limit_retries(5);
Retry.run(policy, f) executes a Retry.policy(schedule, when) in Ship source. After a failed attempt it retries only when the schedule allows another retry and when accepts the error, then sleeps through Clock.sleep:
limit_retries(n)/recurs(n)allownretries after the first attempt. Without a retry limit,max_elapsedalone bounds the attempts; with neither, the first error propagates.spaced(d)waitsdafter each failure.fixed(d)keeps a cadence: it waits what remains ofdsince the failed attempt started.exponential(base)waitsbase * 2^(n - 1)before retryn, saturating instead of overflowing.jitter(factor = f)scales the delay by a uniform draw from[1 - f, 1 + f)through the ambientRandomservice (Random.SystemRandom, orRandom.sequence(values)in tests).cap_delay(max)bounds the delay after jitter.max_elapsed(limit)measures from the first attempt'sClock.mark(); a retry whose delay would end pastlimitdoes not start.The error that stops retrying propagates unchanged.
Schedules touch
Clockonly for delays, cadence, and budgets, andRandomonly for jitter. Executable coverage lives in Retry schedules.
Boundaries
Default wire shapes:
Instant: RFC3339 UTC string, for example"2026-06-02T12:00:00Z".LocalDate: strict ISO date string, for example"2026-06-02".Duration: exact ISO duration string using time units, for example"PT2.25S".MonotonicInstant: no wire shape.Default database shapes:
Postgres
Instant:timestamptz.SQLite
Instant:TEXTusing the same RFC3339 shape.Postgres
LocalDate:date.SQLite
LocalDate:TEXTusing the sameYYYY-MM-DDshape.Postgres and SQLite
Duration: integer nanoseconds. Ship intentionally does not default to Postgresintervalbecauseintervalcarries months/days/microseconds and mixes exact and calendar semantics.Runtime representation:
Native runtime:
DurationandInstantare opaque scalar heap values backed by signed nanoseconds.LocalDateis an opaque scalar backed by a Gregorian day count.JS runtime:
LocalDateuses its validated canonical ISO text internally. This is not exposed as the Ship type representation and avoids JavaScriptDatetime-zone behavior.
Golden Coverage
Current focused coverage:
Runtime duration constructors, arithmetic, ISO parse/format, display, measured
Int<ms>construction, RFC3339 instant parse/format, instant arithmetic, and the checkedClock.SystemClockprovider path.Project builds that use the external Core artifact's lowered
ScheduleIR and inspect duration-backed schedule values.Sleep ordering, yielding, interruption, deadline races, and the virtual and system clocks in Timers.
Retry progressions, jitter, caps, budgets, classification, and interruption under
Clock.virtualandRandom.sequencein Retry schedules.GC rooting of sleeping fibers and their providers under stress in
crates/ship-types/tests/fiber_runtime.rs.Next coverage to add with future runtime slices:
JS backend parity once
DurationandInstantlowering and a browser timer queue exist for the JS target.Negative tests proving
Duration.parse_isorejects calendar months and years.
Follow-Ups
Remaining calendar types:
Period,LocalTime,LocalDateTime,TimeZone, andZonedDateTime.DST ambiguity policy:
reject,earlier,later, orcompatible.Browser timers over the same
TimeTransport.sleep_aftercontract.Timeout and deadline helpers beyond composing
spawnwithClock.sleep.