Skip to content

API: three contracts for the Antenna Cloud mobile framework (signed upload, sync-completion, delete cascade) #1379

Open

Description

@mihow

Context

We're building Antenna Cloud — a reusable Swift framework that lets client apps (a new
iOS/macOS bulk SD-card uploader, and the Antenna Go survey app) talk to this platform's v2 API.
The framework is backend-signed-only: clients must never hold object-storage credentials.

This issue is the single source of truth for the backend contracts that model needs. The
client is built and live-validated against serbia-api.dev (sign-in, project/site/device/
deployment create + resolve all work today); the items below are what's still needed to make the
upload → ingest → process loop real. Earlier discussion is in the comments; everything is
consolidated into the checklist here.

Pipeline (4 independently-triggerable stages):

(1) client upload         (2) backend sync         (3) collection refresh      (4) process
  bytes → storage           storage → DB              DB images → Capture Set      Job → detections
  A1: upload-request         A2: POST …/sync/          A2: refresh collection       POST /jobs/
  + signed PUT

Priority (critical path first)

  • A1 is the keystone — nothing uploads without it; it unblocks the entire demo loop.
  • A1 + A2 together make the push pipeline real end-to-end.
  • A4 (metadata write-once) unblocks the per-session config snapshot.
  • A5 / A6 are scaling & correctness polish. A7 is optional.

A1 · POST /api/v2/deployments/{id}/upload-request/ — batch signed upload targets (net-new · CRITICAL PATH)

Mints signed upload targets so a client PUTs thousands of images directly to the deployment's
StorageSource without ever seeing the S3 keys.

  • Mints signed targets from the deployment's StorageSource (client never sees access_key/secret_key).
  • Deterministic keys — a pure function of (deployment, subdir, filename). Linchpin of resume: a re-mint after expiry must return the same key, or duplicates land and sync double-ingests.
  • Namespace per deployment. The StorageSource prefix is shared across a project's deployments, so the deployment must be in the key — the client sends it in subdir as deployment-<id>/<session-datetime>/…. Without it, same-timestamp captures from two deployments collide.
  • Preserve the client's datetime filenames verbatim. The client names every capture at write time yyyyMMdd'T'HHmmss_NNNN.ext (local zone; _NNNN disambiguates same-second frames), so the filename is already self-describing and intra-deployment collision-proof. Use it as-is in the key (don't re-derive/strip). The client owns datetime naming because only it knows the local shutter time.
  • Accept a per-file sha256; echo it as a signed x-amz-checksum-sha256 header (S3 rejects corrupt PUTs; same-name/different-content is caught).
  • Return per-URL expires_at (not one batch TTL) and a per-file errors[] (one bad filename ≠ whole-batch 4xx).
  • Provider-neutral response { url, method, headers, expires_at } (S3 today; room for GCS/Azure/Drive later).
  • Format: JPEG. Client uploads JPEG; backend validates extension against IMAGE_FILE_EXTENSIONS. HEIC is deferred — it needs pillow-heif on the backend + pipeline re-verification (thumbnails/EXIF/ML) + web display transcode, for ~40–50% smaller files. Not in scope now.

Status (2026-07-24): A1 implemented on branch feat/mobile-upload-api-a1-a5 (signer + upload-request action + check_custom_permission → SYNC_DEPLOYMENT + per-file validation + serializers + tests). Awaiting MinIO test-stack run, PR, and Serbia deploy.

POST /api/v2/deployments/{id}/upload-request/
{ "subdir": "deployment-42/20260723T022054",
  "files": [ { "filename": "20260723T023054_0006.jpg", "size": 3145728,
               "content_type": "image/jpeg", "sha256": "9f86d0818…" } ] }
→ 200
{ "urls": [ { "filename": "20260723T023054_0006.jpg",
              "key": "<prefix>/deployment-42/20260723T022054/20260723T023054_0006.jpg",  // deterministic
              "url": "https://…?X-Amz-Signature=…", "method": "PUT",
              "headers": { "Content-Type": "image/jpeg", "x-amz-checksum-sha256": "n4bQ…" },
              "expires_at": "2026-07-20T12:00:00Z" } ],
  "errors": [] }

