spatialdds 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. spatialdds-0.3.0/PKG-INFO +349 -0
  2. spatialdds-0.3.0/README.md +325 -0
  3. spatialdds-0.3.0/pyproject.toml +68 -0
  4. spatialdds-0.3.0/spatialdds/__init__.py +90 -0
  5. spatialdds-0.3.0/spatialdds/artifacts/idl/core.idl +340 -0
  6. spatialdds-0.3.0/spatialdds/artifacts/idl/discovery.idl +238 -0
  7. spatialdds-0.3.0/spatialdds/artifacts/idl/types.idl +156 -0
  8. spatialdds-0.3.0/spatialdds/artifacts/schemas/builtin_Time.json +22 -0
  9. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_anchors_AnchorDelta.json +235 -0
  10. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_anchors_AnchorEntry.json +197 -0
  11. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_anchors_AnchorSet.json +314 -0
  12. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_anchors_AnchorSetRequest.json +20 -0
  13. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_anchors_AnchorSetResponse.json +335 -0
  14. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_argeo_NodeGeo.json +306 -0
  15. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_argeo_QualityRequirements.json +19 -0
  16. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_argeo_VpsRequest.json +298 -0
  17. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_argeo_VpsResponse.json +355 -0
  18. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_common_AssetRef.json +73 -0
  19. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_common_FrameRef.json +56 -0
  20. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_common_KV.json +19 -0
  21. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_common_MetaKV.json +45 -0
  22. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_common_Vec2.json +19 -0
  23. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_Aabb3.json +29 -0
  24. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_BlobChunk.json +38 -0
  25. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_BlobRef.json +23 -0
  26. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_ComponentRef.json +19 -0
  27. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_Edge.json +113 -0
  28. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_EntityBinding.json +283 -0
  29. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_FrameTransform.json +231 -0
  30. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_FramedPose.json +223 -0
  31. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_GeoAnchor.json +254 -0
  32. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_GeoPose.json +157 -0
  33. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_NavSatStatus.json +113 -0
  34. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_Node.json +245 -0
  35. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_PlannedTrajectory.json +214 -0
  36. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_PlannedWaypoint.json +95 -0
  37. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_PoseSE3.json +29 -0
  38. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_SnapshotRequest.json +54 -0
  39. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_SnapshotResponse.json +66 -0
  40. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_TileKey.json +35 -0
  41. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_TileMeta.json +133 -0
  42. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_core_TilePatch.json +122 -0
  43. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_Announce.json +488 -0
  44. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_Capabilities.json +67 -0
  45. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_ContentAnnounce.json +343 -0
  46. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_CoverageElement.json +181 -0
  47. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_CoverageFilter.json +35 -0
  48. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_CoverageHint.json +307 -0
  49. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_CoverageQuery.json +267 -0
  50. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_CoverageResponse.json +264 -0
  51. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_Depart.json +40 -0
  52. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_KV.json +19 -0
  53. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_ProfileSupport.json +33 -0
  54. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_ServiceSummary.json +240 -0
  55. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_TopicMeta.json +41 -0
  56. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_Transform.json +153 -0
  57. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_disco_ValidityWindow.json +42 -0
  58. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_events_CrossingLine.json +235 -0
  59. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_events_SpatialEvent.json +349 -0
  60. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_events_SpatialZone.json +425 -0
  61. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_events_ZoneState.json +132 -0
  62. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_mapping_Edge.json +173 -0
  63. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_mapping_MapAlignment.json +248 -0
  64. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_mapping_MapEvent.json +97 -0
  65. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_mapping_MapMeta.json +471 -0
  66. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_mapping_MapQuality.json +65 -0
  67. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_owm_Entity.json +250 -0
  68. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_owm_ModelCommand.json +124 -0
  69. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_owm_ModelPose.json +74 -0
  70. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_semantics_Detection2D.json +77 -0
  71. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_semantics_Detection2DSet.json +113 -0
  72. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_semantics_Detection3D.json +493 -0
  73. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_semantics_Detection3DSet.json +533 -0
  74. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_semantics_FusedTrack.json +215 -0
  75. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_semantics_FusedTrackSet.json +328 -0
  76. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_semantics_Keypoint3D.json +32 -0
  77. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_semantics_KeypointLink.json +23 -0
  78. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_common_Axis.json +85 -0
  79. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_common_FrameHeader.json +111 -0
  80. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_common_FrameQuality.json +36 -0
  81. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_common_Linspace.json +25 -0
  82. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_common_ROI.json +91 -0
  83. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_common_ROIReply.json +136 -0
  84. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_common_ROIRequest.json +200 -0
  85. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_common_StreamMeta.json +112 -0
  86. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarDetection.json +34 -0
  87. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarDetectionSet.json +141 -0
  88. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarFrame.json +237 -0
  89. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_lidar_LidarMeta.json +235 -0
  90. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_rad_RadDetection.json +147 -0
  91. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_rad_RadDetectionSet.json +308 -0
  92. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_rad_RadSensorMeta.json +202 -0
  93. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_rad_RadTensorFrame.json +222 -0
  94. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_rad_RadTensorMeta.json +357 -0
  95. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioObservation.json +149 -0
  96. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioScan.json +327 -0
  97. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_radio_RadioSensorMeta.json +214 -0
  98. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamArraySet.json +285 -0
  99. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamFrame.json +236 -0
  100. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_rf_beam_RfBeamMeta.json +212 -0
  101. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_vision_CamIntrinsics.json +103 -0
  102. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_vision_Keypoint2D.json +23 -0
  103. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_vision_Track2D.json +46 -0
  104. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionDetections.json +102 -0
  105. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionFrame.json +229 -0
  106. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_sensing_vision_VisionMeta.json +302 -0
  107. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_slam_frontend_CameraInfo.json +143 -0
  108. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_slam_frontend_Feature2D.json +31 -0
  109. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_slam_frontend_FeatureMatch.json +35 -0
  110. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_slam_frontend_KeyframeFeatures.json +108 -0
  111. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_slam_frontend_Landmark.json +84 -0
  112. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_slam_frontend_MatchSet.json +84 -0
  113. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_slam_frontend_TrackObs.json +23 -0
  114. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_slam_frontend_Tracklet.json +80 -0
  115. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_vio_FusedState.json +231 -0
  116. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_vio_ImuInfo.json +113 -0
  117. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_vio_ImuSample.json +179 -0
  118. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_vio_MagnetometerSample.json +115 -0
  119. spatialdds-0.3.0/spatialdds/artifacts/schemas/spatial_vio_SensorFusionSample.json +133 -0
  120. {spatialdds-0.1.0 → spatialdds-0.3.0}/spatialdds/canonical.py +15 -11
  121. spatialdds-0.3.0/spatialdds/canonjson.py +74 -0
  122. spatialdds-0.1.0/spatialdds/__init__.py → spatialdds-0.3.0/spatialdds/client.py +230 -34
  123. spatialdds-0.3.0/spatialdds/demo/assets/duck.gltf +66 -0
  124. spatialdds-0.3.0/spatialdds/demo/assets/fountain-label.txt +9 -0
  125. spatialdds-0.3.0/spatialdds/demo/assets/fountain-marker.gltf +64 -0
  126. spatialdds-0.3.0/spatialdds/demo/fountain-anchors.mcap +0 -0
  127. spatialdds-0.3.0/spatialdds/demo/manifests/content-fountain-label.json +59 -0
  128. spatialdds-0.3.0/spatialdds/demo/manifests/content-fountain-marker.json +59 -0
  129. spatialdds-0.3.0/spatialdds/records.py +801 -0
  130. spatialdds-0.3.0/spatialdds/serve.py +311 -0
  131. spatialdds-0.3.0/spatialdds/server/__init__.py +21 -0
  132. spatialdds-0.3.0/spatialdds/server/gateway.py +695 -0
  133. spatialdds-0.3.0/spatialdds/server/gw_http.py +741 -0
  134. spatialdds-0.3.0/spatialdds/server/idlmodel.py +439 -0
  135. spatialdds-0.3.0/spatialdds/server/pin.py +146 -0
  136. spatialdds-0.3.0/spatialdds/server/publish.py +374 -0
  137. spatialdds-0.3.0/spatialdds/server/webjson.py +302 -0
  138. spatialdds-0.3.0/spatialdds/server/xcdr2.py +442 -0
  139. spatialdds-0.3.0/spatialdds.egg-info/PKG-INFO +349 -0
  140. spatialdds-0.3.0/spatialdds.egg-info/SOURCES.txt +145 -0
  141. spatialdds-0.3.0/spatialdds.egg-info/entry_points.txt +2 -0
  142. spatialdds-0.3.0/spatialdds.egg-info/requires.txt +7 -0
  143. spatialdds-0.1.0/PKG-INFO +0 -193
  144. spatialdds-0.1.0/README.md +0 -173
  145. spatialdds-0.1.0/pyproject.toml +0 -36
  146. spatialdds-0.1.0/spatialdds.egg-info/PKG-INFO +0 -193
  147. spatialdds-0.1.0/spatialdds.egg-info/SOURCES.txt +0 -11
  148. spatialdds-0.1.0/spatialdds.egg-info/requires.txt +0 -2
  149. {spatialdds-0.1.0 → spatialdds-0.3.0}/setup.cfg +0 -0
  150. {spatialdds-0.1.0/spatialdds → spatialdds-0.3.0/spatialdds/artifacts}/serializer_tables.json +0 -0
  151. {spatialdds-0.1.0 → spatialdds-0.3.0}/spatialdds/errors.py +0 -0
  152. {spatialdds-0.1.0 → spatialdds-0.3.0}/spatialdds.egg-info/dependency_links.txt +0 -0
  153. {spatialdds-0.1.0 → spatialdds-0.3.0}/spatialdds.egg-info/top_level.txt +0 -0
