commit 8423186f10df8d86c868eaebb1623bd9fa27ee8b
parent 67d1b91a86d027465a37b4f462253cc1e03ed951
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 10:04:53 +0000
geonames: complete package conformance and quarantine
- document the explicit asset database and query contract
- record and guard the reviewed public API baseline
- meet the four-dimension package coverage policy
- quarantine the SDK-bound predecessor until Step 248
Diffstat:
16 files changed, 912 insertions(+), 17 deletions(-)
diff --git a/crates/geocoder/Cargo.toml b/crates/geocoder/Cargo.toml
@@ -12,6 +12,13 @@ homepage.workspace = true
documentation = "https://docs.rs/radroots_geocoder"
readme = "README"
+[package.metadata.radroots.compatibility]
+status = "publish_frozen"
+replacement = "radroots_geonames"
+deviation = "RCRV1-DEV-011"
+removal_step = 248
+new_consumers_forbidden = true
+
[features]
test-fixture-geonames-asset = []
diff --git a/crates/geocoder/README b/crates/geocoder/README
@@ -3,6 +3,14 @@
This is the README for `radroots_geocoder`, which provides offline geocoder
queries for the `radroots` core libraries.
+## Compatibility quarantine
+
+This package is a publish-frozen private bridge for the standalone SDK's
+existing GeoNames feature. `radroots_geonames` is the only approved public
+provider identity. New consumers, features, contracts, and behavior are
+forbidden under `RCRV1-DEV-011`; SDK manifest cutover begins at Step 226 and
+this package is removed at the SDK quarantine gate in Step 248.
+
## Overview
* a `Geocoder` type that opens prepared datasets from a filesystem path or
diff --git a/crates/geonames/README.md b/crates/geonames/README.md
@@ -1,7 +1,105 @@
# radroots_geonames
-GeoNames-backed geocoding for Radroots.
+`radroots_geonames` is the concrete GeoNames data provider for Radroots. It
+owns a pinned asset specification, explicit integrity-checked acquisition,
+read-only database lifecycle, and deterministic forward, reverse, feature, and
+country queries through provider-owned types.
-This package root is established for the Release V1 refactor. Dataset models,
-indexes, and deterministic lookup behavior are migrated in the subsequent
-GeoNames checkpoints.
+The crate does not choose cache or runtime paths, download during construction,
+create an executor, spawn a worker, install a timer, expose SQLite or HTTP
+client types, or define a generic geocoder SPI. Publication remains disabled
+during the `0.1.0-alpha` refactor.
+
+The authoritative package charter is the
+[`radroots_geonames` section of the Release V1 specification](https://github.com/radrootslabs/lib/blob/master/docs/specs/radroots_crates_release_v1.md#17-radroots_geonames).
+The reviewed Rust surface is recorded in the
+[public API baseline](../../docs/api/radroots_geonames.txt).
+
+## Prepare a query without I/O
+
+Construction is validated and inert:
+
+```rust
+use radroots_geonames::{Point, Query};
+
+let locality = Query::locality("Victoria")?
+ .with_region("BC")?
+ .with_country("CA")?
+ .with_limit(5)?;
+assert_eq!(locality.limit(), 5);
+
+let reverse = Query::reverse(Point::new(48.4284, -123.3656)?)
+ .with_radius_degrees(0.25)?;
+assert_eq!(reverse.limit(), 1);
+# Ok::<(), radroots_geonames::Error>(())
+```
+
+A runnable inert example is available at
+[`examples/prepare_query.rs`](examples/prepare_query.rs).
+
+## Asset identity and acquisition
+
+[`asset::official_asset_spec`](crate::asset::official_asset_spec) returns the
+byte-pinned official version, file name, HTTPS source and authority, exact
+length, and SHA-256. Hosts may construct another [`AssetSpec`] only with one
+safe destination file name and an HTTPS URL whose authority matches exactly;
+userinfo, query strings, fragments, non-default ports, and plaintext sources
+are rejected.
+
+[`asset::inspect`](crate::asset::inspect) is passive. It reports missing,
+available, or invalid bytes and never repairs or downloads them.
+[`download::acquire`](crate::download::acquire) must be called explicitly with
+an existing host-selected directory and a host-owned [`download::Fetcher`].
+The crate bounds the stream before writing, stages in that same directory,
+checks exact size and SHA-256, synchronizes it, and atomically replaces the
+destination under an advisory lock. Symlink destinations fail closed.
+
+The fetcher owns DNS, network deadlines, and cancellation. Its typed failure
+phase cannot carry source URLs, credentials, or upstream error strings across
+the public boundary. The final rename is the local acquisition commit point;
+an interruption before it leaves the existing destination unchanged.
+
+## Database and query behavior
+
+[`Geocoder::open`] accepts only an explicit regular file matching its
+[`AssetSpec`]. It opens SQLite read-only and query-only, runs an integrity
+check, and validates the required `geonames` and `coordinates` table columns.
+Use [`Geocoder::close`] when an explicit terminal close result is required.
+
+[`Geocoder::query`] supports:
+
+- structured locality filters by locality, region, and country;
+- comma-separated free-form locality input;
+- exact GeoNames feature identifiers;
+- bounded reverse lookup with antimeridian and polar handling; and
+- deterministic country lists with provider-derived center points.
+
+Every candidate order has explicit tie-breakers. Numeric or text SQLite
+administrative identifiers become opaque strings at the private row boundary.
+Candidate, country, point, query, and result fields remain private and are
+read through accessors.
+
+## Errors, serialization, and side effects
+
+[`Error`] exposes stable package-owned categories without paths, SQL, hashes,
+URLs, credentials, Rusqlite errors, or fetch-client errors. Host diagnostics
+should add their own path and transport context only at an access-controlled
+application boundary.
+
+The package defines no Cargo features and no stable serialized form. Persist
+host configuration in a host-owned versioned contract, then reconstruct
+`AssetSpec` and `Query` through validating constructors. Merely importing the
+crate, obtaining the official specification, or constructing a query performs
+no network, filesystem, database, runtime, clock, or process-global work.
+
+## Intended consumers
+
+- `radroots_sdk` composes GeoNames as an explicit optional capability.
+- CLI and geocoding applications may acquire and query an asset directly.
+- Ordinary applications normally use the curated `radroots` package.
+
+## Copyright
+
+Except as otherwise noted, all files in the `radroots_geonames` distribution
+are copyright (c) 2025 Tyson Lupul. See `LICENSE` for usage, redistribution,
+and warranty terms.
diff --git a/crates/geonames/examples/prepare_query.rs b/crates/geonames/examples/prepare_query.rs
@@ -0,0 +1,19 @@
+use radroots_geonames::asset::official_asset_spec;
+use radroots_geonames::{Point, Query};
+
+fn main() -> Result<(), radroots_geonames::Error> {
+ let spec = official_asset_spec();
+ let locality = Query::locality("Victoria")?
+ .with_region("BC")?
+ .with_country("CA")?
+ .with_limit(5)?;
+ let reverse = Query::reverse(Point::new(48.4284, -123.3656)?).with_radius_degrees(0.25)?;
+
+ println!(
+ "prepared asset {} and query limits {}/{} without I/O",
+ spec.version(),
+ locality.limit(),
+ reverse.limit()
+ );
+ Ok(())
+}
diff --git a/crates/geonames/src/asset.rs b/crates/geonames/src/asset.rs
@@ -225,7 +225,14 @@ fn validate_source(source: &str, allowed_host: &str) -> Result<(), Error> {
#[cfg(test)]
mod tests {
- use super::{AssetSpec, AssetStatus, OFFICIAL_ASSET_SHA256, official_asset_spec};
+ use std::fs;
+
+ use sha2::{Digest, Sha256};
+ use tempfile::tempdir;
+
+ use super::{
+ AssetSpec, AssetStatus, OFFICIAL_ASSET_SHA256, inspect, io_error, official_asset_spec,
+ };
use crate::Error;
fn spec() -> AssetSpec {
@@ -316,4 +323,69 @@ mod tests {
assert_ne!(AssetStatus::Missing, AssetStatus::Available);
assert_ne!(AssetStatus::Available, AssetStatus::Invalid);
}
+
+ #[test]
+ fn asset_validation_rejects_every_unsafe_name_and_url_shape() {
+ let source = "https://assets.example/a";
+ for file_name in ["", ".", "..", "a/b", "a\\b", "a:b", "a\0b"] {
+ assert_eq!(
+ AssetSpec::new("v1", file_name, source, "assets.example", 1, [0; 32]),
+ Err(Error::InvalidAssetFileName)
+ );
+ }
+ for untrusted in [
+ "not a url",
+ "https://user@assets.example/a",
+ "https://user:pass@assets.example/a",
+ "https://:pass@assets.example/a",
+ "https://assets.example:444/a",
+ "https://assets.example/a?token=secret",
+ "https://assets.example/a#fragment",
+ ] {
+ assert_eq!(
+ AssetSpec::new("v1", "asset.db", untrusted, "assets.example", 1, [0; 32]),
+ Err(Error::UntrustedAssetSource)
+ );
+ }
+ assert_eq!(
+ AssetSpec::new("v1", "asset.db", source, " ", 1, [0; 32]),
+ Err(Error::InvalidAssetSource)
+ );
+ }
+
+ #[test]
+ fn passive_inspection_distinguishes_regular_invalid_and_unsafe_entries() {
+ let directory = tempdir().expect("tempdir");
+ let bytes = b"asset bytes";
+ let spec = AssetSpec::new(
+ "v1",
+ "asset.db",
+ "https://assets.example/a",
+ "assets.example",
+ u64::try_from(bytes.len()).expect("length"),
+ Sha256::digest(bytes).into(),
+ )
+ .expect("spec");
+ let path = directory.path().join("asset.db");
+ fs::write(&path, b"short").expect("short asset");
+ assert_eq!(inspect(&path, &spec), Ok(AssetStatus::Invalid));
+ fs::write(&path, b"wrong bytes").expect("wrong hash asset");
+ assert_eq!(inspect(&path, &spec), Ok(AssetStatus::Invalid));
+ fs::write(&path, bytes).expect("valid asset");
+ assert_eq!(inspect(&path, &spec), Ok(AssetStatus::Available));
+ assert_eq!(
+ inspect(directory.path(), &spec),
+ Err(Error::UnsafeAssetDestination)
+ );
+ assert_eq!(
+ io_error(
+ "read asset",
+ std::io::Error::from(std::io::ErrorKind::BrokenPipe)
+ ),
+ Error::Io {
+ operation: "read asset",
+ kind: std::io::ErrorKind::BrokenPipe,
+ }
+ );
+ }
}
diff --git a/crates/geonames/src/database.rs b/crates/geonames/src/database.rs
@@ -468,16 +468,13 @@ fn validate_table(
required_columns: &[&str],
column_pragma: &str,
) -> Result<(), Error> {
- let object_type = connection
+ connection
.query_row(
- "SELECT type FROM sqlite_schema WHERE name = ?1 AND type = 'table'",
+ "SELECT 1 FROM sqlite_schema WHERE name = ?1 AND type = 'table'",
[table],
- |row| row.get::<_, String>(0),
+ |_| Ok(()),
)
.map_err(|_| Error::InvalidDatabaseSchema)?;
- if object_type != "table" {
- return Err(Error::InvalidDatabaseSchema);
- }
let mut statement = connection
.prepare(column_pragma)
@@ -504,8 +501,11 @@ mod tests {
use sha2::{Digest, Sha256};
use tempfile::{TempDir, tempdir};
- use super::Geocoder;
- use crate::{AssetSpec, Error};
+ use super::{
+ Geocoder, country_matches, normalize_name, normalize_region_code, parse_freeform_query,
+ region_aliases, region_matches,
+ };
+ use crate::{AssetSpec, Candidate, Error, Point};
fn database_fixture(schema: &str) -> (TempDir, std::path::PathBuf, AssetSpec) {
let directory = tempdir().expect("tempdir");
@@ -758,4 +758,92 @@ mod tests {
Err(Error::UnsafeAssetDestination)
));
}
+
+ #[test]
+ fn parsing_and_filter_helpers_cover_direct_alias_and_no_match_paths() {
+ let point = Point::new(1.0, 2.0).expect("point");
+ let washington = Candidate::from_provider_row(
+ 1,
+ "Victoria".to_owned(),
+ Some("WA".to_owned()),
+ Some("Washington".to_owned()),
+ "US".to_owned(),
+ Some("United States".to_owned()),
+ point,
+ );
+ assert!(country_matches(&washington, "us"));
+ assert!(country_matches(&washington, "united states"));
+ assert!(!country_matches(&washington, "canada"));
+ assert!(region_matches(&washington, "wa"));
+ assert!(region_matches(&washington, "washington"));
+
+ let legacy_washington = Candidate::from_provider_row(
+ 2,
+ "Legacy".to_owned(),
+ Some("53".to_owned()),
+ Some("Washington".to_owned()),
+ "US".to_owned(),
+ None,
+ point,
+ );
+ assert!(region_matches(&legacy_washington, "wa"));
+ assert!(!country_matches(&legacy_washington, "canada"));
+
+ let unclassified = Candidate::from_provider_row(
+ 3,
+ "Island".to_owned(),
+ None,
+ None,
+ "FJ".to_owned(),
+ None,
+ point,
+ );
+ assert!(!region_matches(&unclassified, "unknown"));
+ assert!(region_aliases("FJ").is_empty());
+ assert!(!region_aliases("CA").is_empty());
+ assert!(!region_aliases("us").is_empty());
+ assert_eq!(normalize_name(" New York "), "new york");
+ assert_eq!(normalize_region_code("b.c."), "BC");
+
+ let empty = parse_freeform_query(", ,");
+ assert!(empty.locality.is_empty());
+ let one = parse_freeform_query("Victoria");
+ assert_eq!(one.locality, "Victoria");
+ let two = parse_freeform_query("Victoria, BC");
+ assert_eq!(two.region.as_deref(), Some("BC"));
+ assert_eq!(two.country, None);
+ let many = parse_freeform_query("Greater, Victoria, BC, CA");
+ assert_eq!(many.locality, "Greater, Victoria");
+ assert_eq!(many.country.as_deref(), Some("CA"));
+ }
+
+ #[test]
+ fn database_open_and_row_mapping_fail_closed_for_invalid_shapes() {
+ let directory = tempdir().expect("tempdir");
+ let placeholder = AssetSpec::new(
+ "v1",
+ "asset.db",
+ "https://assets.example/a",
+ "assets.example",
+ 1,
+ [0; 32],
+ )
+ .expect("placeholder spec");
+ assert!(matches!(
+ Geocoder::open(directory.path(), &placeholder),
+ Err(Error::UnsafeAssetDestination)
+ ));
+
+ let invalid_row_schema = governed_schema().replace(
+ "(6174041, 'Victoria', 2, 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656)",
+ "(-1, 'Victoria', 2, 'British Columbia', 'CA', 'Canada', 48.4284, -123.3656)",
+ );
+ let (_directory, path, spec) = database_fixture(&invalid_row_schema);
+ let geocoder = Geocoder::open(path, &spec).expect("open negative-id fixture");
+ let query = crate::Query::locality("Victoria").expect("query");
+ assert!(matches!(
+ geocoder.query(&query),
+ Err(Error::DatabaseOperationFailed { operation: "query" })
+ ));
+ }
}
diff --git a/crates/geonames/src/download.rs b/crates/geonames/src/download.rs
@@ -192,13 +192,14 @@ impl Write for BoundedWriter<'_> {
#[cfg(test)]
mod tests {
- use std::fs;
+ use std::fs::{self, OpenOptions};
use std::io::Write;
+ use fs2::FileExt;
use sha2::{Digest, Sha256};
use tempfile::tempdir;
- use super::{FetchFailurePhase, Fetcher, acquire};
+ use super::{BoundedWriter, FetchFailurePhase, Fetcher, acquire};
use crate::asset::inspect;
use crate::{AssetSpec, AssetStatus, Error};
@@ -227,6 +228,14 @@ mod tests {
}
}
+ struct PanicFetcher;
+
+ impl Fetcher for PanicFetcher {
+ fn fetch(&self, _source: &str, _destination: &mut dyn Write) -> Result<(), Error> {
+ panic!("available assets must not invoke the fetcher")
+ }
+ }
+
fn spec(bytes: &[u8]) -> AssetSpec {
AssetSpec::new(
"test-v1",
@@ -331,4 +340,73 @@ mod tests {
Err(Error::UnsafeAssetDestination)
);
}
+
+ #[test]
+ fn available_short_busy_and_invalid_directory_paths_are_explicit() {
+ let directory = tempdir().expect("tempdir");
+ let bytes = b"asset";
+ let spec = spec(bytes);
+ fs::write(directory.path().join(spec.file_name()), bytes).expect("available asset");
+ let owned_directory = directory.path().to_path_buf();
+ assert_eq!(
+ acquire(&owned_directory, &spec, &PanicFetcher),
+ Ok(AssetStatus::Available)
+ );
+
+ fs::write(directory.path().join(spec.file_name()), b"old").expect("invalid asset");
+ assert!(matches!(
+ acquire(directory.path(), &spec, &BytesFetcher(b"a".to_vec())),
+ Err(Error::AssetSizeMismatch {
+ expected: 5,
+ actual: 1
+ })
+ ));
+
+ let lock_path = directory.path().join(format!(".{}.lock", spec.file_name()));
+ let lock = OpenOptions::new()
+ .read(true)
+ .write(true)
+ .create(true)
+ .truncate(false)
+ .open(lock_path)
+ .expect("lock file");
+ lock.lock_exclusive().expect("exclusive lock");
+ assert_eq!(
+ acquire(directory.path(), &spec, &BytesFetcher(bytes.to_vec())),
+ Err(Error::AssetDestinationBusy)
+ );
+
+ let not_directory = directory.path().join("plain-file");
+ fs::write(¬_directory, b"file").expect("plain file");
+ assert_eq!(
+ acquire(¬_directory, &spec, &BytesFetcher(bytes.to_vec())),
+ Err(Error::UnsafeAssetDestination)
+ );
+ assert!(matches!(
+ acquire(
+ directory.path().join("missing"),
+ &spec,
+ &BytesFetcher(bytes.to_vec())
+ ),
+ Err(Error::Io {
+ operation: "inspect asset directory",
+ kind: std::io::ErrorKind::NotFound,
+ })
+ ));
+ }
+
+ #[test]
+ fn fetch_phases_and_bounded_writer_flush_are_covered() {
+ assert_eq!(FetchFailurePhase::Connect.to_string(), "connect");
+ assert_eq!(FetchFailurePhase::Response.to_string(), "response");
+ assert_eq!(FetchFailurePhase::Read.to_string(), "read");
+ assert_eq!(FetchFailurePhase::Cancelled.to_string(), "cancellation");
+
+ let mut file = tempfile::tempfile().expect("temporary file");
+ let mut writer = BoundedWriter::new(&mut file, 4);
+ writer.write_all(b"data").expect("bounded write");
+ writer.flush().expect("flush");
+ assert_eq!(writer.observed, 4);
+ assert!(!writer.overflowed);
+ }
}
diff --git a/crates/geonames/src/error.rs b/crates/geonames/src/error.rs
@@ -130,3 +130,51 @@ impl fmt::Display for Error {
}
impl std::error::Error for Error {}
+
+#[cfg(test)]
+mod tests {
+ use super::Error;
+ use crate::download::FetchFailurePhase;
+
+ #[test]
+ fn every_public_error_has_a_secret_safe_stable_message() {
+ let cases = [
+ Error::InvalidAssetVersion,
+ Error::InvalidAssetFileName,
+ Error::InvalidAssetSource,
+ Error::UntrustedAssetSource,
+ Error::InvalidAssetByteSize,
+ Error::UnsafeAssetDestination,
+ Error::AssetDestinationBusy,
+ Error::Io {
+ operation: "read asset",
+ kind: std::io::ErrorKind::PermissionDenied,
+ },
+ Error::Fetch {
+ phase: FetchFailurePhase::Connect,
+ },
+ Error::AssetSizeMismatch {
+ expected: 10,
+ actual: 9,
+ },
+ Error::AssetHashMismatch,
+ Error::InvalidDatabase,
+ Error::InvalidDatabaseSchema,
+ Error::DatabaseConnectionUnavailable,
+ Error::DatabaseOperationFailed { operation: "query" },
+ Error::InvalidPoint,
+ Error::InvalidQueryText,
+ Error::InvalidQueryLimit,
+ Error::InvalidQueryRadius,
+ Error::InvalidFeatureId,
+ Error::QueryOptionNotApplicable,
+ ];
+ for error in cases {
+ let message = error.to_string();
+ assert!(!message.is_empty());
+ for forbidden in ["https://", "/tmp/", "SELECT ", "token="] {
+ assert!(!message.contains(forbidden));
+ }
+ }
+ }
+}
diff --git a/crates/geonames/src/lib.rs b/crates/geonames/src/lib.rs
@@ -2,6 +2,22 @@
//!
//! This crate never chooses runtime paths or performs work during
//! construction. Hosts explicitly provide every asset source and destination.
+//!
+//! # Inert query construction
+//!
+//! ```
+//! use radroots_geonames::{Point, Query};
+//!
+//! let locality = Query::locality("Victoria")?
+//! .with_region("BC")?
+//! .with_country("CA")?;
+//! assert_eq!(locality.limit(), 10);
+//!
+//! let reverse = Query::reverse(Point::new(48.4284, -123.3656)?)
+//! .with_radius_degrees(0.25)?;
+//! assert_eq!(reverse.limit(), 1);
+//! # Ok::<(), radroots_geonames::Error>(())
+//! ```
#![forbid(unsafe_code)]
diff --git a/crates/geonames/src/query.rs b/crates/geonames/src/query.rs
@@ -243,8 +243,9 @@ fn normalized_query_text(value: impl Into<String>) -> Result<String, Error> {
#[cfg(test)]
mod tests {
- use super::{Query, QueryKind};
- use crate::{Error, Point};
+ use super::{Query, QueryKind, QueryResult};
+ use crate::model::Country;
+ use crate::{Candidate, Error, Point};
#[test]
fn structured_queries_keep_normalized_filters_private() {
@@ -302,5 +303,58 @@ mod tests {
.with_radius_degrees(1.0),
Err(Error::QueryOptionNotApplicable)
);
+ assert_eq!(
+ Query::reverse(point).with_radius_degrees(f64::NAN),
+ Err(Error::InvalidQueryRadius)
+ );
+ assert_eq!(
+ Query::reverse(point).with_radius_degrees(10.1),
+ Err(Error::InvalidQueryRadius)
+ );
+ }
+
+ #[test]
+ fn query_and_result_accessors_distinguish_every_kind() {
+ let point = Point::new(48.4284, -123.3656).expect("point");
+ let locality = Query::locality("Victoria").expect("locality");
+ assert_eq!(locality.locality_fields(), Some(("Victoria", None, None)));
+ assert_eq!(locality.freeform_text(), None);
+ assert_eq!(locality.exact_feature_id(), None);
+ assert_eq!(locality.reverse_point(), None);
+ assert!(!locality.is_country_list());
+
+ let freeform = Query::freeform("Victoria, BC, CA").expect("freeform");
+ assert_eq!(freeform.locality_fields(), None);
+ assert_eq!(freeform.freeform_text(), Some("Victoria, BC, CA"));
+
+ let feature = Query::feature_id(42).expect("feature");
+ assert_eq!(feature.exact_feature_id(), Some(42));
+ assert_eq!(feature.freeform_text(), None);
+
+ let reverse = Query::reverse(point);
+ assert_eq!(reverse.reverse_point(), Some(point));
+ assert_eq!(reverse.exact_feature_id(), None);
+
+ let countries = Query::countries();
+ assert!(countries.is_country_list());
+ assert_eq!(countries.reverse_point(), None);
+
+ let candidate = Candidate::from_provider_row(
+ 1,
+ "Victoria".to_owned(),
+ None,
+ None,
+ "CA".to_owned(),
+ None,
+ point,
+ );
+ let candidate_result = QueryResult::candidates(vec![candidate]);
+ assert_eq!(candidate_result.as_candidates().map(<[_]>::len), Some(1));
+ assert_eq!(candidate_result.as_countries(), None);
+
+ let country = Country::from_provider_row("CA".to_owned(), None, point);
+ let country_result = QueryResult::countries(vec![country]);
+ assert_eq!(country_result.as_candidates(), None);
+ assert_eq!(country_result.as_countries().map(<[_]>::len), Some(1));
}
}
diff --git a/crates/geonames/tests/package_boundary.rs b/crates/geonames/tests/package_boundary.rs
@@ -0,0 +1,216 @@
+use std::collections::BTreeSet;
+use std::fs;
+use std::path::Path;
+
+const MANIFEST: &str = include_str!("../Cargo.toml");
+const README: &str = include_str!("../README.md");
+const EXAMPLE: &str = include_str!("../examples/prepare_query.rs");
+const PUBLIC_API: &str = include_str!("../../../docs/api/radroots_geonames.txt");
+const API_INDEX: &str = include_str!("../../../docs/api/README.md");
+const ROOT: &str = include_str!("../src/lib.rs");
+const LEGACY_MANIFEST: &str = include_str!("../../geocoder/Cargo.toml");
+const LEGACY_README: &str = include_str!("../../geocoder/README");
+const COMPATIBILITY: &str = include_str!("../../../docs/implementation/COMPATIBILITY_SHIMS.md");
+const DEVIATIONS: &str = include_str!("../../../docs/implementation/deviations.toml");
+const PUBLISH_POLICY: &str = include_str!("../../../contracts/releases/publish_policy.toml");
+
+#[test]
+fn manifest_and_root_match_the_governed_provider_boundary() {
+ for required in [
+ "name = \"radroots_geonames\"",
+ "version = \"0.1.0-alpha\"",
+ "publish = false",
+ "[lib]\nname = \"radroots_geonames\"",
+ ] {
+ assert!(
+ MANIFEST.contains(required),
+ "manifest is missing `{required}`"
+ );
+ }
+ assert!(!MANIFEST.contains("[features]"));
+ assert!(radroots_dependency_keys(MANIFEST).is_empty());
+ assert_eq!(
+ public_modules(ROOT),
+ BTreeSet::from(["asset", "database", "download", "model", "query"])
+ );
+ assert_eq!(private_modules(ROOT), BTreeSet::from(["error"]));
+ for export in [
+ "pub use asset::{AssetSpec, AssetStatus};",
+ "pub use database::Geocoder;",
+ "pub use error::Error;",
+ "pub use model::{Candidate, Point};",
+ "pub use query::Query;",
+ ] {
+ assert!(ROOT.contains(export), "crate root is missing `{export}`");
+ }
+}
+
+#[test]
+fn documentation_example_and_reviewed_api_baseline_are_complete() {
+ for required in [
+ "## Prepare a query without I/O",
+ "## Asset identity and acquisition",
+ "## Database and query behavior",
+ "## Errors, serialization, and side effects",
+ "## Intended consumers",
+ "radroots_crates_release_v1.md#17-radroots_geonames",
+ "examples/prepare_query.rs",
+ "docs/api/radroots_geonames.txt",
+ ] {
+ assert!(README.contains(required), "README is missing `{required}`");
+ }
+ for required in [
+ "official_asset_spec()",
+ "Query::locality(\"Victoria\")",
+ "Query::reverse(Point::new(",
+ "without I/O",
+ ] {
+ assert!(
+ EXAMPLE.contains(required),
+ "example is missing `{required}`"
+ );
+ }
+ for required in [
+ "pub struct radroots_geonames::AssetSpec",
+ "pub enum radroots_geonames::AssetStatus",
+ "pub struct radroots_geonames::Candidate",
+ "pub struct radroots_geonames::Geocoder",
+ "pub struct radroots_geonames::Point",
+ "pub struct radroots_geonames::Query",
+ "pub enum radroots_geonames::Error",
+ "Geocoder::open",
+ "Geocoder::query",
+ "Geocoder::close",
+ ] {
+ assert!(
+ PUBLIC_API.contains(required),
+ "public API is missing `{required}`"
+ );
+ }
+ for forbidden in [
+ "rusqlite",
+ "sqlx",
+ "reqwest",
+ "tokio",
+ "runtime_paths",
+ "GeocoderLocality",
+ "GeoNamesAsset",
+ ] {
+ assert!(
+ !PUBLIC_API.contains(forbidden),
+ "public API exposes `{forbidden}`"
+ );
+ }
+ assert!(
+ API_INDEX
+ .contains("| `radroots_geonames` | [`radroots_geonames.txt`](radroots_geonames.txt) |")
+ );
+}
+
+#[test]
+fn provider_source_has_no_hidden_runtime_path_or_download_implementation() {
+ let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+ let source = fs::read_dir(source_root)
+ .expect("source directory")
+ .map(|entry| {
+ let path = entry.expect("source entry").path();
+ fs::read_to_string(path).expect("utf-8 source")
+ })
+ .collect::<String>();
+ for forbidden in [
+ "radroots_runtime_paths",
+ "reqwest::",
+ "tokio::",
+ "sqlx::",
+ "std::env::",
+ "directories::",
+ "test-fixture-geonames-asset",
+ ] {
+ assert!(
+ !source.contains(forbidden),
+ "provider source contains `{forbidden}`"
+ );
+ }
+}
+
+#[test]
+fn superseded_geocoder_is_a_bounded_publish_frozen_sdk_bridge() {
+ for required in [
+ "name = \"radroots_geocoder\"",
+ "publish = false",
+ "status = \"publish_frozen\"",
+ "replacement = \"radroots_geonames\"",
+ "deviation = \"RCRV1-DEV-011\"",
+ "removal_step = 248",
+ "new_consumers_forbidden = true",
+ ] {
+ assert!(
+ LEGACY_MANIFEST.contains(required),
+ "legacy manifest is missing `{required}`"
+ );
+ }
+ for required in [
+ "## Compatibility quarantine",
+ "RCRV1-DEV-011",
+ "Step 226",
+ "Step 248",
+ ] {
+ assert!(
+ LEGACY_README.contains(required),
+ "legacy README is missing `{required}`"
+ );
+ }
+ for required in [
+ "| `radroots_geocoder` | `radroots_geonames` |",
+ "SDK manifest cutover Step 226",
+ "SDK quarantine removal Step 248",
+ ] {
+ assert!(
+ COMPATIBILITY.contains(required),
+ "quarantine ledger is missing `{required}`"
+ );
+ }
+ assert!(DEVIATIONS.contains("id = \"RCRV1-DEV-011\""));
+ let private = PUBLISH_POLICY
+ .split_once("[workspace_classification]")
+ .expect("workspace classification")
+ .1
+ .split_once("build_codegen")
+ .expect("private classification")
+ .0;
+ assert!(private.contains("\"radroots_geocoder\""));
+}
+
+fn radroots_dependency_keys(manifest: &str) -> BTreeSet<&str> {
+ dependency_keys(manifest)
+ .into_iter()
+ .filter(|key| key.starts_with("radroots_"))
+ .collect()
+}
+
+fn dependency_keys(manifest: &str) -> BTreeSet<&str> {
+ manifest
+ .split_once("[dependencies]")
+ .map(|(_, dependencies)| dependencies)
+ .unwrap_or_default()
+ .lines()
+ .skip(1)
+ .take_while(|line| !line.starts_with('['))
+ .filter_map(|line| line.split_once('=').map(|(key, _)| key.trim()))
+ .filter(|key| !key.is_empty())
+ .collect()
+}
+
+fn public_modules(root: &str) -> BTreeSet<&str> {
+ root.lines()
+ .filter_map(|line| line.trim().strip_prefix("pub mod "))
+ .filter_map(|module| module.strip_suffix(';'))
+ .collect()
+}
+
+fn private_modules(root: &str) -> BTreeSet<&str> {
+ root.lines()
+ .filter_map(|line| line.trim().strip_prefix("mod "))
+ .filter_map(|module| module.strip_suffix(';'))
+ .collect()
+}
diff --git a/docs/api/README.md b/docs/api/README.md
@@ -44,3 +44,4 @@ expand a package beyond its charter.
| `radroots_secrets` | [`radroots_secrets.txt`](radroots_secrets.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) |
| `radroots_storage` | [`radroots_storage.txt`](radroots_storage.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) |
| `radroots_transport_nostr` | [`radroots_transport_nostr.txt`](radroots_transport_nostr.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) |
+| `radroots_geonames` | [`radroots_geonames.txt`](radroots_geonames.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) |
diff --git a/docs/api/radroots_geonames.txt b/docs/api/radroots_geonames.txt
@@ -0,0 +1,163 @@
+pub mod radroots_geonames
+pub mod radroots_geonames::asset
+#[non_exhaustive] pub enum radroots_geonames::asset::AssetStatus
+pub radroots_geonames::asset::AssetStatus::Available
+pub radroots_geonames::asset::AssetStatus::Invalid
+pub radroots_geonames::asset::AssetStatus::Missing
+pub struct radroots_geonames::asset::AssetSpec
+impl radroots_geonames::asset::AssetSpec
+pub fn radroots_geonames::asset::AssetSpec::allowed_host(&self) -> &str
+pub const fn radroots_geonames::asset::AssetSpec::byte_size(&self) -> u64
+pub fn radroots_geonames::asset::AssetSpec::file_name(&self) -> &str
+pub fn radroots_geonames::asset::AssetSpec::new(impl core::convert::Into<alloc::string::String>, impl core::convert::Into<alloc::string::String>, impl core::convert::Into<alloc::string::String>, impl core::convert::Into<alloc::string::String>, u64, [u8; 32]) -> core::result::Result<Self, radroots_geonames::Error>
+pub const fn radroots_geonames::asset::AssetSpec::sha256(&self) -> &[u8; 32]
+pub fn radroots_geonames::asset::AssetSpec::source(&self) -> &str
+pub fn radroots_geonames::asset::AssetSpec::version(&self) -> &str
+pub const radroots_geonames::asset::OFFICIAL_ASSET_BYTE_SIZE: u64
+pub const radroots_geonames::asset::OFFICIAL_ASSET_FILE_NAME: &str
+pub const radroots_geonames::asset::OFFICIAL_ASSET_SHA256: [u8; 32]
+pub const radroots_geonames::asset::OFFICIAL_ASSET_SOURCE: &str
+pub const radroots_geonames::asset::OFFICIAL_ASSET_VERSION: &str
+pub fn radroots_geonames::asset::inspect(impl core::convert::AsRef<std::path::Path>, &radroots_geonames::asset::AssetSpec) -> core::result::Result<radroots_geonames::asset::AssetStatus, radroots_geonames::Error>
+pub fn radroots_geonames::asset::official_asset_spec() -> radroots_geonames::asset::AssetSpec
+pub mod radroots_geonames::database
+pub struct radroots_geonames::database::Geocoder
+impl radroots_geonames::database::Geocoder
+pub fn radroots_geonames::database::Geocoder::close(self) -> core::result::Result<(), radroots_geonames::Error>
+pub fn radroots_geonames::database::Geocoder::open(impl core::convert::AsRef<std::path::Path>, &radroots_geonames::asset::AssetSpec) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::database::Geocoder::query(&self, &radroots_geonames::query::Query) -> core::result::Result<radroots_geonames::query::QueryResult, radroots_geonames::Error>
+pub mod radroots_geonames::download
+#[non_exhaustive] pub enum radroots_geonames::download::FetchFailurePhase
+pub radroots_geonames::download::FetchFailurePhase::Cancelled
+pub radroots_geonames::download::FetchFailurePhase::Connect
+pub radroots_geonames::download::FetchFailurePhase::Read
+pub radroots_geonames::download::FetchFailurePhase::Response
+impl core::fmt::Display for radroots_geonames::download::FetchFailurePhase
+pub fn radroots_geonames::download::FetchFailurePhase::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub trait radroots_geonames::download::Fetcher
+pub fn radroots_geonames::download::Fetcher::fetch(&self, &str, &mut dyn core::io::write::Write) -> core::result::Result<(), radroots_geonames::Error>
+pub fn radroots_geonames::download::acquire(impl core::convert::AsRef<std::path::Path>, &radroots_geonames::asset::AssetSpec, &dyn radroots_geonames::download::Fetcher) -> core::result::Result<radroots_geonames::asset::AssetStatus, radroots_geonames::Error>
+pub mod radroots_geonames::model
+pub struct radroots_geonames::model::Candidate
+impl radroots_geonames::model::Candidate
+pub fn radroots_geonames::model::Candidate::admin1_id(&self) -> core::option::Option<&str>
+pub fn radroots_geonames::model::Candidate::admin1_name(&self) -> core::option::Option<&str>
+pub fn radroots_geonames::model::Candidate::country_id(&self) -> &str
+pub fn radroots_geonames::model::Candidate::country_name(&self) -> core::option::Option<&str>
+pub fn radroots_geonames::model::Candidate::display_name(&self) -> &str
+pub const fn radroots_geonames::model::Candidate::feature_id(&self) -> u64
+pub fn radroots_geonames::model::Candidate::name(&self) -> &str
+pub const fn radroots_geonames::model::Candidate::point(&self) -> radroots_geonames::model::Point
+pub struct radroots_geonames::model::Country
+impl radroots_geonames::model::Country
+pub const fn radroots_geonames::model::Country::center(&self) -> radroots_geonames::model::Point
+pub fn radroots_geonames::model::Country::id(&self) -> &str
+pub fn radroots_geonames::model::Country::name(&self) -> core::option::Option<&str>
+pub struct radroots_geonames::model::Point
+impl radroots_geonames::model::Point
+pub const fn radroots_geonames::model::Point::latitude(self) -> f64
+pub const fn radroots_geonames::model::Point::longitude(self) -> f64
+pub fn radroots_geonames::model::Point::new(f64, f64) -> core::result::Result<Self, radroots_geonames::Error>
+pub mod radroots_geonames::query
+pub struct radroots_geonames::query::Query
+impl radroots_geonames::query::Query
+pub const fn radroots_geonames::query::Query::countries() -> Self
+pub fn radroots_geonames::query::Query::exact_feature_id(&self) -> core::option::Option<u64>
+pub fn radroots_geonames::query::Query::feature_id(u64) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::freeform(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::freeform_text(&self) -> core::option::Option<&str>
+pub fn radroots_geonames::query::Query::is_country_list(&self) -> bool
+pub const fn radroots_geonames::query::Query::limit(&self) -> usize
+pub fn radroots_geonames::query::Query::locality(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::locality_fields(&self) -> core::option::Option<(&str, core::option::Option<&str>, core::option::Option<&str>)>
+pub const fn radroots_geonames::query::Query::reverse(radroots_geonames::model::Point) -> Self
+pub fn radroots_geonames::query::Query::reverse_point(&self) -> core::option::Option<radroots_geonames::model::Point>
+pub fn radroots_geonames::query::Query::with_country(self, impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::with_limit(self, usize) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::with_radius_degrees(self, f64) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::with_region(self, impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_geonames::Error>
+pub struct radroots_geonames::query::QueryResult
+impl radroots_geonames::query::QueryResult
+pub fn radroots_geonames::query::QueryResult::as_candidates(&self) -> core::option::Option<&[radroots_geonames::model::Candidate]>
+pub fn radroots_geonames::query::QueryResult::as_countries(&self) -> core::option::Option<&[radroots_geonames::model::Country]>
+#[non_exhaustive] pub enum radroots_geonames::AssetStatus
+pub radroots_geonames::AssetStatus::Available
+pub radroots_geonames::AssetStatus::Invalid
+pub radroots_geonames::AssetStatus::Missing
+#[non_exhaustive] pub enum radroots_geonames::Error
+pub radroots_geonames::Error::AssetDestinationBusy
+pub radroots_geonames::Error::AssetHashMismatch
+pub radroots_geonames::Error::AssetSizeMismatch
+pub radroots_geonames::Error::AssetSizeMismatch::actual: u64
+pub radroots_geonames::Error::AssetSizeMismatch::expected: u64
+pub radroots_geonames::Error::DatabaseConnectionUnavailable
+pub radroots_geonames::Error::DatabaseOperationFailed
+pub radroots_geonames::Error::DatabaseOperationFailed::operation: &'static str
+pub radroots_geonames::Error::Fetch
+pub radroots_geonames::Error::Fetch::phase: radroots_geonames::download::FetchFailurePhase
+pub radroots_geonames::Error::InvalidAssetByteSize
+pub radroots_geonames::Error::InvalidAssetFileName
+pub radroots_geonames::Error::InvalidAssetSource
+pub radroots_geonames::Error::InvalidAssetVersion
+pub radroots_geonames::Error::InvalidDatabase
+pub radroots_geonames::Error::InvalidDatabaseSchema
+pub radroots_geonames::Error::InvalidFeatureId
+pub radroots_geonames::Error::InvalidPoint
+pub radroots_geonames::Error::InvalidQueryLimit
+pub radroots_geonames::Error::InvalidQueryRadius
+pub radroots_geonames::Error::InvalidQueryText
+pub radroots_geonames::Error::Io
+pub radroots_geonames::Error::Io::kind: core::io::error::ErrorKind
+pub radroots_geonames::Error::Io::operation: &'static str
+pub radroots_geonames::Error::QueryOptionNotApplicable
+pub radroots_geonames::Error::UnsafeAssetDestination
+pub radroots_geonames::Error::UntrustedAssetSource
+impl core::error::Error for radroots_geonames::Error
+impl core::fmt::Display for radroots_geonames::Error
+pub fn radroots_geonames::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct radroots_geonames::AssetSpec
+impl radroots_geonames::asset::AssetSpec
+pub fn radroots_geonames::asset::AssetSpec::allowed_host(&self) -> &str
+pub const fn radroots_geonames::asset::AssetSpec::byte_size(&self) -> u64
+pub fn radroots_geonames::asset::AssetSpec::file_name(&self) -> &str
+pub fn radroots_geonames::asset::AssetSpec::new(impl core::convert::Into<alloc::string::String>, impl core::convert::Into<alloc::string::String>, impl core::convert::Into<alloc::string::String>, impl core::convert::Into<alloc::string::String>, u64, [u8; 32]) -> core::result::Result<Self, radroots_geonames::Error>
+pub const fn radroots_geonames::asset::AssetSpec::sha256(&self) -> &[u8; 32]
+pub fn radroots_geonames::asset::AssetSpec::source(&self) -> &str
+pub fn radroots_geonames::asset::AssetSpec::version(&self) -> &str
+pub struct radroots_geonames::Candidate
+impl radroots_geonames::model::Candidate
+pub fn radroots_geonames::model::Candidate::admin1_id(&self) -> core::option::Option<&str>
+pub fn radroots_geonames::model::Candidate::admin1_name(&self) -> core::option::Option<&str>
+pub fn radroots_geonames::model::Candidate::country_id(&self) -> &str
+pub fn radroots_geonames::model::Candidate::country_name(&self) -> core::option::Option<&str>
+pub fn radroots_geonames::model::Candidate::display_name(&self) -> &str
+pub const fn radroots_geonames::model::Candidate::feature_id(&self) -> u64
+pub fn radroots_geonames::model::Candidate::name(&self) -> &str
+pub const fn radroots_geonames::model::Candidate::point(&self) -> radroots_geonames::model::Point
+pub struct radroots_geonames::Geocoder
+impl radroots_geonames::database::Geocoder
+pub fn radroots_geonames::database::Geocoder::close(self) -> core::result::Result<(), radroots_geonames::Error>
+pub fn radroots_geonames::database::Geocoder::open(impl core::convert::AsRef<std::path::Path>, &radroots_geonames::asset::AssetSpec) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::database::Geocoder::query(&self, &radroots_geonames::query::Query) -> core::result::Result<radroots_geonames::query::QueryResult, radroots_geonames::Error>
+pub struct radroots_geonames::Point
+impl radroots_geonames::model::Point
+pub const fn radroots_geonames::model::Point::latitude(self) -> f64
+pub const fn radroots_geonames::model::Point::longitude(self) -> f64
+pub fn radroots_geonames::model::Point::new(f64, f64) -> core::result::Result<Self, radroots_geonames::Error>
+pub struct radroots_geonames::Query
+impl radroots_geonames::query::Query
+pub const fn radroots_geonames::query::Query::countries() -> Self
+pub fn radroots_geonames::query::Query::exact_feature_id(&self) -> core::option::Option<u64>
+pub fn radroots_geonames::query::Query::feature_id(u64) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::freeform(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::freeform_text(&self) -> core::option::Option<&str>
+pub fn radroots_geonames::query::Query::is_country_list(&self) -> bool
+pub const fn radroots_geonames::query::Query::limit(&self) -> usize
+pub fn radroots_geonames::query::Query::locality(impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::locality_fields(&self) -> core::option::Option<(&str, core::option::Option<&str>, core::option::Option<&str>)>
+pub const fn radroots_geonames::query::Query::reverse(radroots_geonames::model::Point) -> Self
+pub fn radroots_geonames::query::Query::reverse_point(&self) -> core::option::Option<radroots_geonames::model::Point>
+pub fn radroots_geonames::query::Query::with_country(self, impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::with_limit(self, usize) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::with_radius_degrees(self, f64) -> core::result::Result<Self, radroots_geonames::Error>
+pub fn radroots_geonames::query::Query::with_region(self, impl core::convert::Into<alloc::string::String>) -> core::result::Result<Self, radroots_geonames::Error>
diff --git a/docs/implementation/COMPATIBILITY_SHIMS.md b/docs/implementation/COMPATIBILITY_SHIMS.md
@@ -12,6 +12,7 @@ identity or a second contract authority.
| hidden `radroots_nostr_connect::prelude` and prefixed client bridge | final `radroots_nostr_connect` modules and client state machine | `oss/cli`, `oss/myc`, enterprise NIP-46 adapters, integration harnesses | CLI Step 271; Myc Step 288; residual consumers Step 293; matrix Step 294 | Step 313 |
| `radroots_nostr_runtime` | `radroots_transport_nostr`, `radroots_sync` | private `radroots_nostrdb` runtime adapter | sync Steps 202-214 | Step 215 |
| `radroots_net` | `radroots_transport`, `radroots_sync`, `radroots_sdk` | `app_rt` as `radroots_net_core` | downstream Steps 269-293; matrix Step 294 | Step 313 |
+| `radroots_geocoder` | `radroots_geonames` | standalone `radroots_sdk` GeoNames feature and error adapter | SDK manifest cutover Step 226 | SDK quarantine removal Step 248 |
Both compatibility package manifests and `radroots_nostr_connect` keep
`publish = false`. Release policy classifies the shims as private and excludes
diff --git a/docs/implementation/DEVIATIONS.md b/docs/implementation/DEVIATIONS.md
@@ -16,6 +16,7 @@ silently change `radroots.crates.release.v1`.
| `RCRV1-DEV-007` | 122, 170, 215, 235, 305 | Remove the predecessor monolithic transport SPI now; quarantine publish-frozen runtime, SDK, CLI, and daemon consumer shims until their explicit removal gates. |
| `RCRV1-DEV-008` | 153, 155, 171, 179, 226, 288, 293, 313 | Activate final secrets dependency edges now; quarantine legacy vault/store consumers until their ordered storage, SDK, downstream, and final-removal gates. |
| `RCRV1-DEV-009` | 170, 179, 189, 196, 201, 213, 226, 235, 263, 269, 288, 292, 313 | Quarantine the four superseded storage packages until their independently buildable first-party consumers migrate, then remove them at Step 313. |
+| `RCRV1-DEV-011` | 225, 226, 248 | Quarantine the superseded geocoder package until the standalone SDK adopts `radroots_geonames`, then remove it at the SDK retirement gate. |
## Record template
diff --git a/docs/implementation/deviations.toml b/docs/implementation/deviations.toml
@@ -224,3 +224,28 @@ verification = [
unresolved_risk = "A 90% development gate admits untested paths that a later 100% gate would reject; the final restoration remains intentionally unscheduled pending explicit authority."
normative_architecture_change = false
adr_required = false
+
+[[deviation]]
+id = "RCRV1-DEV-011"
+date = "2026-08-03"
+status = "active"
+approval = "All code-review recommendations and the full multi-RCLD implementation sequence were explicitly approved by the user."
+affected_steps = ["225", "226", "248"]
+spec_anchors = [
+ "docs/specs/radroots_crates_release_v1.md#17-radroots_geonames",
+ "docs/specs/radroots_crates_release_v1.md#20-current-to-target-migration-map",
+]
+source_evidence = [
+ "The standalone SDK manifest and GeoNames module still resolve radroots_geocoder and its test-fixture feature while the final provider package is implemented in this independently versioned repository.",
+ "The legacy package has no in-repository consumer, is publish disabled, and is already excluded from the exact release-v1 public package inventory.",
+ "Deleting the package at Step 225 would make the independently buildable SDK repository unresolvable before its ordered manifest and API cutover begins at Step 226.",
+]
+replacement_action = "Keep radroots_geocoder as a documentation-marked, machine-classified publish-frozen bridge with no new consumers, features, contracts, or behavior; migrate the standalone SDK to radroots_geonames beginning at Step 226 and delete the bridge at the SDK retirement gate in Step 248."
+verification = [
+ "The predecessor manifest names radroots_geonames as its replacement, RCRV1-DEV-011 as authority, and Step 248 as the exact removal gate.",
+ "GeoNames package quarantine tests require the predecessor to remain private and absent from the approved publication inventory while the SDK source census remains non-empty.",
+ "Step 248 must reject every remaining package, dependency, feature, import, and error-adapter reference to radroots_geocoder before deletion.",
+]
+unresolved_risk = "The standalone SDK remains coupled to the predecessor API until its ordered cutover; package-realistic publication is blocked until Step 248 removes the bridge."
+normative_architecture_change = false
+adr_required = false