diff --git a/.github/workflows/spec-hash.yml b/.github/workflows/spec-hash.yml index d5f14edbfe..69bd0d1bbc 100644 --- a/.github/workflows/spec-hash.yml +++ b/.github/workflows/spec-hash.yml @@ -1,4 +1,4 @@ -# Validate the common deterministic spec-hash implementation and fixture profile. +# Validate the common deterministic spec-hash and structural GeoJSON digest profiles. name: spec-hash "on": @@ -12,8 +12,9 @@ name: spec-hash - "contracts/common/spec_hash.md" - "schemas/contracts/v1/common/spec_hash.schema.json" - "fixtures/contracts/v1/common/spec_hash/**" - - "tests/validators/test_validate_spec_hash.py" + - "tests/validators/test_validate_spec_hash*.py" - "data/receipts/generated/genrec-spec-hash-validator-*.json" + - "data/receipts/generated/genrec-geojson-feature-digests-*.json" push: branches: [main] paths: @@ -25,8 +26,9 @@ name: spec-hash - "contracts/common/spec_hash.md" - "schemas/contracts/v1/common/spec_hash.schema.json" - "fixtures/contracts/v1/common/spec_hash/**" - - "tests/validators/test_validate_spec_hash.py" + - "tests/validators/test_validate_spec_hash*.py" - "data/receipts/generated/genrec-spec-hash-validator-*.json" + - "data/receipts/generated/genrec-geojson-feature-digests-*.json" workflow_dispatch: permissions: @@ -75,7 +77,7 @@ jobs: set -euo pipefail python -m unittest discover \ --start-directory tests/validators \ - --pattern 'test_validate_spec_hash.py' \ + --pattern 'test_validate_spec_hash*.py' \ --verbose python tools/validators/validate_spec_hash.py --fixtures @@ -85,14 +87,17 @@ jobs: python tools/validators/validate_generated_receipt.py \ data/receipts/generated/genrec-spec-hash-validator-20260806.json \ --repo-root . + python tools/validators/validate_generated_receipt.py \ + data/receipts/generated/genrec-geojson-feature-digests-20260806.json \ + --repo-root . - name: Record trust boundary if: always() run: | { - echo "### Common spec-hash validation" + echo "### Common spec-hash and GeoJSON digest validation" echo echo "- Result: ${{ job.status }}." - echo "- Scope: bounded RFC 8785 JCS + SHA-256 computation, current sha256: shape, fixture polarity, and authoring-receipt integrity." - echo "- Boundary: a green result creates no source, evidence, policy, review, promotion, release, publication, or public-use authority." + echo "- Scope: bounded RFC 8785 JCS + SHA-256 computation, current sha256: shape, structural GeoJSON geometry/record digests, fixture polarity, and authoring-receipt integrity." + echo "- Boundary: GeoJSON digest equality is structural under a declared CRS/precision profile; it is not topological equivalence and creates no source, evidence, policy, review, promotion, release, publication, or public-use authority." } >> "$GITHUB_STEP_SUMMARY" diff --git a/data/receipts/generated/genrec-geojson-feature-digests-20260806.json b/data/receipts/generated/genrec-geojson-feature-digests-20260806.json new file mode 100644 index 0000000000..a220abc779 --- /dev/null +++ b/data/receipts/generated/genrec-geojson-feature-digests-20260806.json @@ -0,0 +1,122 @@ +{ + "receipt_id": "genrec-geojson-feature-digests-20260806", + "contract_version": "3.0.0", + "artifact_paths": [ + ".github/workflows/spec-hash.yml", + "packages/hashing/src/hashing/__init__.py", + "packages/hashing/src/hashing/cli.py", + "packages/hashing/src/hashing/geojson.py", + "tests/validators/test_validate_spec_hash_geojson.py", + "tools/spec_hash/GEOJSON_FEATURE_DIGESTS.md" + ], + "artifact_hashes": { + ".github/workflows/spec-hash.yml": "sha256:2f8163719dfd706f546144c152a51f508a50d69f5e143ec370543ac8d39f32c5", + "packages/hashing/src/hashing/__init__.py": "sha256:288e6783711382dd1a3953f79613b4294eec9907e3716f378b1d10e15f83fc5d", + "packages/hashing/src/hashing/cli.py": "sha256:155cfac4ff345967cb2cfe9fe6890ef066d04ea6f8f4969efc279b99366ae568", + "packages/hashing/src/hashing/geojson.py": "sha256:491ed9f7eb84202299e5c174b1c2bd598de4d3883df6095849e5f7e780471eb6", + "tests/validators/test_validate_spec_hash_geojson.py": "sha256:681f02b7e38cb71200a6ef7d09613de898491a2a602d0c4ac784bb475b8f844a", + "tools/spec_hash/GEOJSON_FEATURE_DIGESTS.md": "sha256:25f4efe988dc613c7545274f5a1b042ae6e9cbd866c609ccd9927a792600d5b0" + }, + "model_identity": { + "provider": "OpenAI", + "model": "GPT-5.6 Pro", + "version": "2026-08-06" + }, + "prompt_or_contract": "sha256:22251a0f1604b769d1d7ae0c33ff3c75e34dea0d8083bf104260a80b60c32cbb", + "parameters": { + "seed": null, + "temperature": null, + "top_p": null, + "max_tokens": null, + "tools_enabled": [ + "GitHub connector", + "uploaded-file search", + "local deterministic validation" + ] + }, + "inputs": { + "attached_docs": [ + "New Ideas.pdf" + ], + "evidence_refs": [ + "attachment:New Ideas.pdf#verifiable-digests-attestations-stac-pmtiles-cog", + "repo:packages/hashing/src/hashing/core.py@a6bbaa2a7986858bd72629cf3a77181b9e72a761", + "repo:tools/spec_hash/README.md@a6bbaa2a7986858bd72629cf3a77181b9e72a761", + "repo:docs/adr/ADR-0029-adopt-directory-governance-standard-v2.md@a6bbaa2a7986858bd72629cf3a77181b9e72a761" + ] + }, + "truth_labels": { + ".github/workflows/spec-hash.yml": "CONFIRMED", + "packages/hashing/src/hashing/__init__.py": "CONFIRMED", + "packages/hashing/src/hashing/cli.py": "CONFIRMED", + "packages/hashing/src/hashing/geojson.py": "CONFIRMED", + "tests/validators/test_validate_spec_hash_geojson.py": "CONFIRMED", + "tools/spec_hash/GEOJSON_FEATURE_DIGESTS.md": "CONFIRMED" + }, + "validation_gates": [ + { + "gate": "local-focused-tests", + "outcome": "PASS", + "reason": "Three deterministic no-network GeoJSON digest tests passed, including a golden vector, hash-domain separation, explicit profile inputs, fail-closed invalid input, and bounded CLI behavior." + }, + { + "gate": "local-python-compile", + "outcome": "PASS", + "reason": "Changed Python modules and the new focused test compiled successfully." + }, + { + "gate": "local-rfc8785-dependency-parity", + "outcome": "SKIPPED", + "reason": "The execution image did not expose the repository-pinned rfc8785 distribution; focused local tests used a test-only compatible canonicalization stand-in that is not committed. Exact dependency parity is delegated to hosted CI." + }, + { + "gate": "artifact-byte-binding", + "outcome": "PASS", + "reason": "Every declared authored artifact is bound to its prepared SHA-256 bytes without receipt self-reference." + }, + { + "gate": "hosted-exact-head-ci", + "outcome": "SKIPPED", + "reason": "Hosted validation requires the pushed exact pull-request head." + }, + { + "gate": "human-review", + "outcome": "SKIPPED", + "reason": "Human review remains pending on the draft pull request." + } + ], + "policy_decisions": [ + "docs/adr/ADR-0029-adopt-directory-governance-standard-v2.md" + ], + "citations": [ + { + "id": "attachment:new-ideas-geojson-digests", + "validated": true, + "evidence_ref": "attachment:New Ideas.pdf#verifiable-digests-attestations-stac-pmtiles-cog" + }, + { + "id": "repository:hashing-core", + "validated": true, + "evidence_ref": "repo:packages/hashing/src/hashing/core.py@a6bbaa2a7986858bd72629cf3a77181b9e72a761" + }, + { + "id": "repository:directory-governance-adr", + "validated": true, + "evidence_ref": "repo:docs/adr/ADR-0029-adopt-directory-governance-standard-v2.md@a6bbaa2a7986858bd72629cf3a77181b9e72a761" + } + ], + "human_review": { + "reviewer_ids": [], + "state": "pending", + "timestamp": null + }, + "override_record": null, + "created_at": "2026-08-07T04:03:38Z", + "emitter": "OpenAI GPT-5.6 Pro via ChatGPT", + "links": { + "pr_number": 2099, + "adr_link": "docs/adr/ADR-0029-adopt-directory-governance-standard-v2.md", + "drift_register_entry": null + }, + "notes": "Additive structural GeoJSON Feature digest profile using the repository RFC 8785 JCS plus SHA-256 implementation. The profile binds declared CRS and precision, emits separate geometry and record digests, and never claims topology or spatial equivalence. No source, evidence, policy, review, promotion, release, publication, or public-use authority is created." +} diff --git a/packages/hashing/src/hashing/__init__.py b/packages/hashing/src/hashing/__init__.py index c2dc49ff7a..676ddec2ed 100644 --- a/packages/hashing/src/hashing/__init__.py +++ b/packages/hashing/src/hashing/__init__.py @@ -15,19 +15,35 @@ load_json_file, verify_spec_hash, ) +from .geojson import ( + DEFAULT_COORDINATE_PRECISION, + GEOJSON_DIGEST_PROFILE, + GeoJSONDigestError, + GeoJSONFeatureDigests, + compute_geojson_feature_digests, + compute_geojson_geometry_hash, + normalize_geojson_geometry, +) __all__ = [ "CANONICALIZATION_PROFILE", + "DEFAULT_COORDINATE_PRECISION", + "GEOJSON_DIGEST_PROFILE", "HASH_ALGORITHM", "SPEC_HASH_PREFIX", "CanonicalizationFailure", + "GeoJSONDigestError", + "GeoJSONFeatureDigests", "JsonInputError", "SpecHashError", "SpecHashFormatError", "VerificationResult", "canonicalize_json", + "compute_geojson_feature_digests", + "compute_geojson_geometry_hash", "compute_spec_hash", "is_valid_spec_hash", "load_json_file", + "normalize_geojson_geometry", "verify_spec_hash", ] diff --git a/packages/hashing/src/hashing/cli.py b/packages/hashing/src/hashing/cli.py index 6c2a14e970..860b7f04ad 100644 --- a/packages/hashing/src/hashing/cli.py +++ b/packages/hashing/src/hashing/cli.py @@ -1,4 +1,4 @@ -"""Command-line interface for bounded KFM spec-hash computation and comparison.""" +"""Command-line interface for bounded KFM deterministic content digests.""" from __future__ import annotations @@ -19,6 +19,12 @@ load_json_file, verify_spec_hash, ) +from .geojson import ( + DEFAULT_COORDINATE_PRECISION, + GEOJSON_DIGEST_PROFILE, + GeoJSONDigestError, + compute_geojson_feature_digests, +) SCOPE = "common.spec_hash" NON_EFFECTS = [ @@ -34,13 +40,13 @@ def _emit(payload: dict[str, object]) -> None: print(json.dumps(payload, sort_keys=True, separators=(",", ":"))) -def _base(status: str) -> dict[str, object]: +def _base(status: str, *, scope: str = SCOPE) -> dict[str, object]: return { "authority": "NONE", "canonicalization": CANONICALIZATION_PROFILE, "hash_algorithm": HASH_ALGORITHM, "non_effects": NON_EFFECTS, - "scope": SCOPE, + "scope": scope, "status": status, } @@ -100,9 +106,51 @@ def _verify(subject_path: Path, hash_path: Path) -> int: return 2 +def _geojson_feature( + path: Path, + *, + crs: str, + precision: int, + excluded_property_keys: Sequence[str], + include_feature_id: bool, +) -> int: + scope = "geojson.feature_digests" + try: + feature = load_json_file(path) + digests = compute_geojson_feature_digests( + feature, + crs=crs, + coordinate_precision=precision, + excluded_property_keys=excluded_property_keys, + include_feature_id=include_feature_id, + ) + payload = _base("GEOJSON_FEATURE_DIGESTS_CREATED", scope=scope) + payload.update(digests.as_dict()) + payload["input"] = str(path) + _emit(payload) + return 0 + except JsonInputError: + payload = _base("JSON_INPUT_INVALID", scope=scope) + except GeoJSONDigestError: + payload = _base("GEOJSON_DIGEST_INPUT_INVALID", scope=scope) + except CanonicalizationFailure: + payload = _base("CANONICALIZATION_ERROR", scope=scope) + payload.update( + { + "input": str(path), + "normalization_profile": GEOJSON_DIGEST_PROFILE, + } + ) + _emit(payload) + return 2 + + def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( - description="Compute or verify deterministic RFC 8785 JCS + SHA-256 spec hashes." + description=( + "Compute or verify deterministic RFC 8785 JCS + SHA-256 content " + "digests." + ) ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -113,11 +161,48 @@ def main(argv: Sequence[str] | None = None) -> int: verify_parser.add_argument("input", type=Path) verify_parser.add_argument("hash_record", type=Path) + geojson_parser = subparsers.add_parser( + "geojson-feature", + help="compute separate structural geometry and record digests", + ) + geojson_parser.add_argument("input", type=Path) + geojson_parser.add_argument( + "--crs", + required=True, + help="declared CRS bound into both digests, for example EPSG:4326", + ) + geojson_parser.add_argument( + "--precision", + type=int, + default=DEFAULT_COORDINATE_PRECISION, + help="coordinate decimal places retained before RFC 8785 hashing", + ) + geojson_parser.add_argument( + "--exclude-property", + action="append", + default=[], + dest="excluded_property_keys", + help="top-level property key to exclude; repeat for multiple keys", + ) + geojson_parser.add_argument( + "--include-feature-id", + action="store_true", + help="bind the optional top-level GeoJSON Feature id into record_sha256", + ) + args = parser.parse_args(argv) if args.command == "compute": return _compute(args.input) if args.command == "verify": return _verify(args.input, args.hash_record) + if args.command == "geojson-feature": + return _geojson_feature( + args.input, + crs=args.crs, + precision=args.precision, + excluded_property_keys=args.excluded_property_keys, + include_feature_id=args.include_feature_id, + ) parser.error("unsupported command") return 2 diff --git a/packages/hashing/src/hashing/geojson.py b/packages/hashing/src/hashing/geojson.py new file mode 100644 index 0000000000..2db35caf8a --- /dev/null +++ b/packages/hashing/src/hashing/geojson.py @@ -0,0 +1,357 @@ +"""Deterministic structural digests for GeoJSON Feature records. + +The ``kfm-geojson-feature-digest-v1`` profile makes the caller declare a CRS, +quantizes finite coordinates with a versioned precision, strips GeoJSON foreign +members from the geometry hash domain, and hashes RFC 8785 JCS subjects with the +current ``sha256:`` grammar. + +This profile intentionally does *not* reproject, repair topology, rotate polygon +rings, canonicalize line direction, sort collections, or prove spatial equality. +Those operations require a separately governed geospatial normalization contract. +Digest equality establishes only equality under this declared structural profile; +it creates no source, evidence, policy, review, promotion, release, publication, +or public-use authority. +""" + +from __future__ import annotations + +import math +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN, localcontext +from typing import Any + +from .core import SpecHashError, compute_spec_hash + +GEOJSON_DIGEST_PROFILE = "kfm-geojson-feature-digest-v1" +DEFAULT_COORDINATE_PRECISION = 7 +MIN_COORDINATE_PRECISION = 0 +MAX_COORDINATE_PRECISION = 15 +MAX_COORDINATE_VALUES = 1_000_000 +MAX_GEOMETRY_DEPTH = 32 + +_GEOMETRY_COORDINATE_DEPTH = { + "Point": 1, + "MultiPoint": 2, + "LineString": 2, + "MultiLineString": 3, + "Polygon": 3, + "MultiPolygon": 4, +} + + +class GeoJSONDigestError(SpecHashError): + """Raised when a GeoJSON value cannot enter the structural digest profile.""" + + +@dataclass(frozen=True) +class GeoJSONFeatureDigests: + """Digest result and replay parameters for one GeoJSON Feature.""" + + profile: str + crs: str + coordinate_precision: int + geometry_sha256: str + record_sha256: str + excluded_property_keys: tuple[str, ...] + feature_id_included: bool + + def as_dict(self) -> dict[str, object]: + """Return a deterministic JSON-compatible report payload.""" + + return { + "profile": self.profile, + "crs": self.crs, + "coordinate_precision": self.coordinate_precision, + "geometry_sha256": self.geometry_sha256, + "record_sha256": self.record_sha256, + "excluded_property_keys": list(self.excluded_property_keys), + "feature_id_included": self.feature_id_included, + } + + +@dataclass +class _CoordinateBudget: + consumed: int = 0 + + def add(self, amount: int) -> None: + self.consumed += amount + if self.consumed > MAX_COORDINATE_VALUES: + raise GeoJSONDigestError( + "GeoJSON geometry exceeds the coordinate-value validation budget" + ) + + +def _validated_precision(value: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise GeoJSONDigestError("coordinate precision must be an integer") + if not MIN_COORDINATE_PRECISION <= value <= MAX_COORDINATE_PRECISION: + raise GeoJSONDigestError( + "coordinate precision must be between 0 and 15 decimal places" + ) + return value + + +def _validated_crs(value: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise GeoJSONDigestError("a non-empty declared CRS is required") + return value.strip() + + +def _validated_exclusions(values: Iterable[str]) -> tuple[str, ...]: + exclusions: set[str] = set() + for value in values: + if not isinstance(value, str) or not value: + raise GeoJSONDigestError( + "excluded property keys must be non-empty strings" + ) + exclusions.add(value) + return tuple(sorted(exclusions)) + + +def _quantize_coordinate(value: object, precision: int) -> int | float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise GeoJSONDigestError("GeoJSON positions must contain only JSON numbers") + numeric = float(value) + if not math.isfinite(numeric): + raise GeoJSONDigestError("GeoJSON coordinates must be finite") + + try: + with localcontext() as context: + context.prec = 64 + quantum = Decimal(1).scaleb(-precision) + quantized = Decimal(str(value)).quantize( + quantum, rounding=ROUND_HALF_EVEN + ) + except (InvalidOperation, ValueError) as exc: + raise GeoJSONDigestError("GeoJSON coordinate could not be quantized") from exc + + if quantized == 0: + return 0 + if quantized == quantized.to_integral_value(): + return int(quantized) + return float(quantized) + + +def _normalize_position( + value: object, + *, + precision: int, + budget: _CoordinateBudget, +) -> list[int | float]: + if not isinstance(value, list) or len(value) < 2: + raise GeoJSONDigestError( + "GeoJSON positions must be arrays containing at least two numbers" + ) + budget.add(len(value)) + return [_quantize_coordinate(item, precision) for item in value] + + +def _normalize_coordinates( + value: object, + *, + depth: int, + precision: int, + budget: _CoordinateBudget, +) -> object: + if depth == 1: + return _normalize_position(value, precision=precision, budget=budget) + if not isinstance(value, list): + raise GeoJSONDigestError("GeoJSON coordinate nesting does not match its type") + return [ + _normalize_coordinates( + item, + depth=depth - 1, + precision=precision, + budget=budget, + ) + for item in value + ] + + +def _normalize_geojson_geometry( + geometry: object, + *, + precision: int, + budget: _CoordinateBudget, + depth: int, +) -> dict[str, object] | None: + if depth > MAX_GEOMETRY_DEPTH: + raise GeoJSONDigestError("GeoJSON geometry exceeds the nesting limit") + if geometry is None: + return None + if not isinstance(geometry, Mapping): + raise GeoJSONDigestError("GeoJSON geometry must be an object or null") + + geometry_type = geometry.get("type") + if not isinstance(geometry_type, str): + raise GeoJSONDigestError("GeoJSON geometry type must be a string") + + if geometry_type == "GeometryCollection": + members = geometry.get("geometries") + if not isinstance(members, list): + raise GeoJSONDigestError( + "GeometryCollection must contain a geometries array" + ) + normalized_members: list[dict[str, object]] = [] + for member in members: + normalized_member = _normalize_geojson_geometry( + member, + precision=precision, + budget=budget, + depth=depth + 1, + ) + if normalized_member is None: + raise GeoJSONDigestError( + "GeometryCollection members must be geometry objects" + ) + normalized_members.append(normalized_member) + return {"type": geometry_type, "geometries": normalized_members} + + coordinate_depth = _GEOMETRY_COORDINATE_DEPTH.get(geometry_type) + if coordinate_depth is None: + raise GeoJSONDigestError(f"unsupported GeoJSON geometry type: {geometry_type}") + if "coordinates" not in geometry: + raise GeoJSONDigestError("GeoJSON geometry is missing coordinates") + + return { + "type": geometry_type, + "coordinates": _normalize_coordinates( + geometry["coordinates"], + depth=coordinate_depth, + precision=precision, + budget=budget, + ), + } + + +def normalize_geojson_geometry( + geometry: object, + *, + coordinate_precision: int = DEFAULT_COORDINATE_PRECISION, +) -> dict[str, object] | None: + """Normalize one GeoJSON geometry under the structural v1 profile. + + ``None`` is retained as a deterministic missing-geometry value. Geometry + foreign members, including ``bbox``, are deliberately outside this hash + domain. Coordinate and collection ordering remains significant. + """ + + precision = _validated_precision(coordinate_precision) + return _normalize_geojson_geometry( + geometry, + precision=precision, + budget=_CoordinateBudget(), + depth=0, + ) + + +def _geometry_subject( + normalized_geometry: dict[str, object] | None, + *, + crs: str, + coordinate_precision: int, +) -> dict[str, object]: + return { + "profile": GEOJSON_DIGEST_PROFILE, + "crs": crs, + "coordinate_precision": coordinate_precision, + "geometry": normalized_geometry, + } + + +def compute_geojson_geometry_hash( + geometry: object, + *, + crs: str, + coordinate_precision: int = DEFAULT_COORDINATE_PRECISION, +) -> str: + """Compute a structural geometry digest bound to CRS and precision.""" + + precision = _validated_precision(coordinate_precision) + declared_crs = _validated_crs(crs) + normalized = normalize_geojson_geometry( + geometry, coordinate_precision=precision + ) + return compute_spec_hash( + _geometry_subject( + normalized, + crs=declared_crs, + coordinate_precision=precision, + ) + ) + + +def compute_geojson_feature_digests( + feature: object, + *, + crs: str, + coordinate_precision: int = DEFAULT_COORDINATE_PRECISION, + excluded_property_keys: Iterable[str] = (), + include_feature_id: bool = False, +) -> GeoJSONFeatureDigests: + """Compute separate structural geometry and feature-record digests. + + The record hash binds the geometry hash to the Feature's top-level + ``properties`` after only caller-declared exclusions. Feature ``id`` is + excluded by default and can be admitted explicitly. Foreign Feature members + are outside the v1 hash domain. + """ + + if not isinstance(feature, Mapping) or feature.get("type") != "Feature": + raise GeoJSONDigestError("input must be a GeoJSON Feature object") + if "geometry" not in feature: + raise GeoJSONDigestError("GeoJSON Feature is missing geometry") + if "properties" not in feature: + raise GeoJSONDigestError("GeoJSON Feature is missing properties") + + properties = feature.get("properties") + if properties is not None and not isinstance(properties, Mapping): + raise GeoJSONDigestError("GeoJSON Feature properties must be an object or null") + if not isinstance(include_feature_id, bool): + raise GeoJSONDigestError("include_feature_id must be boolean") + + precision = _validated_precision(coordinate_precision) + declared_crs = _validated_crs(crs) + exclusions = _validated_exclusions(excluded_property_keys) + + normalized_geometry = normalize_geojson_geometry( + feature["geometry"], coordinate_precision=precision + ) + geometry_sha256 = compute_spec_hash( + _geometry_subject( + normalized_geometry, + crs=declared_crs, + coordinate_precision=precision, + ) + ) + + filtered_properties: dict[str, Any] | None + if properties is None: + filtered_properties = None + else: + if any(not isinstance(key, str) for key in properties): + raise GeoJSONDigestError("GeoJSON property keys must be strings") + filtered_properties = { + key: value for key, value in properties.items() if key not in exclusions + } + + record_subject: dict[str, object] = { + "profile": GEOJSON_DIGEST_PROFILE, + "crs": declared_crs, + "coordinate_precision": precision, + "geometry_sha256": geometry_sha256, + "properties": filtered_properties, + } + if include_feature_id: + record_subject["feature_id"] = feature.get("id") + + return GeoJSONFeatureDigests( + profile=GEOJSON_DIGEST_PROFILE, + crs=declared_crs, + coordinate_precision=precision, + geometry_sha256=geometry_sha256, + record_sha256=compute_spec_hash(record_subject), + excluded_property_keys=exclusions, + feature_id_included=include_feature_id, + ) diff --git a/tests/validators/test_validate_spec_hash_geojson.py b/tests/validators/test_validate_spec_hash_geojson.py new file mode 100644 index 0000000000..9d8d044422 --- /dev/null +++ b/tests/validators/test_validate_spec_hash_geojson.py @@ -0,0 +1,113 @@ +"""Deterministic tests for structural GeoJSON feature digests.""" + +from __future__ import annotations + +import copy +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "packages/hashing/src")) + +from hashing import GeoJSONDigestError, compute_geojson_feature_digests # noqa: E402 + + +def point(*, x: float = 1.0, status: str = "open", updated: str = "a") -> dict: + return { + "type": "Feature", + "id": "feature-1", + "geometry": {"type": "Point", "coordinates": [x, 2]}, + "properties": {"status": status, "updated_at": updated}, + } + + +class GeoJSONFeatureDigestTests(unittest.TestCase): + def test_golden_vector_and_structural_invariance(self) -> None: + candidate = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [1, 2]}, + "properties": {"name": "alpha"}, + } + result = compute_geojson_feature_digests(candidate, crs="EPSG:4326") + self.assertEqual( + result.geometry_sha256, + "sha256:794d83ef220b209249067faba710e478316ef8855e89eef3f90b8765609fb2aa", + ) + self.assertEqual( + result.record_sha256, + "sha256:b3fc04347950c0fef2990fe7c70e3c04d1f225c6f4e604a6f18702a34b05cbdc", + ) + noisy = { + "properties": {"name": "alpha"}, + "geometry": {"coordinates": [1.000000049, 2], "type": "Point", "bbox": [0, 0, 2, 3]}, + "type": "Feature", + } + self.assertEqual(result, compute_geojson_feature_digests(noisy, crs="EPSG:4326")) + + def test_domains_profiles_exclusions_and_fail_closed(self) -> None: + left = point(status="open", updated="a") + right = point(status="closed", updated="b") + snapshot = copy.deepcopy(left) + a = compute_geojson_feature_digests(left, crs="EPSG:4326") + b = compute_geojson_feature_digests(right, crs="EPSG:4326") + self.assertEqual(a.geometry_sha256, b.geometry_sha256) + self.assertNotEqual(a.record_sha256, b.record_sha256) + right["properties"]["status"] = "open" + a = compute_geojson_feature_digests( + left, crs="EPSG:4326", excluded_property_keys=["updated_at"] + ) + b = compute_geojson_feature_digests( + right, crs="EPSG:4326", excluded_property_keys=["updated_at"] + ) + self.assertEqual(a.record_sha256, b.record_sha256) + self.assertNotEqual( + a.geometry_sha256, + compute_geojson_feature_digests(left, crs="EPSG:3857").geometry_sha256, + ) + self.assertNotEqual( + a.record_sha256, + compute_geojson_feature_digests( + left, + crs="EPSG:4326", + include_feature_id=True, + excluded_property_keys=["updated_at"], + ).record_sha256, + ) + self.assertEqual(left, snapshot) + with self.assertRaises(GeoJSONDigestError): + compute_geojson_feature_digests({"type": "Feature", "geometry": None}, crs="EPSG:4326") + + def test_cli_is_deterministic_bounded_and_non_authoritative(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "feature.json" + path.write_text(json.dumps( point()) + "\n", encoding="utf-8") + command = [ + sys.executable, + str(ROOT / "tools/spec_hash/spec_hash.py"), + "geojson-feature", + str(path), + "--crs", + "EPSG:4326", + "--exclude-property", + "updated_at", + ] + first = subprocess.run(command, check=False, capture_output=True, text=True) + second = subprocess.run(command, check=False, capture_output=True, text=True) + self.assertEqual((first.returncode, first.stdout), (0, second.stdout)) + payload = json.loads(first.stdout) + self.assertEqual( + (payload["status"], payload["authority"]), + ("GEOJSON_FEATURE_DIGESTS_CREATED", "NONE"), + ) + path.write_text('{"type":"Point","coordinates":[0,0]}\n', encoding="utf-8") + failed = subprocess.run(command, check=False, capture_output=True, text=True) + self.assertEqual(failed.returncode, 2) + self.assertEqual(json.loads(failed.stdout)["status"], "GEOJSON_DIGEST_INPUT_INVALID") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/spec_hash/GEOJSON_FEATURE_DIGESTS.md b/tools/spec_hash/GEOJSON_FEATURE_DIGESTS.md new file mode 100644 index 0000000000..d8020d0449 --- /dev/null +++ b/tools/spec_hash/GEOJSON_FEATURE_DIGESTS.md @@ -0,0 +1,74 @@ +# Structural GeoJSON Feature Digests + +**Status:** implemented on a feature branch; fixture-tested locally; exact-head hosted CI pending. +**Authority:** none. Digest equality is integrity evidence under a declared profile, not evidence, policy, review, release, publication, or public-use authority. + +## Purpose + +`packages/hashing/src/hashing/geojson.py` implements the bounded `kfm-geojson-feature-digest-v1` profile derived from the attached *New Ideas.pdf* pattern for separate geometry and record digests. + +For one GeoJSON `Feature`, it emits: + +- `geometry_sha256`: hashes only the normalized geometry subject plus the declared CRS and coordinate precision. +- `record_sha256`: hashes `geometry_sha256`, filtered `properties`, the same profile inputs, and optionally the top-level Feature `id`. + +Both identifiers use the repository's current executable `sha256:<64 lowercase hex>` grammar and RFC 8785 JCS implementation. + +## Directory Rules basis + +- Reusable deterministic implementation: `packages/hashing/src/hashing/`. +- Operator/CI command surface: `tools/spec_hash/spec_hash.py`. +- Executable tests: `tests/validators/test_validate_spec_hash_geojson.py`. +- Workflow orchestration: `.github/workflows/spec-hash.yml`. +- Authoring receipt: `data/receipts/generated/`. + +No parallel schema, contract, policy, registry, receipt, proof, release, or publication home is created. + +## Declared profile + +The caller must provide a non-empty CRS label and may select a coordinate precision from 0 through 15 decimal places. The default is 7. Finite coordinates are quantized with decimal round-half-even semantics; negative zero becomes zero. JSON object order is removed by RFC 8785 JCS. + +The profile supports `Point`, `MultiPoint`, `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`, `GeometryCollection`, and a Feature-level null geometry. It strips geometry foreign members such as `bbox` from the geometry hash domain. + +Top-level property exclusions are **never inferred**. Volatile fields affect `record_sha256` unless named with repeated `--exclude-property` options. Feature `id` is excluded by default and included only with `--include-feature-id`. + +## Command + +```bash +python tools/spec_hash/spec_hash.py geojson-feature feature.json \ + --crs EPSG:4326 \ + --precision 7 \ + --exclude-property updated_at \ + --include-feature-id +``` + +A successful report contains the profile, CRS, precision, separate digests, explicit exclusions, Feature-ID posture, `authority: NONE`, and the established non-effects list. Invalid JSON, unsupported geometry, missing Feature fields, invalid coordinate nesting, non-finite numbers, empty CRS, or out-of-range precision returns exit code `2` and a bounded failure status. + +## Deliberate non-capabilities + +This implementation does not: + +- reproject coordinates; +- repair or validate topology; +- rotate polygon rings or canonicalize line direction; +- sort geometry collections or feature collections; +- establish topological or real-world spatial equivalence; +- choose object-family volatile fields automatically; +- create SourceDescriptors, EvidenceBundles, PolicyDecisions, receipts, proofs, signatures, release manifests, or published artifacts. + +A domain requiring those transforms must define, version, review, and test its own pre-hash normalization contract before using this generic structural profile. + +## Validation + +Focused tests cover a stable golden vector, key-order and sub-precision invariance, separate geometry and record domains, explicit property exclusions, CRS/precision/Feature-ID binding, input immutability, fail-closed invalid input, deterministic CLI output, and the no-authority boundary. + +```bash +python -m unittest discover \ + --start-directory tests/validators \ + --pattern 'test_validate_spec_hash*.py' \ + --verbose +``` + +## Rollback + +Revert the additive implementation commit. The slice performs no source activation, data migration, release, publication, cache change, or public lifecycle transition.