@@ -0,0 +1,349 @@
1
+ Metadata-Version: 2.4
2
+ Name: spatialdds
3
+ Version: 0.3.0
4
+ Summary: SpatialDDS over plain HTTPS and a WebSocket: a client for the web binding (Appendix N), and the reference registry behind one command.
5
+ Author: Open AR Cloud
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/OpenArCloud/spatialdds-web
8
+ Project-URL: Specification, https://github.com/OpenArCloud/SpatialDDS-spec
9
+ Keywords: spatialdds,spatial,geopose,anchors,registry,openarcloud
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Scientific/Engineering :: GIS
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: requests>=2.25
19
+ Requires-Dist: websockets>=12
20
+ Provides-Extra: server
21
+ Requires-Dist: aiohttp>=3.8; extra == "server"
22
+ Requires-Dist: mcap>=1.1; extra == "server"
23
+ Requires-Dist: jsonschema>=4.0; extra == "server"
24
+
25
+ # spatialdds
26
+
27
+ SpatialDDS over plain HTTPS and a WebSocket: a client for programs, and the
28
+ reference registry behind one command.
29
+
30
+ ```sh
31
+ pip install spatialdds # the client: two dependencies
32
+ pip install "spatialdds[server]" # and the registry
33
+ pip install "./lib/spatialdds[server]" # from a checkout of this repo
34
+ ```
35
+
36
+ `fetch` and a WebSocket really are the whole client -- the endpoint's own
37
+ README shows that in three lines of `curl`, and nothing here replaces it. This
38
+ package is the step after that, when a program wants native values instead of
39
+ canonical JSON, bytes it can trust, refusals it can act on, and records it did
40
+ not have to assemble member by member.
41
+
42
+ **Implements Appendix N as of SpatialDDS-spec `9c3e1f2`.** Package versions and
43
+ specification versions move independently; `spatialdds.SPEC_COMMIT` is the one
44
+ that says what the wire looks like.
45
+
46
+ ## Your own registry, in one command
47
+
48
+ ```sh
49
+ python -m spatialdds.serve --authority registry.mytown.example --publish
50
+ ```
51
+
52
+ That is a conformant SpatialDDS endpoint: empty, typed, and ready to be
53
+ published into. Its types come from the IDL pinned inside this package at
54
+ `SPEC_COMMIT`, so there is no base recording to find first -- the specification
55
+ is the type source, and `spatialdds.server.pin` is where it lives. State goes
56
+ to a journal (`./spatialdds-registry.mcap` by default, named in the banner)
57
+ which is an ordinary SpatialDDS recording: restart replays it, and any tool
58
+ that reads a recording can read your registry's state.
59
+
60
+ `python -m spatialdds.serve --demo` serves the packaged fountain set instead --
61
+ five surveyed anchors, two content manifests, the assets they name -- with
62
+ publishing on and a token printed. Same boot path, seed data on top.
63
+
64
+ The server half is an extra because being an endpoint is the part you opt
65
+ into: `aiohttp` serves it, `mcap` is the journal, `jsonschema` validates every
66
+ published body before it reaches the wire. Reading somebody else's registry
67
+ needs none of them.
68
+
69
+ ## Publishing without writing out eighteen members
70
+
71
+ Appendix N's schemas make every member of a record required, so an `Announce`
72
+ is eighteen members, a `ContentAnnounce` sixteen, a `GeoAnchor` nine. The
73
+ builders take the arguments you actually have:
74
+
75
+ ```python
76
+ import spatialdds as sd
77
+
78
+ ep = sd.connect("http://127.0.0.1:8787", token="tok", scene="mytown")
79
+ ep.publish_service(service_id="mytown-vps", kind="VPS",
80
+ coverage=sd.circle(30.2849, -97.7393, 500))
81
+ ep.publish_content(content_id="duck", title="Duck", position=(30.2849, -97.7393),
82
+ asset="https://cdn.example/duck.gltf", sha256=digest)
83
+ ep.publish_anchor("anchor/duck-perch", lat=30.2849, lon=-97.7393,
84
+ method="VisualFix", sigma_enu_m=(0.25, 0.25, 0.5),
85
+ content="duck")
86
+ ```
87
+
88
+ `publish_service` and `publish_content` are two PUTs each -- the manifest
89
+ first, then the record that points at it -- and return what `publish` returns,
90
+ with the manifest's own result on `.manifest`.
91
+
92
+ A full service contract is the same one call, with the claims stated:
93
+
94
+ ```python
95
+ area = sd.circle(30.2849, -97.7393, 500)
96
+ ep.publish_service(
97
+ service_id="mytown-vps", kind="VPS", coverage=area,
98
+ caps=sd.capabilities(
99
+ supported=[sd.profile("spatial.core", minor=8)],
100
+ features=["web-binding/json"]),
101
+ manifest=sd.service_manifest(
102
+ "mytown-vps", "VPS", authority=ep.authority, scene="mytown",
103
+ profiles=["Core", "AR+Geo"],
104
+ request={{"features_topic": "feat.keyframe"}},
105
+ response={{"minimal": "geo.fix"}},
106
+ limits={{"max_fps": 10}},
107
+ auth={{"scheme": "bearer", "issuer": "https://registry.mytown.example"}},
108
+ coverage=area, cells=sd.cells(area)))
109
+ ```
110
+
111
+ Every one of those is something a service operator knows and a library cannot
112
+ guess. [docs/MYTOWN.md](../../docs/MYTOWN.md) publishes that and prints the
113
+ whole served document; `gates/builders.py` rebuilds this repository's
114
+ hand-authored `austin-vps` and duck records through these calls and compares
115
+ them member by member, which is what makes "every member is reachable from an
116
+ argument" a check rather than a claim.
117
+
118
+ **Two forms for an asset, because there are two situations.**
119
+ `sd.asset(path_or_bytes, media_type=...)` reads the data and computes the hash
120
+ and the byte count itself: a hash nobody typed is a hash a reader can trust.
121
+ `sd.asset_ref(url, hash=..., media_type=...)` points at something somebody
122
+ else hosts, and the hash comes from whoever has the bytes. Pass no hash and
123
+ the reference carries no integrity claim, which costs a reader one thing:
124
+ `fetch_asset()` raises rather than reporting a check it could not perform
125
+ (finding 31). `publish_content` takes either, or a path, or bytes, or a URL.
126
+
127
+ The builders are also usable on their own: `sd.service_announce`,
128
+ `sd.service_manifest`, `sd.content_announce`, `sd.content_manifest`,
129
+ `sd.geo_anchor`, with `sd.circle`, `sd.bbox`, `sd.geopose`, `sd.cov_pos3`,
130
+ `sd.cov_none`, `sd.profile`, `sd.capabilities`, `sd.asset_ref`,
131
+ `sd.frame`,
132
+ `sd.earth_fixed`, `sd.stamp`, `sd.topic`, `sd.asset`, `sd.cells`,
133
+ `sd.service_uri` and `sd.content_uri`. Each returns a plain dict in canonical
134
+ form, publishable as it stands and dumpable as the authoring JSON this
135
+ repository's tools read. `spatialdds.records` is the module they live in.
136
+
137
+ **Conventions fill plumbing. They never fill claims.** A stamp of now, an
138
+ earth-fixed frame, a checksum computed from the record itself: plumbing, with
139
+ one sensible value each, and filling them saves you writing out what you had
140
+ no choice about. A profile list, a rate limit, a request topic, an auth
141
+ scheme, a title: claims, and no default for any of them is honest. State
142
+ nothing and you publish a minimal record, which is *correct*; state a
143
+ contract and you publish it in one call. There is nothing in between for a
144
+ library to invent.
145
+
146
+ **What they fill in, and what they refuse to.** The conventions above are
147
+ documented where they are applied: a stamp of now unless you pass `when=`, an
148
+ earth-fixed frame unless you pass one, and URIs assembled under a stated
149
+ authority -- never a guessed one. `ep.publish_*` gets the authority from the
150
+ endpoint itself (`ep.authority`, which asks the resolver metadata, then the
151
+ endpoint's own 404 document at `/`, then the address you connected to). What
152
+ they never fill is **identity**: `provider_id` and `org` are yours to state,
153
+ because a library that invented a provider id would be inventing provenance.
154
+
155
+ `GeoAnchor.checksum` is required by 1.8 and specified by nobody
156
+ (spatialdds-web finding 31). The builder fills it with the SHA-256 of the
157
+ record's own canonical JSON with `checksum` emptied -- a convention, chosen
158
+ because a reader can recompute it from what was served. If the specification
159
+ settles the semantics, this changes.
160
+
161
+ The full map of builder argument to IDL member, including every member filled
162
+ by convention, is printed by `python3 gates/builders.py --census` in the
163
+ repository.
164
+
165
+ ## What it is for: the lift
166
+
167
+ An endpoint serves Appendix N.1 canonical JSON, and three rules separate that
168
+ from the values a program wants. All three are easy to get subtly wrong:
169
+
170
+ * `int64` and `uint64` are **unconditional decimal strings**. A client that
171
+ treats `stamp.sec` as a string gets surprising comparisons; one that calls
172
+ `int()` on every string corrupts identifiers.
173
+ * An octet run is **base64**.
174
+ * A union is tagged on `"type"`, and carries `_discriminator` once lifted.
175
+
176
+ These are not this library's rules -- they are the reference gateway's, and
177
+ the repository gates that the two agree by lifting every record in both
178
+ committed recordings with each implementation and comparing. Same rules, same
179
+ results, different source of the type information: the server reads the IDL,
180
+ and a client reading a remote endpoint has none, so this reads the generated
181
+ serializer tables that travel with the package. That gate earned its keep on
182
+ 2026-10-15, when the disagreement it found turned out to be in the server.
183
+
184
+ A lifted union keeps `_discriminator` rather than `"type"`, because that is
185
+ what the lift produces and inventing a fourth shape would be a fourth rule.
186
+ Pass `lifted=False` to `connect()` to get the canonical JSON untouched.
187
+
188
+ ## The three bars
189
+
190
+ Each one runs on its own, pasted into a fresh shell with nothing else run
191
+ first, which is why the serve line all three need is printed here rather than
192
+ assumed. `gates/library.py` executes each bar against its own fresh endpoint
193
+ and compares its output to what is printed below it.
194
+
195
+ ```sh
196
+ python3 tools/run_gateway.py --mcap samples/fountain-anchors.mcap \
197
+ --manifests samples/manifests --assets samples/assets \
198
+ --publish --token not-a-secret --public-url https://demo.spatialdds.org
199
+ ```
200
+
201
+ **Hello spatial, six lines.** Discover, subscribe, and read typed samples until
202
+ the latched set is complete.
203
+
204
+ <!-- bar: read -->
205
+ ```python
206
+ import spatialdds as sd
207
+ ep = sd.connect("http://127.0.0.1:8787")
208
+ svc = next(h for h in ep.search(geohash="9v6krc") if h.serves("geo_anchor"))
209
+ for ev in svc.topic("geo_anchor").subscribe(timeout=10):
210
+ if isinstance(ev, sd.EndOfReplay): print("latched set complete:", ev.replayed); break
211
+ print(" ", ev.key, ev.data["method"], ev.data["geopose"]["stamp"]["sec"])
212
+ ```
213
+
214
+ ```console
215
+ anchor/fountain-north-rim Surveyed 1791559800
216
+ anchor/fountain-south-rim VisualFix 1791559800
217
+ anchor/fountain-east-rim VisualFix 1791559800
218
+ anchor/fountain-west-rim VisualFix 1791559800
219
+ anchor/fountain-plaque GNSS 1791559800
220
+ latched set complete: 5
221
+ ```
222
+
223
+ `geopose.stamp.sec` arrives as an `int` there, not as `"1791559800"`. That is
224
+ the whole lift, in the one place a reader will notice it.
225
+
226
+ **Publish the duck, four lines.** The content manifest, and the announce that
227
+ places it.
228
+
229
+ <!-- bar: publish -->
230
+ ```python
231
+ import json, spatialdds as sd
232
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret")
233
+ d = json.load(open("samples/duck.input.json"))
234
+ print("manifest", ep.publish_manifest(d["manifest"]).created, "| announce", ep.publish(topic=d["announce"]["path"], key=d["announce"]["key"], record=d["announce"]["record"]).created)
235
+ ```
236
+
237
+ ```console
238
+ manifest True | announce True
239
+ ```
240
+
241
+ **Resolve and verify, two lines.** The URI resolved per the endpoint's own
242
+ §7.5.2 metadata, and the asset's bytes checked against the hash the manifest
243
+ declared.
244
+
245
+ <!-- bar: resolve -->
246
+ ```python
247
+ import json, spatialdds as sd
248
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret"); ep.publish_manifest(json.load(open("samples/duck.input.json"))["manifest"]); print(len(ep.resolve("spatialdds://demo.spatialdds.org/south-mall/content/duck").fetch_asset()), "bytes verified")
249
+ ```
250
+
251
+ ```console
252
+ 1432 bytes verified
253
+ ```
254
+
255
+ The duck's manifest is published on the first line because these two lines
256
+ have to stand alone. Against an endpoint that already holds the manifest, that
257
+ call goes away and the bar is one line.
258
+
259
+ ## The surface
260
+
261
+ | | |
262
+ | --- | --- |
263
+ | `sd.connect(base, token=None, verify_integrity=True, timeout=15.0, lifted=True, scene="default", authority=None)` | an `Endpoint`; nothing is fetched until you ask |
264
+ | `ep.search(geohash=None, kinds=None)` | §3.3.0 search → a list of `Hit` |
265
+ | `ep.near(lat, lon, radius_m)` | a helper over `search`, never a second protocol |
266
+ | `ep.resolve(uri)` | §7.5.2 resolution → `Resolved` |
267
+ | `ep.authority` | the DNS name the endpoint says it answers as |
268
+ | `ep.publish(topic=, key=, record=, if_match=None)` | PUT one instance → `Written` |
269
+ | `ep.publish_manifest(record)` | PUT a manifest, keyed by its own `id` |
270
+ | `ep.publish_service(service_id=, kind=, coverage=, manifest=None, ...)` | build and PUT an announce, and its manifest if given |
271
+ | `ep.publish_content(content_id=, position=, asset=, ...)` | build and PUT a content manifest and its announce |
272
+ | `ep.publish_anchor(anchor_id, lat=, lon=, method=, content=None, ...)` | build and PUT an anchor, optionally naming content |
273
+ | `ep.withdraw(topic=, key=)` | DELETE: leaves the latched set and disposes |
274
+ | `hit.service_id`, `.kind`, `.coverage`, `.topics` | the typed service manifest |
275
+ | `hit.serves(type_name)` | does this service offer a topic of that type? for picking one out of a search result |
276
+ | `hit.topic(type_name)` | a `Topic`, URL taken from the manifest, never built |
277
+ | `hit.reaches(lat, lon, radius_m)` | does the declared coverage reach a point |
278
+ | `topic.type`, `.url`, `.name`, `.durability`, `.access`, `.fqn` | what the manifest said |
279
+ | `topic.latched()` | every live instance, lifted |
280
+ | `topic.instance(key)` | one instance, key percent-encoded per N.3 |
281
+ | `topic.subscribe(timeout=None)` | a generator of `Sample` / `Dispose` / `EndOfReplay` / `Liveliness` |
282
+ | `resolved.doc`, `.uri`, `.etag`, `.assets` | the resolved manifest |
283
+ | `resolved.asset_url(i)`, `.fetch_asset(i)` | the URL it would fetch, and the verified bytes |
284
+ | `written.doc`, `.etag`, `.created`, `.manifest` | what a publish returned |
285
+ | `sd.service_manifest(service_id, kind, authority=, scene=, profiles=, request=, response=, limits=, auth=, coverage=, cells=)` | the contract behind an announce; every member a claim, so every member an argument |
286
+ | `sd.capabilities(supported=, preferred=, features=)`, `sd.profile(name, minor=)` | what a service supports, for `service_announce(caps=)` |
287
+ | `sd.asset(path_or_bytes, media_type=)` / `sd.asset_ref(url, hash=, media_type=)` | an asset hashed from its bytes, or a reference to one hosted elsewhere |
288
+ | `sd.records` | the builders, listed above |
289
+ | `sd.lift(fqn, doc)` | the lift on its own, for JSON you already have |
290
+ | `sd.SPEC_COMMIT`, `sd.APPENDIX_N`, `sd.__version__` | what this speaks |
291
+
292
+ `ep.near()` picks a geohash precision whose cell covers the radius, encodes the
293
+ centre and eight points around the query circle so the covering cells are found
294
+ without neighbour arithmetic, searches each distinct cell, and filters the hits
295
+ by their own declared coverage. Everything it sends is an ordinary §3.3.0
296
+ geohash query.
297
+
298
+ `topic.url` and `assets[].uri` are followed by different rules, deliberately. A
299
+ topic URL comes out of a service manifest this endpoint served, so its path is
300
+ this endpoint's and the library follows it by path -- which is what makes a
301
+ registry advertising a DNS name usable from a laptop. An asset URI is an
302
+ ordinary web URL that usually points somewhere else entirely, so it is fetched
303
+ exactly as written; the one exception is an asset on the endpoint's own
304
+ advertised authority, which is rebased onto the endpoint you are talking to.
305
+
306
+ ## Errors
307
+
308
+ One family, built from the endpoint's own N.8 problem documents — `status`,
309
+ `type`, `detail` carried, not re-invented. Everything below derives from
310
+ `SpatialDDSError`, so one `except` catches the lot.
311
+
312
+ | | |
313
+ | --- | --- |
314
+ | `Unreachable` | the endpoint did not answer; names the URL and the serve line |
315
+ | `Unauthorized` | 401; says to pass `token=` and where the token is printed |
316
+ | `SurfaceOff` | 405 from a write verb; says to start with `--publish` |
317
+ | `Problem` | any other refusal, with `.status`, `.type`, `.slug`, `.detail` |
318
+ | `IntegrityError` | fetched bytes do not match the declared hash |
319
+ | `UnverifiableIntegrity` | the declared value is not a hash this library can check |
320
+
321
+ That last one is the unusual one and it is deliberate. SpatialDDS counts "a
322
+ checksum field in the body" as a §7.5.2 integrity signal, but nothing specifies
323
+ what such a field digests or in what form (spatialdds-web finding 31). Where a
324
+ declared value does not parse as a SHA-256 of the asset's bytes, this library
325
+ raises rather than reporting a check it did not perform.
326
+
327
+ ## Integrity by default
328
+
329
+ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
330
+ the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
331
+ site, nowhere else.
332
+
333
+ ## Not in 0.3.0
334
+
335
+ Stated so nobody waits for them: no async API, no caching beyond ETag
336
+ revalidation, no retry policy, no MCAP tooling beyond the journal the server
337
+ writes, and no DDS dependency of any kind. The wire is HTTP and a WebSocket.
338
+
339
+ `sd.asset` changed meaning in 0.3.0, which is why the version moved: it now
340
+ reads bytes and computes a hash, where in 0.2.0 it took a URL and a hash you
341
+ supplied. That second form is `sd.asset_ref`. A published release has to stay
342
+ reproducible from a public commit, so the API could not change under the same
343
+ number.
344
+
345
+ On the registry, four things are deferred rather than missing, and the same
346
+ four are on the 1.9 candidates list: **leases** (nothing expires; `ttl_sec` is
347
+ advice to caches), **per-provider scopes** (one token, one operator),
348
+ **server-set identity** (`provider_id` is whatever the publisher says), and
349
+ **upstream subscriptions** (a registry does not follow another registry).
@@ -0,0 +1,325 @@
1
+ # spatialdds
2
+
3
+ SpatialDDS over plain HTTPS and a WebSocket: a client for programs, and the
4
+ reference registry behind one command.
5
+
6
+ ```sh
7
+ pip install spatialdds # the client: two dependencies
8
+ pip install "spatialdds[server]" # and the registry
9
+ pip install "./lib/spatialdds[server]" # from a checkout of this repo
10
+ ```
11
+
12
+ `fetch` and a WebSocket really are the whole client -- the endpoint's own
13
+ README shows that in three lines of `curl`, and nothing here replaces it. This
14
+ package is the step after that, when a program wants native values instead of
15
+ canonical JSON, bytes it can trust, refusals it can act on, and records it did
16
+ not have to assemble member by member.
17
+
18
+ **Implements Appendix N as of SpatialDDS-spec `9c3e1f2`.** Package versions and
19
+ specification versions move independently; `spatialdds.SPEC_COMMIT` is the one
20
+ that says what the wire looks like.
21
+
22
+ ## Your own registry, in one command
23
+
24
+ ```sh
25
+ python -m spatialdds.serve --authority registry.mytown.example --publish
26
+ ```
27
+
28
+ That is a conformant SpatialDDS endpoint: empty, typed, and ready to be
29
+ published into. Its types come from the IDL pinned inside this package at
30
+ `SPEC_COMMIT`, so there is no base recording to find first -- the specification
31
+ is the type source, and `spatialdds.server.pin` is where it lives. State goes
32
+ to a journal (`./spatialdds-registry.mcap` by default, named in the banner)
33
+ which is an ordinary SpatialDDS recording: restart replays it, and any tool
34
+ that reads a recording can read your registry's state.
35
+
36
+ `python -m spatialdds.serve --demo` serves the packaged fountain set instead --
37
+ five surveyed anchors, two content manifests, the assets they name -- with
38
+ publishing on and a token printed. Same boot path, seed data on top.
39
+
40
+ The server half is an extra because being an endpoint is the part you opt
41
+ into: `aiohttp` serves it, `mcap` is the journal, `jsonschema` validates every
42
+ published body before it reaches the wire. Reading somebody else's registry
43
+ needs none of them.
44
+
45
+ ## Publishing without writing out eighteen members
46
+
47
+ Appendix N's schemas make every member of a record required, so an `Announce`
48
+ is eighteen members, a `ContentAnnounce` sixteen, a `GeoAnchor` nine. The
49
+ builders take the arguments you actually have:
50
+
51
+ ```python
52
+ import spatialdds as sd
53
+
54
+ ep = sd.connect("http://127.0.0.1:8787", token="tok", scene="mytown")
55
+ ep.publish_service(service_id="mytown-vps", kind="VPS",
56
+ coverage=sd.circle(30.2849, -97.7393, 500))
57
+ ep.publish_content(content_id="duck", title="Duck", position=(30.2849, -97.7393),
58
+ asset="https://cdn.example/duck.gltf", sha256=digest)
59
+ ep.publish_anchor("anchor/duck-perch", lat=30.2849, lon=-97.7393,
60
+ method="VisualFix", sigma_enu_m=(0.25, 0.25, 0.5),
61
+ content="duck")
62
+ ```
63
+
64
+ `publish_service` and `publish_content` are two PUTs each -- the manifest
65
+ first, then the record that points at it -- and return what `publish` returns,
66
+ with the manifest's own result on `.manifest`.
67
+
68
+ A full service contract is the same one call, with the claims stated:
69
+
70
+ ```python
71
+ area = sd.circle(30.2849, -97.7393, 500)
72
+ ep.publish_service(
73
+ service_id="mytown-vps", kind="VPS", coverage=area,
74
+ caps=sd.capabilities(
75
+ supported=[sd.profile("spatial.core", minor=8)],
76
+ features=["web-binding/json"]),
77
+ manifest=sd.service_manifest(
78
+ "mytown-vps", "VPS", authority=ep.authority, scene="mytown",
79
+ profiles=["Core", "AR+Geo"],
80
+ request={{"features_topic": "feat.keyframe"}},
81
+ response={{"minimal": "geo.fix"}},
82
+ limits={{"max_fps": 10}},
83
+ auth={{"scheme": "bearer", "issuer": "https://registry.mytown.example"}},
84
+ coverage=area, cells=sd.cells(area)))
85
+ ```
86
+
87
+ Every one of those is something a service operator knows and a library cannot
88
+ guess. [docs/MYTOWN.md](../../docs/MYTOWN.md) publishes that and prints the
89
+ whole served document; `gates/builders.py` rebuilds this repository's
90
+ hand-authored `austin-vps` and duck records through these calls and compares
91
+ them member by member, which is what makes "every member is reachable from an
92
+ argument" a check rather than a claim.
93
+
94
+ **Two forms for an asset, because there are two situations.**
95
+ `sd.asset(path_or_bytes, media_type=...)` reads the data and computes the hash
96
+ and the byte count itself: a hash nobody typed is a hash a reader can trust.
97
+ `sd.asset_ref(url, hash=..., media_type=...)` points at something somebody
98
+ else hosts, and the hash comes from whoever has the bytes. Pass no hash and
99
+ the reference carries no integrity claim, which costs a reader one thing:
100
+ `fetch_asset()` raises rather than reporting a check it could not perform
101
+ (finding 31). `publish_content` takes either, or a path, or bytes, or a URL.
102
+
103
+ The builders are also usable on their own: `sd.service_announce`,
104
+ `sd.service_manifest`, `sd.content_announce`, `sd.content_manifest`,
105
+ `sd.geo_anchor`, with `sd.circle`, `sd.bbox`, `sd.geopose`, `sd.cov_pos3`,
106
+ `sd.cov_none`, `sd.profile`, `sd.capabilities`, `sd.asset_ref`,
107
+ `sd.frame`,
108
+ `sd.earth_fixed`, `sd.stamp`, `sd.topic`, `sd.asset`, `sd.cells`,
109
+ `sd.service_uri` and `sd.content_uri`. Each returns a plain dict in canonical
110
+ form, publishable as it stands and dumpable as the authoring JSON this
111
+ repository's tools read. `spatialdds.records` is the module they live in.
112
+
113
+ **Conventions fill plumbing. They never fill claims.** A stamp of now, an
114
+ earth-fixed frame, a checksum computed from the record itself: plumbing, with
115
+ one sensible value each, and filling them saves you writing out what you had
116
+ no choice about. A profile list, a rate limit, a request topic, an auth
117
+ scheme, a title: claims, and no default for any of them is honest. State
118
+ nothing and you publish a minimal record, which is *correct*; state a
119
+ contract and you publish it in one call. There is nothing in between for a
120
+ library to invent.
121
+
122
+ **What they fill in, and what they refuse to.** The conventions above are
123
+ documented where they are applied: a stamp of now unless you pass `when=`, an
124
+ earth-fixed frame unless you pass one, and URIs assembled under a stated
125
+ authority -- never a guessed one. `ep.publish_*` gets the authority from the
126
+ endpoint itself (`ep.authority`, which asks the resolver metadata, then the
127
+ endpoint's own 404 document at `/`, then the address you connected to). What
128
+ they never fill is **identity**: `provider_id` and `org` are yours to state,
129
+ because a library that invented a provider id would be inventing provenance.
130
+
131
+ `GeoAnchor.checksum` is required by 1.8 and specified by nobody
132
+ (spatialdds-web finding 31). The builder fills it with the SHA-256 of the
133
+ record's own canonical JSON with `checksum` emptied -- a convention, chosen
134
+ because a reader can recompute it from what was served. If the specification
135
+ settles the semantics, this changes.
136
+
137
+ The full map of builder argument to IDL member, including every member filled
138
+ by convention, is printed by `python3 gates/builders.py --census` in the
139
+ repository.
140
+
141
+ ## What it is for: the lift
142
+
143
+ An endpoint serves Appendix N.1 canonical JSON, and three rules separate that
144
+ from the values a program wants. All three are easy to get subtly wrong:
145
+
146
+ * `int64` and `uint64` are **unconditional decimal strings**. A client that
147
+ treats `stamp.sec` as a string gets surprising comparisons; one that calls
148
+ `int()` on every string corrupts identifiers.
149
+ * An octet run is **base64**.
150
+ * A union is tagged on `"type"`, and carries `_discriminator` once lifted.
151
+
152
+ These are not this library's rules -- they are the reference gateway's, and
153
+ the repository gates that the two agree by lifting every record in both
154
+ committed recordings with each implementation and comparing. Same rules, same
155
+ results, different source of the type information: the server reads the IDL,
156
+ and a client reading a remote endpoint has none, so this reads the generated
157
+ serializer tables that travel with the package. That gate earned its keep on
158
+ 2026-10-15, when the disagreement it found turned out to be in the server.
159
+
160
+ A lifted union keeps `_discriminator` rather than `"type"`, because that is
161
+ what the lift produces and inventing a fourth shape would be a fourth rule.
162
+ Pass `lifted=False` to `connect()` to get the canonical JSON untouched.
163
+
164
+ ## The three bars
165
+
166
+ Each one runs on its own, pasted into a fresh shell with nothing else run
167
+ first, which is why the serve line all three need is printed here rather than
168
+ assumed. `gates/library.py` executes each bar against its own fresh endpoint
169
+ and compares its output to what is printed below it.
170
+
171
+ ```sh
172
+ python3 tools/run_gateway.py --mcap samples/fountain-anchors.mcap \
173
+ --manifests samples/manifests --assets samples/assets \
174
+ --publish --token not-a-secret --public-url https://demo.spatialdds.org
175
+ ```
176
+
177
+ **Hello spatial, six lines.** Discover, subscribe, and read typed samples until
178
+ the latched set is complete.
179
+
180
+ <!-- bar: read -->
181
+ ```python
182
+ import spatialdds as sd
183
+ ep = sd.connect("http://127.0.0.1:8787")
184
+ svc = next(h for h in ep.search(geohash="9v6krc") if h.serves("geo_anchor"))
185
+ for ev in svc.topic("geo_anchor").subscribe(timeout=10):
186
+ if isinstance(ev, sd.EndOfReplay): print("latched set complete:", ev.replayed); break
187
+ print(" ", ev.key, ev.data["method"], ev.data["geopose"]["stamp"]["sec"])
188
+ ```
189
+
190
+ ```console
191
+ anchor/fountain-north-rim Surveyed 1791559800
192
+ anchor/fountain-south-rim VisualFix 1791559800
193
+ anchor/fountain-east-rim VisualFix 1791559800
194
+ anchor/fountain-west-rim VisualFix 1791559800
195
+ anchor/fountain-plaque GNSS 1791559800
196
+ latched set complete: 5
197
+ ```
198
+
199
+ `geopose.stamp.sec` arrives as an `int` there, not as `"1791559800"`. That is
200
+ the whole lift, in the one place a reader will notice it.
201
+
202
+ **Publish the duck, four lines.** The content manifest, and the announce that
203
+ places it.
204
+
205
+ <!-- bar: publish -->
206
+ ```python
207
+ import json, spatialdds as sd
208
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret")
209
+ d = json.load(open("samples/duck.input.json"))
210
+ print("manifest", ep.publish_manifest(d["manifest"]).created, "| announce", ep.publish(topic=d["announce"]["path"], key=d["announce"]["key"], record=d["announce"]["record"]).created)
211
+ ```
212
+
213
+ ```console
214
+ manifest True | announce True
215
+ ```
216
+
217
+ **Resolve and verify, two lines.** The URI resolved per the endpoint's own
218
+ §7.5.2 metadata, and the asset's bytes checked against the hash the manifest
219
+ declared.
220
+
221
+ <!-- bar: resolve -->
222
+ ```python
223
+ import json, spatialdds as sd
224
+ ep = sd.connect("http://127.0.0.1:8787", token="not-a-secret"); ep.publish_manifest(json.load(open("samples/duck.input.json"))["manifest"]); print(len(ep.resolve("spatialdds://demo.spatialdds.org/south-mall/content/duck").fetch_asset()), "bytes verified")
225
+ ```
226
+
227
+ ```console
228
+ 1432 bytes verified
229
+ ```
230
+
231
+ The duck's manifest is published on the first line because these two lines
232
+ have to stand alone. Against an endpoint that already holds the manifest, that
233
+ call goes away and the bar is one line.
234
+
235
+ ## The surface
236
+
237
+ | | |
238
+ | --- | --- |
239
+ | `sd.connect(base, token=None, verify_integrity=True, timeout=15.0, lifted=True, scene="default", authority=None)` | an `Endpoint`; nothing is fetched until you ask |
240
+ | `ep.search(geohash=None, kinds=None)` | §3.3.0 search → a list of `Hit` |
241
+ | `ep.near(lat, lon, radius_m)` | a helper over `search`, never a second protocol |
242
+ | `ep.resolve(uri)` | §7.5.2 resolution → `Resolved` |
243
+ | `ep.authority` | the DNS name the endpoint says it answers as |
244
+ | `ep.publish(topic=, key=, record=, if_match=None)` | PUT one instance → `Written` |
245
+ | `ep.publish_manifest(record)` | PUT a manifest, keyed by its own `id` |
246
+ | `ep.publish_service(service_id=, kind=, coverage=, manifest=None, ...)` | build and PUT an announce, and its manifest if given |
247
+ | `ep.publish_content(content_id=, position=, asset=, ...)` | build and PUT a content manifest and its announce |
248
+ | `ep.publish_anchor(anchor_id, lat=, lon=, method=, content=None, ...)` | build and PUT an anchor, optionally naming content |
249
+ | `ep.withdraw(topic=, key=)` | DELETE: leaves the latched set and disposes |
250
+ | `hit.service_id`, `.kind`, `.coverage`, `.topics` | the typed service manifest |
251
+ | `hit.serves(type_name)` | does this service offer a topic of that type? for picking one out of a search result |
252
+ | `hit.topic(type_name)` | a `Topic`, URL taken from the manifest, never built |
253
+ | `hit.reaches(lat, lon, radius_m)` | does the declared coverage reach a point |
254
+ | `topic.type`, `.url`, `.name`, `.durability`, `.access`, `.fqn` | what the manifest said |
255
+ | `topic.latched()` | every live instance, lifted |
256
+ | `topic.instance(key)` | one instance, key percent-encoded per N.3 |
257
+ | `topic.subscribe(timeout=None)` | a generator of `Sample` / `Dispose` / `EndOfReplay` / `Liveliness` |
258
+ | `resolved.doc`, `.uri`, `.etag`, `.assets` | the resolved manifest |
259
+ | `resolved.asset_url(i)`, `.fetch_asset(i)` | the URL it would fetch, and the verified bytes |
260
+ | `written.doc`, `.etag`, `.created`, `.manifest` | what a publish returned |
261
+ | `sd.service_manifest(service_id, kind, authority=, scene=, profiles=, request=, response=, limits=, auth=, coverage=, cells=)` | the contract behind an announce; every member a claim, so every member an argument |
262
+ | `sd.capabilities(supported=, preferred=, features=)`, `sd.profile(name, minor=)` | what a service supports, for `service_announce(caps=)` |
263
+ | `sd.asset(path_or_bytes, media_type=)` / `sd.asset_ref(url, hash=, media_type=)` | an asset hashed from its bytes, or a reference to one hosted elsewhere |
264
+ | `sd.records` | the builders, listed above |
265
+ | `sd.lift(fqn, doc)` | the lift on its own, for JSON you already have |
266
+ | `sd.SPEC_COMMIT`, `sd.APPENDIX_N`, `sd.__version__` | what this speaks |
267
+
268
+ `ep.near()` picks a geohash precision whose cell covers the radius, encodes the
269
+ centre and eight points around the query circle so the covering cells are found
270
+ without neighbour arithmetic, searches each distinct cell, and filters the hits
271
+ by their own declared coverage. Everything it sends is an ordinary §3.3.0
272
+ geohash query.
273
+
274
+ `topic.url` and `assets[].uri` are followed by different rules, deliberately. A
275
+ topic URL comes out of a service manifest this endpoint served, so its path is
276
+ this endpoint's and the library follows it by path -- which is what makes a
277
+ registry advertising a DNS name usable from a laptop. An asset URI is an
278
+ ordinary web URL that usually points somewhere else entirely, so it is fetched
279
+ exactly as written; the one exception is an asset on the endpoint's own
280
+ advertised authority, which is rebased onto the endpoint you are talking to.
281
+
282
+ ## Errors
283
+
284
+ One family, built from the endpoint's own N.8 problem documents — `status`,
285
+ `type`, `detail` carried, not re-invented. Everything below derives from
286
+ `SpatialDDSError`, so one `except` catches the lot.
287
+
288
+ | | |
289
+ | --- | --- |
290
+ | `Unreachable` | the endpoint did not answer; names the URL and the serve line |
291
+ | `Unauthorized` | 401; says to pass `token=` and where the token is printed |
292
+ | `SurfaceOff` | 405 from a write verb; says to start with `--publish` |
293
+ | `Problem` | any other refusal, with `.status`, `.type`, `.slug`, `.detail` |
294
+ | `IntegrityError` | fetched bytes do not match the declared hash |
295
+ | `UnverifiableIntegrity` | the declared value is not a hash this library can check |
296
+
297
+ That last one is the unusual one and it is deliberate. SpatialDDS counts "a
298
+ checksum field in the body" as a §7.5.2 integrity signal, but nothing specifies
299
+ what such a field digests or in what form (spatialdds-web finding 31). Where a
300
+ declared value does not parse as a SHA-256 of the asset's bytes, this library
301
+ raises rather than reporting a check it did not perform.
302
+
303
+ ## Integrity by default
304
+
305
+ Latched reads revalidate with the ETag the endpoint gave; `fetch_asset` checks
306
+ the bytes. Opting out exists and is loud: `verify_integrity=False`, at the call
307
+ site, nowhere else.
308
+
309
+ ## Not in 0.3.0
310
+
311
+ Stated so nobody waits for them: no async API, no caching beyond ETag
312
+ revalidation, no retry policy, no MCAP tooling beyond the journal the server
313
+ writes, and no DDS dependency of any kind. The wire is HTTP and a WebSocket.
314
+
315
+ `sd.asset` changed meaning in 0.3.0, which is why the version moved: it now
316
+ reads bytes and computes a hash, where in 0.2.0 it took a URL and a hash you
317
+ supplied. That second form is `sd.asset_ref`. A published release has to stay
318
+ reproducible from a public commit, so the API could not change under the same
319
+ number.
320
+
321
+ On the registry, four things are deferred rather than missing, and the same
322
+ four are on the 1.9 candidates list: **leases** (nothing expires; `ttl_sec` is
323
+ advice to caches), **per-provider scopes** (one token, one operator),
324
+ **server-set identity** (`provider_id` is whatever the publisher says), and
325
+ **upstream subscriptions** (a registry does not follow another registry).