A2 · Completion signal + trigger cadence for POST /api/v2/deployments/{id}/sync/ (confirm/extend)

sync ingests objects from the StorageSource into SourceImages/Events. The client needs to
know when ingest is done before processing, and we need to agree when sync runs.

  • sync returns a job/task id the client polls (reuse the /jobs/ status shape). Fallback: stable polling of data_source_last_checked / data_source_total_files.
  • Client-pushed trigger: the client calls sync right after a deployment's PUTs finish (low-latency; needs client awake).
  • Backend auto-sync: a periodic poll of the StorageSource catches objects that landed without a client trigger (offline/live-upload).
  • Collection refresh: confirm the model — query-backed ephemeral Capture Set per run (client's assumption), or a standing collection that auto-refreshes.
  • Default cadence ≤ 1/min for both auto-sync and any auto-collection refresh — near-real-time during a live survey (captures land ~1/min) but bounded. Ideally a per-StorageSource/deployment knob with a 1-minute floor.
  • Support out-of-cadence "process now" — the client exposes "Process all images from this station" = force sync → collection → Job for one deployment on demand. Confirm forcing a sync between cadence ticks is cheap/allowed, and any back-to-back rate limit.

A3 · DELETE /api/v2/captures/{id}/ must cascade to storage (confirm/extend)

Object (not the DB row) is the source of truth, so deleting only the row lets the next sync
re-import the "deleted" capture (a zombie). The client can't delete the object (no credentials).

  • Delete the backing object (or a tombstone sync respects); define ordering so no orphan row/object remains.
  • Cascade to detections / occurrences / collection membership.
  • Confirm populate/processing honor test_image — PATCH {test_image: true} reliably excludes a capture (the lightweight reversible "withdraw" the client offers instead of hard delete).
  • Define behavior for deleting a capture while a Job is running over a set containing it.
  • Consider batch-delete so deleting a session isn't N sequential DELETEs.

A4 · metadata JSONField + write-once on the session snapshot (net-new)

The push path freezes a per-Session snapshot (device_config, sampling_protocol, position)
onto the Event; the sync path creates the Event with no snapshot, so the client PATCHes it in.
This is the 4th gap the client currently mocks — against real Serbia attachConfig has nothing
to write to today.

  • A metadata JSONField on Event, Device, SourceImage.
  • Write-once enforcement on the snapshot keys (device_config/sampling_protocol/position) — first PATCH sets, second → 409. Descriptive/free keys stay freely patchable.
PATCH /api/v2/events/{id}/   { "metadata": { "device_config": {…}, "sampling_protocol": {…} } }  → 200
PATCH /api/v2/events/{id}/   { "metadata": { "device_config": {…} } }                            → 409

A5 · Query filters (small — scaling)

Client filters these client-side today, but it won't scale as catalogs grow.

  • /deployments/ — add real research_site_id + device_id filters (unknown params are silently ignored today: ?research_site_id=999999 returns the full list). Confirm project_id.
  • Confirm /deployments/sites/?project_id= and /deployments/devices/?project_id= actually filter (the "Choose Site/Device" pickers rely on it; if they drop project_id like /deployments/ does, pickers show every project's records).
  • /projects/ — a writable/"my projects" filter (?role=manager / ?mine=true / /projects/mine/). Client filters on user_permissions today.

A6 · Per-frame location precision on ingest (small — correctness)

  • Read EXIF GPSHPositioningError (horizontal error in meters) + GPSDOP/GPSHDOP into whatever precision/accuracy field SourceImage exposes. Coords are stored at arbitrary precision (client coarsens for privacy), so don't assume the coordinate is exact — carry the accuracy alongside the (possibly coarsened) coordinate.

A7 · POST /api/v2/deployments/quick-start/ (net-new · optional / nice-to-have)

  • One transactional call to resolve-or-create Deployment (+ look up/create Site & Device) so the phone app doesn't chain 4 POSTs and list-scan for the (project, site, device) deployment. The client sequences this itself for now.

Reference notes (no action)

  • Deployment reads nest project/research_site(nullable)/device objects; writes use *_id — except Site/Device create take a bare project (validated live). Handled in the client DTOs.
  • Site/Device create accept description (validated live) — the client stamps helpful notes on newly-created records.
  • The project list serializer is lighter than the OpenAPI Project schema; clients treat the extras as optional.

Full design context: docs/antenna-cloud/ — UPLOAD_PIPELINE.md, DATA_MODEL.md, INTEGRATION_NOTES.md (mihow/AntennaWatch#111). Client tracking: mihow/AntennaWatch#110.

Consolidated from the Antenna Cloud design package + live-validation comments below.

Activity

  1. mihow commented on Jul 22, 2026

    @mihow
    CollaboratorAuthor

    Client tracking issue: mihow/AntennaWatch#110

  2. mihow commented on Jul 23, 2026

    @mihow
    CollaboratorAuthor

    Validation follow-ups (from live testing on serbia-api.dev, 2026-07-23)

    Surfaced while wiring the mobile framework against the running dev server — small backend TODOs:

    Deployment query filters are silently ignored

    • GET /deployments/?research_site_id=… and ?device_id=… have no effect — a bogus value still returns the full project list (unknown query params are dropped by DRF). Verified: ?research_site_id=999999 and ?zzz=1 both return the full count.
    • The mobile framework resolves/dedups a Station by (Site, Device). Without server-side filters it must fetch all deployments in a project and filter client-side — OK for the small demo project, but won't scale to large projects (e.g. 63 deployments in the test data).
    • Ask: add real research_site_id / device_id filter support on /deployments/ (and confirm project_id). Bonus: a lightweight "find deployment by (project, site, device)" lookup — or the optional resolve-or-create POST /deployments/quick-start/ from the issue body — would let the client skip list-scans entirely.

    Notes (no action needed, for reference)

    • Deployment reads return nested project / research_site (nullable) / device objects; writes use *_id. Expected DRF behavior — handled in the client DTOs.
    • The project list serializer is lighter than the OpenAPI Project schema (no owner/settings/is_member) — clients treat those as optional.

    Full validation notes: docs/antenna-cloud/INTEGRATION_NOTES.md (mihow/AntennaWatch#111).

  3. mihow commented on Jul 23, 2026

    @mihow
    CollaboratorAuthor

    Also: "my projects" / writable-projects filter on /projects/

    The mobile sign-in flow needs to show the user the project(s) they can actually deploy to — not the full list of public projects (35 visible for the test account, only 1 manageable: #86 "Ogden St.").

    • Client-side works today via user_permissions on the /projects/ list (empty ⇒ read-only public; ["update","delete",…] ⇒ managed). The app filters on that and auto-selects when there's exactly one.
    • But it won't scale — fetching all public projects to find your one is wasteful as the public catalog grows.
    • Ask: a server-side filter on /projects/ — e.g. ?role=manager / ?mine=true / ?can_write=true, or a /projects/mine/ endpoint. Same class of change as the /deployments/ filters above.
  4. mihow commented on Jul 23, 2026

    @mihow
    CollaboratorAuthor

    One more contract to register: metadata JSONField + write-once (was "backend ask #4")

    While wiring the push flow I noticed our own design docs enumerate a fourth contract that never made it onto this issue (it's called out in DATA_MODEL.md — "This is backend ask #4 (register it)" — and in README.md "Backend asks"). Filing it here so all the mocked gaps live in one place.

    Ask: a metadata JSONField on Event, Device, and SourceImage, with write-once enforcement on the session-snapshot keys.

    • The push path (Antenna Go) freezes a per-Session snapshot — device_config, sampling_protocol, position — onto the Event at creation. On the sync path the backend creates the Event with no snapshot, so the client PATCHes it in afterward (attachConfig).
    • Those snapshot keys must be write-once: the first PATCH sets them, a second attempt is rejected (the client expects 409). Descriptive/free keys stay freely patchable — write-once applies to those keys specifically, not the whole field.
    • Why it's critical path, not nice-to-have: this is the 4th of four gaps the client currently mocks (alongside upload-request, sync-completion, and delete-cascade). Against real serbia-api.dev today attachConfig has nothing to write to, so the snapshot silently no-ops — same situation as upload-request returning 404. The client uses a description-field bridge as an interim, but the real snapshot needs the JSONField.

    Sketch:

    PATCH /api/v2/events/{id}/   { "metadata": { "device_config": { … }, "sampling_protocol": { … } } }
    → 200   // first write sets the snapshot keys
    PATCH /api/v2/events/{id}/   { "metadata": { "device_config": { … } } }
    → 409   // snapshot keys are write-once

    Small validation follow-up (from the Site/Device picker work, 2026-07-23)

    The mobile sign-in → deployment flow now lets the user pick an existing Site (or Device) from their project via GET /deployments/sites/?project_id=… and GET /deployments/devices/?project_id=….

    • Since we confirmed /deployments/ silently ignores unknown query params (?research_site_id=… returns the full list), please confirm these two list endpoints actually honor project_id server-side. If they drop it the same way, the picker would surface Sites/Devices from every project the user can see, not just the selected one. Same class of fix as the /deployments/ filter item above.

    Reference note (no action needed): create shapes are asymmetric — Site and Device create take a bare project (int); Deployment create takes project_id / research_site_id / device_id. The client DTOs handle both; flagging only in case you want to normalize for consistency.

    Full validation notes: docs/antenna-cloud/INTEGRATION_NOTES.md (mihow/AntennaWatch#111).

  5. mihow commented on Jul 23, 2026

    @mihow
    CollaboratorAuthor

    A1 refinement — object key structure (per-deployment namespacing + datetime filenames)

    Two concrete requirements for the deterministic-key contract in A1 (upload-request), surfaced wiring the mobile upload path. Both are about collision safety, since a project's StorageSource prefix is shared across all its deployments.

    1. Keys must be namespaced per deployment. The deployment is not implicit in the key (the prefix is per-project, not per-deployment). The client supplies it in the subdir it sends — deployment-<id>/<session-datetime>/… — so the deterministic key becomes:

    <StorageSource.prefix>/deployment-<id>/<session-datetime-folder>/<filename>
    

    Without this, same-timestamp captures from two deployments in the same project collide. (Alternatively/additionally the backend could fold the deployment id into the prefix — either works, as long as the deployment is in the key.)

    2. Object filenames already carry the capture's LOCAL datetime — please preserve them verbatim. The client names every capture at write time as yyyyMMdd'T'HHmmss_NNNN.ext in the device's local zone (single source of truth; the _NNNN sequence disambiguates same-second frames). So the filename the client sends in upload-request is already self-describing and intra-deployment collision-proof — the deterministic key just needs to use it as-is (don't re-derive or strip it). Net key example:

    …/deployment-42/20260723T022054/20260723T023054_0006.HEIC
    

    This also means the client, not the backend, owns the datetime naming (the backend can't know the local shutter time — it only sees {filename, size, sha256}), which keeps the key a pure function of (deployment, subdir, filename) as A1 already requires.

    Related — per-frame location precision (EXIF), for the sync ingest side: GPS coords are stored as rationals (arbitrary decimal precision; the client coarsens for privacy before writing), and EXIF 2.3+ has GPSHPositioningError (horizontal error in meters) plus GPSDOP/GPSHDOP. So a SourceImage can carry a coarsened coordinate AND an honest accuracy — worth reading GPSHPositioningError into whatever precision/accuracy field the model exposes rather than assuming the coordinate is exact.

    Design detail: docs/antenna-cloud/UPLOAD_PIPELINE.md §4 (mihow/AntennaWatch#111).

  6. mihow commented on Jul 23, 2026

    @mihow
    CollaboratorAuthor

    Consolidated all TODOs into the issue body ☝️ — it's now a single organized checklist (A1–A7) covering the three original contracts, the previously-missing A4 (metadata write-once), and every refinement from the comments below (per-deployment key namespacing + datetime filenames, sync trigger/collection cadence ≤1/min, the sites/devices ?project_id= filter confirmation, EXIF GPSHPositioningError). The comment thread stays as the discussion trail; the body is the source of truth.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions