nts 9.3.0
nts: ^9.3.0 copied to clipboard
Authenticated network time for Flutter apps, secured by Network Time Security (NTS).
Changelog #
Entries for 5.2.4 and earlier live in
CHANGELOG_ARCHIVE.md,
which is kept in the repository but excluded from the published
tarball.
9.3.0 #
Changed #
-
The Native Assets build hook now refuses to build for Android against an NDK below r28 (#335). Android 15+ requires 64-bit native libraries to be 16 KB page aligned; that alignment comes entirely from the NDK clang driver's default
-z max-page-size, which was 4 KB through r27 and became 16 KB in r28.native_toolchain_rustvalidates only to r27, so an older toolchain previously produced a silently 4 KB-alignedlibnts_rust.sothat Google Play rejects. The revision checked is the one the Native Assets build resolves from the Android SDK, which is not thendkVersionthe app's Gradle script pins; current setups are unaffected, since every supported Flutter installs an r28 NDK. The check fails open when the toolchain cannot be identified, so an unrecognised layout does not break the build. -
Bump the exact
flutter_rust_bridgepin from2.12.0to2.13.0in bothpubspec.yamlandrust/Cargo.toml, and regeneratelib/src/ffi/**andrust/src/frb_generated.rsagainstflutter_rust_bridge_codegen 2.13.0(#320). Apps onflutter_rust_bridge: ^2.13.0could not resolve against the old pin. The pin stays exact — the Dart codegen output and the Rust runtime crate share a wire format whose stability across minors upstream does not guarantee, and a mismatch corrupts memory silently rather than failing loudly. -
NtsBridge.dispose()is now a de-initialization (#321).flutter_rust_bridge2.13.0 clears the entrypoint's state before disposing it, where 2.12.0 disposed in place and kept it. SoNtsBridge.statereadsNtsBridgeState.uninitializedafter a disposal rather than being unchanged.NtsBridge.dispose()now drops its own initialization latch to match: without that, a laterNtsBridge.ensureInitialized()would hand back the latched completed future and report success over a bridge holding nothing. Callers that dispose and then re-initialize get a genuine second attempt; callers that never dispose are unaffected. (The wrapper's early return for an uninitialized bridge is unchanged and predates this. Under 2.12.0 it shielded callers from theStateErrorthe rawNtsRustLib.dispose()threw in that case; 2.13.0 made that raw call a no-op when nothing is installed, so the two now agree.) The dartdoc also now states that disposing while anensureInitialized()is in flight is unsupported, and names the two windows in which it misbehaves. -
The raw
NtsRustLib.dispose()is a de-initialization too, andNtsBridge.ensureInitialized()now recovers from one (#321). The entrypoint is exported, so a caller can clear its state without going throughNtsBridge.dispose()— harmless under 2.12.0, where disposal kept the state, but under 2.13.0 it strands the wrapper's latch over a bridge holding nothing. The stale latch is detected and discarded, so the next call runs a fresh attempt. Disposal during an unawaitedensureInitialized()remains unsupported, as it is forNtsBridge.dispose().
Internal #
- The generated bindings pick up two upstream codegen changes
(#321), both
cosmetic: the
Result::<_, ()>::Ok(...)construction is now writtenOk::<_, ()>(...)with the return qualified asstd::result::Result::Ok, andfrb_generated.rscarries a newmismatched_lifetime_syntaxesallow.rustContentHashis unchanged as well, but that is not evidence of wire-format stability: codegen derives it from the sorted bridged function names and nothing else, so it guards against a Dart/Rust function-set mismatch rather than against a signature, codec, or runtime-behaviour change. native_toolchain_ruststays at^1.0.4(#321). From 1.0.5 onward it requireshooks ^2.1.0, which pullsrecord_use ^1.0.0and thereforemeta ^1.19.0. Flutter pinnedmetaexactly through the 3.44 line — 3.38.0, the oldest release this package supports, at 1.17.0, and 3.44.6 at 1.18.0 — so on any of those the bump makes version solving fail outright. Current stable 3.47.1 relaxed the pin to^1.18.3, which does admit 1.19.0, and the bump resolves there; the constraint stays because theflutter: '>=3.38.0'floor keeps the exactly-pinned releases in scope. Revisit once that floor moves past 3.44. The pubspec records the constraint inline.code_assets: ^1.2.1is now a direct dependency (#335). It was already in the graph transitively viahooksandnative_toolchain_rust, buthook/build.dartreadsCodeConfig.targetOSandCodeConfig.cCompilerfrom it for the Android NDK floor check and neither of those packages re-exports it. No new package enters a consumer's dependency graph.rust/fuzz/Cargo.lockmoved to 2.13.0 alongside the main workspace lock (#321). The fuzz workspace is standalone and depends onnts_rustby path, so the new exact constraint would have failed the nightly--lockedfuzz build against the stale 2.12.0 resolution.- Two CI gates needed adjusting for the bump
(#321). The
rust-bridge-synccodegen install now passes--force: its cache key is version-scoped, butSwatinem/rust-cacherestores~/.cargo/binwholesale and can put the previous pin's binary back first, so cargo refused with "binary already exists in destination" on the first run after a bump. Anddependency-reviewgained apkg:pub/flutter_rust_bridgecarve-out — the motivation is not a licence exception, since the package is MIT and already allowed, but pub.flutter-io.cn publishes the field as the lowercase non-SPDX stringmit, which the action cannot validate and so fails closed on.allow-dependencies-licensesis package-scoped, though, so the entry drops FRB from licence evaluation entirely and any licence a future release declares would pass unexamined; the workflow comment records that blind spot and requires a manual re-check on each pin bump. The duplicate-casing entries in that list also went away: purl matching became case-insensitive in v4.9.0, which the pinned v5.0.0 includes. DEVELOPMENT.md's CI job inventory saidci.ymldefines eight jobs when it defines eleven (#321), and the table below it had no rows fordoc-snippetsorcargo-deny. Count corrected, both rows written, and the doc-only skip list in the lead-in prose extended tocargo-denyandandroid-kgp-gate.- Add two rules to the versioning policy in
AGENTS.md(#323). A runtime behaviour change to a public Dart API — existing callers still compile and still resolve, but observe a different return value, post-condition, error, or lifecycle outcome — now requires a major bump, and neither "an upstream dependency forced it" nor a narrow affected surface is an exemption. Shipping one as a minor is still permitted but becomes an explicit deviation: reasoned in the release PR, described by blast radius in the changelog, and recorded in an issue. A tightened dependency constraint that only breaks resolution is excluded — it fails at solve time before any code runs. This release'sdispose()change is named in the policy as the founding case for the deviation, since cutting10.0.0here would strand every consumer on a^9constraint behind the resolution bug this release exists to fix. The reviewer-facing mirrors in.github/copilot-instructions.mdand.github/skills/code-review/architecture.mdcarry the check too, so it applies on release PRs rather than living only in the policy document. - Bump the pinned Rust toolchain in
rust/rust-toolchain.tomlfrom1.97.1to1.98.0(released 2026-08-20) (#324). The FRB bindings are in sync under the new pin with no regeneration needed, and the MSRV inrust/Cargo.tomlis unchanged.clippy::from_iter_instead_of_collectwas dropped from the lint table inrust/Cargo.toml— 1.98.0 removes the lint upstream, and leaving the key emits arenamed_and_removed_lintswarning that-D warningspromotes to a hard error. Allowing that lint instead would silence the same diagnostic for every future rename, so the stale key was removed rather than muted. Nothing replaces it — the retainedformat_collectflagsmap(format!(..)).collect::<String>(), not the explicitFromIterator::from_iter(..)calls the removed lint checked. The crate has no such call sites, so the removal costs no coverage here. - Cap the example app's
share_plusconstraint at>=13.1.0 <13.2.0(#327). From13.2.0the plugin's Android build script configuresKotlinAndroidProjectExtensionunconditionally and skips applying the Kotlin Gradle Plugin whenever AGP is 9 or newer, so it depends on Flutter's Built-in Kotlin to register that extension. The example pins AGP9.2.1, and Built-in Kotlin only arrived in Flutter3.44, butshare_plusstill declaresflutter: '>=3.38.1'— so pub resolves it on the example's3.38.xfloor and Gradle then fails to configure withExtension of type 'KotlinAndroidProjectExtension' does not exist. CI never saw it: the3.38.10matrix leg runs onlypub get,analyze, andtest, andandroid-kgp-gateconfigures the:ntsmodule standalone without a Flutter SDK. The cost of the cap is that the Built-in Kotlin deprecation warning returns forshare_pluson current stable; keeping the floor buildable takes priority, and the cap lifts once the floor reaches3.44— or sooner, ifshare_pluscorrects its declaredenvironment.flutterso the incompatibility becomes solver-visible rather than a Gradle-time failure.shared_preferences_androidneeded no cap — it raised its own floor to3.44in2.4.24, so pub correctly holds it at2.4.23on the old SDK and takes2.4.27on stable, which clears its half of the warning. Example app only; no package API change. - Accept the Flutter 3.47 analyzer migration in
analysis_options.yamlandexample/analysis_options.yaml(#328). Flutter 3.47'spub getinserts ananalyzer.excludeblock coveringbuild/and every platform directory, and re-applies it unconditionally on every run — declining it leaves a permanently dirty working tree for anyone who runspub getbeforeanalyze. Once accepted it is idempotent. No.dartfiles live under any excluded path in either package, so the analyzed file set is unchanged and both packages still report no issues. The generated blocks are repositioned so they do not split theinclude:comment frominclude:in the example, or sit directly beneath the root file's note about what is deliberately not excluded. - Accept the Flutter 3.47 Xcode project regeneration in the example app
(#329). Building
the example on a physical iOS device or on macOS under 3.47.1 raises
IPHONEOS_DEPLOYMENT_TARGETfrom 14.0 to 15.0 andMACOSX_DEPLOYMENT_TARGETfrom 10.15 to 12.0 across all three build configurations, and adds the standardPods/Pods.xcodeprojreference to the iOS workspace. The new floors are Flutter 3.47's own minimums, so declining them leaves the tree dirty after every Apple-platform build, and the generated (gitignored)example/macos/Podfilealready carried the 12.0 floor, so the.pbxprojfiles were the stale side of the inconsistency. Example-only: the package ships no podspec — Apple support goes through Native Assets — so nothing in the published package declares or inherits these floors. - Declare
lib.nameexplicitly inrust/Cargo.toml(#330).native_toolchain_rust1.0.6 readslib.namefrom the manifest and, when it is absent, logs the failed lookup at SEVERE with a full stack trace before falling back topackage.name; the fallback itself logs at FINE and is invisible, so every native-assets build printed onetype 'Null' is not a subtype of type 'String'trace per target triple — six on an Android build — for a non-event. Cargo already defaulted the library name topackage.name, so the declared value is identical and the emitted artefacts are unchanged: verified by rebuilding the example with the hook cache cleared, which emits the samelibnts_rust.sofor all three Android ABIs and the saments_rust.frameworkon macOS. - Raise the example app's Kotlin Gradle Plugin floor from 2.2.20 to
2.3.20 (#331).
Flutter's
DependencyVersionCheckerpinserrorKGPVersionat 2.2.20 andwarnKGPVersionat 2.3.20, so the example sat exactly on the error floor and every Android build printed "Flutter support for your project's Kotlin version (2.2.20) will soon be dropped". 2.3.20 clears the warning and stays within Flutter's documented compatibility range for AGP 9.2.1. Example-only: the published package pins no KGP version —android/build.gradle.ktsresolves whatever the host app puts on the classpath.tool/test_android_kgp_gate.shreads the version offexample/android/settings.gradle.kts, so the CI gate matrix follows without a second edit; all eight assertions still pass. - Move release notes for
5.2.4and earlier intoCHANGELOG_ARCHIVE.md, leaving6.0.0onwards inCHANGELOG.md(#333). The published changelog had grown back to 212 KB — pub.flutter-io.cn renders the whole of it on the package page, and it was again the largest file in the tarball. The cut halves it to 108 KB. This is the second pass of the same exercise the9.0.0release performed at the1.4.0boundary; the archive is tracked in git and excluded from the tarball via.pubignore, so nothing is deleted — the entries only move.6.0.0is the boundary because it is the oldest release whose breaking change (the removal of the pre-3.0NtsErrortypedef aliases) a consumer on a supported constraint could still be migrating past. The preambles in both files, the README's "Upgrading" section, and the.pubignorerationale comment record the new cutoff, and both files stay covered by the doc-snippet validator on the same terms as before.
Documentation #
- Add a "Version compatibility" section to
README.md(#326) recording why theflutter_rust_bridgedependency is an exact pin — the generated Dart and the Rust runtime crate share a wire format that upstream does not guarantee across minors, and a mismatch corrupts memory silently rather than failing loudly — plus the version mapping (nts9.3.x requires2.13.0; 9.2.x and earlier require2.12.0) and the guidance to match the constraint rather than reach fordependency_overrides.
9.2.1 #
Fixed #
- The plugin's Android module gated application of the standalone
Kotlin Gradle Plugin on
android.builtInKotlinalone, which is not a free variable onceandroid.newDsl=true(#316, follow-up to #313). Under the new DSL, AGP does not register the legacycom.android.build.gradle.BaseExtension, and KGP casts theandroidextension to exactly that type as it applies itself — so a host withandroid.newDsl=trueandandroid.builtInKotlin=falsegot aClassCastExceptionpointing at this package's build script. AGP only warns about that combination, so the cast failure was the first hard signal.android.newDslis now read alongsideandroid.builtInKotlin, and the unsupported pairing is rejected up front with a message naming both properties and the fix. The example app'sandroid/app/build.gradle.ktscarries the same gate.
Internal #
tool/test_android_kgp_gate.shconfigures the plugin's Android module standalone — no app project, no Flutter Gradle Plugin — across theandroid.newDsl/android.builtInKotlinmatrix, asserting both the configuration outcome and whether the standalone Kotlin plugin ended up applied. The example app cannot cover thenewDsl=truelegs: its:appproject fails to configure under the new DSL because the Flutter Gradle Plugin resolves the legacyBaseExtensionwith a non-null assertion (flutter/flutter#180137), so isolating the module is what makes those legs testable ahead of that upstream work. Wired into CI as theandroid-kgp-gatejob behind a newandroidpath filter (android/**,example/android/**, and the harness itself).
9.2.0 #
Added #
NtsBridge(lib/src/api/bridge.dart, exported fromlib/nts.dart) — a safe, idempotent lifecycle wrapper over the FRB-generated entrypoint, and the recommended bootstrap from now on:NtsBridge.ensureInitialized({externalLibrary, handler, forceSameCodegenVersion})initialises the bridge if it is not already initialised and completes once it is. Safe to call repeatedly and from more than one code path, whichNtsRustLib.init()is not — that throws aStateErroron every call after the first, and there is no de-init. It is also safe concurrently: FRB'sinitImplassigns its state only after awaiting the external library load, so the guard a consumer would otherwise write by hand (if (!initialized) await init()) races — two callers both observefalse, both enter, and the second throws.NtsBridgelatches on the in-flight future instead. An initialisation performed directly (includingNtsRustLib.initMock()) is recognised rather than fought. Arguments configure the attempt a call actually starts; a call that joins a latched attempt, or finds the bridge already initialised, ignores them.NtsBridge.statereportsNtsBridgeState.uninitialized,.mock, or.native. This is the discrimination consumers previously had to reach into FRB's@internalinstance/apimembers to obtain —MonotonicClockand the example CLI loader both did, each with aninvalid_use_of_internal_memberignore, and both now switch on the enum instead. The mock/native split is structural, onapi is BaseApiImpl, not on the initialisation route: a hand-written double reads as.mockhowever it was installed, and the generated implementation reads as.nativeeven when supplied toinitMock().- Public member dartdocs that stated the initialisation requirement
as
NtsRustLib.init()now nameNtsBridge.ensureInitialized()(NtsClientand its synchronous members,ntsDnsPoolStats,ntsTrustStats, theTrustModeexample).NtsRustLib.init()is still exported and still described where the underlying step is the point. NtsBridge.dispose()releases the bridge's Dart-side resources, or does nothing when it was never initialised.NtsRustLib.dispose()throws in that case. Disposal is not de-initialisation:stateis unchanged afterwards.- A failed
ensureInitializedattempt retains its latch — replaying the error to every later caller — only when the attempt itself took ownership of the entrypoint, which it can only ever do by installing the generated API. A.mockobserved after the failure can only have come from an independentNtsRustLib.initMock(), which is an initialisation that succeeded; the latch is dropped in that case so a later caller completes over the usable mock instead of failing on a stale native-load error. ensureInitialized's dartdoc states that driving the raw entrypoint concurrently with it is unsupported in either direction, and why neither case is detectable. A concurrentNtsRustLib.init(): FRB installs the entrypoint state before awaiting its Rust initializers, so the wrapper can see.nativeand complete while the direct call is still running, and a failure it then suffers is invisible; FRB exposes no way to await someone else's attempt. A concurrentNtsRustLib.initMock()supplying the generated implementation: that also reads as.native, hence is indistinguishable from state the wrapper installed itself, so a failure of the wrapper's own attempt would be replayed over a bridge that is in fact usable. (A hand-written double is not affected — it reads.mock, which is attributable to someone else.) The dartdoc also records the one case a completed direct call leaves behind: aninit()that threw from its Rust initializers leaves the entrypoint installed and permanently unusable, andensureInitializedreports success over it, because FRB records nothing about the failure and the attempt was never latched. That error is the direct caller's to keep.- The live suite (
test/live/nts_live_test.dart) bootstraps throughensureInitialized()rather thanNtsRustLib.init(), and asserts a repeat call completes withstate == .native. That covers the fresh-success branch, which no mock-only test can reach: it needs a real library whose Rust initializers actually run. The mock-only suite covers the already-installed and failed-load branches.
CONTRIBUTING.md— the GitHub-surfaced entry point for third-party contributors. Covers prerequisites, the one-timegit config core.hooksPath tool/hooksopt-in, the branch and pull request loop, the quality gates, the code conventions, and the changelog and release-only versioning policy. It also states explicitly that the maintainer's issue-tracking services (Beads, Dolt, Linear) are not contributor prerequisites:.beads/is inert withoutbdinstalled, and no hook or CI job invokes any of the three. SonarCloud does run as a CI step, but it skips itself whenSONAR_TOKENis absent, as it always is on fork pull requests, and it is not a required check.DEVELOPMENT.mdremains authoritative for the toolchain and CI detail.
Changed #
- Behavioural:
offsetMicrosandpeerDelayMicrosread lower than on 9.1 for the same exchange. T1, the RFC 5905 client transmit timestamp, is now stamped immediately before the C2S AEAD seal that builds the request packet, after the UDP socket is bound and connected. It previously preceded both the packet build and the bind, whileroundTripMicroswas anchored at thesendthat follows the bind, so the two intervals had different start points: the peer delay δ = (T4−T1)−(T3−T2) carried the bind and the NTPv4-host DNS lookup that the round trip excluded, and θ carried half of that interval as apparent offset. Measured against the bundled server catalog, δ ran 1–9% aboveroundTripMicroson every healthy sample — enough that the(0, roundTripMicros]selection windowntsGetTimeapplies rejected δ in practice and theroundTripMicrosfallback was the branch always taken. δ and the round trip now share an anchor, separated only by the request build and seal and the socket write-timeout re-arm that bounds the send against the call's remaining budget — the build and seal memcpy-scale over a packet of a few hundred bytes, the re-arm a single non-blocking syscall, neither waiting on I/O; the window admits δ on healthy samples under ordinary scheduling. It is not reserved solely for implausible exchanges: the worker can still be preempted between T1 and the send, so a loaded host can select the fallback on a healthy sample. That selection excludes a real bias rather than discarding a clean measurement — any intervalpbetween T1 and the send addspto δ andp/2tooffsetMicros, the same arithmetic that made the pre-9.2 ordering a bias, and the fallback keeps thepout of the delay the compensation halves. It does not undo thep/2in θ. Callers that recorded absoluteoffsetMicrosorpeerDelayMicrosvalues from 9.1 or earlier should expect a small downward shift, and any consumer that widened its own δ upper bound to accommodate the setup interval — as the example health prober did — can tighten it to an allowance measured on its own targets.ntsGetTime's synchronized UTC is unaffected in direction: it compensates by half the selected delay either way, but now selects the tighter of the two estimates. - The example health prober's per-sample θ gate
(
example/lib/src/health/probe.dart) tightened accordingly: the allowance overroundTripMicrosis now a flat 5 ms ceiling covering the whole pre-send interval — building and sealing the request packet, and the socket write-timeout re-arm, neither of which blocks on I/O. It previously added the sample's ownphaseTimings.dnsMicros, gated on an inference about whether the query had re-handshaked (since that field sums both lookups a query can make and only the NTPv4-host one fell inside the pre-send interval). No lookup falls between T1 and the send now, so both the DNS term and the inference are gone.
Fixed #
- Android hosts on AGP 9 / Kotlin 2.2+ toolchains failed to compile
android/build.gradle.kts(the plugin's Android module) with a script-compilation error rather than a warning (#313). Three deprecations landed as hard errors: the classicandroid { ... }extension-function accessor is deprecated onceandroid.newDsl=true, the AGP 9 default;kotlinOptions { jvmTarget = ... }had its deprecation level raised toERRORin Kotlin 2.2.0; andAndroidSourceDirectorySet.srcDirs(...)is deprecated in favor of thedirectoriesmutable set. The module now appliesorg.jetbrains.kotlin.androidonly when built-in Kotlin is not in effect. Gating on the resolved AGP major version alone is not sufficient, since AGP 9 still supportsandroid.builtInKotlin=false(and this package's Flutter 3.38 floor has no fallback that applies KGP on that compatibility path), so the effectiveandroid.builtInKotlinGradle property is checked too. The module also configures theandroidextension viaconfigure<LibraryExtension> { ... }, movesjvmTargettokotlin.compilerOptionsbehind aplugins.withId("org.jetbrains.kotlin.android")guard, and adds tojava.directoriesinstead of callingsrcDirs(...). Verified against both AGP 8.11.1 (the example app's previous pin, unaffected) and AGP 9.2.1 withandroid.builtInKotlin=false(Flutter 3.44's current forced default). Theandroid.newDsl=true/android.builtInKotlin=truecombination was verified for the plugin's Android module in isolation, not end to end through an app build: no Flutter app can configure underandroid.newDsl=truetoday, because the Flutter Gradle Plugin itself resolves the legacyBaseExtensionwith a non-null assertion (flutter/flutter#180137). The example app's ownandroid/app/build.gradle.ktsfollows the same pattern, and its AGP pin moved to 9.2.1 with the Gradle wrapper on 9.7.1. - The UDP socket's write timeout is now re-armed against the call-wide
deadline immediately before the
send, matching the re-arm therecvalready had. The bind-time value is anchored at bind completion, and the T1 stamp and the C2S seal now sit between that anchor and thesend; the seal does no I/O, but the worker can be preempted across it, so the bind-time value was stale by an unbounded amount. A blockingsendcould therefore run for the full bind-time budget on top of the time already spent, overshooting the single wall-clock budgettimeoutdocuments. An already-lapsed budget now fails withNtsError.timeout(TimeoutPhase.ntp)instead of putting a packet on the wire the caller has stopped waiting for. - The AES-SIV-CMAC paths (AEAD IDs 15 and 17) migrated to the
aes-siv0.8 API, completing the move of both SIV families onto the RustCryptohybrid-arraytraits line. The crate dropped itsaead::generic_arrayre-export, soSivKey::cipherandSivKey512::cipherconstruct the key array via the infallible&[u8; N]conversion instead ofGenericArray::from_slice, and theaes_gcm_siv::KeyInitimport is no longer needed now thataes_siv::KeyInitis the same trait. The dependency also gains an explicitzeroizefeature: 0.7 wipedSiv::encryption_keyinDropunconditionally, 0.8 gates that wipe behind an optional dependency absent fromdefault, so adefault-features = falsebuild that carried the old feature list forward would have dropped the wipe of the key copy eachcipher()call makes — silently, with nothing failing to flag it. A new compile-time assertion pinsAes128SivandAes256SivasZeroizeOnDropso removing the feature again fails to build. With that in place there is no behavioural change: key handling, the zeroization derives, and the wire format are untouched. Withaes-sivoff the old line, themultiple-versionsgate incargo denysheds six version-pinned skips (aead,aes,cipher,cpufeatures,ctr,inout); the remaining duplicates —block-buffer,crypto-common, and nowdigest— are held byflutter_rust_bridge_macros -> md-5 -> digest 0.10and expire when that chain moves. - The documentation no longer claims that bridge initialisation is a
no-op after the first call.
NtsRustLib.init()throws aStateErrorinstead, so the claim was wrong everywhere it appeared, and the README went further and drew an operational conclusion from it ("safe to call from a shared bootstrap path") that described precisely the usage that throws. The library dartdoc inlib/nts.dart, the README's two-layer initialisation section, quick start, platform support, non-Flutter loader guidance and API summary,example/main.dart,example/example.md, andARCHITECTURE.mdnow documentNtsBridge.ensureInitialized()as the bootstrap and describeNtsRustLib.init()accurately as the single-shot raw entrypoint. TheNtsSyncedTimeclass and constructor dartdoc inlib/src/api/models.dartstate their initialisation prerequisite the same way, keepingNtsRustLib.initMock()as the test alternative. (Theno-opwording inandroid/.../PlatformInit.ktis correct and unchanged — that bootstrap really is idempotent.) - The README's non-Flutter loader guidance no longer describes a later
ensureInitialized()call passing a different library as a "no-op", and both it and theensureInitializeddartdoc now warn that ignored is not unloaded.ExternalLibrary.openmaps the library synchronously inside its own constructor, so the argument's load-time initializers have already run by the timeensureInitializedis entered and can decide to discard it. The library-hijack surface the section exists to describe is closed by nominating one call site as the initialization owner and constructing the library only there, not by a later call being ignored, and the guidance says so. It also says whatNtsBridge.stateis not: a way to tell whether a call will be the one that initializes. The getter rules out a completed initialization only — a latched attempt still awaiting its library load has installed no entrypoint state, sostatereadsuninitializedfor that whole window. - The CI matrix's old-SDK leg is no longer documented as exercising the
declared SDK floor. It runs Flutter 3.38.10, which is ten patches
above the
flutter: '>=3.38.0'constraint and is not the oldest release satisfying it — earlier 3.38.x patches do not build native dependencies through the Native Assets build hook reliably, so a leg pinned to the literal floor would fail for reasons unrelated to this package's sources. The pin is deliberate and unchanged; what moved is the claim attached to it inci.yml,pubspec.yaml,DEVELOPMENT.md(both the bullet and the workflow table), and the pull request template, all of which described 3.38.10 as the declared floor. The declared floor is a dependency-resolution bound, not a build-verified one, and the comments now say which is which. - The pull request template no longer asks every contributor to bump
pubspec.yamlversion:following semver. That instruction contradicted the release-only bumping policy, under which version fields move only in a dedicated release commit. The checklist item now asks for the field to be left untouched, with an explicit carve-out so a release PR can tick the same box truthfully. - The pull request template illustrates the issue-reference
convention with a placeholder (
NTS-<num>) rather than a real, long-closed issue identifier, so the example cannot be pasted through into a pull request that has nothing to do with it. - The example CLI loader rejects a bridge of the wrong kind in both
directions rather than only one.
mockBridgeDispositionbecomesbridgeDisposition(state, useMock:), and a native run that finds an installed mock now exits with the same diagnostic shape as a--mockrun that finds an installed native bridge. Previously that direction fell through toNtsBridge.ensureInitialized(), which completes for any installed state, so the run reported success and then dispatched every call toMockNtsApi. Example app only; no package API change. - The example app's GUI bootstrap installs its fallback mock only when
the failed
ensureInitialized()left the bridge uninitialized. A Rust-initializer failure leaves itnativebut half-built, and the unconditionalinitMockthrew a secondStateErrorover the top of the real error, so the banner never rendered. Example app only; no package API change. - That other arm now aborts to a
Bridge unavailablescreen rather than proceeding into the normal UI. No mock can stand in for a half-built entrypoint, so bootstrap carries abridgeUsableflag andmain()short-circuits on it: noAppState, noNtsController, and so noNtsClientminted over a bridge every call would throw through. Previously the arm fell through, and the UI then misreported itself — the corner banner readsmock fallbackoff any non-null load error, andAppState.bridgeLoadErrorwas documented as implying one had been installed.AppStategains amockFallbackflag that says whether it actually was, the corner banner is driven off that rather than off the error, andbridgeLoadErroris described as what it is: a bootstrap diagnostic that a catalog failure also populates. Ashell diagnosticsgroup covers the resulting three-way split through the publicNtsExampleApp: no diagnostic renders neither banner, a diagnostic without a fallback renders the error banner alone, and a fallback renders both. The middle case is the one the old condition got wrong, and it fails against it. The dead-end screen is public asBridgeUnavailableAppso a fourth case can pump it directly;main()'s branch into it cannot be driven from a test, since reaching it needs a real half-built entrypoint. Example app only; no package API change. - That arm now returns from bootstrap immediately, via a
_Boot.bridgeUnavailablevariant, instead of setting a flag and falling through the remaining steps. Loading the server catalog and hydratingSharedPreferencesfor a UI that will never be built was wasted at best, and at worst lost the bridge error: the catalog arm prefixes rather than replaces, but an uncaughtFavoritesStore.load()failure propagated out of bootstrap andBridgeUnavailableAppnever rendered._Boot.favoritesis now nullable, non-null exactly whenbridgeUsableis true. Example app only; no package API change. - The example CLI loader awaits
NtsBridge.ensureInitialized()on the native reuse arm rather than returning immediately. A retained initialisation failure lives on the wrapper's latch, so returning there converted an installed-but-half-built bridge into apparent success; awaiting it surfaces the original error and exits 70 like every other load failure. Mock reuse still returns directly — a mock is usable the momentinitMock()returns, and routing it through the wrapper would latch a completed future over state the wrapper never installed. Example app only; no package API change. - The example app moves from
file_picker^12.0.0-beta.7to the^12.0.0stable release.FilePicker.pickFiles()now returnsList<PlatformFile>rather than a nullable result object, soCustomRootsPanel._pickFilecalls the single-fileFilePicker.pickFile()instead, which returnsPlatformFile?and matches what the panel wants. The macOS generated plugin registrant follows the plugin's split into federated packages (file_picker→file_picker_darwin). Example app only; no package API change. AGENTS.mdandCLAUDE.mdnow say which of their sections are maintainer workflow. Both open with a short note on who the file is for, and every section covering the maintainer's issue tracking (Beads, DoltHub, Linear, the assignee convention) carries aMaintainer-onlymarker naming the tooling it presumes. The contributor-relevant material — pull request workflow, branch protection, shell conventions, the doc-snippet validator, versioning, and the zeroization policy — is deliberately unmarked.
9.1.0 #
Added #
-
Copilot code review now follows a review protocol tracked in the repository. A
code-reviewagent skill (.github/skills/code-review/) carries the protocol, the architecture-specific checks, and a mandatory summary-comment format;.github/copilot-instructions.mdholds the repository-wide guidance, and.github/instructions/adds path-specific guidance for**/*.dartandrust/**/*.rs. MCP server availability is a repository-settings concern rather than a tracked file, and is documented inDEVELOPMENT.md.The checks are grounded in the surfaces where this repository's defects actually appear: generated-binding drift across the FRB boundary, hand-maintained lists mirroring the sealed
NtsErrorhierarchy that the analyzer cannot see, the two distinctTrustModefallback paths inke.rsandhybrid_verifier.rs, the zeroization rules, and release-only version bumping. The protocol also instructs the reviewer to report every finding rather than suppressing low-confidence ones — on PR #292 four suppressed comments were all valid and two were substantive. Repository tooling only; no package or example change. (NTS-149) -
The example CLI (
example/bin/nts_cli.dart) can now select a trust-anchor policy and assert the backend the call resolved.--trust-modetakesplatform-with-fallback(default),platform-only,bundled-only, orcustom;--custom-roots <path>supplies the PEM bundle or DER certificate thecustommode requires;--require-trust-backendasserts that every call resolved a namedTrustBackend, reportingTrustBackendMismatchinstead of success when it did not and counting that as a failure for--exit-on-error. The assertion reads the attribution off failures too, so a mismatch replaces either a success or an attributed failure: a call that resolved the wrong anchor set and only then lost the NTP leg reports the mismatch rather than the timeout it surfaced as.This is a backend-resolution assertion, not evidence that a chain was verified. Rust attaches the initial backend once
build_tls_configreturns, which is before any DNS, connect, or TLS I/O, so a DNS or connect failure on an attributed variant carries one too and an unreachable host can be reported as mismatching. That is the intended scope — the policy under test is which anchor set the call was configured to trust. The one value not fixed at config-build time is Android'splatformWithHybridFallback, which replaces the initialplatformonly after the webpki-roots fallback verifier accepted a chain during TLS verification, so that attribution does evidence a verified chain. Four variants (invalidSpec,trustBackendUnavailable,internal,abiMismatch) have no attribution field, so they keep their own error type even when raised downstream of config-build.Previously every run went through the top-level
ntsQuery/ntsWarmCookiesand therefore the process-wide default client, whose mode is fixed atTrustMode.platformWithFallback— the most permissive policy the package offers — so the tool could neither probe under a stricter policy nor detect a silentwebpki-rootsfallback. A non-default mode now mints one call-scopedNtsClientfor the batch and disposes it after the fan-out; the default path is unchanged and constructs no client. Under--json,trust_modeandrequired_trust_backendare emitted only when their flag was passed, so a flagless run's records are unchanged. Example app only; no package API change. (NTS-146) -
The two catalog tools (
example/bin/nts_health.dart,example/bin/nts_manifest.dart) accept the same--trust-mode,--custom-roots, and--require-trust-backendflags, so a whole server list can be vetted under a stricter trust policy rather than only the hostnames passed tonts_cli. The flags live on the sharedaddCommonProbeOptionsblock, so both tools gain them together.A non-default policy mints one call-scoped
NtsClientfor the whole catalog and disposes it after the probe wave; the default path constructs no client and keeps routing through the top-level functions, so a flagless run is unchanged.--require-trust-backendis asserted on the NTS-KE warm and on every sample's own attribution, since a query re-handshakes once the warmed cookie pool is spent or its session was evicted, and on the attribution carried by a failed call, which is what a wrong-backend re-handshake that then fails looks like; the first mismatch abandons the rest of the host's run and is classified as a severeTrustBackendMismatchKE-stage failure, which makes the hostnonConformingand therefore a drop candidate for--fail-on-dropsand an exclusion from the generated manifest.All three CLIs validate the
--trust-mode/--custom-rootspairing during argument parsing rather than leaving it to theNtsClientconstructor. The constructor runs afterinitBridge, so on a machine with no loadable dylib an invalid pairing previously exited with the bridge-load code instead of the usage code both README exit-code tables document.The
--custom-rootsbuffer is wiped in place once the client has copied it. The package zeroises only the copy it makes at the FFI boundary and documents the caller's list as read-but-never-retained, so wiping the caller-side bytes is the caller's job — and the buffer was otherwise reachable for the rest of the run. Because the roots are read early (so an unreadable path is an argument error rather than a trust-policy one), several terminations sit between that read and the wipe: the remaining flag validation,nts_manifest's--per-regioncheck, the catalog load, and everyinitBridgefailure.exitterminates the VM without unwinding, so afinallycannot cover them; the buffer is instead registered on read and cleared at each of those sites, with the client construction keeping afinallyfor the success and non-NtsErrorpaths.loadAndProbeCatalogre-registers on entry, since its argument object is publicly constructible and can therefore carry roots the parser never saw. Example app only; no package API change. (NTS-147) -
RFC 8452 known-answer vectors for AEAD ID 30 (the §8 worked example and a §C.1 case with a multi-block AAD), driven through
seal_packet/open_packet, plus an open-path counterpart to the existing GCM-SIV nonce-length rejection test. -
The example package's hand-built list of
NtsErrorsamples (example/test/nts_format_test.dart) is now guarded by a_NtsErrorKindenum and an exhaustive_variantKindswitch. Dart has no reflection over sealed subtypes, so the list is written out by hand and had no way to notice a new variant; every property asserted over "everyNtsErrorshape" was therefore only as complete as that list. Adding a variant to the sealed type is now adart analyzeerror in the switch, and omitting its sample from the list fails a test. The tag, severity,timeoutPhaseName, anderrorTrustBackendassertions are driven off the same pivot rather than hand-enumerated — two of those lists had already drifted, missingabiMismatchandtrustBackendUnavailable. Example tests only; no package or example behaviour change. (NTS-148)
Security #
aes-gcm-sivis now pinned to 0.12 with itszeroizefeature requested explicitly. 0.11 depended onzeroizeunconditionally; 0.12 made it an optional feature that is absent fromdefault, so this crate'sdefault-features = falsebuild would otherwise have silently stopped wiping the derived POLYVAL and AES subkeys and the per-message counter block thatAesGcmSivallocates on the stack per operation. The resolvedzeroizeremains 1.9, above the ≥ 1.8 floor the project's zeroization policy requires.
Changed #
-
The example health prober (
example/lib/src/health/probe.dart) now reports each sample's clock offset fromNtsTimeSample.offsetMicros— the RFC 5905 §8 offset θ computed natively from the four NTP exchange timestamps — instead of deriving one asutcUnixMicros + roundTripMicros / 2 − DateTime.now(). That derivation carried two error terms θ does not, pulling in opposite directions: half the round trip includes the server's own processing time between recv and send, which overstates the offset, while the local reading was taken on the Dart event loop — after the FFI return and worker-thread handoff — and is subtracted, so scheduling lag understates it. Neither cancels the other, and their sum is a function of load rather than of the remote clock. Against a machine measured at +83 ms bysntp, the catalog tools were reporting +90–100 ms. θ has been onNtsTimeSamplesince 7.1; the prober predated it.θ is only meaningful if the local clock was not stepped between the T1 and T4, and unlike
ntsGetTime— which reports θ as a statistic — this module feeds it into a verdict that decides whether a host stays in the catalog. Samples are therefore screened on the peer delay: a value that is not a positive duration cannot be a real delay, so θ is suppressed rather than trusted.ProbeOk.offsetMicrosis now nullable to carry that, suppressed samples are excluded from the median instead of counting as a zero offset that would mask a real skew, and a host with no usable sample is not flagged on an offset it never observed — it reports aclock offset unavailable (no corroborated sample)note. The CSV report gains a trailingnotecolumn, and the text report renders the note in the non-standard section as well as the healthy one, so that explanation reaches every output format and every bucket a host with a suppressed offset can land in.The upper bound is tolerant rather than strict, since a forward step adds to the peer delay instead of subtracting and would otherwise slip past a lower bound alone. The bound
ntsGetTimeapplies (peerDelayMicros <= roundTripMicros) does not hold on this client: T1 is captured before the request packet is built and the UDP socket bound, whileroundTripMicrosstarts at the send, so the peer delay legitimately includes a pre-send interval the round trip excludes. Measured across the bundled catalog it exceeds the round trip by 1–9% on every healthy server, so asserting that bound would suppress every real sample; the prober admits up toroundTripMicros + phaseTimings.dnsMicros + 5 msinstead. That allowance is additive rather than a multiple of the round trip because the pre-send interval includes the NTPv4-host DNS lookup, whose latency is unrelated to the round trip — a ratio-derived ceiling would reject a healthy sample from a nearby server behind a slow resolver. It is measured from the sample rather than derived from the verdict threshold: on a sample that ran no handshakednsMicrosis that lookup alone, and the remaining 5 ms covers the packet build and the bind, which do no I/O. A threshold-derived allowance would have bounded the undetected step at the threshold and so the undetected corruption of θ at half of it, which is enough to move a host whose true offset is non-zero across the verdict line. The lookup term is claimed only where it is attributable: the burst runs against a pre-warmed pool, but an exhausted pool or an evicted session makes a sample re-handshake, anddnsMicrosthen also carries a KE-host lookup that completed before T1. Such a sample is allowed the flat 5 ms only.A second screen corroborates θ across the burst: a surviving sample's θ is kept only if some other surviving sample agrees with it to within half the sum of the two samples' round trips. Asymmetry is the honest source of disagreement and displaces a sample's θ by at most half its own round trip, so that sum bounds what an honest pair can differ by, while a step of S displaces one sample's θ by S/2 and so escapes the window once S exceeds the sum. The window is per pair rather than one figure for the burst: a retransmit or a queued reply makes one sample far slower than its neighbours, and a burst-wide minimum would suppress such a pair over jitter neither is at fault for — which is not the safe direction to err, since a host left with no surviving θ is judged without the clock check at all. The scale is drawn from
roundTripMicrosrather than the peer delay because the round trip is measured on a monotonic clock, so no step can widen the window in either direction, and because it excludes the pre-send interval, which is not a property of the path. This is what rejects a step small enough to pass the per-sample bound but large enough to move the median across the threshold. Neither screen proves the clock was steady: a step that disturbs neither the peer delay beyond the setup cost nor the burst's agreement is not detected. Example app only; no package change. (NTS-152) -
Docs:
NtsTimeSample.offsetMicrosdescribed θ's vulnerable window as spanning the UDP send and recv. It actually opens at T1, which precedes the packet build and the socket bind, so a step during that setup corrupts θ too — and the same early T1 biases θ upward by half the setup interval even on a steady clock (sub-millisecond to a few milliseconds, from the 1–9% measurement above). The rustdoc, generated bindings, and wrapper dartdoc now say so and point at NTS-153. Documentation only; no behaviour change. -
Docs:
NtsTimeSample.peerDelayMicrosdocumented δ as always<= roundTripMicroson a steadily-running clock, and a value outside(0, roundTripMicros]as a clock-step signal. The upper half of that is false on this client for the capture-point reason above — δ measured above the round trip on every healthy sample across the bundled catalog — so the(0, roundTripMicros]windowntsGetTimeapplies is a selection policy rather than a verdict on the sample: it takes theroundTripMicrosbranch in practice rather than distinguishing stepped samples, and only its lower bound is diagnostic. That the round trip wins is an empirical result, not an identity: δ = setup + roundTrip − serverProcessing, so δ clears the ceiling only while the pre-send setup cost outweighs the server's T3−T2, which held across every catalog server measured. A non-positive δ is also no longer attributed to a local step specifically: it witnesses an implausible timestamp exchange, which a server clock stepped between T2 and T3, or inconsistent server stamps, produce just as well. The tolerant upper bound the field recommends to consumers now points atphaseTimings.dnsMicrosas the measurable part of the pre-send interval. The field's rustdoc, the generated bindings, and the wrapper dartdoc now say so, and point at NTS-153 for aligning the capture points.README.md,ARCHITECTURE.md, andexample/GUI_GUIDE.mdcarried the same superseded claim — each describedntsGetTimeas selecting the peer delay — and now all three lead with the round-trip branch taken in practice, scoped to the catalog measurement, citing the window as the condition rather than as a plausibility judgement. The remaining API-doc sites that still called an in-window δ "plausible" (nts_queryandNtsTimeSample::utc_unix_microsin the rustdoc and their generated and wrapper counterparts, plusNtsSyncedTime) now use the same selection-window framing, so the public API surface no longer contradicts the prose. Documentation only; no behaviour change. -
The AES-128-GCM-SIV path (AEAD ID 30) migrated to the
aes-gcm-siv0.12 API. The crate moved to the RustCryptohybrid-arraytraits line, soKeyInitnow comes fromaes_gcm_sivrather thanaes_siv, and the deprecatedArray::from_sliceconstructors are replaced by the infallible&[u8; 16]conversion for the key and aTryFromconversion for the nonce. The nonce conversion subsumes the hand-written length check inseal_packet/open_packet, which now maps its failure to the sameInvalidNonceLengtherror — no behavioural change on either path.aes-siv(AEAD IDs 15 and- is still on the older
generic-arrayline because its 0.8 release is release-candidate only, socargo deny'smultiple-versionsgate carries version-pinned skips for the eight duplicated RustCrypto crates. Six of them expire whenaes-siv0.8 ships;block-bufferandcrypto-commonare additionally held byflutter_rust_bridge_macros -> md-5 -> digest 0.10and will remain needed until that chain also moves.
- is still on the older
9.0.0 #
Breaking #
-
The cumulative counters on
NtsDnsPoolStatsandNtsTrustStatuschanged fromBigInttoint:recovered,refused,spawnFailed,defaultBackendPlatformCount,defaultBackendHybridCount,defaultBackendWebpkiCount,defaultBackendCustomCount, andandroidHybridFallbackCount.These were the last
BigIntfields on the public surface. They wereBigIntonly because the Rust structs behind the bridge declared themu64, which FRB binds toBigInt; the stated rationale on the fields — that a 32-bit wraparound would be visible on long-running builds — justifies the 64-bit backing store, not theBigIntbinding. The backing counters remainAtomicU64; only the bridge-facing struct fields are redeclared asi64, which FRB binds asPlatformInt64and the conversion layer narrows to a plainint. This matches whatPhaseTimingsandntsBoottimeMicrosalready did, and removes the split insideNtsDnsPoolStats, whoseinFlight/highWaterMarkwere already plainint.u64→i64is range-narrowing, so the overflow policy is explicit: the projection saturates ati64::MAXrather than wrapping, keeping the published sequence non-decreasing. The clamp is unreachable in practice — a counter bumped once per DNS lookup or per handshake would need 2^63 events to reach it. Web remains unsupported (NTS-KE needs a raw TCP socket), so the 53-bit JavaScript integer limit is not a consideration.Migration: drop
BigInt.from(...)at construction sites and compare against plain integer literals.stats.refused > BigInt.zerobecomesstats.refused > 0. Both DTOs are nowconst-constructible with literal counters. The wire layout is unchanged — the counters still cross the boundary as 8 bytes each — so no native rebuild is required beyond the regenerated bindings.
Security #
-
NTS cookies are now capped at 512 octets and client NTP requests at 1200 octets. RFC 8915 deliberately leaves the cookie opaque and unbounded (§4.1.6, §5.4) because only the issuing server needs to interpret it; deployed servers issue roughly 100 octets. The client previously accepted whatever a KE server sent, bounded only by the overall KE message budget. That mattered beyond the one allocation because
build_client_requestsizes each cookie placeholder to the cookie it is standing in for, so a single oversized cookie inflated every subsequent NTP request by roughly twice its length — a KE-side input silently driving UDP datagram growth on the query path, past the point of IP fragmentation and into MTU black-holing.Oversized cookies are rejected at two points, with deliberately different policies. During KE record decoding the whole message fails with the new
CodecError::CookieTooLarge, checked before the body is copied, so the handshake stays atomic — a partial harvest would silently degrade the pool. In an AEAD-authenticated NTP response the oversized entries are filtered instead: the time sample is sound, and discarding it would trade a real synchronisation for cookies the client is free to ignore. Conforming cookies in the same packet are still deposited, the drop count is reported onServerResponse::oversized_cookies_dropped, and ants::ntpwarning is logged so the slower-than-expected pool refill is observable rather than silent.build_client_requestalso projects the full on-wire packet size — header, unique identifier, cookie, placeholders, and authenticator, each padded to the 4-octet extension alignment — and refuses with the newNtpError::PacketTooLargebefore allocating. The cookie cap alone does not bound the packet, becauseplaceholder_countis caller-supplied and each placeholder is sized to the cookie. The 1200-octet limit is the RFC 8200 §5 minimum MTU less headers, with margin for tunnel encapsulation. The projection doubles as the allocation hint, replacing a fixed guess. (NTS-125) -
The per-client session table is now bounded, so cached AEAD keys and cookie jars are no longer retained for the life of the process.
SessionTablepreviously held everyhost:portit had ever handshaken with until an explicitinvalidate/clearor a rekey signal for that exact key — a caller that rotated through many servers, or that derived host strings from untrusted input, accumulated key material without limit and had only manualclear()as a remedy.Two bounds now apply. A hard ceiling of 64 entries evicts the least-recently-used session to make room for a new host, ranked by a per-session stamp that each successful cookie draw refreshes so an actively-used session is never the victim; re-handshaking a host already cached replaces it in place and evicts nothing. Independently, any session idle for 24 hours is dropped. That stamp is a
BootInstantrather than anInstant, so idle time keeps accruing across device suspend — underInstanta table populated before a long sleep would hold its keys for the sleep duration on top of the TTL, which is exactly the backgrounded-app case the TTL exists to cover.Both bounds are swept whenever a session is installed, and the TTL is additionally checked when a cached session is drawn from. The second check is what makes the TTL bind for a process that goes quiet and then queries the same host again: that path installs nothing, so without it the stale session would be served and its stamp refreshed, and the entry would never age out.
Eviction drops the
Session, releasing itsZeroizeOnDropAEAD keys and its cookie jar, so the bound is on secret retention and not merely on memory.invalidate(spec)andclear()are unchanged and remain the eager controls for callers that need a session gone at a specific moment. No public API changes; the bounds are internal policy. (NTS-124)
Added #
-
NtsClient.dispose()releases the client's native handle — and with it the session table, its cached AEAD keys, and its cookie jars — at a moment the caller chooses, instead of leaving it to the GC finalizer. The method existed internally (onlyntsGetTime's call-scoped client used it) and is now public.Optional, not required: the finalizer remains the backstop, so a client that is simply dropped is still reclaimed. What was missing was any way to make the timing deterministic — a client scoped to a work batch, a screen, or a test could pin native state well past the point the app considered it dead, and an app minting many short-lived clients had no lever at all short of GC pressure.
Distinct from
clear(), which empties the session table and leaves the client usable;dispose()ends the client. Idempotent, and safe to call with aquery/warmCookies/getTimealready executing on the native side: such a call took its own reference to the native object when its arguments were encoded, and runs to completion. A call still queued at the bridge admission gate has not encoded its arguments yet, so it is refused once admitted, as is any method called afterdispose(); the refusal is an FRBFrbExceptionrather than anNtsError, since the failure is in the handle rather than in the protocol. (NTS-114) -
TimeoutPhase.dnsSpawnFaileddistinguishes "the OS refused to create a DNS worker thread" from the pool-cap refusal already reported asTimeoutPhase.dnsSaturation. Additive enum growth:switchstatements overTimeoutPhasethat were previously exhaustive will now need a case for it (or adefault). -
NtsDnsPoolStats.spawnFailedcounts those refusals, disjoint from bothrefused(admission blocked by the cap) andrecovered(a detached worker that actually ran). The pairrefusedvsspawnFailedis what makes the cap-vs-ceiling distinction observable without parsing error strings, since both refusals surface asWouldBlockinternally. Callers constructingNtsDnsPoolStatsdirectly — test fixtures, chiefly — must pass the new required field. -
NtsTimeSample.keWarningsandNtsWarmCookiesOutcome.keWarningsexpose the non-fatal NTS-KE warning codes a server sent with the handshake (RFC 8915 §4.1.4 record type 3) asList<int>raw code values, in the order received. Previously the KE layer parsed these records but discarded them, so a server signalling a warning was indistinguishable from one that sent none. Empty for every server observed in practice — the IANA NTS-KE warning registry has no assignments as of RFC 8915 — so a non-empty list means the peer sent a code this client version cannot interpret. Codes are surfaced, not acted on: nothing here fails a query, since by definition a warning did not stop the handshake. A non-empty list is also logged once per handshake atwarnon targetnts::ke.A warning describes the handshake, so on
NtsTimeSamplethe value follows the session across cached-session queries rather than resetting to empty likephaseTimings— matching howtrustBackendalready behaves. A caller polling in steady state therefore cannot miss codes by having started after the cookie pool went warm. OnNtsWarmCookiesOutcomethere is no cached-path nuance, since that call always runs a fresh handshake; a singleflight waiter that collapsed onto a concurrent leader reports the leader's codes, asfreshCookiesandtrustBackendalready do.Additive and source-compatible: both fields default to
const [], so existing constructor calls andNtsTimeSamplefixtures compile unchanged. Callers that destructure exhaustively or compare DTOs against hand-built expected values will observe the new field in==,hashCode, andtoString. (NTS-127) -
New advisory CI workflow
.github/workflows/cross-platform.ymlruns the Rust live probes and thetest/live/Dart suite on bothubuntu-latestandwindows-latest, weekly (Mondays 07:00 UTC) and on manual dispatch. It adds the first CI coverage of the Windows-conditionalwindows-sysarm behindnts::boottime, and the first CI run of the Dart live suite on any platform. The workflow is not a required status check: its steps depend on public NTS server reachability, so a red run is a signal to triage rather than a merge blocker. Repository infrastructure only — no packaged code changed. (NTS-12) -
The
dependency-reviewjob now carries anallow-dependencies-licensescarve-out for build-time GitHub Actions, separating them from the NTS-72 SPDXallow-licenseslist. That list is a distribution policy governing what may be linked into the published package or thents_rustcdylib, which is why it is kept in lockstep with[licenses].allowinrust/deny.toml; actions are a different population, executed on an ephemeral runner and never conveyed to a user. Exemptions are named per-action rather than per-licence so a future copyleft action must be added deliberately. One entry today:Swatinem/rust-cache(LGPL-3.0), already used byci.ymlandfuzz.ymland surfaced only because the new workflow above introduced it "newly" from the diff's perspective. Repository infrastructure only — no packaged code changed, and the distribution policy is unchanged. (NTS-12)
Fixed #
-
NTS-KE handshakes against servers that clear the Critical bit on the AEAD Algorithm record now succeed instead of failing with
NtsError.keProtocol. RFC 8915 §4.1.5 states that the Critical bit on this record MAY be set — it is the deliberate exception to the MUST imposed on EndOfMessage (§4.1.1), Next Protocol (§4.1.2), Error (§4.1.3), and Warning (§4.1.4). The parser enforced the bit by false symmetry with the Next Protocol check, making every conforming server that clears it permanently unreachable; members of the publicntp.brpool do exactly this, and becausegps.ntp.brresolves to two addresses that disagree, the failure presented as intermittent.A cleared bit is now recorded at
debuglevel under thents::kelog target and the handshake continues, matching the treatment already given to unknown non-critical records (§4.1.4). No downgrade surface is introduced: the record is carried inside the TLS channel, so an on-path attacker can alter neither the bit nor the algorithm identifier, and the returned identifier is still validated against the client's offered list. The Critical-bit requirement on the Next Protocol record is unchanged — §4.1.2 genuinely says MUST. (NTS-138) -
The Dart-side copy of
customRootsis now wiped after the FFI handoff instead of being left readable until the GC runs. TheNtsClientconstructor copies the caller'sList<int>into theUint8Listthe FFI encoder requires; the Rust side holds its equivalent in aZeroizing<Vec<u8>>(CustomRootsBytes), so the intermediate Dart copy was the weaker end of that story for deployments where the anchor set itself is confidential. The copy is overwritten with zeros in afinally, so it is cleared on the throwing path too — the case where the bytes would otherwise be both unreachable and unwipeable.Bounded, not total, and the constructor dartdoc now says so. Two copies stay outside the package's reach: the caller's own list, which is theirs to manage and is never mutated, and the FRB serializer buffer the encoder writes into, which is upstream-owned — the same class of residue the Rust-side
CustomRootsBytesdocs already record for the PEM parse path. -
The ABI-mismatch conversion no longer rewrites a bare
ArgumentErrorasNtsError.abiMismatch. The wrapper widens its catch around the FFI call to convert codec decode failures — bytes the generated codec cannot read against the layout it was built for — into an error naming the rebuild. The predicate matchedArgumentErroralongsideRangeErrorandUnimplementedError, which is the widest of the three: it swept in throws that have nothing to do with the wire layout, answering an unrelated diagnostic with "rebuild the native library from the Rust sources" and sending the reader somewhere the fault is not.Driving the real generated
sse_decode_*functions over malformed buffers shows every drift shape they produce is aRangeError(a short buffer, or a fieldless-enum index past the end ofvalues) or anUnimplementedError(an unrecognised variant tag). No shape yields a bareArgumentError, so matching it bought no coverage. The predicate is now those two shapes;RangeErrorremains matched in its own right rather than via itsArgumentErrorsupertype. Anything else thrown from inside the call — a bareArgumentError, aFormatException, theStateErrorFRB raises for a missedNtsRustLib.init()— reaches the caller unchanged. The codec-driven tests now assert membership in exactly those two shapes, so a decoder that started throwing something else fails the suite rather than quietly relying on a broader catch. -
A system clock reading before the Unix epoch no longer produces an all-zero NTP transmit timestamp. On a device whose RTC has reset to 1970-or-earlier,
SystemTime::now().duration_since(UNIX_EPOCH)fails and the conversion returned0, so every query on that device sent an identical T1. The server echoes T1 back asorigin_timestamp, and the client checks the echo — a constant makes that check pass for any captured response, not just the one it was sent for, weakening it as an anti-spoof signal precisely on the devices whose clocks are least trustworthy.The pre-epoch branch now derives a non-zero, microsecond-resolution value from the sleep-aware boot clock, so successive queries differ. The boot clock is packed into the NTP64 wire format rather than being offset onto any epoch, so a reader interpreting it as a timestamp lands in the 1900s. That is deliberate: it is a uniqueness and echo token rather than a time, and an implausible year keeps it from being read as a genuine clock value in a packet capture. The offset computed from such an exchange remains meaningless, exactly as it was when the value was zero: T1 and T4 sit in the 1900s while T2 and T3 carry real server time. Peer-delay, by contrast, becomes sound — T1 and T4 come from the same fallback source, so T4−T1 is a true elapsed duration where previously it was zero. The emitted sample time still comes from the server's T3, and round-trip time is still measured locally.
-
DNS worker-thread spawn failure is no longer misreported as a network error. When the bounded resolver pool granted a slot but the OS then refused to create the
nts-dnsworker thread, theio::Errorfromthread::Builder::spawnescaped through the same path as a genuine lookup failure. Because the two mapping sites keyed only offErrorKind, anENOMEMrefusal (ErrorKind::OutOfMemory) surfaced asNtsError.networkwith the messageDNS lookup failed for host:port: …, pointing operators at the network or the server when the actual cause was a process-local thread or memory ceiling. AnEAGAINrefusal (ErrorKind::WouldBlock) was silently conflated with cap saturation instead.Spawn refusal now reports as the new
TimeoutPhase.dnsSpawnFailed(see Added). It is kept distinct fromdnsSaturationbecause the remediations are opposed: saturation means the cap is the binding constraint and raisingdnsConcurrencyCaphelps, whereas a spawn refusal means admission already succeeded, so raising the cap would admit more work the process cannot service. -
The DNS pool's
recoveredcounter no longer credits workers that never started.thread::Builder::spawntakes ownership of the closure and drops it when the spawn fails, so theSlotGuardmoved into the closure ran itsDropon that path — incrementing the counter thatARCHITECTURE.mddesignates as the "libc is wedged" signal for a thread that never ran, and blunting exactly the signal operators are told to alert on. The slot now travels to the worker as aDrop-freePendingSlotand is re-armed there, leaving the spawn-failure branch to release the slot explicitly. -
A call queued behind the bridge admission gate now surfaces its timeout after a device suspend instead of parking past it. Queue wait was already charged on the sleep-aware monotonic clock, so the budget crossing the FFI boundary stayed honest, but cancellation of a still-queued waiter was a
Timerarmed for the full timeout.Timerruns on the event loop's suspend-frozen clock, so a device that slept through the budget resumed with the timer still owing its whole remaining slice — the waiter kept parking for an outcome already decided, and only unparked once a slot happened to free or the frozen timer eventually caught up.Deadlines are now absolute readings on the same sleep-aware clock, swept by one queue-wide timer rather than one full-length timer per waiter. Each arming is capped at 250 ms, so a resume re-evaluates every deadline against the boot clock within one slice; the cap never delays a nearer deadline, which is every deadline while awake, and the sweeper is only armed while the queue is non-empty. Expiry now happens in the same single-pass compaction that performs admission, so the existing O(n) cost under a mass-timeout burst is unchanged and a freed slot goes to a waiter that can still use it rather than to one the dispatch-side residual check would reject again. No public API changes. (NTS-111)
-
KE responses that redirect the NTP phase are now validated before any post-handshake I/O.
validate_responseinnts::ketook the NTPv4 Server and Port records raw: an empty Server body reached the resolver as an empty host, andPort(0)reached the UDP socket as an unroutable destination. Both surfaced as an opaqueNetworkfailure or timeout well after the handshake had succeeded, even though the same host/port shape is rejected up front when it arrives from the caller viaNtsServerSpec. A KE peer that completes TLS — a buggy server, or one whose certificate an attacker holds — could therefore steer the client into failing I/O rather than being refused as a protocol violation. Both now fail the handshake with newKeErrorvariantsEmptyServer/ZeroPort, surfacing to callers asNtsError::KeProtocolwith a stable RFC-citing message. Only the redirected values are checked: an absent Server record still falls back to the already-validated request host, and an absent Port record toDEFAULT_NTPV4_PORT(123). (NTS-123) -
Duplicate NTPv4 Server and Port records in a KE response are now rejected.
validate_responsealready refused duplicate NextProtocol and AEAD Algorithm records, but Server and Port still resolved via a first-matchfind_map— so an ambiguous response silently pinned one endpoint with no signal that the response was malformed, the same pre-hardening pattern deliberately removed for the other two records. NewKeError::DuplicateServer/DuplicatePortvariants are raised from the existing duplicate-detection loop, ahead of the walks that would otherwise mask the violation. (NTS-128) -
Per-call timeout budgets now keep elapsing while the device is asleep. The KE handshake deadline (
nts::ke::Deadline), the UDP setup deadline (api::nts::UdpDeadline), the singleflight leader/waiter budgets incheckout_withandwarm_cookies_with, theHandshakeSlotcondvar waiter, and the call-wide anchor innts_querywere all anchored onstd::time::Instant, which is suspend-frozen on every platform this package targets (CLOCK_MONOTONIC/mach_absolute_time/ QPC). A mobile call withtimeoutMs: 5000that suspended mid-handshake resumed after wake with most of its original budget still nominally unspent, while wall clock had already blown past the caller's limit — and the Dart layer, which charges residual against a sleep-aware clock, disagreed with the native side about how much budget was left. All six now anchor on the newnts::boottime::BootInstant, anInstant-shaped wrapper around the existing suspend-inclusiveboottime_microsreading (CLOCK_BOOTTIME/mach_continuous_time/QueryInterruptTimePrecise). The condvar waiter additionally re-reads the boot clock on every wake, becausewait_timeoutis itself suspend-frozen and would otherwise under-count a suspend that spanned a park. Short in-call measurements — the per-phase durations reported inNtsDiagnosticsand the RTT bracket around a singlesend/recv— deliberately stay onInstant. (NTS-122) -
SeenUidCacheentries now age across device suspend. The replay guard's 5-minute TTL stampedInstantreadings, so a cache populated before a long sleep retained its Unique Identifiers for the sleep duration plus the TTL rather than the TTL alone. The behaviour was conservative for replay detection (the window stayed open longer than documented) and bounded by the existingSEEN_UID_CAPceiling, but it pinned memory across suspend and put the cache on a different clock from the budgets above. Timestamps are nowBootInstant. (NTS-129, landed with NTS-122 so the clock abstraction was reviewed against both a deadline consumer and a TTL consumer at once) -
The Dart wrapper now rejects a
verificationTimeabove the year-9999 ceiling before dispatch._validateRangeschecked only for negatives, so a far-future instant crossed the FFI boundary and came back with a Rust-authoredinvalidSpecmessage fromvalidate_verification_time_ms. The Dart side now mirrorsMAX_VERIFICATION_TIME_MS(253402300799000, 9999-12-31T23:59:59Z) and authors its own message, restoring the front-loaded single error surface the port, timeout, and concurrency caps already use. The ceiling is inclusive on both sides. (NTS-107) -
A blank
NtsServerSpec.hostis now rejected on the Dart boundary. Onlyportwas range-checked; an empty host was left to Rust'svalidate, costing an FFI hop for a Rust-authored message, andNtsClient.invalidatesoft-failed such a spec asfalserather than failing closed._validateSpecnow rejectshost.trim().isEmptywith a wrapper-authoredNtsError.invalidSpecacross the four async wrappers,getTime, andinvalidate. Whitespace-only hosts are rejected rather than normalised, since the session key ishost:portverbatim. (NTS-108) -
getTimeno longer inflates an already-spent budget to dispatch the handshake. The warm phase clamped its share of the shared 8-second budget up to 1ms when the balance had fallen below thetimeout >= 1msfloor the lower-level wrappers enforce, so a call whose budget was gone still ran a full KE handshake — extending the documented total budget, and, when that handshake succeeded, replacing the cached session forspec(the process-wide one on the default-client path) on a call that should never have reached protocol work. The balance is now checked before dispatch and a spent one fails immediately with the same syntheticNtsError.timeout(phase: TimeoutPhase.ntp)the post-handshake exhaustion path already used, dispatching nothing. The 1ms floor is now a single_kMinDispatchBudgetconstant shared with the burst loop, which already broke on the same threshold. Only reachable when a device suspend lands between the budget starting and the handshake dispatching — the budget is metered on a sleep-aware clock, which is what makes that window observable at all. (NTS-110) -
The
ntsGetTimedartdoc now describes the budget it actually enforces. It documented the 8-second total as plain wall-clock and listed only post-handshake exhaustion under its failure modes, omitting that the budget is sleep-aware (so a suspended call resumes with the suspended interval already charged), that a spent balance is refused rather than rounded up, and that an exhausted call therefore leaves the cached session untouched.NtsClient.getTime, which delegates to the same helper and defers to that dartdoc for its contract, gains a matching one-line pointer. Documentation only. (NTS-119) -
The example app's iOS deployment target is raised from
13.0to14.0across all three build configurations, meeting the floor declared byfile_picker. Example app only; no package API change.
Changed #
-
The example app's
NtsControllernow callsNtsClient.dispose()on the client it supersedes when a trust-mode flip or a custom-roots change re-mints one, and gains its owndispose()that cancels the two signal subscriptions and releases the final client.main.dartowns the controller from aStatefulWidgetso that teardown has a place to run. The controller previously dropped every superseded client for the GC finalizer to reclaim — the exact patterndispose()was added in 9.0 to replace — leaving the native session table, cached AEAD keys and cookie jars pinned well past the point the app considered the client dead. The three action methods gain anon FrbExceptionarm ahead of their catch-all: a call already executing natively is unaffected by adispose(), but one still queued at the bridge admission gate is refused, as are the later legs ofgetTime's warm-then-burst sequence. Those are logged as warnings against the superseded client; a bridge failure against the active client stays an error. Example app only; no package API change. (NTS-143) -
The example CLI (
example/bin/nts_cli.dart) now reports the DNS pool counters, snapshottingntsDnsPoolStats()either side of the query run so the cumulative fields read as a per-run delta.refused(admission blocked bydnsConcurrencyCap) andspawnFailed(the OS refused the worker thread) are the pair 9.0 added to make that distinction observable, and the CLI was the reference consumer with no way to show it. Human mode gets a two-line trailing block;--jsongets a{"event":"dns_pool_stats"}NDJSON record. Example app only; no package API change. (NTS-144) -
The example app's log renderings now surface
keWarnings. The text form appends a trailingke-warnings=[1,4097]token to the continuation row only when the list is non-empty — the IANA registry has no assignments as of RFC 8915, so every server observed in practice sends none and an always-presentke-warnings=[]would be pure noise. The JSON payloads carryke_warningsunconditionally, empty list included, so a machine consumer can index the key without a presence branch. Both surfaces route throughnts_format, so the GUI log view and the CLI pick it up without a per-caller change. Example app only; no package API change. (NTS-142) -
The internal cookie store is now a single FIFO queue rather than a map keyed by host. Its only owner, a cached session, is 1:1 with a negotiated
host:port, so the key duplicated a value the session already held and every call site passedsession.ntpv4_hostto get it back. What the key did add was a way to get it wrong: the KE endpoint and the NTPv4 host diverge whenever a KE response carries a Server record (RFC 8915 §4.1.7), so a deposit filed under one and a draw made under the other would strand the cookies behind a second key and present an empty jar — no type error, no panic, just a session that re-handshakes on every query. Removing the key makes that mismatch unrepresentable. Internal only; no public API or observable behaviour changes. (NTS-130) -
The internal cookie store's capacity is now a
NonZeroUsizerather than ausizevalidated by a runtime assertion. A zero capacity is degenerate rather than merely invalid — every insertion would evict what it had just stored, so the jar would read as permanently empty and each query would report having no cookies. The old constructor caught that with anassert!, which turns a caller's mistake into a process abort at the moment the jar is built. Encoding the bound in the parameter type rejects it at the call site instead, and removes the only panic on the path. Internal only; no public API or observable behaviour changes. (NTS-132) -
The host attribution in the NTS-KE warning log moved out of
establish_sessioninto a small named helper. The warnings come from the KE peer, but a KE response carrying a Server record (RFC 8915 §4.1.7) redirects the NTP phase to a different machine that emitted nothing — so labelling the warning with the redirect target names the wrong host. That misattribution was previously caught only by review; the helper makes it directly testable without a log-capture harness. Record-level coverage was also added forvalidate_response, pinning that Warning records reach the caller in wire order and that the redirect host stays distinct from the KE host. Internal only; the emitted log line, the public API, and all observable behaviour are unchanged. (NTS-133) -
The bounded DNS resolver's worker-spawn step is now injectable via an internal
resolve_with_spawner, so the spawn-failure branch has direct test coverage. That branch normalises both libc shapes (EAGAIN,ENOMEM) toWouldBlockand tags the message with a stable prefix, which is the only thing distinguishing a refused spawn from a saturated pool at the two mapping sites that classify the error. The prefix contract was previously pinned only against a hand-built error, so a refactor that dropped or reformatted it would have silently regressed the reported phase back to DNS saturation with no test failure. The new test drives the real branch and follows the resulting error through to its phase tag, exercising producer and consumer together. The production path still resolves to a single monomorphised call to the real thread builder. Internal only; no public API or observable behaviour changes. (NTS-134) -
Breaking (error type): the
trustMode/customRootspair validation now throwsNtsError.invalidSpecinstead ofArgumentError. Both violations — a non-nullcustomRootswithoutTrustMode.custom, andTrustMode.customwithout non-empty roots — previously escaped the documented "single structured failure type" contract, so a caller with only anon NtsError catcharm missed them on the asyncntsGetTimepath. The checks move to_validateTrustPolicyinnts_validation.dartso theNtsClientfactory and every entry point routing through it share one implementation. Messages are unchanged; only the thrown type differs. Callers catchingArgumentErrorfor these two cases must switch toNtsError(orNtsErrorInvalidSpec). (NTS-109) -
ntsGetTimeandNtsClient.getTimenow share one preamble and one closure binding. Both previously repeated the same three-step verification-instant conversion, validation, and re-wrap, then bound a structurally identicalwarm/queryclosure pair forwarding five arguments apiece — the only difference between the two blocks being whether the closures called the top-level functions or the client methods. Both now delegate to a shared_getTimeForhelper that selects the endpoint pair by tear-off and binds the arguments once, so the forwarded arguments cannot drift between the two surfaces. The burst-orchestration engine is unchanged, as is thentsGetTimebranch that runs a non-default trust policy against a private, call-scoped client. Internal only — both public signatures and all observable behaviour, including the promise that validation failures arrive as a rejected future rather than a synchronous throw, are unchanged. (NTS-77) -
Rust intra-doc links in the generated Dart bindings are now rewritten into Dart form. FRB copies
rust/src/api/nts.rsdoc comments verbatim intolib/src/ffi/api/nts.dart, so the Dart mirror documented Dart APIs using Rust paths: 59 links across the file used::, Rust casing, orSelf, none of which name anything on the Dart side, so every one rendered as a dead reference. A new post-codegen pass intool/check_bindings.dartresolves each path against a symbol table derived from the generated Dart rather than from a casing rule, because FRB treats the shapes differently — a plain enum variant becomes a lowerCamelCase value, a freezed sealed-class variant a named factory, a#[frb(sync)]newthe unnamed constructor, a free function a camelCase top-level. A uniform lowercasing rule would emit confidently wrong targets for three of those. References to items FRB excludes from the bindings are downgraded to inline code, matching what the Rust source already does by hand for crate-internal names, and anything that resolves to nothing fails the check with the originatingrust/src/api/*.rsline rather than passing through Rust-shaped. The Rust source is untouched, so Rust readers keep working intra-doc links. Documentation only. (NTS-135) -
MonotonicClockno longer names a generated class when deciding whether the installed bridge API is the real FFI dispatch implementation. The gate testedapi is NtsRustLibApiImpl, an identifier derived fromdart_entrypoint_class_nameinflutter_rust_bridge.yaml; renaming the entrypoint broke the file loudly, but a codegen template change that reshaped the class hierarchy could have left it compiling while selecting the opposite arm — silently demoting a real bridge to the suspend-frozenStopwatchfallback that v7.0.0 removed for production builds. The test is now againstBaseApiImpl, hand-written flutter_rust_bridge runtime code that every generated implementation extends, andtest/api_smoke_test.dartpins both arms of the relationship so an FRB upgrade that broke it fails a test instead. Internal only; no public API or observable behaviour changes. (NTS-115) -
Three dartdoc clarifications on the public API, no code change.
NtsSyncedTime.errorBoundMicrosnow states outright that it is a snapshot bounding the anchor instant and stays fixed whileutcNowkeeps projecting, so it is not the current maximum error; it sketches how to age it with a caller-supplied drift rate, andutcNowcross-links back to it.PhaseTimingsnow says its fields are monotonic elapsed durations measured inside the native call rather than calendar timestamps or sleep-inclusive spans, and points suspend-inclusive budgeting atMonotonicClock/ thegetTimebudget — summing phases stays sound for in-call accounting, but no addend counts suspend, so the sum cannot yield one.NtsClient.invalidatenow distinguishes "no entry was cached" from "the spec is invalid": thefalsereturn reports only the former, an invalid spec throws, and nothing is checked against the network in either direction. (NTS-116, NTS-118, NTS-120)
Documentation #
-
dart run tool/check_bindings.dartis now documented as the canonical way to regenerate the FRB bindings. The docs previously gaveflutter_rust_bridge_codegen generateas the regeneration step, which emits the unpatched form and so reverts the five post-codegen patch passes the script applies — lint suppression, the three diagnostic-message rewrites on the SSE and DCO codec catch-all arms, and the Rust-to-Dart intra-doc link rewriting. The result fails the drift gate, and the gate's own error message pointed back at the command that caused it, so a contributor following it verbatim stayed red. That message now names the script and says why bare codegen is not a substitute,DEVELOPMENT.mdtabulates all five passes rather than only the lint-suppression one, and the remaining references across the PR template, the FRB config,.gitignore,pubspec.yaml,rust/src/lib.rs, and the ABI-mismatch error text were updated to match. Tooling and docs only — no behavioural change. (NTS-136) -
Release notes for
1.4.0and earlier moved to a newCHANGELOG_ARCHIVE.md, which is tracked in git but excluded from the published tarball.CHANGELOG.mdhad reached 221 KB — the largest file in the package and 39% of its uncompressed payload — and pub.flutter-io.cn renders the whole of it on the package page.2.0.0onwards stays inCHANGELOG.md(157 KB); the archive carries the rest, is linked from the top ofCHANGELOG.mdand from the README's "Upgrading" section, and is covered by the doc-snippet validator on the same terms asCHANGELOG.md. No entries were edited or dropped. (NTS-137)
8.0.0 #
Breaking #
-
NtsErrorgains anabiMismatchvariant. The class issealed, so any exhaustiveswitchover it must add an arm; aswitchwith adefaultor wildcard is unaffected. Nothing else about the existing nine variants changed. -
Removed the deprecated millisecond-valued parameters and the constant aliasing them, deprecated since 5.2 in favour of the
Duration/DateTimespellings. Gone: thekDefaultTimeoutMsconstant; thetimeoutMsparameter onntsQuery,ntsWarmCookies,NtsClient.query, andNtsClient.warmCookies; and theverificationTimeMsparameter on those four plusntsGetTimeandNtsClient.getTime. Migration is mechanical:timeoutMs: nbecomestimeout: Duration(milliseconds: n),verificationTimeMs: nbecomesverificationTime: DateTime.fromMillisecondsSinceEpoch(n, isUtc: true), andkDefaultTimeoutMsbecomeskDefaultTimeout.inMilliseconds. TheNtsError.invalidSpecfailures raised when a caller supplied both spellings of a parameter are gone with them — the conflict is no longer representable. (NTS-99)
Added #
-
Failures that originate in the FFI decode path are now converted to
NtsError.abiMismatchinstead of escaping as raw Dart errors. A native library built from Rust sources that disagree with these bindings dispatches successfully and only fails on the way back, inside the generated codec — and because those failures are bareErrors rather thanNtsErrors, they bypassed the wrapper's conversion arm entirely. The result was aRangeError (byteOffset)naming neither the cause nor the fix. All four asynchronous entry points and all five synchronous ones (ntsDnsPoolStats,ntsTrustStatus,NtsClient.trustMode,NtsClient.invalidate,NtsClient.clear) now surface a typed error whose message names the rebuild (cargo build --releaseinrust/, plusflutter_rust_bridge_codegen generateif the Rust API changed). Three decode-failure shapes are attributed to a layout disagreement:RangeError,UnimplementedError(an enum discriminant the generatedswitchhas no arm for), andArgumentError.StateErroris deliberately excluded — it signals a missedNtsRustLib.init(), a bootstrap ordering mistake with its own remediation, and continues to reach callers unconverted as the entry points' dartdoc promises. This complements the CLI loader warning below, which catches the common case ahead of the call but cannot fire for a library loaded from outside a crate tree, a prebuilt binary shipped without sources, or one built for another architecture. (NTS-98)The three attributed shapes are no longer taken on faith. Alongside the mock-driven tests that prove each entry point is wrapped, a second set drives the real generated
sse_decode_*functions over hand-built buffers that disagree with the layout they were generated for — a short struct, an unknown enum tag, an out-of-range fieldless enum index, a nonsense length prefix — and feeds whatever they throw back through the wrapper. Building a genuinely mismatched native library in CI is not practical, so the buffers stand in for one. One case is pinned as deliberately not converted: aStringwhose length prefix is honest but whose bytes are not valid UTF-8 throwsFormatException, which reaches callers unchanged. (NTS-101)
Fixed #
- The example package's CLI tools (
nts_cli,nts_health,nts_manifest) now warn when the native library they load predates the Rust sources it was built from. These tools run under plaindart run, outside the Native Assets pipeline, so nothing kept the dylib in step withrust/src/**:autoLocateDylibresolved the build path by existence alone and opened whatever file was there. A library built before a subsequent Rust change was loaded silently against newer bindings, and the resulting ABI mismatch surfaced as an untypedRangeError (byteOffset)on every host — including known-good ones — with nothing pointing at the real cause. The loader now compares the library's mtime againstrust/src/**andrust/Cargo.toml, and prints a stderr warning namingcargo build --releaseand the crate directory the library came from (derived from its path, so a--library <path>pointing at another crate is named correctly) when it is older. The check stays silent unless bothCargo.tomlandsrc/sit at the derived crate root, so a library outside a crate tree is not reported. The run proceeds, since the mismatch is not certain. Maintainer/contributor-facing only:rust/target/is gitignored and pubignored, and package consumers build throughhook/build.dart, whose cargo invocation tracks freshness itself. Note the check is one-directional — checking out an older Rust revision leaves a newer library that is equally wrong but indistinguishable by mtime. (NTS-97)
Changed #
-
Refreshed both Rust lockfiles ahead of the major, moving 36 packages to their latest compatible versions —
tokio1.52.3 to 1.53.1,regex1.12.3 to 1.13.1,cc1.2.63 to 1.4.0,memchr2.8.1 to 2.8.3,webpki-root-certs1.0.7 to 1.0.9, plusanyhow,bytes, and thefuturesandwasm-bindgenfamilies. Two packages (wasip2,wit-bindgen) drop out of the fuzz lockfile, no longer reachable oncejobservermoved fromgetrandom0.3 to 0.4. No manifest constraint moved;rust/Cargo.tomlis untouched. Three crates are deliberately held back, each pinning rather than loosening the gate that rejected the update, per the guidance in thedependency-reviewjob's own comment block:thiserrorstays at 2.0.18 in both lockfiles. 2.0.19 switchesthiserror-impltosyn 3.0.3while the rest of the graph is onsyn 2.0.119, trippingmultiple-versions = "deny"inrust/deny.toml. (NTS-102)tokiostays at 1.52.3 inrust/fuzz/Cargo.lockonly; the production lockfile carries 1.53.1.rustc 1.99.0-nightlyICEs inrustc_codegen_ssacompiling 1.53.1 under the sanitizer flag setcargo-fuzzpasses. Stable compiles the same version cleanly, so only the fuzz jobs are affected, and the fuzz harness never ships. (NTS-103)rustc-demanglestays at 0.1.27 in both lockfiles. 0.1.28 declares the legacy slash formMIT/Apache-2.0rather than the SPDX expressionMIT OR Apache-2.0;dependency-reviewcannot parse it and synthesizes aLicenseRef-bad-*placeholder that can never match the allow-list. The license terms are unchanged and acceptable — this is a metadata-format defect upstream.cargo denynormalizes the slash form and stays green either way. (NTS-104)
generic-arrayalso stays at 0.14.7, constrained transitively by the RustCrypto AEAD stack rather than by anything this crate declares.
7.1.0 #
Fixed #
- Fixed the example package's
nts_format_test.dartfailing withBad state: MonotonicClock requires the nts bridge: theformatGetTimeSuccessfixture constructsNtsSyncedTime, whose constructor captures a monotonic anchor fromMonotonicClock.instance, but the test never initialized the mock bridge. The test now callsNtsRustLib.initMockinsetUpAll. CI additionally runsflutter testfor the example package (it was previously only analyzed), so example-test regressions fail the build. (NTS-95)
Added #
- New RFC 5905 §8 clock-filter fields on
NtsTimeSample, computed in the native worker from the four on-wire timestamps (T1 client transmit, T2 server receive, T3 server transmit, T4 client receive — T4 is now captured immediately after the UDP recv):offsetMicros(true clock offset θ = ((T2−T1)+(T3−T4))/2, which cancels symmetric network delay and excludes server processing time, unlike theroundTrip / 2approximation),peerDelayMicros(peer delay δ = (T4−T1)−(T3−T2), the round trip minus server processing time),rootDelayMicros/rootDispersionMicros(the reply header's 16.16 fixed-point root metrics converted to microseconds — root delay is decoded as signed per RFC 5905, with negative on-wire values clamped to0since a negative delay is not physically meaningful), andserverPrecision(log₂-seconds clock precision from the reply header). All five Dart constructor parameters are optional and default to0, so existing hand-built fixtures and mocks keep compiling unchanged; a zeropeerDelayMicrosis treated as "not available" by the plausibility check below. (NTS-78) - New RFC 5905 statistics on
NtsSyncedTime:offsetMicros(the winning sample's θ),jitterMicros(sample jitter ψ — the RMS of the offset differences between the winning sample and every other burst sample, RFC 5905 §10;0for a single-sample burst), anderrorBoundMicros(worst-case error at the anchor instant, following the root-distance recipe: half the winning sample's network delay + half the server's root delay + the server's root dispersion + the sample jitter). The constructor parameters are optional: the statistics default to0and the error bound falls back to the pre-7.1roundTripMicros ~/ 2worst case. (NTS-78) - New
NtsTimeSample.recvBoottimeMicrosfield: a sleep-aware monotonic reading (same clock source and epoch asntsBoottimeMicros/MonotonicClock) taken inside the native worker immediately after the AEAD-NTPv4 UDP recv returned — the wire-level receipt instant of the sample, before any FFI-return, worker-thread handoff, or Dart event-loop latency. Subtracting it from a laterMonotonicClockreading in the same process yields the scheduling lag since receipt. The epoch is arbitrary (per-boot): never persist the value or compare it across boots, devices, or processes. The public Dart constructor parameter is optional and defaults to0(an epoch-implausible sentinel that triggers the anchor-lag fallback below), so existing hand-built fixtures and mocks keep compiling unchanged. (NTS-94)
Changed #
ntsGetTime/NtsClient.getTimenow select the winning burst sample by lowest network delay — the RFC 5905 peer delay δ when it is plausible (within(0, roundTripMicros]), falling back to the locally measured round trip when it is not (pre-7.1-shaped fixtures, or a local clock step mid-exchange) — and compensate the one-way delay with that same value (utc + delay / 2instead ofutc + roundTrip / 2). On real servers δ excludes server processing time, so the compensated instant no longer counts the server's receive-to-transmit gap as network transit. (NTS-78)ntsGetTime/NtsClient.getTimenow anchor the constructedNtsSyncedTimeon the winning sample's wire-level receipt stamp instead of a post-awaitDart-side observation, removing the FFI-return / event-loop scheduling latency that previously made the compensated UTC lag true time by that (unmeasured) delta. Samples whose stamp fails an epoch-plausibility window (hand-built fixtures, mock-mode fallback clocks) fall back to the previous post-awaitapproximation. (NTS-94)
7.0.0 #
Added #
- New public
MonotonicClockclass (exported frompackage:nts/nts.dart): a sleep-aware monotonic time source whose readings keep advancing while the device is in deep sleep, unlikeStopwatch. ReadsCLOCK_BOOTTIMEon Android/Linux,mach_continuous_timeon iOS/macOS, andQueryInterruptTimePreciseon Windows through a new synchronous bridge call (ntsBoottimeMicros). Each instance resolves its source once at construction, so readings from one instance never mix epochs; construction before bridge init throws (see the breakingNtsSyncedTimeentry below for the exact contract). The sharedMonotonicClock.instanceis the same timeline the package now uses internally. (NTS-90)
Changed #
-
Breaking: constructing an
NtsSyncedTimebefore the bridge is initialized now throws aStateError(namingNtsRustLib.init()as the fix). In 6.0.0 the constructor anchored on a plainStopwatchand worked without the bridge; it now captures its anchor fromMonotonicClock.instance, which — like directMonotonicClockconstruction — fails fast when neitherNtsRustLib.init()norNtsRustLib.initMock()has run. A production build can therefore never silently degrade to a clock that freezes during device sleep. TheStopwatchfallback exists only for mock mode (NtsRustLib.initMock(), or a hand-supplied API passed toNtsRustLib.init(api: ...), when the API does not stubcrateApiNtsNtsBoottimeMicros) and is gated structurally: a real bridge (the generated FFI implementation installed byNtsRustLib.init()) dispatches the clock read directly with no probe and no catch, so any failure propagates instead of silently switching the instance to a suspend-frozen source. Migration:await NtsRustLib.init()(orNtsRustLib.initMock(...)in tests) before touchingMonotonicClock,NtsSyncedTime, orntsGetTime; downstream mocks should stubcrateApiNtsNtsBoottimeMicrosto keep the sleep-aware source in tests. The throwing lazy static is not poisoned: the firstMonotonicClock.instanceaccess after init resolves normally. (NTS-93) -
NtsSyncedTime.utcNow/elapsedSinceSync, thegetTimetotal timeout budget, and the bridge admission gate's queue-wait metering now run on the sleep-awareMonotonicClock.instancetimeline instead of per-callStopwatches. A device that sleeps mid-session no longer silently freezes the projected clock or stalls an in-flight budget:utcNowstays correct across suspend/resume, and a budget that elapses during sleep surfaces astimeout(ntp)on resume. Pure-Dart tests keep working throughNtsRustLib.initMock(), which retains theStopwatchfallback for mocks that do not stub the boottime call (see the breaking entry above). (NTS-90) -
The top-level
ntsGetTimenow accepts optionaltrustModeandcustomRootsparameters, so a one-call synchronized clock can run under a non-default trust-anchor policy without hand-constructing anNtsClient. The default (TrustMode.platformWithFallback, no custom roots) keeps the existing process-wide singleton path byte-for-byte unchanged; any other policy routes the whole warm+burst flow through a private call-scoped client whose native handle is disposed before the call returns. This is sound on this path specifically becausentsGetTimealways forces a fresh handshake and spends only the cookies that handshake minted — no cache-reuse window exists in which a session established under a different policy could be served. Pair validation matches theNtsClientconstructor (customRootsrequiresTrustMode.customand vice versa, rejected withArgumentErrorbefore any FFI dispatch).ntsQuery/ntsWarmCookiesare deliberately unchanged: their value is the warm session cache, and a per-call policy there requires session-table re-keying tracked separately. (NTS-89)
6.0.0 #
Breaking changes #
- Removed the eight deprecated underscore-prefixed typedef aliases
for the pre-3.0
NtsErrorvariant names (NtsError_InvalidSpec,NtsError_Network,NtsError_KeProtocol,NtsError_NtpProtocol,NtsError_Authentication,NtsError_Timeout,NtsError_NoCookies,NtsError_Internal) and the@Deprecatedfield0getter aliases on the variant subclasses. Both surfaces had been deprecated since 3.0.0; removal was scheduled for 4.0.0, deferred, and missed again at 5.0.0. Migration is mechanical: drop the underscore (NtsError_X→NtsErrorX) and switchfield0reads /:final field0pattern matches to the named field (messageon every string-payload variant,phaseonNtsErrorTimeout). (NTS-87)
