mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 15:21:14 +00:00
64542d32fc
Transitioned the autodev state to phase 21, reflecting the completion of Step 5 and the drafting of Step 6 epics. Revised the architecture documentation to clarify the roles of the Tile Manager and its components, ensuring accurate representation of the system's operational flow. Updated glossary entries for Flight State and Operator to incorporate recent changes and enhance clarity on component interactions and responsibilities.
572 lines
40 KiB
Markdown
572 lines
40 KiB
Markdown
# Data Model
|
||
|
||
> Date: 2026-05-09 (Plan Phase 2b — initial draft).
|
||
> Inputs: `_docs/02_document/architecture.md` § 4 (Data Model Overview), § 5, § 7; `_docs/02_document/glossary.md`; `_docs/00_problem/{acceptance_criteria,restrictions}.md`; `_docs/01_solution/solution.md`; parent-suite `satellite-provider` schema (mirrored target for byte-identical post-landing upload).
|
||
> Scope: persistent and on-disk artifacts produced or consumed by the onboard companion, the operator-side pre-flight tooling (C12), the operator-side Tile Manager (C11 — pre-flight tile download + post-landing tile upload), and the FDR (C13). In-flight runtime DTOs (per-frame estimates, IMU windows, etc.) are summarised at the end of this document but are NOT persistent and NOT in the migration scope.
|
||
|
||
---
|
||
|
||
## 1. Overview
|
||
|
||
The onboard system is **mostly streaming / in-memory**: the per-frame pipeline (`NavCameraFrame → C1 + C2 → C2.5 → C3/C3.5 → C4 → C5 → C8`) operates entirely on in-process Python DTOs and never persists those frames or their derived per-frame estimates as relational rows. What IS persistent splits into four backing stores; the data model below is the union of all four.
|
||
|
||
| Store | Technology | What lives here | Persistence scope |
|
||
|---|---|---|---|
|
||
| **Relational** | PostgreSQL 16 (mirror of `satellite-provider`'s schema; one DB per companion) | `tiles` (mirrored), `flights`, `sector_classifications`, `manifests`, `engine_cache_entries` | Across flights; survives reboot |
|
||
| **Filesystem (tile cache)** | Local NVM (`./tiles/{zoomLevel}/{x}/{y}.jpg` + `./tiles/{zoomLevel}/{x}/{y}.json` sidecar) | Tile JPEG bodies; per-tile metadata sidecar | Across flights; survives reboot |
|
||
| **Filesystem (artifacts)** | Local NVM | FAISS HNSW `.index`; TRT engines + INT8 calibration caches; camera calibration JSON; manifest JSON | Across flights; rebuildable from the relational tables + `satellite-provider` |
|
||
| **Filesystem (FDR)** | Local NVM, ≤ 64 GB ring per flight | `FdrRecord` segments; AC-8.5 ≤ 0.1 Hz failed-tile thumbnails | Per flight; rolling, oldest-segment-dropped on overrun |
|
||
|
||
**Architecture-driven principles** (carried verbatim from `architecture.md` Principle list):
|
||
|
||
1. **The persistent tile schema MUST stay byte-compatible with `satellite-provider`'s** so post-landing upload (D-PROJ-2) is a copy, not a transformation. Onboard-only fields are added as **additive columns**; the canonical Google-Maps-sourced columns must keep their semantics and types.
|
||
2. **No raw-frame storage** (AC-8.5). The only image persistence path is the orthorectified `Tile` (mid-flight or pre-flight). The single forensic exception is the AC-8.5 ≤ 0.1 Hz failed-tile thumbnail log inside the FDR.
|
||
3. **The camera calibration artifact is the ONLY way camera-specific math enters the system** (Principle #1). Test fixtures (`adti26.json`) and production deployments (`adti20.json`) load different artifacts on the same code path; the schema MUST treat `adti20` vs `adti26` as data, not as a code branch.
|
||
4. **No persistent secrets on the airborne companion image.** The MAVLink-2.0 signing key and the per-flight onboard tile-signing key are generated at takeoff load, logged to the FDR, and discarded with the FDR ring. No long-lived secrets table.
|
||
5. **Append-only / additive-only by default.** Migrations may add tables, columns, indexes, and CHECK constraints, but MUST NOT rename or drop existing columns without an ADR-recorded deprecation window. The `tiles` schema specifically is **frozen on the canonical columns** and only extensible via additive onboard-only columns.
|
||
6. **Honest covariance is a schema concern, not a tuning knob.** Every persisted estimate-derived structure (`TileQualityMetadata`, FDR-emitted-position records) carries the same 2×2 horizontal sub-matrix that drives AC-NEW-4 / AC-NEW-7. Lossy storage of covariance is a defect.
|
||
|
||
**What is NOT in scope for this document**:
|
||
|
||
- Per-frame in-memory DTOs (`VioOutput`, `VprQuery`, `VprResult`, `RerankResult`, `MatchResult`, `PoseEstimate`, `EmittedExternalPosition`) — they are listed as "non-persistent runtime entities" in § 5 below for completeness only; their schema lives in component specs (Step 3).
|
||
- The `satellite-provider`-side voting / trust schema (D-PROJ-2 design task #2). The onboard side writes `voting_status = pending` and reads only `voting_status = trusted` (or operator-overridden `pending`); the actual promotion logic is parent-suite work.
|
||
- The operator-workstation pre-flight tooling UI state (C12) — that is operator-side code with its own persistence story, out of the onboard companion's data scope.
|
||
|
||
---
|
||
|
||
## 2. Persistent Entities
|
||
|
||
### 2.1 `tiles` (PostgreSQL — MIRRORS `satellite-provider`; additive onboard columns)
|
||
|
||
The tile is the single most important persistent entity. The schema deliberately starts from `satellite-provider`'s existing PostgreSQL `tiles` table (Google-Maps-sourced) and extends it with onboard-only columns. The **canonical columns** are read-and-writeable from both sides; the **onboard-only columns** are NULL on `satellite-provider`-sourced rows and populated only on companion-orthorectified rows.
|
||
|
||
**Storage split**: row in PostgreSQL ↔ JPEG body on filesystem at `./tiles/{zoomLevel}/{x}/{y}.jpg`. The JPEG is byte-identical to what `satellite-provider` produces / consumes; the sidecar `./tiles/{zoomLevel}/{x}/{y}.json` carries the same row content as a JSON dump for the Tile Manager's batched HTTP form-data payload (D-PROJ-2 contract).
|
||
|
||
#### 2.1.1 Columns
|
||
|
||
| Column | Type | Origin | Constraints | Description |
|
||
|---|---|---|---|---|
|
||
| `id` | `bigserial` PK | both | NOT NULL | Surrogate primary key (consistent with `satellite-provider`). |
|
||
| `zoom_level` | `int` | both | NOT NULL, CHECK (`zoom_level` BETWEEN 10 AND 22) | Slippy/XYZ zoom; same semantics as `satellite-provider`. |
|
||
| `tile_x` | `int` | both | NOT NULL | Slippy/XYZ X index. |
|
||
| `tile_y` | `int` | both | NOT NULL | Slippy/XYZ Y index. |
|
||
| `latitude` | `double precision` | both | NOT NULL | Tile center latitude (WGS84). |
|
||
| `longitude` | `double precision` | both | NOT NULL | Tile center longitude (WGS84). |
|
||
| `tile_size_meters` | `double precision` | both | NOT NULL, CHECK (`tile_size_meters` > 0) | Ground footprint side length in metres (lat-adjusted m/px × pixels). |
|
||
| `tile_size_pixels` | `int` | both | NOT NULL, CHECK (`tile_size_pixels` > 0) | Pixel side length (square tiles). |
|
||
| `capture_timestamp` | `timestamptz` | both | NOT NULL | When the tile imagery was captured (Google Maps ingest time for `googlemaps`; UAV nav-camera frame timestamp for `onboard_ingest`). |
|
||
| `compression` | `text` | both | NOT NULL, default `'jpeg'` | Currently `jpeg` only; reserved for future formats. |
|
||
| `crs` | `text` | both | NOT NULL, default `'EPSG:3857'` | Spherical Mercator; same as `satellite-provider`. |
|
||
| `source` | `text` | both | NOT NULL, CHECK (`source` IN (`'googlemaps'`, `'onboard_ingest'`)) | New CHECK domain; existing `satellite-provider` rows pre-migration are backfilled `'googlemaps'`. |
|
||
| `flight_id` | `uuid` | onboard-only | NULL for `googlemaps`; NOT NULL for `onboard_ingest`; FK → `flights.id` | Which flight produced the tile (D-PROJ-2 design task #1 contract). |
|
||
| `companion_id` | `text` | onboard-only | NULL for `googlemaps`; NOT NULL for `onboard_ingest` | Deployed unit identifier; threat-model boundary marker. |
|
||
| `quality_metadata` | `jsonb` | onboard-only | NULL for `googlemaps`; NOT NULL for `onboard_ingest`; schema § 2.1.3 | Sufficient inputs for parent-suite voting (D-PROJ-2 #2). |
|
||
| `voting_status` | `text` | onboard-only | NULL for `googlemaps`; NOT NULL for `onboard_ingest`; CHECK (`voting_status` IN (`'pending'`, `'trusted'`, `'rejected'`)); default `'pending'` | Cache-poisoning safety budget gate (AC-NEW-7). The companion never writes `'trusted'`; promotion is parent-suite-side only. |
|
||
| `freshness_status` | `text` | both | NOT NULL, CHECK (`freshness_status` IN (`'fresh'`, `'stale_warn'`, `'stale_reject'`)); default `'fresh'` | Computed at ingest from `capture_timestamp` vs. the area's `SectorClassification` (active-conflict 6 mo / stable rear 12 mo); demoted on age regardless of `voting_status`. |
|
||
| `signature` | `bytea` | onboard-only | NULL for `googlemaps`; NOT NULL for `onboard_ingest` | Per-flight onboard signing key signature over the upload payload (D-PROJ-2 § Design task #1). |
|
||
| `created_at` | `timestamptz` | both | NOT NULL, default `now()` | Row creation time on the companion (or on `satellite-provider` for `googlemaps` rows). |
|
||
| `updated_at` | `timestamptz` | both | NOT NULL, default `now()` | Updated by trigger on `voting_status` / `freshness_status` change. |
|
||
|
||
**Composite uniqueness**: `UNIQUE (zoom_level, tile_x, tile_y, source, flight_id)` — `(zoom_level, tile_x, tile_y)` is unique per `(source, flight_id)` tuple, so the same area can have one `googlemaps` row plus N `onboard_ingest` rows from different flights without collision. `flight_id` is treated as the empty UUID `'00000000-...-...-000000000000'` for `googlemaps` rows so the composite uniqueness still holds.
|
||
|
||
**Indexes**:
|
||
|
||
- B-tree on `(zoom_level, tile_x, tile_y)` — primary spatial lookup path for VPR retrieval and pre-flight cache hydration.
|
||
- B-tree on `(latitude, longitude)` — bounding-box queries for sector classification and spatial-coverage reports.
|
||
- B-tree on `voting_status` partial WHERE `source = 'onboard_ingest'` — operator-tooling queries for "which mid-flight tiles are still pending promotion?".
|
||
- B-tree on `flight_id` — FDR cross-reference; post-landing upload batching.
|
||
- B-tree on `created_at` — pruning / rollover queries.
|
||
|
||
**Filesystem invariant**: for every row with `source = 'onboard_ingest'`, the JPEG body MUST exist at `./tiles/{zoom_level}/{tile_x}/{tile_y}.jpg` and a sidecar `./tiles/{zoom_level}/{tile_x}/{tile_y}.json` MUST exist with the row's full JSON form. The Tile Manager's `TileUploader` (C11) reads both and the row may be deleted from the local PostgreSQL only after `satellite-provider` returns 2xx for that `(flight_id, zoom_level, tile_x, tile_y)` tuple.
|
||
|
||
#### 2.1.2 Lifecycle
|
||
|
||
| Trigger | Action on `tiles` row | Action on filesystem | Action on FDR |
|
||
|---|---|---|---|
|
||
| Pre-flight cache hydration (C11 `TileDownloader` download from `satellite-provider`) | INSERT `source='googlemaps'`, `voting_status=NULL`, `freshness_status` computed | WRITE `./tiles/{z}/{x}/{y}.jpg` (atomic) | n/a (pre-flight) |
|
||
| Mid-flight orthorectify-and-store (in-air) | INSERT `source='onboard_ingest'`, `voting_status='pending'`, `quality_metadata` populated | WRITE `./tiles/{z}/{x}/{y}.jpg` (atomic, dedup by composite key) + sidecar JSON | EVENT `tile_emitted` with row PK |
|
||
| Freshness re-evaluation (background, periodic per AC-8.2 / AC-NEW-6) | UPDATE `freshness_status` | n/a | EVENT `freshness_demoted` if changed |
|
||
| Post-landing upload success (C11 `TileUploader`, op-workstation) | DELETE local row after `satellite-provider` 2xx | DELETE local JPEG + sidecar | EVENT `tile_uploaded` with `(flight_id, z, x, y)` and remote ack ID |
|
||
| Post-landing upload failure (retryable) | UPDATE `updated_at`; row retained for retry | retain JPEG + sidecar | EVENT `tile_upload_failed` with reason |
|
||
|
||
**No DELETE in flight**: rows produced in flight (`source='onboard_ingest'`) MUST NOT be deleted while `flight_state == IN_AIR`. Defense-in-depth on Principle #4 (process-level isolation already prevents the upload code path from being loaded).
|
||
|
||
#### 2.1.3 `quality_metadata` jsonb sub-schema
|
||
|
||
For `source = 'onboard_ingest'` rows, `quality_metadata` carries the full input the parent-suite voting layer (D-PROJ-2 design task #2) needs without re-derivation. Schema is fixed; new keys are additive only.
|
||
|
||
```jsonc
|
||
{
|
||
"estimator_label": "satellite_anchored" | "visual_propagated" | "dead_reckoned",
|
||
"covariance_2x2": [[<float σ_xx>, <float σ_xy>], [<float σ_yx>, <float σ_yy>]],
|
||
"last_anchor_age_ms": <int >= 0>,
|
||
"mre_px": <float >= 0>, // reprojection error at the contributing match
|
||
"imu_bias_norm": <float >= 0>, // VIO health proxy
|
||
"vio_strategy": "okvis2" | "vins_mono" | "klt_ransac",
|
||
"vpr_strategy": "ultra_vpr" | "mega_loc" | "mix_vpr" | "sela_vpr" | "eigen_places" | "net_vlad" | "salad",
|
||
"matcher": "disk_lightglue" | "aliked_lightglue" | "xfeat",
|
||
"adhop_invoked": <bool>,
|
||
"thermal_throttle_active": <bool>, // D-CROSS-LATENCY-1 hybrid was active at emit time
|
||
"build_kind": "deployment" | "research" // ADR-002 disambiguation; voting layer may down-weight research-binary tiles
|
||
}
|
||
```
|
||
|
||
The `tiles_quality_metadata_schema_v1` jsonschema lives at `_docs/02_document/schemas/tiles_quality_metadata_v1.json` and is referenced by both the local INSERT path and the C11 `TileUploader` payload validator. Schema versioning: bumping any field requires a new `*_schema_v2.json` and the row stamps the schema version into the jsonb under `_v` (additive-only).
|
||
|
||
---
|
||
|
||
### 2.2 `flights` (PostgreSQL — onboard-only)
|
||
|
||
A lightweight tracking row per flight, used by the FDR's manifest, the Tile Manager `TileUploader` batching, and the cache-poisoning safety audit (AC-NEW-7).
|
||
|
||
| Column | Type | Constraints | Description |
|
||
|---|---|---|---|
|
||
| `id` | `uuid` PK | NOT NULL | Generated at takeoff load. |
|
||
| `companion_id` | `text` | NOT NULL | Same as `tiles.companion_id`. |
|
||
| `started_at` | `timestamptz` | NOT NULL, default `now()` | First MAVLink HEARTBEAT received post-takeoff. |
|
||
| `ended_at` | `timestamptz` | NULL until landing | Set on `flight_state` transition `IN_AIR → ON_GROUND`. |
|
||
| `signing_key_fingerprint` | `bytea` | NOT NULL | SHA-256 of the per-flight onboard signing pubkey; private key is never persisted. |
|
||
| `mavlink_signing_key_fingerprint` | `bytea` | NULL on iNav-only flights | SHA-256 of the per-flight MAVLink-2.0 signing key (D-C8-9 = (d), AP path only). |
|
||
| `fc_profile` | `text` | NOT NULL, CHECK (`fc_profile` IN (`'ardupilot_plane'`, `'inav'`)) | Which FC profile this flight ran. |
|
||
| `vio_strategy` | `text` | NOT NULL | Active C1 strategy this flight (ADR-001 — locked at startup). |
|
||
| `vpr_strategy` | `text` | NOT NULL | Active C2 strategy. |
|
||
| `build_kind` | `text` | NOT NULL, CHECK (`build_kind` IN (`'deployment'`, `'research'`)) | Disambiguates IT-12 research-binary flights from production flights. |
|
||
| `manifest_id` | `uuid` | NOT NULL, FK → `manifests.id` | Pre-flight manifest used for this flight (ties to model + calibration + corpus + sector). |
|
||
| `fdr_path` | `text` | NOT NULL | Local path to the FDR ring directory for this flight. |
|
||
| `created_at` | `timestamptz` | NOT NULL, default `now()` | |
|
||
|
||
**Lifecycle**: row INSERTed at takeoff load; UPDATE on landing; never UPDATEd in flight after the takeoff-load row has been written. Row is never DELETED on the companion (the FDR retention policy decides physical removal).
|
||
|
||
---
|
||
|
||
### 2.3 `sector_classifications` (PostgreSQL — operator-set, onboard-side cache)
|
||
|
||
Mirrors operator-tooling C12's authoritative sector classification onto the companion so the freshness gate (AC-8.2 / AC-NEW-6) can be evaluated locally without a network call.
|
||
|
||
| Column | Type | Constraints | Description |
|
||
|---|---|---|---|
|
||
| `id` | `bigserial` PK | NOT NULL | |
|
||
| `polygon_geojson` | `jsonb` | NOT NULL | GeoJSON polygon (WGS84) of the classified area. |
|
||
| `classification` | `text` | NOT NULL, CHECK (`classification` IN (`'active_conflict'`, `'stable_rear'`)) | Drives the freshness threshold. |
|
||
| `freshness_threshold_days` | `int` | NOT NULL, CHECK (`freshness_threshold_days` IN (180, 365)) | Materialized: 180 for `active_conflict`, 365 for `stable_rear`. |
|
||
| `set_by` | `text` | NOT NULL | Operator identifier from C12. |
|
||
| `set_at` | `timestamptz` | NOT NULL, default `now()` | |
|
||
| `revoked_at` | `timestamptz` | NULL until revoked | Only set when a polygon's classification is changed; the new row supersedes; the old row is retained for audit. |
|
||
| `manifest_id` | `uuid` | NOT NULL, FK → `manifests.id` | Which pre-flight manifest baked this classification. |
|
||
|
||
**Indexes**: GiST on `polygon_geojson` for spatial containment queries; B-tree on `manifest_id`.
|
||
|
||
**Note**: this table is the onboard cache of operator authority. The companion never *originates* a classification; it only consumes the rows the operator-workstation tooling (C12) staged into the manifest. A row mismatch between the manifest's expected classifications and the table is a content-hash gate failure (D-C10-3) → companion refuses takeoff.
|
||
|
||
---
|
||
|
||
### 2.4 `manifests` (PostgreSQL — pre-flight idempotence gate)
|
||
|
||
Implements D-C10-1 (pre-flight idempotence): the same input bundle (model + calibration + tile corpus + sector classifications) must produce a stable manifest hash. The manifest is the takeoff-load content-hash root; D-C10-3 (atomic write + content-hash gate) verifies it.
|
||
|
||
| Column | Type | Constraints | Description |
|
||
|---|---|---|---|
|
||
| `id` | `uuid` PK | NOT NULL | |
|
||
| `manifest_hash` | `bytea` | NOT NULL, UNIQUE | SHA-256 over the canonicalized manifest contents. |
|
||
| `model_bundle_hash` | `bytea` | NOT NULL | SHA-256 over the (VIO + VPR + matcher + AdHoP) model bundle. |
|
||
| `calibration_artifact_hash` | `bytea` | NOT NULL | SHA-256 over the loaded camera calibration JSON. |
|
||
| `corpus_hash` | `bytea` | NOT NULL | SHA-256 over the sorted list of `(zoom_level, tile_x, tile_y, content_hash)` for `source='googlemaps'` rows in the manifest's spatial scope. |
|
||
| `sector_classifications_hash` | `bytea` | NOT NULL | SHA-256 over the sorted list of `sector_classifications` rows. |
|
||
| `engine_cache_bundle_hash` | `bytea` | NOT NULL | SHA-256 over the linked TRT engines + INT8 calibration caches. |
|
||
| `descriptor_index_hash` | `bytea` | NOT NULL | SHA-256 over the FAISS `.index` file. |
|
||
| `created_at` | `timestamptz` | NOT NULL, default `now()` | |
|
||
| `staged_by` | `text` | NOT NULL | Operator workstation identifier. |
|
||
| `verified_at` | `timestamptz` | NULL until verified | Set by C10 at takeoff load when the content-hash gate passes. |
|
||
|
||
**Indexes**: UNIQUE on `manifest_hash`; B-tree on `created_at`.
|
||
|
||
---
|
||
|
||
### 2.5 `engine_cache_entries` (PostgreSQL — TRT engine catalogue)
|
||
|
||
Implements D-C10-7: TRT engines are keyed by the (SM, JetPack, TRT, precision) tuple so a deployed image with the wrong tuple cannot accidentally consume an engine compiled for a different SM.
|
||
|
||
| Column | Type | Constraints | Description |
|
||
|---|---|---|---|
|
||
| `id` | `bigserial` PK | NOT NULL | |
|
||
| `model_name` | `text` | NOT NULL | Logical name (`disk`, `lightglue`, `ultra_vpr`, `aliked`, `xfeat`, …). |
|
||
| `model_revision` | `text` | NOT NULL | Upstream commit / weight revision pin. |
|
||
| `sm` | `text` | NOT NULL | Jetson SM (e.g., `87` for Orin). |
|
||
| `jetpack_version` | `text` | NOT NULL | E.g., `6.2`. |
|
||
| `tensorrt_version` | `text` | NOT NULL | E.g., `10.3`. |
|
||
| `precision` | `text` | NOT NULL, CHECK (`precision` IN (`'fp32'`, `'fp16'`, `'int8'`, `'int8_fp16_mixed'`)) | |
|
||
| `engine_path` | `text` | NOT NULL | Filesystem path to the `.engine` binary. |
|
||
| `engine_size_bytes` | `bigint` | NOT NULL | |
|
||
| `engine_hash` | `bytea` | NOT NULL | SHA-256 over the engine file. |
|
||
| `int8_calibration_path` | `text` | NULL for non-INT8 | Filesystem path to the INT8 calibration cache. |
|
||
| `int8_calibration_hash` | `bytea` | NULL for non-INT8 | SHA-256 over the calibration cache. |
|
||
| `created_at` | `timestamptz` | NOT NULL, default `now()` | |
|
||
|
||
**Composite uniqueness**: `UNIQUE (model_name, model_revision, sm, jetpack_version, tensorrt_version, precision)`.
|
||
|
||
**Filesystem invariant**: `engine_path` and `int8_calibration_path` (when set) MUST be valid local files; their on-disk SHA-256 MUST match `engine_hash` / `int8_calibration_hash`. C7 verifies the hash on engine load; mismatch ⇒ takeoff refused.
|
||
|
||
---
|
||
|
||
### 2.6 Camera calibration artifact (filesystem JSON, NOT in PostgreSQL)
|
||
|
||
Per Principle #1, camera-specific math enters only via this file. It is a single JSON document loaded once at startup. It is **not** stored in PostgreSQL because it is treated as an *image* of the camera (test fixture vs. production unit) rather than a queryable record — different binaries / different fixtures point at different files via config, and the schema is the same on every code path.
|
||
|
||
**Path convention**:
|
||
|
||
- Production: `/etc/gps-denied/calibration/adti20.json` (per-deployed-unit, post D-PROJ-1 hybrid)
|
||
- Test fixtures: `tests/fixtures/calibration/adti26.json`
|
||
|
||
**Schema** (camera_calibration_v1):
|
||
|
||
```jsonc
|
||
{
|
||
"schema_version": 1,
|
||
"camera_name": "adti20" | "adti26" | <other>,
|
||
"model": "ADTi 20MP 20L V1" | <other>,
|
||
"sensor": {
|
||
"width_px": 5472,
|
||
"height_px": 3648,
|
||
"pixel_size_um": 3.76,
|
||
"sensor_size_mm": [23.6, 15.7]
|
||
},
|
||
"intrinsics": {
|
||
"fx_px": <float>, "fy_px": <float>,
|
||
"cx_px": <float>, "cy_px": <float>
|
||
},
|
||
"distortion": {
|
||
"model": "opencv_radtan" | "opencv_kannala_brandt",
|
||
"coefficients": [<float>, ...]
|
||
},
|
||
"body_to_camera": {
|
||
"rotation_quaternion_xyzw": [<float>, <float>, <float>, <float>],
|
||
"translation_m": [<float>, <float>, <float>]
|
||
},
|
||
"acquisition_method": "factory_sheet" | "checkerboard_refined" | "hybrid",
|
||
"acquisition_timestamp": <ISO 8601>,
|
||
"acquisition_notes": <string>,
|
||
"content_hash": <sha256-hex of the canonicalized JSON without this field>
|
||
}
|
||
```
|
||
|
||
The artifact's `content_hash` is the value referenced by `manifests.calibration_artifact_hash`.
|
||
|
||
---
|
||
|
||
### 2.7 FAISS HNSW descriptor index (filesystem binary, NOT in PostgreSQL)
|
||
|
||
VPR descriptors are stored in a FAISS-native `.index` file. This is the on-disk form of `VprQuery` candidates' lookup index, not a queryable relational entity. Implementation details:
|
||
|
||
- Path: `/var/lib/gps-denied/descriptors/{vpr_strategy}_{model_revision}.index`
|
||
- Atomic write: per D-C10-3 — write to `*.index.tmp`, fsync, rename via `atomicwrites`.
|
||
- Content-hash gate: SHA-256 over the file body, recorded in `manifests.descriptor_index_hash`.
|
||
- Companion of the `.index` is a `*.meta.json` carrying `{tile_id → descriptor_offset}` for the rows under the manifest's spatial scope. The mapping uses `tiles.id` PKs, so a tile pruned from the relational table invalidates the offset and forces a re-build of the index.
|
||
|
||
The descriptor index is **rebuildable** from `(tiles, model_bundle)` + a deterministic VPR feature-extraction pass; therefore it is not part of any backup / DR target — losing the `.index` re-runs C10 descriptor generation on the operator workstation pre-flight.
|
||
|
||
---
|
||
|
||
### 2.8 Flight Data Recorder (`FdrRecord`, filesystem ring, NOT in PostgreSQL)
|
||
|
||
The FDR is a per-flight ≤ 64 GB local ring buffer of structured records. It is **append-only** while the flight is `IN_AIR`; rollover (oldest segment dropped first) is logged but never silent (AC-NEW-3).
|
||
|
||
**Layout** (per flight):
|
||
|
||
```
|
||
/var/lib/gps-denied/fdr/{flight_id}/
|
||
manifest.json # flight metadata snapshot (mirror of flights row + manifests row)
|
||
segments/
|
||
seg_00001.bin # rolling segments (~256 MB each)
|
||
seg_00002.bin
|
||
...
|
||
thumbnails/ # AC-8.5 ≤ 0.1 Hz failed-tile thumbnail log (forensic exception)
|
||
YYYYMMDD-HHMMSS-<short-uuid>.jpg
|
||
rollover.log # one line per dropped segment
|
||
```
|
||
|
||
**Record schema (`FdrRecord`, length-prefixed binary stream)**:
|
||
|
||
```
|
||
record_header (16 bytes):
|
||
magic u32 = 0x47464452 # "GFDR"
|
||
version u16 = 1
|
||
type u16 # see record types below
|
||
monotonic_ms u64 # since flight start
|
||
record_body: variable, schema per `type`
|
||
record_crc32 u32
|
||
```
|
||
|
||
**Record types (`type` field — additive-only)**:
|
||
|
||
| Type ID | Name | Body |
|
||
|---|---|---|
|
||
| `0x0001` | `EmittedExternalPosition` | WGS84 + 6×6 covariance + provenance label + `last_satellite_anchor_age_ms` + per-FC encoding (AP `GPS_INPUT` or iNav `MSP2_SENSOR_GPS`) |
|
||
| `0x0002` | `ImuTrace` | timestamped accel + gyro window |
|
||
| `0x0003` | `ReceivedMavlinkRaw` | raw MAVLink frame (signed/unsigned), captured `tlog`-style |
|
||
| `0x0004` | `ReceivedMsp2Raw` | raw MSP2 frame (iNav profile only) |
|
||
| `0x0005` | `SystemHealth` | CPU%, GPU%, temp, throttle flag, RAM, VRAM, NVM remaining |
|
||
| `0x0006` | `SourceLabelTransition` | `{satellite_anchored, visual_propagated, dead_reckoned}` transition |
|
||
| `0x0007` | `MidFlightTileEmitted` | reference to `tiles.id` + `quality_metadata` snapshot |
|
||
| `0x0008` | `MidFlightTileFailed` | reason + thumbnail filename (AC-8.5 forensic exception) |
|
||
| `0x0009` | `MavlinkSigningKeyRotated` | new key fingerprint + reason (D-C8-9) |
|
||
| `0x000A` | `EkfSourceSetCommand` | D-C8-2 source-set switch event |
|
||
| `0x000B` | `VisualBlackoutEvent` | start / end + reason (AC-3.5, AC-NEW-8) |
|
||
| `0x000C` | `SpoofingPromotionEvent` | promoted / rejected + 10 s-gate state (AC-NEW-2, AC-NEW-8) |
|
||
| `0x000D` | `ContentHashGateFail` | takeoff-load gate fail (D-C10-3) |
|
||
| `0x000E` | `ThermalThrottleHybridSwitch` | K=3 ↔ K=2 switch event (D-CROSS-LATENCY-1, ADR-006) |
|
||
| `0x000F` | `ComponentLifecycleEvent` | per-component start / stop / fail |
|
||
|
||
**Backward compatibility**: new record types are appended; readers MUST skip records they don't recognise (the `record_header` length is enough to advance the cursor). No record type is ever renumbered or removed; deprecation is by ceasing to emit.
|
||
|
||
**Retention**: per-flight ring; on `IN_AIR → ON_GROUND` transition, the ring is sealed and the operator-tooling FDR-retrieval workflow (C12) copies it off the companion. The companion auto-prunes flights older than the configured retention window (default: 30 days) — the prune log itself is its own FDR record on the next flight.
|
||
|
||
---
|
||
|
||
### 2.9 Tile JPEG bodies (filesystem)
|
||
|
||
JPEG bodies live at `./tiles/{zoomLevel}/{x}/{y}.jpg`. A sidecar `./tiles/{zoomLevel}/{x}/{y}.json` carries the full row content for upload-time payload assembly. Both files are atomic-written (via `atomicwrites`); both are removed only after the corresponding `tiles` row's lifecycle says it is safe (see § 2.1.2). Filesystem and PostgreSQL drift is treated as a defect: the operator-tooling C12 has a periodic `consistency_audit` that reports any orphan files / missing files.
|
||
|
||
---
|
||
|
||
## 3. Mermaid ERD
|
||
|
||
```mermaid
|
||
erDiagram
|
||
flights ||--o{ tiles : "1 flight produces N onboard tiles"
|
||
flights ||--|| manifests : "1 flight references 1 manifest"
|
||
manifests ||--o{ sector_classifications : "manifest captures N classifications"
|
||
manifests ||--o{ engine_cache_entries : "manifest pins N engines"
|
||
sector_classifications ||--o{ tiles : "polygon contains N tiles (spatial)"
|
||
|
||
flights {
|
||
uuid id PK
|
||
text companion_id
|
||
timestamptz started_at
|
||
timestamptz ended_at
|
||
bytea signing_key_fingerprint
|
||
bytea mavlink_signing_key_fingerprint
|
||
text fc_profile
|
||
text vio_strategy
|
||
text vpr_strategy
|
||
text build_kind
|
||
uuid manifest_id FK
|
||
text fdr_path
|
||
timestamptz created_at
|
||
}
|
||
|
||
tiles {
|
||
bigserial id PK
|
||
int zoom_level
|
||
int tile_x
|
||
int tile_y
|
||
double latitude
|
||
double longitude
|
||
double tile_size_meters
|
||
int tile_size_pixels
|
||
timestamptz capture_timestamp
|
||
text compression
|
||
text crs
|
||
text source
|
||
uuid flight_id FK
|
||
text companion_id
|
||
jsonb quality_metadata
|
||
text voting_status
|
||
text freshness_status
|
||
bytea signature
|
||
timestamptz created_at
|
||
timestamptz updated_at
|
||
}
|
||
|
||
sector_classifications {
|
||
bigserial id PK
|
||
jsonb polygon_geojson
|
||
text classification
|
||
int freshness_threshold_days
|
||
text set_by
|
||
timestamptz set_at
|
||
timestamptz revoked_at
|
||
uuid manifest_id FK
|
||
}
|
||
|
||
manifests {
|
||
uuid id PK
|
||
bytea manifest_hash
|
||
bytea model_bundle_hash
|
||
bytea calibration_artifact_hash
|
||
bytea corpus_hash
|
||
bytea sector_classifications_hash
|
||
bytea engine_cache_bundle_hash
|
||
bytea descriptor_index_hash
|
||
timestamptz created_at
|
||
text staged_by
|
||
timestamptz verified_at
|
||
}
|
||
|
||
engine_cache_entries {
|
||
bigserial id PK
|
||
text model_name
|
||
text model_revision
|
||
text sm
|
||
text jetpack_version
|
||
text tensorrt_version
|
||
text precision
|
||
text engine_path
|
||
bigint engine_size_bytes
|
||
bytea engine_hash
|
||
text int8_calibration_path
|
||
bytea int8_calibration_hash
|
||
timestamptz created_at
|
||
}
|
||
```
|
||
|
||
> Filesystem-only artifacts (camera calibration JSON, FAISS `.index`, FDR records, tile JPEG bodies) are NOT shown in the ERD. Their referential ties to PostgreSQL are: `manifests.calibration_artifact_hash` ↔ calibration JSON; `manifests.descriptor_index_hash` ↔ FAISS `.index`; `flights.fdr_path` ↔ FDR ring directory; `tiles.id` × `(zoom_level, tile_x, tile_y)` ↔ tile JPEG body + sidecar.
|
||
|
||
---
|
||
|
||
## 4. Migration Strategy
|
||
|
||
### 4.1 Versioning tool
|
||
|
||
**Alembic** (SQLAlchemy-based migrations) is the chosen tool. Justification:
|
||
|
||
- Python is the host language (per `architecture.md` § 2). Alembic ships with the SQLAlchemy ecosystem the project is already using for any DB-touching code.
|
||
- Alembic supports both autogenerate and hand-written migrations; the team will use **hand-written migrations only** (autogenerate is acceptable as a starting point for a migration but must be human-reviewed before commit — autogenerate cannot infer the additive-only / freeze-canonical rules).
|
||
- Alembic's `downgrade()` direction satisfies the reversibility requirement below for additive migrations; non-reversible migrations (rare; only for the controlled-deprecation cases) are explicitly marked.
|
||
- Alembic plays well with the per-companion local PostgreSQL deployment model — no shared upstream migration coordinator is needed.
|
||
|
||
**Coordination with `satellite-provider`**: the parent suite owns the canonical `satellite-provider` schema (whatever migration tool — likely EF Core — they use is irrelevant to the onboard side). The onboard project's `tiles` migration baseline MUST be cross-checked against the latest `satellite-provider` `tiles` schema at every onboard cycle; the onboard-only columns are added on top. A Plan-phase carryforward (Step 4 risk register) captures the ongoing coordination obligation.
|
||
|
||
### 4.2 Reversibility requirement
|
||
|
||
**Default = reversible.** Every migration MUST implement a working `downgrade()` that returns the schema to the prior state without data loss. Migrations that cannot be reversed (e.g., DROP COLUMN) are **forbidden by default** and require an ADR + user sign-off as part of the migration commit.
|
||
|
||
Concretely:
|
||
|
||
- `op.add_column(...)` ⇄ `op.drop_column(...)` — reversible.
|
||
- `op.create_table(...)` ⇄ `op.drop_table(...)` — reversible (data loss is intentional for new tables).
|
||
- `op.create_index(...)` ⇄ `op.drop_index(...)` — reversible.
|
||
- `op.alter_column(...)` for type changes — only reversible if the type widening is truly bidirectional; otherwise this is a forbidden-by-default migration that requires ADR.
|
||
- Backfills / data migrations — must be implemented as either (a) idempotent (safe to re-run on either direction) or (b) split into a separate "data-only" migration whose reverse is a no-op with a documented data-loss note.
|
||
|
||
### 4.3 Naming convention
|
||
|
||
```
|
||
migrations/versions/YYYY_MM_DD_HHMM_<short_slug>.py
|
||
```
|
||
|
||
Where:
|
||
|
||
- `YYYY_MM_DD_HHMM` is the migration's authored timestamp in UTC (matches Alembic's natural ordering and is operator-friendly).
|
||
- `<short_slug>` is kebab-case-with-underscores describing the change in ≤ 6 words: `add_voting_status_to_tiles`, `create_engine_cache_entries`, `freshness_threshold_check`, etc.
|
||
- The Alembic `revision` and `down_revision` IDs are 12-character random hex (Alembic default).
|
||
|
||
`migrations/env.py` uses the `gps_denied.db.metadata` SQLAlchemy `MetaData` object for autogenerate diffs; the connection URL is read from the same config file the runtime uses (no separate migration config).
|
||
|
||
### 4.4 Migration baseline
|
||
|
||
The first migration `0001_initial.py` creates `flights`, `manifests`, `sector_classifications`, `engine_cache_entries`, and `tiles` (with the canonical `satellite-provider` columns). The first onboard-extension migration `0002_add_onboard_columns_to_tiles.py` adds the onboard-only columns (`flight_id`, `companion_id`, `quality_metadata`, `voting_status`, `freshness_status`, `signature`) plus the indexes specific to those columns. Splitting the baseline this way keeps the diff against `satellite-provider`'s migration history mechanically clear.
|
||
|
||
---
|
||
|
||
## 5. Seed Data
|
||
|
||
| Table | dev-tier1 (Tier-1 Docker) | staging-tier1 / staging-tier2 (CI) | production |
|
||
|---|---|---|---|
|
||
| `flights` | One synthetic flight row keyed against `tests/fixtures/flight_derkachi/` | Synthetic flights per replay scenario; matches IT-12 comparative-study fixture set | Real flight data — written by takeoff-load code; NEVER seeded |
|
||
| `tiles` (`source='googlemaps'`) | Loaded from `tests/fixtures/tiles_corpus/` (a curated, ≤100 MB subset of `satellite-provider`'s output for the Derkachi area) | Same as dev-tier1; the e2e-test `mock-suite-sat-service` fixture populates additional rows on demand for upload-side scenarios | Written by C11 `TileDownloader` from the operator workstation; NEVER seeded |
|
||
| `tiles` (`source='onboard_ingest'`) | Synthetic injected for IT / NFT replays | Synthetic injected for NFT-SEC-01 cache-poisoning Monte-Carlo runs | NEVER seeded (only takeoff-load orthorectification code can write) |
|
||
| `sector_classifications` | One `active_conflict` polygon over Derkachi + one `stable_rear` polygon (test fixtures) | Same | Loaded by C10 from operator-staged manifest; NEVER seeded |
|
||
| `manifests` | One pre-built manifest for the Derkachi fixture set | Multiple — one per CI scenario | Loaded by C10 from operator-staged manifest; NEVER seeded |
|
||
| `engine_cache_entries` | Tier-1 has no GPU engines; rows reference Tier-2-built engines pulled from a CI artifact cache | Tier-2 builds engines on first run; rows generated then | Loaded by C10 from operator-staged engine cache; NEVER seeded |
|
||
|
||
**Hard rule**: production NEVER seeds any of these tables. The pre-flight cache build flow (C11 `TileDownloader` writes `tiles` rows; C10 writes `manifests` and `engine_cache_entries`) is the only writer of canonical pre-flight rows; the takeoff-load flow is the only writer of flight rows; the in-flight orthorectifier is the only writer of `source='onboard_ingest'` rows. A test fixture writing into a production database is a defect (per coderule.mdc "mocking data is needed only for tests, never mock data for dev or prod env").
|
||
|
||
**Seed mechanism (Tier-1 / staging)**: `tests/fixtures/seed_db.py` is the canonical Tier-1 seeder; it issues only INSERTs against a dedicated test database. It is invoked by the test harness (`pytest`) and by the `docker compose` developer setup. It does NOT use Alembic — it operates on the schema Alembic has already created.
|
||
|
||
---
|
||
|
||
## 6. Backward Compatibility
|
||
|
||
### 6.1 Default: additive-only
|
||
|
||
Every schema change is **additive by default**:
|
||
|
||
- New tables — additive; existing code paths unaffected.
|
||
- New columns — additive; default value or NULL; existing rows backfilled per migration data step.
|
||
- New indexes — additive; not visible to read-paths semantically, only to performance.
|
||
- New CHECK constraints — additive **only if** all existing rows already satisfy the constraint; otherwise the migration MUST split into "backfill rows" then "add constraint".
|
||
- New jsonb keys (e.g., in `tiles.quality_metadata`) — additive; readers MUST tolerate missing keys (treat as absent / null).
|
||
- New FDR record types — additive; readers skip unknown types.
|
||
|
||
### 6.2 Forbidden by default
|
||
|
||
The following changes are **forbidden by default** and require an ADR + user sign-off:
|
||
|
||
- Renaming a column or table.
|
||
- Dropping a column or table.
|
||
- Tightening a column type (narrowing).
|
||
- Tightening a CHECK constraint that existing rows could violate.
|
||
- Removing a value from a CHECK constraint enum.
|
||
|
||
This rule is the data-model expression of `coderule.mdc`'s "Do not rename any databases or tables or table columns without confirmation."
|
||
|
||
### 6.3 The `tiles` schema specifically: frozen on canonical columns
|
||
|
||
Because the `tiles` schema MUST stay byte-compatible with `satellite-provider`'s on-disk and ingest format, the **canonical columns** (those marked "both" in § 2.1.1) are **frozen**: this project never renames, types-narrows, or drops them. Coordination with the parent suite is the only path to canonical-column changes; the onboard-only columns can evolve under the additive-only rule above without parent-suite coordination.
|
||
|
||
If `satellite-provider` introduces a canonical-column change in a future cycle, the onboard project must:
|
||
|
||
1. Open an ADR documenting the upstream change.
|
||
2. Run a migration that mirrors the upstream change exactly.
|
||
3. Re-validate that the post-landing upload payload (D-PROJ-2 contract) still matches.
|
||
4. Re-run the post-landing upload integration test (`mock-suite-sat-service` swapped for the real `satellite-provider`).
|
||
|
||
### 6.4 Schema versioning for jsonb sub-schemas
|
||
|
||
`tiles.quality_metadata` (and any future jsonb fields with non-trivial structure) carries a `_v` integer key. Readers branch on `_v`:
|
||
|
||
- `_v == 1` (current): the schema in § 2.1.3.
|
||
- `_v` missing: treat as `_v == 1` (back-compat for rows written before `_v` was added).
|
||
- `_v == 2+`: future additive evolution; readers MUST tolerate unknown additional keys.
|
||
|
||
Schema-version bumps are tracked in `_docs/02_document/schemas/` (a new `tiles_quality_metadata_v{N}.json` per bump).
|
||
|
||
### 6.5 FDR file-format compatibility
|
||
|
||
The FDR `record_header` is fixed at version 1. Every FDR reader (operator-tooling, replay tools) MUST:
|
||
|
||
- Validate `magic == 0x47464452` and skip a corrupt segment.
|
||
- Read the `version` field; on `version != 1`, refuse to interpret the body and emit a "unknown FDR version" diagnostic.
|
||
- For known `version == 1`, read the `type` field; on unknown `type`, advance the cursor by `body_length + 4` (CRC) and continue.
|
||
|
||
The FDR file format will be bumped to version 2 only if a structural change to `record_header` is required; that change is gated on the same ADR + user sign-off rule as a forbidden-by-default schema change.
|
||
|
||
---
|
||
|
||
## 7. Non-Persistent Runtime Entities (referenced for completeness only)
|
||
|
||
The following DTOs flow through the per-frame pipeline in memory and are **NOT** persistent (except as derived FDR records — see § 2.8 above). They are documented here because § 4 of `architecture.md` lists them as "Core entities" — but the data-model migration scope does not include them. Their full schema lives in component specs (Plan Step 3).
|
||
|
||
| DTO | Producer | Consumers | Persisted as |
|
||
|---|---|---|---|
|
||
| `NavCameraFrame` | Camera ingest thread | C1, C2 | Never (AC-8.5 forbids raw-frame storage; the only forensic exception is AC-8.5 ≤ 0.1 Hz failed-tile thumbnails inside the FDR) |
|
||
| `ImuSample` / `ImuWindow` | C8 inbound side | C1, C5 | FDR `ImuTrace` records (lossy summary, not full stream) |
|
||
| `VioOutput` | C1 | C5 | FDR `SystemHealth` (covariance summary only); not as a row |
|
||
| `VprQuery` / `VprResult` | C2 | C2.5 | Never (transient retrieval state) |
|
||
| `RerankResult` | C2.5 | C3 / C3.5 | Never |
|
||
| `MatchResult` | C3 / C3.5 | C4 | Never |
|
||
| `PoseEstimate` | C4 → C5 | C8, C13 | FDR `EmittedExternalPosition` records (post-emission); also feeds `tiles.quality_metadata` for in-flight orthorectified tiles |
|
||
| `EmittedExternalPosition` | C8 | FC, FDR | FDR record |
|
||
| `FlightStateSignal` | C8 inbound side | flight-state guard, FDR | FDR `ComponentLifecycleEvent` on transition |
|
||
| `CameraCalibration` (loaded once) | calibration loader | C1, C3, C4 | NOT in PostgreSQL — see § 2.6 |
|
||
|
||
---
|
||
|
||
## 8. Open Items / Plan-Phase Carryforward
|
||
|
||
- **`satellite-provider` schema-drift coordination** (recurring): the canonical-columns freeze depends on an explicit cross-project schema review every cycle. Captured as a Plan-Step-4 risk register entry; carryforward.
|
||
- **D-PROJ-2 #1 ingest-endpoint contract**: the `signature` column's exact algorithm (Ed25519 vs ECDSA) and the per-flight key distribution is a parent-suite design decision; onboard side is contract-flexible and treats `signature` as opaque `bytea`.
|
||
- **D-PROJ-2 #2 voting-layer schema**: parent-suite-side; this onboard data model writes `voting_status='pending'` and reads `'trusted'` only — the actual promotion table lives in `satellite-provider`'s schema and is out of scope here.
|
||
- **GeoJSON polygon precision** (`sector_classifications.polygon_geojson`): GeoJSON is precision-bounded by JSON number representation; if AC-NEW-7 cache-poisoning safety needs sub-metre polygon edges, a future migration can switch to PostGIS `geography(Polygon, 4326)`. Captured as carryforward (currently no AC requirement to do so).
|
||
- **FDR retention policy default**: 30 days post-landing is a reasonable default but is not pinned in any AC; carryforward to the operator-tooling spec (C12) for confirmation.
|