lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
Mcrates/geocoder/Cargo.toml | 7+++++++
Mcrates/geocoder/README | 8++++++++
Mcrates/geonames/README.md | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Acrates/geonames/examples/prepare_query.rs | 19+++++++++++++++++++
Mcrates/geonames/src/asset.rs | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/geonames/src/database.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/geonames/src/download.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/geonames/src/error.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/geonames/src/lib.rs | 16++++++++++++++++
Mcrates/geonames/src/query.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/geonames/tests/package_boundary.rs | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/api/README.md | 1+
Adocs/api/radroots_geonames.txt | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/COMPATIBILITY_SHIMS.md | 1+
Mdocs/implementation/DEVIATIONS.md | 1+
Mdocs/implementation/deviations.toml | 25+++++++++++++++++++++++++
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(&not_directory, b"file").expect("plain file"); + assert_eq!( + acquire(&not_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