Repository navigation
API: three contracts for the Antenna Cloud mobile framework (signed upload, sync-completion, delete cascade) #1379
Description
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 seesaccess_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 andsyncdouble-ingests. - Namespace per deployment. The
StorageSourceprefix is shared across a project's deployments, so the deployment must be in the key — the client sends it insubdirasdeployment-<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;_NNNNdisambiguates 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 signedx-amz-checksum-sha256header (S3 rejects corrupt PUTs; same-name/different-content is caught). - Return per-URL
expires_at(not one batch TTL) and a per-fileerrors[](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 needspillow-heifon 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-requestaction +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.
-
syncreturns a job/task id the client polls (reuse the/jobs/status shape). Fallback: stable polling ofdata_source_last_checked/data_source_total_files. - Client-pushed trigger: the client calls
syncright after a deployment's PUTs finish (low-latency; needs client awake). - Backend auto-sync: a periodic poll of the
StorageSourcecatches 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 →Jobfor 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
syncrespects); define ordering so no orphan row/object remains. - Cascade to detections / occurrences / collection membership.
- Confirm
populate/processing honortest_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
metadataJSONFieldonEvent,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": {…} } } → 409A5 · Query filters (small — scaling)
Client filters these client-side today, but it won't scale as catalogs grow.
-
/deployments/— add realresearch_site_id+device_idfilters (unknown params are silently ignored today:?research_site_id=999999returns the full list). Confirmproject_id. - Confirm
/deployments/sites/?project_id=and/deployments/devices/?project_id=actually filter (the "Choose Site/Device" pickers rely on it; if they dropproject_idlike/deployments/does, pickers show every project's records). -
/projects/— a writable/"my projects" filter (?role=manager/?mine=true//projects/mine/). Client filters onuser_permissionstoday.
A6 · Per-frame location precision on ingest (small — correctness)
- Read EXIF
GPSHPositioningError(horizontal error in meters) +GPSDOP/GPSHDOPinto whatever precision/accuracy fieldSourceImageexposes. 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)/deviceobjects; writes use*_id— except Site/Device create take a bareproject(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
Projectschema; 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
mihow commentedon Jul 22, 2026
Client tracking issue: mihow/AntennaWatch#110
mihow commentedon Jul 23, 2026
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=999999and?zzz=1both return the full count.research_site_id/device_idfilter support on/deployments/(and confirmproject_id). Bonus: a lightweight "find deployment by (project, site, device)" lookup — or the optional resolve-or-createPOST /deployments/quick-start/from the issue body — would let the client skip list-scans entirely.Notes (no action needed, for reference)
project/research_site(nullable) /deviceobjects; writes use*_id. Expected DRF behavior — handled in the client DTOs.Projectschema (noowner/settings/is_member) — clients treat those as optional.Full validation notes:
docs/antenna-cloud/INTEGRATION_NOTES.md(mihow/AntennaWatch#111).mihow commentedon Jul 23, 2026
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.").
user_permissionson the/projects/list (empty ⇒ read-only public;["update","delete",…]⇒ managed). The app filters on that and auto-selects when there's exactly one./projects/— e.g.?role=manager/?mine=true/?can_write=true, or a/projects/mine/endpoint. Same class of change as the/deployments/filters above.mihow commentedon Jul 23, 2026
One more contract to register:
metadataJSONField + 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 inREADME.md"Backend asks"). Filing it here so all the mocked gaps live in one place.Ask: a
metadataJSONFieldonEvent,Device, andSourceImage, with write-once enforcement on the session-snapshot keys.device_config,sampling_protocol,position— onto theEventat creation. On the sync path the backend creates theEventwith no snapshot, so the client PATCHes it in afterward (attachConfig).409). Descriptive/free keys stay freely patchable — write-once applies to those keys specifically, not the whole field.upload-request,sync-completion, and delete-cascade). Against realserbia-api.devtodayattachConfighas nothing to write to, so the snapshot silently no-ops — same situation asupload-requestreturning 404. The client uses a description-field bridge as an interim, but the real snapshot needs the JSONField.Sketch:
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=…andGET /deployments/devices/?project_id=…./deployments/silently ignores unknown query params (?research_site_id=…returns the full list), please confirm these two list endpoints actually honorproject_idserver-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 takesproject_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).mihow commentedon Jul 23, 2026
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'sStorageSourceprefix 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
subdirit sends —deployment-<id>/<session-datetime>/…— so the deterministic key becomes: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.extin the device's local zone (single source of truth; the_NNNNsequence disambiguates same-second frames). So thefilenamethe client sends inupload-requestis 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: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
syncingest side: GPS coords are stored as rationals (arbitrary decimal precision; the client coarsens for privacy before writing), and EXIF 2.3+ hasGPSHPositioningError(horizontal error in meters) plusGPSDOP/GPSHDOP. So aSourceImagecan carry a coarsened coordinate AND an honest accuracy — worth readingGPSHPositioningErrorinto 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).mihow commentedon Jul 23, 2026
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, EXIFGPSHPositioningError). The comment thread stays as the discussion trail; the body is the source of truth.