diff --git a/crates/registry-breg-client-node/README.md b/crates/registry-breg-client-node/README.md index 23dde3dda7..033cb7cfed 100644 --- a/crates/registry-breg-client-node/README.md +++ b/crates/registry-breg-client-node/README.md @@ -144,7 +144,9 @@ key. Ordinary list calls accept `bbox: [west, south, east, north]` as four exact decimal strings when the served query contract supports it. GeoJSON has a separate `getGeoJsonRecord` and `listGeoJsonRecords` surface, with typed -features, GeoJSON media checks, and its own continuation type. +features whose geometry is `Point | Polygon | MultiPolygon | null`, GeoJSON +media checks, and its own continuation type. Polygon ring order and winding are +preserved. `listCurrentRecords`, `listRecordsAsOf`, and `listSnapshotRecords` keep their temporal parameters and continuations distinct. Snapshot continuations bind diff --git a/crates/registry-breg-client-node/__test__/facade.test-d.ts b/crates/registry-breg-client-node/__test__/facade.test-d.ts index 1b363b1d99..f7a66944c4 100644 --- a/crates/registry-breg-client-node/__test__/facade.test-d.ts +++ b/crates/registry-breg-client-node/__test__/facade.test-d.ts @@ -20,6 +20,7 @@ import { AsOfListContinuation, CurrentListContinuation, GeoJsonListContinuation, + GeoJsonGeometry, RelationshipListContinuation, SnapshotListContinuation, ListContinuation, @@ -84,6 +85,14 @@ declare const asOfContinuation: AsOfListContinuation declare const snapshotContinuation: SnapshotListContinuation declare const relationshipContinuation: RelationshipListContinuation declare const geoJsonContinuation: GeoJsonListContinuation +declare const geometry: GeoJsonGeometry +if (geometry.type === 'Polygon') { + geometry.coordinates[0]?.[0]?.[0].toFixed() +} else if (geometry.type === 'MultiPolygon') { + geometry.coordinates[0]?.[0]?.[0]?.[1].toFixed() +} else { + geometry.coordinates[0].toFixed() +} metadata.actions[0]?.inputs[0]?.schemaJson.toUpperCase() metadata.actions[0]?.attachments[0]?.contentTypes[0]?.toUpperCase() diff --git a/crates/registry-breg-client-node/__test__/read-parity.test.js b/crates/registry-breg-client-node/__test__/read-parity.test.js index 5f47d63cab..723cbd370f 100644 --- a/crates/registry-breg-client-node/__test__/read-parity.test.js +++ b/crates/registry-breg-client-node/__test__/read-parity.test.js @@ -11,7 +11,9 @@ const traceparent = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; const link = '; rel="profile", ; rel="describedby"'; const meta = { registryIdentifier: 'registry', datasetIdentifier: 'dataset', recordTypeIdentifier: 'company' }; const record = { recordIdentifier: id, revisionIdentifier: '1', domainData: { label: 'one' } }; -const feature = { type: 'Feature', id, geometry: null, properties: { wide: 9007199254740992 }, registry: { revision: 1 } }; +const polygon = { type: 'Polygon', coordinates: [[[100.25, 13.25], [101, 13.25], [101, 14], [100.25, 13.25]]] }; +const multiPolygon = { type: 'MultiPolygon', coordinates: [[[[100.25, 13.25], [101, 13.25], [101, 14], [100.25, 13.25]]]] }; +const feature = { type: 'Feature', id, geometry: polygon, properties: { wide: 9007199254740992 }, registry: { revision: 1 } }; function respond(response, mediaType, document, includeLink = false, etag = false) { response.setHeader('content-type', mediaType); @@ -29,7 +31,7 @@ test('specialized read methods keep routes, cursors, snapshots, exact JSON and o const continuation = request.url.includes('$skiptoken='); if (request.headers.accept === 'application/geo+json') { if (path.endsWith(id)) return respond(response, 'application/geo+json', feature); - return respond(response, 'application/geo+json', `{"type":"FeatureCollection","features":[{"type":"Feature","id":"${id}","geometry":null,"properties":{"wide":9007199254740992},"registry":{"revision":1}}],"numberReturned":1,"registry":{"pageInfo":{"nextCursor":${continuation ? 'null' : '"geo-next"'}}}}`); + return respond(response, 'application/geo+json', `{"type":"FeatureCollection","features":[{"type":"Feature","id":"${id}","geometry":${JSON.stringify(multiPolygon)},"properties":{"wide":9007199254740992},"registry":{"revision":1}}],"numberReturned":1,"registry":{"pageInfo":{"nextCursor":${continuation ? 'null' : '"geo-next"'}}}}`); } const collection = !path.endsWith(id) && !path.includes('/revisions/'); const responseMeta = path.includes('/incomplete-meta/') @@ -55,9 +57,12 @@ test('specialized read methods keep routes, cursors, snapshots, exact JSON and o await client.getRecordJson('companies', id, { select: ['name'] }); assert(Buffer.isBuffer((await client.getRecordRevision('companies', id, 1)).body)); - assert.equal((await client.getGeoJsonRecord('companies', id)).value.type, 'Feature'); + const polygonFeature = (await client.getGeoJsonRecord('companies', id)).value; + assert.equal(polygonFeature.geometry.type, 'Polygon'); + assert.deepEqual(polygonFeature.geometry.coordinates[0][0], [100.25, 13.25]); assert.match((await client.getGeoJsonRecordJson('companies', id)).valueJson, /9007199254740992/); const geo = await client.listGeoJsonRecords('companies', { accessProfile: 'map', bbox: ['100.1', '13.1', '100.2', '13.2'] }); + assert.equal(geo.value.features[0].geometry.type, 'MultiPolygon'); await client.continueGeoJsonList(geo.continuation); const geoJson = await client.listGeoJsonRecordsJson('companies'); assert.match(geoJson.valueJson, /9007199254740992/); @@ -101,6 +106,7 @@ test('specialized read methods keep routes, cursors, snapshots, exact JSON and o const requestCount = requests.length; await assert.rejects(client.listCurrentRecords('companies', { bbox: ['1', '2', '3', '4'] }), error => error.kind === 'invalid-request'); + await assert.rejects(client.listGeoJsonRecords('companies', { bbox: ['100', '13', '99', '14'] }), error => error.kind === 'invalid-request'); await assert.rejects(client.getRecordRevision('companies', id, Number.MAX_SAFE_INTEGER + 1), error => error.kind === 'invalid-request'); assert.equal(requests.length, requestCount); } finally { diff --git a/crates/registry-breg-client-node/client.d.ts b/crates/registry-breg-client-node/client.d.ts index f9f72a039a..a4cbb59ebe 100644 --- a/crates/registry-breg-client-node/client.d.ts +++ b/crates/registry-breg-client-node/client.d.ts @@ -351,14 +351,24 @@ export interface JsonOutcome { validAt?: string } +export type GeoJsonPosition = readonly [longitude: number, latitude: number] export interface GeoJsonPoint { readonly type: 'Point' - readonly coordinates: readonly [number, number] + readonly coordinates: GeoJsonPosition } +export interface GeoJsonPolygon { + readonly type: 'Polygon' + readonly coordinates: ReadonlyArray> +} +export interface GeoJsonMultiPolygon { + readonly type: 'MultiPolygon' + readonly coordinates: ReadonlyArray>> +} +export type GeoJsonGeometry = GeoJsonPoint | GeoJsonPolygon | GeoJsonMultiPolygon export interface GeoJsonFeature { readonly type: 'Feature' readonly id: string - readonly geometry: GeoJsonPoint | null + readonly geometry: GeoJsonGeometry | null readonly properties: Readonly> readonly registry: { readonly revision: SafeInteger } } diff --git a/crates/registry-breg-client-py/README.md b/crates/registry-breg-client-py/README.md index d406a3c4bd..c6641fc4e4 100644 --- a/crates/registry-breg-client-py/README.md +++ b/crates/registry-breg-client-py/README.md @@ -22,7 +22,8 @@ These descriptions support interface choices; select a binding before execution. `list_records` accepts an optional exact decimal `bbox=(west, south, east, north)`. GeoJSON has separate `get_geojson_record`, `list_geojson_records`, and `continue_geojson_list` methods so its response contract cannot be confused -with a Registry Record. +with a Registry Record. Their typed feature geometry is `Point`, `Polygon`, +`MultiPolygon`, or `None`. Polygon ring order and winding are preserved. Temporal and relationship collections also use operation-specific methods and continuations: diff --git a/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi b/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi index 30fedfe86b..9cddd668ca 100644 --- a/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi +++ b/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi @@ -7,6 +7,58 @@ StatisticsFormat = Literal["json", "csv"] StatisticsReleaseStatus = Literal["provisional", "final"] StatisticsReleaseSelection = Literal["any", "final"] StatisticsWithdrawalReason = Literal["computation-error", "source-data-error", "disclosure-risk"] +GeoJsonPosition = list[float | int] + +class GeoJsonPoint(TypedDict): + type: Literal["Point"] + coordinates: GeoJsonPosition + +class GeoJsonPolygon(TypedDict): + type: Literal["Polygon"] + coordinates: list[list[GeoJsonPosition]] + +class GeoJsonMultiPolygon(TypedDict): + type: Literal["MultiPolygon"] + coordinates: list[list[list[GeoJsonPosition]]] + +GeoJsonGeometry = GeoJsonPoint | GeoJsonPolygon | GeoJsonMultiPolygon + +class GeoJsonFeatureRegistry(TypedDict): + revision: int + +class GeoJsonFeature(TypedDict): + type: Literal["Feature"] + id: str + geometry: GeoJsonGeometry | None + properties: dict[str, JsonValue] + registry: GeoJsonFeatureRegistry + +class GeoJsonPageInfo(TypedDict): + nextCursor: str | None + +class GeoJsonCollectionRegistry(TypedDict): + pageInfo: GeoJsonPageInfo + count: NotRequired[int] + +class GeoJsonFeatureCollection(TypedDict): + type: Literal["FeatureCollection"] + features: list[GeoJsonFeature] + numberReturned: int + registry: GeoJsonCollectionRegistry + +class GeoJsonCompleteOutcome(TypedDict): + kind: Literal["complete"] + value: GeoJsonFeature + trace_id: str + etag: str | None + location: str | None + +class GeoJsonPageOutcome(TypedDict): + kind: Literal["complete"] + value: GeoJsonFeatureCollection + continuation: dict[str, JsonValue] | None + trace_id: str + etag: str | None class BRegStatisticalDimensionDescriptor(TypedDict): field: str @@ -389,7 +441,7 @@ class BaseRegistryClient: *, select: Sequence[str] | None = None, access_profile: str | None = None, - ) -> dict[str, Any]: ... + ) -> GeoJsonCompleteOutcome: ... def list_geojson_records( self, record_route: str, @@ -401,10 +453,10 @@ class BaseRegistryClient: orderby: str | None = None, count: bool | None = None, bbox: tuple[str, str, str, str] | None = None, - ) -> dict[str, Any]: ... + ) -> GeoJsonPageOutcome: ... def continue_geojson_list( self, continuation: dict[str, JsonValue] - ) -> dict[str, Any]: ... + ) -> GeoJsonPageOutcome: ... def record_revisions( self, record_route: str, diff --git a/crates/registry-breg-client-py/tests/python/test_read_parity.py b/crates/registry-breg-client-py/tests/python/test_read_parity.py index 779642b963..90d256c13c 100644 --- a/crates/registry-breg-client-py/tests/python/test_read_parity.py +++ b/crates/registry-breg-client-py/tests/python/test_read_parity.py @@ -39,11 +39,17 @@ def metadata() -> dict: } -def feature() -> dict: +def feature(geometry: dict | None = None) -> dict: return { "type": "Feature", "id": RECORD_ID, - "geometry": {"type": "Point", "coordinates": [100.25, 13.25]}, + "geometry": geometry + or { + "type": "Polygon", + "coordinates": [ + [[100.25, 13.25], [101, 13.25], [101, 14], [100.25, 13.25]] + ], + }, "properties": {"label": "one", "large": 2**63}, "registry": {"revision": 1}, } @@ -76,7 +82,23 @@ def do_GET(self) -> None: # noqa: N802 self.respond( { "type": "FeatureCollection", - "features": [feature()], + "features": [ + feature( + { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [100.25, 13.25], + [101, 13.25], + [101, 14], + [100.25, 13.25], + ] + ] + ], + } + ) + ], "numberReturned": 1, "registry": { "pageInfo": {"nextCursor": cursor}, @@ -151,7 +173,10 @@ def tearDown(self) -> None: def test_geojson_bbox_and_typed_continuation(self) -> None: single = self.client.get_geojson_record("companies", RECORD_ID) - self.assertEqual(single["value"]["geometry"]["coordinates"], [100.25, 13.25]) + self.assertEqual(single["value"]["geometry"]["type"], "Polygon") + self.assertEqual( + single["value"]["geometry"]["coordinates"][0][0], [100.25, 13.25] + ) self.assertEqual(single["value"]["properties"]["large"], 2**63) self.assertIsNone(single["etag"]) @@ -163,10 +188,20 @@ def test_geojson_bbox_and_typed_continuation(self) -> None: bbox=("100.1", "13.1", "100.2", "13.2"), ) self.assertEqual(first["value"]["numberReturned"], 1) + self.assertEqual( + first["value"]["features"][0]["geometry"]["type"], "MultiPolygon" + ) self.assertEqual(first["continuation"]["skiptoken"], "geo-next") second = self.client.continue_geojson_list(first["continuation"]) self.assertIsNone(second["continuation"]) self.assertTrue(any("bbox=100.1%2C13.1%2C100.2%2C13.2" in path for path, _ in self.requests)) + request_count = len(self.requests) + with self.assertRaises(BaseRegistryClientError) as invalid_bbox: + self.client.list_geojson_records( + "companies", bbox=("100", "13", "99", "14") + ) + self.assertEqual(invalid_bbox.exception.kind, "invalid-request") + self.assertEqual(len(self.requests), request_count) def test_temporal_snapshot_relationship_revision_reads(self) -> None: current = self.client.list_current_records("companies", top=1) diff --git a/crates/registry-breg-client/README.md b/crates/registry-breg-client/README.md index fe7c5992c1..336317afc6 100644 --- a/crates/registry-breg-client/README.md +++ b/crates/registry-breg-client/README.md @@ -87,13 +87,15 @@ same collection identity. ## Native collection capabilities -Native Point queries use `BRegBoundingBox` and `BRegListRequest::bbox` for JSON, +Native Point and polygon queries use `BRegBoundingBox` and `BRegListRequest::bbox` for JSON, or the separate `get_geojson_record`, `list_geojson_records`, and `continue_geojson_list` methods for GeoJSON. Boxes contain exact decimal strings -in west, south, east, north order. Edges are inclusive; zero-area boxes are valid. +in west, south, east, north order. Edges are inclusive and zero-area boxes are valid. The client validates coordinate and wire bounds, and the server enforces the -profile's geometry/query grants and span limits. GeoJSON get responses have no -mutation ETag. The `/v1/gis` QGIS adapter routes are outside SDK scope. +profile's geometry/query grants and span limits. Typed GeoJSON features retain +`Point`, `Polygon`, and `MultiPolygon` coordinates, including ring order and +winding. Read the native Record representation for a mutation ETag. QGIS +connects directly to the `/v1/gis` adapter. The runnable `native_geojson` example prints each FeatureCollection as one JSON line. Run it against an existing spatial registry from the workspace root, diff --git a/crates/registry-breg-client/examples/native_geojson.rs b/crates/registry-breg-client/examples/native_geojson.rs index 2f8c9d67a4..687a516f5c 100644 --- a/crates/registry-breg-client/examples/native_geojson.rs +++ b/crates/registry-breg-client/examples/native_geojson.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -//! Read a configured Point collection with a bbox and explicit pagination. +//! Read a configured spatial collection with a bbox and explicit pagination. //! See the crate README for the required environment variables. use std::sync::Arc; diff --git a/crates/registry-breg-client/src/actions.rs b/crates/registry-breg-client/src/actions.rs index 3ca3987a6f..f8e1c36078 100644 --- a/crates/registry-breg-client/src/actions.rs +++ b/crates/registry-breg-client/src/actions.rs @@ -1115,6 +1115,7 @@ fn valid_action_input( .is_some_and(|values| values.iter().any(|candidate| candidate == value)) }), Some("crs84-point") => valid_point(value, contract), + Some("crs84-polygon") => valid_polygon(value, contract), Some("structured") => valid_structured(value, contract), _ => false, } @@ -1271,6 +1272,144 @@ fn valid_point(value: &Value, contract: &Map) -> bool { west <= longitude && longitude <= east && south <= latitude && latitude <= north } +fn valid_polygon(value: &Value, contract: &Map) -> bool { + let Some(precision) = contract + .get("precision") + .and_then(Value::as_u64) + .filter(|precision| *precision <= 9) + else { + return false; + }; + let Some(maximum_positions) = contract + .get("maximumPositions") + .and_then(Value::as_u64) + .filter(|maximum| (4..=10_000).contains(maximum)) + else { + return false; + }; + let bounds = match contract.get("bbox") { + None => None, + Some(Value::Object(bbox)) => { + let [Some(west), Some(south), Some(east), Some(north)] = [ + ("west", -180.0, 180.0), + ("south", -90.0, 90.0), + ("east", -180.0, 180.0), + ("north", -90.0, 90.0), + ] + .map(|(name, minimum, maximum)| { + bbox.get(name) + .and_then(Value::as_str) + .and_then(|value| coordinate_text(value, precision, minimum, maximum)) + }) else { + return false; + }; + Some([west, south, east, north]) + } + Some(_) => return false, + }; + let Some(object) = value.as_object() else { + return false; + }; + if object.len() != 2 { + return false; + } + let Some(coordinates) = object.get("coordinates") else { + return false; + }; + let mut position_count = 0_u64; + match object.get("type").and_then(Value::as_str) { + Some("Polygon") => valid_polygon_coordinates( + coordinates, + precision, + bounds, + maximum_positions, + &mut position_count, + ), + Some("MultiPolygon") => coordinates.as_array().is_some_and(|polygons| { + !polygons.is_empty() + && polygons.iter().all(|polygon| { + valid_polygon_coordinates( + polygon, + precision, + bounds, + maximum_positions, + &mut position_count, + ) + }) + }), + _ => false, + } +} + +fn valid_polygon_coordinates( + value: &Value, + precision: u64, + bounds: Option<[f64; 4]>, + maximum_positions: u64, + position_count: &mut u64, +) -> bool { + value.as_array().is_some_and(|rings| { + !rings.is_empty() + && rings.iter().all(|ring| { + valid_linear_ring(ring, precision, bounds, maximum_positions, position_count) + }) + }) +} + +fn valid_linear_ring( + value: &Value, + precision: u64, + bounds: Option<[f64; 4]>, + maximum_positions: u64, + position_count: &mut u64, +) -> bool { + let Some(values) = value.as_array().filter(|values| values.len() >= 4) else { + return false; + }; + let Ok(length) = u64::try_from(values.len()) else { + return false; + }; + let Some(total) = position_count.checked_add(length) else { + return false; + }; + if total > maximum_positions { + return false; + } + *position_count = total; + let positions = values + .iter() + .map(|value| valid_polygon_position(value, precision, bounds)) + .collect::>>(); + let Some(positions) = positions else { + return false; + }; + positions.first() == positions.last() + && positions + .windows(2) + .all(|edge| (edge[0].0 - edge[1].0).abs() <= 180.0) +} + +fn valid_polygon_position( + value: &Value, + precision: u64, + bounds: Option<[f64; 4]>, +) -> Option<(f64, f64)> { + let values = value.as_array().filter(|values| values.len() == 2)?; + let longitude = polygon_coordinate(&values[0], precision, -180.0, 180.0)?; + let latitude = polygon_coordinate(&values[1], precision, -90.0, 90.0)?; + if bounds.is_some_and(|[west, south, east, north]| { + longitude < west || longitude > east || latitude < south || latitude > north + }) { + return None; + } + Some((longitude, latitude)) +} + +fn polygon_coordinate(value: &Value, precision: u64, minimum: f64, maximum: f64) -> Option { + let value = value.as_f64()?; + coordinate_text(&value.to_string(), precision, minimum, maximum) +} + fn coordinate(value: &Value, precision: u64, minimum: f64, maximum: f64) -> Option { value .is_number() @@ -1692,6 +1831,40 @@ mod tests { }), None, )); + let polygon_contract = json!({ + "type":"crs84-polygon", "precision":6, "maximumPositions":8, + "bbox":{"west":"100.0","south":"13.0","east":"103.0","north":"16.0"} + }); + assert!(valid_action_input( + &json!({"type":"Polygon","coordinates":[[ + [100.0,13.0],[101.0,13.0],[101.0,14.0],[100.0,13.0] + ]]}), + &polygon_contract, + None, + )); + assert!(valid_action_input( + &json!({"type":"MultiPolygon","coordinates":[ + [[[100.0,13.0],[101.0,13.0],[101.0,14.0],[100.0,13.0]]], + [[[102.0,15.0],[103.0,15.0],[103.0,16.0],[102.0,15.0]]] + ]}), + &polygon_contract, + None, + )); + for invalid in [ + json!({"type":"Polygon","coordinates":[]}), + json!({"type":"Polygon","coordinates":[[[100.0,13.0],[101.0,13.0],[100.0,13.0]]]}), + json!({"type":"Polygon","coordinates":[[[100.0,13.0],[101.0,13.0],[101.0,14.0],[100.0,14.0]]]}), + json!({"type":"Polygon","coordinates":[[[-170.0,13.0],[170.0,13.0],[170.0,14.0],[-170.0,13.0]]]}), + json!({"type":"Polygon","coordinates":[[[100.0,13.0],[104.0,13.0],[104.0,14.0],[100.0,13.0]]]}), + json!({"type":"Polygon","coordinates":[[[100.0000001,13.0],[101.0,13.0],[101.0,14.0],[100.0000001,13.0]]]}), + json!({"type":"MultiPolygon","coordinates":[ + [[[100.0,13.0],[101.0,13.0],[101.0,14.0],[100.0,13.0]]], + [[[102.0,15.0],[103.0,15.0],[103.0,16.0],[102.0,16.0],[102.0,15.0]]] + ]}), + json!({"type":"MultiPolygon","coordinates":[]}), + ] { + assert!(!valid_action_input(&invalid, &polygon_contract, None)); + } assert!(valid_action_input( &json!({"flag":true}), &json!({ @@ -1716,6 +1889,24 @@ mod tests { )); } + #[test] + fn polygon_precision_nine_accepts_near_zero_coordinates() { + let coordinate = json!(0.000000001); + assert!(coordinate.to_string().contains('e')); + assert!(valid_action_input( + &json!({"type":"Polygon","coordinates":[[ + [0.000000001,0.000000001], + [0.000000002,0.000000001], + [0.000000002,0.000000002], + [0.000000001,0.000000001] + ]]}), + &json!({ + "type":"crs84-polygon", "precision":9, "maximumPositions":4 + }), + None, + )); + } + #[test] fn requests_bind_saved_conditions_to_the_original_target() { let action = action_binding(false); diff --git a/crates/registry-breg-client/src/geojson.rs b/crates/registry-breg-client/src/geojson.rs index 7bbfc0737d..383105168b 100644 --- a/crates/registry-breg-client/src/geojson.rs +++ b/crates/registry-breg-client/src/geojson.rs @@ -15,6 +15,7 @@ use crate::{ }; pub(crate) const GEOJSON_MEDIA_TYPE: &str = "application/geo+json"; +const MAXIMUM_GEOMETRY_POSITIONS: usize = 10_000; /// Projection and authorization options for one native GeoJSON record read. #[derive(Clone, Default, PartialEq, Eq)] @@ -126,6 +127,41 @@ pub enum BRegGeoJsonPointType { Point, } +/// One strict CRS84 GeoJSON Polygon. Ring order and winding are preserved. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct BRegGeoJsonPolygon { + #[serde(rename = "type")] + pub polygon_type: BRegGeoJsonPolygonType, + pub coordinates: Vec>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub enum BRegGeoJsonPolygonType { + Polygon, +} + +/// One strict CRS84 GeoJSON MultiPolygon. Part, ring, and winding order are preserved. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct BRegGeoJsonMultiPolygon { + #[serde(rename = "type")] + pub multi_polygon_type: BRegGeoJsonMultiPolygonType, + pub coordinates: Vec>>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub enum BRegGeoJsonMultiPolygonType { + MultiPolygon, +} + +/// A strict native CRS84 GeoJSON geometry served by BReg. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(untagged)] +pub enum BRegGeoJsonGeometry { + Point(BRegGeoJsonPoint), + Polygon(BRegGeoJsonPolygon), + MultiPolygon(BRegGeoJsonMultiPolygon), +} + /// BReg foreign members attached to one GeoJSON Feature. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct BRegGeoJsonFeatureRegistry { @@ -138,7 +174,7 @@ pub struct BRegGeoJsonFeature { #[serde(rename = "type")] pub feature_type: BRegGeoJsonFeatureType, pub id: String, - pub geometry: Option, + pub geometry: Option, pub properties: BTreeMap, pub registry: BRegGeoJsonFeatureRegistry, } @@ -296,7 +332,7 @@ pub(crate) fn decode_feature(value: Value) -> Result { } let geometry = match take(&mut document, "geometry")? { Value::Null => None, - value => Some(decode_point(value)?), + value => Some(decode_geometry(value)?), }; let properties = object(take(&mut document, "properties")?)? .into_iter() @@ -359,6 +395,15 @@ pub(crate) fn decode_collection(value: Value) -> Result Result { + match value.get("type").and_then(Value::as_str) { + Some("Point") => decode_point(value).map(BRegGeoJsonGeometry::Point), + Some("Polygon") => decode_polygon(value).map(BRegGeoJsonGeometry::Polygon), + Some("MultiPolygon") => decode_multi_polygon(value).map(BRegGeoJsonGeometry::MultiPolygon), + _ => Err(()), + } +} + fn decode_point(value: Value) -> Result { let mut document = object(value)?; exact_string(take(&mut document, "type")?, "Point")?; @@ -378,6 +423,97 @@ fn decode_point(value: Value) -> Result { }) } +fn decode_polygon(value: Value) -> Result { + let mut document = object(value)?; + exact_string(take(&mut document, "type")?, "Polygon")?; + let mut positions = 0; + let coordinates = + decode_polygon_coordinates(take(&mut document, "coordinates")?, &mut positions)?; + empty(document)?; + Ok(BRegGeoJsonPolygon { + polygon_type: BRegGeoJsonPolygonType::Polygon, + coordinates, + }) +} + +fn decode_multi_polygon(value: Value) -> Result { + let mut document = object(value)?; + exact_string(take(&mut document, "type")?, "MultiPolygon")?; + let polygons = match take(&mut document, "coordinates")? { + Value::Array(polygons) if !polygons.is_empty() => polygons, + _ => return Err(()), + }; + let mut positions = 0; + let coordinates = polygons + .into_iter() + .map(|polygon| decode_polygon_coordinates(polygon, &mut positions)) + .collect::, _>>()?; + empty(document)?; + Ok(BRegGeoJsonMultiPolygon { + multi_polygon_type: BRegGeoJsonMultiPolygonType::MultiPolygon, + coordinates, + }) +} + +fn decode_polygon_coordinates( + value: Value, + positions: &mut usize, +) -> Result>, ()> { + let rings = match value { + Value::Array(rings) if !rings.is_empty() => rings, + _ => return Err(()), + }; + rings + .into_iter() + .map(|ring| decode_ring(ring, positions)) + .collect() +} + +fn decode_ring(value: Value, positions: &mut usize) -> Result, ()> { + let values = match value { + Value::Array(values) if values.len() >= 4 => values, + _ => return Err(()), + }; + *positions = positions.checked_add(values.len()).ok_or(())?; + if *positions > MAXIMUM_GEOMETRY_POSITIONS { + return Err(()); + } + let ring = values + .into_iter() + .map(decode_position) + .collect::, _>>()?; + if !same_position(&ring[0], ring.last().ok_or(())?) + || ring + .windows(2) + .any(|edge| longitude_delta(&edge[0], &edge[1]) > 180.0) + { + return Err(()); + } + Ok(ring) +} + +fn decode_position(value: Value) -> Result<[Number; 2], ()> { + match value { + Value::Array(mut values) if values.len() == 2 => { + let latitude = number(values.pop().ok_or(())?, -90.0, 90.0)?; + let longitude = number(values.pop().ok_or(())?, -180.0, 180.0)?; + Ok([longitude, latitude]) + } + _ => Err(()), + } +} + +fn same_position(left: &[Number; 2], right: &[Number; 2]) -> bool { + left.iter() + .zip(right) + .all(|(left, right)| left.as_f64() == right.as_f64()) +} + +fn longitude_delta(left: &[Number; 2], right: &[Number; 2]) -> f64 { + (left[0].as_f64().unwrap_or(f64::INFINITY) - right[0].as_f64().unwrap_or(f64::NEG_INFINITY)) + .abs() +} + fn object(value: Value) -> Result, ()> { value.as_object().cloned().ok_or(()) } @@ -533,4 +669,63 @@ mod tests { assert!(decode_feature(feature(json!(0), json!(0), json!(0))).is_err()); assert!(decode_feature(feature(json!(0), json!(0), json!(u64::MAX))).is_err()); } + + #[test] + fn polygon_and_multipolygon_preserve_tagged_shapes_and_winding() { + let polygon = json!({ + "type": "Feature", + "id": "00000000-0000-4000-8000-000000000001", + "geometry": { + "type": "Polygon", + "coordinates": [[ + [100.125, 13.25], [101, 13.25], [101, 14], [100.125, 13.25] + ]] + }, + "properties": {}, + "registry": {"revision": 1} + }); + let decoded = decode_feature(polygon.clone()).expect("valid polygon"); + assert_eq!(serde_json::to_value(decoded).unwrap(), polygon); + + let multipolygon = json!({ + "type": "Feature", + "id": "00000000-0000-4000-8000-000000000001", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [[[100, 13], [100, 14], [101, 14], [100, 13]]], + [[[102, 15], [103, 15], [103, 16], [102, 15]]] + ] + }, + "properties": {}, + "registry": {"revision": 1} + }); + let decoded = decode_feature(multipolygon.clone()).expect("valid multipolygon"); + assert_eq!(serde_json::to_value(decoded).unwrap(), multipolygon); + } + + #[test] + fn polygon_geometry_rejects_invalid_rings_parts_and_edges() { + let feature = |geometry: Value| { + json!({ + "type": "Feature", + "id": "00000000-0000-4000-8000-000000000001", + "geometry": geometry, + "properties": {}, + "registry": {"revision": 1} + }) + }; + for invalid in [ + json!({"type":"Polygon","coordinates":[]}), + json!({"type":"Polygon","coordinates":[[]]}), + json!({"type":"Polygon","coordinates":[[[0,0],[1,0],[0,0]]]}), + json!({"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1]]]}), + json!({"type":"Polygon","coordinates":[[[-170,0],[170,0],[170,1],[-170,0]]]}), + json!({"type":"MultiPolygon","coordinates":[]}), + json!({"type":"MultiPolygon","coordinates":[[]]}), + json!({"type":"MultiPolygon","coordinates":[[[[0,0,1],[1,0],[1,1],[0,0]]]]}), + ] { + assert!(decode_feature(feature(invalid)).is_err()); + } + } } diff --git a/crates/registry-breg-client/src/metadata.rs b/crates/registry-breg-client/src/metadata.rs index f5d913988a..0d29894549 100644 --- a/crates/registry-breg-client/src/metadata.rs +++ b/crates/registry-breg-client/src/metadata.rs @@ -1820,6 +1820,19 @@ fn action_field_type(value: Value) -> Result<(), BRegMetadataError> { validate_action_bbox(bbox, precision)?; } } + "crs84-polygon" => { + let precision = required(&mut field_type, "precision")? + .as_u64() + .filter(|precision| *precision <= 9) + .ok_or_else(|| metadata_error(BRegMetadataErrorKind::Shape))?; + required(&mut field_type, "maximumPositions")? + .as_u64() + .filter(|maximum| (4..=10_000).contains(maximum)) + .ok_or_else(|| metadata_error(BRegMetadataErrorKind::Shape))?; + if let Some(bbox) = field_type.remove("bbox") { + validate_action_bbox(bbox, precision)?; + } + } "structured" => { let max_bytes = positive_integer(required(&mut field_type, "maximumBytes")?)?; if max_bytes > 1_048_576 diff --git a/crates/registry-breg-client/src/metadata_tests.rs b/crates/registry-breg-client/src/metadata_tests.rs index f47dc0604c..b58eeafae1 100644 --- a/crates/registry-breg-client/src/metadata_tests.rs +++ b/crates/registry-breg-client/src/metadata_tests.rs @@ -1455,6 +1455,47 @@ fn exact_tombstone_batch_and_immediate_action_contracts_promote() { .select_action("register-company", "company-writer") .expect("a bounded closed Draft 2020-12 structured schema promotes"); + let mut polygon = value.clone(); + polygon["actions"][0]["inputs"][0]["fieldType"] = json!({ + "type": "crs84-polygon", "precision": 9, "maximumPositions": 10_000, + "bbox": {"west":"-180","south":"-90","east":"180","north":"90"} + }); + let action = parse(&polygon) + .select_action("register-company", "company-writer") + .expect("bounded polygon type promotes"); + assert_eq!( + action.inputs()[0].field_type(), + &polygon["actions"][0]["inputs"][0]["fieldType"] + ); + for invalid_maximum in [json!(3), json!(10_001), json!(4.0), json!("4")] { + let mut malformed = polygon.clone(); + malformed["actions"][0]["inputs"][0]["fieldType"]["maximumPositions"] = invalid_maximum; + assert_eq!( + parse(&malformed) + .select_action("register-company", "company-writer") + .unwrap_err() + .kind(), + BRegMetadataSelectionErrorKind::ContractMismatch + ); + } + for malformed_field_type in [ + json!({"type":"crs84-polygon", "precision":10, "maximumPositions":4}), + json!({ + "type":"crs84-polygon", "precision":6, "maximumPositions":4, + "bbox":{"west":"100","south":"13","east":"99","north":"14"} + }), + ] { + let mut malformed = polygon.clone(); + malformed["actions"][0]["inputs"][0]["fieldType"] = malformed_field_type; + assert_eq!( + parse(&malformed) + .select_action("register-company", "company-writer") + .unwrap_err() + .kind(), + BRegMetadataSelectionErrorKind::ContractMismatch + ); + } + let oversized_properties = (0..3_000) .map(|index| (format!("field{index}"), json!({"type": "string"}))) .collect::>(); diff --git a/crates/registry-breg/src/access.rs b/crates/registry-breg/src/access.rs index 626303d44b..7935c4a57b 100644 --- a/crates/registry-breg/src/access.rs +++ b/crates/registry-breg/src/access.rs @@ -54,6 +54,7 @@ pub(crate) fn validate_access_requirements( field.field_type, FieldTypeSource::Structured { .. } | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } ) }); if !valid_field diff --git a/crates/registry-breg/src/api/gis.rs b/crates/registry-breg/src/api/gis.rs index 69024f3a99..bb2ee95d07 100644 --- a/crates/registry-breg/src/api/gis.rs +++ b/crates/registry-breg/src/api/gis.rs @@ -2,7 +2,7 @@ //! Bounded QGIS-facing OAPIF-shaped adapter. //! -//! This module is only a transport adapter over compiled direct list operations. +//! This module is only a transport adapter over compiled direct spatial list operations. //! It does not declare OGC API Features conformance, compute extents, add a //! query engine, or reinterpret Registry access profiles. @@ -67,9 +67,16 @@ async fn landing( return unavailable(); }; let collections = visible_gis_collections(&service, &claims); + let polygon_visible = collections + .iter() + .any(|collection| collection.geometry_kind == GisGeometryKind::Polygon); let body = json!({ "title": service.registry.registry_id(), - "description": "Bounded Registry GIS adapter for authorized CRS84 Point bbox reads.", + "description": if polygon_visible { + "Bounded Registry GIS adapter for authorized CRS84 geometry bbox reads." + } else { + "Bounded Registry GIS adapter for authorized CRS84 Point bbox reads." + }, "links": [ link(origin, "self", "application/json", GIS_ROOT), link(origin, "data", "application/json", &format!("{GIS_ROOT}/collections")), @@ -77,7 +84,11 @@ async fn landing( link(origin, "service-desc", SERVICE_DESC_MEDIA_TYPE, &format!("{GIS_ROOT}/api")), ], "registry": { - "profile": "breg-gis-bounded-crs84-point-bbox-v1", + "profile": if polygon_visible { + "breg-gis-bounded-crs84-geometry-bbox-v1" + } else { + "breg-gis-bounded-crs84-point-bbox-v1" + }, "collections": collections.len(), }, }); @@ -168,6 +179,7 @@ async fn api( }), ); } + let geometry_schema = gis_geometry_schema(&collections); let body = json!({ "openapi": "3.0.0", "info": { @@ -196,20 +208,7 @@ async fn api( "properties": { "type": {"type": "string", "enum": ["Feature"]}, "id": {"type": "string"}, - "geometry": { - "type": "object", - "nullable": true, - "required": ["type", "coordinates"], - "properties": { - "type": {"type": "string", "enum": ["Point"]}, - "coordinates": { - "type": "array", - "items": {"type": "number"}, - "minItems": 2, - "maxItems": 2, - }, - }, - }, + "geometry": geometry_schema, "properties": {"type": "object"}, "registry": {"type": "object"}, }, @@ -441,10 +440,18 @@ struct GisCollection { entity_id: String, profile_id: String, geometry_field: String, + geometry_kind: GisGeometryKind, + geometry_maximum_positions: Option, max_page_size: u16, fields: Vec, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GisGeometryKind { + Point, + Polygon, +} + #[derive(Clone, Debug, Eq, PartialEq)] struct GisField { name: String, @@ -466,7 +473,10 @@ impl GisCollection { "accessProfile": self.profile_id, "geometryField": self.geometry_field, "pageLimitMaximum": self.max_page_size, - "profile": "bounded-crs84-point-bbox", + "profile": match self.geometry_kind { + GisGeometryKind::Point => "bounded-crs84-point-bbox", + GisGeometryKind::Polygon => "bounded-crs84-polygon-bbox", + }, "fields": self.fields.iter().map(|field| { json!({ "name": field.name, @@ -557,6 +567,14 @@ fn gis_collection_value( ) -> Option { let collection_id = operation.gis_collection_id()?; let bbox = operation.spatial.as_ref()?.bbox.as_ref()?; + let (geometry_kind, geometry_maximum_positions) = + match data_field_type(entity, &bbox.geometry_field)? { + FieldTypeSource::Crs84Point { .. } => (GisGeometryKind::Point, None), + FieldTypeSource::Crs84Polygon { max_positions, .. } => { + (GisGeometryKind::Polygon, Some(*max_positions)) + } + _ => return None, + }; let mut fields = Vec::new(); for field_id in &operation.projection_fields { if field_id == &bbox.geometry_field || !surface.readable_fields.contains(field_id) { @@ -574,6 +592,8 @@ fn gis_collection_value( entity_id: operation.entity_id.clone(), profile_id: operation.profile_id.clone(), geometry_field: bbox.geometry_field.clone(), + geometry_kind, + geometry_maximum_positions, max_page_size: operation.max_page_size, fields, }) @@ -584,13 +604,93 @@ fn geojson_geometry_available(entity: &CompiledEntity, readable_fields: &BTreeSe return false; }; readable_fields.contains(&geojson.geometry_field) - && matches!( - entity - .fields - .get(&geojson.geometry_field) - .map(|field| &field.field_type), - Some(FieldTypeSource::Crs84Point { .. }) - ) + && entity + .fields + .get(&geojson.geometry_field) + .map(|field| &field.field_type) + .is_some_and(FieldTypeSource::is_crs84_geometry) +} + +fn gis_geometry_schema(collections: &[GisCollection]) -> Value { + let point_visible = collections + .iter() + .any(|collection| collection.geometry_kind == GisGeometryKind::Point); + let polygon_visible = collections + .iter() + .any(|collection| collection.geometry_kind == GisGeometryKind::Polygon); + if !polygon_visible { + return point_geometry_schema(); + } + let minimum_ring_positions = crate::contract::MIN_CRS84_POLYGON_POSITIONS; + let maximum_positions = collections + .iter() + .filter_map(|collection| collection.geometry_maximum_positions) + .max() + .unwrap_or(crate::contract::MAX_CRS84_POLYGON_POSITIONS); + let maximum_rings = maximum_positions / minimum_ring_positions; + let position = json!({ + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }); + let ring = json!({ + "type": "array", + "items": position, + "minItems": minimum_ring_positions, + "maxItems": maximum_positions, + }); + let polygon_coordinates = json!({ + "type": "array", + "items": ring, + "minItems": 1, + "maxItems": maximum_rings, + }); + let polygon = tagged_geometry_schema("Polygon", polygon_coordinates.clone()); + let multi_polygon = tagged_geometry_schema( + "MultiPolygon", + json!({ + "type": "array", + "items": polygon_coordinates, + "minItems": 1, + "maxItems": maximum_rings, + }), + ); + let mut variants = Vec::with_capacity(if point_visible { 3 } else { 2 }); + if point_visible { + variants.push(point_geometry_schema_without_null()); + } + variants.extend([polygon, multi_polygon]); + json!({"type": "object", "nullable": true, "oneOf": variants}) +} + +fn point_geometry_schema() -> Value { + let mut schema = point_geometry_schema_without_null(); + schema["nullable"] = Value::Bool(true); + schema +} + +fn point_geometry_schema_without_null() -> Value { + tagged_geometry_schema( + "Point", + json!({ + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }), + ) +} + +fn tagged_geometry_schema(kind: &str, coordinates: Value) -> Value { + json!({ + "type": "object", + "required": ["type", "coordinates"], + "properties": { + "type": {"type": "string", "enum": [kind]}, + "coordinates": coordinates, + }, + }) } fn api_name_for_field<'a>(entity: &'a CompiledEntity, field_id: &str) -> Option<&'a str> { @@ -784,6 +884,7 @@ fn field_type_name(field_type: &FieldTypeSource) -> &'static str { FieldTypeSource::VocabularyCode { .. } => "string", FieldTypeSource::Reference { .. } => "string", FieldTypeSource::Crs84Point { .. } => "geometry", + FieldTypeSource::Crs84Polygon { .. } => "geometry", FieldTypeSource::Structured { schema, .. } => { if schema.get("type").and_then(Value::as_str) == Some("array") { "array" @@ -880,6 +981,8 @@ mod tests { entity_id: "service-site".to_owned(), profile_id: "map-reader".to_owned(), geometry_field: "location".to_owned(), + geometry_kind: GisGeometryKind::Point, + geometry_maximum_positions: None, max_page_size: 100, fields: vec![], }; @@ -896,6 +999,7 @@ mod tests { assert!(links.iter().all(|link| link["href"].as_str().is_some_and( |href| href.starts_with("https://registry.example.test/registry-a/v1/gis") ))); + assert_eq!(value["registry"]["profile"], "bounded-crs84-point-bbox"); } #[tokio::test] @@ -919,6 +1023,18 @@ mod tests { #[tokio::test] async fn gis_service_description_uses_openapi_3_nullable_point_schema() { + let landing = request_with_malicious_origin_headers( + test_app(Some("https://registry.example.test")), + "/v1/gis", + Some(map_reader_claims()), + ) + .await; + let landing: Value = + serde_json::from_str(&response_body_string(landing).await).expect("landing JSON"); + assert_eq!( + landing["registry"]["profile"], + "breg-gis-bounded-crs84-point-bbox-v1" + ); let response = request_with_malicious_origin_headers( test_app(Some("https://registry.example.test")), "/v1/gis/api", @@ -943,10 +1059,99 @@ mod tests { let geometry = &body["components"]["schemas"]["Feature"]["properties"]["geometry"]; assert_eq!(geometry["type"], "object"); assert_eq!(geometry["nullable"], true); + assert_eq!(geometry["properties"]["type"]["enum"], json!(["Point"])); assert!(geometry.get("oneOf").is_none()); + assert!(!body.to_string().contains("Polygon")); assert!(!body.to_string().contains("\"type\":\"null\"")); } + #[tokio::test] + async fn polygon_visibility_changes_landing_collection_and_schema_profiles() { + let app = test_app(Some("https://registry.example.test")); + let landing = request_with_malicious_origin_headers( + app.clone(), + "/v1/gis", + Some(area_reader_claims()), + ) + .await; + assert_eq!(landing.status(), StatusCode::OK); + let landing: Value = + serde_json::from_str(&response_body_string(landing).await).expect("landing JSON"); + assert_eq!( + landing["registry"]["profile"], + "breg-gis-bounded-crs84-geometry-bbox-v1" + ); + assert_eq!(landing["registry"]["collections"], 1); + + let collection = request_with_malicious_origin_headers( + app.clone(), + "/v1/gis/collections/service-area.area-map-reader", + Some(area_reader_claims()), + ) + .await; + assert_eq!(collection.status(), StatusCode::OK); + let collection: Value = + serde_json::from_str(&response_body_string(collection).await).expect("collection JSON"); + assert_eq!( + collection["registry"]["profile"], + "bounded-crs84-polygon-bbox" + ); + + let api = + request_with_malicious_origin_headers(app, "/v1/gis/api", Some(area_reader_claims())) + .await; + assert_eq!(api.status(), StatusCode::OK); + let api: Value = serde_json::from_str(&response_body_string(api).await).expect("API JSON"); + assert!(api["paths"] + .get("/v1/gis/collections/service-area.area-map-reader/items") + .is_some()); + assert!(api["paths"] + .get("/v1/gis/collections/service-site.map-reader/items") + .is_none()); + let geometry = &api["components"]["schemas"]["Feature"]["properties"]["geometry"]; + assert_eq!(geometry["type"], "object"); + assert_eq!(geometry["nullable"], true); + let variants = geometry["oneOf"].as_array().expect("polygon variants"); + assert_eq!( + geometry_kinds(variants), + BTreeSet::from(["MultiPolygon", "Polygon"]) + ); + let polygon = variants + .iter() + .find(|variant| variant["properties"]["type"]["enum"][0] == "Polygon") + .expect("Polygon schema"); + assert_eq!( + polygon["properties"]["coordinates"]["items"]["items"]["minItems"], + 2 + ); + assert_eq!(polygon["properties"]["coordinates"]["items"]["minItems"], 4); + assert_eq!( + polygon["properties"]["coordinates"]["items"]["maxItems"], + 1000 + ); + assert_eq!(polygon["properties"]["coordinates"]["maxItems"], 250); + } + + #[tokio::test] + async fn mixed_visible_collections_describe_all_served_geometry_tags() { + let response = request_with_malicious_origin_headers( + test_app(Some("https://registry.example.test")), + "/v1/gis/api", + Some(all_map_reader_claims()), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body: Value = + serde_json::from_str(&response_body_string(response).await).expect("API JSON"); + let variants = body["components"]["schemas"]["Feature"]["properties"]["geometry"]["oneOf"] + .as_array() + .expect("mixed geometry variants"); + assert_eq!( + geometry_kinds(variants), + BTreeSet::from(["MultiPolygon", "Point", "Polygon"]) + ); + } + #[tokio::test] async fn gis_metadata_without_public_origin_refuses_without_relative_fallback() { let records = Arc::new(RecordingReadService::default()); @@ -1016,6 +1221,38 @@ mod tests { ); } + #[tokio::test] + async fn polygon_gis_items_reach_the_bounded_spatial_read_plan() { + let records = Arc::new(RecordingReadService::default()); + let app = test_app_with_records( + Some("https://registry.example.test"), + Arc::clone(&records) as Arc, + ); + let response = request_with_malicious_origin_headers( + app, + "/v1/gis/collections/service-area.area-map-reader/items?bbox=100,13,101,14&limit=50", + Some(area_reader_claims()), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let requests = records.list_requests.lock().expect("recorded requests"); + assert_eq!(requests.len(), 1); + let read = match &requests[0].kind { + RecordReadKind::List { plan } => plan, + _ => panic!("GIS items must remain a list read"), + }; + assert_eq!( + read.spatial + .as_ref() + .map(|spatial| spatial.bbox.geometry_field.as_str()), + Some("boundary") + ); + assert_eq!( + read.cursor_binding.representation, + CursorRepresentation::GeoJson + ); + } + #[test] fn compiled_gis_collection_id_omits_get_only_and_no_bbox_profiles() { let registry = compile_fixture(); @@ -1048,6 +1285,8 @@ mod tests { entity_id: "service-site".to_owned(), profile_id: "map-reader".to_owned(), geometry_field: "location".to_owned(), + geometry_kind: GisGeometryKind::Point, + geometry_maximum_positions: None, max_page_size: operation.max_page_size, fields: vec![], }, @@ -1155,6 +1394,32 @@ mod tests { .expect("claims") } + fn area_reader_claims() -> VerifiedRequestClaims { + map_claims(["registry:areas:read"]) + } + + fn all_map_reader_claims() -> VerifiedRequestClaims { + map_claims(["registry:areas:read", "registry:sites:read"]) + } + + fn map_claims(scopes: impl IntoIterator) -> VerifiedRequestClaims { + VerifiedRequestClaims::authenticated( + "principal", + "qgis-client", + scopes.into_iter().map(str::to_owned).collect(), + None, + BTreeMap::new(), + ) + .expect("claims") + } + + fn geometry_kinds(variants: &[Value]) -> BTreeSet<&str> { + variants + .iter() + .filter_map(|variant| variant["properties"]["type"]["enum"][0].as_str()) + .collect() + } + async fn request_with_malicious_origin_headers( app: Router, uri: &str, @@ -1233,6 +1498,14 @@ recordTypes: - {id: code, type: string, maximumLength: 32, classification: public, required: true} - {id: label, type: string, maximumLength: 80, classification: public} - {id: location, type: crs84-point, precision: 6, classification: public} + - id: service-area + primaryDataset: test-dataset + route: service-areas + mutationMode: create-only + geojson: {geometryField: boundary} + fields: + - {id: code, type: string, maximumLength: 32, classification: public, required: true} + - {id: boundary, type: crs84-polygon, precision: 6, maximumPositions: 1000, classification: public} accessProfiles: - id: map-reader default: true @@ -1266,6 +1539,19 @@ accessProfiles: rowBoundaries: unrestricted operations: [get, list] readableFields: [code, label, location] + - id: area-map-reader + principalClaim: principal + requiredScopes: [registry:areas:read] + permissions: + records: + - recordType: service-area + rowBoundaries: unrestricted + operations: [get, list] + readableFields: [code, boundary] + spatialQueries: + bbox: + maximumLongitudeSpanDegrees: 2 + maximumLatitudeSpanDegrees: 2 "#; let project = parse_project_yaml(source.as_bytes()).expect("fixture parses"); compile_project(&project, &[], CompileProfile::Authoring) diff --git a/crates/registry-breg/src/api/mod.rs b/crates/registry-breg/src/api/mod.rs index da15314208..ffbfed3803 100644 --- a/crates/registry-breg/src/api/mod.rs +++ b/crates/registry-breg/src/api/mod.rs @@ -3416,10 +3416,9 @@ fn first_page_spatial_query( if geojson.geometry_field != capability.geometry_field { return Err(ReadQueryError::Invalid); } - if !matches!( - data_field_type(surface.response_entity, &capability.geometry_field), - Some(FieldTypeSource::Crs84Point { .. }) - ) { + if !data_field_type(surface.response_entity, &capability.geometry_field) + .is_some_and(FieldTypeSource::is_crs84_geometry) + { return Err(ReadQueryError::Invalid); } let maximum_longitude_span = @@ -3751,10 +3750,8 @@ fn validate_spatial_shape( if spatial.bbox.geometry_field != capability.geometry_field || spatial.bbox.maximum_longitude_span_degrees != maximum_longitude_span || spatial.bbox.maximum_latitude_span_degrees != maximum_latitude_span - || !matches!( - data_field_type(entity, &spatial.bbox.geometry_field), - Some(FieldTypeSource::Crs84Point { .. }) - ) + || !data_field_type(entity, &spatial.bbox.geometry_field) + .is_some_and(FieldTypeSource::is_crs84_geometry) { return Err(ReadQueryError::Invalid); } @@ -4399,7 +4396,9 @@ fn lookup_json_scalar( .as_str() .map(str::to_owned) .ok_or(LookupResolutionError::InvalidRequest)?, - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => { return Err(LookupResolutionError::InvalidRequest); } }; @@ -4967,10 +4966,8 @@ fn geojson_available(surface: &AuthorizedSurface<'_>) -> bool { return false; }; surface.readable_fields.contains(&geojson.geometry_field) - && matches!( - data_field_type(surface.response_entity, &geojson.geometry_field), - Some(FieldTypeSource::Crs84Point { .. }) - ) + && data_field_type(surface.response_entity, &geojson.geometry_field) + .is_some_and(FieldTypeSource::is_crs84_geometry) } fn exact_mutation( diff --git a/crates/registry-breg/src/artifacts.rs b/crates/registry-breg/src/artifacts.rs index 2e8f668f1c..ecdb2b2b55 100644 --- a/crates/registry-breg/src/artifacts.rs +++ b/crates/registry-breg/src/artifacts.rs @@ -1993,6 +1993,72 @@ pub(crate) fn field_schema(field_type: &FieldTypeSource) -> Value { } schema } + FieldTypeSource::Crs84Polygon { + precision, + bbox, + max_positions, + } => { + let position = json!({ + "type": "array", + "prefixItems": [ + {"type": "number", "minimum": -180, "maximum": 180}, + {"type": "number", "minimum": -90, "maximum": 90} + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }); + let ring = json!({ + "type": "array", + "items": position, + "minItems": 4, + "maxItems": max_positions + }); + let polygon_coordinates = json!({ + "type": "array", + "items": ring, + "minItems": 1, + "maxItems": max_positions / 4 + }); + let mut schema = json!({ + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": {"const": "Polygon"}, + "coordinates": polygon_coordinates.clone() + }, + "required": ["type", "coordinates"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": {"const": "MultiPolygon"}, + "coordinates": { + "type": "array", + "items": polygon_coordinates, + "minItems": 1, + "maxItems": max_positions / 4 + } + }, + "required": ["type", "coordinates"] + } + ], + "description": "CRS84 GeoJSON Polygon or MultiPolygon with positions in [longitude, latitude] order. Rings are closed and contain at least four positions.", + "x-registry-coordinateReferenceSystem": "CRS84", + "x-registry-coordinatePrecision": precision, + "x-registry-maximumPositions": max_positions + }); + if let Some(bbox) = bbox { + schema + .as_object_mut() + .expect("polygon schema is an object") + .insert("x-registry-bbox".to_owned(), json!(bbox)); + } + schema + } FieldTypeSource::Structured { max_bytes, schema } => { let mut schema = schema.clone(); schema @@ -4923,6 +4989,34 @@ mod spatial_tests { .iter() .any(|p| p["name"] == "bbox")); } + + #[test] + fn polygon_field_schema_describes_polygon_and_multipolygon_nesting() { + let schema = field_schema(&FieldTypeSource::Crs84Polygon { + precision: 6, + bbox: None, + max_positions: 16, + }); + let validator = jsonschema::JSONSchema::options() + .with_draft(jsonschema::Draft::Draft202012) + .compile(&schema) + .expect("polygon schema compiles"); + let polygon = json!({ + "type": "Polygon", + "coordinates": [[[100.0, 10.0], [101.0, 10.0], [101.0, 11.0], [100.0, 10.0]]] + }); + let multi = json!({ + "type": "MultiPolygon", + "coordinates": [[[[100.0, 10.0], [101.0, 10.0], [101.0, 11.0], [100.0, 10.0]]]] + }); + assert!(validator.is_valid(&polygon)); + assert!(validator.is_valid(&multi)); + assert!(!validator.is_valid(&json!({ + "type": "Polygon", + "coordinates": [[100.0, 10.0]] + }))); + assert_eq!(schema["x-registry-maximumPositions"], 16); + } } #[cfg(test)] diff --git a/crates/registry-breg/src/auth.rs b/crates/registry-breg/src/auth.rs index 7e9baff6a5..e568649796 100644 --- a/crates/registry-breg/src/auth.rs +++ b/crates/registry-breg/src/auth.rs @@ -708,6 +708,7 @@ fn validate_claim_mapping( FieldTypeSource::Boolean | FieldTypeSource::Int64 | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } | FieldTypeSource::Structured { .. } ) { @@ -906,7 +907,9 @@ fn mapped_scalar_claim( .as_str() .map(str::to_owned) .ok_or(AuthenticationError::InvalidClaims)?, - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => { return Err(AuthenticationError::InvalidClaims); } }; diff --git a/crates/registry-breg/src/authoring_integrity.rs b/crates/registry-breg/src/authoring_integrity.rs index 61f2dc365c..0a1ee73740 100644 --- a/crates/registry-breg/src/authoring_integrity.rs +++ b/crates/registry-breg/src/authoring_integrity.rs @@ -195,6 +195,7 @@ fn add_value_type_diagnostics( field_type, FieldTypeSource::Reference { .. } | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } | FieldTypeSource::Structured { .. } ) { continue; @@ -208,7 +209,7 @@ fn add_value_type_diagnostics( "breg.value-type.scalar-required", &pointer, "reusable value types must declare scalar constraints", - "Declare the reference, crs84-point, or structured type directly on each record field or Action input.", + "Declare the reference, CRS84 geometry, or structured type directly on each record field or Action input.", )); } } diff --git a/crates/registry-breg/src/compiler.rs b/crates/registry-breg/src/compiler.rs index b667f2286c..102dc2d9f4 100644 --- a/crates/registry-breg/src/compiler.rs +++ b/crates/registry-breg/src/compiler.rs @@ -4477,15 +4477,15 @@ fn validate_geojson(entity: &EntitySource, errors: &mut Vec) { errors.push(Diagnostic::error( "breg.geojson.geometry-field-unknown", "recordTypes[].geojson.geometryField", - "a GeoJSON geometry field must name one stored CRS84 point field", + "a GeoJSON geometry field must name one stored CRS84 geometry field", )); return; }; - if !matches!(field.field_type, FieldTypeSource::Crs84Point { .. }) { + if !field.field_type.is_crs84_geometry() { errors.push(Diagnostic::error( "breg.geojson.geometry-field-type-unsupported", "recordTypes[].geojson.geometryField", - "a GeoJSON geometry field must name one stored CRS84 point field", + "a GeoJSON geometry field must name one stored CRS84 geometry field", )); } } @@ -4692,6 +4692,24 @@ fn validate_entity_fields( "CRS84 point precision or CRS84 bounding box is invalid", )); } + FieldTypeSource::Crs84Polygon { + precision, + bbox, + max_positions, + } if *precision > 9 + || !(crate::contract::MIN_CRS84_POLYGON_POSITIONS + ..=crate::contract::MAX_CRS84_POLYGON_POSITIONS) + .contains(max_positions) + || bbox + .as_ref() + .is_some_and(|bbox| parsed_bbox(bbox, *precision).is_none()) => + { + errors.push(Diagnostic::error( + "breg.field.crs84-polygon-bounds-invalid", + "recordTypes[].fields[]", + "CRS84 polygon precision, CRS84 bounding box, or maximumPositions is invalid", + )); + } FieldTypeSource::Structured { max_bytes, schema } if *max_bytes == 0 || *max_bytes > MAX_STRUCTURED_VALUE_BYTES @@ -4932,7 +4950,9 @@ fn generated_identifier_scope_max_bytes(field_type: &FieldTypeSource) -> Option< FieldTypeSource::Decimal { .. } | FieldTypeSource::Timestamp => Some(64), FieldTypeSource::Date => Some(10), FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => Some(36), - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => None, + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => None, } } @@ -5118,6 +5138,24 @@ fn validate_field_type_bounds( "CRS84 point precision or CRS84 bounding box is invalid", )); } + FieldTypeSource::Crs84Polygon { + precision, + bbox, + max_positions, + } if *precision > 9 + || !(crate::contract::MIN_CRS84_POLYGON_POSITIONS + ..=crate::contract::MAX_CRS84_POLYGON_POSITIONS) + .contains(max_positions) + || bbox + .as_ref() + .is_some_and(|bbox| parsed_bbox(bbox, *precision).is_none()) => + { + errors.push(Diagnostic::error( + "breg.field.crs84-polygon-bounds-invalid", + path, + "CRS84 polygon precision, CRS84 bounding box, or maximumPositions is invalid", + )); + } FieldTypeSource::Structured { max_bytes, schema } if *max_bytes == 0 || *max_bytes > MAX_STRUCTURED_VALUE_BYTES @@ -5403,7 +5441,9 @@ enum FieldStorageKind { fn selector_field_supported(field_type: &FieldTypeSource) -> bool { !matches!( field_type, - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } ) } @@ -5574,9 +5614,9 @@ fn validate_selector_profiles(entity: &EntitySource, errors: &mut Vec = selector @@ -5594,7 +5634,7 @@ fn validate_selector_profiles(entity: &EntitySource, errors: &mut Vec Option { // The closed Point shape and precision grammar fit well below this // conservative bound. FieldTypeSource::Crs84Point { .. } => 128, + FieldTypeSource::Crs84Polygon { max_positions, .. } => { + crate::contract::maximum_polygon_json_bytes(*max_positions) + } FieldTypeSource::Structured { max_bytes, .. } => u64::from(*max_bytes), }; Some(bytes) @@ -8747,11 +8790,11 @@ fn query_filter_field( | FieldTypeSource::Timestamp => { operators.push(CompiledQueryFilterOperator::Range); } - FieldTypeSource::Crs84Point { .. } => { + FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Crs84Polygon { .. } => { errors.push(Diagnostic::error( "breg.query.filter-field-type-unsupported", "recordTypes[].accessProfiles[].filterableFields", - "CRS84 point fields cannot be filterableFields; use spatialQueries.bbox for bounded spatial search", + "CRS84 geometry fields cannot be filterableFields; use spatialQueries.bbox for bounded spatial search", )); return None; } @@ -8818,11 +8861,11 @@ fn query_sort_field( field: &str, errors: &mut Vec, ) -> Option { - if matches!(field_type, FieldTypeSource::Crs84Point { .. }) { + if field_type.is_crs84_geometry() { errors.push(Diagnostic::error( "breg.query.sort-field-type-unsupported", "recordTypes[].accessProfiles[].sortableFields", - "CRS84 point fields cannot be sortableFields; use spatialQueries.bbox for bounded spatial search", + "CRS84 geometry fields cannot be sortableFields; use spatialQueries.bbox for bounded spatial search", )); return None; } @@ -9135,12 +9178,14 @@ fn validate_unique_when_field<'a>( }; if matches!( source.field_type, - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } ) { errors.push(Diagnostic::error( "breg.constraint.unique-when-field-unsupported", "recordTypes[].constraints[].when[].field", - "CRS84 point and structured fields cannot be partial unique predicates", + "CRS84 geometry and structured fields cannot be partial unique predicates", )); return None; } @@ -9193,7 +9238,9 @@ fn canonical_field_literal(value: &Value, field_type: &FieldTypeSource) -> Optio .as_str() .filter(|value| values.iter().any(|allowed| allowed == *value)) .map(str::to_owned), - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => None, + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => None, } } @@ -9234,6 +9281,68 @@ fn canonical_timestamp(value: &str) -> bool { formatted == value } +#[cfg(test)] +mod polygon_compiler_tests { + use super::*; + use crate::contract::parse_project_json; + + fn project(field: &str) -> RegistryProject { + parse_project_json( + format!( + r#"{{ + "apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1", + "kind":"BRegProject", + "project":{{"id":"polygon-compiler","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://polygon.example.test"}}, + "datasets":[{{"id":"test-dataset"}}], + "recordTypes":[{{ + "id":"area","primaryDataset":"test-dataset","route":"areas","mutationMode":"mutable", + "geojson":{{"geometryField":"geometry"}}, + "fields":[{field}] + }}] + }}"# + ) + .as_bytes(), + ) + .expect("polygon compiler fixture parses") + } + + #[test] + fn compiler_accepts_polygon_as_geojson_geometry() { + let project = project( + r#"{"id":"geometry","type":"crs84-polygon","precision":6,"maximumPositions":128,"classification":"internal"}"#, + ); + let compiled = compile_project(&project, &[], CompileProfile::Authoring) + .expect("bounded polygon geometry compiles"); + let field = &compiled.entities()["area"].fields["geometry"].field_type; + assert!(matches!( + field, + FieldTypeSource::Crs84Polygon { + precision: 6, + max_positions: 128, + .. + } + )); + } + + #[test] + fn compiler_refuses_polygon_bounds_outside_the_contract() { + let mut project = project( + r#"{"id":"geometry","type":"crs84-polygon","precision":6,"maximumPositions":128,"classification":"internal"}"#, + ); + project.entities[0].fields[0].field_type = FieldTypeSource::Crs84Polygon { + precision: 6, + bbox: None, + max_positions: 3, + }; + let errors = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("too-small maximumPositions is refused"); + assert!(errors + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == "breg.field.crs84-polygon-bounds-invalid")); + } +} + fn valid_uuid(value: &str) -> bool { value.len() == 36 && value.as_bytes()[8] == b'-' @@ -9335,6 +9444,7 @@ fn manifest_projects_field(field: &CompiledField) -> bool { &field.field_type, FieldTypeSource::Reference { .. } | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } | FieldTypeSource::Structured { .. } ) } @@ -9654,6 +9764,7 @@ fn validate_local_value_types( FieldTypeSource::Reference { .. } | FieldTypeSource::Structured { .. } | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } ) { errors.push(Diagnostic::error( "breg.value-type.scalar-required", diff --git a/crates/registry-breg/src/contract.rs b/crates/registry-breg/src/contract.rs index 18aa372343..5058306071 100644 --- a/crates/registry-breg/src/contract.rs +++ b/crates/registry-breg/src/contract.rs @@ -2671,6 +2671,7 @@ enum FieldSourceSchema { VocabularyCode(VocabularyCodeFieldSourceSchema), Reference(ReferenceFieldSourceSchema), Crs84Point(Crs84PointFieldSourceSchema), + Crs84Polygon(Crs84PolygonFieldSourceSchema), Structured(StructuredFieldSourceSchema), } @@ -2921,6 +2922,31 @@ struct Crs84PointFieldSourceSchema { bbox: Option, } +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct Crs84PolygonFieldSourceSchema { + #[schemars(with = "registry_platform_yaml::LocalId")] + id: String, + #[serde(default)] + api_name: Option, + #[serde(rename = "type")] + field_type: Crs84PolygonFieldKindSchema, + #[serde(default)] + required: bool, + classification: Classification, + #[serde(default)] + valid_time_role: Option, + #[schemars(with = "BoundedU32<0, MAX_CRS84_PRECISION>")] + precision: u8, + #[serde(default)] + bbox: Option, + #[serde(rename = "maximumPositions")] + #[schemars(with = "BoundedU32")] + max_positions: u32, +} + #[cfg(feature = "schema")] #[allow(dead_code)] #[derive(schemars::JsonSchema)] @@ -3047,6 +3073,15 @@ enum Crs84PointFieldKindSchema { Crs84Point, } +#[cfg(feature = "schema")] +#[allow(dead_code)] +#[derive(schemars::JsonSchema)] +#[schemars(inline)] +enum Crs84PolygonFieldKindSchema { + #[serde(rename = "crs84-polygon")] + Crs84Polygon, +} + #[cfg(feature = "schema")] #[allow(dead_code)] #[derive(schemars::JsonSchema)] @@ -3269,6 +3304,33 @@ fn parse_field_type(raw: &RawFieldSource) -> Result { + let precision = raw.precision.ok_or_else(|| { + Invalid::expected( + "a crs84-polygon field with precision", + "Declare precision on the crs84-polygon field.", + ) + .into_error() + })?; + if u32::from(precision) > MAX_CRS84_PRECISION { + return Err(Invalid::expected( + "a crs84-polygon field with precision from 0 to 9", + "Lower precision on the crs84-polygon field to at most 9.", + ) + .into_error()); + } + FieldTypeSource::Crs84Polygon { + precision, + bbox: raw.bbox.clone(), + max_positions: raw.max_positions.ok_or_else(|| { + Invalid::expected( + "a crs84-polygon field with maximumPositions from 4 to 10000", + "Declare maximumPositions from 4 to 10000 on the crs84-polygon field.", + ) + .into_error() + })?, + } + } RawFieldKind::Structured => FieldTypeSource::Structured { max_bytes: raw.max_bytes.ok_or_else(|| { Invalid::expected( @@ -3335,6 +3397,12 @@ struct RawFieldSource { maximum: Option, #[serde(default)] bbox: Option, + #[serde( + default, + rename = "maximumPositions", + deserialize_with = "optional_bounded_u32::<_, MIN_CRS84_POLYGON_POSITIONS, MAX_CRS84_POLYGON_POSITIONS>" + )] + max_positions: Option, #[serde( default, rename = "maximumBytes", @@ -3426,6 +3494,8 @@ enum RawFieldKind { Reference, #[serde(rename = "crs84-point")] Crs84Point, + #[serde(rename = "crs84-polygon")] + Crs84Polygon, Structured, } @@ -3443,6 +3513,7 @@ impl RawFieldKind { "vocabulary-code" => Self::VocabularyCode, "reference" => Self::Reference, "crs84-point" => Self::Crs84Point, + "crs84-polygon" => Self::Crs84Polygon, "structured" => Self::Structured, _ => return None, }) @@ -3459,6 +3530,7 @@ impl RawFieldKind { Self::VocabularyCode => TypeOptionAllowances::VOCABULARY, Self::Reference => TypeOptionAllowances::REFERENCE, Self::Crs84Point => TypeOptionAllowances::CRS84_POINT, + Self::Crs84Polygon => TypeOptionAllowances::CRS84_POLYGON, Self::Structured => TypeOptionAllowances::STRUCTURED, } } @@ -3475,6 +3547,7 @@ struct TypeOptionAllowances { vocabulary: bool, target: bool, bbox: bool, + max_positions: bool, delete: bool, } @@ -3489,6 +3562,7 @@ impl TypeOptionAllowances { vocabulary: false, target: false, bbox: false, + max_positions: false, delete: false, }; const STRING: Self = Self { @@ -3520,6 +3594,12 @@ impl TypeOptionAllowances { bbox: true, ..Self::NONE }; + const CRS84_POLYGON: Self = Self { + precision: true, + bbox: true, + max_positions: true, + ..Self::NONE + }; const STRUCTURED: Self = Self { structured: true, ..Self::NONE @@ -3536,6 +3616,7 @@ impl TypeOptionAllowances { "vocabulary" | "values" => self.vocabulary, "target" => self.target, "bbox" => self.bbox, + "maximumPositions" => self.max_positions, "onDelete" => self.delete, _ => false, } @@ -3553,6 +3634,7 @@ fn reject_type_options( || (!allowed.decimal_bounds && (raw.minimum.is_some() || raw.maximum.is_some())) || (!allowed.structured && (raw.max_bytes.is_some() || raw.schema.is_some())) || (!allowed.bbox && raw.bbox.is_some()) + || (!allowed.max_positions && raw.max_positions.is_some()) || (!allowed.vocabulary && (raw.vocabulary.is_some() || !raw.values.is_empty())) || (!allowed.target && raw.target.is_some()) || (!allowed.delete && raw.on_delete.is_some()) @@ -3562,7 +3644,8 @@ fn reject_type_options( "Remove the options the type does not take: string takes minimumLength and \ maximumLength, text maximumLength, decimal precision, scale, minimum, and maximum, \ vocabulary-code vocabulary and values, reference target and onDelete, crs84-point \ - precision and bbox, and structured maximumBytes and schema.", + precision and bbox, crs84-polygon precision, bbox, and maximumPositions, and \ + structured maximumBytes and schema.", ) .into_error()); } @@ -3642,6 +3725,24 @@ pub enum FieldTypeSource { #[serde(skip_serializing_if = "Option::is_none")] bbox: Option, }, + #[serde(rename = "crs84-polygon")] + Crs84Polygon { + #[cfg_attr( + feature = "schema", + schemars(with = "BoundedU32<0, MAX_CRS84_PRECISION>") + )] + precision: u8, + #[serde(skip_serializing_if = "Option::is_none")] + bbox: Option, + #[serde(rename = "maximumPositions")] + #[cfg_attr( + feature = "schema", + schemars( + with = "BoundedU32" + ) + )] + max_positions: u32, + }, Structured { #[serde(rename = "maximumBytes")] #[cfg_attr( @@ -3694,6 +3795,12 @@ struct RawValueTypeSource { maximum: Option, #[serde(default)] bbox: Option, + #[serde( + default, + rename = "maximumPositions", + deserialize_with = "optional_bounded_u32::<_, MIN_CRS84_POLYGON_POSITIONS, MAX_CRS84_POLYGON_POSITIONS>" + )] + max_positions: Option, #[serde( default, rename = "maximumBytes", @@ -3729,6 +3836,7 @@ impl<'de> Deserialize<'de> for FieldTypeSource { minimum: raw.minimum, maximum: raw.maximum, bbox: raw.bbox, + max_positions: raw.max_positions, max_bytes: raw.max_bytes, schema: raw.schema, vocabulary: raw.vocabulary, @@ -3746,6 +3854,11 @@ impl<'de> Deserialize<'de> for FieldTypeSource { } impl FieldTypeSource { + /// Whether the value is one of the closed CRS84 GeoJSON geometry types. + pub fn is_crs84_geometry(&self) -> bool { + matches!(self, Self::Crs84Point { .. } | Self::Crs84Polygon { .. }) + } + /// Whether every value `previous` admitted is still a valid value of this /// type with no change to its stored column type: the vocabulary gained /// codes, a `text` length limit rose, or a `string` minimum length fell. @@ -3835,6 +3948,16 @@ pub const MAX_TEXT_FIELD_LENGTH: u32 = 10_000_000; pub const MAX_DECIMAL_PRECISION: u32 = 38; /// The largest CRS84 point `precision`, in decimal places. pub const MAX_CRS84_PRECISION: u32 = 9; +/// Every GeoJSON linear ring contains at least four positions, including closure. +pub const MIN_CRS84_POLYGON_POSITIONS: u32 = 4; +/// The largest total position count accepted by one polygon field value. +pub const MAX_CRS84_POLYGON_POSITIONS: u32 = 10_000; + +/// Allow room for both coordinate tokens, separators, ring and part brackets, +/// and PostgreSQL's JSONB whitespace at the largest supported precision. +pub(crate) const fn maximum_polygon_json_bytes(max_positions: u32) -> u64 { + 40 * max_positions as u64 + 64 +} pub(crate) fn decimal_scaled_value(value: &str, precision: u8, scale: u8) -> Option { if !(1..=38).contains(&precision) || scale > precision { @@ -4007,6 +4130,136 @@ pub(crate) fn valid_crs84_point( }) } +/// Validate the closed CRS84 Polygon/MultiPolygon wire contract without +/// normalizing or otherwise rewriting the supplied JSON value. +pub fn valid_crs84_polygon( + value: &Value, + precision: u8, + bbox: Option<&Crs84BboxSource>, + max_positions: u32, +) -> bool { + if precision > MAX_CRS84_PRECISION as u8 + || !(MIN_CRS84_POLYGON_POSITIONS..=MAX_CRS84_POLYGON_POSITIONS).contains(&max_positions) + { + return false; + } + let Some(object) = value.as_object() else { + return false; + }; + if object.len() != 2 { + return false; + } + let Some(kind) = object.get("type").and_then(Value::as_str) else { + return false; + }; + let Some(coordinates) = object.get("coordinates").and_then(Value::as_array) else { + return false; + }; + if coordinates.is_empty() { + return false; + } + let mut position_count = 0_u32; + match kind { + "Polygon" => valid_crs84_polygon_coordinates( + coordinates, + precision, + bbox, + max_positions, + &mut position_count, + ), + "MultiPolygon" => coordinates.iter().all(|polygon| { + polygon.as_array().is_some_and(|rings| { + valid_crs84_polygon_coordinates( + rings, + precision, + bbox, + max_positions, + &mut position_count, + ) + }) + }), + _ => false, + } +} + +fn valid_crs84_polygon_coordinates( + rings: &[Value], + precision: u8, + bbox: Option<&Crs84BboxSource>, + max_positions: u32, + position_count: &mut u32, +) -> bool { + if rings.is_empty() { + return false; + } + rings.iter().all(|ring| { + ring.as_array().is_some_and(|positions| { + valid_crs84_linear_ring(positions, precision, bbox, max_positions, position_count) + }) + }) +} + +fn valid_crs84_linear_ring( + positions: &[Value], + precision: u8, + bbox: Option<&Crs84BboxSource>, + max_positions: u32, + position_count: &mut u32, +) -> bool { + let Ok(ring_positions) = u32::try_from(positions.len()) else { + return false; + }; + let Some(total_positions) = position_count.checked_add(ring_positions) else { + return false; + }; + if ring_positions < MIN_CRS84_POLYGON_POSITIONS || total_positions > max_positions { + return false; + } + *position_count = total_positions; + let bounds = bbox.and_then(|bbox| parsed_bbox(bbox, precision)); + let mut first = None; + let mut previous_longitude = None; + let mut last = None; + for position in positions { + let Some(position) = position.as_array() else { + return false; + }; + if position.len() != 2 { + return false; + } + let Some(longitude) = polygon_coordinate_number(&position[0], precision, -180.0, 180.0) + else { + return false; + }; + let Some(latitude) = polygon_coordinate_number(&position[1], precision, -90.0, 90.0) else { + return false; + }; + if bounds.is_some_and(|(west, south, east, north)| { + longitude < west || longitude > east || latitude < south || latitude > north + }) { + return false; + } + if previous_longitude.is_some_and(|previous: f64| (longitude - previous).abs() > 180.0) { + return false; + } + let current = (longitude, latitude); + first.get_or_insert(current); + previous_longitude = Some(longitude); + last = Some(current); + } + first == last +} + +fn polygon_coordinate_number( + value: &Value, + precision: u8, + minimum: f64, + maximum: f64, +) -> Option { + let value = value.as_f64()?; + parse_coordinate(&value.to_string(), precision, minimum, maximum) +} + pub(crate) fn valid_structured_schema(schema: &Value) -> bool { schema.as_object().is_some_and(|object| { (schema_declares_object(object) @@ -5898,7 +6151,7 @@ impl schemars::JsonSchema for ReusableValueTypeSchema { branch .pointer("/properties/type/const") .and_then(Value::as_str), - Some("reference" | "crs84-point" | "structured") + Some("reference" | "crs84-point" | "crs84-polygon" | "structured") ) }); value_type @@ -6022,4 +6275,108 @@ mod tests { json!({"type": "structured", "maximumBytes": 512, "schema": schema}) ); } + + #[test] + fn crs84_polygon_authoring_requires_bounded_maximum_positions() { + let polygon: FieldTypeSource = serde_json::from_value(json!({ + "type": "crs84-polygon", + "precision": 6, + "maximumPositions": 32, + "bbox": {"west": "100.000000", "south": "10.000000", "east": "110.000000", "north": "20.000000"} + })) + .expect("bounded polygon type parses"); + assert_eq!( + polygon, + FieldTypeSource::Crs84Polygon { + precision: 6, + bbox: Some(Crs84BboxSource { + west: "100.000000".to_owned(), + south: "10.000000".to_owned(), + east: "110.000000".to_owned(), + north: "20.000000".to_owned(), + }), + max_positions: 32, + } + ); + for invalid in [ + json!({"type": "crs84-polygon", "precision": 6}), + json!({"type": "crs84-polygon", "precision": 6, "maximumPositions": 3}), + json!({"type": "crs84-polygon", "precision": 6, "maximumPositions": 10001}), + ] { + assert!( + serde_json::from_value::(invalid).is_err(), + "an absent or out-of-range maximumPositions must be refused" + ); + } + } + + #[test] + fn crs84_polygon_validator_accepts_both_kinds_without_rewriting_json() { + let polygon = json!({ + "type": "Polygon", + "coordinates": [[ + [100.000000, 10.000000], + [101.000000, 10.000000], + [101.000000, 11.000000], + [100.000000, 10.000000] + ]] + }); + let multi = json!({ + "type": "MultiPolygon", + "coordinates": [ + [[ + [-1.000000, 50.000000], + [0.000000, 50.000000], + [0.000000, 51.000000], + [-1.000000, 50.000000] + ]], + [[ + [1.000000, 50.000000], + [2.000000, 50.000000], + [2.000000, 51.000000], + [1.000000, 50.000000] + ]] + ] + }); + let original = polygon.clone(); + assert!(valid_crs84_polygon(&polygon, 6, None, 4)); + assert_eq!(polygon, original, "validation preserves authored JSON"); + assert!(valid_crs84_polygon(&multi, 6, None, 8)); + } + + #[test] + fn crs84_polygon_validator_accepts_nine_place_near_zero_wire_coordinates() { + let polygon: Value = serde_json::from_str( + r#"{"type":"Polygon","coordinates":[[[0.000000001,0.000000001],[0.000000002,0.000000001],[0.000000001,0.000000002],[0.000000001,0.000000001]]]}"#, + ) + .expect("wire polygon parses"); + assert!(valid_crs84_polygon(&polygon, 9, None, 4)); + } + + #[test] + fn crs84_polygon_validator_refuses_structural_and_coordinate_violations() { + let invalid = [ + json!({"type":"Point","coordinates":[100.000000, 10.000000]}), + json!({"type":"Polygon","coordinates":[]}), + json!({"type":"Polygon","coordinates":[[]]}), + json!({"type":"Polygon","coordinates":[[[100.000000,10.000000],[101.000000,10.000000],[100.000000,10.000000]]]}), + json!({"type":"Polygon","coordinates":[[[100.000000,10.000000],[101.000000,10.000000],[101.000000,11.000000],[100.000001,10.000000]]]}), + json!({"type":"Polygon","coordinates":[[[100.000000,10.000000],[101.000000,10.000000],[101.000000,11.000000],[100.000000,10.000000,0]]]}), + json!({"type":"Polygon","coordinates":[[[100.000000,10.000000],[101.000000,10.000000],[101.000000,11.000000],[100.000000,10.000000]]],"bbox":[100,10,101,11]}), + json!({"type":"Polygon","coordinates":[[[179.000000,10.000000],[-179.000000,10.000000],[-179.000000,11.000000],[179.000000,10.000000]]]}), + ]; + for value in invalid { + assert!(!valid_crs84_polygon(&value, 6, None, 10)); + } + let too_many = json!({"type":"Polygon","coordinates":[[[100.000000,10.000000],[101.000000,10.000000],[101.000000,11.000000],[100.000000,10.000000]]]}); + assert!(!valid_crs84_polygon(&too_many, 6, None, 3)); + let out_of_bbox = json!({"type":"Polygon","coordinates":[[[100.000000,10.000000],[101.000000,10.000000],[101.000000,11.000000],[100.000000,10.000000]]]}); + let bbox = Crs84BboxSource { + west: "100.000000".to_owned(), + south: "10.000000".to_owned(), + east: "100.500000".to_owned(), + north: "11.000000".to_owned(), + }; + assert!(!valid_crs84_polygon(&out_of_bbox, 6, Some(&bbox), 10)); + } } diff --git a/crates/registry-breg/src/data.rs b/crates/registry-breg/src/data.rs index eb6df12928..691a3076b5 100644 --- a/crates/registry-breg/src/data.rs +++ b/crates/registry-breg/src/data.rs @@ -23,8 +23,8 @@ use time::{format_description::well_known::Rfc3339, Date, Month, OffsetDateTime} use uuid::Uuid; use crate::contract::{ - valid_crs84_point, valid_decimal_value, valid_structured_value, FieldTypeSource, MutationMode, - Operation, + valid_crs84_point, valid_crs84_polygon, valid_decimal_value, valid_structured_value, + FieldTypeSource, MutationMode, Operation, }; use crate::model::{ CompiledEntity, CompiledQueryKind, CompiledRegistry, CompiledRoute, CompiledStoredField, @@ -222,6 +222,11 @@ fn validate_json_field_value(value: &Value, field_type: &FieldTypeSource) -> boo FieldTypeSource::Crs84Point { precision, bbox } => { valid_crs84_point(value, *precision, bbox.as_ref()) } + FieldTypeSource::Crs84Polygon { + precision, + bbox, + max_positions, + } => valid_crs84_polygon(value, *precision, bbox.as_ref(), *max_positions), FieldTypeSource::Structured { max_bytes, schema } => { valid_structured_value(value, *max_bytes, schema) } @@ -265,6 +270,13 @@ fn validate_text_field_value(value: &str, field_type: &FieldTypeSource) -> bool } FieldTypeSource::Crs84Point { precision, bbox } => parse_json_strict(value.as_bytes()) .is_ok_and(|parsed| valid_crs84_point(&parsed, *precision, bbox.as_ref())), + FieldTypeSource::Crs84Polygon { + precision, + bbox, + max_positions, + } => parse_json_strict(value.as_bytes()).is_ok_and(|parsed| { + valid_crs84_polygon(&parsed, *precision, bbox.as_ref(), *max_positions) + }), FieldTypeSource::Structured { max_bytes, schema } => parse_json_strict(value.as_bytes()) .is_ok_and(|parsed| valid_structured_value(&parsed, *max_bytes, schema)), } diff --git a/crates/registry-breg/src/event_record.rs b/crates/registry-breg/src/event_record.rs index e6f0248d25..6fcecc7de5 100644 --- a/crates/registry-breg/src/event_record.rs +++ b/crates/registry-breg/src/event_record.rs @@ -59,6 +59,7 @@ fn value_type(field: &FieldSource) -> Option { | FieldTypeSource::Int64 | FieldTypeSource::Decimal { .. } | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } | FieldTypeSource::Structured { .. } => None, } } @@ -77,6 +78,7 @@ fn field_type_name(field_type: &FieldTypeSource) -> &'static str { FieldTypeSource::VocabularyCode { .. } => "vocabulary-code", FieldTypeSource::Reference { .. } => "reference", FieldTypeSource::Crs84Point { .. } => "crs84-point", + FieldTypeSource::Crs84Polygon { .. } => "crs84-polygon", FieldTypeSource::Structured { .. } => "structured", } } diff --git a/crates/registry-breg/src/generated_ddl.rs b/crates/registry-breg/src/generated_ddl.rs index d53ef854c4..22b28f3192 100644 --- a/crates/registry-breg/src/generated_ddl.rs +++ b/crates/registry-breg/src/generated_ddl.rs @@ -10,8 +10,9 @@ use sha2::{Digest, Sha256}; mod proposal; use crate::contract::{ - BoundaryOperator, ComparisonOperator, ConstraintSource, FieldTypeSource, Operation, - UniqueWhenPredicate, ValidTimeRole, + maximum_polygon_json_bytes, BoundaryOperator, ComparisonOperator, ConstraintSource, + FieldTypeSource, Operation, UniqueWhenPredicate, ValidTimeRole, MAX_CRS84_POLYGON_POSITIONS, + MAX_CRS84_PRECISION, MIN_CRS84_POLYGON_POSITIONS, }; use crate::model::{ CompiledAction, CompiledActionInventory, CompiledActionTargetUse, @@ -28,6 +29,9 @@ const GENERATED_TEXT_COLLATION: &str = "pg_catalog.pg_c_utf8"; pub(crate) const REFERENCE_LIFECYCLE_COLUMN: &str = "record_reference_lifecycle"; pub(crate) const POSTGIS_EXTENSION_SCHEMA: &str = "registry_spatial_ext"; pub(crate) const SPATIAL_BBOX_FUNCTION_NAME: &str = "spatial_bbox_geometry"; +pub(crate) const SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME: &str = "valid_crs84_polygon"; +const SPATIAL_POLYGON_CONTRACT_ARGUMENTS: &str = + "jsonb, integer, numeric, numeric, numeric, numeric, integer"; #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -189,6 +193,20 @@ fn is_false(value: &bool) -> bool { } impl DdlInventory { + #[cfg(feature = "runtime")] + pub(crate) fn requires_spatial_bbox(&self) -> bool { + self.views + .iter() + .any(|view| view.owner == DdlObjectOwner::SpatialBbox) + } + + #[cfg(any(test, feature = "runtime"))] + pub(crate) fn requires_spatial_polygon_contract(&self) -> bool { + self.functions + .iter() + .any(|function| function.name == SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME) + } + pub fn script(&self) -> String { let mut output = String::new(); for statement in &self.statements { @@ -229,10 +247,34 @@ pub(crate) fn generate_ddl_with_actions( $breg_function$" .to_owned(), }); - let requires_postgis = requires_postgis(entities); - if requires_postgis { + let requires_spatial_bbox = entities + .values() + .any(|entity| spatial_bbox_geometry_field(entity).is_some()); + let requires_spatial_polygon_contract = entities.values().any(|entity| { + entity + .fields + .values() + .any(|field| matches!(field.field_type, FieldTypeSource::Crs84Polygon { .. })) + }); + let requires_postgis = requires_spatial_bbox + || entities.values().any(|entity| { + entity + .fields + .values() + .any(|field| matches!(field.field_type, FieldTypeSource::Crs84Polygon { .. })) + }) + || actions.actions.iter().any(|action| { + action + .inputs + .iter() + .any(|input| matches!(input.field_type, FieldTypeSource::Crs84Polygon { .. })) + }); + if requires_spatial_bbox { statements.push(spatial_bbox_function_statement()); } + if requires_spatial_polygon_contract { + statements.push(spatial_polygon_contract_statement()); + } for entity in entities.values() { let mut columns = vec![ @@ -464,7 +506,7 @@ pub(crate) fn generate_ddl_with_actions( runtime_execute: true, spatial_bbox_execute: false, }]; - if requires_postgis { + if requires_spatial_bbox { functions.push(DdlFunction { id: format!("registry_context.{SPATIAL_BBOX_FUNCTION_NAME}"), schema: "registry_context".to_owned(), @@ -474,6 +516,16 @@ pub(crate) fn generate_ddl_with_actions( spatial_bbox_execute: true, }); } + if requires_spatial_polygon_contract { + functions.push(DdlFunction { + id: format!("registry_context.{SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME}"), + schema: "registry_context".to_owned(), + name: SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME.to_owned(), + arguments: SPATIAL_POLYGON_CONTRACT_ARGUMENTS.to_owned(), + runtime_execute: true, + spatial_bbox_execute: false, + }); + } // Invoker helpers give membership facts a processing-only RLS context. // The exception block rolls back the local marker on errors; normal return // restores its prior value. No cluster-wide custom-parameter ACL is needed. @@ -829,33 +881,34 @@ pub(crate) fn generated_identifier_seed_statement( } } -fn requires_postgis(entities: &BTreeMap) -> bool { - entities +pub(crate) fn spatial_projection_fields(entity: &CompiledEntity) -> BTreeSet { + entity + .fields .values() - .any(|entity| !spatial_projection_fields(entity).is_empty()) + .filter(|field| matches!(field.field_type, FieldTypeSource::Crs84Polygon { .. })) + .map(|field| field.id.clone()) + .chain(spatial_bbox_geometry_field(entity).map(str::to_owned)) + .collect() } -pub(crate) fn spatial_projection_fields(entity: &CompiledEntity) -> BTreeSet { - let Some(geojson) = entity.geojson.as_ref() else { - return BTreeSet::new(); - }; +fn spatial_bbox_geometry_field(entity: &CompiledEntity) -> Option<&str> { + let geojson = entity.geojson.as_ref()?; entity .access_profiles .values() - .filter(|profile| profile.operations.contains(&Operation::List)) - .filter(|profile| { - profile - .spatial_queries - .as_ref() - .and_then(|spatial| spatial.bbox.as_ref()) - .is_some() + .any(|profile| { + profile.operations.contains(&Operation::List) + && profile + .spatial_queries + .as_ref() + .and_then(|spatial| spatial.bbox.as_ref()) + .is_some() }) - .map(|_| geojson.geometry_field.clone()) - .collect() + .then_some(geojson.geometry_field.as_str()) } fn ordinary_policy_role(entity: &CompiledEntity) -> DdlPolicyRole { - if spatial_projection_fields(entity).is_empty() { + if spatial_bbox_geometry_field(entity).is_none() { DdlPolicyRole::Public } else { DdlPolicyRole::Runtime @@ -916,6 +969,151 @@ pub(crate) fn spatial_bbox_function_statement() -> DdlStatement { } } +#[cfg_attr( + not(feature = "runtime"), + allow(dead_code, reason = "runtime package planning consumes this helper") +)] +pub(crate) fn drop_spatial_polygon_contract_statement() -> DdlStatement { + DdlStatement { + id: format!( + "function.registry_context.{SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME}.drop" + ), + kind: DdlStatementKind::Function, + sql: format!( + "DROP FUNCTION IF EXISTS registry_context.{SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME}({SPATIAL_POLYGON_CONTRACT_ARGUMENTS})" + ), + } +} + +pub(crate) fn spatial_polygon_contract_statement() -> DdlStatement { + DdlStatement { + id: format!("function.registry_context.{SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME}"), + kind: DdlStatementKind::Function, + sql: format!( + "CREATE OR REPLACE FUNCTION registry_context.{SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME}( + {SPATIAL_POLYGON_CONTRACT_ARGUMENTS} + ) RETURNS boolean + LANGUAGE plpgsql IMMUTABLE STRICT + SECURITY INVOKER + SET search_path = pg_catalog + AS $breg_polygon_contract$ + DECLARE + geometry_value ALIAS FOR $1; + coordinate_precision ALIAS FOR $2; + west_bound ALIAS FOR $3; + south_bound ALIAS FOR $4; + east_bound ALIAS FOR $5; + north_bound ALIAS FOR $6; + maximum_positions ALIAS FOR $7; + polygons jsonb; + polygon_value jsonb; + ring_value jsonb; + position_value jsonb; + polygon_count integer; + ring_count integer; + ring_length integer; + position_count integer := 0; + polygon_index integer; + ring_index integer; + position_index integer; + longitude numeric; + latitude numeric; + first_longitude numeric; + first_latitude numeric; + previous_longitude numeric; + BEGIN + IF coordinate_precision NOT BETWEEN 0 AND {MAX_CRS84_PRECISION} + OR maximum_positions NOT BETWEEN {MIN_CRS84_POLYGON_POSITIONS} AND {MAX_CRS84_POLYGON_POSITIONS} + OR west_bound < -180 OR east_bound > 180 OR west_bound > east_bound + OR south_bound < -90 OR north_bound > 90 OR south_bound > north_bound + THEN + RETURN false; + END IF; + IF pg_catalog.jsonb_typeof(geometry_value) <> 'object' + OR NOT geometry_value ? 'type' + OR NOT geometry_value ? 'coordinates' + OR geometry_value - 'type' - 'coordinates' <> '{{}}'::jsonb + THEN + RETURN false; + END IF; + + IF geometry_value ->> 'type' = 'Polygon' THEN + polygons := pg_catalog.jsonb_build_array(geometry_value -> 'coordinates'); + ELSIF geometry_value ->> 'type' = 'MultiPolygon' THEN + polygons := geometry_value -> 'coordinates'; + ELSE + RETURN false; + END IF; + IF pg_catalog.jsonb_typeof(polygons) <> 'array' THEN + RETURN false; + END IF; + IF pg_catalog.jsonb_array_length(polygons) = 0 THEN + RETURN false; + END IF; + + polygon_count := pg_catalog.jsonb_array_length(polygons); + FOR polygon_index IN 0..(polygon_count - 1) LOOP + polygon_value := polygons -> polygon_index; + IF pg_catalog.jsonb_typeof(polygon_value) <> 'array' THEN + RETURN false; + END IF; + IF pg_catalog.jsonb_array_length(polygon_value) = 0 THEN + RETURN false; + END IF; + ring_count := pg_catalog.jsonb_array_length(polygon_value); + FOR ring_index IN 0..(ring_count - 1) LOOP + ring_value := polygon_value -> ring_index; + IF pg_catalog.jsonb_typeof(ring_value) <> 'array' THEN + RETURN false; + END IF; + ring_length := pg_catalog.jsonb_array_length(ring_value); + position_count := position_count + ring_length; + IF ring_length < {MIN_CRS84_POLYGON_POSITIONS} OR position_count > maximum_positions THEN + RETURN false; + END IF; + previous_longitude := NULL; + FOR position_index IN 0..(ring_length - 1) LOOP + position_value := ring_value -> position_index; + IF pg_catalog.jsonb_typeof(position_value) <> 'array' THEN + RETURN false; + END IF; + IF pg_catalog.jsonb_array_length(position_value) <> 2 THEN + RETURN false; + END IF; + IF pg_catalog.jsonb_typeof(position_value -> 0) <> 'number' + OR pg_catalog.jsonb_typeof(position_value -> 1) <> 'number' + THEN + RETURN false; + END IF; + longitude := (position_value ->> 0)::numeric; + latitude := (position_value ->> 1)::numeric; + IF longitude <> pg_catalog.trunc(longitude, coordinate_precision) + OR latitude <> pg_catalog.trunc(latitude, coordinate_precision) + OR longitude NOT BETWEEN west_bound AND east_bound + OR latitude NOT BETWEEN south_bound AND north_bound + OR (previous_longitude IS NOT NULL + AND pg_catalog.abs(longitude - previous_longitude) > 180) + THEN + RETURN false; + END IF; + IF position_index = 0 THEN + first_longitude := longitude; + first_latitude := latitude; + END IF; + previous_longitude := longitude; + END LOOP; + IF longitude <> first_longitude OR latitude <> first_latitude THEN + RETURN false; + END IF; + END LOOP; + END LOOP; + RETURN true; + END; + $breg_polygon_contract$" + ), + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct SpatialProjectionStatements { pub add_column: DdlStatement, @@ -928,13 +1126,7 @@ pub(crate) fn spatial_projection_statements( entity: &CompiledEntity, field_id: &str, ) -> SpatialProjectionStatements { - assert!( - matches!( - entity.fields[field_id].field_type, - FieldTypeSource::Crs84Point { .. } - ), - "spatial projections are generated only for CRS84 point fields" - ); + assert!(entity.fields[field_id].field_type.is_crs84_geometry()); let column_name = spatial_geometry_column_name(&entity.id, field_id); let index_name = spatial_geometry_index_name(&entity.id, field_id); let table = quote_identifier(&entity.physical_table); @@ -970,6 +1162,33 @@ pub(crate) fn spatial_projection_statements( fn spatial_projection_column_definition(entity: &CompiledEntity, field_id: &str) -> String { let source = quote_identifier(&entity.fields[field_id].physical_name); let column = quote_identifier(&spatial_geometry_column_name(&entity.id, field_id)); + if let FieldTypeSource::Crs84Polygon { + precision, + bbox, + max_positions, + } = &entity.fields[field_id].field_type + { + let contract = + spatial_polygon_contract_expression(&source, *precision, bbox.as_ref(), *max_positions); + let maximum_bytes = maximum_polygon_json_bytes(*max_positions); + // Keep the authored geometry type and winding in JSONB. Use a single + // typmod for the derived representation of either polygon type. + return format!( + "{column} {POSTGIS_EXTENSION_SCHEMA}.geometry(MultiPolygon,4326) + GENERATED ALWAYS AS ( + {POSTGIS_EXTENSION_SCHEMA}.ST_Multi( + {POSTGIS_EXTENSION_SCHEMA}.ST_SetSRID( + {POSTGIS_EXTENSION_SCHEMA}.ST_GeomFromGeoJSON({source}), + 4326 + ) + )::{POSTGIS_EXTENSION_SCHEMA}.geometry(MultiPolygon,4326) + ) STORED CHECK ( + pg_catalog.octet_length({source}::text) <= {maximum_bytes} + AND {contract} + AND {POSTGIS_EXTENSION_SCHEMA}.ST_IsValid({column}, 0) + )" + ); + } format!( "{column} {POSTGIS_EXTENSION_SCHEMA}.geometry(Point,4326) GENERATED ALWAYS AS ( @@ -987,6 +1206,31 @@ fn spatial_projection_column_definition(entity: &CompiledEntity, field_id: &str) ) } +fn spatial_polygon_contract_expression( + value: &str, + precision: u8, + bbox: Option<&crate::contract::Crs84BboxSource>, + max_positions: u32, +) -> String { + let (west, south, east, north) = bbox.map_or(("-180", "-90", "180", "90"), |bbox| { + ( + bbox.west.as_str(), + bbox.south.as_str(), + bbox.east.as_str(), + bbox.north.as_str(), + ) + }); + format!( + "registry_context.{SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME}( + {value}, {precision}, {}::numeric, {}::numeric, {}::numeric, {}::numeric, {max_positions} + )", + quote_literal(west), + quote_literal(south), + quote_literal(east), + quote_literal(north), + ) +} + fn spatial_projection_index_sql(entity: &CompiledEntity, field_id: &str) -> String { let table = quote_identifier(&entity.physical_table); let column = quote_identifier(&spatial_geometry_column_name(&entity.id, field_id)); @@ -1238,7 +1482,7 @@ fn column_definition(field: &crate::model::CompiledField, inline_requiredness: b } fn spatial_bbox_table_privileges(entity: &CompiledEntity) -> BTreeSet { - if spatial_projection_fields(entity).is_empty() { + if spatial_bbox_geometry_field(entity).is_none() { BTreeSet::new() } else { BTreeSet::from([TablePrivilege::Select]) @@ -1555,21 +1799,33 @@ fn spatial_bbox_predicate( bbox: &crate::contract::SpatialBboxPermissionSource, ) -> String { let geometry = quote_identifier(&spatial_geometry_column_name(&entity.id, geometry_field)); - let source = quote_identifier(&entity.fields[geometry_field].physical_name); let west = "NULLIF(current_setting('registry.bbox_west', true), '')::numeric"; let south = "NULLIF(current_setting('registry.bbox_south', true), '')::numeric"; let east = "NULLIF(current_setting('registry.bbox_east', true), '')::numeric"; let north = "NULLIF(current_setting('registry.bbox_north', true), '')::numeric"; + // Points already promise exact numeric bbox edge checks. The PostGIS + // predicate uses doubles, so retain that residual on Point fields. + let point_residual = if matches!( + entity.fields[geometry_field].field_type, + FieldTypeSource::Crs84Point { .. } + ) { + let source = quote_identifier(&entity.fields[geometry_field].physical_name); + format!( + " + AND ({source} -> 'coordinates' ->> 0)::numeric >= {west} + AND ({source} -> 'coordinates' ->> 0)::numeric <= {east} + AND ({source} -> 'coordinates' ->> 1)::numeric >= {south} + AND ({source} -> 'coordinates' ->> 1)::numeric <= {north}" + ) + } else { + String::new() + }; format!( "{geometry} IS NOT NULL AND ({east} - {west}) BETWEEN 0 AND {} AND ({north} - {south}) BETWEEN 0 AND {} AND {geometry} OPERATOR({POSTGIS_EXTENSION_SCHEMA}.&&) registry_context.{SPATIAL_BBOX_FUNCTION_NAME}() - AND {POSTGIS_EXTENSION_SCHEMA}.ST_Intersects({geometry}, registry_context.{SPATIAL_BBOX_FUNCTION_NAME}()) - AND ({source} -> 'coordinates' ->> 0)::numeric >= {west} - AND ({source} -> 'coordinates' ->> 0)::numeric <= {east} - AND ({source} -> 'coordinates' ->> 1)::numeric >= {south} - AND ({source} -> 'coordinates' ->> 1)::numeric <= {north}", + AND {POSTGIS_EXTENSION_SCHEMA}.ST_Intersects({geometry}, registry_context.{SPATIAL_BBOX_FUNCTION_NAME}()){point_residual}", bbox.maximum_longitude_span_degrees, bbox.maximum_latitude_span_degrees, ) @@ -2497,7 +2753,9 @@ fn policy_value_type(field_type: &FieldTypeSource) -> &'static str { FieldTypeSource::Date => "date", FieldTypeSource::Timestamp => "timestamptz", FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid", - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => "jsonb", + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => "jsonb", } } @@ -2835,7 +3093,9 @@ fn typed_literal_sql(value: &Value, field_type: &FieldTypeSource) -> String { "{}::uuid", quote_literal(value.as_str().expect("validated UUID literal")) ), - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => { unreachable!("validated partial unique predicates reject JSON field types") } } @@ -2866,9 +3126,9 @@ fn sql_type(field_type: &FieldTypeSource) -> String { FieldTypeSource::Date => "date".to_owned(), FieldTypeSource::Timestamp => "timestamptz".to_owned(), FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid".to_owned(), - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { - "jsonb".to_owned() - } + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => "jsonb".to_owned(), } } @@ -3103,9 +3363,10 @@ mod tests { use crate::compiler::{compile_project, CompileProfile}; use crate::contract::parse_project_json; use crate::generated_ddl::{ - drop_spatial_bbox_function_statement, spatial_bbox_function_statement, + drop_spatial_bbox_function_statement, drop_spatial_polygon_contract_statement, + quote_identifier, spatial_bbox_function_statement, spatial_polygon_contract_statement, spatial_projection_fields, spatial_projection_statements, DdlStatementKind, - SPATIAL_BBOX_FUNCTION_NAME, + SPATIAL_BBOX_FUNCTION_NAME, SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME, }; #[cfg(feature = "runtime")] @@ -3272,6 +3533,75 @@ mod tests { ))); } + #[test] + fn polygon_projection_uses_the_compiler_owned_structural_contract() { + let registry = compile_polygon_registry(); + assert!(registry.ddl().requires_spatial_polygon_contract()); + assert!(registry + .ddl() + .functions + .iter() + .any(|function| function.name == SPATIAL_POLYGON_CONTRACT_FUNCTION_NAME)); + let entity = ®istry.entities()["area"]; + let add = spatial_projection_statements(entity, "boundary").add_column; + assert!(add.sql.contains("registry_context.valid_crs84_polygon(")); + let source_argument = format!( + "{}, 6", + quote_identifier(&entity.fields["boundary"].physical_name) + ); + for argument in [ + &source_argument, + "'100.000000'::numeric", + "'10.000000'::numeric", + "'110.000000'::numeric", + "'20.000000'::numeric", + ", 32", + ] { + assert!( + add.sql.contains(argument), + "missing {argument}: {}", + add.sql + ); + } + assert!(add.sql.contains("pg_catalog.octet_length")); + assert!(add.sql.contains("pg_catalog.octet_length")); + assert!(add.sql.contains("registry_spatial_ext.ST_IsValid")); + + let helper = spatial_polygon_contract_statement(); + assert!(helper.sql.contains("LANGUAGE plpgsql IMMUTABLE STRICT")); + assert!(helper.sql.contains("SECURITY INVOKER")); + assert!(helper.sql.contains("SET search_path = pg_catalog")); + assert!(helper.sql.contains("pg_catalog.trunc")); + assert!(helper.sql.contains("> 180")); + assert!(drop_spatial_polygon_contract_statement() + .sql + .contains("DROP FUNCTION IF EXISTS registry_context.valid_crs84_polygon(jsonb, integer, numeric, numeric, numeric, numeric, integer)")); + + assert!(!compile_spatial_registry() + .ddl() + .requires_spatial_polygon_contract()); + } + + fn compile_polygon_registry() -> crate::CompiledRegistry { + let project = parse_project_json( + br#"{ + "apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1", + "kind":"BRegProject", + "project":{"id":"polygon-ddl","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://polygon-ddl.example.test"}, + "datasets":[{"id":"test-dataset"}], "recordTypes":[{ + "id":"area","primaryDataset":"test-dataset","route":"areas","mutationMode":"mutable","classification":"internal", + "fields":[ + {"id":"boundary","type":"crs84-polygon","precision":6,"maximumPositions":32,"bbox":{"west":"100.000000","south":"10.000000","east":"110.000000","north":"20.000000"},"required":true,"classification":"internal"} + ], + "geojson":{"geometryField":"boundary"} + }] + }"#, + ) + .expect("polygon DDL project parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("polygon DDL project compiles") + } + fn compile_spatial_registry() -> crate::CompiledRegistry { let project = parse_project_json( br#"{ diff --git a/crates/registry-breg/src/immediate_actions.rs b/crates/registry-breg/src/immediate_actions.rs index 6ecf4b5132..874778aabe 100644 --- a/crates/registry-breg/src/immediate_actions.rs +++ b/crates/registry-breg/src/immediate_actions.rs @@ -513,11 +513,13 @@ fn compile_handler( ), )); } - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => { errors.push(Diagnostic::error( "breg.action.handler-input-type-unsupported", format!("{input_path}.type"), - &format!("{} accepts scalar inputs; use scalar fields or fixed effects for crs84-point and structured values", abi), + &format!("{} accepts scalar inputs; use scalar fields or fixed effects for CRS84 geometry and structured values", abi), )); } _ => {} @@ -1203,7 +1205,9 @@ fn compile_requirements( !expected.required || matches!( expected.field_type, - FieldTypeSource::Structured { .. } | FieldTypeSource::Crs84Point { .. } + FieldTypeSource::Structured { .. } + | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } ) || !compatible_field_types(&expected.field_type, &field.field_type) }) @@ -1224,7 +1228,9 @@ fn compile_requirements( || ordered_input_invalid || matches!( field.field_type, - FieldTypeSource::Structured { .. } | FieldTypeSource::Crs84Point { .. } + FieldTypeSource::Structured { .. } + | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } ) || literal_invalid || input_invalid @@ -2258,7 +2264,9 @@ fn validate_row_boundaries( }; if matches!( field.field_type, - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } ) { errors.push(Diagnostic::error( "breg.action.permission-row-boundary-type-unsupported", @@ -2483,6 +2491,24 @@ fn validate_field_type_bounds( "CRS84 point precision or CRS84 bounding box is invalid", )); } + FieldTypeSource::Crs84Polygon { + precision, + bbox, + max_positions, + } if *precision > 9 + || !(crate::contract::MIN_CRS84_POLYGON_POSITIONS + ..=crate::contract::MAX_CRS84_POLYGON_POSITIONS) + .contains(max_positions) + || bbox + .as_ref() + .is_some_and(|bbox| crate::contract::parsed_bbox(bbox, *precision).is_none()) => + { + errors.push(Diagnostic::error( + "breg.action.input-crs84-polygon-bounds-invalid", + path, + "CRS84 polygon precision, bounding box, or maximumPositions is invalid", + )); + } FieldTypeSource::Structured { max_bytes, schema } if *max_bytes == 0 || *max_bytes > crate::contract::MAX_STRUCTURED_VALUE_BYTES @@ -2522,6 +2548,9 @@ fn maximum_field_json_bytes(field_type: &FieldTypeSource) -> Option { .max() .unwrap_or(Some(2)), FieldTypeSource::Crs84Point { .. } => Some(96), + FieldTypeSource::Crs84Polygon { max_positions, .. } => { + Some(crate::contract::maximum_polygon_json_bytes(*max_positions)) + } FieldTypeSource::Structured { max_bytes, .. } => Some(u64::from(*max_bytes)), } } diff --git a/crates/registry-breg/src/manifest_adapter.rs b/crates/registry-breg/src/manifest_adapter.rs index fb0bb2a8b0..ece2a708fe 100644 --- a/crates/registry-breg/src/manifest_adapter.rs +++ b/crates/registry-breg/src/manifest_adapter.rs @@ -522,6 +522,7 @@ fn project_field( ), FieldTypeSource::Reference { .. } | FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } | FieldTypeSource::Structured { .. } => return None, }; Some(FieldManifest { diff --git a/crates/registry-breg/src/mutation.rs b/crates/registry-breg/src/mutation.rs index 663d2e6b84..6f2f0c49aa 100644 --- a/crates/registry-breg/src/mutation.rs +++ b/crates/registry-breg/src/mutation.rs @@ -2738,6 +2738,22 @@ async fn apply_current_row( ) .await?; authorize_record_snapshot(request, &data, record_uuid)?; + let profile = selected_profile(request)?; + for (field_id, value) in &data { + validate_polygon_topology( + transaction, + &request.plan.entity.fields[field_id].field_type, + value, + || { + MutationError::InvalidRequestAt(data_field_location( + &request.plan.entity, + profile, + field_id, + )) + }, + ) + .await?; + } let row = apply_create_row(transaction, request, &record_id, &data, field_encryption).await?; Ok(row) @@ -2761,7 +2777,7 @@ async fn apply_current_row( return Err(MutationError::PreconditionFailed); } let before_data = current.data.clone(); - let data = apply_patch_document(request, ¤t.data)?; + let data = apply_patch_document(transaction, request, ¤t.data).await?; let mut admitted_intake = current.data.clone(); admitted_intake.extend(data.clone()); authorize_record_snapshot(request, &admitted_intake, current.record_uuid)?; @@ -2896,6 +2912,15 @@ pub(super) async fn write_plan_row( { return Err(MutationError::InvalidRequest); } + for (field_id, value) in &fields { + validate_polygon_topology( + transaction, + &entity.fields[field_id].field_type, + value, + || MutationError::InvalidRequest, + ) + .await?; + } let record_id = record_uuid.to_string(); let mut current = match expected_revision { None => { @@ -3427,7 +3452,8 @@ fn response_data( Ok(response) } -fn apply_patch_document( +async fn apply_patch_document( + transaction: &Transaction<'_>, request: &MutationRequest<'_>, current: &Map, ) -> Result, MutationError> { @@ -3460,6 +3486,10 @@ fn apply_patch_document( } written_sql_value(&request.plan.entity, field, value) .map_err(|_| at(PatchMember::Value))?; + validate_polygon_topology(transaction, &field.field_type, value, || { + at(PatchMember::Value) + }) + .await?; materialized.insert(field_id.clone(), value.clone()); changed.insert(field_id, value.clone()); mutated = true; @@ -4152,6 +4182,54 @@ fn written_sql_value( sql_value(value, &field.field_type) } +/// Check topology only after bounding the geometry. The flags overload avoids +/// emitting PostGIS NOTICEs containing coordinates of invalid geometries. +async fn validate_polygon_topology( + client: &(impl GenericClient + Sync), + field_type: &FieldTypeSource, + value: &Value, + invalid: impl FnOnce() -> MutationError, +) -> Result<(), MutationError> { + if !matches!(field_type, FieldTypeSource::Crs84Polygon { .. }) || value.is_null() { + return Ok(()); + } + if !validate_field_value(FieldValue::Json(value), field_type) { + return Err(invalid()); + } + use crate::generated_ddl::POSTGIS_EXTENSION_SCHEMA; + let row = client + .query_one( + &format!( + "SELECT {POSTGIS_EXTENSION_SCHEMA}.ST_IsValid( + {POSTGIS_EXTENSION_SCHEMA}.ST_GeomFromGeoJSON($1::jsonb), 0)" + ), + &[value], + ) + .await + .map_err(map_database_error)?; + if !row.try_get::<_, bool>(0).map_err(map_database_error)? { + return Err(invalid()); + } + Ok(()) +} + +async fn check_action_input_constraints( + client: &(impl GenericClient + Sync), + action: &CompiledAction, + inputs: &Map, +) -> Result<(), MutationError> { + crate::input_patterns::check_input_patterns(client, action, inputs).await?; + for input in &action.inputs { + if let Some(value) = inputs.get(&input.id) { + validate_polygon_topology(client, &input.field_type, value, || { + MutationError::InvalidRequestAt(RequestLocation::action_input(&input.api_name)) + }) + .await?; + } + } + Ok(()) +} + /// Whether `value` written to `field_id` is a link key an external view could /// not send to its source. Proposal preparation and action input apply it /// beside the type check, so a malformed key is refused before anything is @@ -4182,11 +4260,11 @@ pub(crate) fn sql_value( | FieldTypeSource::Timestamp | FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => value.as_str().map(str::to_owned), - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { - canonicalize_json(value) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok().filter(|_| !value.is_null())) - } + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => canonicalize_json(value) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok().filter(|_| !value.is_null())), } .ok_or(MutationError::InvalidRequest)?; Ok(Some(value)) @@ -4207,7 +4285,9 @@ pub(crate) fn typed_parameter(index: usize, field_type: &FieldTypeSource) -> Str FieldTypeSource::Date => "date", FieldTypeSource::Timestamp => "timestamptz", FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid", - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => "jsonb", + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => "jsonb", }; format!("${index}::text::{cast}") } diff --git a/crates/registry-breg/src/mutation/action.rs b/crates/registry-breg/src/mutation/action.rs index a60e57029a..b1c251234f 100644 --- a/crates/registry-breg/src/mutation/action.rs +++ b/crates/registry-breg/src/mutation/action.rs @@ -169,7 +169,7 @@ impl MutationTransaction { #[cfg(feature = "postgres-test")] authority.wait_after_preflight().await?; let normalized_input = - match validate_action_input_with_patterns(&***client, action, input).await { + match validate_action_input_with_constraints(&***client, action, input).await { Ok(input) => input, Err(MutationError::Unavailable) => return Err(MutationError::Unavailable), Err(_) => { @@ -568,7 +568,7 @@ impl MutationTransaction { )?; validate_action_claims(action, claims, Operation::Invoke)?; let normalized_input = - validate_action_input_with_patterns(&***client, action, input.input).await?; + validate_action_input_with_constraints(&***client, action, input.input).await?; validate_precondition_set(action, &input.preconditions)?; let _attempt = self .begin_action_boundary_audit(claims, input.route_id, input.correlation) @@ -1056,7 +1056,7 @@ impl MutationTransaction { ); } let normalized_input = - match validate_action_input_with_patterns(&***client, action, input_values).await { + match validate_action_input_with_constraints(&***client, action, input_values).await { Ok(input) => input, Err(MutationError::Unavailable) => return Err(UncertainApply), Err(_) => { @@ -2951,14 +2951,14 @@ impl ActionClaimExt for ActionClaimContext { } } -/// The declared inputs, type-checked and then matched against any input pattern. -async fn validate_action_input_with_patterns( +/// Type-check the declared inputs before applying database-backed constraints. +async fn validate_action_input_with_constraints( client: &(impl tokio_postgres::GenericClient + Sync), action: &CompiledAction, input: Map, ) -> Result { let input = validate_action_input(action, input)?; - crate::input_patterns::check_input_patterns(client, action, &input).await?; + super::check_action_input_constraints(client, action, &input).await?; Ok(input) } @@ -3826,7 +3826,7 @@ impl MutationTransaction { )?; validate_action_claims(action, claims, Operation::Invoke)?; let normalized = - validate_action_input_with_patterns(&***client, action, input.input).await?; + validate_action_input_with_constraints(&***client, action, input.input).await?; validate_precondition_set(action, &input.preconditions)?; *attempt = Some( self.begin_action_boundary_audit(claims, input.route_id, input.correlation) diff --git a/crates/registry-breg/src/mutation/proposal.rs b/crates/registry-breg/src/mutation/proposal.rs index 2ec2e2b427..d91f5c1aa8 100644 --- a/crates/registry-breg/src/mutation/proposal.rs +++ b/crates/registry-breg/src/mutation/proposal.rs @@ -1177,7 +1177,7 @@ impl MutationTransaction { action, input.input.as_ref().ok_or(MutationError::InvalidRequest)?, )?; - crate::input_patterns::check_input_patterns(tx, action, &values).await?; + super::check_action_input_constraints(tx, action, &values).await?; validate_input_write_authority(action, input.authority, &values, None)?; let id = Uuid::new_v4(); proposal_store::initialize_draft( @@ -1385,6 +1385,23 @@ impl MutationTransaction { { return Err(MutationError::PreconditionFailed); } + for (field, value) in &target.after { + if target.before.as_ref().and_then(|before| before.get(field)) + != Some(value) + { + let field = entity + .fields + .get(field) + .ok_or(MutationError::InvalidRequest)?; + super::validate_polygon_topology( + tx, + &field.field_type, + value, + || MutationError::InvalidRequest, + ) + .await?; + } + } } let workflow = workflow .submit(context, prepared.proposal) @@ -1469,7 +1486,7 @@ impl MutationTransaction { .or(document.input.as_ref()) .ok_or(MutationError::InvalidRequest)?, )?; - crate::input_patterns::check_input_patterns(tx, action, &values).await?; + super::check_action_input_constraints(tx, action, &values).await?; validate_input_write_authority( action, input.authority, diff --git a/crates/registry-breg/src/package.rs b/crates/registry-breg/src/package.rs index 7bc2588725..64bb59cc42 100644 --- a/crates/registry-breg/src/package.rs +++ b/crates/registry-breg/src/package.rs @@ -34,9 +34,10 @@ use crate::contract::{ use crate::derived_sql::MAX_DERIVED_SQL_BYTES; use crate::generated_ddl::{ add_blind_index_column_statement, add_column_statement, drop_spatial_bbox_function_statement, - drop_spatial_candidate_view_statement, generate_ddl_with_actions, quote_identifier, - replace_length_check_statement, replace_vocabulary_check_statement, - set_column_not_null_statement, spatial_bbox_function_statement, spatial_projection_fields, + drop_spatial_candidate_view_statement, drop_spatial_polygon_contract_statement, + generate_ddl_with_actions, quote_identifier, replace_length_check_statement, + replace_vocabulary_check_statement, set_column_not_null_statement, + spatial_bbox_function_statement, spatial_polygon_contract_statement, spatial_projection_fields, spatial_projection_statements, DdlInventory, DdlPolicy, DdlPolicyRole, DdlStatement, DdlStatementKind, DdlTable, }; @@ -2331,11 +2332,26 @@ fn additive_migration_plan( }); } } - if candidate.ddl().requires_postgis && !previous_ddl.requires_postgis { + if candidate.ddl().requires_spatial_bbox() && !previous_ddl.requires_spatial_bbox() { new_statement_ids.insert(spatial_bbox_function_statement().id); } + if candidate.ddl().requires_spatial_polygon_contract() + && !previous_ddl.requires_spatial_polygon_contract() + { + new_statement_ids.insert(spatial_polygon_contract_statement().id); + } for previous_table in &previous_ddl.tables { let previous_entity = &previous.entities[&previous_table.entity_id]; + let previous_fields = spatial_projection_fields(previous_entity); + let candidate_entity = candidate.entities().get(&previous_table.entity_id); + let candidate_fields = candidate_entity + .map(spatial_projection_fields) + .unwrap_or_default(); + let changed_fields = candidate_entity + .map(|candidate_entity| { + changed_spatial_projection_fields(previous_entity, candidate_entity) + }) + .unwrap_or_default(); let candidate_view_statement_id = format!("entity.{}.spatial-candidates-view", previous_entity.id); let previous_view_statement = previous_ddl @@ -2347,8 +2363,20 @@ fn additive_migration_plan( .statements .iter() .find(|statement| statement.id == candidate_view_statement_id); + let bbox_projection_changed = changed_fields.iter().any(|field| { + (previous_view_statement.is_some() + && previous_entity + .geojson + .as_ref() + .is_some_and(|geojson| geojson.geometry_field.as_str() == field.as_str())) + || (candidate_view_statement.is_some() + && candidate_entity + .and_then(|entity| entity.geojson.as_ref()) + .is_some_and(|geojson| geojson.geometry_field.as_str() == field.as_str())) + }); if previous_view_statement.map(|statement| &statement.sql) != candidate_view_statement.map(|statement| &statement.sql) + || bbox_projection_changed { if previous_view_statement.is_some() { // The view depends on the generated geometry and helper. Drop @@ -2362,11 +2390,6 @@ fn additive_migration_plan( new_statement_ids.insert(candidate_view_statement_id); } } - let previous_fields = spatial_projection_fields(previous_entity); - let candidate_entity = candidate.entities().get(&previous_table.entity_id); - let candidate_fields = candidate_entity - .map(spatial_projection_fields) - .unwrap_or_default(); let removed_fields: Vec<_> = previous_fields.difference(&candidate_fields).collect(); let candidate_table = candidate .ddl() @@ -2376,6 +2399,7 @@ fn additive_migration_plan( for policy in &previous_table.policies { if policy.applies_to == DdlPolicyRole::SpatialBbox && (!removed_fields.is_empty() + || bbox_projection_changed || !candidate_table.is_some_and(|table| { table .policies @@ -2390,12 +2414,17 @@ fn additive_migration_plan( } } } - for field in removed_fields { + for field in removed_fields.into_iter().chain(&changed_fields) { let projection = spatial_projection_statements(previous_entity, field); removed_dependency_statements.extend([projection.drop_index, projection.drop_column]); } } - if previous_ddl.requires_postgis && !candidate.ddl().requires_postgis { + if previous_ddl.requires_spatial_polygon_contract() + && !candidate.ddl().requires_spatial_polygon_contract() + { + removed_dependency_statements.push(drop_spatial_polygon_contract_statement()); + } + if previous_ddl.requires_spatial_bbox() && !candidate.ddl().requires_spatial_bbox() { removed_dependency_statements.push(drop_spatial_bbox_function_statement()); } @@ -2589,7 +2618,12 @@ fn additive_migration_plan( new_statement_ids.insert(source_view_id); } let previous_fields = spatial_projection_fields(previous_entity); - for field_id in spatial_projection_fields(candidate_entity).difference(&previous_fields) { + let candidate_fields = spatial_projection_fields(candidate_entity); + let changed_fields = changed_spatial_projection_fields(previous_entity, candidate_entity); + for field_id in candidate_fields + .difference(&previous_fields) + .chain(&changed_fields) + { let projection = spatial_projection_statements(candidate_entity, field_id); added_columns .entry(entity_id.clone()) @@ -2705,6 +2739,22 @@ fn additive_migration_plan( } } +fn changed_spatial_projection_fields( + previous: &CompiledEntity, + candidate: &CompiledEntity, +) -> BTreeSet { + let previous_fields = spatial_projection_fields(previous); + let candidate_fields = spatial_projection_fields(candidate); + previous_fields + .intersection(&candidate_fields) + .filter(|field| { + spatial_projection_statements(previous, field) + != spatial_projection_statements(candidate, field) + }) + .cloned() + .collect() +} + fn statement_sql<'a>(ddl: &'a DdlInventory, id: &str) -> Option<&'a str> { ddl.statements .iter() @@ -5437,3 +5487,213 @@ fn digest(bytes: &[u8]) -> String { } result } + +#[cfg(test)] +mod spatial_projection_migration_tests { + use super::*; + use crate::compiler::{compile_project, CompileProfile}; + use crate::contract::parse_project_json; + + const PACKAGE_DIGEST: &str = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + + #[test] + fn polygon_contract_changes_replace_projection_dependencies_in_order() { + let previous = compiled_spatial_registry( + r#""type":"crs84-polygon","precision":6,"maximumPositions":8"#, + ); + for (contract, field_type) in [ + ( + "precision", + r#""type":"crs84-polygon","precision":7,"maximumPositions":8"#, + ), + ( + "bbox", + r#""type":"crs84-polygon","precision":6,"maximumPositions":8,"bbox":{"west":"100","south":"10","east":"110","north":"20"}"#, + ), + ( + "maximumPositions", + r#""type":"crs84-polygon","precision":6,"maximumPositions":12"#, + ), + ] { + let candidate = compiled_spatial_registry(field_type); + assert_ne!( + spatial_projection_statements(&previous.entities()["area"], "geometry").add_column, + spatial_projection_statements(&candidate.entities()["area"], "geometry").add_column, + "the generated projection must bind the authored {contract} contract" + ); + + let plan = reviewed_plan(&previous, &candidate); + assert_projection_replacement(&plan, &candidate); + } + } + + #[test] + fn adding_the_first_polygon_installs_its_validator_before_the_projection() { + let previous = compiled_spatial_registry(r#""type":"crs84-point","precision":6"#); + let candidate = compiled_spatial_registry_with_fields( + r#"{"id":"geometry","type":"crs84-point","precision":6,"classification":"internal"},{"id":"boundary","type":"crs84-polygon","precision":6,"maximumPositions":12,"classification":"internal"}"#, + ); + + let baseline = CompiledRegistryMigrationBaseline::from_compiled(PACKAGE_DIGEST, &previous); + let changes = + compiled_registry_change_set_from_baseline(&baseline, &candidate, PACKAGE_DIGEST); + assert!(changes.changes.iter().any(|change| { + change.code == CompiledRegistryChangeCode::FieldAddedOptional + && change.target.record_type_id.as_deref() == Some("area") + && change.target.member_id.as_deref() == Some("boundary") + })); + let plan = additive_migration_plan(&baseline, &candidate, PACKAGE_DIGEST, changes.changes); + assert!( + statement_index(&plan, &spatial_polygon_contract_statement().id) + < statement_index(&plan, "entity.area.field.boundary.spatial-column.add"), + "the polygon validator is installed before its generated column" + ); + assert!( + statement(&plan, "entity.area.field.boundary.spatial-column.add") + .sql + .contains("geometry(MultiPolygon,4326)") + ); + assert!(!plan.statements.iter().any(|statement| { + statement.id == "entity.area.field.geometry.spatial-column.drop" + || statement.id == "entity.area.field.geometry.spatial-column.add" + })); + } + + #[test] + fn removing_the_last_polygon_drops_its_validator_after_the_projection() { + let previous = compiled_spatial_registry_with_fields( + r#"{"id":"geometry","type":"crs84-point","precision":6,"classification":"internal"},{"id":"boundary","type":"crs84-polygon","precision":6,"maximumPositions":12,"classification":"internal"}"#, + ); + let candidate = compiled_spatial_registry(r#""type":"crs84-point","precision":6"#); + + let plan = reviewed_plan_for_change( + &previous, + &candidate, + CompiledRegistryChangeCode::FieldRemoved, + "boundary", + ); + let projection_drop = + statement_index(&plan, "entity.area.field.boundary.spatial-column.drop"); + let validator_drop = statement_index(&plan, &drop_spatial_polygon_contract_statement().id); + assert!(projection_drop < validator_drop); + assert!(!plan.statements.iter().any(|statement| { + statement.id == "entity.area.field.geometry.spatial-column.drop" + || statement.id == "entity.area.field.geometry.spatial-column.add" + })); + } + + fn compiled_spatial_registry(field_type: &str) -> CompiledRegistry { + compiled_spatial_registry_with_fields(&format!( + r#"{{"id":"geometry",{field_type},"classification":"internal"}}"# + )) + } + + fn compiled_spatial_registry_with_fields(fields: &str) -> CompiledRegistry { + let source = format!( + r#"{{ + "apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1", + "kind":"BRegProject", + "project":{{"id":"spatial-projection-migration","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://spatial.example.test"}}, + "datasets":[{{"id":"registry"}}], + "recordTypes":[{{ + "id":"area","primaryDataset":"registry","route":"areas","mutationMode":"mutable", + "fields":[{fields}], + "geojson":{{"geometryField":"geometry"}} + }}], + "accessProfiles":[{{ + "id":"map-reader","principalClaim":"principal","requiredScopes":"unrestricted", + "permissions":{{"records":[{{ + "recordType":"area","operations":["get","list"], + "rowBoundaries":"unrestricted","readableFields":["geometry"], + "spatialQueries":{{"bbox":{{"maximumLongitudeSpanDegrees":1,"maximumLatitudeSpanDegrees":1}}}} + }}]}} + }}] + }}"# + ); + let project = parse_project_json(source.as_bytes()).expect("spatial project parses"); + compile_project(&project, &[], CompileProfile::Authoring).expect("spatial project compiles") + } + + fn reviewed_plan(previous: &CompiledRegistry, candidate: &CompiledRegistry) -> MigrationPlan { + reviewed_plan_for_change( + previous, + candidate, + CompiledRegistryChangeCode::FieldTypeChanged, + "geometry", + ) + } + + fn reviewed_plan_for_change( + previous: &CompiledRegistry, + candidate: &CompiledRegistry, + expected_code: CompiledRegistryChangeCode, + expected_member: &str, + ) -> MigrationPlan { + let baseline = CompiledRegistryMigrationBaseline::from_compiled(PACKAGE_DIGEST, previous); + let changes = + compiled_registry_change_set_from_baseline(&baseline, candidate, PACKAGE_DIGEST); + assert!(changes.changes.iter().any(|change| { + change.code == expected_code + && change.target.record_type_id.as_deref() == Some("area") + && change.target.member_id.as_deref() == Some(expected_member) + })); + reviewed_successor_migration_plan( + &baseline, + candidate, + &changes, + vec!["migrations/spatial/descriptor.json".to_owned()], + PACKAGE_DIGEST.to_owned(), + ) + .expect("reviewed successor plan builds") + } + + fn assert_projection_replacement(plan: &MigrationPlan, candidate: &CompiledRegistry) { + let view_drop = statement_index(plan, "entity.area.spatial-candidates-view.drop"); + let policy_drop = plan + .statements + .iter() + .position(|statement| { + statement.kind == DdlStatementKind::Policy + && statement.id.starts_with("entity.area.policy.") + && statement.id.ends_with(".drop") + }) + .expect("the old bbox policy is dropped"); + let index_drop = statement_index(plan, "entity.area.field.geometry.spatial-index.drop"); + let column_drop = statement_index(plan, "entity.area.field.geometry.spatial-column.drop"); + let column_add = statement_index(plan, "entity.area.field.geometry.spatial-column.add"); + let index_add = statement_index(plan, "entity.area.field.geometry.spatial-index.create"); + let view_add = statement_index(plan, "entity.area.spatial-candidates-view"); + + assert!(view_drop < policy_drop); + assert!(policy_drop < index_drop); + assert!(index_drop < column_drop); + assert!(column_drop < column_add); + assert!(column_add < index_add); + assert!(index_add < view_add); + + let add = statement(plan, "entity.area.field.geometry.spatial-column.add"); + assert_eq!( + add, + &spatial_projection_statements(&candidate.entities()["area"], "geometry").add_column + ); + assert_eq!( + plan.statements + .iter() + .filter(|statement| statement.id == "entity.area.field.geometry.spatial-column.add") + .count(), + 1 + ); + } + + fn statement_index(plan: &MigrationPlan, id: &str) -> usize { + plan.statements + .iter() + .position(|statement| statement.id == id) + .unwrap_or_else(|| panic!("migration statement `{id}` is present")) + } + + fn statement<'a>(plan: &'a MigrationPlan, id: &str) -> &'a DdlStatement { + &plan.statements[statement_index(plan, id)] + } +} diff --git a/crates/registry-breg/src/postgres/catalog.rs b/crates/registry-breg/src/postgres/catalog.rs index e832a27637..fdf0d05082 100644 --- a/crates/registry-breg/src/postgres/catalog.rs +++ b/crates/registry-breg/src/postgres/catalog.rs @@ -399,7 +399,7 @@ impl ExpectedManagedCatalog { Some("EXECUTE"), None, ); - if registry.ddl().requires_postgis { + if registry.ddl().requires_spatial_bbox() { catalog.grant_schema_spatial_bbox("registry_data"); catalog.grant_schema_spatial_bbox("registry_context"); } @@ -1484,6 +1484,14 @@ async fn verify_closed_ambient_catalog( (n.nspname = 'registry_context' AND ((p.proname IN ('evaluation_date', 'spatial_bbox_geometry') AND pg_catalog.pg_get_function_identity_arguments(p.oid) = '') + OR (p.proname = 'valid_crs84_polygon' + AND pg_catalog.pg_get_function_identity_arguments(p.oid) = 'jsonb, integer, numeric, numeric, numeric, numeric, integer' + AND p.prorettype = 'boolean'::regtype + AND NOT p.proretset + AND NOT p.prosecdef + AND p.proisstrict + AND p.provolatile = 'i' + AND p.proconfig = ARRAY['search_path=pg_catalog']) OR (p.proname ~ '^membership_[0-9a-f]{24}$' AND pg_catalog.pg_get_function_identity_arguments(p.oid) = 'uuid' AND p.prorettype = 'boolean'::regtype diff --git a/crates/registry-breg/src/postgres/context.rs b/crates/registry-breg/src/postgres/context.rs index bd09334521..aee18365ec 100644 --- a/crates/registry-breg/src/postgres/context.rs +++ b/crates/registry-breg/src/postgres/context.rs @@ -1626,6 +1626,7 @@ fn canonical_snapshot_field_value( .map(|value| value.to_string()) .ok_or_else(invalid_context), crate::contract::FieldTypeSource::Crs84Point { .. } + | crate::contract::FieldTypeSource::Crs84Polygon { .. } | crate::contract::FieldTypeSource::Structured { .. } => { let bytes = canonicalize_json(value).map_err(|_| invalid_context())?; String::from_utf8(bytes).map_err(|_| invalid_context()) diff --git a/crates/registry-breg/src/postgres/history_read.rs b/crates/registry-breg/src/postgres/history_read.rs index d540afcb09..ce64d9c0b8 100644 --- a/crates/registry-breg/src/postgres/history_read.rs +++ b/crates/registry-breg/src/postgres/history_read.rs @@ -1362,9 +1362,9 @@ fn field_typed_sql(field: &HistorySqlField) -> Result "({alias} #>> '{{}}')::{}", postgres_cast(&field.field_type) )), - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { - Err(ReadServiceError::Unavailable) - } + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => Err(ReadServiceError::Unavailable), } } @@ -1978,7 +1978,9 @@ fn postgres_cast(field_type: &FieldTypeSource) -> &'static str { FieldTypeSource::Date => "date", FieldTypeSource::Timestamp => "timestamptz", FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid", - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => "jsonb", + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => "jsonb", } } diff --git a/crates/registry-breg/src/postgres/interlock.rs b/crates/registry-breg/src/postgres/interlock.rs index dc33452122..c820d595e4 100644 --- a/crates/registry-breg/src/postgres/interlock.rs +++ b/crates/registry-breg/src/postgres/interlock.rs @@ -51,7 +51,7 @@ use super::{ is_spatial_candidate_view_sql, map_pattern_database_error, pattern_field_for_constraint, reconcile_compiled_runtime_acl, retire_spatial_bbox_role, transfer_spatial_candidate_views, }, - verify_btree_gist, verify_migration_role, verify_postgis, ConnectionConfig, + verify_btree_gist, verify_migration_role, verify_postgis_prerequisites, ConnectionConfig, ExpectedRegistryIdentity, PostgresKernelError, Result, SqlIdentifier, }; @@ -3442,9 +3442,12 @@ fn is_deferred_not_null(statement: &PackageDdlStatement<'_>) -> bool { fn is_spatial_projection_addition(statement: &PackageDdlStatement<'_>) -> bool { (statement.kind == DdlStatementKind::Column && statement.sql.contains(" ADD COLUMN ") - && statement + && (statement .sql - .contains("registry_spatial_ext.geometry(Point,4326)")) + .contains("registry_spatial_ext.geometry(Point,4326)") + || statement + .sql + .contains("registry_spatial_ext.geometry(MultiPolygon,4326)"))) || (statement.kind == DdlStatementKind::Index && statement.sql.contains(" USING gist ") && statement.sql.contains("\"breg_spgeom_")) @@ -3469,7 +3472,13 @@ async fn verify_compiled_prerequisites_for_client( verify_btree_gist(client).await?; } if registry.ddl().requires_postgis { - verify_postgis(client, migration_role, runtime_role).await?; + verify_postgis_prerequisites( + client, + migration_role, + runtime_role, + registry.ddl().requires_spatial_bbox(), + ) + .await?; } Ok(()) } @@ -3982,6 +3991,7 @@ pub(crate) fn field_plaintext_string( .ok_or(PostgresKernelError::RegistryUnavailable)? .to_string(), crate::contract::FieldTypeSource::Crs84Point { .. } + | crate::contract::FieldTypeSource::Crs84Polygon { .. } | crate::contract::FieldTypeSource::Structured { .. } => String::from_utf8( registry_platform_canonical_json::canonicalize_json(value) .map_err(|_| PostgresKernelError::RegistryUnavailable)?, diff --git a/crates/registry-breg/src/postgres/mod.rs b/crates/registry-breg/src/postgres/mod.rs index 0c42be74c6..811a9e6993 100644 --- a/crates/registry-breg/src/postgres/mod.rs +++ b/crates/registry-breg/src/postgres/mod.rs @@ -131,12 +131,12 @@ pub use rehearsal::{ pub use revision_read::PostgresRevisionReadService; #[cfg(feature = "postgres-test")] pub use revision_read::RevisionReadFaultPoint; -pub(crate) use roles::find_runtime_write_authority; #[cfg(feature = "postgres-test")] #[doc(hidden)] pub use roles::find_runtime_write_authority as find_runtime_write_authority_for_test; #[doc(hidden)] pub use roles::RuntimeRevoke; +pub(crate) use roles::{find_runtime_write_authority, verify_postgis_prerequisites}; pub(crate) use timer_activation_preview::read_timer_activation_previews; #[cfg(feature = "postgres-test")] diff --git a/crates/registry-breg/src/postgres/read.rs b/crates/registry-breg/src/postgres/read.rs index af845ec74c..39854627ed 100644 --- a/crates/registry-breg/src/postgres/read.rs +++ b/crates/registry-breg/src/postgres/read.rs @@ -1176,10 +1176,12 @@ fn feature_value( .fields .get(&geometry_field) .ok_or(ReadServiceError::Unavailable)?; - let FieldTypeSource::Crs84Point { precision, bbox } = &field.field_type else { - return Err(ReadServiceError::Unavailable); - }; - if !crate::contract::valid_crs84_point(&value, *precision, bbox.as_ref()) { + if !field.field_type.is_crs84_geometry() + || !crate::data::validate_field_value( + crate::data::FieldValue::Json(&value), + &field.field_type, + ) + { return Err(ReadServiceError::Unavailable); } value @@ -1746,10 +1748,8 @@ fn validate_spatial_query( if spatial.bbox.geometry_field != capability.geometry_field || spatial.bbox.maximum_longitude_span_degrees != maximum_longitude_span || spatial.bbox.maximum_latitude_span_degrees != maximum_latitude_span - || !matches!( - compiled_field_type(entity, &spatial.bbox.geometry_field), - Some(FieldTypeSource::Crs84Point { .. }) - ) + || !compiled_field_type(entity, &spatial.bbox.geometry_field) + .is_some_and(FieldTypeSource::is_crs84_geometry) { return Err(()); } @@ -2923,7 +2923,9 @@ fn postgres_cast(field_type: &FieldTypeSource) -> &'static str { FieldTypeSource::Date => "date", FieldTypeSource::Timestamp => "timestamptz", FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } => "uuid", - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => "jsonb", + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => "jsonb", } } diff --git a/crates/registry-breg/src/postgres/revision_read.rs b/crates/registry-breg/src/postgres/revision_read.rs index 2b8623abd0..da0371cd6b 100644 --- a/crates/registry-breg/src/postgres/revision_read.rs +++ b/crates/registry-breg/src/postgres/revision_read.rs @@ -635,7 +635,9 @@ fn canonical_boundary_value( | FieldTypeSource::Uuid | FieldTypeSource::Reference { .. } | FieldTypeSource::VocabularyCode { .. } => Value::String(value.to_owned()), - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => { + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => { return Err(ReadServiceError::Unavailable); } }; diff --git a/crates/registry-breg/src/postgres/roles.rs b/crates/registry-breg/src/postgres/roles.rs index 85eed7b895..7a674cb341 100644 --- a/crates/registry-breg/src/postgres/roles.rs +++ b/crates/registry-breg/src/postgres/roles.rs @@ -186,6 +186,15 @@ pub async fn verify_postgis( client: &impl GenericClient, migration_role: &SqlIdentifier, runtime_role: &SqlIdentifier, +) -> Result<()> { + verify_postgis_prerequisites(client, migration_role, runtime_role, true).await +} + +pub(crate) async fn verify_postgis_prerequisites( + client: &impl GenericClient, + migration_role: &SqlIdentifier, + runtime_role: &SqlIdentifier, + requires_spatial_bbox: bool, ) -> Result<()> { verify_postgres_version(client).await?; // A missing extension is the first setup action, even when its dependent @@ -203,8 +212,10 @@ pub async fn verify_postgis( "administrator-installed PostGIS is required", )); } - let bbox_role = spatial_bbox_role(runtime_role); - verify_postgis_spatial_bbox_role(client, migration_role, runtime_role, &bbox_role).await?; + let bbox_role = requires_spatial_bbox.then(|| spatial_bbox_role(runtime_role)); + if let Some(bbox_role) = &bbox_role { + verify_postgis_spatial_bbox_role(client, migration_role, runtime_role, bbox_role).await?; + } let installed = client .query_opt( "WITH postgis AS ( @@ -260,7 +271,7 @@ pub async fn verify_postgis( &[ &migration_role.as_str(), &runtime_role.as_str(), - &bbox_role.as_str(), + &bbox_role.as_ref().map(SqlIdentifier::as_str), ], ) .await?; @@ -272,11 +283,11 @@ pub async fn verify_postgis( let version: String = row.get(0); if !postgis_version_supported(&version) || row.get::<_, String>(1) != POSTGIS_EXTENSION_SCHEMA - || (3..=6).any(|index| row.get::<_, bool>(index)) - || !row.get::<_, bool>(7) + || (3..=6).any(|index| row.get::<_, Option>(index).unwrap_or(false)) + || requires_spatial_bbox && !row.get::<_, bool>(7) || !row.get::<_, bool>(8) || !row.get::<_, bool>(9) - || (10..=18).any(|index| row.get::<_, bool>(index)) + || (10..=18).any(|index| row.get::<_, Option>(index).unwrap_or(false)) { return Err(PostgresKernelError::CatalogInvariant( "administrator-installed PostGIS does not match the governed prerequisite", @@ -374,11 +385,14 @@ async fn verify_required_postgis_symbols(client: &impl GenericClient) -> Result< to_regprocedure('registry_spatial_ext.st_makeline(registry_spatial_ext.geometry,registry_spatial_ext.geometry)') IS NOT NULL, to_regprocedure('registry_spatial_ext.st_makeenvelope(double precision,double precision,double precision,double precision,integer)') IS NOT NULL, to_regprocedure('registry_spatial_ext.st_intersects(registry_spatial_ext.geometry,registry_spatial_ext.geometry)') IS NOT NULL, - to_regoperator('registry_spatial_ext.&&(registry_spatial_ext.geometry,registry_spatial_ext.geometry)') IS NOT NULL", + to_regoperator('registry_spatial_ext.&&(registry_spatial_ext.geometry,registry_spatial_ext.geometry)') IS NOT NULL, + to_regprocedure('registry_spatial_ext.st_geomfromgeojson(jsonb)') IS NOT NULL, + to_regprocedure('registry_spatial_ext.st_multi(registry_spatial_ext.geometry)') IS NOT NULL, + to_regprocedure('registry_spatial_ext.st_isvalid(registry_spatial_ext.geometry,integer)') IS NOT NULL", &[], ) .await?; - if (0..=6).any(|index| !row.get::<_, bool>(index)) { + if (0..row.len()).any(|index| !row.get::<_, bool>(index)) { return Err(PostgresKernelError::CatalogInvariant( "administrator-installed PostGIS does not expose the required spatial symbols", )); diff --git a/crates/registry-breg/src/postgres/schema.rs b/crates/registry-breg/src/postgres/schema.rs index 8f9fb16c88..c143199fe1 100644 --- a/crates/registry-breg/src/postgres/schema.rs +++ b/crates/registry-breg/src/postgres/schema.rs @@ -22,8 +22,8 @@ use crate::mutation::{install_mutation_schema, reconcile_action_result_target_in #[cfg(all(feature = "runtime", feature = "tooling"))] use super::config::ConnectionTls; use super::{ - catalog::install_registry_state_schema, spatial_bbox_role, verify_btree_gist, verify_postgis, - PostgresKernelError, Result, RuntimeRevoke, SqlIdentifier, + catalog::install_registry_state_schema, spatial_bbox_role, verify_btree_gist, + verify_postgis_prerequisites, PostgresKernelError, Result, RuntimeRevoke, SqlIdentifier, }; #[cfg(all(feature = "runtime", feature = "tooling"))] use super::{ @@ -50,7 +50,13 @@ pub async fn install_compiled_schema( } if registry.ddl().requires_postgis { let migration_role = current_role_identifier(migration).await?; - verify_postgis(migration, &migration_role, runtime_role).await?; + verify_postgis_prerequisites( + migration, + &migration_role, + runtime_role, + registry.ddl().requires_spatial_bbox(), + ) + .await?; } install_registry_state_schema(migration, runtime_role).await?; @@ -110,12 +116,12 @@ pub(crate) async fn reconcile_compiled_runtime_acl( ) -> Result<()> { reconcile_action_result_target_index(client, !registry.actions().actions.is_empty()).await?; let candidate_bbox_role = spatial_bbox_role(runtime_role); - let bbox_role = - if registry.ddl().requires_postgis || role_exists(client, &candidate_bbox_role).await? { - Some(candidate_bbox_role) - } else { - None - }; + let requires_spatial_bbox = registry.ddl().requires_spatial_bbox(); + let bbox_role = if requires_spatial_bbox || role_exists(client, &candidate_bbox_role).await? { + Some(candidate_bbox_role) + } else { + None + }; let bbox_revoke = bbox_role .as_ref() .map(|role| format!(", {}", role.quoted())) @@ -130,7 +136,7 @@ pub(crate) async fn reconcile_compiled_runtime_acl( runtime_role.quoted(), )) .await?; - if let Some(role) = &bbox_role { + if let Some(role) = bbox_role.as_ref().filter(|_| requires_spatial_bbox) { client .batch_execute(&format!( "GRANT USAGE ON SCHEMA registry_data, registry_context TO {};", diff --git a/crates/registry-breg/src/startup.rs b/crates/registry-breg/src/startup.rs index 2a8eb39af2..7d25477844 100644 --- a/crates/registry-breg/src/startup.rs +++ b/crates/registry-breg/src/startup.rs @@ -1585,6 +1585,7 @@ async fn finish_prepared_server( lock_key, key_source: Arc::clone(&key_source), requires_postgis: registry.ddl().requires_postgis, + requires_spatial_bbox: registry.ddl().requires_spatial_bbox(), audit_writer: audit.writer().clone(), }); if !readiness.is_ready().await { @@ -2671,9 +2672,14 @@ async fn verify_opened_startup( ) .await?; if package.registry().ddl().requires_postgis { - crate::postgres::verify_postgis(&transaction, migration_role, runtime_role) - .await - .map_err(|_| StartupError::DatabaseUnready)?; + crate::postgres::verify_postgis_prerequisites( + &transaction, + migration_role, + runtime_role, + package.registry().ddl().requires_spatial_bbox(), + ) + .await + .map_err(|_| StartupError::DatabaseUnready)?; } // The physical claim is checked before the recorded identity, so a // restored copy is named as a copy whatever package or database id it @@ -2770,6 +2776,7 @@ struct DynamicRuntimeReadiness { lock_key: RegistryLockKey, key_source: Arc, requires_postgis: bool, + requires_spatial_bbox: bool, audit_writer: AuditWriter, } @@ -2802,10 +2809,11 @@ impl DynamicRuntimeReadiness { .await .map_err(|_| StartupError::DatabaseUnready)?; if self.requires_postgis { - crate::postgres::verify_postgis( + crate::postgres::verify_postgis_prerequisites( &*transaction, &self.migration_role, &self.runtime_role, + self.requires_spatial_bbox, ) .await .map_err(|_| StartupError::DatabaseUnready)?; diff --git a/crates/registry-breg/tests/compiler_contract.rs b/crates/registry-breg/tests/compiler_contract.rs index f7184cbd32..5ebe53a19e 100644 --- a/crates/registry-breg/tests/compiler_contract.rs +++ b/crates/registry-breg/tests/compiler_contract.rs @@ -4861,6 +4861,7 @@ fn scalar_grammar_is_exactly_the_typed_allowlist_and_rejects_json_or_reference_l FieldTypeSource::VocabularyCode { .. } => "vocabulary-code", FieldTypeSource::Reference { .. } => "reference", FieldTypeSource::Crs84Point { .. } => "crs84-point", + FieldTypeSource::Crs84Polygon { .. } => "crs84-polygon", FieldTypeSource::Structured { .. } => "structured", }) .collect(); diff --git a/crates/registry-breg/tests/postgres_fixture_journeys.rs b/crates/registry-breg/tests/postgres_fixture_journeys.rs index 76645aa1ae..674097fca3 100644 --- a/crates/registry-breg/tests/postgres_fixture_journeys.rs +++ b/crates/registry-breg/tests/postgres_fixture_journeys.rs @@ -1322,6 +1322,21 @@ fn spatial_credential_bindings( "create-edge-service-site", Zeroizing::new(spatial_admin_token(idp)), ), + SchemaTestCredentialBinding::bearer( + "service-site-source-profile-smoke", + "create-intersecting-service-area", + Zeroizing::new(spatial_admin_token(idp)), + ), + SchemaTestCredentialBinding::bearer( + "service-site-source-profile-smoke", + "create-envelope-only-service-area", + Zeroizing::new(spatial_admin_token(idp)), + ), + SchemaTestCredentialBinding::bearer( + "service-site-source-profile-smoke", + "area-map-reader-bbox-uses-geometry-intersection", + Zeroizing::new(spatial_public_area_map_token(idp)), + ), SchemaTestCredentialBinding::bearer( "service-site-source-profile-smoke", "public-map-reader-lists-public-point-fields", @@ -1498,6 +1513,16 @@ fn spatial_public_map_token(idp: &MockIdp) -> String { })) } +fn spatial_public_area_map_token(idp: &MockIdp) -> String { + idp.mint_token(json!({ + "aud": AUDIENCE, + "registry_actor_kind": "service", + "registry_principal": "synthetic-public-area-map", + "registry_purpose": "service-area-public-map", + "scope": "service-areas:public:read", + })) +} + fn spatial_public_directory_token(idp: &MockIdp) -> String { idp.mint_token(json!({ "aud": AUDIENCE, diff --git a/crates/registry-breg/tests/postgres_spatial_migration.rs b/crates/registry-breg/tests/postgres_spatial_migration.rs index 4332ff16a3..0bac9c1689 100644 --- a/crates/registry-breg/tests/postgres_spatial_migration.rs +++ b/crates/registry-breg/tests/postgres_spatial_migration.rs @@ -16,10 +16,11 @@ use registry_breg::migration::{ ApplyVerifiedPackageRequest, DestructiveBackupEvidence, MigrationError, }; use registry_breg::migration_plan::{ - ArtifactDigestBinding, ExternalBackupBinding, MigrationRehearsalReceipt, RehearsalFixture, - ReviewedChangeCover, ReviewedMigrationAssertionDescriptor, ReviewedMigrationDescriptor, - ReviewedMigrationFile, ReviewedMigrationObject, ReviewedMigrationObjectKind, - ReviewedMigrationRecovery, ReviewedMigrationSource, ReviewedMigrationStepDescriptor, + AffectedRowBounds, ArtifactDigestBinding, ExternalBackupBinding, MigrationRehearsalReceipt, + RehearsalFixture, RehearsalRowAssertion, ReviewedChangeCover, + ReviewedMigrationAssertionDescriptor, ReviewedMigrationDescriptor, ReviewedMigrationFile, + ReviewedMigrationObject, ReviewedMigrationObjectKind, ReviewedMigrationRecovery, + ReviewedMigrationSource, ReviewedMigrationStepDescriptor, }; use registry_breg::package::{ compiled_registry_change_set, load_package, prepare_package, CompiledRegistryChangeClass, @@ -94,7 +95,7 @@ async fn enabling_bbox_on_existing_point_registry_preserves_data_and_recovers_sa ) .await .expect("initial non-GIS package activates"); - seed_existing_site_and_history(&database, &base, &active).await; + seed_existing_site_and_history(&database, &base, &active, SeedGeometry::Point).await; let prior_rows = site_rows(&database, &base).await; let prior_history = revision_snapshots(&database).await; assert_eq!(prior_history.len(), 2); @@ -194,7 +195,7 @@ async fn enabling_bbox_on_existing_point_registry_preserves_data_and_recovers_sa assert_ready_target(&database, &spatial_active).await; assert_eq!(site_rows(&database, &spatial).await, prior_rows); assert_eq!(revision_snapshots(&database).await, prior_history); - assert_spatial_projection_and_index(&database, &spatial).await; + assert_spatial_projection_and_index(&database, &spatial, SpatialProjection::Point).await; assert_spatial_candidate_view_owner(&database, &spatial, &bbox_role).await; assert_spatial_startup_accepts_exact_identity(&database, &spatial_package, &spatial_active) .await; @@ -216,7 +217,6 @@ async fn enabling_bbox_on_existing_point_registry_preserves_data_and_recovers_sa let removed = compile_variant(Variant::WithBboxLegacyRemoved); let removed_fingerprint = destructive_target_fingerprint(&database, &spatial, &removed).await; let backup_bytes = synthetic_backup_sql(&spatial, &spatial_active, &database.runtime_role, 1); - let backup_digest = digest(&backup_bytes); let destructive_source = destructive_source_with_recovery_fault( &spatial_active, &spatial, @@ -237,43 +237,11 @@ async fn enabling_bbox_on_existing_point_registry_preserves_data_and_recovers_sa .expect("reviewed destructive spatial successor prepares"), local_context(), ); - let backup_root = tempfile::Builder::new() - .prefix("registry-spatial-migration-backup-") - .tempdir_in( - std::env::temp_dir() - .canonicalize() - .expect("canonical temporary root"), - ) - .expect("backup temporary directory creates"); - let backup_path = backup_root.path().join("spatial-restore.backup"); - fs::write(&backup_path, &backup_bytes).expect("backup evidence writes"); - fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) - .expect("backup evidence permissions close"); - let now = OffsetDateTime::now_utc() - .format(&Rfc3339) - .expect("current time formats"); - let backup_binding = ExternalBackupBinding { - database_id: DATABASE.to_owned(), - prior_package_digest: spatial_active.package_digest.clone(), - prior_schema_fingerprint: spatial_active.schema_fingerprint.clone(), - backup_file: backup_path.to_str().expect("UTF-8 backup path").to_owned(), - sha256: backup_digest, - byte_length: backup_bytes.len() as u64, - created_at: now, - max_age_seconds: 3_600, - }; - let binding_file = backup_root.path().join("spatial-restore-binding.json"); - fs::write(&binding_file, canonical(&backup_binding)).expect("backup binding writes"); - let binding_path = destructive_package - .verified - .reviewed_migration_plan() - .expect("destructive package carries a reviewed plan") - .migrations()[0] - .descriptor - .backup_binding_path - .as_deref() - .expect("destructive backup binding path exists"); - let backup_evidence = [DestructiveBackupEvidence::new(binding_path, &binding_file)]; + let backup = backup_fixture(&spatial_active, &backup_bytes); + let backup_evidence = [DestructiveBackupEvidence::new( + destructive_binding_path(&destructive_package), + &backup.binding_file, + )]; let destructive_failure = apply_with_evidence( &database, &destructive_package.verified, @@ -300,7 +268,7 @@ async fn enabling_bbox_on_existing_point_registry_preserves_data_and_recovers_sa assert_record_work_is_blocked(&database, &spatial, &spatial_active).await; assert_legacy_column_absent(&database, &spatial).await; - restore_synthetic_backup(&database, &backup_binding, &backup_path).await; + restore_synthetic_backup(&database, &backup.binding, &backup.backup_file).await; assert_eq!(site_rows(&database, &spatial).await, prior_rows); assert_eq!(revision_snapshots(&database).await, prior_history); @@ -319,6 +287,341 @@ async fn enabling_bbox_on_existing_point_registry_preserves_data_and_recovers_sa database.cleanup().await; } +/// A nullable polygon is ordinary retained Record state, even though its +/// generated storage needs PostGIS before any profile can query a bbox. The +/// no-bbox successor keeps the existing table authority and creates no +/// candidate-ID view or bbox role dependency. Enabling and removing bbox then +/// changes only that query authority, never the retained polygon projection. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn adding_nullable_polygon_retains_rows_without_bbox_authority() { + let database = TestDatabase::create(2).await; + let base = compile_variant(Variant::NoGeometry); + assert!(!base.ddl().requires_postgis); + let base_fingerprint = initial_fingerprint(&database, &base).await; + + let polygon = compile_variant(Variant::PolygonNoBbox); + assert!(polygon.ddl().requires_postgis); + assert_no_compiled_spatial_candidate_view(&polygon); + let polygon_bbox = compile_variant(Variant::PolygonWithBbox); + assert!(polygon_bbox.ddl().requires_postgis); + + let fingerprint_bbox_role = provision_postgis_prerequisites( + &database.admin, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("administrator temporarily provisions PostGIS for target fingerprints"); + let polygon_fingerprint = initial_fingerprint(&database, &polygon).await; + let polygon_bbox_fingerprint = initial_fingerprint(&database, &polygon_bbox).await; + cleanup_bbox_role(&database, &fingerprint_bbox_role).await; + assert!(!role_exists(&database, &fingerprint_bbox_role).await); + + let initial_package = publish_and_load( + prepare_package(build_request( + Variant::NoGeometry, + None, + &base_fingerprint, + PackageMigrationPlanInput::InitialCompiledDdl, + )) + .expect("initial nonspatial package prepares"), + local_context(), + ); + let active = apply( + &database, + &initial_package.verified, + ApplyPrecondition::InitialActivation, + ) + .await + .expect("initial nonspatial package activates"); + seed_existing_site_and_history(&database, &base, &active, SeedGeometry::None).await; + let prior_rows = plain_site_rows(&database, &base).await; + let prior_history = revision_snapshots(&database).await; + let prior_authority = table_authority_snapshot(&database, &base).await; + + let polygon_package = reviewed_successor_package( + Variant::PolygonNoBbox, + &active, + &base, + &polygon, + &polygon_fingerprint, + ); + let polygon_statements = &polygon_package + .verified + .manifest() + .migration_plan + .statements; + assert!(polygon_statements.iter().any(|statement| { + statement.id == "entity.site.field.boundary.column" + && statement.sql.contains(" ADD COLUMN ") + })); + assert!(polygon_statements.iter().any(|statement| { + statement.id == "entity.site.field.boundary.spatial-column.add" + && statement + .sql + .contains("registry_spatial_ext.geometry(MultiPolygon,4326)") + })); + assert!(polygon_statements.iter().any(|statement| { + statement.id == "entity.site.field.boundary.spatial-index.create" + && statement.sql.contains(" USING gist ") + })); + assert!(polygon_statements + .iter() + .all(|statement| !statement.id.contains("spatial-candidates-view"))); + + let polygon_active = apply( + &database, + &polygon_package.verified, + ApplyPrecondition::Successor { current: &active }, + ) + .await + .expect("polygon storage applies without a bbox role"); + assert_ready_target(&database, &polygon_active).await; + assert_eq!(plain_site_rows(&database, &polygon).await, prior_rows); + assert_eq!(revision_snapshots(&database).await, prior_history); + assert_eq!( + table_authority_snapshot(&database, &polygon).await, + prior_authority + ); + assert_spatial_projection_and_index( + &database, + &polygon, + SpatialProjection::Polygon { + expected_non_null: 0, + }, + ) + .await; + assert_no_spatial_candidate_view(&database, &polygon).await; + assert!(!role_exists(&database, &fingerprint_bbox_role).await); + assert_spatial_startup_accepts_exact_identity(&database, &polygon_package, &polygon_active) + .await; + + let bbox_role = provision_postgis_prerequisites( + &database.admin, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("administrator provisions bbox authority for the bbox successor"); + let bbox_package = reviewed_successor_package( + Variant::PolygonWithBbox, + &polygon_active, + &polygon, + &polygon_bbox, + &polygon_bbox_fingerprint, + ); + let bbox_active = apply( + &database, + &bbox_package.verified, + ApplyPrecondition::Successor { + current: &polygon_active, + }, + ) + .await + .expect("polygon bbox successor applies"); + assert_eq!(plain_site_rows(&database, &polygon_bbox).await, prior_rows); + assert_eq!(revision_snapshots(&database).await, prior_history); + assert_spatial_projection_and_index( + &database, + &polygon_bbox, + SpatialProjection::Polygon { + expected_non_null: 0, + }, + ) + .await; + assert_spatial_candidate_view_owner(&database, &polygon_bbox, &bbox_role).await; + + let removed_bbox_package = reviewed_successor_package( + Variant::PolygonNoBbox, + &bbox_active, + &polygon_bbox, + &polygon, + &polygon_fingerprint, + ); + let removed_bbox_active = apply( + &database, + &removed_bbox_package.verified, + ApplyPrecondition::Successor { + current: &bbox_active, + }, + ) + .await + .expect("bbox removal keeps polygon storage"); + assert_ready_target(&database, &removed_bbox_active).await; + assert_eq!(plain_site_rows(&database, &polygon).await, prior_rows); + assert_eq!(revision_snapshots(&database).await, prior_history); + assert_eq!( + table_authority_snapshot(&database, &polygon).await, + prior_authority + ); + assert_spatial_projection_and_index( + &database, + &polygon, + SpatialProjection::Polygon { + expected_non_null: 0, + }, + ) + .await; + assert_no_spatial_candidate_view(&database, &polygon).await; + assert_eq!(bbox_role_holdings(&database, &bbox_role).await, (0, 0, 0)); + + cleanup_bbox_role(&database, &bbox_role).await; + database.cleanup().await; +} + +/// A reviewed polygon contract change rebuilds the generated projection around +/// reviewed work. Valid retained values survive the rebuilt constraint. A +/// retained value outside a successor's authored bbox fails that constraint +/// before the successor can activate. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn polygon_contract_changes_refresh_and_enforce_retained_projection() { + let database = TestDatabase::create(2).await; + let initial = compile_variant(Variant::PolygonNoBbox); + let limited = compile_variant(Variant::PolygonLimit6); + let bounded = compile_variant(Variant::PolygonBounded); + + let bbox_role = provision_postgis_prerequisites( + &database.admin, + &database.migration_role, + &database.runtime_role, + ) + .await + .expect("administrator provisions PostGIS for polygon storage"); + let base_fingerprint = initial_fingerprint(&database, &initial).await; + let limited_fingerprint = initial_fingerprint(&database, &limited).await; + let bounded_fingerprint = initial_fingerprint(&database, &bounded).await; + cleanup_bbox_role(&database, &bbox_role).await; + + let initial_package = publish_and_load( + prepare_package(build_request( + Variant::PolygonNoBbox, + None, + &base_fingerprint, + PackageMigrationPlanInput::InitialCompiledDdl, + )) + .expect("initial polygon package prepares"), + local_context(), + ); + let active = apply( + &database, + &initial_package.verified, + ApplyPrecondition::InitialActivation, + ) + .await + .expect("initial polygon package activates without bbox authority"); + seed_existing_site_and_history(&database, &initial, &active, SeedGeometry::None).await; + let retained = json!({ + "type": "Polygon", + "coordinates": [[[102.0, 15.0], [102.5, 15.0], [102.25, 15.5], [102.0, 15.0]]] + }); + set_polygon_value(&database, &initial, &retained).await; + let prior_history = revision_snapshots(&database).await; + + let limited_package = destructive_spatial_successor_package( + Variant::PolygonLimit6, + &active, + &initial, + &limited, + &limited_fingerprint, + ); + assert_polygon_projection_refresh( + &limited_package, + &[ + "registry_context.valid_crs84_polygon", + ", 6,", + "'-180'::numeric", + "'-90'::numeric", + "'180'::numeric", + "'90'::numeric, 6", + ], + ); + let limited_backup = backup_fixture(&active, b"verified synthetic polygon backup"); + let limited_binding = destructive_binding_path(&limited_package); + let limited_evidence = [DestructiveBackupEvidence::new( + limited_binding, + &limited_backup.binding_file, + )]; + let limited_active = apply_with_evidence( + &database, + &limited_package.verified, + &active, + &limited_evidence, + ) + .await + .expect("valid retained polygons satisfy the refreshed position limit"); + assert_ready_target(&database, &limited_active).await; + assert_eq!(polygon_value(&database, &limited).await, retained); + assert_eq!(revision_snapshots(&database).await, prior_history); + assert_spatial_projection_and_index( + &database, + &limited, + SpatialProjection::Polygon { + expected_non_null: 1, + }, + ) + .await; + assert_polygon_projection_catalog( + &database, + &limited, + &["valid_crs84_polygon", "'-180'::numeric", "'90'::numeric, 6"], + ) + .await; + + let bounded_package = destructive_spatial_successor_package( + Variant::PolygonBounded, + &limited_active, + &limited, + &bounded, + &bounded_fingerprint, + ); + assert_polygon_projection_refresh( + &bounded_package, + &[ + "registry_context.valid_crs84_polygon", + ", 6,", + "'100.0'::numeric", + "'13.0'::numeric", + "'101.0'::numeric", + "'14.0'::numeric, 6", + ], + ); + let bounded_backup = backup_fixture( + &limited_active, + b"verified synthetic bounded polygon backup", + ); + let bounded_binding = destructive_binding_path(&bounded_package); + let bounded_evidence = [DestructiveBackupEvidence::new( + bounded_binding, + &bounded_backup.binding_file, + )]; + let table = &bounded.entities()["site"].physical_table; + assert_value_free( + apply_with_evidence( + &database, + &bounded_package.verified, + &limited_active, + &bounded_evidence, + ) + .await + .err(), + MigrationError::StatementFailed(PostgresFailure { + sqlstate: Some("23514".to_owned()), + table: Some(table.clone()), + column: None, + constraint: Some(format!("{table}_check")), + }), + ); + assert_non_ready_target( + &database, + &limited_active, + &bounded_package.verified, + "failed", + ) + .await; + + database.cleanup().await; +} + /// A role change on a spatial registry moves each candidate view to the bbox /// role of the runtime role it serves with, and the bbox role of the runtime /// role it retires keeps no view, policy, or privilege on the managed @@ -465,7 +768,12 @@ async fn bbox_role_holdings(database: &TestDatabase, role: &SqlIdentifier) -> (i #[derive(Clone, Copy)] enum Variant { + NoGeometry, NoBbox, + PolygonBounded, + PolygonLimit6, + PolygonNoBbox, + PolygonWithBbox, WithBbox, WithBboxLegacyRemoved, } @@ -476,6 +784,60 @@ struct PublishedPackage { verified: VerifiedPackage, } +struct BackupFixture { + _root: tempfile::TempDir, + backup_file: PathBuf, + binding: ExternalBackupBinding, + binding_file: PathBuf, +} + +fn backup_fixture(current: &ExpectedRegistryIdentity, backup_bytes: &[u8]) -> BackupFixture { + let root = tempfile::Builder::new() + .prefix("registry-spatial-contract-backup-") + .tempdir_in( + std::env::temp_dir() + .canonicalize() + .expect("canonical temporary root"), + ) + .expect("backup temporary directory creates"); + let backup_file = root.path().join("polygon.backup"); + fs::write(&backup_file, backup_bytes).expect("backup evidence writes"); + fs::set_permissions(&backup_file, fs::Permissions::from_mode(0o600)) + .expect("backup evidence permissions close"); + let binding = ExternalBackupBinding { + database_id: DATABASE.to_owned(), + prior_package_digest: current.package_digest.clone(), + prior_schema_fingerprint: current.schema_fingerprint.clone(), + backup_file: backup_file.to_str().expect("UTF-8 backup path").to_owned(), + sha256: digest(backup_bytes), + byte_length: backup_bytes.len() as u64, + created_at: OffsetDateTime::now_utc() + .format(&Rfc3339) + .expect("current time formats"), + max_age_seconds: 3_600, + }; + let binding_file = root.path().join("binding.json"); + fs::write(&binding_file, canonical(&binding)).expect("backup binding writes"); + BackupFixture { + _root: root, + backup_file, + binding, + binding_file, + } +} + +fn destructive_binding_path(package: &PublishedPackage) -> &str { + package + .verified + .reviewed_migration_plan() + .expect("destructive package carries a reviewed plan") + .migrations()[0] + .descriptor + .backup_binding_path + .as_deref() + .expect("destructive backup binding path exists") +} + fn publish_and_load( prepared: registry_breg::package::PreparedPackage, context: PackageLoadContext<'_>, @@ -517,7 +879,44 @@ fn project_bytes(digest: &str) -> Vec { } fn module_bytes(variant: Variant) -> Vec { - let spatial_queries = if matches!(variant, Variant::WithBbox | Variant::WithBboxLegacyRemoved) { + let point = if matches!( + variant, + Variant::NoBbox | Variant::WithBbox | Variant::WithBboxLegacyRemoved + ) { + r#", {"id":"location","type":"crs84-point","precision":6,"required":true,"classification":"internal"}"# + } else { + "" + }; + let polygon = match variant { + Variant::PolygonNoBbox | Variant::PolygonWithBbox => { + r#", {"id":"boundary","type":"crs84-polygon","precision":6,"maximumPositions":128,"required":false,"classification":"internal"}"# + } + Variant::PolygonLimit6 => { + r#", {"id":"boundary","type":"crs84-polygon","precision":6,"maximumPositions":6,"required":false,"classification":"internal"}"# + } + Variant::PolygonBounded => { + r#", {"id":"boundary","type":"crs84-polygon","precision":6,"maximumPositions":6,"bbox":{"west":"100.0","south":"13.0","east":"101.0","north":"14.0"},"required":false,"classification":"internal"}"# + } + _ => "", + }; + let (geometry_field, geometry_access) = match variant { + Variant::NoGeometry => ("", ""), + Variant::NoBbox | Variant::WithBbox | Variant::WithBboxLegacyRemoved => ( + r#", "geojson":{"geometryField":"location"}"#, + r#", "location""#, + ), + Variant::PolygonBounded + | Variant::PolygonLimit6 + | Variant::PolygonNoBbox + | Variant::PolygonWithBbox => ( + r#", "geojson":{"geometryField":"boundary"}"#, + r#", "boundary""#, + ), + }; + let spatial_queries = if matches!( + variant, + Variant::WithBbox | Variant::WithBboxLegacyRemoved | Variant::PolygonWithBbox + ) { r#","spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":0.5,"maximumLatitudeSpanDegrees":0.25}}"# } else { "" @@ -528,7 +927,7 @@ fn module_bytes(variant: Variant) -> Vec { r#",{"id":"legacy","type":"string","maximumLength":16,"classification":"internal"}"# }; format!( - r#"{{"apiVersion":"id.registrystack.org/formats/breg/module/v1alpha1","kind":"BRegModule","id":"core","version":"1","recordTypes":[{{"id":"site","primaryDataset":"spatial-migration-registry","route":"sites","mutationMode":"mutable","classification":"internal","fields":[{{"id":"code","type":"string","maximumLength":16,"required":true,"classification":"internal"}},{{"id":"location","type":"crs84-point","precision":6,"required":true,"classification":"internal"}}{legacy}],"geojson":{{"geometryField":"location"}},"accessProfiles":[{{"requiredScopes":"unrestricted","rowBoundaries":"unrestricted", "id":"reader","principalClaim":"principal","operations":["get","list","create","patch"],"readableFields":["code","location"],"writableFields":["code","location"]{spatial_queries}}}]}}]}}"# + r#"{{"apiVersion":"id.registrystack.org/formats/breg/module/v1alpha1","kind":"BRegModule","id":"core","version":"1","recordTypes":[{{"id":"site","primaryDataset":"spatial-migration-registry","route":"sites","mutationMode":"mutable","classification":"internal","fields":[{{"id":"code","type":"string","maximumLength":16,"required":true,"classification":"internal"}}{point}{legacy}{polygon}]{geometry_field},"accessProfiles":[{{"requiredScopes":"unrestricted","rowBoundaries":"unrestricted","id":"reader","principalClaim":"principal","operations":["get","list","create","patch"],"readableFields":["code"{geometry_access}],"writableFields":["code"{geometry_access}]{spatial_queries}}}]}}]}}"# ) .into_bytes() } @@ -691,6 +1090,155 @@ fn metadata_only_source_between( } } +fn reviewed_successor_package( + variant: Variant, + current: &ExpectedRegistryIdentity, + previous: &CompiledRegistry, + candidate: &CompiledRegistry, + final_fingerprint: &str, +) -> PublishedPackage { + let source = metadata_only_source_between(current, previous, candidate, final_fingerprint); + publish_and_load( + prepare_package(build_request( + variant, + Some(¤t.package_digest), + final_fingerprint, + PackageMigrationPlanInput::ReviewedSuccessor { + prior_registry: Box::new(previous.clone()), + prior_schema_fingerprint: current.schema_fingerprint.clone(), + migrations: vec![source], + }, + )) + .expect("reviewed spatial successor package prepares"), + local_context(), + ) +} + +fn destructive_spatial_successor_package( + variant: Variant, + current: &ExpectedRegistryIdentity, + previous: &CompiledRegistry, + candidate: &CompiledRegistry, + final_fingerprint: &str, +) -> PublishedPackage { + let change_set = compiled_registry_change_set(previous, candidate, ¤t.package_digest); + let changes = change_set + .changes + .iter() + .filter(|change| change.class != CompiledRegistryChangeClass::CompatibleAdditive) + .collect::>(); + assert_eq!(changes.len(), 1); + assert_eq!( + changes[0].class, + CompiledRegistryChangeClass::DestructiveOrIrreversible + ); + assert_eq!( + changes[0].code, + CompiledRegistryChangeCode::FieldTypeChanged + ); + assert_eq!(changes[0].target.record_type_id.as_deref(), Some("site")); + assert_eq!(changes[0].target.member_id.as_deref(), Some("boundary")); + + let base = "modules/core/migrations/refresh-polygon-contract"; + let step_path = format!("{base}/steps/rewrite-boundary.sql"); + let pre_path = format!("{base}/assertions/pre.sql"); + let post_path = format!("{base}/assertions/post.sql"); + let entity = &previous.entities()["site"]; + let field = &entity.fields["boundary"]; + let object = ReviewedMigrationObject { + schema: "registry_data".to_owned(), + table: entity.physical_table.clone(), + entity_id: "site".to_owned(), + kind: ReviewedMigrationObjectKind::Field, + member_id: Some("boundary".to_owned()), + physical_name: field.physical_name.clone(), + }; + let descriptor = ReviewedMigrationDescriptor { + id: "refresh-polygon-contract".to_owned(), + change_class: CompiledRegistryChangeClass::DestructiveOrIrreversible, + covers: vec![ReviewedChangeCover::from(changes[0])], + recovery: ReviewedMigrationRecovery::ExactTargetResume, + history: None, + lock_timeout_ms: 10_000, + statement_timeout_ms: 60_000, + steps: vec![ReviewedMigrationStepDescriptor::TransactionalSql { + id: "rewrite-boundary".to_owned(), + sql_path: step_path.clone(), + objects: vec![object], + affected_rows: Some(AffectedRowBounds { min: 1, max: 1 }), + }], + pre_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "pre".to_owned(), + sql_path: pre_path.clone(), + }], + post_assertions: vec![ReviewedMigrationAssertionDescriptor { + id: "post".to_owned(), + sql_path: post_path.clone(), + }], + rehearsal_receipt_path: format!("{base}/rehearsal.json"), + backup_binding_path: Some(format!("{base}/backup.json")), + }; + let rewrite = format!( + "UPDATE registry_data.{} SET {} = {} WHERE {} IS NOT NULL", + entity.physical_table, field.physical_name, field.physical_name, field.physical_name + ); + let assertion = format!( + "SELECT pg_catalog.count(*) >= 0 FROM registry_data.{}", + entity.physical_table + ); + let source = reviewed_source(ReviewedSourceRequest { + descriptor, + current, + final_fingerprint, + steps: vec![(step_path, rewrite)], + pre: (pre_path, assertion.clone()), + post: (post_path, assertion), + row_assertions: vec![RehearsalRowAssertion { + step_id: "rewrite-boundary".to_owned(), + affected_rows: 1, + }], + }); + publish_and_load( + prepare_package(build_request( + variant, + Some(¤t.package_digest), + final_fingerprint, + PackageMigrationPlanInput::ReviewedSuccessor { + prior_registry: Box::new(previous.clone()), + prior_schema_fingerprint: current.schema_fingerprint.clone(), + migrations: vec![source], + }, + )) + .expect("reviewed polygon contract successor package prepares"), + local_context(), + ) +} + +fn assert_polygon_projection_refresh(package: &PublishedPackage, sql_fragments: &[&str]) { + let statements = &package.verified.manifest().migration_plan.statements; + let index = |id: &str| { + statements + .iter() + .position(|statement| statement.id == id) + .unwrap_or_else(|| panic!("migration statement `{id}` is present")) + }; + let index_drop = index("entity.site.field.boundary.spatial-index.drop"); + let column_drop = index("entity.site.field.boundary.spatial-column.drop"); + let column_add = index("entity.site.field.boundary.spatial-column.add"); + let index_add = index("entity.site.field.boundary.spatial-index.create"); + assert!(index_drop < column_drop); + assert!(column_drop < column_add); + assert!(column_add < index_add); + let sql = &statements[column_add].sql; + let mut offset = 0; + for fragment in sql_fragments { + let relative = sql[offset..] + .find(fragment) + .unwrap_or_else(|| panic!("projection SQL omits ordered `{fragment}`: {sql}")); + offset += relative + fragment.len(); + } +} + fn destructive_source_with_recovery_fault( current: &ExpectedRegistryIdentity, prior: &CompiledRegistry, @@ -768,6 +1316,7 @@ fn destructive_source_with_recovery_fault( ], pre: (pre_path, assertion.clone()), post: (post_path, assertion), + row_assertions: Vec::new(), }) } @@ -778,6 +1327,7 @@ struct ReviewedSourceRequest<'a> { steps: Vec<(String, String)>, pre: (String, String), post: (String, String), + row_assertions: Vec, } fn reviewed_source(request: ReviewedSourceRequest<'_>) -> ReviewedMigrationSource { @@ -788,6 +1338,7 @@ fn reviewed_source(request: ReviewedSourceRequest<'_>) -> ReviewedMigrationSourc steps, pre, post, + row_assertions, } = request; let descriptor_path = format!("modules/core/migrations/{}/descriptor.json", descriptor.id); let descriptor_bytes = canonical(&descriptor); @@ -824,7 +1375,7 @@ fn reviewed_source(request: ReviewedSourceRequest<'_>) -> ReviewedMigrationSourc row_count: 1, }], postgres_major: 17, - row_assertions: Vec::new(), + row_assertions, final_schema_fingerprint: final_fingerprint.to_owned(), }; let mut files = steps @@ -863,46 +1414,86 @@ fn reviewed_source(request: ReviewedSourceRequest<'_>) -> ReviewedMigrationSourc } } +enum SeedGeometry { + None, + Point, +} + async fn seed_existing_site_and_history( database: &TestDatabase, registry: &CompiledRegistry, active: &ExpectedRegistryIdentity, + geometry: SeedGeometry, ) { let entity = ®istry.entities()["site"]; let table = quote(&entity.physical_table); let code = quote(&entity.fields["code"].physical_name); - let location = quote(&entity.fields["location"].physical_name); let legacy = quote(&entity.fields["legacy"].physical_name); - let first_snapshot = canonical(&json!({ - "code": "SITE-001", - "legacy": "legacy-1", - "location": {"type": "Point", "coordinates": [100.5, 13.75]} - })); - let second_snapshot = canonical(&json!({ - "code": "SITE-001", - "legacy": "legacy-1", - "location": {"type": "Point", "coordinates": [100.55, 13.76]} - })); - let current_location: Value = json!({"type": "Point", "coordinates": [100.55, 13.76]}); - database - .admin - .execute( - &format!( - "INSERT INTO registry_data.{table} - (record_id, record_revision, record_lifecycle, active_package_revision, - {code}, {location}, {legacy}) - VALUES ($1::text::uuid, 2, 'active', $2, $3, $4::jsonb, $5)" - ), - &[ - &SITE_RECORD, - &active.activation_id, - &"SITE-001", - ¤t_location, - &"legacy-1", - ], - ) - .await - .expect("administrator seeds the existing current Point record"); + let base = json!({"code": "SITE-001", "legacy": "legacy-1"}); + let (first_snapshot, second_snapshot) = match geometry { + SeedGeometry::None => { + database + .admin + .execute( + &format!( + "INSERT INTO registry_data.{table} + (record_id, record_revision, record_lifecycle, + active_package_revision, {code}, {legacy}) + VALUES ($1::text::uuid, 2, 'active', $2, $3, $4)" + ), + &[ + &SITE_RECORD, + &active.activation_id, + &"SITE-001", + &"legacy-1", + ], + ) + .await + .expect("administrator seeds the existing nonspatial Record"); + (canonical(&base), canonical(&base)) + } + SeedGeometry::Point => { + let location = quote(&entity.fields["location"].physical_name); + let first = json!({ + "code": "SITE-001", "legacy": "legacy-1", + "location": {"type": "Point", "coordinates": [100.5, 13.75]} + }); + let second = json!({ + "code": "SITE-001", "legacy": "legacy-1", + "location": {"type": "Point", "coordinates": [100.55, 13.76]} + }); + let current_location = &second["location"]; + database + .admin + .execute( + &format!( + "INSERT INTO registry_data.{table} + (record_id, record_revision, record_lifecycle, + active_package_revision, {code}, {location}, {legacy}) + VALUES ($1::text::uuid, 2, 'active', $2, $3, $4::jsonb, $5)" + ), + &[ + &SITE_RECORD, + &active.activation_id, + &"SITE-001", + current_location, + &"legacy-1", + ], + ) + .await + .expect("administrator seeds the existing current Point record"); + (canonical(&first), canonical(&second)) + } + }; + seed_site_history(database, active, first_snapshot, second_snapshot).await; +} + +async fn seed_site_history( + database: &TestDatabase, + active: &ExpectedRegistryIdentity, + first_snapshot: Vec, + second_snapshot: Vec, +) { for (revision, predecessor, mutation, snapshot) in [ (1_i64, None, "create", first_snapshot), (2_i64, Some(1_i64), "patch", second_snapshot), @@ -931,6 +1522,71 @@ async fn seed_existing_site_and_history( } } +async fn set_polygon_value(database: &TestDatabase, registry: &CompiledRegistry, value: &Value) { + let entity = ®istry.entities()["site"]; + database + .admin + .execute( + &format!( + "UPDATE registry_data.{} SET {} = $2::jsonb + WHERE record_id = $1::text::uuid", + quote(&entity.physical_table), + quote(&entity.fields["boundary"].physical_name), + ), + &[&SITE_RECORD, value], + ) + .await + .expect("administrator sets the retained valid polygon"); +} + +async fn polygon_value(database: &TestDatabase, registry: &CompiledRegistry) -> Value { + let entity = ®istry.entities()["site"]; + database + .admin + .query_one( + &format!( + "SELECT {} FROM registry_data.{} WHERE record_id = $1::text::uuid", + quote(&entity.fields["boundary"].physical_name), + quote(&entity.physical_table) + ), + &[&SITE_RECORD], + ) + .await + .expect("retained polygon reads") + .get(0) +} + +async fn assert_polygon_projection_catalog( + database: &TestDatabase, + registry: &CompiledRegistry, + constraint_fragments: &[&str], +) { + let entity = ®istry.entities()["site"]; + let table = &entity.physical_table; + let constraint: String = database + .admin + .query_one( + "SELECT pg_catalog.pg_get_constraintdef(c.oid) + FROM pg_catalog.pg_constraint AS c + JOIN pg_catalog.pg_class AS t ON t.oid = c.conrelid + JOIN pg_catalog.pg_namespace AS n ON n.oid = t.relnamespace + WHERE n.nspname = 'registry_data' + AND t.relname = $1 + AND c.contype = 'c' + AND pg_catalog.pg_get_constraintdef(c.oid) LIKE '%valid_crs84_polygon%'", + &[table], + ) + .await + .expect("generated polygon contract constraint reads") + .get(0); + for fragment in constraint_fragments { + assert!( + constraint.contains(fragment), + "projection constraint omits `{fragment}`: {constraint}" + ); + } +} + async fn site_rows( database: &TestDatabase, registry: &CompiledRegistry, @@ -955,6 +1611,63 @@ async fn site_rows( .collect() } +async fn plain_site_rows( + database: &TestDatabase, + registry: &CompiledRegistry, +) -> Vec<(String, i64, String, Option)> { + let entity = ®istry.entities()["site"]; + let rows = database + .admin + .query( + &format!( + "SELECT record_id::text, record_revision, {}, {} + FROM registry_data.{} + ORDER BY record_id", + quote(&entity.fields["code"].physical_name), + quote(&entity.fields["legacy"].physical_name), + quote(&entity.physical_table) + ), + &[], + ) + .await + .expect("plain site rows read"); + rows.into_iter() + .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3))) + .collect() +} + +async fn table_authority_snapshot( + database: &TestDatabase, + registry: &CompiledRegistry, +) -> (String, String, bool, bool, String) { + let table = ®istry.entities()["site"].physical_table; + let row = database + .admin + .query_one( + "SELECT owner.rolname, + COALESCE(class.relacl::text, ''), + class.relrowsecurity, + class.relforcerowsecurity, + COALESCE(( + SELECT string_agg( + policy.polname || ':' || policy.polroles::text, + ',' ORDER BY policy.polname + ) + FROM pg_catalog.pg_policy AS policy + WHERE policy.polrelid = class.oid + ), '') + FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace + JOIN pg_catalog.pg_roles AS owner ON owner.oid = class.relowner + WHERE namespace.nspname = 'registry_data' + AND class.relname = $1", + &[table], + ) + .await + .expect("table authority reads"); + (row.get(0), row.get(1), row.get(2), row.get(3), row.get(4)) +} + async fn revision_snapshots(database: &TestDatabase) -> Vec<(i64, Option, Vec)> { database .admin @@ -972,13 +1685,22 @@ async fn revision_snapshots(database: &TestDatabase) -> Vec<(i64, Option, V .collect() } -async fn assert_spatial_projection_and_index(database: &TestDatabase, registry: &CompiledRegistry) { +enum SpatialProjection { + Point, + Polygon { expected_non_null: i64 }, +} + +async fn assert_spatial_projection_and_index( + database: &TestDatabase, + registry: &CompiledRegistry, + expected: SpatialProjection, +) { let entity = ®istry.entities()["site"]; let table = &entity.physical_table; - let geometry_column: String = database + let row = database .admin .query_one( - "SELECT column_name + "SELECT column_name, is_generated FROM information_schema.columns WHERE table_schema = 'registry_data' AND table_name = $1 @@ -987,33 +1709,74 @@ async fn assert_spatial_projection_and_index(database: &TestDatabase, registry: &[table], ) .await - .expect("generated geometry column is catalogued") - .get(0); - let matches_point: bool = database - .admin - .query_one( - &format!( - "SELECT registry_spatial_ext.ST_Intersects( - {}, - registry_spatial_ext.ST_SetSRID( - registry_spatial_ext.ST_MakePoint(100.55, 13.76), - 4326 - ) - ) - FROM registry_data.{} - WHERE record_id = $1::text::uuid", - quote(&geometry_column), - quote(table) - ), - &[&SITE_RECORD], - ) - .await - .expect("generated projection is readable by administrator") - .get(0); - assert!( - matches_point, - "generated geometry stores longitude as X and latitude as Y" - ); + .expect("generated geometry column is catalogued"); + let geometry_column: String = row.get(0); + assert_eq!(row.get::<_, String>(1), "ALWAYS"); + + match expected { + SpatialProjection::Point => { + let matches_point: bool = database + .admin + .query_one( + &format!( + "SELECT registry_spatial_ext.ST_Intersects( + {}, + registry_spatial_ext.ST_SetSRID( + registry_spatial_ext.ST_MakePoint(100.55, 13.76), + 4326 + ) + ) + FROM registry_data.{} + WHERE record_id = $1::text::uuid", + quote(&geometry_column), + quote(table) + ), + &[&SITE_RECORD], + ) + .await + .expect("generated Point projection is readable") + .get(0); + assert!( + matches_point, + "generated geometry stores longitude as X and latitude as Y" + ); + } + SpatialProjection::Polygon { expected_non_null } => { + let boundary = quote(&entity.fields["boundary"].physical_name); + let projected: i64 = database + .admin + .query_one( + &format!( + "SELECT count(*) FROM registry_data.{} + WHERE {boundary} IS NOT NULL AND {} IS NOT NULL", + quote(table), + quote(&geometry_column), + ), + &[], + ) + .await + .expect("polygon field and projection count reads") + .get(0); + assert_eq!(projected, expected_non_null); + + let geometry_type: String = database + .admin + .query_one( + "SELECT pg_catalog.format_type(attribute.atttypid, attribute.atttypmod) + FROM pg_catalog.pg_attribute AS attribute + JOIN pg_catalog.pg_class AS class ON class.oid = attribute.attrelid + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'registry_data' + AND class.relname = $1 + AND attribute.attname = $2", + &[table, &geometry_column], + ) + .await + .expect("generated polygon type reads") + .get(0); + assert!(geometry_type.ends_with("geometry(MultiPolygon,4326)")); + } + } let index_count: i64 = database .admin .query_one( @@ -1031,6 +1794,46 @@ async fn assert_spatial_projection_and_index(database: &TestDatabase, registry: assert_eq!(index_count, 1); } +fn assert_no_compiled_spatial_candidate_view(registry: &CompiledRegistry) { + assert!(registry + .ddl() + .views + .iter() + .all(|view| view.id != "entity.site.spatial-candidates")); + assert!(registry + .ddl() + .statements + .iter() + .all(|statement| !statement.id.contains("spatial-candidates-view"))); +} + +async fn assert_no_spatial_candidate_view(database: &TestDatabase, registry: &CompiledRegistry) { + assert_no_compiled_spatial_candidate_view(registry); + let count: i64 = database + .admin + .query_one( + r"SELECT count(*) + FROM pg_catalog.pg_class AS class + JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'registry_context' + AND class.relname LIKE 'breg_spcand\_%' ESCAPE '\'", + &[], + ) + .await + .expect("spatial candidate view absence reads") + .get(0); + assert_eq!(count, 0); +} + +async fn role_exists(database: &TestDatabase, role: &SqlIdentifier) -> bool { + database + .admin + .query_one("SELECT to_regrole($1) IS NOT NULL", &[&role.as_str()]) + .await + .expect("role existence reads") + .get(0) +} + async fn assert_spatial_candidate_view_owner( database: &TestDatabase, registry: &CompiledRegistry, diff --git a/crates/registry-breg/tests/postgres_spatial_read.rs b/crates/registry-breg/tests/postgres_spatial_read.rs index 454a5bc5fb..4951f6827e 100644 --- a/crates/registry-breg/tests/postgres_spatial_read.rs +++ b/crates/registry-breg/tests/postgres_spatial_read.rs @@ -28,10 +28,11 @@ use registry_breg::compiler::{ use registry_breg::contract::{parse_module_json, parse_project_json, ModuleAssetSource}; use registry_breg::cursor::CursorCodec; use registry_breg::postgres::{ - begin_record_transaction, initialize_registry_state_for_catalog_test, install_compiled_schema, + begin_record_transaction, initialize_compiled_registry_state_for_test, + initialize_registry_state_for_catalog_test, install_compiled_schema, provision_postgis_prerequisites, spatial_bbox_role, ClaimContext, ExpectedManagedCatalog, - PostgresRecordReadService, ReadFaultPoint, RegistryLockKey, RegistryStateTestIdentity, - RowBoundaryContext, RuntimePool, + PostgresRecordMutationService, PostgresRecordReadService, ReadFaultPoint, RegistryLockKey, + RegistryStateTestIdentity, RowBoundaryContext, RuntimePool, }; use registry_breg::runtime_config::PublicOrigin; use registry_platform_audit::AuditProfile; @@ -256,6 +257,244 @@ const SERVICE_REGION_B: &str = "00000000-0000-4000-8000-000000000034"; const PLAIN_POINT: &str = "10000000-0000-4000-8000-000000000001"; const LARGE_NOTE_BYTES: usize = 1_080_000; +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn polygon_records_validate_write_and_intersect_bbox_through_native_and_gis_routes() { + let harness = SpatialHarness::create(compiled_polygon_registry()).await; + let app = harness.router(None, cursor_codec()); + let polygon = json!({"type":"Polygon","coordinates":[ + [[100,13],[101,13],[101,14],[100,14],[100,13]], + [[100.4,13.4],[100.4,13.6],[100.6,13.6],[100.6,13.4],[100.4,13.4]] + ]}); + let multipolygon = json!({"type":"MultiPolygon","coordinates":[ + [[[102,13],[103,13],[103,14],[102,13]]], + [[[104,13],[105,13],[105,14],[104,13]]] + ]}); + let mut patch_target = None; + for (jurisdiction, key, geometry) in [ + ("zone-a", "visible-polygon", polygon.clone()), + ("zone-a", "visible-multipolygon", multipolygon.clone()), + ("zone-b", "hidden-polygon", polygon.clone()), + ] { + let response = create_plot(&app, jurisdiction, key, geometry.clone()).await; + let status = response.status(); + let etag = response.headers().get(header::ETAG).cloned(); + let body = body_json(response).await; + assert_eq!(status, StatusCode::CREATED, "{key}: {body}"); + assert_eq!(body["data"]["domainData"]["boundary"], geometry); + if key == "visible-polygon" { + let id = body["data"]["recordIdentifier"].as_str().unwrap(); + patch_target = Some((id.to_owned(), etag.unwrap())); + } + } + for (key, coordinates) in [ + ( + "bow-tie", + json!([[[100, 13], [101, 14], [100, 14], [101, 13], [100, 13]]]), + ), + ( + "open", + json!([[[100, 13], [101, 13], [101, 14], [100, 14]]]), + ), + ("few", json!([[[100, 13], [101, 13], [100, 13]]])), + ( + "range", + json!([[[181, 13], [101, 13], [101, 14], [181, 13]]]), + ), + ( + "precision", + json!([[[100.0000001, 13], [101, 13], [101, 14], [100.0000001, 13]]]), + ), + ( + "bounds", + json!([[[99, 13], [101, 13], [101, 14], [99, 13]]]), + ), + ( + "size", + json!([[ + [100, 13], + [101, 13], + [102, 13], + [103, 13], + [104, 13], + [104, 14], + [103, 14], + [102, 14], + [101, 14], + [100, 14], + [100, 13] + ]]), + ), + ] { + let response = create_plot( + &app, + "zone-a", + key, + json!({"type":"Polygon","coordinates":coordinates}), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{key}"); + let problem = body_json(response).await; + assert_eq!(problem["code"], "request.invalid", "{key}"); + assert_eq!(problem["fieldPath"], "/data/boundary", "{key}"); + } + let (id, etag) = patch_target.unwrap(); + let mut patch = Request::patch(format!("/v1/records/plots/{id}")) + .header(header::CONTENT_TYPE, "application/json-patch+json") + .header(header::IF_MATCH, etag) + .header("idempotency-key", "patch-bow-tie") + .body(Body::from(json!([{ + "op":"replace","path":"/data/boundary", + "value":{"type":"Polygon","coordinates":[[[100,13],[101,14],[100,14],[101,13],[100,13]]]} + }]).to_string())) + .unwrap(); + patch.extensions_mut().insert(claims(["zone-a"])); + let response = app.clone().call(patch).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(body_json(response).await["fieldPath"], "/0/value"); + + for (bbox, expected) in [ + ("100.2,13.2,100.3,13.3", Some(&polygon)), + ("100.45,13.45,100.55,13.55", None), + ("100,13.5,100,13.5", Some(&polygon)), + ("104.8,13.2,104.9,13.3", Some(&multipolygon)), + ] { + for route in [ + "/v1/records/plots", + "/v1/gis/collections/plot.plot-reader/items", + ] { + let response = send( + &app, + &format!("{route}?bbox={bbox}"), + Some(claims(["zone-a"])), + Some("application/geo+json"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK, "{route} {bbox}"); + let body = body_json(response).await; + let features = body["features"].as_array().unwrap(); + assert_eq!( + features.len(), + usize::from(expected.is_some()), + "{route} {bbox}" + ); + if let Some(expected) = expected { + assert_eq!(&features[0]["geometry"], expected, "{route} {bbox}"); + } + } + } + let denied = send( + &app, + "/v1/records/plots?accessProfile=no-bbox&bbox=100,13,101,14", + Some(claims(["zone-a"])), + None, + ) + .await; + assert_eq!(denied.status(), StatusCode::BAD_REQUEST); + + let landing = send(&app, "/v1/gis", Some(claims(["zone-a"])), None).await; + assert_eq!( + body_json(landing).await["registry"]["profile"], + "breg-gis-bounded-crs84-geometry-bbox-v1" + ); + let entity = &harness.compiled.entities()["plot"]; + let table = quote_identifier(&entity.physical_table); + let index: String = harness.database.admin + .query_one( + "SELECT indexdef FROM pg_indexes WHERE schemaname = 'registry_data' AND tablename = $1 AND indexdef LIKE '% USING gist %'", + &[&entity.physical_table], + ).await.unwrap().get(0); + assert!(index.contains("breg_spgeom_")); + let geometry_column: String = harness.database.admin + .query_one( + "SELECT column_name FROM information_schema.columns WHERE table_schema = 'registry_data' AND table_name = $1 AND is_generated = 'ALWAYS'", + &[&entity.physical_table], + ).await.unwrap().get(0); + let geometry_column = quote_identifier(&geometry_column); + let projected: i64 = harness + .database + .admin + .query_one( + &format!( + "SELECT count(*) FROM registry_data.{table} + WHERE registry_spatial_ext.ST_SRID({geometry_column}) = 4326 + AND registry_spatial_ext.ST_GeometryType({geometry_column}) = 'ST_MultiPolygon'" + ), + &[], + ) + .await + .unwrap() + .get(0); + assert_eq!(projected, 3); + + use registry_breg_client::{BRegBoundingBox, BRegGeoJsonGeometry, BRegGeoJsonListRequest}; + let http = client_http::ClientHttp::start(app, claims(["zone-a"])).await; + let request = BRegGeoJsonListRequest::default() + .bbox(BRegBoundingBox::new("104.8", "13.2", "104.9", "13.3").unwrap()); + let page = http + .client + .list_geojson_records("plots", &request) + .await + .unwrap(); + let features = &page.value.value.features; + assert_eq!(features.len(), 1); + assert!(matches!( + features[0].geometry, + Some(BRegGeoJsonGeometry::MultiPolygon(_)) + )); + drop(http); + harness.cleanup().await; +} + +async fn create_plot( + app: &axum::Router, + jurisdiction: &str, + key: &str, + boundary: Value, +) -> Response { + let mut request = Request::post("/v1/records/plots") + .header(header::CONTENT_TYPE, "application/json") + .header("idempotency-key", key) + .body(Body::from( + json!({"data":{"jurisdiction":jurisdiction,"boundary":boundary}}).to_string(), + )) + .unwrap(); + request.extensions_mut().insert(claims([jurisdiction])); + app.clone().call(request).await.unwrap() +} + +fn compiled_polygon_registry() -> registry_breg::CompiledRegistry { + let project = parse_project_json(br#"{ + "apiVersion":"id.registrystack.org/formats/breg/project/v1alpha1","kind":"BRegProject", + "project":{"id":"spatial-read-registry","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://authoring.example.test"}, + "datasets":[{"id":"test-dataset"}], + "recordTypes":[{ + "id":"plot","primaryDataset":"test-dataset","route":"plots","mutationMode":"mutable","classification":"internal", + "fields":[ + {"id":"jurisdiction","type":"string","required":true,"maximumLength":32,"classification":"internal"}, + {"id":"boundary","type":"crs84-polygon","precision":6,"maximumPositions":10, + "bbox":{"west":"100","south":"13","east":"105","north":"14"},"classification":"internal"} + ], + "geojson":{"geometryField":"boundary"} + }], + "accessProfiles":[{ + "id":"plot-reader","default":true,"principalClaim":"registry_principal","requiredScopes":["registry.read"], + "requiredPurposes":["case-management"],"permissions":{"records":[{ + "recordType":"plot","operations":["create","get","list","patch"], + "readableFields":["jurisdiction","boundary"],"writableFields":["jurisdiction","boundary"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}], + "spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":1.25,"maximumLatitudeSpanDegrees":1.25}} + }]} + },{ + "id":"no-bbox","principalClaim":"registry_principal","requiredScopes":["registry.read"], + "requiredPurposes":["case-management"],"permissions":{"records":[{ + "recordType":"plot","operations":["list"],"readableFields":["boundary"], + "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] + }]} + }] + }"#).expect("polygon project parses"); + compile_project(&project, &[], CompileProfile::Authoring).expect("polygon project compiles") +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn real_postgres_spatial_bbox_reads_preserve_authority_and_geojson_audit() { let harness = SpatialHarness::create(compiled_spatial_registry()).await; @@ -1135,11 +1374,10 @@ impl SpatialHarness { install_compiled_schema(&migration, &compiled, &database.runtime_role) .await .expect("migration installs the complete compiled spatial schema"); - let catalog = ExpectedManagedCatalog::compiled(&compiled); - let identity = initialize_registry_state_for_catalog_test( + let identity = initialize_compiled_registry_state_for_test( &migration, &database.runtime_role, - &catalog, + &compiled, RegistryStateTestIdentity { package_id: PACKAGE_ID, database_id: DATABASE_ID, @@ -1242,6 +1480,15 @@ fn read_router( package_revision: identity.activation_id.clone(), schema_fingerprint: identity.schema_fingerprint.clone(), }); + let mutations = PostgresRecordMutationService::new( + pool.clone(), + registry.clone(), + identity.clone(), + "spatial-read-test", + lock_key, + Duration::from_secs(2), + audit.clone(), + ); let mut records = PostgresRecordReadService::new( pool, registry.clone(), @@ -1264,7 +1511,8 @@ fn read_router( Arc::new(AlwaysReady), cursors, ) - .with_public_origin(PublicOrigin::parse("http://127.0.0.1:18080").expect("public origin")); + .with_public_origin(PublicOrigin::parse("http://127.0.0.1:18080").expect("public origin")) + .with_postgres_mutations(Arc::new(mutations)); router(Arc::new(service)) } diff --git a/crates/registry-breg/tests/value_type_consistency.rs b/crates/registry-breg/tests/value_type_consistency.rs index 2f4fff973e..48a2106bc3 100644 --- a/crates/registry-breg/tests/value_type_consistency.rs +++ b/crates/registry-breg/tests/value_type_consistency.rs @@ -198,6 +198,10 @@ fn compound_value_types_are_refused_at_each_authored_declaration() { "crs84-point", json!({"type": "crs84-point", "precision": 4}), ), + ( + "crs84-polygon", + json!({"type": "crs84-polygon", "precision": 6, "maximumPositions": 32}), + ), ( "structured", json!({ @@ -391,7 +395,7 @@ fn authoring_schemas_expose_scalar_aliases_without_hiding_direct_compound_fields branch .pointer("/properties/type/const") .and_then(Value::as_str), - Some("reference" | "crs84-point" | "structured") + Some("reference" | "crs84-point" | "crs84-polygon" | "structured") ) }) .cloned() @@ -419,6 +423,7 @@ fn authoring_schemas_expose_scalar_aliases_without_hiding_direct_compound_fields for compound_definition in [ "ReferenceFieldSourceSchema", "Crs84PointFieldSourceSchema", + "Crs84PolygonFieldSourceSchema", "StructuredFieldSourceSchema", ] { assert!( @@ -440,43 +445,45 @@ fn authoring_schemas_expose_scalar_aliases_without_hiding_direct_compound_fields "{schema_file} accepts scalar aliases" ); - let mut aliased_compound = source.clone(); - aliased_compound["valueTypes"]["shared-value"] = json!({ - "type": "structured", - "maximumBytes": 256, - "schema": {"type": "object", "additionalProperties": false} - }); - assert!( - !schema.is_valid(&aliased_compound), - "{schema_file} refuses reusable compound aliases" - ); - - for use_site in ["field", "action"] { - let mut direct_compound = if schema_file == REGISTRY_MODULE_SCHEMA_FILE { - module_source(json!({"type": "string", "maximumLength": 24}), use_site) - } else { - project_source(json!({"type": "string", "maximumLength": 24}), use_site) - }; - direct_compound - .as_object_mut() - .unwrap() - .remove("valueTypes"); - let declaration = json!({ - "id": "value", + for compound in [ + json!({ "type": "structured", "maximumBytes": 256, - "schema": {"type": "object", "additionalProperties": false}, - "classification": "internal" - }); - if use_site == "field" { - direct_compound["recordTypes"][0]["fields"][0] = declaration; - } else { - direct_compound["actions"][0]["inputs"][0] = declaration; - } + "schema": {"type": "object", "additionalProperties": false} + }), + json!({"type": "crs84-polygon", "precision": 6, "maximumPositions": 32}), + ] { + let kind = compound["type"].as_str().expect("compound type name"); + let mut aliased_compound = source.clone(); + aliased_compound["valueTypes"]["shared-value"] = compound.clone(); assert!( - schema.is_valid(&direct_compound), - "{schema_file} keeps direct compound {use_site} declarations" + !schema.is_valid(&aliased_compound), + "{schema_file} refuses reusable {kind} aliases" ); + + for use_site in ["field", "action"] { + let mut direct_compound = if schema_file == REGISTRY_MODULE_SCHEMA_FILE { + module_source(json!({"type": "string", "maximumLength": 24}), use_site) + } else { + project_source(json!({"type": "string", "maximumLength": 24}), use_site) + }; + direct_compound + .as_object_mut() + .unwrap() + .remove("valueTypes"); + let mut declaration = compound.clone(); + declaration["id"] = json!("value"); + declaration["classification"] = json!("internal"); + if use_site == "field" { + direct_compound["recordTypes"][0]["fields"][0] = declaration; + } else { + direct_compound["actions"][0]["inputs"][0] = declaration; + } + assert!( + schema.is_valid(&direct_compound), + "{schema_file} keeps direct {kind} {use_site} declarations" + ); + } } } } diff --git a/crates/registry-bregctl/src/hook_lifecycle.rs b/crates/registry-bregctl/src/hook_lifecycle.rs index dc56df4c4e..3dff5e80ba 100644 --- a/crates/registry-bregctl/src/hook_lifecycle.rs +++ b/crates/registry-bregctl/src/hook_lifecycle.rs @@ -8,7 +8,9 @@ use std::collections::BTreeMap; use std::path::Path; -use registry_breg::contract::{Crs84BboxSource, EventTrigger, FieldTypeSource}; +use registry_breg::contract::{ + valid_crs84_polygon, Crs84BboxSource, EventTrigger, FieldTypeSource, +}; use registry_breg::hooks::{ HookDeliveryStatus, HookDeliveryStatusKind, HookDrainReport, HookOperatorError, HookOperatorService, MAX_HOOK_STATUS_RESULTS, @@ -509,10 +511,67 @@ fn synthetic_field_value(field_type: &FieldTypeSource) -> Result polygon_in_bbox(*precision, bbox.as_ref(), *max_positions), FieldTypeSource::Structured { schema, .. } => synthesize_schema(schema, schema, 0), } } +fn polygon_in_bbox( + precision: u8, + bbox: Option<&Crs84BboxSource>, + max_positions: u32, +) -> Result { + if precision > 9 || max_positions < 4 { + return Err(HookLifecycleError::Sample); + } + let coordinates = if let Some(bbox) = bbox { + let scale = 10_i64.pow(u32::from(precision)); + // All CRS84 coordinates scaled to nine decimal places fit exactly + // within the integer precision of f64. + let scaled = |bound: &str| { + bound + .parse::() + .ok() + .filter(|value| value.abs() <= 180.0) + .map(|value| (value * scale as f64).round() as i64) + .ok_or(HookLifecycleError::Sample) + }; + let (west, south, east, north) = ( + scaled(&bbox.west)?, + scaled(&bbox.south)?, + scaled(&bbox.east)?, + scaled(&bbox.north)?, + ); + if west >= east || south >= north { + return Err(HookLifecycleError::Sample); + } + let coordinate = |units: i64| { + if units % scale == 0 { + json!(units / scale) + } else { + json!(units as f64 / scale as f64) + } + }; + let start = json!([coordinate(west), coordinate(south)]); + vec![ + start.clone(), + json!([coordinate(west + 1), coordinate(south)]), + json!([coordinate(west), coordinate(south + 1)]), + start, + ] + } else { + vec![json!([0, 0]), json!([1, 0]), json!([0, 1]), json!([0, 0])] + }; + let value = json!({"type": "Polygon", "coordinates": [coordinates]}); + valid_crs84_polygon(&value, precision, bbox, max_positions) + .then_some(value) + .ok_or(HookLifecycleError::Sample) +} + fn point_in_bbox(bbox: Option<&Crs84BboxSource>) -> Result<(Number, Number), HookLifecycleError> { let coordinate = |minimum: Option<&str>, maximum: Option<&str>| { let minimum = minimum.and_then(|value| value.parse::().ok()); @@ -663,6 +722,110 @@ mod tests { use super::*; use registry_breg::hooks::HookDeliveryFailureReason; + #[test] + fn polygon_sample_is_closed_bounded_and_counted() { + let field_type = FieldTypeSource::Crs84Polygon { + precision: 6, + bbox: Some(Crs84BboxSource { + west: "100.000000".to_owned(), + south: "10.000000".to_owned(), + east: "101.000000".to_owned(), + north: "11.000000".to_owned(), + }), + max_positions: 4, + }; + let value = synthetic_field_value(&field_type).expect("polygon sample synthesizes"); + assert!(registry_breg::contract::valid_crs84_polygon( + &value, + 6, + match &field_type { + FieldTypeSource::Crs84Polygon { bbox, .. } => bbox.as_ref(), + _ => unreachable!(), + }, + 4, + )); + assert_eq!(value["type"], "Polygon"); + assert_eq!(value["coordinates"][0].as_array().unwrap().len(), 4); + } + + #[test] + fn polygon_sample_refuses_bounds_without_one_precision_step() { + for bbox in [ + Crs84BboxSource { + west: "100.000000".to_owned(), + south: "10.000000".to_owned(), + east: "100.000000".to_owned(), + north: "11.000000".to_owned(), + }, + Crs84BboxSource { + west: "100.000000".to_owned(), + south: "10.000000".to_owned(), + east: "101.000000".to_owned(), + north: "10.000000".to_owned(), + }, + ] { + let field_type = FieldTypeSource::Crs84Polygon { + precision: 6, + bbox: Some(bbox), + max_positions: 4, + }; + assert_eq!( + synthetic_field_value(&field_type), + Err(HookLifecycleError::Sample) + ); + } + } + + #[test] + fn polygon_sample_emits_plain_numbers_at_precision_zero() { + let field_type = FieldTypeSource::Crs84Polygon { + precision: 0, + bbox: Some(Crs84BboxSource { + west: "100".to_owned(), + south: "10".to_owned(), + east: "101".to_owned(), + north: "11".to_owned(), + }), + max_positions: 4, + }; + let value = synthetic_field_value(&field_type).expect("integer polygon synthesizes"); + let rendered = serde_json::to_string(&value).expect("polygon serializes"); + assert!(!rendered.contains("100.0"), "{rendered}"); + assert!(registry_breg::contract::valid_crs84_polygon( + &value, + 0, + match &field_type { + FieldTypeSource::Crs84Polygon { bbox, .. } => bbox.as_ref(), + _ => unreachable!(), + }, + 4, + )); + } + + #[test] + fn polygon_sample_supports_fine_scale_precision_nine_coordinates() { + let field_type = FieldTypeSource::Crs84Polygon { + precision: 9, + bbox: Some(Crs84BboxSource { + west: "0.000000001".to_owned(), + south: "0.000000001".to_owned(), + east: "0.000000002".to_owned(), + north: "0.000000002".to_owned(), + }), + max_positions: 4, + }; + let value = synthetic_field_value(&field_type).expect("fine-scale polygon synthesizes"); + assert!(registry_breg::contract::valid_crs84_polygon( + &value, + 9, + match &field_type { + FieldTypeSource::Crs84Polygon { bbox, .. } => bbox.as_ref(), + _ => unreachable!(), + }, + 4, + )); + } + #[test] fn structured_sample_synthesis_is_deterministic_and_uses_required_typed_properties() { let schema = json!({ diff --git a/crates/registry-bregctl/src/identity_facts.rs b/crates/registry-bregctl/src/identity_facts.rs index 64370df215..399ba97b43 100644 --- a/crates/registry-bregctl/src/identity_facts.rs +++ b/crates/registry-bregctl/src/identity_facts.rs @@ -421,6 +421,7 @@ fn refuse_unissuable_claim( FieldTypeSource::Boolean => "boolean", FieldTypeSource::Int64 => "int64", FieldTypeSource::Crs84Point { .. } => "crs84-point", + FieldTypeSource::Crs84Polygon { .. } => "crs84-polygon", FieldTypeSource::Structured { .. } => "structured", _ => return Ok(()), }; diff --git a/crates/registry-bregctl/src/lib.rs b/crates/registry-bregctl/src/lib.rs index 36b4de7272..0ab5ac63f3 100644 --- a/crates/registry-bregctl/src/lib.rs +++ b/crates/registry-bregctl/src/lib.rs @@ -11205,7 +11205,9 @@ fn filter_literal(field_type: &FieldTypeSource) -> Option { FieldTypeSource::VocabularyCode { values, .. } => { values.first().map(|value| quote_filter_string(value)) } - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => None, + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => None, } } @@ -11232,7 +11234,9 @@ fn alternate_filter_literal(field_type: &FieldTypeSource) -> Option { FieldTypeSource::VocabularyCode { values, .. } => { values.get(1).map(|value| quote_filter_string(value)) } - FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } => None, + FieldTypeSource::Crs84Point { .. } + | FieldTypeSource::Crs84Polygon { .. } + | FieldTypeSource::Structured { .. } => None, } } diff --git a/crates/registry-bregctl/tests/cli.rs b/crates/registry-bregctl/tests/cli.rs index f9e9fe41b8..15ee88b93f 100644 --- a/crates/registry-bregctl/tests/cli.rs +++ b/crates/registry-bregctl/tests/cli.rs @@ -6104,6 +6104,41 @@ accessProfiles: assert!(plain.get("spatialQueries").is_none()); } +#[test] +fn explain_model_reports_polygon_type_and_position_bound() { + let project = TestProject::from_registry_source( + br#"apiVersion: id.registrystack.org/formats/breg/project/v1alpha1 +kind: BRegProject +project: {id: polygon-explanation, version: "1", defaultLanguage: en, canonicalBaseIri: https://polygon-explanation.example.test} +datasets: + - id: test-dataset +recordTypes: + - id: service-area + primaryDataset: test-dataset + route: service-areas + mutationMode: create-only + geojson: {geometryField: boundary} + fields: + - {id: boundary, type: crs84-polygon, precision: 6, maximumPositions: 128, classification: internal} +"#, + ); + let output = bregctl(&["--format", "json", "explain", "model", path(project.path())]); + assert!(output.status.success(), "{output:?}"); + let report = json_stdout(&output); + assert_eq!( + report["explanation"]["recordTypes"][0]["fields"][0]["fieldType"], + json!({ + "type": "crs84-polygon", + "precision": 6, + "maximumPositions": 128 + }) + ); + assert_eq!( + report["explanation"]["recordTypes"][0]["geojson"]["geometryField"], + "boundary" + ); +} + #[test] fn check_reports_derived_sql_module_path_without_sql_values() { let project = TestProject::from_registry_source( diff --git a/crates/registry-stack-client-node/breg/client.d.ts b/crates/registry-stack-client-node/breg/client.d.ts index f9f72a039a..a4cbb59ebe 100644 --- a/crates/registry-stack-client-node/breg/client.d.ts +++ b/crates/registry-stack-client-node/breg/client.d.ts @@ -351,14 +351,24 @@ export interface JsonOutcome { validAt?: string } +export type GeoJsonPosition = readonly [longitude: number, latitude: number] export interface GeoJsonPoint { readonly type: 'Point' - readonly coordinates: readonly [number, number] + readonly coordinates: GeoJsonPosition } +export interface GeoJsonPolygon { + readonly type: 'Polygon' + readonly coordinates: ReadonlyArray> +} +export interface GeoJsonMultiPolygon { + readonly type: 'MultiPolygon' + readonly coordinates: ReadonlyArray>> +} +export type GeoJsonGeometry = GeoJsonPoint | GeoJsonPolygon | GeoJsonMultiPolygon export interface GeoJsonFeature { readonly type: 'Feature' readonly id: string - readonly geometry: GeoJsonPoint | null + readonly geometry: GeoJsonGeometry | null readonly properties: Readonly> readonly registry: { readonly revision: SafeInteger } } diff --git a/docs/site/src/content/docs/configure/breg.mdx b/docs/site/src/content/docs/configure/breg.mdx index 0f2e4b70aa..b4d0fd4301 100644 --- a/docs/site/src/content/docs/configure/breg.mdx +++ b/docs/site/src/content/docs/configure/breg.mdx @@ -112,13 +112,28 @@ every key with its type and bounds. Each [Record type](../../reference/glossary/#record-type) names its route, its dataset, and its fields. Every field declares its type, bounds, requiredness, and classification; `typeRef` reuses scalar constraints from `valueTypes`, and `apiName` sets the public property name. In both projects -and modules, declare reference, `crs84-point`, and structured types directly on each field or -Action input instead of through `valueTypes`. `mutationPolicy` here means +and modules, declare reference, `crs84-point`, `crs84-polygon`, and structured types directly on +each field or Action input instead of through `valueTypes`. `mutationPolicy` here means a facility changes only through a [Proposal](../../reference/glossary/#proposal), even for a caller whose profile grants `patch`. +A Record type can declare multiple stored geometry fields. Its `geojson.geometryField` binding +selects one CRS84 Point or polygon field as the primary GeoJSON geometry. A polygon field accepts +GeoJSON Polygon and MultiPolygon values. A polygon field requires `precision` from 0 through 9 and +`maximumPositions` from 4 through 10,000. The position limit counts every ring and part, including +closing positions. Set `geojson.geometryField` to that field when you want GeoJSON and GIS reads. +The [configuration reference](../../reference/breg-configuration/) lists the optional coordinate +bounds and the other field members. + +Polygon values use strict two-dimensional GeoJSON with only `type` and `coordinates`. BReg refuses +foreign members, invalid topology, positions beyond the field's precision or optional bounds, and +an edge whose longitude changes by more than 180 degrees. Split a shape at the antimeridian and +send the parts as a MultiPolygon. BReg preserves ring order and winding and derives no area field. + {/* Evidence: crates/registry-breg/src/authoring_integrity.rs, add_value_type_diagnostics(); - crates/registry-breg/tests/value_type_consistency.rs. */} + crates/registry-breg/src/contract.rs, FieldTypeSource and valid_crs84_polygon(); + crates/registry-breg/tests/value_type_consistency.rs; + products/breg/acceptance/spatial-service-sites/registry.yaml. */} ## Grant access diff --git a/docs/site/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index d6da4e625f..506e6477dd 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -81,8 +81,13 @@ Registry Render. Base Registry Engine serves one governed registry from a PostgreSQL database it owns, under a package an operator activates deliberately. Its current limits are: -- PostgreSQL only: the store requires `btree_gist`, plus PostGIS where the registry declares - spatial fields. There is no SQLite, other SQL engine, file, or HTTP source adapter. +- PostgreSQL only: the store requires `btree_gist`. Polygon and MultiPolygon fields always require + PostGIS. Point fields require it only when a profile grants bbox queries. There is no SQLite, + other SQL engine, file, or HTTP source adapter. +- Spatial fields are two-dimensional CRS84 only. A Record type can store multiple geometry fields. + The `geojson.geometryField` member binds at most one as its primary GeoJSON geometry. A Polygon cannot + cross the antimeridian in one ring. Split it into MultiPolygon parts. BReg computes no area or + collection extent. - Audit is outside PostgreSQL: each process writes its own file or `stdout` stream. A crash after a mutation commits can leave a request entry without its response. Revision history still records the change; external append-only storage supplies audit tamper evidence and completeness. diff --git a/docs/site/src/content/docs/operate/breg.mdx b/docs/site/src/content/docs/operate/breg.mdx index 19e73ba8e2..00be826efa 100644 --- a/docs/site/src/content/docs/operate/breg.mdx +++ b/docs/site/src/content/docs/operate/breg.mdx @@ -45,11 +45,12 @@ occupied by the link; choose another unused, non-symbolic-link name under the re - **PostgreSQL 17 or newer**, reached over TLS, with two login roles: a migration role that owns the schema and applies packages, and a runtime role the serving process uses. A project with a - `crs84-point` field also needs PostGIS 3.5 or newer. Your administrator creates the roles, - schemas, and extensions; the product never creates any of them. SQLite is not a target. + `crs84-polygon` field always needs PostGIS 3.5 or newer. A `crs84-point` field needs PostGIS only + when an access profile grants bbox queries. Your administrator creates the roles, schemas, and + extensions. The product never creates any of them. SQLite is not a target. Ubuntu 24.04 ships PostgreSQL 16 and Debian 12 ships PostgreSQL 15, both too old: install 17 from the PostgreSQL project's apt repository (PGDG), or run the `postgres:17` image - (`postgis/postgis` at PostgreSQL 17 when the project has a `crs84-point` field). + (`postgis/postgis` at PostgreSQL 17 when the project requires PostGIS). - **A host for one process.** The runtime is a single binary that verifies one package and serves its REST API against one database, on the platforms in [platform support](../../explanation/known-limitations/#platform-support). @@ -182,8 +183,9 @@ with `migration role has database-level administrative authority`, which is also role that is a superuser or has `BYPASSRLS`, `CREATEDB`, or `CREATEROLE`. If a managed PostgreSQL service created the database for you, check its owner and its grants first. -For a spatial project, add a bounding-box role named after the runtime role with the suffix -`__spatial_bbox`, and install PostGIS in a schema neither registry role owns: +For a project with polygon storage or bbox queries, install PostGIS in a schema neither registry +role owns. If the project grants bbox queries, also add a bounding-box role named after the runtime +role with the suffix `__spatial_bbox`: ```sql CREATE ROLE registry_runtime__spatial_bbox NOLOGIN NOSUPERUSER NOCREATEDB @@ -199,6 +201,10 @@ GRANT USAGE ON SCHEMA registry_spatial_ext TO registry_migration, registry_runtime, registry_runtime__spatial_bbox; ``` +A package that stores polygons and grants no bbox queries omits the bbox role, its membership, and +its name from the final `GRANT USAGE`. Keep the PostGIS schema, extension, and grants to the +migration and runtime roles. + Size connections, timeouts, and statement statistics once the registry serves; see [Set a PostgreSQL baseline](../breg-monitor/#set-a-postgresql-baseline). diff --git a/docs/site/src/content/docs/reference/breg-api.mdx b/docs/site/src/content/docs/reference/breg-api.mdx index d172c1a52f..aab3a30bc5 100644 --- a/docs/site/src/content/docs/reference/breg-api.mdx +++ b/docs/site/src/content/docs/reference/breg-api.mdx @@ -711,13 +711,25 @@ generation; discard stops future attempts and cannot undo an earlier acceptance. ## Spatial adapter -A Record type with a `crs84-point` field and a `geojson.geometryField` is also served under -`/v1/gis` for desktop GIS clients, with `bbox`, `limit` (at most 10000), and `cursor` on the item -route. It answers GeoJSON feature collections under the same profile, grant, and row-boundary rules -as the record routes and declares the profile `breg-gis-bounded-crs84-point-bbox-v1`. It makes no -OGC API Features conformance claim and computes no collection extents. +A Record type with a `crs84-point` or `crs84-polygon` field and a `geojson.geometryField` is also +served under `/v1/gis` for desktop GIS clients, with `bbox`, `limit` (at most 10000), and `cursor` +on the item route. The polygon field accepts GeoJSON Polygon and MultiPolygon values. The adapter +answers GeoJSON feature collections under the same profile, grant, readable-field, and row-boundary +rules as the record routes. + +The landing document declares `breg-gis-bounded-crs84-point-bbox-v1` when the caller sees only +Point collections. It declares `breg-gis-bounded-crs84-geometry-bbox-v1` when at least one visible +collection uses a Polygon or MultiPolygon. Point collection details keep +`bounded-crs84-point-bbox`. Polygon collection details declare `bounded-crs84-polygon-bbox`. + +A bbox match is inclusive and uses actual geometry intersection. A Polygon or MultiPolygon whose +envelope overlaps the box does not match unless its geometry does. The adapter makes no OGC API +Features conformance claim, computes no collection extents or areas, and does not change ring order +or winding. {/* Evidence: crates/registry-breg/src/api/gis.rs; + crates/registry-breg/src/generated_ddl.rs; + crates/registry-breg/src/postgres/read.rs; products/breg/acceptance/spatial-service-sites/README.md. */} ## Statistical datasets diff --git a/docs/site/src/content/docs/reference/breg-client-capabilities.mdx b/docs/site/src/content/docs/reference/breg-client-capabilities.mdx index 61c979892b..83899b2e0a 100644 --- a/docs/site/src/content/docs/reference/breg-client-capabilities.mdx +++ b/docs/site/src/content/docs/reference/breg-client-capabilities.mdx @@ -149,8 +149,9 @@ let page = client.list_records("establishments", &request).await?; for it. Use the GeoJSON methods when you need a Feature or FeatureCollection. Leaving the geometry field out -of `select` yields null geometry. The `/v1/gis` adapter routes serve QGIS directly and have no client -methods. +of `select` yields null geometry. A configured geometry can be a CRS84 Point, Polygon, or +MultiPolygon. The clients preserve the geometry returned by the server. The `/v1/gis` adapter +routes serve QGIS directly and have no client methods. {/* Evidence: crates/registry-breg-client/src/query.rs, BRegBoundingBox, MAX_BBOX_PARAMETER_BYTES, crates/registry-breg-client/src/geojson.rs, diff --git a/docs/site/src/data/breg-api.yaml b/docs/site/src/data/breg-api.yaml index 4f5e40573c..7fdb61c3be 100644 --- a/docs/site/src/data/breg-api.yaml +++ b/docs/site/src/data/breg-api.yaml @@ -31,12 +31,12 @@ - ['`POST /v1/actions/{action}/availability`', '`invoke`', 'Immediate Action has at least one reference input', 'Advisory availability for one referenced target: `true` or `false` with its observed revision, or `null` when more input is required or the current profile cannot inspect the guard safely.'] - ['`POST /v1/actions/{action}`', '`invoke`', 'Action mode is immediate and the caller holds Invoke', '`{"action", "applicationId", "results"}`. Requires `Idempotency-Key`.'] - ['`POST /v1/review-completions`', 'review completion receiver', 'A review authority configures a completion token and recipient binding', '204 after durable acceptance or an exact replay. Requires bearer authentication, `Registry-Recipient-Binding`, and an `Idempotency-Key` equal to the completion event ID.'] - - ['`GET /v1/gis`', 'GIS adapter', '`listener.publicOrigin` is configured; the response includes only GIS collections visible to the caller', 'Landing document with links to the service description, conformance document, and collection inventory.'] + - ['`GET /v1/gis`', 'GIS adapter', '`listener.publicOrigin` is configured; the response includes only GIS collections visible to the caller', 'Landing document with links to the service description, conformance document, and collection inventory. Declares `breg-gis-bounded-crs84-point-bbox-v1` for Point-only visibility or `breg-gis-bounded-crs84-geometry-bbox-v1` when a polygon collection is visible.'] - ['`GET /v1/gis/api`', 'GIS adapter', '`listener.publicOrigin` is configured; the response includes only GIS collections visible to the caller', 'Caller-filtered OpenAPI 3.0 description for the visible collection item routes.'] - ['`GET /v1/gis/conformance`', 'GIS adapter', '`listener.publicOrigin` is configured', 'An empty `conformsTo` array; the bounded adapter makes no OGC conformance claim.'] - ['`GET /v1/gis/collections`', 'GIS adapter', '`listener.publicOrigin` is configured; the response includes only GIS collections visible to the caller', 'Collection inventory for direct `list` profiles with readable primary geometry and an explicit `bbox` grant.'] - - ['`GET /v1/gis/collections/{collection}`', 'GIS adapter', '`listener.publicOrigin` is configured and the caller reaches the governed GIS collection', 'Collection detail with the Record type, access profile, primary geometry, page bound, fields, and item link.'] - - ['`GET /v1/gis/collections/{collection}/items`', '`list` through the GIS adapter', '`listener.publicOrigin` is configured and the caller reaches the governed GIS collection', 'Authorized GeoJSON FeatureCollection page. Accepts `bbox`, `limit`, `cursor`, and `f=json`.'] + - ['`GET /v1/gis/collections/{collection}`', 'GIS adapter', '`listener.publicOrigin` is configured and the caller reaches the governed GIS collection', 'Collection detail with the Record type, access profile, primary geometry, page bound, fields, item link, and `bounded-crs84-point-bbox` or `bounded-crs84-polygon-bbox` profile.'] + - ['`GET /v1/gis/collections/{collection}/items`', '`list` through the GIS adapter', '`listener.publicOrigin` is configured and the caller reaches the governed GIS collection', 'Authorized GeoJSON FeatureCollection page. Accepts `bbox`, `limit`, `cursor`, and `f=json`; bbox edges are inclusive and polygon matches use actual geometry intersection.'] - ['`POST /v1/actions/{action}/proposals`', '`start`', 'Action mode is proposal and the caller holds Start with target authority', 'Durable Proposal with its current revision and promoted links. Requires `Idempotency-Key`.'] - ['`GET /v1/proposals`', '`list`', 'Caller holds explicit Proposal List authority', '`{"items", "nextCursor"}` page of the Proposals the caller may see. Accepts only `accessProfile`, `action`, `owner=caller`, `state`, `turn`, `pendingManualApplication` (`true` or `false`), `target` (a record UUID), `cursor`, and `limit` (1 to 100, default 50), each at most once. `pendingManualApplication=true` selects locally pending manual applications before pagination, without a remote Review read. `top` answers 400 `query.invalid`; any other key, a repeated key, or a bad value answers the concealed 404. `action` and `state` narrow the candidates before the scan budget is spent. A page may hold fewer than `limit` items.'] - ['`GET /v1/proposals/{proposal_id}`', '`get`', 'Caller holds Proposal Get and current ownership/target scope', 'Proposal with its frozen binding, application status, authorized links, and `ETag`.'] @@ -60,7 +60,7 @@ - ['`$top`', 'Collection reads', 'Page size.', '100.'] - ['`$count`', 'Collection reads', 'Adds the matching count. Refused unless the grant sets `allowCount`.', 'Grant flag.'] - ['`$skiptoken`', 'Collection reads', 'Continuation from `pageInfo.nextCursor`. It carries the original query, so repeating `snapshot` or `validAt` beside it is refused.', 'Expires after `cursor.maximumAgeSeconds`.'] - - ['`bbox`', '`list` on Record types with a `crs84-point` field', '`west,south,east,north` in CRS84 degrees, permitted by `spatialQueries.bbox` on the grant.', '256 bytes, plus the span limits on the grant.'] + - ['`bbox`', '`list` on Record types with a `crs84-point` or `crs84-polygon` field', '`west,south,east,north` in CRS84 degrees, permitted by `spatialQueries.bbox` on the grant. Edges are inclusive; Polygon and MultiPolygon values match by actual intersection, not envelope overlap alone.', '256 bytes, plus the span limits on the grant.'] - ['`asOf`', '`:as-of`', 'The valid-time instant to evaluate, in the type of the Record type''s valid-time fields.', 'One value.'] - ['`snapshot`', '`:snapshot`', 'A `snapshot` value from a Batch response or an earlier `:snapshot` read.', '4096 bytes.'] - ['`validAt`', '`:snapshot`', 'The valid-time instant to evaluate inside the snapshot, for temporal Record types.', 'One value.'] @@ -140,6 +140,7 @@ - ['`vocabulary-code`', 'string', 'One of the vocabulary values; the schema carries them as an enum with `x-registry-vocabulary`.'] - ['`reference`', 'string', 'The target record identifier as a UUID.'] - ['`crs84-point`', 'object', 'GeoJSON Point, `{"type": "Point", "coordinates": [longitude, latitude]}`.'] + - ['`crs84-polygon`', 'object', 'Strict two-dimensional GeoJSON Polygon or MultiPolygon with only `type` and `coordinates`; `maximumPositions` counts every ring and part, including closing positions.'] - ['`structured`', 'object', 'Validated against the declared schema, bounded by `maximumBytes`, advertised as `x-registry-maxBytes`.'] - id: statistical-dataset-routes title: Statistical dataset routes diff --git a/docs/site/src/data/standards.yaml b/docs/site/src/data/standards.yaml index 6953ad48d4..fbd666709e 100644 --- a/docs/site/src/data/standards.yaml +++ b/docs/site/src/data/standards.yaml @@ -132,17 +132,21 @@ standards_body: Internet Engineering Task Force official_url: https://www.rfc-editor.org/rfc/rfc7946 version_or_profile: RFC 7946 - status: referenced - claim_level: compares_against - adoption_mode: not adopted - used_by: [] + status: used + claim_level: emits + adoption_mode: profiled + used_by: + - registry-breg surfaces: + - Base Registry Engine Record and GIS GeoJSON representations - retired Relay wire format evidence_docs: + - label: Base Registry Engine API reference + url: /reference/breg-api/ - label: Registry Relay v0.38.0 product record url: https://github.com/registrystack/registry-stack/blob/v0.38.0/products/relay-v2/README.md - last_checked: 2026-10-03 - notes: The maintained Registry Stack has no GeoJSON service surface. Registry Relay emitted a bounded GeoJSON Point profile through v0.38.0. + last_checked: 2026-11-11 + notes: Base Registry Engine emits a bounded CRS84 GeoJSON Point, Polygon, and MultiPolygon profile under configured Record and GIS routes. It does not claim full RFC 7946 conformance. Registry Relay emitted its separate bounded Point profile through v0.38.0. - id: json-fg name: OGC Features and Geometries JSON standards_body: Open Geospatial Consortium diff --git a/products/breg/CHANGELOG.md b/products/breg/CHANGELOG.md index a6b6783269..e593ae3bb0 100644 --- a/products/breg/CHANGELOG.md +++ b/products/breg/CHANGELOG.md @@ -5,6 +5,22 @@ - `bregctl project-dev` runs BReg projects, including spatial projects, from a `breg/project-dev` v1alpha1 plan. It is an alpha format and may change. `rstack dev` is its entry point. +- A `crs84-polygon` field stores a bounded `Polygon` or `MultiPolygon` in + WGS 84 longitude and latitude. It takes `precision`, `maximumPositions` and + an optional `bbox`, and the compiler validates it with PostGIS and generates + a stored geometry column with a GiST index. A bbox grant in an access + profile filters Polygon and MultiPolygon fields by bounding-box + intersection, as it does for a Point. The GIS profile and the spatial + clients carry the new geometry types. +- BREAKING: The geometry of a GeoJSON Feature in the Rust, Node.js and Python + clients is a union of Point, Polygon and MultiPolygon, where it was always + a Point. The wire format of a Point is unchanged. In Rust, `geometry` is + `Option` instead of `Option`; match + `BRegGeoJsonGeometry::Point(point)` before reading `coordinates`. In + TypeScript, `GeoJsonGeometry` replaces `GeoJsonPoint` on a feature; narrow + on `geometry.type === "Point"` before reading `coordinates`. In Python the + `.pyi` stubs describe the union, so a type checker flags code that assumes a + Point. ## 0.46.0 - 2026-11-08 diff --git a/products/breg/SPATIAL-QUERIES.md b/products/breg/SPATIAL-QUERIES.md index c5c5d79559..5fdff5a610 100644 --- a/products/breg/SPATIAL-QUERIES.md +++ b/products/breg/SPATIAL-QUERIES.md @@ -1,26 +1,54 @@ -# Point queries and QGIS +# CRS84 geometry queries and QGIS -Base Registry Engine can expose a stored CRS84 Point as GeoJSON and grant bounded -current-record bounding-box queries. PostGIS is required for every spatial -predicate. Ordinary Point storage, validation, history and GeoJSON reads that -do not use a spatial predicate work without PostGIS. +Base Registry Engine can expose a stored CRS84 Point, Polygon or MultiPolygon +as GeoJSON and grant bounded current-record bounding-box queries. PostGIS is +required for every spatial predicate. Polygon and MultiPolygon storage always +requires PostGIS. Ordinary Point storage, validation, history and GeoJSON reads +that do not use a spatial predicate work without it. Start with the [spatial quickstart](quickstart/README.md#spatial-service-site-quickstart) for a complete project, local database bootstrap, stock-issuer client, and protected GeoJSON smoke. The [service-site fixture](acceptance/spatial-service-sites/README.md) -contains public and protected map profiles, a hidden-geometry profile, a -get-only profile and synthetic edge/null cases. +contains public and protected Point map profiles, a separate Polygon collection, +a hidden-geometry profile, a get-only profile and synthetic edge/null cases. -## Author one geometry and an explicit query grant +## Author stored geometries and an explicit query grant -On an entity with a stored `crs84-point` field, declare its primary geometry: +Each Record type may declare multiple stored geometry fields. Choose one stored +`crs84-point` or `crs84-polygon` field as the primary GeoJSON geometry through +`geojson.geometryField`. A polygon field accepts both Polygon and MultiPolygon +values: ```yaml +fields: + - id: boundary + type: crs84-polygon + precision: 9 + maximumPositions: 10000 + bbox: + west: "100.450000000" + south: "13.600000000" + east: "100.700000000" + north: "13.900000000" + geojson: - geometryField: location + geometryField: boundary ``` -On the existing access-profile grant for that entity, add: +`precision` is required and ranges from 0 through 9. `maximumPositions` is +required and ranges from 4 through 10,000, counting every position in every +ring and Polygon part, including each closing position. The optional `bbox` +uses the same west, south, east and north bounds as a Point field. + +The value is a closed two-dimensional GeoJSON object with only `type` and +`coordinates`. Its type is `Polygon` or `MultiPolygon`. Rings contain at least +four positions and must close exactly. Foreign members, a third coordinate, +invalid topology and a consecutive longitude change greater than 180 degrees +are refused. Split a shape at the antimeridian and represent its parts as a +MultiPolygon. BReg preserves the geometry, ring order and winding the caller +provided. It does not derive or store an area value. + +On the existing access-profile grant for that Record type, add: ```yaml spatialQueries: @@ -30,14 +58,15 @@ spatialQueries: ``` The grant needs direct `list` authority and must allow reading the primary -Point. Span limits must be positive finite numbers, at most 360 longitude +geometry. Span limits must be positive finite numbers, at most 360 longitude degrees and 180 latitude degrees. Fractional limits are supported. Geometry -selection belongs to the entity; the bbox grant does not repeat a field name. +selection belongs to the Record type. The bbox grant does not repeat a field +name. -Reading a Point does not grant bbox access. Installing PostGIS does not grant +Reading a geometry does not grant bbox access. Installing PostGIS does not grant access either. Existing scope, purpose, row, field and count restrictions still -apply to the exact selected profile. Points cannot be declared as scalar -filter, sort, selector or row-boundary fields. +apply to the exact selected profile. Geometry fields cannot be declared as +scalar filter, sort, selector or row-boundary fields. ## Native requests and responses @@ -49,7 +78,10 @@ Accept: application/geo+json ``` The order is west, south, east, north in longitude/latitude degrees. Edges are -inclusive. A zero-width or zero-height box is valid; null Points do not match. +inclusive. A zero-width or zero-height box is valid. Null geometries do not +match. A Polygon or MultiPolygon matches only when its actual geometry +intersects the box. An overlap between their bounding envelopes alone is not a +match. The input accepts four finite decimal or exponent values with a leading digit. Both the input and its canonical decimal form are bounded to 256 bytes, so an extreme exponent can exceed the budget even when its input text is short. @@ -59,20 +91,21 @@ combined with permitted scalar filters using AND. It is unavailable on historical reads, lookups and relationship read paths. `Accept: application/json` retains the native JSON representation. GeoJSON -`get` returns a Feature; a direct list returns a FeatureCollection. A feature's -`id` is the native record ID, `geometry` is the selected Point and `properties` -contains the other selected logical fields, including authorized derived -fields. Revision metadata is in `registry.revision`. +`get` returns a Feature. A direct list returns a FeatureCollection. A feature's +`id` is the native record ID, `geometry` is the selected primary geometry and +`properties` contains the other selected logical fields, including authorized +derived fields. Revision metadata is in `registry.revision`. -If `$select` omits the readable primary Point, GeoJSON emits `geometry: null`. -A profile that cannot read the primary Point cannot request this representation +If `$select` omits the readable primary geometry, GeoJSON emits `geometry: null`. +A profile that cannot read the primary geometry cannot request this representation or use bbox. A GeoJSON get does not supply a mutation ETag. Follow `registry.pageInfo.nextCursor` with the native `$skiptoken` option and -normal authorization on every request. A cursor binds the effective query, selection, page size, -profile, authority, package and representation. Do not change those options -on continuation. Pages read live data, not a frozen snapshot; concurrent writes -can affect traversal. Count remains separately authorized and opt-in. +normal authorization on every request. A cursor binds the effective query, +selection, page size, profile, authority, package and representation. Do not +change those options on continuation. Pages read live data, not a frozen +snapshot. Concurrent writes can affect traversal. Count remains separately +authorized and opt-in. GeoJSON responses and bbox query responses are limited to 2 MiB of canonical JSON bytes. A response above that limit fails atomically through the read @@ -87,6 +120,13 @@ IDs are `entity-id.profile-id`, so changing a profile cannot silently select another profile's authority. Only direct list permissions with readable primary geometry and explicit bbox capability are advertised. +The landing document advertises `breg-gis-bounded-crs84-point-bbox-v1` when +the caller can see only Point collections. If any visible collection uses a +Polygon or MultiPolygon, it advertises +`breg-gis-bounded-crs84-geometry-bbox-v1`. A Point collection detail retains +the profile `bounded-crs84-point-bbox`. A Polygon collection detail uses +`bounded-crs84-polygon-bbox`. + GIS deployments require an operator-configured origin: ```yaml @@ -104,7 +144,7 @@ links to retain authentication and follow every page. The adapter accepts `bbox`, `limit`, `cursor` and `f=json`. An oversized `limit` is clamped to the compiled page maximum, with an opaque cursor-only next link when more rows remain. It returns flat authorized attributes and -`numberReturned`; it does not compute hidden extents or counts. The maintained +`numberReturned`. It does not compute hidden extents or counts. The maintained stock-issuer quickstart has no QGIS authentication recipe because that issuer accepts private-key JWT clients only. To connect QGIS to a protected collection, configure an institution-operated issuer flow that QGIS supports and keep its @@ -119,13 +159,15 @@ claim. Unsupported formats, CRS options and filter languages are refused. ## Database and upgrades -GIS requires PostgreSQL 17 or later and PostGIS 3.5 or later. The quickstart -pins PostgreSQL 17 with PostGIS 3.5.2. An administrator installs PostGIS into -the admin-owned `registry_spatial_ext` schema and provisions the non-login bbox -role. Runtime and migration roles cannot install or upgrade the extension. +Polygon storage and every bbox query require PostgreSQL 17 or later and PostGIS +3.5 or later. A Point-only package with no bbox grant does not require PostGIS. +The quickstart pins PostgreSQL 17 with PostGIS 3.5.2. An administrator installs +PostGIS into the admin-owned `registry_spatial_ext` schema. When the package +grants bbox, the administrator also provisions the non-login bbox role. Runtime +and migration roles cannot install or upgrade the extension. The bbox role owns only generated views that return matching record IDs. It cannot write data, own tables or bypass row security. The ordinary runtime has -no membership in that role; it joins the ID views while computing authorized +no membership in that role. It joins the ID views to compute authorized attributes, derived values, filters and pages through the normal read path. Run `bregctl doctor --runtime-config /absolute/path/to/runtime.yaml` @@ -136,19 +178,32 @@ USAGE grants and bbox-role privileges as well as the package's maintenance and schema state. Do not give the runtime role extension-installation authority to work around the refusal. -The package generates one stored geometry projection and partial GiST index -for the queried primary Point. JSONB remains the sole writable source. Internal -geometry columns do not become logical fields or appear in history or exports. -PostGIS types, functions and operators are schema-qualified. +The package always generates a stored PostGIS geometry column and GiST index +for a Polygon field. It generates the equivalent column and partial GiST index +for a Point only when a profile grants bbox. JSONB remains the sole writable +source. Internal geometry columns do not become logical fields or appear in +history or exports. PostGIS types, functions and operators are schema-qualified. Adding or changing the geometry binding or bbox grant requires the normal -reviewed successor lifecycle. The compiler derives the projection/index DDL; -an author does not write dummy SQL to enable bbox on an existing Point. +reviewed successor lifecycle. The compiler derives the projection/index DDL. +An author does not write dummy SQL to enable spatial storage or bbox. Missing prerequisites refuse before maintenance begins. An apply failure after maintenance begins keeps records unavailable until the existing reviewed recovery procedure completes. Removing the last bbox grant removes -its generated projection/index and helper, while preserving Point data, -history and the administrator-owned extension and role. +the helper. It also removes a Point's generated projection and index when no +other bbox grant needs them. A Polygon keeps its generated projection and index. +Stored data, history and the administrator-owned extension and role remain. + +Changing a Point or Polygon field's `precision`, `bbox` or `maximumPositions` +is a field type change, even when it only loosens the contract. It needs a +[reviewed migration](../../docs/site/src/content/docs/operate/breg-changes.mdx#write-a-reviewed-migration-descriptor) +with at least one SQL step, a backup binding and a rehearsal receipt. A +tightening that a retained value violates is not caught by a pre-check: the +apply fails on the generated check constraint, the target stays non-ready, and +[the failed-activation recovery](../../docs/site/src/content/docs/operate/breg-changes.mdx#recover-a-failed-activation) +applies. Issue +[#2646](https://github.com/registrystack/registry-stack/issues/2646) proposes +treating a loosening as a compatible change. Adding the stored projection can rewrite existing rows, and creating its index takes locks and additional disk space. Plan a maintenance window and a tested diff --git a/products/breg/acceptance/spatial-service-sites/README.md b/products/breg/acceptance/spatial-service-sites/README.md index 8b509288af..59207a0380 100644 --- a/products/breg/acceptance/spatial-service-sites/README.md +++ b/products/breg/acceptance/spatial-service-sites/README.md @@ -1,23 +1,65 @@ -# Spatial Service Sites Acceptance Fixture +# Spatial service sites acceptance fixture -This is a neutral synthetic Base Registry Engine source fixture for the first BReg GIS increment. It models one `service-site` entity with a CRS84 GeoJSON Point field named `location`, public map attributes, an optional null geometry case and a reviewed derived logical `map-label` attribute. +This is a neutral synthetic Base Registry Engine source fixture for bounded CRS84 geometry reads. +It keeps the existing `service-site` Record type with a GeoJSON Point field named `location`, +public map attributes, an optional null geometry case, and a reviewed derived logical `map-label` +attribute. +It adds a separate `service-area` Record type whose required `boundary` field accepts a GeoJSON +Polygon or MultiPolygon. -The public map collection id is `service-site.map-reader`: entity `service-site`, profile `map-reader`, direct current-record list authority and an explicit bounded bbox grant. The quickstart calls the protected `service-site.installation-map-reader` collection with a stock-issuer client to exercise the installation's `service_zones: central` row restriction. That client registration supplies `registry_principal: synthetic-qgis-installation`, `registry_purpose: service-site-map`, scope `service-sites:map.read` and claim `service_zones: central`. No fabricated runtime claim is required. +The public map collection id is `service-site.map-reader`: Record type `service-site`, profile +`map-reader`, direct current-record list authority, and an explicit bounded bbox grant. +The quickstart calls the protected `service-site.installation-map-reader` collection with a +stock-issuer client to exercise the installation's `service_zones: central` row restriction. +That client registration supplies `registry_principal: synthetic-qgis-installation`, +`registry_purpose: service-site-map`, scope `service-sites:map:read`, and claim +`service_zones: central`. +No fabricated runtime claim is required. -Every profile uses the verified `registry_principal` claim, matching the quickstart's one runtime principal mapping. The token's `sub` remains its client identity; it is not substituted for the declared fixture principal. +The polygon collection id is `service-area.area-map-reader`. +Its caller sees only the polygon collection. The existing `map-reader` caller continues to see +only the point collection. +This separation lets GIS acceptance prove the point-only landing profile and the landing profile +used when at least one polygon collection is visible. + +Every profile uses the verified `registry_principal` claim, matching the quickstart's one runtime +principal mapping. The token's `sub` remains its client identity. It is not substituted for the +declared fixture principal. Profiles: -- `map-reader`: public map reader with `service-sites:public.read`, purpose `service-site-public-map`, `get` and `list` over every site, public `location`, count, scalar filters and bbox with maximum spans of 0.25 longitude degrees and 0.20 latitude degrees. -- `installation-map-reader`: authenticated QGIS installation map reader with `service-sites:map.read`, purpose `service-site-map`, `zone` row boundary from the `service_zones` claim and the same bbox spans. -- `directory-reader`: public directory reader with `service-sites:public.read`, purpose `service-site-public-directory`, nonspatial list/get over every site without the geometry field. -- `hidden-geometry-reader`: authenticated directory read where geometry is not readable and no spatial query is declared. +- `map-reader`: public map reader with `service-sites:public:read`, purpose + `service-site-public-map`, `get` and `list` over every site, public `location`, count, scalar + filters, and bbox with maximum spans of 0.25 longitude degrees and 0.20 latitude degrees. +- `area-map-reader`: public polygon map reader with `service-areas:public:read`, purpose + `service-area-public-map`, `get` and `list` over every area, public `boundary`, count, scalar + filters, and the same bbox spans. +- `installation-map-reader`: authenticated QGIS installation map reader with + `service-sites:map:read`, purpose `service-site-map`, `zone` row boundary from the + `service_zones` claim, and the same bbox spans. +- `directory-reader`: public directory reader with `service-sites:public:read`, purpose + `service-site-public-directory`, nonspatial list/get over every site without the geometry field. +- `hidden-geometry-reader`: authenticated directory read where geometry is not readable and no + spatial query is declared. - `get-only-map-reader`: authenticated get-only geometry read with no bbox authority. -- `service-site-admin`: writable seed/admin profile with `service-sites:seed`, create/patch/batch and no data export. There is no QGIS editing profile. +- `service-site-admin`: writable seed/admin profile with `service-sites:seed`, create and patch for + both Record types, batch for service sites, and no data export. There is no QGIS editing profile. -The seed input in `fixtures/seed-service-sites.jsonl` contains 225 create operations with synthetic UUID `native-id` values. The records include dense central points, exact edge points, just-inside and just-outside bbox examples, and one null-geometry row. They are designed so normal page size 100 requires more than two pages. +The seed input in `fixtures/seed-service-sites.jsonl` contains 225 create operations with synthetic +UUID `native-id` values. The records include dense central points, exact edge points, just-inside +and just-outside bbox examples, and one null-geometry row. They are designed so normal page size +100 requires more than two pages. -The representative `tests/journeys.yaml` includes a declared public bbox query and a nonspatial-profile bbox refusal. Use the [spatial quickstart](../../quickstart/README.md#spatial-service-site-quickstart) to bootstrap PostGIS and run the protected GeoJSON journey with a stock-issuer client. Its optional refresh exercise imports `fixtures/qgis-refresh-service-site.jsonl` through the ordinary batch API. +The representative `tests/journeys.yaml` includes the existing Point bbox query and nonspatial +profile refusal. +It also creates one Polygon that intersects the representative query and a MultiPolygon whose +combined envelope overlaps that query although neither polygon intersects it. +The native create expectations preserve each GeoJSON geometry exactly, and the polygon bbox +expectation returns only the intersecting Record. +Use the [spatial quickstart](../../quickstart/README.md#spatial-service-site-quickstart) to bootstrap +PostGIS and run the protected Point GeoJSON journey with a stock-issuer client. +Its optional refresh exercise imports `fixtures/qgis-refresh-service-site.jsonl` through the +ordinary batch API. Useful offline checks: @@ -28,4 +70,6 @@ mkdir -p out cargo run --locked -p registry-bregctl -- generate openapi products/breg/acceptance/spatial-service-sites --production --output out/spatial-service-sites-openapi ``` -The offline commands validate authored source and generated contracts. They do not exercise token acquisition or database execution; use the quickstart and the product's PostgreSQL tests for those paths. +The offline commands validate authored source and generated contracts. They do not exercise token +acquisition or database execution. Use the quickstart and the product's PostgreSQL tests for those +paths. diff --git a/products/breg/acceptance/spatial-service-sites/dev-clients.yaml b/products/breg/acceptance/spatial-service-sites/dev-clients.yaml index f64060390a..b306f9f3ed 100644 --- a/products/breg/acceptance/spatial-service-sites/dev-clients.yaml +++ b/products/breg/acceptance/spatial-service-sites/dev-clients.yaml @@ -21,6 +21,10 @@ clients: accessProfiles: [map-reader] scopes: [service-sites:public:read] claims: {registry_principal: synthetic-public-map, registry_purpose: service-site-public-map} + - id: public-area-map-reader + accessProfiles: [area-map-reader] + scopes: [service-areas:public:read] + claims: {registry_principal: synthetic-public-area-map, registry_purpose: service-area-public-map} - id: public-directory-reader accessProfiles: [directory-reader] scopes: [service-sites:public:read] diff --git a/products/breg/acceptance/spatial-service-sites/registry.yaml b/products/breg/acceptance/spatial-service-sites/registry.yaml index a2aaf739dc..33d431bb08 100644 --- a/products/breg/acceptance/spatial-service-sites/registry.yaml +++ b/products/breg/acceptance/spatial-service-sites/registry.yaml @@ -161,6 +161,37 @@ ], "allowCount": true, "recordType": "service-site" + }, + { + "rowBoundaries": "unrestricted", + "operations": [ + "create", + "get", + "list", + "patch" + ], + "readableFields": [ + "native-id", + "area-code", + "label", + "boundary" + ], + "writableFields": [ + "native-id", + "area-code", + "label", + "boundary" + ], + "filterableFields": [ + "native-id", + "area-code" + ], + "sortableFields": [ + "area-code", + "label" + ], + "allowCount": true, + "recordType": "service-area" } ] } @@ -211,6 +242,48 @@ ] } }, + { + "id": "area-map-reader", + "principalClaim": "registry_principal", + "requiredScopes": [ + "service-areas:public:read" + ], + "requiredPurposes": [ + "service-area-public-map" + ], + "permissions": { + "records": [ + { + "rowBoundaries": "unrestricted", + "operations": [ + "get", + "list" + ], + "readableFields": [ + "native-id", + "area-code", + "label", + "boundary" + ], + "filterableFields": [ + "area-code" + ], + "sortableFields": [ + "area-code", + "label" + ], + "allowCount": true, + "spatialQueries": { + "bbox": { + "maximumLongitudeSpanDegrees": 0.25, + "maximumLatitudeSpanDegrees": 0.2 + } + }, + "recordType": "service-area" + } + ] + } + }, { "id": "installation-map-reader", "principalClaim": "registry_principal", @@ -394,6 +467,74 @@ } ], "recordTypes": [ + { + "id": "service-area", + "primaryDataset": "spatial-service-sites", + "route": "service-areas", + "mutationMode": "mutable", + "classification": "public", + "fields": [ + { + "id": "native-id", + "type": "uuid", + "required": true, + "classification": "public" + }, + { + "id": "area-code", + "type": "string", + "required": true, + "maximumLength": 64, + "classification": "public" + }, + { + "id": "label", + "type": "string", + "required": true, + "maximumLength": 160, + "classification": "public" + }, + { + "id": "boundary", + "type": "crs84-polygon", + "precision": 9, + "maximumPositions": 100, + "bbox": { + "west": "100.450000000", + "south": "13.600000000", + "east": "100.700000000", + "north": "13.900000000" + }, + "required": true, + "classification": "public" + } + ], + "geojson": { + "geometryField": "boundary" + }, + "constraints": [ + { + "type": "unique", + "fields": [ + "native-id" + ] + }, + { + "type": "unique", + "fields": [ + "area-code" + ] + } + ], + "indexes": [ + { + "id": "by-area-label", + "fields": [ + "label" + ] + } + ] + }, { "id": "service-site", "primaryDataset": "spatial-service-sites", diff --git a/products/breg/acceptance/spatial-service-sites/tests/journeys.yaml b/products/breg/acceptance/spatial-service-sites/tests/journeys.yaml index e21951a6c5..1de4eac14f 100644 --- a/products/breg/acceptance/spatial-service-sites/tests/journeys.yaml +++ b/products/breg/acceptance/spatial-service-sites/tests/journeys.yaml @@ -133,6 +133,248 @@ "capture": "edge-site", "recordType": "service-site" }, + { + "id": "create-intersecting-service-area", + "accessProfile": "service-site-admin", + "claims": { + "principal": "synthetic-service-site-admin", + "purpose": "service-site-administration", + "scopes": [ + "service-sites:seed" + ] + }, + "request": { + "type": "create", + "data": { + "native-id": "55555555-5555-4555-8555-555555555555", + "area-code": "AREA-SYNTH-INTERSECTS-001", + "label": "Synthetic intersecting service area", + "boundary": { + "type": "Polygon", + "coordinates": [ + [ + [ + 100.535, + 13.745 + ], + [ + 100.55, + 13.755 + ], + [ + 100.565, + 13.745 + ], + [ + 100.535, + 13.745 + ] + ] + ] + } + } + }, + "expect": { + "outcome": "success", + "status": 201, + "fields": { + "area-code": "AREA-SYNTH-INTERSECTS-001", + "boundary": { + "type": "Polygon", + "coordinates": [ + [ + [ + 100.535, + 13.745 + ], + [ + 100.55, + 13.755 + ], + [ + 100.565, + 13.745 + ], + [ + 100.535, + 13.745 + ] + ] + ] + } + } + }, + "capture": "intersecting-service-area", + "recordType": "service-area" + }, + { + "id": "create-envelope-only-service-area", + "accessProfile": "service-site-admin", + "claims": { + "principal": "synthetic-service-site-admin", + "purpose": "service-site-administration", + "scopes": [ + "service-sites:seed" + ] + }, + "request": { + "type": "create", + "data": { + "native-id": "66666666-6666-4666-8666-666666666666", + "area-code": "AREA-SYNTH-ENVELOPE-ONLY-001", + "label": "Synthetic envelope-only service area", + "boundary": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 100.52, + 13.73 + ], + [ + 100.53, + 13.73 + ], + [ + 100.53, + 13.77 + ], + [ + 100.52, + 13.77 + ], + [ + 100.52, + 13.73 + ] + ] + ], + [ + [ + [ + 100.57, + 13.73 + ], + [ + 100.58, + 13.73 + ], + [ + 100.58, + 13.77 + ], + [ + 100.57, + 13.77 + ], + [ + 100.57, + 13.73 + ] + ] + ] + ] + } + } + }, + "expect": { + "outcome": "success", + "status": 201, + "fields": { + "area-code": "AREA-SYNTH-ENVELOPE-ONLY-001", + "boundary": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [ + 100.52, + 13.73 + ], + [ + 100.53, + 13.73 + ], + [ + 100.53, + 13.77 + ], + [ + 100.52, + 13.77 + ], + [ + 100.52, + 13.73 + ] + ] + ], + [ + [ + [ + 100.57, + 13.73 + ], + [ + 100.58, + 13.73 + ], + [ + 100.58, + 13.77 + ], + [ + 100.57, + 13.77 + ], + [ + 100.57, + 13.73 + ] + ] + ] + ] + } + } + }, + "capture": "envelope-only-service-area", + "recordType": "service-area" + }, + { + "id": "area-map-reader-bbox-uses-geometry-intersection", + "accessProfile": "area-map-reader", + "claims": { + "principal": "synthetic-public-area-map", + "purpose": "service-area-public-map", + "scopes": [ + "service-areas:public:read" + ] + }, + "request": { + "type": "query", + "count": true, + "top": 10, + "bbox": { + "west": "100.540000000", + "south": "13.740000000", + "east": "100.560000000", + "north": "13.760000000" + }, + "select": [ + "native-id", + "area-code", + "label", + "boundary" + ] + }, + "expect": { + "outcome": "success", + "status": 200, + "count": 1 + }, + "recordType": "service-area" + }, { "id": "public-map-reader-lists-public-point-fields", "accessProfile": "map-reader", diff --git a/products/breg/contracts/security-invariant-matrix.yaml b/products/breg/contracts/security-invariant-matrix.yaml index 14cbab732f..d067515351 100644 --- a/products/breg/contracts/security-invariant-matrix.yaml +++ b/products/breg/contracts/security-invariant-matrix.yaml @@ -24,7 +24,7 @@ invariants: - {id: BREG-SEC-20, state: enforced, targetWave: W3, threat: "A lookup selector accepts partial, extra, mistyped, caller-supplied claim values, queries unbounded rows, or otherwise becomes an enumeration oracle.", enforcementPoint: compiled selector permission bounded lookup request parser and two-row PostgreSQL limit, refusal: "Require the selector's exact configured field set and origin, query at most two rows, and collapse unknown, ungranted, missing-claim, zero-match, and multiple-match outcomes to one value-free unresolved response without audit cardinality.", negativeId: BREG-NEG-20, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value_free}} - {id: BREG-SEC-21, state: enforced, targetWave: W3, threat: "A relationship traversal inherits direct target rights or widens its configured target projection, filtering, ordering, or count authority.", enforcementPoint: compiled named read-path permission and route-specific query plan, refusal: "Require an independently granted path and enforce only that path's target fields and query capabilities; conceal unknown and ungranted paths identically before record I/O.", negativeId: BREG-NEG-21, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: relationship_route_uses_path_grant_not_direct_target_rights}} - {id: BREG-SEC-22, state: enforced, targetWave: W5, threat: A new profile or relationship grant omits mandatory entity access restrictions., enforcementPoint: compiler access requirements after module composition and before artifact generation, refusal: Reject profiles missing mandatory scopes or row bindings or widening allowed purposes; reject relationship grants whose target or join row requirements cannot be enforced., negativeId: BREG-NEG-22, negativeTest: {path: crates/registry-breg/tests/access_configuration.rs, name: requirements_are_mandatory_not_grants_and_cannot_be_weakened_by_profiles}} - - {id: BREG-SEC-23, state: enforced, targetWave: W5, threat: A caller uses map membership to query a Point without explicit spatial authority., enforcementPoint: compiled direct-list bbox permission and HTTP query admission, refusal: Reject malformed or undeclared bbox queries before record-service entry and retain the exact selected profile., negativeId: BREG-NEG-23, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: bbox_reaches_record_service_only_with_declared_spatial_grant}} + - {id: BREG-SEC-23, state: enforced, targetWave: W5, threat: A caller uses map membership to query stored geometry without explicit spatial authority., enforcementPoint: compiled direct-list bbox permission and HTTP query admission, refusal: Reject malformed or undeclared bbox queries before record-service entry and retain the exact selected profile., negativeId: BREG-NEG-23, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: bbox_reaches_record_service_only_with_declared_spatial_grant}} - {id: BREG-SEC-24, state: enforced, targetWave: W5, threat: Spatial predicates resolve through caller-controlled objects or a bbox role with excess authority., enforcementPoint: qualified PostGIS prerequisite verification and bbox role validation, refusal: Reject missing or misplaced extension prerequisites and bbox role privilege or membership drift before spatial execution., negativeId: BREG-NEG-24, negativeTest: {path: crates/registry-breg/tests/postgres_spatial_storage.rs, name: real_postgres_postgis_prerequisites_are_role_bound_and_shadow_resistant}} - {id: BREG-SEC-25, state: enforced, targetWave: W5, threat: A Rhai planner widens its package-declared entity target binding operation or field ceiling and turns script output into record authority., enforcementPoint: compiler-owned effect ceiling and the shared canonical proposal verifier before target locks or writes, refusal: Reject every undeclared write dimension before target locking and grant no authority from planner output., negativeId: BREG-NEG-25, negativeTest: {path: crates/registry-breg/src/proposal_prepare/tests.rs, name: rhai_plan_refuses_authority_ceiling_escape_before_target_locks}} - {id: BREG-SEC-26, state: enforced, targetWave: W5, threat: A Rhai planner bypasses configured external review or fabricates approved evidence through its result., enforcementPoint: closed planner result ABI frozen separately from the source-owned review requirement and exact approved-result binding, refusal: Reject planner-owned review fields and keep application unavailable until exact approval evidence from the configured authority is retained., negativeId: BREG-NEG-26, negativeTest: {path: crates/registry-breg/src/proposal_workflow.rs, name: required_review_applies_only_with_exact_approved_result_binding}} diff --git a/products/breg/generated/authoring/registry-module.schema.json b/products/breg/generated/authoring/registry-module.schema.json index da7d644037..0b215cf314 100644 --- a/products/breg/generated/authoring/registry-module.schema.json +++ b/products/breg/generated/authoring/registry-module.schema.json @@ -631,6 +631,35 @@ ], "type": "object" }, + { + "properties": { + "bbox": { + "$ref": "#/$defs/Crs84BboxSource" + }, + "maximumPositions": { + "format": "uint32", + "maximum": 10000, + "minimum": 4, + "type": "integer" + }, + "precision": { + "format": "uint32", + "maximum": 9, + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "crs84-polygon", + "type": "string" + } + }, + "required": [ + "type", + "precision", + "maximumPositions" + ], + "type": "object" + }, { "properties": { "maximumBytes": { @@ -1612,6 +1641,59 @@ ], "type": "object" }, + "Crs84PolygonFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "apiName": { + "type": "string" + }, + "bbox": { + "$ref": "#/$defs/Crs84BboxSource" + }, + "classification": { + "$ref": "#/$defs/Classification" + }, + "display": { + "$ref": "#/$defs/DisplaySource" + }, + "id": { + "$ref": "#/$defs/LocalId" + }, + "maximumPositions": { + "format": "uint32", + "maximum": 10000, + "minimum": 4, + "type": "integer" + }, + "precision": { + "format": "uint32", + "maximum": 9, + "minimum": 0, + "type": "integer" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "crs84-polygon" + ], + "type": "string" + }, + "validTimeRole": { + "$ref": "#/$defs/ValidTimeRole" + } + }, + "required": [ + "id", + "type", + "classification", + "precision", + "maximumPositions" + ], + "type": "object" + }, "CurrentDatePredicateSource": { "enum": [ "on-or-after", @@ -1935,6 +2017,35 @@ ], "type": "object" }, + { + "properties": { + "bbox": { + "$ref": "#/$defs/Crs84BboxSource" + }, + "maximumPositions": { + "format": "uint32", + "maximum": 10000, + "minimum": 4, + "type": "integer" + }, + "precision": { + "format": "uint32", + "maximum": 9, + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "crs84-polygon", + "type": "string" + } + }, + "required": [ + "type", + "precision", + "maximumPositions" + ], + "type": "object" + }, { "properties": { "maximumBytes": { @@ -2612,6 +2723,9 @@ { "$ref": "#/$defs/Crs84PointFieldSourceSchema" }, + { + "$ref": "#/$defs/Crs84PolygonFieldSourceSchema" + }, { "$ref": "#/$defs/StructuredFieldSourceSchema" }, diff --git a/products/breg/generated/authoring/registry-project.schema.json b/products/breg/generated/authoring/registry-project.schema.json index b7934975d0..7778a1ee3d 100644 --- a/products/breg/generated/authoring/registry-project.schema.json +++ b/products/breg/generated/authoring/registry-project.schema.json @@ -631,6 +631,35 @@ ], "type": "object" }, + { + "properties": { + "bbox": { + "$ref": "#/$defs/Crs84BboxSource" + }, + "maximumPositions": { + "format": "uint32", + "maximum": 10000, + "minimum": 4, + "type": "integer" + }, + "precision": { + "format": "uint32", + "maximum": 9, + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "crs84-polygon", + "type": "string" + } + }, + "required": [ + "type", + "precision", + "maximumPositions" + ], + "type": "object" + }, { "properties": { "maximumBytes": { @@ -1665,6 +1694,59 @@ ], "type": "object" }, + "Crs84PolygonFieldSourceSchema": { + "additionalProperties": false, + "properties": { + "apiName": { + "type": "string" + }, + "bbox": { + "$ref": "#/$defs/Crs84BboxSource" + }, + "classification": { + "$ref": "#/$defs/Classification" + }, + "display": { + "$ref": "#/$defs/DisplaySource" + }, + "id": { + "$ref": "#/$defs/LocalId" + }, + "maximumPositions": { + "format": "uint32", + "maximum": 10000, + "minimum": 4, + "type": "integer" + }, + "precision": { + "format": "uint32", + "maximum": 9, + "minimum": 0, + "type": "integer" + }, + "required": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "crs84-polygon" + ], + "type": "string" + }, + "validTimeRole": { + "$ref": "#/$defs/ValidTimeRole" + } + }, + "required": [ + "id", + "type", + "classification", + "precision", + "maximumPositions" + ], + "type": "object" + }, "CurrentDatePredicateSource": { "enum": [ "on-or-after", @@ -2048,6 +2130,35 @@ ], "type": "object" }, + { + "properties": { + "bbox": { + "$ref": "#/$defs/Crs84BboxSource" + }, + "maximumPositions": { + "format": "uint32", + "maximum": 10000, + "minimum": 4, + "type": "integer" + }, + "precision": { + "format": "uint32", + "maximum": 9, + "minimum": 0, + "type": "integer" + }, + "type": { + "const": "crs84-polygon", + "type": "string" + } + }, + "required": [ + "type", + "precision", + "maximumPositions" + ], + "type": "object" + }, { "properties": { "maximumBytes": { @@ -3069,6 +3180,9 @@ { "$ref": "#/$defs/Crs84PointFieldSourceSchema" }, + { + "$ref": "#/$defs/Crs84PolygonFieldSourceSchema" + }, { "$ref": "#/$defs/StructuredFieldSourceSchema" }, diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 0a8544ba4c..7a2f28288e 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -4740,7 +4740,7 @@ "description": "Base Registry Engine portable Action contract export schema, reproduced from the canonical compiled project.", "source": { "path": "crates/registry-breg/src/artifacts.rs", - "sha256": "1e95863f83e08914cb08a8b382ecf3c1b0b9a55fe25e00cb96fbd3b863e40cd1" + "sha256": "600d5133238849206bf7246c99da0fa29cba2ae62391f9a660ba337ed7e003ad" }, "artifact": { "path": "products/breg/generated/facility/generated/action-contracts.schema.json", @@ -4978,7 +4978,7 @@ }, "artifact": { "path": "products/breg/generated/authoring/registry-module.schema.json", - "sha256": "7beb51728884e600dccd3b47fd1c46e47936423883079f7cffb9dba147f0339a", + "sha256": "53cc277c73bdd30f5e5ec09a29fa1d41f349b74c61089f5f5fb44edbffc5b7c5", "mediaType": "application/schema+json" } }, @@ -5014,7 +5014,7 @@ }, "artifact": { "path": "products/breg/generated/authoring/registry-project.schema.json", - "sha256": "673823f6efdd44555b18b3ca4ab52aeed102b82657eb0b49956268f738cee020", + "sha256": "0f096c00328bb82c6150c5d72a7b6b238b18a379f23f9a21aa60f3212a35f86d", "mediaType": "application/schema+json" } }, diff --git a/products/platform/config-conventions-exceptions.yaml b/products/platform/config-conventions-exceptions.yaml index fd83d89688..e34ed7fad9 100644 --- a/products/platform/config-conventions-exceptions.yaml +++ b/products/platform/config-conventions-exceptions.yaml @@ -391,7 +391,7 @@ exceptions: resolution: "Permanent for the BReg v1alpha1 named-value-type exchange model. native_source_conventions verifies typed declarations and refactor_contract verifies closed local resolution." - rule: CFG-SEC-1 format: breg/module - location: products/breg/generated/authoring/registry-module.schema.json#/$defs/FieldSource/anyOf/12/properties/typeRef + location: products/breg/generated/authoring/registry-module.schema.json#/$defs/FieldSource/anyOf/13/properties/typeRef class: exchange-model reason: "typeRef is the BReg v1alpha1 exchange model's reference to a local valueTypes declaration, typed LocalId. It never names a secret or invokes a secret provider; the named type supplies only the same typed constraints as an inline field/input." resolution: "Permanent for the BReg v1alpha1 named-value-type exchange model. native_source_conventions verifies typed declarations and refactor_contract verifies closed local resolution." @@ -439,7 +439,7 @@ exceptions: resolution: "Permanent for the BReg v1alpha1 named-value-type exchange model. native_source_conventions verifies typed declarations and refactor_contract verifies closed local resolution." - rule: CFG-SEC-1 format: breg/project - location: products/breg/generated/authoring/registry-project.schema.json#/$defs/FieldSource/anyOf/12/properties/typeRef + location: products/breg/generated/authoring/registry-project.schema.json#/$defs/FieldSource/anyOf/13/properties/typeRef class: exchange-model reason: "typeRef is the BReg v1alpha1 exchange model's reference to a local valueTypes declaration, typed LocalId. It never names a secret or invokes a secret provider; the named type supplies only the same typed constraints as an inline field/input." resolution: "Permanent for the BReg v1alpha1 named-value-type exchange model. native_source_conventions verifies typed declarations and refactor_contract verifies closed local resolution."